From 8e0f798721fb0255cee42be05339a39f27a691af Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 17:17:33 +0200 Subject: [PATCH 1/5] ci: build signed Play bundles from main --- .github/workflows/play-bundle.yml | 61 +++++++++++++++++++++++++++++++ docs/PLAY-RELEASES.md | 13 +++++++ 2 files changed, 74 insertions(+) create mode 100644 .github/workflows/play-bundle.yml create mode 100644 docs/PLAY-RELEASES.md diff --git a/.github/workflows/play-bundle.yml b/.github/workflows/play-bundle.yml new file mode 100644 index 0000000..0bff850 --- /dev/null +++ b/.github/workflows/play-bundle.yml @@ -0,0 +1,61 @@ +name: Play release bundle +on: + push: + branches: [main] + paths: ['app/**', 'i18n/**', 'gradle/**', '*.gradle.kts', 'gradle.properties', '.github/workflows/play-bundle.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: play-bundle + cancel-in-progress: false +jobs: + bundle: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: play-internal + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 + with: + distribution: temurin + java-version: '21' + - name: Release tests and lint + run: ./gradlew testReleaseUnitTest lintRelease --no-daemon --no-configuration-cache + - name: Sign release bundle + env: + KEY_BASE64: ${{ secrets.PINAKES_KEYSTORE_BASE64 }} + PINAKES_KEYSTORE_PASSWORD: ${{ secrets.PINAKES_KEYSTORE_PASSWORD }} + PINAKES_KEY_ALIAS: ${{ secrets.PINAKES_KEY_ALIAS }} + PINAKES_KEY_PASSWORD: ${{ secrets.PINAKES_KEY_PASSWORD }} + run: | + set -euo pipefail + umask 077 + test -n "$KEY_BASE64" + test -n "$PINAKES_KEYSTORE_PASSWORD" + test -n "$PINAKES_KEY_ALIAS" + test -n "$PINAKES_KEY_PASSWORD" + export PINAKES_KEYSTORE="$RUNNER_TEMP/pinakes-upload.jks" + trap 'rm -f "$PINAKES_KEYSTORE"' EXIT + printf '%s' "$KEY_BASE64" | base64 --decode > "$PINAKES_KEYSTORE" + ./gradlew bundleRelease --no-daemon --no-configuration-cache + jarsigner -verify app/build/outputs/bundle/release/app-release.aab + sha256sum app/build/outputs/bundle/release/app-release.aab > app/build/outputs/bundle/release/SHA256SUMS.txt + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: pinakes-play-${{ github.sha }} + path: | + app/build/outputs/bundle/release/*.aab + app/build/outputs/bundle/release/SHA256SUMS.txt + app/build/outputs/mapping/release/mapping.txt + if-no-files-found: error + retention-days: 30 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: play-reports-${{ github.sha }} + path: app/build/reports/ + retention-days: 14 diff --git a/docs/PLAY-RELEASES.md b/docs/PLAY-RELEASES.md new file mode 100644 index 0000000..6d12ee0 --- /dev/null +++ b/docs/PLAY-RELEASES.md @@ -0,0 +1,13 @@ +# Play releases + +GitHub is the source of truth. Pull requests run Android CI. After merging Android changes into main, `Play release bundle` runs release unit tests and lint, then signs an AAB and retains the bundle, checksum, R8 mapping and reports as GitHub artifacts. Manual dispatch is supported on main only. No PR code receives signing secrets. + +Environment `play-internal` must allow main only. Required environment secrets: `PINAKES_KEYSTORE_BASE64`, `PINAKES_KEYSTORE_PASSWORD`, `PINAKES_KEY_ALIAS`, `PINAKES_KEY_PASSWORD`. Use the existing release key; never generate a replacement silently. Keys are materialized in runner temporary storage and removed after signing; configuration caching is disabled. + +This workflow builds bundles; it does NOT upload to Play or publish to production. Publisher API credentials and app-scoped Play permissions are still required for that separate stage. Increment versionCode before each new Play upload, including corrective rebuilds after a code has been accepted. Debug GitHub releases keep their existing path and are not Play artifacts. + +Review library: https://biblioteca.fabiodalez.it. Mobile API was enabled on 2026-09-09. A dedicated standard reader was created; credentials are private outside Git, not in this document or release artifacts. + +Remaining release gates: accurate privacy policy including Sentry, account-deletion path and public request URL, reviewer access test, minified-device smoke test, native 16 KB compatibility and Play questionnaires. The personal developer account requires the closed-test period before production access. Do not interpret a green bundle build as policy approval. + +The older PLAY_STORE_COMPLIANCE.md audit is historical: its no-third-party-SDK and disabled-R8 statements no longer describe the app. Sentry is present, R8 is enabled, and logout does not delete the user's server account. From 8a7fa3c8b240572e099a4717195a776f0480c37a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 17:18:42 +0200 Subject: [PATCH 2/5] docs: correct privacy claims for diagnostics and account deletion --- PRIVACY.md | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 99a00b6..2794c9a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,10 +1,13 @@ # Privacy Policy — Pinakes Android -_Last updated: 2026-06-19_ +_Technical privacy description updated: 2026-09-09. This document still requires +the publisher's controller/contact and retention details before use as the final +Google Play privacy policy._ Pinakes Android is an open-source client app that connects to a **Pinakes -library instance chosen by you**. The app itself has no backend of its own: all -your data lives on the library server you point it at, operated by that library. +library instance chosen by you**. Library services use that instance's server. +The app also integrates Sentry for crash diagnostics and loads media from URLs +supplied by the library; not all requests are limited to the library's domain. ## What the app stores on your device @@ -22,15 +25,22 @@ when you log out or disconnect from the instance. ## What the app sends, and to whom -The app communicates **only** with the Pinakes instance URL you configure. It +For library services, the app communicates with the Pinakes instance you configure. It sends your email and password (over HTTPS) at login, and your bearer token on subsequent requests, to authenticate and to perform the actions you initiate (search, loans/reservations, wishlist, profile edits, contact messages). It also fetches book cover images from URLs returned by that instance. -The app does **not** include third-party analytics, advertising, or tracking -SDKs, and does not transmit your data to the app's authors or any party other -than your chosen library instance. +The app includes **Sentry crash reporting**, configured during app startup. +Default PII attachment is disabled and performance trace sampling is zero. +These settings do not mean no data is transmitted: error events, technical +device/app information and diagnostic context can reach Sentry. Retention and +the precise diagnostic fields must be verified against the publisher's Sentry +configuration before completing the Play Data Safety declaration. + +Catalog and HTTP caches are also stored locally. Signing out or switching +instances clears the relevant caches. Signing out revokes the session; it does +**not** delete the account, loans or other records held by the library. ## Network security @@ -40,16 +50,22 @@ required to be HTTPS. ## Push notifications (optional) -When enabled by your library and by you, push delivery uses **UnifiedPush** -through a distributor of your choosing. Registration data (a push endpoint and -WebPush keys) is sent to your library instance only. +The app has a notification feed and UnifiedPush-related API support. A complete +distributor integration has not been verified for this release; do not describe +background push delivery as universally available. Any enabled distributor and +its data handling must be included in the deployment's privacy review. ## Data controller -Your data is controlled by the **library operating the Pinakes instance** you -connect to. For requests about access, correction, or deletion of your account -data, contact that library directly. For questions about the app itself, open an -issue on the project repository. +The library operates the account and circulation services. Contact that library +for access, correction or deletion requests; do not post credentials, loan +history or other private data in public GitHub issues. The app publisher must +also identify its role and private contact channel for app diagnostics. + +The app supports account registration. A compliant in-app account-deletion +request path and an external request URL remain release gates; logout must not +be presented as account deletion. A library may need to retain specific records +under its obligations, which its policy must explain accurately. > Libraries publishing their own build: replace this section with your > organisation's contact details and host this policy at a public URL, then link From 553d0b942f7047d9343765f29ebc4fa9a6935eb0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 17:22:16 +0200 Subject: [PATCH 3/5] docs: identify Pinakes publisher and privacy contact --- PRIVACY.md | 27 +++++++++++++++++---------- docs/PLAY-RELEASES.md | 2 ++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 2794c9a..5725698 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,8 +1,8 @@ # Privacy Policy — Pinakes Android -_Technical privacy description updated: 2026-09-09. This document still requires -the publisher's controller/contact and retention details before use as the final -Google Play privacy policy._ +_Technical privacy description updated: 2026-09-09. The publisher has confirmed +the controller and contact below. Retention details and the remaining release +gates still need verification before this becomes the final Play privacy policy._ Pinakes Android is an open-source client app that connects to a **Pinakes library instance chosen by you**. Library services use that instance's server. @@ -57,16 +57,23 @@ its data handling must be included in the deployment's privacy review. ## Data controller -The library operates the account and circulation services. Contact that library -for access, correction or deletion requests; do not post credentials, loan -history or other private data in public GitHub issues. The app publisher must -also identify its role and private contact channel for app diagnostics. +The publisher and controller for this Pinakes Android distribution and its app +diagnostics is **D'Alessandro Fabio Gaetano**, reachable at +**[info@fabiodalez.it](mailto:info@fabiodalez.it)**. This is also the contact for +the reference library at **https://biblioteca.fabiodalez.it**. + +When you connect to another independently operated library, that library +operates its own account and circulation services. Consult its privacy policy +and contact it for access, correction or deletion requests relating to those +records. Do not post credentials, loan history or other private data in public +GitHub issues. Never send your password in a privacy request. The app supports account registration. A compliant in-app account-deletion request path and an external request URL remain release gates; logout must not be presented as account deletion. A library may need to retain specific records under its obligations, which its policy must explain accurately. -> Libraries publishing their own build: replace this section with your -> organisation's contact details and host this policy at a public URL, then link -> that URL in the Google Play Console (App content → Privacy policy). +For the reference library, account and privacy requests may be sent to +info@fabiodalez.it, identifying the library URL and account email. Identity +verification may be necessary before processing a request. This contact does +not yet replace the in-app and public-web deletion paths listed above. diff --git a/docs/PLAY-RELEASES.md b/docs/PLAY-RELEASES.md index 6d12ee0..9cdf6a0 100644 --- a/docs/PLAY-RELEASES.md +++ b/docs/PLAY-RELEASES.md @@ -8,6 +8,8 @@ This workflow builds bundles; it does NOT upload to Play or publish to productio Review library: https://biblioteca.fabiodalez.it. Mobile API was enabled on 2026-09-09. A dedicated standard reader was created; credentials are private outside Git, not in this document or release artifacts. +Publisher/controller confirmed by the owner on 2026-09-09: **D'Alessandro Fabio Gaetano**. Public support/privacy email: **info@fabiodalez.it**. Use these for this distribution, not as the controller of independently operated third-party library instances. + Remaining release gates: accurate privacy policy including Sentry, account-deletion path and public request URL, reviewer access test, minified-device smoke test, native 16 KB compatibility and Play questionnaires. The personal developer account requires the closed-test period before production access. Do not interpret a green bundle build as policy approval. The older PLAY_STORE_COMPLIANCE.md audit is historical: its no-third-party-SDK and disabled-R8 statements no longer describe the app. Sentry is present, R8 is enabled, and logout does not delete the user's server account. From da223827d262bcf7abd7500151bba3326afbc710 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 17:29:33 +0200 Subject: [PATCH 4/5] ci: fail the Play bundle when the artifact is not actually signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jarsigner -verify exits 0 on an unsigned artifact — it prints "jar is unsigned" and reports success. Paired with the signing config's deliberate fallback to an unsigned bundle when the credentials do not reach Gradle, an exit code alone would have let an unsigned bundle travel as a verified one. The step now requires the positive "jar verified" statement and fails loudly otherwise. --- .github/workflows/play-bundle.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/play-bundle.yml b/.github/workflows/play-bundle.yml index 0bff850..48028a8 100644 --- a/.github/workflows/play-bundle.yml +++ b/.github/workflows/play-bundle.yml @@ -42,7 +42,17 @@ jobs: trap 'rm -f "$PINAKES_KEYSTORE"' EXIT printf '%s' "$KEY_BASE64" | base64 --decode > "$PINAKES_KEYSTORE" ./gradlew bundleRelease --no-daemon --no-configuration-cache - jarsigner -verify app/build/outputs/bundle/release/app-release.aab + # `jarsigner -verify` exits 0 on an UNSIGNED artifact — it just prints + # "jar is unsigned". Since the build deliberately falls back to an + # unsigned bundle when the credentials do not reach Gradle, an exit + # code alone would let an unsigned bundle pass as verified. Require + # the positive statement instead. + verify_out="$(jarsigner -verify app/build/outputs/bundle/release/app-release.aab)" + printf '%s\n' "$verify_out" + case "$verify_out" in + *"jar verified"*) ;; + *) echo "::error::release bundle is not signed"; exit 1 ;; + esac sha256sum app/build/outputs/bundle/release/app-release.aab > app/build/outputs/bundle/release/SHA256SUMS.txt - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: From 9968411a21dca00391397b96ae25d64dda600f0c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 14 Sep 2026 14:16:34 +0200 Subject: [PATCH 5/5] feat(periodicals): browse standalone articles from the phone Pinakes 0.7.84 catalogues articles that belong to no issue the library owns (#412). The app now reads them: a list with search, masthead filter and cursor pagination, and a bibliographic detail with the host citation, identifiers and the public PDF when the library published one. The section appears only when the server advertises it. /periodicals/health carries capabilities.standalone_articles, probed with the existing health refresh, so an older server simply does not show the entry instead of failing a request. The PDF URL comes from the server absolute; the client never builds a storage path. Strings in all four languages. Covered by a contract test over the API shapes and a ViewModel test over list, search, pagination and the empty state. --- README.md | 8 ++ .../app/data/model/PeriodicalsModels.kt | 41 +++++++ .../app/data/network/PeriodicalsApi.kt | 11 ++ .../data/repository/PeriodicalsRepository.kt | 24 +++- .../repository/StandaloneArticlesSource.kt | 17 +++ .../app/di/StandaloneArticlesModule.kt | 15 +++ .../app/ui/navigation/PinakesNavHost.kt | 26 ++++ .../com/pinakes/app/ui/navigation/Routes.kt | 6 + .../periodicals/PeriodicalDetailScreen.kt | 8 ++ .../periodicals/PeriodicalDetailViewModel.kt | 6 + .../screens/periodicals/PeriodicalsScreen.kt | 7 ++ .../periodicals/PeriodicalsViewModel.kt | 16 ++- .../periodicals/StandaloneArticleScreen.kt | 115 ++++++++++++++++++ .../periodicals/StandaloneArticleViewModel.kt | 48 ++++++++ .../periodicals/StandaloneArticlesScreen.kt | 107 ++++++++++++++++ .../StandaloneArticlesViewModel.kt | 107 ++++++++++++++++ .../app/StandaloneArticleContractTest.kt | 55 +++++++++ .../StandaloneArticlesViewModelTest.kt | 103 ++++++++++++++++ docs/emeroteca-standalone-articles.md | 40 ++++++ i18n/de.json | 27 +++- i18n/en.json | 27 +++- i18n/fr.json | 27 +++- i18n/it.json | 27 +++- 23 files changed, 860 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt create mode 100644 app/src/main/java/com/pinakes/app/di/StandaloneArticlesModule.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt create mode 100644 app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt create mode 100644 app/src/test/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModelTest.kt create mode 100644 docs/emeroteca-standalone-articles.md diff --git a/README.md b/README.md index 307a3a3..93e4975 100644 --- a/README.md +++ b/README.md @@ -183,3 +183,11 @@ distributor receiver is a follow-up. See `STATUS.md` for the full breakdown. ## License Released under the same license as Pinakes: **AGPL-3.0**. + +### Standalone articles in Emeroteca + +On compatible servers, **Emeroteca → Articles** searches and displays standalone +newspaper and magazine articles, without requiring ownership of their issues. +Publication screens also link to their associated articles. Public PDFs use the +URL supplied by the server. Older servers retain the existing periodicals browser. +See [the article integration notes](docs/emeroteca-standalone-articles.md). 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 0f529de..83ff746 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 @@ -19,6 +19,7 @@ import kotlinx.serialization.Serializable @Serializable data class PeriodicalsHealth( val status: String = "", + val capabilities: PeriodicalsCapabilities = PeriodicalsCapabilities(), ) // ---------- Mastheads list ---------- @@ -148,3 +149,43 @@ data class IssueArticle( @SerialName("page_end") val pageEnd: Int? = null, val type: String? = null, ) + +/** Missing capability means an older server, whose existing periodicals remain usable. */ +@Serializable +data class PeriodicalsCapabilities( + @SerialName("standalone_articles") val standaloneArticles: Boolean = false, +) + +/** Standalone contributions use the server's Italian column names, unlike issue indexes. */ +@Serializable +data class StandaloneArticle( + val id: Int = 0, + @SerialName("titolo") val title: String = "", + @SerialName("autori") val authors: String? = null, + @SerialName("tipo_contributo") val contributionType: String? = null, + @SerialName("contenitore_tipo") val containerType: String? = null, + @SerialName("contenitore_titolo") val containerTitle: String? = null, + val issn: String? = null, + @SerialName("data_pubblicazione_testo") val publicationDate: String? = null, + @SerialName("anno_pubblicazione") val publicationYear: Int? = null, + val volume: String? = null, + @SerialName("numero") val number: String? = null, + @SerialName("pagine") val pages: String? = null, + val doi: String? = null, + @SerialName("supporto") val medium: String? = null, + val keywords: String? = null, + @SerialName("abstract") val description: String? = null, + @SerialName("testata_id") val mastheadId: Int? = null, + @SerialName("fascicolo_id") val issueId: Int? = null, + @SerialName("has_public_pdf") val hasPublicPdf: Boolean = false, + @SerialName("pdf_url") val pdfUrl: String? = null, +) { + // Date text and page spans are citations, not ISO dates or page counts. + val dateLabel: String? get() = publicationDate?.takeIf { it.isNotBlank() } + ?: publicationYear?.toString() + val canOpenPdf: Boolean get() = hasPublicPdf && publicPdfUrl != null + val publicPdfUrl: String? get() = pdfUrl?.takeIf { + val uri = runCatching { java.net.URI(it) }.getOrNull() + uri?.scheme?.lowercase() in listOf("https", "http") && !uri?.host.isNullOrBlank() + } +} diff --git a/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt index 1b1a613..0ea0f7f 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt @@ -6,6 +6,7 @@ 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 com.pinakes.app.data.model.StandaloneArticle import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -45,4 +46,14 @@ interface PeriodicalsApi { /** Issue detail + spoglio articles + public PDF url when available. */ @GET("periodicals/issues/{id}") suspend fun issue(@Path("id") id: Int): Envelope + + @GET("periodicals/articles") + suspend fun articles( + @Query("q") query: String? = null, + @Query("testata_id") mastheadId: Int? = null, + @Query("cursor") cursor: String? = null, + ): Envelope> + + @GET("periodicals/articles/{id}") + suspend fun article(@Path("id") id: Int): Envelope } diff --git a/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt index 88664b7..f07e8d8 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt @@ -1,5 +1,6 @@ package com.pinakes.app.data.repository +import com.pinakes.app.data.model.StandaloneArticle import com.pinakes.app.data.model.PeriodicalDetail import com.pinakes.app.data.model.PeriodicalIssue import com.pinakes.app.data.model.PeriodicalIssueDetail @@ -30,7 +31,7 @@ class PeriodicalsRepository( private val network: NetworkModule, private val features: FeatureStore, private val session: SessionStore, -) { +) : StandaloneArticlesSource { /** * Probe `GET /periodicals/health`. @@ -84,7 +85,7 @@ class PeriodicalsRepository( * 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 { + override 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 @@ -92,4 +93,23 @@ class PeriodicalsRepository( val applied = applyAvailability(available, instance) return applied && available == false } + + override suspend fun standaloneArticlesSupported(): Boolean? { + val instance = session.instanceUrl + val result = apiCall { network.periodicalsApi().health() } + if (instance != session.instanceUrl) return null + return when (result) { + is ApiResult.Success -> result.data.capabilities.standaloneArticles + is ApiResult.Failure -> if (result.httpStatus == 404) false else null + } + } + + override suspend fun articles(query: String?, mastheadId: Int?, cursor: String?): ApiResult = + when (val result = apiCall { network.periodicalsApi().articles(query, mastheadId, cursor) }) { + is ApiResult.Success -> ApiResult.Success(StandaloneArticlesPage(result.data, result.meta?.nextCursor), result.meta) + is ApiResult.Failure -> result + } + + override suspend fun article(id: Int): ApiResult = + apiCall { network.periodicalsApi().article(id) } } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt b/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt new file mode 100644 index 0000000..53224b8 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt @@ -0,0 +1,17 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.data.network.ApiResult + +data class StandaloneArticlesPage( + val items: List, + val nextCursor: String? = null, +) + +/** Read-only article source, separately injectable for lifecycle/pagination tests. */ +interface StandaloneArticlesSource { + suspend fun standaloneArticlesSupported(): Boolean? + suspend fun articles(query: String? = null, mastheadId: Int? = null, cursor: String? = null): ApiResult + suspend fun article(id: Int): ApiResult + suspend fun confirmGone(): Boolean +} diff --git a/app/src/main/java/com/pinakes/app/di/StandaloneArticlesModule.kt b/app/src/main/java/com/pinakes/app/di/StandaloneArticlesModule.kt new file mode 100644 index 0000000..f8a263b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/di/StandaloneArticlesModule.kt @@ -0,0 +1,15 @@ +package com.pinakes.app.di + +import com.pinakes.app.data.repository.PeriodicalsRepository +import com.pinakes.app.data.repository.StandaloneArticlesSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object StandaloneArticlesModule { + @Provides + fun source(repository: PeriodicalsRepository): StandaloneArticlesSource = repository +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 2c16489..1d7a556 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -30,6 +30,8 @@ import com.pinakes.app.ui.screens.onboarding.OnboardingScreen import com.pinakes.app.ui.screens.periodicals.IssueDetailScreen import com.pinakes.app.ui.screens.periodicals.IssueListScreen import com.pinakes.app.ui.screens.periodicals.PeriodicalDetailScreen +import com.pinakes.app.ui.screens.periodicals.StandaloneArticlesScreen +import com.pinakes.app.ui.screens.periodicals.StandaloneArticleScreen import com.pinakes.app.ui.screens.periodicals.PeriodicalsScreen import com.pinakes.app.ui.screens.reviews.MyReviewsScreen @@ -169,6 +171,28 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ClubDetailScreen(onNavigateUp = { navController.popBackStack() }) } + composable( + Routes.STANDALONE_ARTICLES, + arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType; defaultValue = 0 }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StandaloneArticlesScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenArticle = { id -> navController.navigate(Routes.standaloneArticle(id)) }, + ) + } + composable( + Routes.STANDALONE_ARTICLE, + arguments = listOf(navArgument(Routes.ARG_ARTICLE_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StandaloneArticleScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) }, + onOpenIssue = { id -> navController.navigate(Routes.periodicalIssue(id)) }, + ) + } + // ---- Periodicals / Emeroteca (optional plugin) ---- composable( Routes.PERIODICALS, @@ -178,6 +202,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { PeriodicalsScreen( onNavigateUp = { navController.popBackStack() }, onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) }, + onOpenArticles = { navController.navigate(Routes.standaloneArticles()) }, ) } @@ -188,6 +213,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { popExitTransition = slideOut, ) { PeriodicalDetailScreen( + onOpenArticles = { id -> navController.navigate(Routes.standaloneArticles(id)) }, onNavigateUp = { navController.popBackStack() }, onOpenYear = { yearId, year -> navController.navigate(Routes.periodicalYearIssues(yearId, year)) diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index 4e834ed..4a9cacd 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -37,6 +37,12 @@ object Routes { fun periodicalDetail(id: Int): String = "periodicals/$id" const val ARG_PERIODICAL_ID = "periodicalId" + const val STANDALONE_ARTICLES = "periodicals/articles?periodicalId={periodicalId}" + fun standaloneArticles(mastheadId: Int = 0): String = "periodicals/articles?periodicalId=$mastheadId" + const val STANDALONE_ARTICLE = "periodicals/articles/{articleId}" + const val ARG_ARTICLE_ID = "articleId" + fun standaloneArticle(id: Int): String = "periodicals/articles/$id" + // The display year rides along as a nav arg so the issues screen can title itself // ("Year 1998") without re-fetching the masthead detail. const val PERIODICAL_YEAR_ISSUES = "periodicals/years/{yearId}/{year}" 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 980731f..dc98497 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 @@ -49,6 +49,7 @@ import com.pinakes.app.ui.theme.Spacing fun PeriodicalDetailScreen( onNavigateUp: () -> Unit, onOpenYear: (yearId: Int, year: Int) -> Unit, + onOpenArticles: (Int) -> Unit, ) { val vm: PeriodicalDetailViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() @@ -81,6 +82,13 @@ fun PeriodicalDetailScreen( verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { item { PeriodicalHeader(detail) } + if (state.articlesSupported) { + item { + androidx.compose.material3.TextButton(onClick = { onOpenArticles(detail.id) }) { + Text(stringResource(R.string.standalone_articles_in_masthead)) + } + } + } if (detail.years.isNotEmpty()) { item { Text( 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 0294b9e..3e4670c 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 @@ -22,6 +22,7 @@ data class PeriodicalDetailUiState( val refreshing: Boolean = false, /** The plugin was deactivated server-side (confirmed via health re-probe). */ val pluginGone: Boolean = false, + val articlesSupported: Boolean = false, ) @HiltViewModel @@ -41,6 +42,11 @@ class PeriodicalDetailViewModel @Inject constructor( fun refresh() = load(initial = false) private fun load(initial: Boolean) { + viewModelScope.launch { + repo.standaloneArticlesSupported()?.let { supported -> + _state.update { it.copy(articlesSupported = supported) } + } + } if (initial) _state.update { it.copy(content = UiState.Loading) } else _state.update { it.copy(refreshing = true) } viewModelScope.launch { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt index aa926b7..3ac90fd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt @@ -63,6 +63,7 @@ import com.pinakes.app.ui.theme.Spacing fun PeriodicalsScreen( onNavigateUp: () -> Unit, onOpenPeriodical: (Int) -> Unit, + onOpenArticles: () -> Unit, ) { val vm: PeriodicalsViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() @@ -84,6 +85,12 @@ fun PeriodicalsScreen( topBar = { PinakesTopBar(title = stringResource(R.string.periodicals_title), onNavigateUp = onNavigateUp) }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { + if (state.articlesSupported) { + androidx.compose.material3.TextButton( + onClick = onOpenArticles, + modifier = Modifier.padding(horizontal = Spacing.lg), + ) { Text(stringResource(R.string.standalone_articles_title)) } + } Column(Modifier.padding(horizontal = Spacing.lg).padding(top = Spacing.sm)) { SearchField( query = state.query, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt index 5ff747c..62f36a7 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt @@ -27,6 +27,7 @@ data class PeriodicalsUiState( val error: String? = null, /** The plugin was deactivated server-side (confirmed via health re-probe). */ val pluginGone: Boolean = false, + val articlesSupported: Boolean = false, ) { val hasMore: Boolean get() = nextCursor != null } @@ -74,9 +75,20 @@ class PeriodicalsViewModel @Inject constructor( */ private var generation = 0 - init { load(reset = true) } + init { refresh() } - fun refresh() = load(reset = true) + fun refresh() { + discoverArticles() + load(reset = true) + } + + private fun discoverArticles() { + viewModelScope.launch { + repo.standaloneArticlesSupported()?.let { supported -> + _state.update { it.copy(articlesSupported = supported) } + } + } + } fun onQueryChange(value: String) { _state.update { it.withQuery(value) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt new file mode 100644 index 0000000..532f1cf --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt @@ -0,0 +1,115 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material3.* +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.* +import com.pinakes.app.ui.screens.bookclub.openWeb +import com.pinakes.app.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StandaloneArticleScreen( + onNavigateUp: () -> Unit, + onOpenPeriodical: (Int) -> Unit, + onOpenIssue: (Int) -> Unit, + vm: StandaloneArticleViewModel = hiltViewModel(), +) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + Scaffold(topBar = { + PinakesTopBar(title = stringResource(R.string.standalone_article_title), onNavigateUp = onNavigateUp, + actions = { + IconButton(onClick = vm::refresh) { + Icon(Icons.Outlined.Refresh, contentDescription = stringResource(R.string.action_refresh)) + } + }) + }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when (val content = state) { + UiState.Loading -> LoadingState(label = stringResource(R.string.standalone_articles_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> StandaloneArticleContent( + article = content.data, + onOpenPdf = { content.data.publicPdfUrl?.let { openWeb(context, it) } }, + onOpenPeriodical = onOpenPeriodical, onOpenIssue = onOpenIssue, + ) + } + } + } +} + +@Composable +internal fun StandaloneArticleContent( + article: StandaloneArticle, + onOpenPdf: () -> Unit, + onOpenPeriodical: (Int) -> Unit, + onOpenIssue: (Int) -> Unit, +) { + LazyColumn(contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.lg)) { + item { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + Text(article.title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface) + article.authors?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + item { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + ArticleMetadata(R.string.standalone_article_publication, article.containerTitle) + article.containerType?.takeIf { it.isNotBlank() }?.let { + ArticleMetadata(R.string.standalone_article_publication_type, stringResource(periodicalTypeLabelRes(it))) + } + ArticleMetadata(R.string.standalone_article_date, article.dateLabel) + ArticleMetadata(R.string.standalone_article_volume_label, article.volume) + ArticleMetadata(R.string.standalone_article_number_label, article.number) + ArticleMetadata(R.string.standalone_article_pages_label, article.pages) + ArticleMetadata(R.string.standalone_article_issn, article.issn) + ArticleMetadata(R.string.standalone_article_doi, article.doi) + ArticleMetadata(R.string.standalone_article_keywords, article.keywords) + } + } + } + article.description?.takeIf { it.isNotBlank() }?.let { description -> + item { SelectionContainer { Text(description, style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface) } } + } + if (article.canOpenPdf) item { + PrimaryButton(label = stringResource(R.string.periodicals_open_pdf), onClick = onOpenPdf, + modifier = Modifier.fillMaxWidth(), leadingIcon = Icons.AutoMirrored.Outlined.OpenInNew) + } + article.mastheadId?.takeIf { it > 0 }?.let { id -> + item { TextButton(onClick = { onOpenPeriodical(id) }) { Text(stringResource(R.string.standalone_article_open_masthead)) } } + } + article.issueId?.takeIf { it > 0 }?.let { id -> + item { TextButton(onClick = { onOpenIssue(id) }) { Text(stringResource(R.string.standalone_article_open_issue)) } } + } + } +} + +@Composable +private fun ArticleMetadata(@androidx.annotation.StringRes label: Int, value: String?) { + if (!value.isNullOrBlank()) Column { + Text(stringResource(label), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt new file mode 100644 index 0000000..fb7530f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt @@ -0,0 +1,48 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StandaloneArticlesSource +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class StandaloneArticleViewModel @Inject constructor( + private val source: StandaloneArticlesSource, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val id = savedStateHandle.get(Routes.ARG_ARTICLE_ID) ?: 0 + private val mutableState = MutableStateFlow>(UiState.Loading) + val state = mutableState.asStateFlow() + private var generation = 0 + + init { refresh() } + + fun refresh() { + val request = ++generation + // Do not retain a previously public record/PDF after a 404 or visibility change. + mutableState.value = UiState.Loading + viewModelScope.launch { + val result = source.article(id) + if (request != generation) return@launch + mutableState.value = when (result) { + is ApiResult.Success -> UiState.Success(result.data) + is ApiResult.Failure -> { + val gone = isNotFoundFailure(result) && source.confirmGone() + if (request != generation) return@launch + periodicalsErrorState(result, periodicalsFailureKind(result, gone), + R.string.standalone_articles_error, R.string.standalone_article_not_found) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt new file mode 100644 index 0000000..5ecc364 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt @@ -0,0 +1,107 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.ui.components.* +import com.pinakes.app.ui.theme.Spacing + +/** Articles are readable citation rows, without suggesting possession or lending status. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StandaloneArticlesScreen( + onNavigateUp: () -> Unit, + onOpenArticle: (Int) -> Unit, + vm: StandaloneArticlesViewModel = hiltViewModel(), +) { + val state by vm.state.collectAsStateWithLifecycle() + Scaffold(topBar = { + PinakesTopBar(title = stringResource(R.string.standalone_articles_title), onNavigateUp = onNavigateUp, + actions = { + IconButton(onClick = vm::refresh) { + Icon(Icons.Outlined.Refresh, contentDescription = stringResource(R.string.action_refresh)) + } + }) + }) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField( + query = state.query, onQueryChange = vm::onQueryChange, onSearch = vm::refresh, + placeholder = stringResource(R.string.standalone_articles_search), + modifier = Modifier.fillMaxWidth().padding(Spacing.lg), + ) + when { + state.loading -> LoadingState(label = stringResource(R.string.standalone_articles_loading)) + state.unavailable -> EmptyState( + title = stringResource(R.string.standalone_articles_title), + subtitle = stringResource(R.string.standalone_articles_unavailable), + ) + state.error -> ErrorState(message = stringResource(R.string.standalone_articles_error), onRetry = vm::refresh) + state.items.isEmpty() -> EmptyState( + title = stringResource(R.string.standalone_articles_empty), + subtitle = stringResource(R.string.standalone_articles_empty_hint), + ) + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = Spacing.lg, vertical = Spacing.sm), + ) { + items(state.items, key = { it.id }) { article -> + StandaloneArticleRow(article, onClick = { onOpenArticle(article.id) }) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + if (state.nextCursor != null) item { + if (state.moreError) Text( + stringResource(R.string.standalone_articles_error), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Spacing.md), + ) + TextButton( + onClick = vm::loadMore, enabled = !state.loadingMore, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.md), + ) { + if (state.loadingMore) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + else Text(stringResource(if (state.moreError) R.string.action_retry else R.string.action_load_more)) + } + } + } + } + } + } +} + +@Composable +private fun StandaloneArticleRow(article: StandaloneArticle, onClick: () -> Unit) { + Column( + Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick).padding(vertical = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + Text(article.title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) + article.authors?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + article.containerTitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val citation = listOfNotNull( + article.dateLabel, + article.volume?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_volume, it) }, + article.number?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_number, it) }, + article.pages?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_pages, it) }, + ).joinToString(" · ") + if (citation.isNotBlank()) Text(citation, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt new file mode 100644 index 0000000..8cfcfdc --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt @@ -0,0 +1,107 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StandaloneArticlesSource +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class StandaloneArticlesUiState( + val query: String = "", + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val loadingMore: Boolean = false, + val error: Boolean = false, + val moreError: Boolean = false, + val unavailable: Boolean = false, +) + +@HiltViewModel +class StandaloneArticlesViewModel @Inject constructor( + private val source: StandaloneArticlesSource, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val mastheadId = savedStateHandle.get(Routes.ARG_PERIODICAL_ID)?.takeIf { it > 0 } + private val mutableState = MutableStateFlow(StandaloneArticlesUiState()) + val state = mutableState.asStateFlow() + private var supported: Boolean? = null + private var generation = 0 + private var searchJob: Job? = null + + init { refresh() } + + fun onQueryChange(query: String) { + mutableState.update { it.copy(query = query) } + reload(debounce = true) + } + + fun refresh() = reload(debounce = false) + + private fun reload(debounce: Boolean) { + // Invalidate immediately, before debounce: an older response must never display + // underneath the newly typed query, even if cancellation is swallowed by transport. + val request = ++generation + searchJob?.cancel() + mutableState.update { + it.copy(items = emptyList(), nextCursor = null, loading = true, + loadingMore = false, error = false, moreError = false, unavailable = false) + } + val query = mutableState.value.query.takeIf { it.isNotBlank() } + searchJob = viewModelScope.launch { + if (debounce) delay(350) + if (!debounce || supported == null) { + val capability = source.standaloneArticlesSupported() + if (request != generation) return@launch + if (capability != null) supported = capability + } + if (request != generation) return@launch + if (supported == false) { + mutableState.update { it.copy(loading = false, unavailable = true) } + return@launch + } + val result = source.articles(query, mastheadId) + if (request != generation) return@launch + when (result) { + is ApiResult.Success -> mutableState.update { + it.copy(items = result.data.items.distinctBy { article -> article.id }, + nextCursor = result.data.nextCursor, loading = false) + } + is ApiResult.Failure -> { + val gone = isNotFoundFailure(result) && source.confirmGone() + if (request != generation) return@launch + mutableState.update { it.copy(loading = false, error = !gone, unavailable = gone) } + } + } + } + } + + fun loadMore() { + val current = mutableState.value + if (current.loading || current.loadingMore || current.nextCursor == null) return + val request = generation + mutableState.update { it.copy(loadingMore = true, moreError = false) } + viewModelScope.launch { + val result = source.articles(current.query.takeIf { it.isNotBlank() }, mastheadId, current.nextCursor) + if (request != generation) return@launch + when (result) { + is ApiResult.Success -> mutableState.update { + it.copy(items = (it.items + result.data.items).distinctBy { article -> article.id }, + nextCursor = result.data.nextCursor?.takeUnless { cursor -> cursor == current.nextCursor }, + loadingMore = false) + } + is ApiResult.Failure -> mutableState.update { it.copy(loadingMore = false, moreError = true) } + } + } + } +} diff --git a/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt b/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt new file mode 100644 index 0000000..7f7745a --- /dev/null +++ b/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt @@ -0,0 +1,55 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.PeriodicalsHealth +import com.pinakes.app.data.model.StandaloneArticle +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Test + +class StandaloneArticleContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test fun oldServerDoesNotAdvertiseArticles() { + val legacy = json.decodeFromString>("""{"data":{"status":"ok"},"error":null}""") + val current = json.decodeFromString>("""{"data":{"status":"ok","capabilities":{"standalone_articles":true}},"error":null}""") + assertFalse(legacy.data!!.capabilities.standaloneArticles) + assertTrue(current.data!!.capabilities.standaloneArticles) + } + + @Test fun italianWireFieldsPreserveTheIssue412Citation() { + val result = json.decodeFromString>>(""" + {"data":[{"id":42,"titolo":"Intertextuality in Daniel Kehlmann's Novel Tyll", + "autori":"Schweissinger, Marc J.","contenitore_titolo":"International Journal of Language and Literature", + "contenitore_tipo":"rivista","data_pubblicazione_testo":"June 2019","anno_pubblicazione":2019, + "volume":"7","numero":"1","pagine":"138–148","testata_id":null,"fascicolo_id":null, + "abstract":"Description","has_public_pdf":false,"pdf_url":null,"kind":"autonomo"}], + "meta":{"next_cursor":"42"},"error":null} + """.trimIndent()) + val article = result.data!!.single() + assertEquals("June 2019", article.dateLabel) + assertEquals("138–148", article.pages) + assertEquals("7", article.volume) + assertEquals("1", article.number) + assertEquals("Schweissinger, Marc J.", article.authors) + assertEquals("Description", article.description) + assertNull(article.mastheadId) + assertEquals("42", result.meta!!.nextCursor) + } + + @Test fun dateAndPagesAreNeverCoercedToIsoOrIntegers() { + val article = StandaloneArticle(publicationYear = 2019, pages = "iv–x, 138–148") + assertEquals("2019", article.dateLabel) + assertEquals("iv–x, 138–148", article.pages) + assertNull(StandaloneArticle().dateLabel) + } + + @Test fun pdfRequiresBothPublicFlagAndValidWebUrl() { + val article = StandaloneArticle(hasPublicPdf = true, pdfUrl = "https://library.example/subdir/emeroteca/articolo/42/pdf") + assertTrue(article.canOpenPdf) + assertFalse(article.copy(hasPublicPdf = false).canOpenPdf) + assertFalse(article.copy(pdfUrl = null).canOpenPdf) + assertFalse(article.copy(pdfUrl = "javascript:alert(1)").canOpenPdf) + assertFalse(article.copy(pdfUrl = "/storage/private.pdf").canOpenPdf) + } +} diff --git a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModelTest.kt b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModelTest.kt new file mode 100644 index 0000000..0327a5d --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModelTest.kt @@ -0,0 +1,103 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.StandaloneArticlesPage +import com.pinakes.app.data.repository.StandaloneArticlesSource +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StandaloneArticlesViewModelTest { + private val dispatcher = StandardTestDispatcher() + @Before fun setup() { Dispatchers.setMain(dispatcher) } + @After fun cleanup() { Dispatchers.resetMain() } + + private class Source : StandaloneArticlesSource { + var supported: Boolean? = true + var calls = mutableListOf>() + var list: suspend (String?, String?) -> ApiResult = { _, _ -> + ApiResult.Success(StandaloneArticlesPage(listOf(StandaloneArticle(id = 1)), "1")) + } + var detail: ApiResult = ApiResult.Success(StandaloneArticle(id = 1, hasPublicPdf = true, pdfUrl = "https://example.org/pdf")) + override suspend fun standaloneArticlesSupported() = supported + override suspend fun articles(query: String?, mastheadId: Int?, cursor: String?): ApiResult { + calls += Triple(query, mastheadId, cursor) + return list(query, cursor) + } + override suspend fun article(id: Int) = detail + override suspend fun confirmGone() = false + } + + @Test fun oldServerKeepsArticlesUnavailableWithoutCallingMissingEndpoint() = runTest { + val source = Source().apply { supported = false } + val vm = StandaloneArticlesViewModel(source, SavedStateHandle()) + advanceUntilIdle() + assertTrue(vm.state.value.unavailable) + assertTrue(source.calls.isEmpty()) + } + + @Test fun mastheadFilterAndSearchArePassedToEveryCursorPage() = runTest { + val source = Source() + val vm = StandaloneArticlesViewModel(source, SavedStateHandle(mapOf(Routes.ARG_PERIODICAL_ID to 7))) + advanceUntilIdle() + vm.onQueryChange("Tyll") + advanceUntilIdle() + source.list = { _, _ -> ApiResult.Success(StandaloneArticlesPage(listOf(StandaloneArticle(id = 1), StandaloneArticle(id = 2)))) } + vm.loadMore() + advanceUntilIdle() + assertEquals(Triple("Tyll", 7, "1"), source.calls.last()) + assertEquals(listOf(1, 2), vm.state.value.items.map { it.id }) + assertNull(vm.state.value.nextCursor) + } + + @Test fun aLatePageCannotAppendAfterTheQueryChanges() = runTest { + val source = Source() + val vm = StandaloneArticlesViewModel(source, SavedStateHandle()) + advanceUntilIdle() + val pending = CompletableDeferred>() + source.list = { _, cursor -> + if (cursor != null) withContext(NonCancellable) { pending.await() } + else ApiResult.Success(StandaloneArticlesPage(listOf(StandaloneArticle(id = 9)))) + } + vm.loadMore(); runCurrent() + vm.onQueryChange("new") + pending.complete(ApiResult.Success(StandaloneArticlesPage(listOf(StandaloneArticle(id = 2))))) + runCurrent() + assertTrue(vm.state.value.items.isEmpty()) + advanceUntilIdle() + assertEquals(listOf(9), vm.state.value.items.map { it.id }) + } + + @Test fun paginationFailureKeepsRowsAndSupportsRetry() = runTest { + val source = Source() + val vm = StandaloneArticlesViewModel(source, SavedStateHandle()) + advanceUntilIdle() + source.list = { _, _ -> ApiResult.Failure(ErrorCodes.NETWORK, "offline") } + vm.loadMore(); advanceUntilIdle() + assertTrue(vm.state.value.moreError) + assertEquals(listOf(1), vm.state.value.items.map { it.id }) + source.list = { _, _ -> ApiResult.Success(StandaloneArticlesPage(listOf(StandaloneArticle(id = 2)))) } + vm.loadMore(); advanceUntilIdle() + assertFalse(vm.state.value.moreError) + assertEquals(listOf(1, 2), vm.state.value.items.map { it.id }) + } + + @Test fun privateOrDeletedArticleClearsCachedPdfOnRefresh() = runTest { + val source = Source() + val vm = StandaloneArticleViewModel(source, SavedStateHandle(mapOf(Routes.ARG_ARTICLE_ID to 1))) + advanceUntilIdle() + assertTrue(vm.state.value is UiState.Success) + source.detail = ApiResult.Failure(ErrorCodes.NOT_FOUND, "Not found", 404) + vm.refresh(); advanceUntilIdle() + assertTrue(vm.state.value is UiState.Error) + } +} diff --git a/docs/emeroteca-standalone-articles.md b/docs/emeroteca-standalone-articles.md new file mode 100644 index 0000000..47cc92d --- /dev/null +++ b/docs/emeroteca-standalone-articles.md @@ -0,0 +1,40 @@ +# Standalone articles (Pinakes #412) + +The Emeroteca section now offers **Articles** when `GET /api/v1/periodicals/health` +returns `data.capabilities.standalone_articles = true`. Older servers omit this +capability and keep the existing masthead → year → issue → contents workflow. + +Articles can be searched by title, author, publication or keywords. A publication's +screen links to the same list filtered by `testata_id`. Pagination follows +`meta.next_cursor`, preserves the filter, avoids duplicate IDs and exposes retry +on a failed page. Search changes invalidate earlier requests immediately. + +The article detail preserves partial dates (such as June 2019), volume/issue +strings and page spans (including roman numerals). The publication and issue +links appear only when the server supplies their IDs. No holding or lending +status is inferred from the existence of an article. + +PDFs open in an external viewer only when `has_public_pdf` is true and `pdf_url` +is a valid HTTP(S) URL. The server resolves the URL, including a possible +installation subdirectory. Servers implementing the original capability without +`pdf_url` can still display article metadata; the PDF action stays hidden until +the server is updated. Refreshing an article that became private or was deleted +removes its previously displayed content and PDF action. + +## Wire contract + +- `GET periodicals/articles?q=&testata_id=&cursor=` → core envelope with an array. +- `GET periodicals/articles/{id}` → core envelope with one article; private or + deleted records return 404. +- Article fields use Italian names (`titolo`, `autori`, `contenitore_titolo`, + `data_pubblicazione_testo`, `anno_pubblicazione`, `numero`, `pagine`, `testata_id`, + `fascicolo_id`), mapped explicitly by Kotlin serialization. Existing issue + index fields retain their English names. +- `pdf_url` is nullable. Internal filenames, shelf marks and private notes are + never needed by this client. + +This is a consultation feature. Cataloguing, CSV import/export, associations and +publication permissions remain in the web administration interface. + +Translations are provided in English, Italian, French and German through the +existing JSON resource generator. No new dependency is required. diff --git a/i18n/de.json b/i18n/de.json index ca1a860..b464c1e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -497,5 +497,30 @@ "periodicals_status_danneggiato": "Beschädigt", "periodicals_status_in_restauro": "In Restaurierung", "periodicals_status_smarrito": "Verloren", - "periodicals_status_atteso": "Erwartet" + "periodicals_status_atteso": "Erwartet", + "standalone_articles_title": "Artikel", + "standalone_article_title": "Artikel", + "standalone_articles_in_masthead": "Artikel dieser Publikation", + "standalone_articles_search": "Titel, Autor oder Publikation", + "standalone_articles_loading": "Artikel werden geladen…", + "standalone_articles_error": "Artikel konnten nicht geladen werden. Bitte erneut versuchen.", + "standalone_articles_unavailable": "Artikel sind auf diesem Server nicht verfügbar. Die Zeitschriften können weiterhin durchsucht werden.", + "standalone_articles_empty": "Keine Artikel gefunden", + "standalone_articles_empty_hint": "Versuche eine andere Suche. Hier erscheinen nur von der Bibliothek veröffentlichte Artikel.", + "standalone_article_not_found": "Dieser Artikel ist nicht mehr verfügbar oder wurde als privat markiert.", + "standalone_article_volume": "Bd. %1$s", + "standalone_article_number": "Nr. %1$s", + "standalone_article_pages": "S. %1$s", + "standalone_article_publication": "Publikation", + "standalone_article_publication_type": "Publikationstyp", + "standalone_article_date": "Erscheinungsdatum", + "standalone_article_volume_label": "Band", + "standalone_article_number_label": "Ausgabe", + "standalone_article_pages_label": "Seiten", + "standalone_article_issn": "ISSN", + "standalone_article_doi": "DOI", + "standalone_article_keywords": "Schlagwörter", + "standalone_article_open_masthead": "Publikation ansehen", + "standalone_article_open_issue": "Ausgabe ansehen", + "action_refresh": "Aktualisieren" } diff --git a/i18n/en.json b/i18n/en.json index 8253623..9eae4b8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -497,5 +497,30 @@ "periodicals_status_danneggiato": "Damaged", "periodicals_status_in_restauro": "Under restoration", "periodicals_status_smarrito": "Lost", - "periodicals_status_atteso": "Expected" + "periodicals_status_atteso": "Expected", + "standalone_articles_title": "Articles", + "standalone_article_title": "Article", + "standalone_articles_in_masthead": "Articles from this publication", + "standalone_articles_search": "Title, author or publication", + "standalone_articles_loading": "Loading articles…", + "standalone_articles_error": "Could not load articles. Please try again.", + "standalone_articles_unavailable": "Articles are not available on this server. You can still browse its periodicals.", + "standalone_articles_empty": "No articles found", + "standalone_articles_empty_hint": "Try another search. Only articles published by the library appear here.", + "standalone_article_not_found": "This article is no longer available or has been made private.", + "standalone_article_volume": "Vol. %1$s", + "standalone_article_number": "No. %1$s", + "standalone_article_pages": "pp. %1$s", + "standalone_article_publication": "Publication", + "standalone_article_publication_type": "Publication type", + "standalone_article_date": "Publication date", + "standalone_article_volume_label": "Volume", + "standalone_article_number_label": "Issue", + "standalone_article_pages_label": "Pages", + "standalone_article_issn": "ISSN", + "standalone_article_doi": "DOI", + "standalone_article_keywords": "Keywords", + "standalone_article_open_masthead": "View publication", + "standalone_article_open_issue": "View issue", + "action_refresh": "Refresh" } diff --git a/i18n/fr.json b/i18n/fr.json index a5169dd..899a720 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -497,5 +497,30 @@ "periodicals_status_danneggiato": "Endommagé", "periodicals_status_in_restauro": "En restauration", "periodicals_status_smarrito": "Perdu", - "periodicals_status_atteso": "Attendu" + "periodicals_status_atteso": "Attendu", + "standalone_articles_title": "Articles", + "standalone_article_title": "Article", + "standalone_articles_in_masthead": "Articles de cette publication", + "standalone_articles_search": "Titre, auteur ou publication", + "standalone_articles_loading": "Chargement des articles…", + "standalone_articles_error": "Impossible de charger les articles. Réessayez.", + "standalone_articles_unavailable": "Les articles ne sont pas disponibles sur ce serveur. Vous pouvez toujours consulter les périodiques.", + "standalone_articles_empty": "Aucun article trouvé", + "standalone_articles_empty_hint": "Essayez une autre recherche. Seuls les articles rendus publics par la bibliothèque apparaissent ici.", + "standalone_article_not_found": "Cet article n’est plus disponible ou est devenu privé.", + "standalone_article_volume": "Vol. %1$s", + "standalone_article_number": "N° %1$s", + "standalone_article_pages": "p. %1$s", + "standalone_article_publication": "Publication", + "standalone_article_publication_type": "Type de publication", + "standalone_article_date": "Date de publication", + "standalone_article_volume_label": "Volume", + "standalone_article_number_label": "Numéro", + "standalone_article_pages_label": "Pages", + "standalone_article_issn": "ISSN", + "standalone_article_doi": "DOI", + "standalone_article_keywords": "Mots-clés", + "standalone_article_open_masthead": "Consulter la publication", + "standalone_article_open_issue": "Consulter le numéro", + "action_refresh": "Actualiser" } diff --git a/i18n/it.json b/i18n/it.json index 38ead9f..33cecde 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -497,5 +497,30 @@ "periodicals_status_danneggiato": "Danneggiato", "periodicals_status_in_restauro": "In restauro", "periodicals_status_smarrito": "Smarrito", - "periodicals_status_atteso": "Atteso" + "periodicals_status_atteso": "Atteso", + "standalone_articles_title": "Articoli", + "standalone_article_title": "Articolo", + "standalone_articles_in_masthead": "Articoli di questa testata", + "standalone_articles_search": "Titolo, autore o pubblicazione", + "standalone_articles_loading": "Caricamento articoli…", + "standalone_articles_error": "Impossibile caricare gli articoli. Riprova.", + "standalone_articles_unavailable": "Gli articoli non sono disponibili su questo server. Puoi continuare a consultare le testate.", + "standalone_articles_empty": "Nessun articolo trovato", + "standalone_articles_empty_hint": "Prova un’altra ricerca. Qui compaiono solo gli articoli resi pubblici dalla biblioteca.", + "standalone_article_not_found": "Questo articolo non è più disponibile oppure è stato reso privato.", + "standalone_article_volume": "Vol. %1$s", + "standalone_article_number": "N. %1$s", + "standalone_article_pages": "pp. %1$s", + "standalone_article_publication": "Pubblicazione", + "standalone_article_publication_type": "Tipo di pubblicazione", + "standalone_article_date": "Data di pubblicazione", + "standalone_article_volume_label": "Volume", + "standalone_article_number_label": "Numero", + "standalone_article_pages_label": "Pagine", + "standalone_article_issn": "ISSN", + "standalone_article_doi": "DOI", + "standalone_article_keywords": "Parole chiave", + "standalone_article_open_masthead": "Consulta la testata", + "standalone_article_open_issue": "Consulta il fascicolo", + "action_refresh": "Aggiorna" }