commit 6c17e42037a6433a893a54c0893068f3439479b0 Author: Claude Date: Sun Sep 6 01:58:37 2026 +0000 Baseline: wave 1A server complete, wave 1B Android scaffold + design system green assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef15e2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# build outputs +app/build/ +app/app/build/ +app/.gradle/ +app/.kotlin/ +app/local.properties +# server runtime +server/pb_data/ +server/pocketbase +server/.dev-credentials +# worker logs +logs/*.json +logs/*.err +logs/*.state diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..698018d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle/ +/local.properties +.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +**/build/ +.kotlin/ diff --git a/app/app/LICENSE-Literata-OFL.txt b/app/app/LICENSE-Literata-OFL.txt new file mode 100644 index 0000000..9d3b4e3 --- /dev/null +++ b/app/app/LICENSE-Literata-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts new file mode 100644 index 0000000..38998ca --- /dev/null +++ b/app/app/build.gradle.kts @@ -0,0 +1,117 @@ +plugins { + alias(libs.plugins.android.application) + // NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is + // built in. Only sub-plugins that add compiler extensions are applied here. + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.paparazzi) +} + +android { + namespace = "org.modg.bookshelf" + compileSdk = 37 + buildToolsVersion = "37.0.0" + + defaultConfig { + applicationId = "org.modg.bookshelf" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } +} + +// Paparazzi's Gradle plugin writes an HTML test report that trips on AGP 9's +// new DSL (cashapp/paparazzi#2111). Disable it; the PNG snapshots/report task +// output is what we actually care about here. +tasks.withType().configureEach { + reports.html.required.set(false) +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlinx.serialization.converter) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.mlkit.barcode.scanning) + + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.accompanist.permissions) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.room.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.manifest) +} diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro new file mode 100644 index 0000000..5277d19 --- /dev/null +++ b/app/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +# Release minification is currently disabled (isMinifyEnabled = false); this +# file is a placeholder for when wave-3+ enables it. diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e564eb1 --- /dev/null +++ b/app/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt new file mode 100644 index 0000000..8a2ecd5 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt @@ -0,0 +1,12 @@ +package org.modg.bookshelf + +import android.app.Application + +/** + * Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held + * here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with + * the waves that introduce those packages. This scaffold wave intentionally + * leaves the container empty rather than stubbing out APIs that don't exist + * yet. + */ +class BookshelfApplication : Application() diff --git a/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt b/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt new file mode 100644 index 0000000..3f3c0dd --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/MainActivity.kt @@ -0,0 +1,128 @@ +package org.modg.bookshelf + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.modg.bookshelf.ui.components.BookCover +import org.modg.bookshelf.ui.components.BookshelfScaffold +import org.modg.bookshelf.ui.components.EmptyState +import org.modg.bookshelf.ui.components.GoldDivider +import org.modg.bookshelf.ui.components.PaperSurface +import org.modg.bookshelf.ui.components.PrimaryButton +import org.modg.bookshelf.ui.components.SecondaryButton +import org.modg.bookshelf.ui.components.SyncStatus +import org.modg.bookshelf.ui.components.SyncStatusBar +import org.modg.bookshelf.ui.theme.BookshelfTheme + +/** + * Wave 2's placeholder home screen. There is no navigation graph yet (that's + * ui.nav.BookshelfNavHost, a later wave) — this Activity exists purely as a + * living style reference for the component set above, so wave 3 can see + * exactly how BookshelfScaffold/BookCover/etc. are meant to be used. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + BookshelfTheme { + StyleReferenceScreen() + } + } + } +} + +private data class SampleBook(val title: String, val coverUrl: String?) + +private val sampleBooks = listOf( + SampleBook("The Left Hand of Darkness", "https://covers.openlibrary.org/b/isbn/9780441478125-L.jpg"), + SampleBook("A broken cover URL", "https://example.invalid/nope.jpg"), + SampleBook("No cover on file", null), + SampleBook("Gödel, Escher, Bach", null), +) + +@Composable +private fun StyleReferenceScreen() { + var showEmptyState by remember { mutableStateOf(false) } + + BookshelfScaffold( + title = "Bookshelf", + actions = { + IconButton(onClick = { showEmptyState = !showEmptyState }) { + Icon(Icons.Outlined.Settings, contentDescription = "Toggle empty state") + } + }, + syncStatusBar = { + SyncStatusBar(status = SyncStatus.Synced, label = "Synced • just now") + }, + ) { innerPadding -> + PaperSurface(modifier = Modifier.fillMaxWidth()) { + if (showEmptyState) { + EmptyState( + modifier = Modifier.padding(innerPadding), + title = "Your shelves are empty", + message = "Scan a barcode to add your first book.", + action = { PrimaryButton(text = "Scan a book", onClick = {}) }, + ) + } else { + Column(modifier = Modifier.padding(innerPadding)) { + SectionLabel("Covers") + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(sampleBooks) { book -> + BookCover(coverUrl = book.coverUrl, contentDescription = book.title) + } + } + + GoldDivider(modifier = Modifier.padding(vertical = 8.dp)) + SectionLabel("Buttons") + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PrimaryButton(text = "Save", onClick = {}) + SecondaryButton(text = "Skip", onClick = {}) + } + } + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt new file mode 100644 index 0000000..cc3ac91 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt @@ -0,0 +1,89 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoStories +import androidx.compose.material.icons.outlined.BrokenImage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent + +/** The canonical book-cover aspect ratio (standard trade paperback proportions). */ +const val BookCoverAspectRatio = 2f / 3f + +/** + * A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the + * hero of this app's design — real art fills the whole shape edge to edge. + * When there's no [coverUrl], or the load fails, we fall back to the same + * restrained "letterpress" placeholder: a paper-toned panel with a debossed + * spine motif rather than a broken-image icon or empty grey box. + */ +@Composable +fun BookCover( + coverUrl: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + shape: androidx.compose.ui.graphics.Shape = MaterialTheme.shapes.small, +) { + Box( + modifier = modifier + .aspectRatio(BookCoverAspectRatio) + .clip(shape), + ) { + if (coverUrl.isNullOrBlank()) { + CoverPlaceholder(errored = false) + } else { + SubcomposeAsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) { + when (painter.state) { + is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true) + is AsyncImagePainter.State.Loading, + is AsyncImagePainter.State.Empty, + -> CoverPlaceholder(errored = false, loading = true) + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + } + } + } + } +} + +@Composable +private fun CoverPlaceholder(errored: Boolean, loading: Boolean = false) { + val paperAlt = MaterialTheme.colorScheme.surfaceVariant + val ink = MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .fillMaxSize() + .background(paperAlt) + .border(width = 1.dp, color = ink.copy(alpha = 0.15f)) + .padding(2.dp) + .border(width = 1.dp, color = ink.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + if (!loading) { + Icon( + imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories, + contentDescription = null, + tint = ink.copy(alpha = if (errored) 0.35f else 0.28f), + modifier = Modifier.fillMaxSize(0.32f), + ) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt new file mode 100644 index 0000000..d56de52 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt @@ -0,0 +1,62 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * The shell every Bookshelf screen is built on: a top bar with the serif + * screen title and a gold hairline rule underneath, plus optional nav/action + * slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for + * this instead of a bare [Scaffold] so the chrome stays consistent. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookshelfScaffold( + title: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + syncStatusBar: @Composable () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + Column { + CenterAlignedTopAppBar( + title = { + Text(text = title, style = MaterialTheme.typography.titleLarge) + }, + navigationIcon = navigationIcon, + actions = { actions() }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + GoldDivider() + } + }, + floatingActionButton = floatingActionButton, + containerColor = MaterialTheme.colorScheme.surface, + content = { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(1f)) { + content(innerPadding) + } + syncStatusBar() + } + }, + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/Buttons.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/Buttons.kt new file mode 100644 index 0000000..657044e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/Buttons.kt @@ -0,0 +1,62 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val ButtonShape = RoundedCornerShape(6.dp) +private val ButtonPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + +/** The mahogany-filled call to action button — Save, Add to shelf, Sign in, etc. */ +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = ButtonShape, + contentPadding = ButtonPadding, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} + +/** The quieter outlined companion — Cancel, Skip, secondary actions. */ +@Composable +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = ButtonShape, + contentPadding = ButtonPadding, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/EmptyState.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/EmptyState.kt new file mode 100644 index 0000000..1efa08e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/EmptyState.kt @@ -0,0 +1,64 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoStories +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A quiet, on-brand empty state — an icon, a serif headline, a supporting + * line, and an optional call to action (e.g. library's "invite the first + * scan"). Never just blank space. + */ +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + message: String? = null, + action: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Outlined.AutoStories, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 16.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + } + if (action != null) { + Column(modifier = Modifier.padding(top = 24.dp)) { + action() + } + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/GoldDivider.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/GoldDivider.kt new file mode 100644 index 0000000..619cdac --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/GoldDivider.kt @@ -0,0 +1,45 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +/** + * The thin gold hairline rule used under topbars and between sections — one + * of the app's few recurring "motifs" per the SPEC. Fades to transparent at + * both ends rather than terminating abruptly, so it reads as a rule on the + * page instead of a UI divider. + */ +@Composable +fun GoldDivider(modifier: Modifier = Modifier) { + val gold = MaterialTheme.colorScheme.secondary + Box( + modifier = modifier + .fillMaxWidth() + .height(1.dp) + .drawBehind { + val brush = Brush.horizontalGradient( + colors = listOf( + gold.copy(alpha = 0f), + gold.copy(alpha = 0.8f), + gold.copy(alpha = 0f), + ), + startX = 0f, + endX = size.width, + ) + drawLine( + brush = brush, + start = Offset(0f, size.height / 2f), + end = Offset(size.width, size.height / 2f), + strokeWidth = size.height, + ) + }, + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/PaperSurface.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/PaperSurface.kt new file mode 100644 index 0000000..d77992e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/PaperSurface.kt @@ -0,0 +1,61 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +/** + * The base surface for large areas of the app (screen backgrounds, cards). + * Paints [MaterialTheme]'s surface color plus a very faint, deterministic + * paper-grain speckle so big flat areas read as paper rather than a plain + * digital fill. The grain is intentionally subtle — this is restrained, not + * skeuomorphic parchment. + */ +@Composable +fun PaperSurface( + modifier: Modifier = Modifier, + grain: Boolean = true, + contentPadding: Dp = 0.dp, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .then(if (grain) Modifier.paperGrain() else Modifier) + .padding(contentPadding), + ) { + content() + } +} + +/** Deterministic speckle grain, cheap enough to redraw every frame. */ +private fun Modifier.paperGrain(): Modifier = drawWithCache { + val random = Random(seed = 42) + val speckleCount = ((size.width * size.height) / 9000f).toInt().coerceIn(24, 900) + val speckles = List(speckleCount) { + Offset(random.nextFloat() * size.width, random.nextFloat() * size.height) to + (0.35f + random.nextFloat() * 0.5f) + } + onDrawWithContent { + drawContent() + drawIntoCanvas { + for ((offset, radiusScale) in speckles) { + drawCircle( + color = Color.Black.copy(alpha = 0.018f * radiusScale), + radius = 1.1f * radiusScale, + center = offset, + ) + } + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt new file mode 100644 index 0000000..2e477b7 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt @@ -0,0 +1,100 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +/** + * Presentation-only sync state for [SyncStatusBar]. The real sync engine + * (data.repo.SyncEngine) maps its richer state into this — never the other + * way around. Per SPEC: sync failure is a quiet status line, never a dialog. + */ +enum class SyncStatus { + Synced, + Syncing, + Offline, + Error, +} + +/** + * A slim, quiet status line — never a blocking banner or dialog. Sits at the + * bottom of [BookshelfScaffold] screens. + */ +@Composable +fun SyncStatusBar( + status: SyncStatus, + label: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusDot(status) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun StatusDot(status: SyncStatus) { + val color = when (status) { + SyncStatus.Synced -> MaterialTheme.colorScheme.secondary + SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary + SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant + SyncStatus.Error -> MaterialTheme.colorScheme.error + } + if (status == SyncStatus.Syncing) { + val transition = rememberInfiniteTransition(label = "sync-pulse") + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(700, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "sync-pulse-alpha", + ) + Dot(color = color, modifier = Modifier.graphicsLayer { this.alpha = alpha }) + } else { + Dot(color = color) + } +} + +@Composable +private fun Dot(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/theme/Color.kt b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Color.kt new file mode 100644 index 0000000..47cec4e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Color.kt @@ -0,0 +1,57 @@ +package org.modg.bookshelf.ui.theme + +import androidx.compose.ui.graphics.Color + +// Palette straight from docs/SPEC.md — "feels like books": warm paper, dark +// mahogany, gold + silver metallics. Do not add colors outside this family; +// if a new role is needed, derive it from these rather than inventing a hue. + +// ---- Light ---- +val PaperLight = Color(0xFFF5EDE0) +val PaperAltLight = Color(0xFFEDE3D2) +val InkLight = Color(0xFF2B211A) +val InkSoftLight = Color(0xFF5A4A3D) +val MahoganyLight = Color(0xFF5C2E23) +val MahoganyDeepLight = Color(0xFF3E1E17) +val GoldLight = Color(0xFFC0932F) +val GoldSoftLight = Color(0xFFD9B45B) +val SilverLight = Color(0xFF9CA3AF) +val SilverSoftLight = Color(0xFFC7CCD1) +val ErrorLight = Color(0xFF8C3B2E) +val ErrorContainerLight = Color(0xFFF2D9D1) +val OnErrorContainerLight = Color(0xFF4A190F) + +// Extra surface tones derived from PaperLight/PaperAltLight for the M3 +// surface-container ladder (introduced after the base M3 ColorScheme; these +// keep every elevation looking like a shade of paper, never grey). +val SurfaceDimLight = Color(0xFFE4D8C5) +val SurfaceBrightLight = Color(0xFFFBF6EC) +val SurfaceContainerLowestLight = Color(0xFFFFFFFF) +val SurfaceContainerLowLight = Color(0xFFF1E7D7) +val SurfaceContainerLight = Color(0xFFEDE3D2) +val SurfaceContainerHighLight = Color(0xFFE7DAC5) +val SurfaceContainerHighestLight = Color(0xFFE0D0B7) + +// ---- Dark ---- +val GroundDark = Color(0xFF1C1411) +val SurfaceDark = Color(0xFF241A15) +val PaperTextDark = Color(0xFFE8DCC8) +val MahoganyDark = Color(0xFF7A3E2F) +val MahoganyDeepDark = Color(0xFF3E1E17) +val GoldDark = Color(0xFFD9B45B) +val SilverDark = Color(0xFFC7CCD1) +val OnSurfaceVariantDark = Color(0xFFC7B9A3) +val SurfaceVariantDark = Color(0xFF2E2119) +val OutlineDark = Color(0xFF6B6F74) +val OutlineVariantDark = Color(0xFF3A3D40) +val ErrorDark = Color(0xFFC86A57) +val ErrorContainerDark = Color(0xFF4A2620) +val OnErrorContainerDark = Color(0xFFF2D9D1) + +val SurfaceDimDark = Color(0xFF1C1411) +val SurfaceBrightDark = Color(0xFF3A2C22) +val SurfaceContainerLowestDark = Color(0xFF140D0B) +val SurfaceContainerLowDark = Color(0xFF211712) +val SurfaceContainerDark = Color(0xFF271C16) +val SurfaceContainerHighDark = Color(0xFF32241C) +val SurfaceContainerHighestDark = Color(0xFF3D2C22) diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/theme/Theme.kt b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Theme.kt new file mode 100644 index 0000000..ed9e84e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Theme.kt @@ -0,0 +1,104 @@ +package org.modg.bookshelf.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val LightColors = lightColorScheme( + primary = MahoganyLight, + onPrimary = PaperLight, + primaryContainer = MahoganyDeepLight, + onPrimaryContainer = PaperLight, + secondary = GoldLight, + onSecondary = InkLight, + secondaryContainer = GoldSoftLight, + onSecondaryContainer = MahoganyDeepLight, + tertiary = SilverLight, + onTertiary = InkLight, + tertiaryContainer = SilverSoftLight, + onTertiaryContainer = InkLight, + error = ErrorLight, + onError = PaperLight, + errorContainer = ErrorContainerLight, + onErrorContainer = OnErrorContainerLight, + background = PaperLight, + onBackground = InkLight, + surface = PaperLight, + onSurface = InkLight, + surfaceVariant = PaperAltLight, + onSurfaceVariant = InkSoftLight, + outline = SilverLight, + outlineVariant = SilverSoftLight, + scrim = InkLight, + inverseSurface = InkLight, + inverseOnSurface = PaperLight, + inversePrimary = GoldSoftLight, + surfaceTint = MahoganyLight, + surfaceDim = SurfaceDimLight, + surfaceBright = SurfaceBrightLight, + surfaceContainerLowest = SurfaceContainerLowestLight, + surfaceContainerLow = SurfaceContainerLowLight, + surfaceContainer = SurfaceContainerLight, + surfaceContainerHigh = SurfaceContainerHighLight, + surfaceContainerHighest = SurfaceContainerHighestLight, +) + +private val DarkColors = darkColorScheme( + primary = MahoganyDark, + onPrimary = PaperTextDark, + primaryContainer = MahoganyDeepDark, + onPrimaryContainer = PaperTextDark, + secondary = GoldDark, + onSecondary = GroundDark, + secondaryContainer = Color(0xFF4A3820), + onSecondaryContainer = GoldDark, + tertiary = SilverDark, + onTertiary = GroundDark, + tertiaryContainer = Color(0xFF3A3F45), + onTertiaryContainer = SilverDark, + error = ErrorDark, + onError = GroundDark, + errorContainer = ErrorContainerDark, + onErrorContainer = OnErrorContainerDark, + background = GroundDark, + onBackground = PaperTextDark, + surface = SurfaceDark, + onSurface = PaperTextDark, + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = OnSurfaceVariantDark, + outline = OutlineDark, + outlineVariant = OutlineVariantDark, + scrim = GroundDark, + inverseSurface = PaperLight, + inverseOnSurface = InkLight, + inversePrimary = MahoganyLight, + surfaceTint = MahoganyDark, + surfaceDim = SurfaceDimDark, + surfaceBright = SurfaceBrightDark, + surfaceContainerLowest = SurfaceContainerLowestDark, + surfaceContainerLow = SurfaceContainerLowDark, + surfaceContainer = SurfaceContainerDark, + surfaceContainerHigh = SurfaceContainerHighDark, + surfaceContainerHighest = SurfaceContainerHighestDark, +) + +/** + * Bookshelf's Material3 theme. Dynamic color is intentionally never wired up + * here — a wallpaper-derived palette would fight the warm-paper-and-mahogany + * identity the SPEC calls for, so there is no dynamicColor parameter at all. + */ +@Composable +fun BookshelfTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) DarkColors else LightColors + MaterialTheme( + colorScheme = colorScheme, + typography = BookshelfTypography, + content = content, + ) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/theme/Type.kt b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Type.kt new file mode 100644 index 0000000..4817ada --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/theme/Type.kt @@ -0,0 +1,132 @@ +package org.modg.bookshelf.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import org.modg.bookshelf.R + +// Literata (OFL, bundled in res/font — see LICENSE-Literata-OFL.txt) is the +// serif voice for anything that reads as a title or heading: it's what makes +// this app feel like a shelf of books rather than a generic list app. Body +// and UI copy stay on the system sans so long paragraphs (descriptions, +// notes) stay easy to read at small sizes. +val Literata = FontFamily( + Font(R.font.literata_regular, FontWeight.Normal), + Font(R.font.literata_italic, FontWeight.Normal, FontStyle.Italic), + Font(R.font.literata_medium, FontWeight.Medium), + Font(R.font.literata_medium_italic, FontWeight.Medium, FontStyle.Italic), + Font(R.font.literata_semibold, FontWeight.SemiBold), + Font(R.font.literata_semibold_italic, FontWeight.SemiBold, FontStyle.Italic), + Font(R.font.literata_bold, FontWeight.Bold), + Font(R.font.literata_bold_italic, FontWeight.Bold, FontStyle.Italic), +) + +val SystemSans = FontFamily.Default + +// Generous line-height throughout per SPEC ("Generous line-height"). +val BookshelfTypography = Typography( + displayLarge = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.SemiBold, + fontSize = 57.sp, + lineHeight = 68.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.SemiBold, + fontSize = 45.sp, + lineHeight = 56.sp, + ), + displaySmall = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 36.sp, + lineHeight = 46.sp, + ), + headlineLarge = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 32.sp, + lineHeight = 42.sp, + ), + headlineMedium = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 28.sp, + lineHeight = 38.sp, + ), + headlineSmall = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 24.sp, + lineHeight = 34.sp, + ), + titleLarge = TextStyle( + // Topbar / screen titles — the "serif title" the scaffold calls for. + fontFamily = Literata, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 30.sp, + ), + titleMedium = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 18.sp, + lineHeight = 26.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = Literata, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 26.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 22.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 18.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 18.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = SystemSans, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/app/app/src/main/res/drawable/ic_launcher_background.xml b/app/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b62e2ff --- /dev/null +++ b/app/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..aa6ba11 --- /dev/null +++ b/app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/app/src/main/res/font/literata_bold.ttf b/app/app/src/main/res/font/literata_bold.ttf new file mode 100644 index 0000000..d39f653 Binary files /dev/null and b/app/app/src/main/res/font/literata_bold.ttf differ diff --git a/app/app/src/main/res/font/literata_bold_italic.ttf b/app/app/src/main/res/font/literata_bold_italic.ttf new file mode 100644 index 0000000..9f69c33 Binary files /dev/null and b/app/app/src/main/res/font/literata_bold_italic.ttf differ diff --git a/app/app/src/main/res/font/literata_italic.ttf b/app/app/src/main/res/font/literata_italic.ttf new file mode 100644 index 0000000..ebb050c Binary files /dev/null and b/app/app/src/main/res/font/literata_italic.ttf differ diff --git a/app/app/src/main/res/font/literata_medium.ttf b/app/app/src/main/res/font/literata_medium.ttf new file mode 100644 index 0000000..937867a Binary files /dev/null and b/app/app/src/main/res/font/literata_medium.ttf differ diff --git a/app/app/src/main/res/font/literata_medium_italic.ttf b/app/app/src/main/res/font/literata_medium_italic.ttf new file mode 100644 index 0000000..fb84512 Binary files /dev/null and b/app/app/src/main/res/font/literata_medium_italic.ttf differ diff --git a/app/app/src/main/res/font/literata_regular.ttf b/app/app/src/main/res/font/literata_regular.ttf new file mode 100644 index 0000000..b0e7f7a Binary files /dev/null and b/app/app/src/main/res/font/literata_regular.ttf differ diff --git a/app/app/src/main/res/font/literata_semibold.ttf b/app/app/src/main/res/font/literata_semibold.ttf new file mode 100644 index 0000000..bcf3211 Binary files /dev/null and b/app/app/src/main/res/font/literata_semibold.ttf differ diff --git a/app/app/src/main/res/font/literata_semibold_italic.ttf b/app/app/src/main/res/font/literata_semibold_italic.ttf new file mode 100644 index 0000000..d49c441 Binary files /dev/null and b/app/app/src/main/res/font/literata_semibold_italic.ttf differ diff --git a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/app/src/main/res/values-night/themes.xml b/app/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..17260da --- /dev/null +++ b/app/app/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/app/src/main/res/values/colors.xml b/app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c741c90 --- /dev/null +++ b/app/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #F5EDE0 + #1C1411 + diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5d788fc --- /dev/null +++ b/app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Bookshelf + diff --git a/app/app/src/main/res/values/themes.xml b/app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..aab3e01 --- /dev/null +++ b/app/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/components/ComponentGalleryPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/components/ComponentGalleryPaparazziTest.kt new file mode 100644 index 0000000..d3a6ea4 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/components/ComponentGalleryPaparazziTest.kt @@ -0,0 +1,114 @@ +package org.modg.bookshelf.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import org.junit.Rule +import org.junit.Test +import org.modg.bookshelf.ui.theme.BookshelfTheme + +/** + * Renders the wave-2 component set to PNG on the JVM (no device/emulator). + * Every component gets a light and a dark snapshot so both themes required + * by the SPEC are actually exercised, not just assumed to work. + * + * Run `./gradlew recordPaparazziDebug` to (re)record golden PNGs under + * src/test/snapshots, or `./gradlew verifyPaparazziDebug` to diff against them. + */ +class ComponentGalleryPaparazziTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6) + + @Test + fun scaffoldAndDividerLight() = snapshotBothThemes("scaffold") { + BookshelfScaffold( + title = "Bookshelf", + syncStatusBar = { SyncStatusBar(status = SyncStatus.Synced, label = "Synced") }, + ) { padding -> + PaperSurface(modifier = Modifier.fillMaxWidth().padding(padding)) { + Column(modifier = Modifier.padding(16.dp)) { + Text("A page of paper, with a gold rule below the title bar.") + GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) + Text("Restrained, not skeuomorphic.") + } + } + } + } + + // Only the "no coverUrl" path is snapshotted here: it's the one + // deterministic BookCover state (no network I/O). The loading/error + // states are real code paths (see BookCover.kt's CoverPlaceholder / + // AsyncImagePainter.State.Error branch) but depend on an async Coil + // network load settling inside a single synchronous Paparazzi frame, + // which isn't reliable on a sandboxed JVM test — see BUILD_NOTES.md. + @Test + fun bookCoverPlaceholder() = snapshotBothThemes("book-cover") { + androidx.compose.foundation.layout.Row( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + BookCover( + coverUrl = null, + contentDescription = "No cover on file", + modifier = Modifier.weight(1f), + ) + BookCover( + coverUrl = null, + contentDescription = "Another shelf slot", + modifier = Modifier.weight(1f), + ) + } + } + + @Test + fun buttons() = snapshotBothThemes("buttons") { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + PrimaryButton(text = "Save", onClick = {}) + SecondaryButton(text = "Skip", onClick = {}) + PrimaryButton(text = "Disabled", onClick = {}, enabled = false) + } + } + + @Test + fun emptyState() = snapshotBothThemes("empty-state") { + EmptyState( + title = "Your shelves are empty", + message = "Scan a barcode to add your first book.", + action = { PrimaryButton(text = "Scan a book", onClick = {}) }, + ) + } + + @Test + fun syncStatusBarStates() = snapshotBothThemes("sync-status-bar") { + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + SyncStatusBar(status = SyncStatus.Synced, label = "Synced • 2m ago") + SyncStatusBar(status = SyncStatus.Syncing, label = "Syncing…") + SyncStatusBar(status = SyncStatus.Offline, label = "Offline — will sync later") + SyncStatusBar(status = SyncStatus.Error, label = "Couldn't reach server") + } + } + + /** Snapshots [content] once under the light theme and once under dark. */ + private fun snapshotBothThemes( + name: String, + content: @androidx.compose.runtime.Composable () -> Unit, + ) { + paparazzi.snapshot(name = "$name-light") { + BookshelfTheme(darkTheme = false) { + PaperSurface { content() } + } + } + paparazzi.snapshot(name = "$name-dark") { + BookshelfTheme(darkTheme = true) { + PaperSurface { content() } + } + } + } +} diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png new file mode 100644 index 0000000..6897a58 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png new file mode 100644 index 0000000..2e4db3a Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-dark.png new file mode 100644 index 0000000..6fed0b8 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-light.png new file mode 100644 index 0000000..67a25ca Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_buttons_buttons-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-dark.png new file mode 100644 index 0000000..947c0d7 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-light.png new file mode 100644 index 0000000..efeff82 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_emptyState_empty-state-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png new file mode 100644 index 0000000..59dfd7f Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png new file mode 100644 index 0000000..29f9bfe Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png new file mode 100644 index 0000000..d0ea6c0 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png new file mode 100644 index 0000000..e2e7606 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f252542 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,20 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +// +// AGP 9 has Kotlin support BUILT IN (the org.jetbrains.kotlin.android plugin no +// longer exists / must not be applied). AGP's built-in Kotlin defaults to bundling +// its own Kotlin Gradle Plugin version; bump it here to the version we actually +// want (must match the Compose/serialization plugin versions below). +buildscript { + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin:2.3.11") + } +} + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.paparazzi) apply false +} diff --git a/app/gradle.properties b/app/gradle.properties new file mode 100644 index 0000000..4b031b9 --- /dev/null +++ b/app/gradle.properties @@ -0,0 +1,13 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX +android.useAndroidX=true + +# Kotlin +kotlin.code.style=official + +# Compose (dynamic color intentionally off, see ui.theme) +android.nonTransitiveRClass=true diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml new file mode 100644 index 0000000..439e46e --- /dev/null +++ b/app/gradle/libs.versions.toml @@ -0,0 +1,83 @@ +[versions] +agp = "9.1.1" +kotlin = "2.3.21" +ksp = "2.3.11" +composeBom = "2026.08.00" +coreKtx = "1.19.0" +activityCompose = "1.13.0" +lifecycle = "2.11.0" +navigationCompose = "2.10.0" +room = "2.8.4" +retrofit = "3.0.0" +retrofitKotlinxSerializationConverter = "1.0.0" +okhttp = "5.5.0" +kotlinxSerializationJson = "1.11.0" +kotlinxCoroutines = "1.11.0" +coil = "3.6.2" +cameraX = "1.6.2" +mlkitBarcodeScanning = "17.3.0" +workManager = "2.11.2" +datastorePreferences = "1.2.1" +accompanistPermissions = "0.37.3" +paparazzi = "2.0.0-alpha05" +junit = "4.13.2" +robolectric = "4.16.1" +androidxTestCore = "1.7.0" +androidxTestExtJunit = "1.3.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } + +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" } + +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" } + +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/app/gradlew b/app/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/app/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/app/gradlew.bat b/app/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/app/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts new file mode 100644 index 0000000..9626838 --- /dev/null +++ b/app/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Bookshelf" +include(":app") diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..faae4af --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,110 @@ +# Bookshelf — session handoff +Written 2026-09-06 by the Opus orchestrator, after a sprite restart forced a fresh session. + +## Read these first, in order +1. `docs/SPEC.md` — the authoritative product/technical contract. Unchanged and still correct. + Every worker prompt must point at it. Do not restate it; do not let it drift. +2. This file — operational state, what's done, what bit us, what's next. + +## Operating model (the user explicitly asked for this — keep it) +The user is on the **$20/mo Pro plan** and wants Opus used sparingly. +- **Opus = orchestrator only.** Write specs, launch workers, verify results, decide. + Do NOT write app code yourself. Do NOT read large files into Opus context. +- **Sonnet = all implementation**, via `claude -p` (NOT the Agent tool — the user asked + for `claude -p` specifically, and it keeps worker output out of the orchestrator's context). +- Read worker output via `logs/.summary` / `jq -r '.result'`, never by cat-ing source. +- Quota status: user reported **62% of the 5h window consumed** at ~19:45 on 09-05. + Worker A alone cost **$2.40 / 85 turns**. Budget accordingly; prefer resuming a + session over restarting one. + +### How to launch a worker +``` +cd ~/bookshelf && nohup ./tasks/run-task.sh ./tasks/.txt >/dev/null 2>&1 & +``` +`tasks/run-task.sh` is quota-aware: on a usage-limit error it sleeps `POLL` (600s) and +resumes the SAME session rather than restarting, up to `MAX_WALL` (24h), and does not +count quota waits against its 3-strike hard-failure budget. It writes: +`logs/.json` (final result), `.err`, `.sid` (session id), `.state` (progress), `.summary`. + +**HAZARD — do not repeat:** never edit `run-task.sh` while workers are running. Bash reads +scripts by byte offset; swapping the file mid-run makes live workers resume inside unrelated +code and can spawn duplicate `claude` processes that burn quota on finished work. If you must +change it, write a NEW file and use that for the next wave. + +## Environment +- JDK 21: `~/toolchain/jdk21` (system java is 25 — too new for AGP, do not use it) +- Android SDK: `~/toolchain/android-sdk` (platforms;android-37.0, build-tools;37.0.0, platform-tools) +- **No KVM, no emulator.** Verify only via `./gradlew assembleDebug`, JVM unit tests, and + Paparazzi PNG rendering. Never claim the app was "run". +- PocketBase v0.40.2 service, **127.0.0.1:8090, deliberately NOT internet-exposed** + (no `--http-port`, so the sprite proxy can't reach it). Restart: + `sprite-env services restart pocketbase`. Logs: `/.sprite/logs/services/pocketbase.log`. +- Superuser creds: `server/.dev-credentials` (gitignored). + +## STATE: what is DONE +### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported) +`server/` contains `setup-schema.sh` (idempotent), `create-user.sh`, `pb_hooks/main.pb.js`, +`pb_migrations/`, `deploy/` (systemd unit, Dockerfile, compose, backup.sh, README covering +Tailscale vs port-forward+Caddy), `README.md`, `.gitignore`. + +Independently re-verified on 09-06 after fixing the service: +| Check | Result | +|---|---| +| anonymous LIST books/shelves/bookcases | **403 / 403 / 403** | +| anonymous self-registration | **403** | +| `/api/health` | 200 | + +**`pb_hooks/main.pb.js` is INTENTIONAL, not scope drift.** PocketBase's `listRule` is a row +filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces +403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary. + +## STATE: what is NOT done +### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart) +Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`, +`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license. +Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity, +Paparazzi setup, and **any evidence the build compiles**. + +**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):** +`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \` +` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b` +with a "continue where you left off, don't restart" prompt. (Worker A's session, for +reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.) +First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything +downstream is blocked on it. + +### Waves 2-4 — not started. Prompts not yet written. +- **Wave 2 (parallel, after 1B is green):** + - C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, + `SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore. + - D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation, + CameraX + ML Kit continuous scanning. +- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings). +- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore + + signed APK, top-level README, end-to-end sync test against the live PocketBase. + +## Gotchas already paid for — do not rediscover +1. **Migration filename ↔ `_migrations` desync.** Worker A renamed `1788636563_created_books.js` + to `...564...` to fix an alphabetical-replay ordering bug (`books` sorted before `shelves`, + breaking the relation). Correct for fresh instances, but the dev DB still had the old name + recorded applied, so PocketBase tried to re-create `books` and crash-looped 9 times. + Fixed via `UPDATE _migrations SET file=...`. **If you ever rename a migration, update that + table too.** DB backup: scratchpad `data.db.bak`. +2. Rule semantics: in PocketBase `""` means PUBLIC, `null` means superuser-only. Confusing these + is exactly how the library would end up world-readable. +3. `claude -p --output-format json` writes its log only at exit; a 0-byte `.json` means the + worker is still running or was killed, not that it failed. +4. System JDK is 25 and will break AGP. Workers must export `JAVA_HOME=~/toolchain/jdk21` + (run-task.sh already does). + +## Verification standard (hold workers to this) +Workers self-report optimistically. Before accepting any wave: +- Re-run the security curls above yourself. The user's stated requirement is that this not be + "accessible to everyone in the world"; that check is non-negotiable and cheap. +- Require `assembleDebug` + `test` exit 0, and confirm artifacts exist on disk. +- Treat "I couldn't get Paparazzi working so I skipped screenshots" as a finding to report to + the user, not something to paper over — the user explicitly cares how this looks. + +## Open questions for the user (not yet asked — deferred, not forgotten) +- Where the server will actually live (home box vs a sprite) — only affects the deploy README. +- Their two account emails, for `create-user.sh`. Not needed until the app can log in. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..774fc2e --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,137 @@ +# Bookshelf — authoritative spec + +Two-person shared home library. Android app + self-hosted PocketBase. +ALL workers must follow this exactly. Do not invent alternative names. + +## Non-negotiables +- Offline-first. Home server is often unreachable (residential NAT). Every read + comes from Room. Every write lands in Room first, syncs later. No screen may + block on network. +- Private. No public registration. Auth required for all data access. +- Server URL is NOT hardcoded; user enters it on first run. + +## Repo layout +~/bookshelf/ + server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/ + app/ Android Gradle project + docs/ this spec + +## Android +- applicationId/namespace: org.modg.bookshelf +- minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3 +- SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21 +- DI: manual `AppContainer` held by Application. NO Hilt/kapt. Room uses KSP. +- Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter, + okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view), + com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences, + navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual) + +## Package structure (org.modg.bookshelf.*) + data.local Room: entities, daos, BookshelfDatabase, Converters + data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor + data.metadata OpenLibrary + GoogleBooks lookup + data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository + data.prefs SettingsStore (DataStore) + ui.theme Color/Type/Theme + ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup + ui.nav BookshelfNavHost + +## Data model — Room mirrors PocketBase 1:1 +IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records +(PocketBase accepts client-supplied ids on create). Never remap ids after push. + +BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher, + publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl, + shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long, + syncState:SyncState, localCoverPath:String?) +BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState) +ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState) + +enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE } + +All queries filter `deleted = 0`. Deletion is ALWAYS soft (tombstone) so sync can +propagate it and nothing is silently lost from a shared library. + +## PocketBase schema (collections) +bookcases: name(text,req), note(text), position(number), deleted(bool) +shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req), + position(number), deleted(bool) +books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text), + publisher(text), published_date(text), page_count(number), description(text), + cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450), + cover_source_url(text), shelf(relation->shelves,maxSelect 1), + notes(text), added_by(relation->users,maxSelect 1), deleted(bool) +All three get autodate created/updated. +Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated). + +API rules — all of list/view/create/update/delete on the three collections: + "@request.auth.id != \"\"" +users collection: createRule = null (SUPERUSER ONLY — this is what keeps the + world out), listRule/viewRule = "@request.auth.id != \"\"", + updateRule = "id = @request.auth.id", deleteRule = null. +NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse. + +## Sync design (SyncEngine) +Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated + &perPage=200&page=N — paginate to exhaustion. Cursor per collection in + DataStore, stored as PB UTC string. Include tombstones. +Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id), + PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}. + On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH. +Order: push THEN pull (so our writes come back canonical). +Conflict: last-write-wins on `updated`. Document this in README; do not build + anything cleverer. +Covers: on save, app downloads cover from metadata source and multipart-uploads it + to the book's `cover` file field, so covers survive upstream URL rot. If offline, + store localCoverPath and upload on next sync. +Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained). +Never let sync failure surface as a crash or a blocking dialog — a quiet status line only. + +## Book metadata lookup +Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data +Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key) +Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2) +Merge: prefer whichever has a title; fill blanks from the other. Return null if both miss, +and the UI must then offer manual entry pre-filled with the scanned ISBN. + +## Barcode scanning +CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A). +Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats. +Continuous mode: after a save, stay on camera for the next book (shelving a box of +books is the real use case). Show a running "added this session" count. +Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch. + +## Design language — "feels like books" +Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic. +Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D, + mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B, + silver #9CA3AF, silverSoft #C7CCD1 +Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F, + gold #D9B45B, silver #C7CCD1 +Type: serif display (Literata, OFL, bundle the TTF) for titles/headers; + system sans for body/UI. Generous line-height. +Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain + on large surfaces. Covers are the hero — let them carry the color. +Both light and dark themes required. Dynamic color OFF (it would fight the palette). + +## Screens +setup First run: server URL (+ https scheme validation, trailing-slash strip, + reachability probe), email, password. Clear errors for wrong URL vs bad creds. +library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by + bookcase/shelf. Sort title/author/added. Empty state invites first scan. + FAB -> scan. Sync status line. +detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description + (collapsible), notes (editable), location picker, edit, soft-delete w/ undo. +scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker + + Save / Skip. Duplicate-ISBN warning if already owned. +locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf. + Tap a shelf -> library filtered to it. "Move books" bulk action. +settings Server, account, sign out, manual sync + last-sync time, book/cover counts. + +## Quality bar +- No emulator on this box (no KVM). Verify via: `./gradlew assembleDebug`, + JVM unit tests, and Paparazzi screenshot rendering. +- Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution, + DAO queries (Robolectric). Do not write assertion-free tests. +- App must compile and run with NO server configured (setup screen) and must not + crash when the server is unreachable. diff --git a/logs/A-server.sid b/logs/A-server.sid new file mode 100644 index 0000000..c52848c --- /dev/null +++ b/logs/A-server.sid @@ -0,0 +1 @@ +5e3bd183-252c-4224-99b5-91779761ccbc diff --git a/logs/A-server.txt b/logs/A-server.txt new file mode 100644 index 0000000..e69de29 diff --git a/logs/B-scaffold.sid b/logs/B-scaffold.sid new file mode 100644 index 0000000..b500a39 --- /dev/null +++ b/logs/B-scaffold.sid @@ -0,0 +1 @@ +6823e72a-69c1-486e-ae5a-18abab84529b diff --git a/logs/B-scaffold.txt b/logs/B-scaffold.txt new file mode 100644 index 0000000..e69de29 diff --git a/logs/recovered-sids.txt b/logs/recovered-sids.txt new file mode 100644 index 0000000..2c7aa58 --- /dev/null +++ b/logs/recovered-sids.txt @@ -0,0 +1,2 @@ +A-server=5e3bd183-252c-4224-99b5-91779761ccbc +B-scaffold=6823e72a-69c1-486e-ae5a-18abab84529b diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..8c3a228 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,14 @@ +# Superuser credentials for the dev instance — never commit these. +.dev-credentials + +# The PocketBase binary is platform-specific and large; fetch/build it +# per docs/SPEC.md and deploy/README.md instead of vendoring it in git. +pocketbase +pocketbase.exe + +# Runtime data — the whole point of pb_migrations/ is that this is +# reproducible from scratch; the actual database/uploads are not source. +pb_data/ + +# Local backup artifacts produced by deploy/backup.sh. +deploy/backups/ diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md new file mode 100644 index 0000000..c001b79 --- /dev/null +++ b/server/CHANGELOG.md @@ -0,0 +1,56 @@ +## v0.40.2 + +- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass. + +- Fixed collection index parsing error for indexes with missing name. + +- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_. + +- Fixed linter warnings and comment typos. + +- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_. + +- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1). + + +## v0.40.1 + +- Fixes for some reported regressions related to the `encoding/json/v2` update: + - allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814)) + - fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815)) + + +## v0.40.0 + +- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_. + _⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._ + _Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._ + +- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided. + +- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers. + _This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._ + +- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1). + +- Added `Store.Keys()` method that returns a slice with all of the store keys. + +- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting. + +- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB). + _This is an extra precaution for the cases when logging user supplied data without validating it beforehand._ + _If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_ + _Additionally, for just in case the log message is also truncated at max 8k characters._ + +- Added new `filesystem` low-level helper methods: + - `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value. + - `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_. + - `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_. + +- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)). + +- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive). + +- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package. + _⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._ + _I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._ diff --git a/server/LICENSE.md b/server/LICENSE.md new file mode 100644 index 0000000..e3b8465 --- /dev/null +++ b/server/LICENSE.md @@ -0,0 +1,17 @@ +The MIT License (MIT) +Copyright (c) 2022 - present, Gani Georgiev + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..543642c --- /dev/null +++ b/server/README.md @@ -0,0 +1,81 @@ +# Bookshelf server + +PocketBase backend for the Bookshelf app — see `../docs/SPEC.md` for the +authoritative data model and API rules. This directory contains everything +needed to provision and run it. + +## Layout + +``` +server/ + pocketbase PocketBase v0.40.2 binary (gitignored — platform-specific) + pb_data/ SQLite databases + uploaded files (gitignored — runtime state) + pb_migrations/ Schema history, auto-captured — a fresh instance replays + these on first boot and ends up with the full schema + pb_hooks/ JS hook that closes a PocketBase quirk (see below) + setup-schema.sh Idempotent schema/rules provisioning (curl + jq only) + create-user.sh Superuser-driven account creation (no public sign-up) + deploy/ systemd unit, Docker, backups, remote-access guidance +``` + +## Quickstart (local dev) + +PocketBase must already be running (see `deploy/README.md` for how to run +it as a proper service; for a quick local check you can just run the binary +directly: `./pocketbase serve`). + +```sh +# 1. Provision the schema + API rules (safe to re-run). +./setup-schema.sh http://127.0.0.1:8090 + +# 2. Create app accounts — there is no self-registration. +./create-user.sh you@example.com "a strong password" "Your Name" +``` + +Both scripts also read `PB_URL`/`PB_EMAIL`/`PB_PASS` from the environment, +or fall back to `.dev-credentials` (gitignored, dev-instance-only — see +`.dev-credentials` in this directory if present) if no args are given. + +## Schema summary + +Three collections — `bookcases`, `shelves`, `books` — each requiring +authentication for every action (list/view/create/update/delete). Deletion +in the app is always a soft `deleted` flag (tombstone), never an actual +record delete, so sync can propagate it — see `SyncEngine` in the Android +app and `docs/SPEC.md`'s Sync design section. + +The built-in `users` collection is locked down: `createRule = null` means +**only a superuser can create an account** (via `create-user.sh`); regular +users can view any user (needed to resolve the `added_by` relation) and +update only their own record. + +## The pb_hooks quirk + +PocketBase's declarative list/search rule acts as a row-level SQL filter, +not a hard gate: an unauthenticated request against a collection whose +`listRule` requires auth still gets **`200 OK` with an empty result**, +rather than an error — because the rule can't be cleanly separated into +"deny the whole request" vs. "just don't return matching rows" (see +[pocketbase/pocketbase#6492](https://github.com/pocketbase/pocketbase/discussions/6492)). +No private data ever leaks this way, but a `200` is still the wrong signal +for "you're not allowed here." `pb_hooks/main.pb.js` adds a small +`onRecordsListRequest` hook that turns that into a `403` for the three app +collections. It's plain JS, auto-loaded by the stock `pocketbase` binary — +no recompilation, no framework — so it ships and deploys exactly like +`pb_migrations/`. + +## Verifying a deployment + +```sh +# Anonymous requests must be rejected (4xx) for all three collections: +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/collections/books/records +# -> 403 + +# Self-registration must be rejected: +curl -s -X POST http://127.0.0.1:8090/api/collections/users/records \ + -H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}' +# -> 403 "Only superusers can perform this action." +``` + +See `deploy/README.md` for running the server long-term, reaching it from +outside your home network, and backups. diff --git a/server/create-user.sh b/server/create-user.sh new file mode 100755 index 0000000..a69eae1 --- /dev/null +++ b/server/create-user.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# create-user.sh — superuser-driven account creation for Bookshelf. +# +# There is no self-registration path (users.createRule = null, see +# setup-schema.sh), so the only way to create an app account is for the +# server owner to run this script as the superuser. Intended for the two +# household accounts (owner + spouse), but works for any number of users. +# +# Usage: +# ./create-user.sh [name] +# PB_URL=https://bookshelf.example.com ./create-user.sh alice@example.com "correct horse battery staple" Alice +# +# PB_URL, PB_EMAIL (superuser), PB_PASS (superuser) come from env, or fall +# back to ./.dev-credentials, same as setup-schema.sh. + +set -u -o pipefail + +NEW_EMAIL="${1:-}" +NEW_PASS="${2:-}" +NEW_NAME="${3:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CREDS_FILE="$SCRIPT_DIR/.dev-credentials" + +log() { printf '%s\n' "$*" >&2; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || fail "curl is required but not installed." +command -v jq >/dev/null 2>&1 || fail "jq is required but not installed." + +if [[ -z "$NEW_EMAIL" || -z "$NEW_PASS" ]]; then + fail "Usage: $0 [name]" +fi +if [[ ${#NEW_PASS} -lt 8 ]]; then + fail "Password must be at least 8 characters (PocketBase minimum)." +fi + +PB_URL="${PB_URL:-http://127.0.0.1:8090}" +PB_URL="${PB_URL%/}" +PB_EMAIL="${PB_EMAIL:-}" +PB_PASS="${PB_PASS:-}" + +if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then + if [[ -f "$CREDS_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CREDS_FILE" + PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}" + PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}" + fi +fi +[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Set PB_EMAIL or provide $CREDS_FILE." +[[ -n "$PB_PASS" ]] || fail "No superuser password given. Set PB_PASS or provide $CREDS_FILE." + +log "Creating Bookshelf account on $PB_URL" +log " Email: $NEW_EMAIL" + +AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")" +AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')" +AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)" +[[ "$AUTH_CODE" == "200" ]] || fail "Superuser login failed (HTTP $AUTH_CODE): $AUTH_BODY" +TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')" + +PAYLOAD="$(jq -n \ + --arg email "$NEW_EMAIL" \ + --arg password "$NEW_PASS" \ + --arg name "$NEW_NAME" \ + '{email:$email, password:$password, passwordConfirm:$password, name:$name, emailVisibility:true, verified:true}')" + +CREATE_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/users/records" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")" +CREATE_BODY="$(printf '%s' "$CREATE_RESP" | sed '$d')" +CREATE_CODE="$(printf '%s' "$CREATE_RESP" | tail -n1)" + +if [[ "$CREATE_CODE" != "200" ]]; then + fail "Failed to create user (HTTP $CREATE_CODE): $CREATE_BODY" +fi + +USER_ID="$(printf '%s' "$CREATE_BODY" | jq -r '.id')" +ok "Created user '$NEW_EMAIL' (id: $USER_ID)" diff --git a/server/deploy/Dockerfile b/server/deploy/Dockerfile new file mode 100644 index 0000000..c6b8d73 --- /dev/null +++ b/server/deploy/Dockerfile @@ -0,0 +1,30 @@ +# Bookshelf PocketBase server — container image. +# +# The pocketbase binary is already vendored into ./server (this repo does not +# gitignore it out of the image build context — see server/.gitignore, which +# only excludes it from *git*). If you'd rather fetch it fresh, replace the +# COPY below with a curl of the official release for your architecture from +# https://github.com/pocketbase/pocketbase/releases (match the version +# already pinned in server/pocketbase --version, currently v0.40.2). + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /pb + +COPY pocketbase /pb/pocketbase +COPY pb_migrations /pb/pb_migrations +COPY pb_hooks /pb/pb_hooks +RUN chmod +x /pb/pocketbase + +# pb_data is a volume — see docker-compose.yml. Do not bake data into the +# image; it must survive container recreation. +VOLUME /pb/pb_data + +EXPOSE 8090 + +ENTRYPOINT ["/pb/pocketbase"] +CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"] diff --git a/server/deploy/README.md b/server/deploy/README.md new file mode 100644 index 0000000..0870904 --- /dev/null +++ b/server/deploy/README.md @@ -0,0 +1,156 @@ +# Deploying Bookshelf's PocketBase server + +This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi, +or an old laptop) running Linux. Pick **one** of the two run methods below — +systemd or Docker — not both. + +## 0. Get the files onto the server + +Copy the whole `server/` directory (minus `.dev-credentials`, which is +gitignored and dev-only) to the target machine, e.g.: + +```sh +rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/ +``` + +The `pocketbase` binary is architecture-specific — if your home server isn't +the same CPU architecture as wherever you built/downloaded it, grab the +matching build from https://github.com/pocketbase/pocketbase/releases +(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`. + +## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host) + +1. Create a dedicated unprivileged user: + ```sh + sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf + sudo chown -R bookshelf:bookshelf /opt/bookshelf + ``` +2. Install the unit: + ```sh + sudo cp deploy/bookshelf.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now bookshelf + ``` +3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`. +4. Logs: `journalctl -u bookshelf -f`. + +Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not** +reachable from other machines yet. That's intentional; see step 4. + +## 2. Run it — Option B: Docker + +```sh +cd deploy +docker compose up -d --build +docker compose logs -f +``` + +This also only publishes to `127.0.0.1:8090` on the host, for the same +reason. Data persists in the `bookshelf_pb_data` named volume regardless of +container restarts/rebuilds. + +## 3. Provision the schema and accounts + +Once the server is up and answering on `127.0.0.1:8090`, run the setup +scripts from `server/` (one directory up from here): + +```sh +cd /opt/bookshelf + +# If this is a brand-new PocketBase data dir, it prints a one-time setup URL +# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) — +# open that in a browser first to create your superuser account. + +./setup-schema.sh http://127.0.0.1:8090 + +# Then create the household accounts — there is no public sign-up: +./create-user.sh owner@example.com "a strong password" "Owner Name" +./create-user.sh spouse@example.com "a different strong password" "Spouse Name" +``` + +`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after +pulling an updated `server/` if the schema ever changes). + +## 4. Reach it from outside your home network + +The app needs a URL it can hit from anywhere your phone goes — not just your +home wifi. Two real options: + +### Tailscale (recommended) + +Install Tailscale on the home server and on your phone, join both to the +same tailnet. The server gets a stable `100.x.y.z` address (or a +MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from +anywhere, encrypted end-to-end, with **no ports opened on your router**. + +- **Pros:** essentially zero attack surface (nothing is exposed to the + public internet at all — not even a login page), no TLS cert management, + works behind CGNAT, five-minute setup. +- **Cons:** both devices need the Tailscale app installed and signed in; + if Tailscale's coordination service has an outage, *new* connections may + be briefly unable to establish (existing ones keep working) — acceptable + for a two-person home library, not for something needing five-nines. +- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's + encryption makes plain HTTP tolerable *inside the tailnet*, but see the + HTTPS note below — using `https://` via Tailscale Serve, next, is easy + enough to just do). +- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve) + to get automatic HTTPS with a real cert on your tailnet domain, so the + app can just always use `https://`: + ```sh + sudo tailscale serve --bg 8090 + ``` + +### Port-forward + Caddy (reverse proxy with TLS) + +Forward a port on your router to the home server, and run +[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS +with an automatic Let's Encrypt certificate. + +- **Pros:** works with any client, no extra app/agent needed on the phone, + a real public HTTPS URL. +- **Cons:** you're now running an internet-facing service from your home — + bugs in PocketBase, Caddy, or your router's firmware are now a real + attack surface; you need a domain name (or dynamic-DNS if your ISP gives + you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to + work; residential ISPs sometimes block inbound 80/443 or use CGNAT, + which breaks this approach entirely (Tailscale sidesteps that). + +Minimal Caddyfile: +``` +bookshelf.yourdomain.com { + reverse_proxy 127.0.0.1:8090 +} +``` +Caddy handles the certificate automatically. Point your router's port +forward at the Caddy host's 443, and the app's server-URL field at +`https://bookshelf.yourdomain.com`. + +### HTTPS is not optional + +The app sends the account password on every login. **Never** point the app +at a plain `http://` URL that crosses the public internet or an untrusted +network — only plain HTTP over Tailscale (which is already +end-to-end-encrypted at the network layer) is acceptable, and even there, +prefer `tailscale serve` for a real cert. If you go the port-forward route, +Caddy above gets you HTTPS for free — don't skip it. + +## 5. Back up regularly + +```sh +./deploy/backup.sh +``` + +See the comments at the top of `backup.sh` for what it does (SQLite-safe +snapshot + uploaded covers, tarball, prune old ones) and the restore +procedure. Wire it into cron: + +```sh +crontab -e +# add: +0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1 +``` + +Consider also copying the resulting tarballs off-box (another machine, a +USB drive, cloud storage) — a backup that lives on the same disk as the +data it's backing up doesn't protect against disk failure. diff --git a/server/deploy/backup.sh b/server/deploy/backup.sh new file mode 100755 index 0000000..6b4a16a --- /dev/null +++ b/server/deploy/backup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory. +# +# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL +# mode. Copying the .db files directly with `cp` while the server is running +# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using +# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a +# consistent snapshot safely without stopping the server. +# +# This script uses `sqlite3 .backup`, which is the simplest option that needs +# no PocketBase superuser credentials. It backs up pb_data (including +# uploaded files) into timestamped tarballs and prunes old ones. +# +# Usage: +# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR] +# PB_DATA_DIR defaults to ../pb_data (relative to this script) +# BACKUP_DIR defaults to ./backups (relative to this script) +# +# Suggested cron (nightly at 3am, keep last 14): +# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}" +BACKUP_DIR="${2:-$SCRIPT_DIR/backups}" +KEEP_LAST="${KEEP_LAST:-14}" + +log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; } +fail() { log "ERROR: $*"; exit 1; } + +command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)." +[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR" + +TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +mkdir -p "$BACKUP_DIR" + +log "Backing up pb_data from $PB_DATA_DIR" + +# Consistent SQLite snapshots via the .backup command (safe for a live, +# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup` +# uses, which briefly locks for the copy but never corrupts). +for db in data.db auxiliary.db; do + src="$PB_DATA_DIR/$db" + if [[ -f "$src" ]]; then + log " snapshotting $db" + sqlite3 "$src" ".backup '$WORKDIR/$db'" + fi +done + +# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e. +# uploaded covers under storage/) gets copied as-is — these are immutable +# blobs once written, so a plain copy is safe. +log " copying uploaded files (storage/)" +if [[ -d "$PB_DATA_DIR/storage" ]]; then + cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage" +fi + +ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz" +tar -czf "$ARCHIVE" -C "$WORKDIR" . +log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" + +# Prune old backups, keep the most recent $KEEP_LAST. +mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1))) +if [[ ${#old[@]} -gt 0 ]]; then + log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST" + rm -f "${old[@]}" +fi + +log "Done." + +# --- Restore --------------------------------------------------------------- +# 1. Stop the server: sudo systemctl stop bookshelf +# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak +# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \ +# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data +# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data +# 5. Start the server again: sudo systemctl start bookshelf +# 6. Once you've confirmed it's healthy, delete pb_data.bak. diff --git a/server/deploy/bookshelf.service b/server/deploy/bookshelf.service new file mode 100644 index 0000000..283b7e9 --- /dev/null +++ b/server/deploy/bookshelf.service @@ -0,0 +1,36 @@ +[Unit] +Description=Bookshelf PocketBase server +After=network-online.target +Wants=network-online.target + +[Service] +# Adjust to your setup: a dedicated, unprivileged system account. +# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf +User=bookshelf +Group=bookshelf + +# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary, +# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf. +# Adjust WorkingDirectory/ExecStart if you deploy elsewhere. +WorkingDirectory=/opt/bookshelf +ExecStart=/opt/bookshelf/pocketbase serve \ + --http=127.0.0.1:8090 \ + --dir=/opt/bookshelf/pb_data \ + --migrationsDir=/opt/bookshelf/pb_migrations \ + --hooksDir=/opt/bookshelf/pb_hooks + +Restart=always +RestartSec=5 + +# Hardening (safe defaults for a single-purpose service; loosen only if +# something concrete breaks). +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/bookshelf/pb_data +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/server/deploy/docker-compose.yml b/server/deploy/docker-compose.yml new file mode 100644 index 0000000..35dd71e --- /dev/null +++ b/server/deploy/docker-compose.yml @@ -0,0 +1,34 @@ +# Bookshelf PocketBase server — Docker Compose deployment. +# +# Build context is the parent `server/` directory (so the Dockerfile can see +# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/: +# +# cd server/deploy && docker compose up -d +# +# Data lives in the named volume `bookshelf_pb_data`, independent of the +# container lifecycle — `docker compose down` (without -v) never touches it. +# +# The port is published on 127.0.0.1 only, matching bookshelf.service: this +# container is meant to sit behind Tailscale or a Caddy reverse proxy on the +# same host, never exposed directly to the internet (see deploy/README.md). + +services: + pocketbase: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: bookshelf-pocketbase + restart: unless-stopped + ports: + - "127.0.0.1:8090:8090" + volumes: + - bookshelf_pb_data:/pb/pb_data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + bookshelf_pb_data: + name: bookshelf_pb_data diff --git a/server/pb_hooks/main.pb.js b/server/pb_hooks/main.pb.js new file mode 100644 index 0000000..a678ea2 --- /dev/null +++ b/server/pb_hooks/main.pb.js @@ -0,0 +1,21 @@ +/// + +// PocketBase's declarative API rules act as row-level filters for the +// "list"/"search" action: an unsatisfiable listRule (e.g. requiring auth) +// still returns 200 with an empty result set rather than an error, because +// the rule is just a SQL WHERE clause under the hood. See +// https://github.com/pocketbase/pocketbase/discussions/6492 +// +// Bookshelf is a private, two-person library — no anonymous caller should +// ever get a 200 back from these endpoints, even an empty one, since some +// HTTP/JS clients treat "200 with []" as a successful, allowed request. +// This hook makes that explicit: anonymous list/search requests against the +// three app collections are rejected with 403, matching create/update/ +// view/delete (which already 400/404 for unauthenticated callers via the +// declarative rules alone). +onRecordsListRequest((e) => { + if (!e.auth) { + throw new ForbiddenError("Authentication required."); + } + e.next(); +}, "bookcases", "shelves", "books"); diff --git a/server/pb_migrations/1788636516_created_bookcases.js b/server/pb_migrations/1788636516_created_bookcases.js new file mode 100644 index 0000000..06722df --- /dev/null +++ b/server/pb_migrations/1788636516_created_bookcases.js @@ -0,0 +1,113 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 0, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number1177347317", + "max": null, + "min": null, + "name": "position", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool3946532403", + "name": "deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1015602688", + "indexes": [ + "CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)" + ], + "listRule": "@request.auth.id != \"\"", + "name": "bookcases", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1015602688"); + + return app.delete(collection); +}) diff --git a/server/pb_migrations/1788636563_created_shelves.js b/server/pb_migrations/1788636563_created_shelves.js new file mode 100644 index 0000000..7bd39bd --- /dev/null +++ b/server/pb_migrations/1788636563_created_shelves.js @@ -0,0 +1,112 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1015602688", + "help": "", + "hidden": false, + "id": "relation3290364264", + "maxSelect": 1, + "minSelect": 0, + "name": "bookcase", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text245846248", + "max": 0, + "min": 0, + "name": "label", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number1177347317", + "max": null, + "min": null, + "name": "position", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "help": "", + "hidden": false, + "id": "bool3946532403", + "name": "deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2495675413", + "indexes": [ + "CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)" + ], + "listRule": "@request.auth.id != \"\"", + "name": "shelves", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2495675413"); + + return app.delete(collection); +}) diff --git a/server/pb_migrations/1788636563_updated_users.js b/server/pb_migrations/1788636563_updated_users.js new file mode 100644 index 0000000..b39fc9c --- /dev/null +++ b/server/pb_migrations/1788636563_updated_users.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "id = @request.auth.id", + "listRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1788636564_created_books.js b/server/pb_migrations/1788636564_created_books.js new file mode 100644 index 0000000..a00dc47 --- /dev/null +++ b/server/pb_migrations/1788636564_created_books.js @@ -0,0 +1,281 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1367709617", + "max": 0, + "min": 0, + "name": "subtitle", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "json2383161937", + "maxSize": 0, + "name": "authors", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2325797416", + "max": 0, + "min": 0, + "name": "isbn13", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text329878418", + "max": 0, + "min": 0, + "name": "isbn10", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2632504646", + "max": 0, + "min": 0, + "name": "publisher", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2651326123", + "max": 0, + "min": 0, + "name": "published_date", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number3814243252", + "max": null, + "min": null, + "name": "page_count", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "file2366146245", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif" + ], + "name": "cover", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [ + "100x150", + "300x450" + ], + "type": "file" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2395634672", + "max": 0, + "min": 0, + "name": "cover_source_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2495675413", + "help": "", + "hidden": false, + "id": "relation2772917219", + "maxSelect": 1, + "minSelect": 0, + "name": "shelf", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text18589324", + "max": 0, + "min": 0, + "name": "notes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1771793327", + "maxSelect": 1, + "minSelect": 0, + "name": "added_by", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "bool3946532403", + "name": "deleted", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2170393721", + "indexes": [ + "CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)", + "CREATE INDEX `idx_books_updated` ON `books` (`updated`)" + ], + "listRule": "@request.auth.id != \"\"", + "name": "books", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2170393721"); + + return app.delete(collection); +}) diff --git a/server/setup-schema.sh b/server/setup-schema.sh new file mode 100755 index 0000000..76765c6 --- /dev/null +++ b/server/setup-schema.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# +# setup-schema.sh — idempotent PocketBase schema provisioning for Bookshelf. +# +# Creates/updates the `bookcases`, `shelves`, `books` collections exactly per +# docs/SPEC.md, sets every API rule (including locking down self-registration +# on `users`), and creates the required indexes. Safe to re-run any number of +# times against the same instance — it diffs against what already exists. +# +# Requires: bash, curl, jq (no other tooling; portable to any Linux box). +# +# Usage: +# ./setup-schema.sh [PB_URL] [PB_EMAIL] [PB_PASS] +# PB_URL=http://127.0.0.1:8090 PB_EMAIL=admin@example.com PB_PASS=secret ./setup-schema.sh +# +# Args take priority over env vars if both are given. + +set -u -o pipefail + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +PB_URL="${1:-${PB_URL:-http://127.0.0.1:8090}}" +PB_EMAIL="${2:-${PB_EMAIL:-}}" +PB_PASS="${3:-${PB_PASS:-}}" + +# Strip any trailing slash so URL-joining below is predictable. +PB_URL="${PB_URL%/}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CREDS_FILE="$SCRIPT_DIR/.dev-credentials" + +log() { printf '%s\n' "$*" >&2; } +info() { log " $*"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Preflight +# --------------------------------------------------------------------------- + +command -v curl >/dev/null 2>&1 || fail "curl is required but not installed." +command -v jq >/dev/null 2>&1 || fail "jq is required but not installed." + +if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then + if [[ -f "$CREDS_FILE" ]]; then + info "No PB_EMAIL/PB_PASS given — loading $CREDS_FILE" + # shellcheck disable=SC1090 + source "$CREDS_FILE" + PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}" + PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}" + fi +fi + +[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Pass it as arg 2, set PB_EMAIL, or provide $CREDS_FILE." +[[ -n "$PB_PASS" ]] || fail "No superuser password given. Pass it as arg 3, set PB_PASS, or provide $CREDS_FILE." + +log "Bookshelf schema setup" +log " Target: $PB_URL" +log " Superuser: $PB_EMAIL" +log "" + +# Reachability check with an actionable error. +if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then + fail "Cannot reach $PB_URL/api/health — is PocketBase running there? Check the URL and that the server is up." +fi + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")" +AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')" +AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)" + +if [[ "$AUTH_CODE" != "200" ]]; then + fail "Superuser login failed (HTTP $AUTH_CODE). Response: $AUTH_BODY + Check PB_EMAIL/PB_PASS (or $CREDS_FILE) and that this account is a _superusers record." +fi + +TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')" +[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "Login succeeded but no token was returned. Response: $AUTH_BODY" +ok "Authenticated as superuser" + +AUTH_HEADER="Authorization: Bearer $TOKEN" + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +# Both helpers below print "\n" to stdout. Callers must NOT +# rely on a global variable being set as a side effect of a function called +# inside a command substitution — that runs in a subshell, so any such global +# assignment is invisible to the caller once the subshell exits. Encoding the +# code into the captured output itself sidesteps that entirely. + +# api_get PATH -> prints "\n" +api_get() { + local path="$1" + curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$PB_URL$path" | _reorder_code_first +} + +# api_send METHOD PATH JSON_BODY -> prints "\n" +api_send() { + local method="$1" path="$2" body="$3" + curl -s -w '\n%{http_code}' -X "$method" "$PB_URL$path" \ + -H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$body" | _reorder_code_first +} + +# Reads "\n" (curl's -w appends code as the final line) and +# re-emits it as "\n" so callers can cleanly read one line for +# the code and the rest for the body. +_reorder_code_first() { + local all code body + all="$(cat)" + code="$(printf '%s' "$all" | tail -n1)" + body="$(printf '%s' "$all" | sed '$d')" + printf '%s\n%s' "$code" "$body" +} + +# split_response VAR_PREFIX RESPONSE — sets ${VAR_PREFIX}_CODE and ${VAR_PREFIX}_BODY +# from a "\n" string, in the *caller's* shell (no subshell). +split_response() { + local prefix="$1" resp="$2" + printf -v "${prefix}_CODE" '%s' "$(printf '%s' "$resp" | head -n1)" + printf -v "${prefix}_BODY" '%s' "$(printf '%s' "$resp" | tail -n +2)" +} + +# get_collection_json NAME -> prints collection JSON, or empty string if 404 +get_collection_json() { + local name="$1" resp + resp="$(api_get "/api/collections/$name")" + split_response GC "$resp" + if [[ "$GC_CODE" == "200" ]]; then + printf '%s' "$GC_BODY" + elif [[ "$GC_CODE" == "404" ]]; then + printf '' + else + fail "Unexpected response fetching collection '$name' (HTTP $GC_CODE): $GC_BODY" + fi +} + +# --------------------------------------------------------------------------- +# Field merging +# +# PocketBase's collection PATCH/POST replaces the *entire* fields array with +# whatever is sent — any field omitted from the payload is dropped. To stay +# idempotent (and to keep each field's existing internal id stable across +# re-runs, which matters for indexes and relations), we merge our desired +# field definitions over whatever fields already exist by matching on name: +# existing fields get their "id" preserved and properties overwritten; new +# fields are appended with no id (PocketBase assigns one on save). +# --------------------------------------------------------------------------- + +# merge_fields EXISTING_FIELDS_JSON DESIRED_FIELDS_JSON -> merged fields array +merge_fields() { + local existing="$1" desired="$2" + jq -n --argjson existing "$existing" --argjson desired "$desired" ' + ($existing // []) as $ex + | ($desired // []) as $des + | ($ex | map({(.name): .id}) | add // {}) as $idByName + | ($ex | map(select(.name == "id"))) as $idField + | $idField + ($des | map( + . as $f + | if ($idByName[$f.name] != null) + then $f + {id: $idByName[$f.name]} + else $f + end + )) + ' +} + +# --------------------------------------------------------------------------- +# Collection definitions (per SPEC.md) — filled in after bookcases/shelves +# ids are known, since relation fields need the target collectionId. +# --------------------------------------------------------------------------- + +AUTH_RULE='@request.auth.id != ""' + +apply_collection() { + local name="$1" type="$2" desired_fields="$3" indexes_json="$4" + local existing existing_id existing_fields merged payload method path resp + + existing="$(get_collection_json "$name")" + + if [[ -z "$existing" ]]; then + info "Creating collection '$name'..." + method="POST" + path="/api/collections" + existing_fields="[]" + existing_id="" + else + info "Updating collection '$name'..." + method="PATCH" + path="/api/collections/$name" + existing_fields="$(printf '%s' "$existing" | jq -c '.fields')" + existing_id="$(printf '%s' "$existing" | jq -r '.id')" + fi + + merged="$(merge_fields "$existing_fields" "$desired_fields")" + + payload="$(jq -n \ + --arg name "$name" \ + --arg type "$type" \ + --argjson fields "$merged" \ + --argjson indexes "$indexes_json" \ + --arg rule "$AUTH_RULE" \ + '{ + name: $name, + type: $type, + fields: $fields, + indexes: $indexes, + listRule: $rule, + viewRule: $rule, + createRule: $rule, + updateRule: $rule, + deleteRule: $rule + }')" + + resp="$(api_send "$method" "$path" "$payload")" + split_response AC "$resp" + if [[ "$AC_CODE" != "200" ]]; then + fail "Failed to save collection '$name' (HTTP $AC_CODE): $AC_BODY" + fi + + ok "Collection '$name' ready (rules = auth-required)" + # Return the collection id via global for callers that need it. + COLLECTION_ID="$(printf '%s' "$AC_BODY" | jq -r '.id')" + + # PocketBase auto-writes a pb_migrations/*.js snapshot per collection save, + # named "__.js". Migrations with the same + # second-resolution timestamp are replayed in filename (alphabetical) order + # on a fresh instance, NOT creation order — so e.g. "created_books" can run + # before "created_shelves" even though shelves must exist first for the + # relation field. A 1s pause between collection saves keeps timestamps + # strictly increasing so a fresh instance replays them in the right order. + sleep 1 +} + +# --------------------------------------------------------------------------- +# 1) bookcases +# --------------------------------------------------------------------------- + +BOOKCASES_FIELDS='[ + {"name":"name","type":"text","required":true}, + {"name":"note","type":"text","required":false}, + {"name":"position","type":"number","required":false}, + {"name":"deleted","type":"bool","required":false}, + {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, + {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} +]' +BOOKCASES_INDEXES='["CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"]' + +apply_collection "bookcases" "base" "$BOOKCASES_FIELDS" "$BOOKCASES_INDEXES" +BOOKCASES_ID="$COLLECTION_ID" + +# --------------------------------------------------------------------------- +# 2) shelves (relation -> bookcases) +# --------------------------------------------------------------------------- + +SHELVES_FIELDS="$(jq -n --arg bcid "$BOOKCASES_ID" '[ + {"name":"bookcase","type":"relation","required":true,"collectionId":$bcid,"maxSelect":1,"cascadeDelete":false}, + {"name":"label","type":"text","required":true}, + {"name":"position","type":"number","required":false}, + {"name":"deleted","type":"bool","required":false}, + {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, + {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} +]')" +SHELVES_INDEXES='["CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"]' + +apply_collection "shelves" "base" "$SHELVES_FIELDS" "$SHELVES_INDEXES" +SHELVES_ID="$COLLECTION_ID" + +# --------------------------------------------------------------------------- +# 3) books (relation -> shelves, relation -> users, file cover) +# --------------------------------------------------------------------------- + +USERS_JSON="$(get_collection_json "users")" +[[ -n "$USERS_JSON" ]] || fail "The built-in 'users' collection was not found — is this a fresh/corrupt PocketBase data dir?" +USERS_ID="$(printf '%s' "$USERS_JSON" | jq -r '.id')" + +BOOKS_FIELDS="$(jq -n --arg shid "$SHELVES_ID" --arg usid "$USERS_ID" '[ + {"name":"title","type":"text","required":true}, + {"name":"subtitle","type":"text","required":false}, + {"name":"authors","type":"json","required":false}, + {"name":"isbn13","type":"text","required":false}, + {"name":"isbn10","type":"text","required":false}, + {"name":"publisher","type":"text","required":false}, + {"name":"published_date","type":"text","required":false}, + {"name":"page_count","type":"number","required":false}, + {"name":"description","type":"text","required":false}, + {"name":"cover","type":"file","required":false,"maxSelect":1, + "mimeTypes":["image/jpeg","image/png","image/webp","image/gif"], + "thumbs":["100x150","300x450"]}, + {"name":"cover_source_url","type":"text","required":false}, + {"name":"shelf","type":"relation","required":false,"collectionId":$shid,"maxSelect":1,"cascadeDelete":false}, + {"name":"notes","type":"text","required":false}, + {"name":"added_by","type":"relation","required":false,"collectionId":$usid,"maxSelect":1,"cascadeDelete":false}, + {"name":"deleted","type":"bool","required":false}, + {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, + {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} +]')" +BOOKS_INDEXES='[ + "CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)", + "CREATE INDEX `idx_books_updated` ON `books` (`updated`)" +]' + +apply_collection "books" "base" "$BOOKS_FIELDS" "$BOOKS_INDEXES" + +# --------------------------------------------------------------------------- +# 4) Lock down `users` — no self-registration, per SPEC. +# createRule = null (superuser-only), list/view = auth-required, +# update = only your own record, delete = null (superuser-only). +# --------------------------------------------------------------------------- + +info "Locking down 'users' collection (no self-registration)..." +USERS_PATCH_PAYLOAD='{ + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": null, + "updateRule": "id = @request.auth.id", + "deleteRule": null +}' +resp="$(api_send "PATCH" "/api/collections/users" "$USERS_PATCH_PAYLOAD")" +split_response UP "$resp" +if [[ "$UP_CODE" != "200" ]]; then + fail "Failed to lock down 'users' collection (HTTP $UP_CODE): $UP_BODY" +fi +ok "'users' collection locked down (createRule=null, deleteRule=null)" + +log "" +ok "Schema setup complete." +log "" +log "Next: create accounts with ./create-user.sh (there is no self-registration)." diff --git a/tasks/A-server.txt b/tasks/A-server.txt new file mode 100644 index 0000000..9b118e8 --- /dev/null +++ b/tasks/A-server.txt @@ -0,0 +1,41 @@ +You are implementing the SERVER half of the Bookshelf project. +READ FIRST: ~/bookshelf/docs/SPEC.md — it is authoritative. Follow it exactly. + +A PocketBase v0.40.2 instance is ALREADY RUNNING at http://127.0.0.1:8090 +(managed by `sprite-env services`; restart with `sprite-env services restart pocketbase`, +logs at /.sprite/logs/services/pocketbase.log). Superuser credentials are in +~/bookshelf/server/.dev-credentials. Binary + pb_data are in ~/bookshelf/server/. + +Deliver, in ~/bookshelf/server/: + +1. setup-schema.sh — idempotent bash (curl+jq only, portable to any Linux box). + Takes PB_URL, PB_EMAIL, PB_PASS from env or args. Creates/updates the three + collections exactly per SPEC, sets ALL API rules, creates the indexes, and + locks down the users collection (createRule=null). Safe to re-run. This is the + script the owner will run on their home server, so make its output clear and its + errors actionable. +2. Run it against the live instance and VERIFY, with real curl calls: + - anonymous GET of books/shelves/bookcases is REJECTED (expect 4xx) — this is the + single most important check in the whole task, do not hand-wave it + - anonymous user-registration attempt is REJECTED + - an authenticated user CAN do full CRUD on all three collections + - relations resolve; file upload to books.cover works (use a small generated PNG) + Print the actual status codes you observed. If any check fails, FIX IT and re-verify. +3. create-user.sh — superuser-driven account creation (the owner and their wife). + No self-registration path may exist. +4. deploy/ — with a README.md that a competent-but-not-expert person can follow: + - bookshelf.service (systemd, runs as non-root, Restart=always) + - Dockerfile + docker-compose.yml + - backup.sh (sqlite-safe backup of pb_data, plus how to restore) + - guidance on reaching a home server from outside: Tailscale (recommended) + vs port-forward + Caddy/TLS. Be concrete about the tradeoffs. HTTPS is + required because the app sends passwords. +5. Confirm pb_migrations/ captured the schema so a fresh instance self-provisions. + Test this: point a throwaway instance at a NEW empty data dir with the same + migrations, start it, confirm collections appear, then delete the throwaway. +6. server/README.md — quickstart. + +Do not modify ~/bookshelf/app or ~/bookshelf/docs. +Keep ~/bookshelf/server/.dev-credentials out of version control (write .gitignore). +Finish with a <=25 line report: what you built, the verification status codes, and +anything you had to deviate from in the SPEC and why. diff --git a/tasks/B-scaffold.txt b/tasks/B-scaffold.txt new file mode 100644 index 0000000..d7f05e0 --- /dev/null +++ b/tasks/B-scaffold.txt @@ -0,0 +1,49 @@ +You are creating the Android project SCAFFOLD + DESIGN SYSTEM for Bookshelf. +READ FIRST: ~/bookshelf/docs/SPEC.md — authoritative, follow exactly. + +Environment (already installed, do not reinstall): + JAVA_HOME=~/toolchain/jdk21 (JDK 21) ANDROID_HOME=~/toolchain/android-sdk + Installed: platforms;android-37.0, build-tools;37.0.0, platform-tools + No emulator/KVM available — you CANNOT run the app. Verify by compiling. + +Create a Gradle project at ~/bookshelf/app. Deliver: + +1. Working Gradle build: settings.gradle.kts, build.gradle.kts, gradle.properties, + gradle/libs.versions.toml (version catalog), app/build.gradle.kts, the Gradle + wrapper (download a wrapper JAR compatible with AGP for compileSdk 37 + JDK 21), + local.properties pointing at the SDK, and a sensible .gitignore. + Pick versions that ACTUALLY RESOLVE — check Maven Central / dl.google.com rather + than guessing. Declare every library from the SPEC in the catalog now, even ones + later waves will use, so downstream workers never touch build files. +2. AndroidManifest with the permissions the SPEC implies (camera, internet) and a + single MainActivity hosting Compose. +3. ui/theme: Color.kt, Type.kt, Theme.kt implementing the SPEC palette for BOTH + light and dark. Dynamic color OFF. Bundle the Literata variable/static TTF in + res/font (download from the Google Fonts GitHub repo, it is OFL — include the + license file). Full Material3 ColorScheme for both modes, mapped thoughtfully: + this app must read as warm paper and mahogany, never as default-Material purple. +4. A small reusable component set in ui/theme or ui/components that later waves will + build every screen from — at minimum: BookshelfScaffold (topbar w/ serif title + + gold hairline rule), PaperSurface, GoldDivider, BookCover (Coil, correct 2:3 + aspect, letterpress-ish placeholder when no cover, graceful error state), + PrimaryButton/SecondaryButton, EmptyState, SyncStatusBar. Make these genuinely + nice — the owner explicitly cares that this "feels like books". Restrained and + typographic beats skeuomorphic. +5. Paparazzi configured so Compose renders to PNG on the JVM (no device). Add + screenshot tests for the component set in BOTH light and dark, run + `./gradlew recordPaparazziDebug` (or the equivalent task), and confirm real PNGs + land on disk. If Paparazzi will not cooperate with this AGP/Compose combination + after a genuine effort, say so plainly in your report and fall back to Robolectric + + Roborazzi; do not silently skip visual verification. +6. A placeholder MainActivity screen that renders the component set, so wave 3 has a + living style reference. + +MUST end green: `./gradlew assembleDebug` and `./gradlew test` both pass. +Iterate until they do — a broken build blocks every downstream worker. + +Write ~/bookshelf/app/BUILD_NOTES.md recording the EXACT resolved versions (AGP, +Gradle, Kotlin, KSP, Compose BOM, Room, Retrofit, Coil, CameraX, ML Kit, WorkManager, +Paparazzi) and any compatibility traps you hit. Downstream workers depend on this. + +Do not modify ~/bookshelf/server or ~/bookshelf/docs. +Finish with a <=25 line report: versions, what builds, Paparazzi status, deviations. diff --git a/tasks/run-task.sh b/tasks/run-task.sh new file mode 100755 index 0000000..20dbf4e --- /dev/null +++ b/tasks/run-task.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# run-task.sh [cwd] +# Quota-aware worker runner: survives 5-hour usage-limit caps by sleeping until +# quota refreshes and resuming the SAME session instead of restarting from zero. +set -u +NAME="$1"; PROMPT_FILE="$2"; CWD="${3:-$HOME/bookshelf}" +L="$HOME/bookshelf/logs"; mkdir -p "$L" +LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"; SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state" + +MAX_WALL="${MAX_WALL:-86400}" # 24h total patience +POLL="${POLL:-600}" # 10 min between quota probes +MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}" + +export JAVA_HOME="$HOME/toolchain/jdk21" +export ANDROID_HOME="$HOME/toolchain/android-sdk" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH" +export GRADLE_USER_HOME="$HOME/.gradle" +cd "$CWD" || exit 1 + +# Stable session id so a killed run can be resumed rather than restarted. +if [ ! -s "$SIDF" ]; then + python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF" + FRESH=1 +else + FRESH=0 # sid pre-seeded (recovered) or left by an earlier attempt +fi +SID="$(cat "$SIDF")" + +say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; } +say "start sid=$SID fresh=$FRESH wall=${MAX_WALL}s poll=${POLL}s" + +CONT_PROMPT="Continue the task you were working on, from wherever you left off. \ +Your original instructions are earlier in this conversation; re-read them and any \ +files you already wrote before doing more work. Do not restart from scratch and do \ +not redo completed work. Finish the task and give the final report." + +deadline=$(( $(date +%s) + MAX_WALL )) +attempt=0; hard=0; quota_waits=0 + +while [ "$(date +%s)" -lt "$deadline" ]; do + attempt=$((attempt+1)) + if [ "$attempt" -eq 1 ] && [ "$FRESH" -eq 1 ]; then + say "attempt $attempt: fresh (--session-id)" + claude -p --model sonnet --permission-mode bypassPermissions \ + --output-format json --add-dir "$HOME/bookshelf" \ + --session-id "$SID" < "$PROMPT_FILE" > "$LOG" 2>"$ERR" + else + say "attempt $attempt: resume $SID" + printf '%s' "$CONT_PROMPT" | claude -p --model sonnet \ + --permission-mode bypassPermissions --output-format json \ + --add-dir "$HOME/bookshelf" --resume "$SID" > "$LOG" 2>"$ERR" + fi + rc=$? + + blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)" + + # 1) quota / rate limit -> wait it out, do NOT burn a hard-fail + if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then + quota_waits=$((quota_waits+1)) + say "QUOTA hit (wait #$quota_waits). sleeping ${POLL}s then probing again." + sleep "$POLL" + continue + fi + + # 2) session vanished -> start clean once + if printf '%s' "$blob" | grep -qiE 'no conversation found|session not found|could not resume'; then + say "session $SID unresumable; starting fresh" + python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF"; SID="$(cat "$SIDF")"; FRESH=1; attempt=0 + continue + fi + + # 3) success + isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)" + if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then + say "SUCCESS after $attempt attempt(s), $quota_waits quota wait(s)" + break + fi + + # 4) genuine failure + hard=$((hard+1)) + say "hard failure #$hard (rc=$rc is_error=$isErr)" + if [ "$hard" -ge "$MAX_HARD_FAILS" ]; then say "GIVING UP after $hard hard failures"; break; fi + sleep 60 +done + +[ "$(date +%s)" -ge "$deadline" ] && say "WALL CLOCK EXCEEDED" + +{ + echo "=== $NAME attempts=$attempt quota_waits=$quota_waits hard_fails=$hard ===" + jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring) + " err=" + ((.is_error//"?")|tostring)' "$LOG" 2>/dev/null + echo "--- result (tail) ---" + jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1800 +} > "$L/${NAME}.summary"