Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
**/build/
|
||||
.kotlin/
|
||||
@@ -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.
|
||||
@@ -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<Test>().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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Camera for barcode scanning (ui.scan) -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
|
||||
<!-- PocketBase sync, metadata lookup (Open Library / Google Books), cover uploads -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".BookshelfApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Bookshelf"
|
||||
tools:targetApi="31"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Bookshelf">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#5C2E23"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Three book spines, gold on mahogany, standing side by side. -->
|
||||
<path
|
||||
android:fillColor="#D9B45B"
|
||||
android:pathData="M34,28 h10 v52 h-10 z" />
|
||||
<path
|
||||
android:fillColor="#C0932F"
|
||||
android:pathData="M48,24 h10 v56 h-10 z" />
|
||||
<path
|
||||
android:fillColor="#D9B45B"
|
||||
android:pathData="M62,30 h10 v50 h-10 z" />
|
||||
<path
|
||||
android:fillColor="#E8DCC8"
|
||||
android:pathData="M30,80 h48 v6 h-48 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Bookshelf" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/ground_dark</item>
|
||||
<item name="android:statusBarColor">@color/ground_dark</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Mirrors ui.theme.Color.kt; used only for the pre-Compose XML window theme. -->
|
||||
<color name="paper_light">#F5EDE0</color>
|
||||
<color name="ground_dark">#1C1411</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Bookshelf</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Base application theme. The real Material3 theming happens in Compose
|
||||
(ui.theme.BookshelfTheme); this XML theme only needs to cover the
|
||||
window background shown before the first Compose frame. -->
|
||||
<style name="Theme.Bookshelf" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/paper_light</item>
|
||||
<item name="android:statusBarColor">@color/paper_light</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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/<name>.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 <NAME> ./tasks/<NAME>.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/<name>.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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
5e3bd183-252c-4224-99b5-91779761ccbc
|
||||
@@ -0,0 +1 @@
|
||||
6823e72a-69c1-486e-ae5a-18abab84529b
|
||||
@@ -0,0 +1,2 @@
|
||||
A-server=5e3bd183-252c-4224-99b5-91779761ccbc
|
||||
B-scaffold=6823e72a-69c1-486e-ae5a-18abab84529b
|
||||
@@ -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/
|
||||
@@ -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._
|
||||
@@ -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.
|
||||
@@ -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 <superuser-email> <superuser-password>
|
||||
|
||||
# 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.
|
||||
@@ -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 <email> <password> [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 <email> <password> [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)"
|
||||
@@ -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"]
|
||||
@@ -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 <superuser-email> <superuser-password>
|
||||
|
||||
# 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// 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");
|
||||
@@ -0,0 +1,113 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -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 "<http_code>\n<body>" 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 "<code>\n<body>"
|
||||
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 "<code>\n<body>"
|
||||
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 "<body...>\n<code>" (curl's -w appends code as the final line) and
|
||||
# re-emits it as "<code>\n<body...>" 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 "<code>\n<body>" 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 "<unix_seconds>_<action>_<name>.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)."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-task.sh <task-name> <prompt-file> [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"
|
||||