initial Box Manifest server and Android client

This commit is contained in:
2026-08-26 16:35:09 -05:00
parent 0d2809b689
commit f39868c2a3
53 changed files with 4963 additions and 195 deletions
+4
View File
@@ -7,9 +7,13 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetSelector.xml
/.idea/runConfigurations.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/server/data/
/server/box-manifest-server
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+83
View File
@@ -0,0 +1,83 @@
# Box Manifest
Box Manifest is a self-hosted physical inventory system built around one
universal hierarchy. Locations, containers, and items are represented by the
same kind of node, so any node can contain other nodes.
This repository currently contains:
- A Go HTTP server backed by SQLite
- A native Android client built with Kotlin and Jetpack Compose
- A deliberately barebones web client for development and API diagnostics
- An OpenAPI 3.1 contract
The project is pre-release and has no compatibility guarantee yet.
## Current behavior
- Arbitrarily deep node hierarchy
- Optional non-negative integer quantities
- Single and bulk node creation
- Atomic single and multi-node moves
- Empty-only deletion
- Local hierarchy search and breadcrumbs on Android
- JSON tree-import preview and atomic commit
- Stable six-character lookup codes
- QR-label preview, sharing, printing, and scanning on Android
See [the universal-tree decision](docs/decisions/0001-universal-node-tree.md)
for the core model and [the project plan](docs/PROJECT_PLAN.md) for current
boundaries and near-term direction.
## Server development
The server requires Go 1.25 or newer.
```bash
cd server
go run ./cmd/box-manifest-server
```
Open `http://localhost:8080` for the diagnostic web client. SQLite data is
stored at `server/data/box-manifest.db` by default and is ignored by Git.
Available options:
```bash
go run ./cmd/box-manifest-server \
-address :9000 \
-database /path/to/box-manifest.db \
-web ../web
```
Run server tests with:
```bash
cd server
go test ./...
```
The API contract is [api/openapi.yaml](api/openapi.yaml). The nested AI-import
format is [api/tree-import.schema.json](api/tree-import.schema.json).
## Android development
The debug client currently connects to `http://127.0.0.1:8080`. Forward that
port before running on a physical device connected through USB or wireless
ADB:
```bash
adb reverse tcp:8080 tcp:8080
```
Build and verify from the command line with:
```bash
JAVA_HOME=/opt/android-studio/jbr \
ANDROID_HOME="$HOME/Android/Sdk" \
./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug
```
The QR scanner uses Google Play services and may download its scanner module
the first time it is opened. Label URLs also use the local development address
until server configuration is introduced; do not print permanent labels yet.
+522
View File
@@ -0,0 +1,522 @@
openapi: 3.1.0
info:
title: Box Manifest API
version: 0.0.0
description: >-
Pre-release API for the Box Manifest universal node tree. Breaking changes
are allowed until a compatibility policy is adopted.
servers:
- url: /
paths:
/api/tree:
get:
operationId: getTree
summary: Get the complete node tree
responses:
"200":
description: The tree beginning at Root
content:
application/json:
schema:
$ref: "#/components/schemas/TreeNode"
default:
$ref: "#/components/responses/Error"
/api/imports/tree/preview:
post:
operationId: previewTreeImport
summary: Validate and preview a nested tree import
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TreeImportPreviewRequest"
responses:
"200":
description: Normalized import plan ready for review
content:
application/json:
schema:
$ref: "#/components/schemas/TreeImportPreview"
default:
$ref: "#/components/responses/Error"
/api/imports/tree/commit:
post:
operationId: commitTreeImport
summary: Atomically commit a reviewed tree import plan
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TreeImportCommitRequest"
responses:
"201":
description: Entire tree imported
content:
application/json:
schema:
$ref: "#/components/schemas/TreeImportCommitResponse"
default:
$ref: "#/components/responses/Error"
/api/nodes:
post:
operationId: createNode
summary: Create a node
description: A missing parentId places the new node beneath Unsorted.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateNodeRequest"
responses:
"201":
description: Node created
content:
application/json:
schema:
$ref: "#/components/schemas/Node"
default:
$ref: "#/components/responses/Error"
/api/nodes/bulk:
post:
operationId: createNodesBulk
summary: Create multiple sibling nodes atomically
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateNodesBulkRequest"
responses:
"201":
description: All nodes created in request order
content:
application/json:
schema:
$ref: "#/components/schemas/CreateNodesBulkResponse"
default:
$ref: "#/components/responses/Error"
/api/nodes/bulk/move:
post:
operationId: moveNodesBulk
summary: Move sibling nodes atomically
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MoveNodesBulkRequest"
responses:
"200":
description: All selected nodes moved
content:
application/json:
schema:
$ref: "#/components/schemas/MoveNodesBulkResponse"
default:
$ref: "#/components/responses/Error"
/api/nodes/bulk/delete:
post:
operationId: deleteNodesBulk
summary: Delete empty nodes atomically
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteNodesBulkRequest"
responses:
"204":
description: All selected nodes deleted
default:
$ref: "#/components/responses/Error"
/api/nodes/{nodeId}:
parameters:
- $ref: "#/components/parameters/NodeId"
get:
operationId: getNode
summary: Get one node
responses:
"200":
description: Node found
content:
application/json:
schema:
$ref: "#/components/schemas/Node"
default:
$ref: "#/components/responses/Error"
patch:
operationId: updateNode
summary: Update a node's name or quantity
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateNodeRequest"
responses:
"200":
description: Node updated
content:
application/json:
schema:
$ref: "#/components/schemas/Node"
default:
$ref: "#/components/responses/Error"
delete:
operationId: deleteNode
summary: Delete an empty node
description: Root, Unsorted, and nodes with children cannot be deleted.
responses:
"204":
description: Node deleted
"409":
description: The node is protected or is not empty
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
$ref: "#/components/responses/Error"
/api/nodes/by-code/{lookupCode}:
get:
operationId: getNodeByLookupCode
summary: Resolve a printed lookup code to its node
parameters:
- name: lookupCode
in: path
required: true
schema:
type: string
pattern: "^[A-Z2-7]{6}$"
responses:
"200":
description: Node found
content:
application/json:
schema:
$ref: "#/components/schemas/Node"
default:
$ref: "#/components/responses/Error"
/api/nodes/{nodeId}/move:
parameters:
- $ref: "#/components/parameters/NodeId"
post:
operationId: moveNode
summary: Move a node
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MoveNodeRequest"
responses:
"200":
description: Node moved
content:
application/json:
schema:
$ref: "#/components/schemas/Node"
"409":
description: The move is forbidden or would create a cycle
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
$ref: "#/components/responses/Error"
components:
parameters:
NodeId:
name: nodeId
in: path
required: true
schema:
type: string
responses:
Error:
description: Request failed
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
schemas:
Node:
type: object
additionalProperties: false
required:
- id
- lookupCode
- parentId
- name
- quantity
properties:
id:
type: string
lookupCode:
type: string
description: Stable human-enterable lookup key; not the internal identity.
pattern: "^[A-Z2-7]{6}$"
parentId:
type:
- string
- "null"
description: Null only for Root.
name:
type: string
minLength: 1
quantity:
type:
- integer
- "null"
minimum: 0
ImportNode:
type: object
additionalProperties: false
required:
- name
properties:
name:
type: string
minLength: 1
quantity:
type:
- integer
- "null"
minimum: 0
children:
type: array
items:
$ref: "#/components/schemas/ImportNode"
TreeImportManifest:
type: object
additionalProperties: false
required:
- nodes
properties:
nodes:
type: array
minItems: 1
maxItems: 10000
items:
$ref: "#/components/schemas/ImportNode"
TreeImportSummary:
type: object
additionalProperties: false
required:
- nodes
- maximumDepth
properties:
nodes:
type: integer
minimum: 1
maximumDepth:
type: integer
minimum: 1
maximum: 64
TreeImportPreviewRequest:
type: object
additionalProperties: false
required:
- parentId
- manifest
properties:
parentId:
type: string
manifest:
$ref: "#/components/schemas/TreeImportManifest"
TreeImportPreview:
type: object
additionalProperties: false
required:
- planId
- summary
- warnings
- manifest
properties:
planId:
type: string
summary:
$ref: "#/components/schemas/TreeImportSummary"
warnings:
type: array
items:
type: string
manifest:
$ref: "#/components/schemas/TreeImportManifest"
TreeImportCommitRequest:
type: object
additionalProperties: false
required:
- planId
properties:
planId:
type: string
TreeImportCommitResponse:
type: object
additionalProperties: false
required:
- created
properties:
created:
$ref: "#/components/schemas/TreeImportSummary"
TreeNode:
type: object
additionalProperties: false
required:
- id
- lookupCode
- parentId
- name
- quantity
- children
properties:
id:
type: string
lookupCode:
type: string
pattern: "^[A-Z2-7]{6}$"
parentId:
type:
- string
- "null"
description: Null only for Root.
name:
type: string
minLength: 1
quantity:
type:
- integer
- "null"
minimum: 0
children:
type: array
items:
$ref: "#/components/schemas/TreeNode"
CreateNodeRequest:
type: object
additionalProperties: false
required:
- name
properties:
parentId:
type: string
description: Omit to place the node beneath Unsorted.
name:
type: string
minLength: 1
quantity:
type:
- integer
- "null"
minimum: 0
CreateNodesBulkRequest:
type: object
additionalProperties: false
required:
- parentId
- names
properties:
parentId:
type: string
names:
type: array
minItems: 1
items:
type: string
minLength: 1
CreateNodesBulkResponse:
type: object
additionalProperties: false
required:
- nodes
properties:
nodes:
type: array
items:
$ref: "#/components/schemas/Node"
MoveNodesBulkRequest:
type: object
additionalProperties: false
required:
- nodeIds
- targetParentId
properties:
nodeIds:
type: array
minItems: 1
uniqueItems: true
items:
type: string
targetParentId:
type: string
childHandling:
type: string
enum:
- WITH_SUBTREE
- PROMOTE_CHILDREN
- CHILDREN_TO_UNSORTED
default: WITH_SUBTREE
MoveNodesBulkResponse:
type: object
additionalProperties: false
required:
- nodes
properties:
nodes:
type: array
items:
$ref: "#/components/schemas/Node"
DeleteNodesBulkRequest:
type: object
additionalProperties: false
required:
- nodeIds
properties:
nodeIds:
type: array
minItems: 1
uniqueItems: true
items:
type: string
UpdateNodeRequest:
type: object
additionalProperties: false
minProperties: 1
properties:
name:
type: string
minLength: 1
quantity:
type:
- integer
- "null"
minimum: 0
MoveNodeRequest:
type: object
additionalProperties: false
required:
- targetParentId
properties:
targetParentId:
type: string
childHandling:
type: string
enum:
- WITH_SUBTREE
- PROMOTE_CHILDREN
- CHILDREN_TO_UNSORTED
default: WITH_SUBTREE
Error:
type: object
additionalProperties: false
required:
- code
- message
properties:
code:
type: string
message:
type: string
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://box-manifest.local/schemas/tree-import.schema.json",
"title": "Box Manifest tree import manifest",
"type": "object",
"additionalProperties": false,
"required": ["nodes"],
"properties": {
"nodes": {
"type": "array",
"minItems": 1,
"maxItems": 10000,
"items": { "$ref": "#/$defs/node" }
}
},
"$defs": {
"node": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"quantity": {
"type": ["integer", "null"],
"minimum": 0
},
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
}
}
+9 -6
View File
@@ -18,10 +18,12 @@ android {
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
optimization {
enable = false
@@ -41,16 +43,17 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.google.code.scanner)
implementation(libs.zxing.core)
implementation(libs.androidx.print)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
@@ -1,24 +0,0 @@
package app.boxmanifest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.boxmanifest", appContext.packageName)
}
}
+17 -5
View File
@@ -1,16 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Manifest">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -0,0 +1,197 @@
package app.boxmanifest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class ApiNode(
val id: String,
val parentId: String?,
val name: String,
val quantity: Long?,
val children: List<ApiNode> = emptyList(),
val lookupCode: String = "",
)
data class ImportNode(
val name: String,
val quantity: Long?,
val children: List<ImportNode>,
)
data class TreeImportPreview(
val planId: String,
val nodeCount: Int,
val maximumDepth: Int,
val warnings: List<String>,
val nodes: List<ImportNode>,
)
enum class ChildHandling {
WITH_SUBTREE,
PROMOTE_CHILDREN,
CHILDREN_TO_UNSORTED,
}
class BoxManifestApi(
private val baseUrl: String = "http://127.0.0.1:8080",
) {
fun canonicalNodeUrl(lookupCode: String): String = "$baseUrl/n/$lookupCode"
suspend fun getTree(): ApiNode = request("GET", "/api/tree").toNode()
suspend fun createNode(parentId: String?, name: String, quantity: Long?): ApiNode {
val body = JSONObject()
.put("name", name)
.put("quantity", quantity ?: JSONObject.NULL)
if (parentId != null) body.put("parentId", parentId)
return request("POST", "/api/nodes", body).toNode()
}
suspend fun createNodes(parentId: String, names: List<String>): List<ApiNode> {
val values = org.json.JSONArray()
names.forEach(values::put)
val response = request(
"POST",
"/api/nodes/bulk",
JSONObject().put("parentId", parentId).put("names", values),
)
val nodes = response.getJSONArray("nodes")
return List(nodes.length()) { index -> nodes.getJSONObject(index).toNode() }
}
suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview {
val manifest = JSONObject(manifestJson)
val response = request(
"POST",
"/api/imports/tree/preview",
JSONObject().put("parentId", parentId).put("manifest", manifest),
)
val summary = response.getJSONObject("summary")
val warningsJson = response.getJSONArray("warnings")
val nodesJson = response.getJSONObject("manifest").getJSONArray("nodes")
return TreeImportPreview(
planId = response.getString("planId"),
nodeCount = summary.getInt("nodes"),
maximumDepth = summary.getInt("maximumDepth"),
warnings = List(warningsJson.length()) { warningsJson.getString(it) },
nodes = List(nodesJson.length()) { nodesJson.getJSONObject(it).toImportNode() },
)
}
suspend fun commitTreeImport(planId: String) {
request(
"POST",
"/api/imports/tree/commit",
JSONObject().put("planId", planId),
)
}
suspend fun updateNode(id: String, name: String, quantity: Long?): ApiNode =
request(
"PATCH",
"/api/nodes/$id",
JSONObject()
.put("name", name)
.put("quantity", quantity ?: JSONObject.NULL),
).toNode()
suspend fun moveNode(id: String, targetParentId: String, handling: ChildHandling): ApiNode =
request(
"POST",
"/api/nodes/$id/move",
JSONObject()
.put("targetParentId", targetParentId)
.put("childHandling", handling.name),
).toNode()
suspend fun moveNodes(ids: Set<String>, targetParentId: String, handling: ChildHandling) {
val values = org.json.JSONArray()
ids.forEach(values::put)
request(
"POST",
"/api/nodes/bulk/move",
JSONObject()
.put("nodeIds", values)
.put("targetParentId", targetParentId)
.put("childHandling", handling.name),
)
}
suspend fun deleteNode(id: String) {
request("DELETE", "/api/nodes/$id", expectBody = false)
}
suspend fun deleteNodes(ids: Set<String>) {
val values = org.json.JSONArray()
ids.forEach(values::put)
request(
"POST",
"/api/nodes/bulk/delete",
JSONObject().put("nodeIds", values),
expectBody = false,
)
}
private suspend fun request(
method: String,
path: String,
body: JSONObject? = null,
expectBody: Boolean = true,
): JSONObject = withContext(Dispatchers.IO) {
val connection = URL(baseUrl + path).openConnection() as HttpURLConnection
try {
connection.requestMethod = method
connection.connectTimeout = 5_000
connection.readTimeout = 10_000
connection.setRequestProperty("Accept", "application/json")
if (body != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.bufferedWriter().use { it.write(body.toString()) }
}
val status = connection.responseCode
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
if (status !in 200..299) {
val message = runCatching { JSONObject(text).getString("message") }
.getOrDefault("Request failed with status $status")
error(message)
}
if (!expectBody || text.isBlank()) JSONObject() else JSONObject(text)
} finally {
connection.disconnect()
}
}
}
private fun JSONObject.toNode(): ApiNode {
val childValues = optJSONArray("children")
val children = if (childValues == null) {
emptyList()
} else {
List(childValues.length()) { index -> childValues.getJSONObject(index).toNode() }
}
return ApiNode(
id = getString("id"),
lookupCode = getString("lookupCode"),
parentId = if (isNull("parentId")) null else getString("parentId"),
name = getString("name"),
quantity = if (isNull("quantity")) null else getLong("quantity"),
children = children,
)
}
private fun JSONObject.toImportNode(): ImportNode {
val childrenJson = optJSONArray("children")
return ImportNode(
name = getString("name"),
quantity = if (isNull("quantity")) null else getLong("quantity"),
children = if (childrenJson == null) emptyList() else {
List(childrenJson.length()) { childrenJson.getJSONObject(it).toImportNode() }
},
)
}
@@ -7,10 +7,10 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import app.boxmanifest.tree.TreeScreen
import app.boxmanifest.tree.TreeViewModel
import app.boxmanifest.ui.theme.ManifestTheme
class MainActivity : ComponentActivity() {
@@ -19,29 +19,14 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
ManifestTheme {
val treeViewModel: TreeViewModel = viewModel(factory = TreeViewModel.factory())
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
TreeScreen(
viewModel = treeViewModel,
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ManifestTheme {
Greeting("Android")
}
}
@@ -0,0 +1,47 @@
package app.boxmanifest.data
import app.boxmanifest.ApiNode
import app.boxmanifest.BoxManifestApi
import app.boxmanifest.ChildHandling
import app.boxmanifest.TreeImportPreview
class NodeRepository(
private val api: BoxManifestApi = BoxManifestApi(),
) {
suspend fun loadTree(): ApiNode = api.getTree()
suspend fun createChild(parentId: String, name: String, quantity: Long?) {
api.createNode(parentId, name, quantity)
}
suspend fun createChildren(parentId: String, names: List<String>) {
api.createNodes(parentId, names)
}
suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview =
api.previewTreeImport(parentId, manifestJson)
suspend fun commitTreeImport(planId: String) {
api.commitTreeImport(planId)
}
suspend fun update(id: String, name: String, quantity: Long?) {
api.updateNode(id, name, quantity)
}
suspend fun move(id: String, targetParentId: String, childHandling: ChildHandling) {
api.moveNode(id, targetParentId, childHandling)
}
suspend fun moveMany(ids: Set<String>, targetParentId: String, childHandling: ChildHandling) {
api.moveNodes(ids, targetParentId, childHandling)
}
suspend fun delete(id: String) {
api.deleteNode(id)
}
suspend fun deleteMany(ids: Set<String>) {
api.deleteNodes(ids)
}
}
@@ -0,0 +1,103 @@
package app.boxmanifest.labels
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import androidx.core.content.FileProvider
import androidx.print.PrintHelper
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import java.io.File
import java.io.FileOutputStream
private val lookupCodePattern = Regex("^[A-Z2-7]{6}$")
fun createLabelBitmap(name: String, breadcrumb: String, lookupCode: String, canonicalUrl: String): Bitmap {
val bitmap = Bitmap.createBitmap(1200, 800, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK }
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textSize = 68f
canvas.drawText(name.take(30), 64f, 105f, paint)
paint.typeface = Typeface.DEFAULT
paint.textSize = 34f
paint.color = Color.DKGRAY
canvas.drawText(breadcrumb.take(58), 64f, 165f, paint)
val matrix = QRCodeWriter().encode(
canonicalUrl,
BarcodeFormat.QR_CODE,
500,
500,
mapOf(EncodeHintType.MARGIN to 1),
)
val qr = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
for (y in 0 until matrix.height) {
for (x in 0 until matrix.width) {
qr.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
canvas.drawBitmap(qr, 64f, 225f, paint)
paint.color = Color.BLACK
paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
paint.textSize = 72f
canvas.drawText(lookupCode, 650f, 455f, paint)
paint.typeface = Typeface.DEFAULT
paint.textSize = 28f
paint.color = Color.DKGRAY
canvas.drawText("Lookup code", 650f, 505f, paint)
return bitmap
}
fun shareLabel(context: Context, bitmap: Bitmap, lookupCode: String) {
val directory = File(context.cacheDir, "labels").apply { mkdirs() }
val file = File(directory, "box-manifest-$lookupCode.png")
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share label"))
}
fun printLabel(context: Context, bitmap: Bitmap, name: String) {
PrintHelper(context).apply {
colorMode = PrintHelper.COLOR_MODE_MONOCHROME
scaleMode = PrintHelper.SCALE_MODE_FIT
orientation = PrintHelper.ORIENTATION_LANDSCAPE
}.printBitmap("Box Manifest — $name", bitmap)
}
fun scanNodeCode(activity: Activity, onCode: (String) -> Unit, onError: (String) -> Unit) {
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.enableAutoZoom()
.build()
GmsBarcodeScanning.getClient(activity, options).startScan()
.addOnSuccessListener { barcode ->
val code = barcode.rawValue?.let(::extractLookupCode)
if (code == null) onError("That QR code is not a Box Manifest label") else onCode(code)
}
.addOnFailureListener { onError(it.message ?: "Could not open QR scanner") }
}
fun extractLookupCode(value: String): String? {
val normalized = value.trim().uppercase()
if (lookupCodePattern.matches(normalized)) return normalized
return normalized.substringAfterLast('/').substringBefore('?').takeIf(lookupCodePattern::matches)
}
@@ -0,0 +1,32 @@
package app.boxmanifest.tree
enum class BulkDelimiter(val label: String) {
AUTO("Auto"),
NEW_LINES("New lines"),
COMMAS("Commas"),
}
data class BulkParseResult(
val names: List<String>,
val delimiter: BulkDelimiter,
)
fun parseBulkNodes(input: String, requestedDelimiter: BulkDelimiter): BulkParseResult {
val delimiter = when (requestedDelimiter) {
BulkDelimiter.AUTO -> if ('\n' in input || '\r' in input) {
BulkDelimiter.NEW_LINES
} else {
BulkDelimiter.COMMAS
}
else -> requestedDelimiter
}
val parts = when (delimiter) {
BulkDelimiter.NEW_LINES -> input.split(Regex("\\r?\\n|\\r"))
BulkDelimiter.COMMAS -> input.split(',')
BulkDelimiter.AUTO -> error("Auto delimiter must be resolved")
}
return BulkParseResult(
names = parts.map(String::trim).filter(String::isNotEmpty),
delimiter = delimiter,
)
}
@@ -0,0 +1,919 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package app.boxmanifest.tree
import android.app.Activity
import androidx.compose.foundation.Image
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.boxmanifest.ApiNode
import app.boxmanifest.BoxManifestApi
import app.boxmanifest.ChildHandling
import app.boxmanifest.ImportNode
import app.boxmanifest.TreeImportPreview
import app.boxmanifest.ui.components.NodeRow
import app.boxmanifest.ui.components.PropertyRow
import app.boxmanifest.ui.components.HierarchyPicker
import app.boxmanifest.ui.components.BackIcon
import app.boxmanifest.ui.components.CloseIcon
import app.boxmanifest.ui.components.MoreIcon
import app.boxmanifest.ui.components.NodeBreadcrumbs
import app.boxmanifest.ui.components.NewNodeIcon
import app.boxmanifest.ui.components.SearchIcon
import app.boxmanifest.labels.createLabelBitmap
import app.boxmanifest.labels.printLabel
import app.boxmanifest.labels.scanNodeCode
import app.boxmanifest.labels.shareLabel
import kotlinx.coroutines.launch
private enum class OpenSheet { CREATE, IMPORT, EDIT, ACTIONS, LABEL, MOVE, MULTI_MOVE, MULTI_DELETE }
@Composable
fun TreeScreen(
viewModel: TreeViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsState()
val current = state.currentNode
val snackbarHost = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val activity = LocalContext.current as Activity
var openSheet by remember { mutableStateOf<OpenSheet?>(null) }
var searchOpen by remember { mutableStateOf(false) }
var selectedIds by remember(current?.id) { mutableStateOf<Set<String>>(emptySet()) }
val selectedNodes = current?.children.orEmpty().filter { it.id in selectedIds }
val selectionCanDelete = selectedNodes.isNotEmpty() && selectedNodes.all { it.children.isEmpty() }
BackHandler(enabled = state.canGoBack || openSheet != null || selectedIds.isNotEmpty() || searchOpen) {
when {
openSheet != null -> openSheet = null
searchOpen -> searchOpen = false
selectedIds.isNotEmpty() -> selectedIds = emptySet()
else -> viewModel.goBack()
}
}
LaunchedEffect(state.error) {
state.error?.let {
snackbarHost.showSnackbar(it)
viewModel.clearError()
}
}
Column(modifier = modifier.fillMaxSize()) {
NodeTopBar(
canGoBack = state.canGoBack,
protectedNode = current?.id == "root" || current?.id == "unsorted",
onBack = viewModel::goBack,
onRefresh = viewModel::refresh,
onActions = { openSheet = OpenSheet.ACTIONS },
selectedCount = selectedIds.size,
selectionCanDelete = selectionCanDelete,
onCancelSelection = { selectedIds = emptySet() },
onMoveSelection = { openSheet = OpenSheet.MULTI_MOVE },
onDeleteSelection = { openSheet = OpenSheet.MULTI_DELETE },
)
if (state.loading && current == null) {
Text("Loading…", modifier = Modifier.padding(20.dp))
} else if (current != null) {
Box(modifier = Modifier.weight(1f)) {
NodeScreen(
node = current,
breadcrumbs = state.breadcrumbs,
onNavigateBreadcrumb = viewModel::openBreadcrumb,
onOpenChild = viewModel::openNode,
selectedIds = selectedIds,
onToggleSelection = { id ->
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
},
onStartSelection = { id -> selectedIds = setOf(id) },
)
if (selectedIds.isEmpty()) {
FloatingActionButton(
onClick = { searchOpen = true },
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp).size(48.dp),
shape = CircleShape,
containerColor = Color(0xFF363636),
contentColor = Color.White,
) { SearchIcon(Modifier.size(24.dp)) }
FloatingActionButton(
onClick = { openSheet = OpenSheet.CREATE },
modifier = Modifier.align(Alignment.BottomEnd).padding(20.dp).size(48.dp),
shape = CircleShape,
containerColor = Color(0xFF363636),
contentColor = Color.White,
) { NewNodeIcon(Modifier.size(24.dp)) }
}
}
}
}
SnackbarHost(hostState = snackbarHost)
if (searchOpen && state.root != null) {
SearchDialog(
root = state.root!!,
onDismiss = { searchOpen = false },
onOpen = { id ->
viewModel.openBreadcrumb(id)
searchOpen = false
},
onScan = {
scanNodeCode(
activity = activity,
onCode = { code ->
val node = state.root?.allNodes()?.find { it.lookupCode == code }
if (node == null) {
coroutineScope.launch { snackbarHost.showSnackbar("No node has code $code") }
} else {
viewModel.openBreadcrumb(node.id)
searchOpen = false
}
},
onError = { message -> coroutineScope.launch { snackbarHost.showSnackbar(message) } },
)
},
)
}
when (openSheet) {
OpenSheet.CREATE -> current?.let { node ->
QuickAddDialog(
parentName = node.name,
onDismiss = { openSheet = null },
onAddOne = { name, quantity ->
viewModel.createChild(name, quantity)
openSheet = null
},
onAddMultiple = { names ->
viewModel.createChildren(names)
openSheet = null
},
onImportTree = {
viewModel.clearImportPreview()
openSheet = OpenSheet.IMPORT
},
)
}
OpenSheet.IMPORT -> current?.let { node ->
ImportTreeDialog(
parentName = node.name,
preview = state.importPreview,
loading = state.loading,
onDismiss = {
viewModel.clearImportPreview()
openSheet = null
},
onPreview = viewModel::previewTreeImport,
onCommit = { planId ->
viewModel.commitTreeImport(planId)
openSheet = null
},
onEdit = viewModel::clearImportPreview,
)
}
OpenSheet.EDIT -> current?.let { node ->
NodeEditorSheet(
title = "Edit",
initialName = node.name,
initialQuantity = node.quantity,
actionLabel = "Save",
onDismiss = { openSheet = null },
onSubmit = { name, quantity ->
viewModel.updateCurrent(name, quantity)
openSheet = null
},
)
}
OpenSheet.ACTIONS -> current?.let { node ->
ActionSheet(
node = node,
onDismiss = { openSheet = null },
onLabel = { openSheet = OpenSheet.LABEL },
onEdit = { openSheet = OpenSheet.EDIT },
onMove = { openSheet = OpenSheet.MOVE },
onDelete = {
viewModel.deleteCurrent()
openSheet = null
},
)
}
OpenSheet.LABEL -> current?.let { node ->
LabelSheet(
node = node,
breadcrumb = state.breadcrumbs.joinToString(" / ") { it.name },
onDismiss = { openSheet = null },
)
}
OpenSheet.MOVE -> current?.let { node ->
MoveSheet(
node = node,
root = state.root,
onDismiss = { openSheet = null },
onMove = { target, handling ->
viewModel.moveCurrent(target, handling)
openSheet = null
},
)
}
OpenSheet.MULTI_MOVE -> state.root?.let { root ->
MultiMoveSheet(
nodes = selectedNodes,
root = root,
onDismiss = { openSheet = null },
onMove = { target, handling ->
viewModel.moveMany(selectedIds, target, handling)
selectedIds = emptySet()
openSheet = null
},
)
}
OpenSheet.MULTI_DELETE -> {
AlertDialog(
onDismissRequest = { openSheet = null },
title = { Text("Delete ${selectedIds.size} nodes?") },
text = { Text("Only empty nodes can be deleted. This cannot be undone.") },
confirmButton = {
Button(onClick = {
viewModel.deleteMany(selectedIds)
selectedIds = emptySet()
openSheet = null
}) { Text("Delete") }
},
dismissButton = {
OutlinedButton(onClick = { openSheet = null }) { Text("Cancel") }
},
)
}
null -> Unit
}
}
@Composable
private fun QuickAddDialog(
parentName: String,
onDismiss: () -> Unit,
onAddOne: (String, Long?) -> Unit,
onAddMultiple: (List<String>) -> Unit,
onImportTree: () -> Unit,
) {
var input by remember { mutableStateOf("") }
var quantity by remember { mutableStateOf("") }
var addMultiple by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val parsed = remember(input) { parseBulkNodes(input, BulkDelimiter.AUTO) }
val hasDelimiter = remember(input) { input.any { it == ',' || it == '\n' || it == '\r' } }
val canAddMultiple = hasDelimiter && parsed.names.size > 1
val validQuantity = quantity.isBlank() || quantity.toLongOrNull()?.let { it >= 0 } == true
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboard?.show()
}
LaunchedEffect(canAddMultiple) {
if (!canAddMultiple) addMultiple = false
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.92f),
shape = MaterialTheme.shapes.large,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("New", style = MaterialTheme.typography.titleLarge)
Text("Inside: $parentName", color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Name") },
placeholder = { Text("Type or dictate a name") },
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
if (canAddMultiple) {
Row(
modifier = Modifier.fillMaxWidth().clickable { addMultiple = !addMultiple },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = addMultiple, onCheckedChange = { addMultiple = it })
Column {
Text("Add as ${parsed.names.size} separate nodes")
Text(
"Detected ${parsed.delimiter.label.lowercase()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (addMultiple) {
Column {
parsed.names.take(4).forEach { name ->
Text("$name", style = MaterialTheme.typography.bodySmall)
}
if (parsed.names.size > 4) {
Text(
"+ ${parsed.names.size - 4} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
OutlinedTextField(
value = quantity,
onValueChange = { quantity = it },
label = { Text("Quantity (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onDismiss) { Text("Cancel") }
Button(
onClick = {
if (addMultiple) {
onAddMultiple(parsed.names)
} else {
onAddOne(input.trim(), quantity.takeIf(String::isNotBlank)?.toLong())
}
},
enabled = input.isNotBlank() && validQuantity && (!addMultiple || parsed.names.isNotEmpty()),
) { Text(if (addMultiple) "Add ${parsed.names.size}" else "Add") }
}
Text(
"Import a tree manifest",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(onClick = onImportTree).padding(vertical = 6.dp),
)
}
}
}
}
private data class ImportPreviewRow(val node: ImportNode, val depth: Int)
@Composable
private fun ImportTreeDialog(
parentName: String,
preview: TreeImportPreview?,
loading: Boolean,
onDismiss: () -> Unit,
onPreview: (String) -> Unit,
onCommit: (String) -> Unit,
onEdit: () -> Unit,
) {
var manifestJson by remember {
mutableStateOf(
"""{
"nodes": [
{
"name": "Box A",
"children": [
{ "name": "Example item", "quantity": 1 }
]
}
]
}""",
)
}
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(preview) {
if (preview == null) {
focusRequester.requestFocus()
keyboard?.show()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onDismiss) { BackIcon(Modifier.size(24.dp)) }
Text("Import tree", style = MaterialTheme.typography.titleLarge)
}
Text("Inside: $parentName", color = MaterialTheme.colorScheme.onSurfaceVariant)
if (preview == null) {
Text(
"Paste a JSON manifest produced by an AI or another tool.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = manifestJson,
onValueChange = { manifestJson = it },
label = { Text("Tree manifest JSON") },
modifier = Modifier.fillMaxWidth().weight(1f).focusRequester(focusRequester),
textStyle = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { onPreview(manifestJson) },
enabled = manifestJson.isNotBlank() && !loading,
modifier = Modifier.fillMaxWidth(),
) { Text(if (loading) "Validating…" else "Preview import") }
} else {
Text(
"${preview.nodeCount} nodes · maximum depth ${preview.maximumDepth}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
preview.warnings.forEach { warning -> Text("Warning: $warning") }
LazyColumn(modifier = Modifier.weight(1f)) {
items(preview.nodes.flatMap { it.previewRows() }) { row ->
Text(
text = buildString {
append(row.node.name)
row.node.quantity?.let { append(" ($it)") }
},
modifier = Modifier
.fillMaxWidth()
.padding(start = (row.depth * 16).dp, top = 4.dp, bottom = 4.dp),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onEdit, enabled = !loading) { Text("Edit") }
Button(
onClick = { onCommit(preview.planId) },
enabled = !loading,
) { Text(if (loading) "Importing…" else "Import ${preview.nodeCount}") }
}
}
}
}
}
}
private fun ImportNode.previewRows(depth: Int = 0): List<ImportPreviewRow> =
listOf(ImportPreviewRow(this, depth)) + children.flatMap { it.previewRows(depth + 1) }
private data class SearchResult(val node: ApiNode, val location: String)
@Composable
private fun SearchDialog(
root: ApiNode,
onDismiss: () -> Unit,
onOpen: (String) -> Unit,
onScan: () -> Unit,
) {
var query by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val results = remember(root, query) {
if (query.isBlank()) {
emptyList()
} else {
root.allNodes()
.asSequence()
.filter {
it.id != "root" && (
it.name.contains(query.trim(), ignoreCase = true) ||
it.lookupCode.contains(query.trim(), ignoreCase = true)
)
}
.map { node ->
val ancestors = root.pathTo(node.id).orEmpty().dropLast(1)
SearchResult(node, ancestors.joinToString(" / ") { it.name })
}
.sortedBy { it.node.name.lowercase() }
.toList()
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboard?.show()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.8f)
.padding(horizontal = 16.dp),
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search") },
leadingIcon = { SearchIcon(Modifier.size(20.dp)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
OutlinedButton(onClick = onScan, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Scan QR code")
}
when {
query.isBlank() -> Unit
results.isEmpty() -> Text(
"No results",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
else -> LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
) {
items(results, key = { it.node.id }) { result ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen(result.node.id) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
result.node.name,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
Text(
"${result.location} · ${result.node.lookupCode}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
}
}
}
@Composable
private fun NodeTopBar(
canGoBack: Boolean,
protectedNode: Boolean,
onBack: () -> Unit,
onRefresh: () -> Unit,
onActions: () -> Unit,
selectedCount: Int,
selectionCanDelete: Boolean,
onCancelSelection: () -> Unit,
onMoveSelection: () -> Unit,
onDeleteSelection: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selectedCount > 0) {
IconButton(onClick = onCancelSelection) { CloseIcon(Modifier.size(19.dp)) }
Text("$selectedCount selected")
} else {
if (canGoBack) {
IconButton(onClick = onBack) { BackIcon(Modifier.size(24.dp)) }
} else {
Spacer(Modifier.size(48.dp))
}
}
Spacer(modifier = Modifier.weight(1f))
if (selectedCount > 0) {
Text("Move", modifier = Modifier.clickable(onClick = onMoveSelection).padding(10.dp))
Text(
"Delete",
color = if (selectionCanDelete) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(enabled = selectionCanDelete, onClick = onDeleteSelection).padding(10.dp),
)
} else {
Text("Refresh", modifier = Modifier.clickable(onClick = onRefresh).padding(12.dp))
if (!protectedNode) {
IconButton(onClick = onActions) { MoreIcon(Modifier.size(24.dp)) }
}
}
}
}
@Composable
private fun NodeScreen(
node: ApiNode,
breadcrumbs: List<ApiNode>,
onNavigateBreadcrumb: (String) -> Unit,
onOpenChild: (String) -> Unit,
selectedIds: Set<String>,
onToggleSelection: (String) -> Unit,
onStartSelection: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
NodeBreadcrumbs(
nodes = breadcrumbs,
onNavigate = onNavigateBreadcrumb,
modifier = Modifier.fillMaxWidth(),
)
node.quantity?.let { PropertyRow("Quantity", it.toString()) }
}
if (node.children.isEmpty()) {
item {
Text(
"Nothing here yet",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
} else {
items(node.children, key = { it.id }) { child ->
NodeRow(
node = child,
onClick = {
if (selectedIds.isEmpty()) onOpenChild(child.id) else if (child.id != "unsorted") onToggleSelection(child.id)
},
onLongClick = if (child.id == "root" || child.id == "unsorted") null else {
{ onStartSelection(child.id) }
},
selected = child.id in selectedIds,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp),
)
}
}
}
}
@Composable
private fun NodeEditorSheet(
title: String,
initialName: String,
initialQuantity: Long?,
actionLabel: String,
onDismiss: () -> Unit,
onSubmit: (String, Long?) -> Unit,
) {
var name by remember(initialName) { mutableStateOf(initialName) }
var quantity by remember(initialQuantity) { mutableStateOf(initialQuantity?.toString().orEmpty()) }
val validQuantity = quantity.isBlank() || quantity.toLongOrNull()?.let { it >= 0 } == true
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(title, style = MaterialTheme.typography.titleLarge)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = quantity,
onValueChange = { quantity = it },
label = { Text("Quantity (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { onSubmit(name.trim(), quantity.takeIf { it.isNotBlank() }?.toLong()) },
enabled = name.isNotBlank() && validQuantity,
modifier = Modifier.fillMaxWidth(),
) { Text(actionLabel) }
}
}
}
@Composable
private fun ActionSheet(
node: ApiNode,
onDismiss: () -> Unit,
onLabel: () -> Unit,
onEdit: () -> Unit,
onMove: () -> Unit,
onDelete: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(node.name, style = MaterialTheme.typography.titleLarge)
OutlinedButton(onClick = onLabel, modifier = Modifier.fillMaxWidth()) { Text("Label") }
OutlinedButton(onClick = onEdit, modifier = Modifier.fillMaxWidth()) { Text("Edit") }
OutlinedButton(onClick = onMove, modifier = Modifier.fillMaxWidth()) { Text("Move") }
OutlinedButton(
onClick = onDelete,
enabled = node.children.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text(if (node.children.isEmpty()) "Delete" else "Delete (must be empty)") }
}
}
}
@Composable
private fun LabelSheet(
node: ApiNode,
breadcrumb: String,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val canonicalUrl = remember(node.lookupCode) { BoxManifestApi().canonicalNodeUrl(node.lookupCode) }
val bitmap = remember(node.name, breadcrumb, node.lookupCode) {
createLabelBitmap(node.name, breadcrumb, node.lookupCode, canonicalUrl)
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Label", style = MaterialTheme.typography.titleLarge)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Label preview for ${node.name}",
modifier = Modifier.fillMaxWidth(),
)
Text(node.lookupCode, fontWeight = FontWeight.Bold)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { shareLabel(context, bitmap, node.lookupCode) },
modifier = Modifier.weight(1f),
) { Text("Share") }
Button(
onClick = { printLabel(context, bitmap, node.name) },
modifier = Modifier.weight(1f),
) { Text("Print") }
}
}
}
}
@Composable
private fun MoveSheet(
node: ApiNode,
root: ApiNode?,
onDismiss: () -> Unit,
onMove: (String, ChildHandling) -> Unit,
) {
val forbidden = remember(node) { node.descendantIds() + node.id }
var targetId by remember { mutableStateOf(node.parentId.orEmpty()) }
var handling by remember { mutableStateOf(ChildHandling.WITH_SUBTREE) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Move ${node.name}", style = MaterialTheme.typography.titleLarge)
root?.let {
HierarchyPicker(
root = it,
selectedId = targetId,
forbiddenIds = forbidden,
onSelect = { selected -> targetId = selected },
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(8.dp))
Text("Children", style = MaterialTheme.typography.titleMedium)
ChildHandling.entries.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth().clickable { handling = option },
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = handling == option, onClick = { handling = option })
Text(option.label())
}
}
Button(
onClick = { onMove(targetId, handling) },
enabled = targetId.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Move") }
}
}
}
@Composable
private fun MultiMoveSheet(
nodes: List<ApiNode>,
root: ApiNode,
onDismiss: () -> Unit,
onMove: (String, ChildHandling) -> Unit,
) {
val forbidden = remember(nodes) {
nodes.flatMapTo(mutableSetOf()) { node -> node.descendantIds() + node.id }
}
var targetId by remember { mutableStateOf(nodes.firstOrNull()?.parentId.orEmpty()) }
var handling by remember { mutableStateOf(ChildHandling.WITH_SUBTREE) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Move ${nodes.size} nodes", style = MaterialTheme.typography.titleLarge)
HierarchyPicker(
root = root,
selectedId = targetId,
forbiddenIds = forbidden,
onSelect = { selected -> targetId = selected },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text("Children of selected nodes", style = MaterialTheme.typography.titleMedium)
ChildHandling.entries.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth().clickable { handling = option },
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = handling == option, onClick = { handling = option })
Text(option.label())
}
}
Button(
onClick = { onMove(targetId, handling) },
enabled = nodes.isNotEmpty() && targetId.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Move ${nodes.size} nodes") }
}
}
}
private fun ChildHandling.label(): String = when (this) {
ChildHandling.WITH_SUBTREE -> "Bring everything inside"
ChildHandling.PROMOTE_CHILDREN -> "Leave contents in the current parent"
ChildHandling.CHILDREN_TO_UNSORTED -> "Send contents to Unsorted"
}
@@ -0,0 +1,40 @@
package app.boxmanifest.tree
import app.boxmanifest.ApiNode
import app.boxmanifest.TreeImportPreview
data class TreeUiState(
val root: ApiNode? = null,
val currentNodeId: String = "root",
val backStack: List<String> = emptyList(),
val loading: Boolean = false,
val error: String? = null,
val importPreview: TreeImportPreview? = null,
) {
val currentNode: ApiNode?
get() = root?.find(currentNodeId)
val canGoBack: Boolean
get() = backStack.isNotEmpty()
val breadcrumbs: List<ApiNode>
get() = root?.pathTo(currentNodeId).orEmpty()
}
fun ApiNode.find(id: String): ApiNode? {
if (this.id == id) return this
return children.firstNotNullOfOrNull { it.find(id) }
}
fun ApiNode.pathTo(id: String): List<ApiNode>? {
if (this.id == id) return listOf(this)
for (child in children) {
val childPath = child.pathTo(id)
if (childPath != null) return listOf(this) + childPath
}
return null
}
fun ApiNode.allNodes(): List<ApiNode> = listOf(this) + children.flatMap { it.allNodes() }
fun ApiNode.descendantIds(): Set<String> = children.flatMap { it.allNodes() }.mapTo(mutableSetOf()) { it.id }
@@ -0,0 +1,160 @@
package app.boxmanifest.tree
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import app.boxmanifest.ChildHandling
import app.boxmanifest.data.NodeRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class TreeViewModel(
private val repository: NodeRepository,
) : ViewModel() {
private val _state = MutableStateFlow(TreeUiState())
val state: StateFlow<TreeUiState> = _state.asStateFlow()
init {
refresh()
}
fun refresh() = perform { repository.loadTree() }
fun openNode(id: String) {
val current = _state.value
if (current.root?.find(id) == null || id == current.currentNodeId) return
_state.update {
it.copy(currentNodeId = id, backStack = it.backStack + it.currentNodeId)
}
}
fun openBreadcrumb(id: String) {
val root = _state.value.root ?: return
val path = root.pathTo(id) ?: return
_state.update {
it.copy(
currentNodeId = id,
backStack = path.dropLast(1).map(app.boxmanifest.ApiNode::id),
)
}
}
fun goBack(): Boolean {
val current = _state.value
val destination = current.backStack.lastOrNull() ?: return false
_state.update {
it.copy(currentNodeId = destination, backStack = it.backStack.dropLast(1))
}
return true
}
fun createChild(name: String, quantity: Long?) = perform {
repository.createChild(_state.value.currentNodeId, name, quantity)
repository.loadTree()
}
fun createChildren(names: List<String>) = perform {
repository.createChildren(_state.value.currentNodeId, names)
repository.loadTree()
}
fun previewTreeImport(manifestJson: String) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, importPreview = null) }
runCatching {
repository.previewTreeImport(_state.value.currentNodeId, manifestJson)
}.onSuccess { preview ->
_state.update { it.copy(loading = false, importPreview = preview) }
}.onFailure { failure ->
_state.update {
it.copy(loading = false, error = failure.message ?: "Import preview failed")
}
}
}
}
fun clearImportPreview() {
_state.update { it.copy(importPreview = null) }
}
fun commitTreeImport(planId: String) {
_state.update { it.copy(importPreview = null) }
perform {
repository.commitTreeImport(planId)
repository.loadTree()
}
}
fun updateCurrent(name: String, quantity: Long?) = perform {
repository.update(_state.value.currentNodeId, name, quantity)
repository.loadTree()
}
fun moveCurrent(targetParentId: String, handling: ChildHandling) = perform {
repository.move(_state.value.currentNodeId, targetParentId, handling)
repository.loadTree()
}
fun moveMany(ids: Set<String>, targetParentId: String, handling: ChildHandling) = perform {
repository.moveMany(ids, targetParentId, handling)
repository.loadTree()
}
fun deleteCurrent() {
val current = _state.value.currentNode ?: return
val destination = current.parentId ?: return
perform(targetNodeId = destination, resetBackStack = true) {
repository.delete(current.id)
repository.loadTree()
}
}
fun deleteMany(ids: Set<String>) = perform {
repository.deleteMany(ids)
repository.loadTree()
}
fun clearError() {
_state.update { it.copy(error = null) }
}
private fun perform(
targetNodeId: String? = null,
resetBackStack: Boolean = false,
action: suspend () -> app.boxmanifest.ApiNode,
) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { action() }
.onSuccess { root ->
_state.update { previous ->
val requested = targetNodeId ?: previous.currentNodeId
val validTarget = root.find(requested)?.id ?: root.id
previous.copy(
root = root,
currentNodeId = validTarget,
backStack = if (resetBackStack) emptyList() else previous.backStack,
loading = false,
)
}
}
.onFailure { failure ->
_state.update {
it.copy(loading = false, error = failure.message ?: "Request failed")
}
}
}
}
companion object {
fun factory(repository: NodeRepository = NodeRepository()): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
TreeViewModel(repository) as T
}
}
}
@@ -0,0 +1,41 @@
package app.boxmanifest.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@Composable
fun SearchIcon(modifier: Modifier = Modifier) {
val color = LocalContentColor.current
Canvas(modifier = modifier.semantics { contentDescription = "Search" }) {
val stroke = size.minDimension * 0.08f
drawCircle(
color = color,
radius = size.minDimension * 0.28f,
center = Offset(size.width * 0.43f, size.height * 0.43f),
style = Stroke(stroke),
)
drawLine(
color = color,
start = Offset(size.width * 0.64f, size.height * 0.64f),
end = Offset(size.width * 0.86f, size.height * 0.86f),
strokeWidth = stroke,
)
}
}
@Composable
fun NewNodeIcon(modifier: Modifier = Modifier) {
Icon(
painter = painterResource(app.boxmanifest.R.drawable.ic_edit_square),
contentDescription = "New node",
modifier = modifier,
)
}
@@ -0,0 +1,116 @@
package app.boxmanifest.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.boxmanifest.ApiNode
private data class HierarchyRow(val node: ApiNode, val depth: Int)
@Composable
fun HierarchyPicker(
root: ApiNode,
selectedId: String,
forbiddenIds: Set<String>,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expandedIds by remember(root, selectedId, forbiddenIds) {
mutableStateOf(root.pathTo(selectedId).orEmpty().dropLast(1).toSet() + root.id)
}
val rows = remember(root, forbiddenIds, expandedIds) {
root.visibleRows(forbiddenIds, expandedIds)
}
LazyColumn(modifier = modifier.heightIn(max = 280.dp)) {
items(rows, key = { it.node.id }) { row ->
val children = row.node.children.filterNot { it.id in forbiddenIds }
val expanded = row.node.id in expandedIds
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = if (row.node.id == selectedId) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
},
shape = RoundedCornerShape(6.dp),
)
.clickable { onSelect(row.node.id) }
.padding(start = (row.depth * 16).dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.size(32.dp).clickable(enabled = children.isNotEmpty()) {
expandedIds = if (expanded) expandedIds - row.node.id else expandedIds + row.node.id
},
verticalAlignment = Alignment.CenterVertically,
) {
when {
children.isEmpty() -> Unit
expanded -> DownChevronIcon(
Modifier.size(18.dp),
contentDescription = "Collapse ${row.node.name}",
)
else -> RightChevronIcon(
Modifier.size(18.dp),
contentDescription = "Expand ${row.node.name}",
)
}
}
Text(
text = row.node.name,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
RadioButton(
selected = row.node.id == selectedId,
onClick = { onSelect(row.node.id) },
modifier = Modifier.size(32.dp),
)
}
}
}
}
private fun ApiNode.visibleRows(
forbiddenIds: Set<String>,
expandedIds: Set<String>,
depth: Int = 0,
): List<HierarchyRow> {
if (id in forbiddenIds) return emptyList()
val ownRow = listOf(HierarchyRow(this, depth))
if (id !in expandedIds) return ownRow
return ownRow + children.flatMap { it.visibleRows(forbiddenIds, expandedIds, depth + 1) }
}
private fun ApiNode.pathTo(targetId: String): List<String>? {
if (id == targetId) return listOf(id)
for (child in children) {
val childPath = child.pathTo(targetId)
if (childPath != null) return listOf(id) + childPath
}
return null
}
@@ -0,0 +1,37 @@
package app.boxmanifest.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import app.boxmanifest.R
@Composable
fun RightChevronIcon(modifier: Modifier = Modifier, contentDescription: String? = null) =
ManifestIcon(R.drawable.ic_arrow_right_18, contentDescription, modifier)
@Composable
fun DownChevronIcon(modifier: Modifier = Modifier, contentDescription: String? = null) =
ManifestIcon(R.drawable.ic_arrow_down_18, contentDescription, modifier)
@Composable
fun BackIcon(modifier: Modifier = Modifier, contentDescription: String? = "Back") =
ManifestIcon(R.drawable.ic_back_24, contentDescription, modifier)
@Composable
fun CloseIcon(modifier: Modifier = Modifier, contentDescription: String? = "Close") =
ManifestIcon(R.drawable.ic_clear_18, contentDescription, modifier)
@Composable
fun MoreIcon(modifier: Modifier = Modifier, contentDescription: String? = "More options") =
ManifestIcon(R.drawable.ic_action_more_24, contentDescription, modifier)
@Composable
private fun ManifestIcon(
@DrawableRes drawable: Int,
contentDescription: String?,
modifier: Modifier,
) {
Icon(painterResource(drawable), contentDescription, modifier)
}
@@ -0,0 +1,57 @@
package app.boxmanifest.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.boxmanifest.ApiNode
@Composable
fun NodeBreadcrumbs(
nodes: List<ApiNode>,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
LaunchedEffect(nodes, scrollState.maxValue) {
scrollState.scrollTo(scrollState.maxValue)
}
Row(
modifier = modifier.horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
nodes.forEachIndexed { index, node ->
if (index > 0) {
RightChevronIcon(Modifier.size(12.dp))
}
val current = index == nodes.lastIndex
Text(
text = node.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal,
color = if (current) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier
.clickable(enabled = !current) { onNavigate(node.id) }
.padding(vertical = 6.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
}
@@ -0,0 +1,71 @@
package app.boxmanifest.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.TextOverflow
import androidx.compose.ui.unit.dp
import app.boxmanifest.ApiNode
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun NodeRow(
node: ApiNode,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
onLongClick: (() -> Unit)? = null,
) {
Row(
modifier = modifier
.background(
color = if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(10.dp),
)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
modifier = Modifier.size(40.dp),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Box(contentAlignment = Alignment.Center) {
Text(if (selected) "" else node.name.firstOrNull()?.uppercase() ?: "")
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = node.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val details = buildList {
node.quantity?.let { add("Quantity $it") }
if (node.children.isNotEmpty()) add("${node.children.size} inside")
}.joinToString(" · ")
if (details.isNotEmpty()) {
Text(
text = details,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
RightChevronIcon(Modifier.size(18.dp))
}
}
@@ -0,0 +1,22 @@
package app.boxmanifest.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PropertyRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) {
Text(
text = label,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value)
}
}
@@ -2,10 +2,12 @@ package app.boxmanifest.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Ink = Color(0xFF171717)
val Paper = Color(0xFFFCFCFA)
val MutedPaper = Color(0xFFF1F1ED)
val MutedInk = Color(0xFF6F6F6A)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val DarkInk = Color(0xFFF1F1ED)
val DarkPaper = Color(0xFF171717)
val DarkMutedPaper = Color(0xFF292927)
val DarkMutedInk = Color(0xFFAAAAA4)
@@ -1,54 +1,39 @@
package app.boxmanifest.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
primary = DarkInk,
onPrimary = DarkPaper,
background = DarkPaper,
onBackground = DarkInk,
surface = DarkPaper,
onSurface = DarkInk,
surfaceVariant = DarkMutedPaper,
onSurfaceVariant = DarkMutedInk,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
primary = Ink,
onPrimary = Paper,
background = Paper,
onBackground = Ink,
surface = Paper,
onSurface = Ink,
surfaceVariant = MutedPaper,
onSurfaceVariant = MutedInk,
)
@Composable
fun ManifestTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
@@ -8,27 +8,32 @@ import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 38.sp,
letterSpacing = (-0.5).sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
labelSmall = TextStyle(
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
<path android:fillColor="#FF000000" android:pathData="M12,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
<path android:fillColor="#FF000000" android:pathData="M18,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:fillColor="#00000000"
android:pathData="M4,7L9,12L14,7"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeWidth="1.5" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M6.22,14.789C5.927,14.508 5.927,14.052 6.22,13.771L11.189,9L6.22,4.229C5.927,3.948 5.927,3.492 6.22,3.211C6.513,2.93 6.987,2.93 7.28,3.211L13.311,9L7.28,14.789C6.987,15.07 6.513,15.07 6.22,14.789Z" />
</vector>
+12
View File
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M15,20L7,12L15,4"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeWidth="2" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="19dp"
android:height="18dp"
android:viewportWidth="19"
android:viewportHeight="18">
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M17.115,9C17.115,13.418 13.533,17 9.115,17C4.696,17 1.115,13.418 1.115,9C1.115,4.582 4.696,1 9.115,1C13.533,1 17.115,4.582 17.115,9ZM5.69,5.576C5.925,5.341 6.305,5.341 6.539,5.576L9.115,8.151L11.691,5.576C11.925,5.341 12.305,5.341 12.539,5.576C12.773,5.81 12.773,6.19 12.539,6.424L9.963,9L12.539,11.576C12.773,11.81 12.773,12.19 12.539,12.424C12.305,12.659 11.925,12.659 11.691,12.424L9.115,9.849L6.539,12.424C6.305,12.659 5.925,12.659 5.69,12.424C5.456,12.19 5.456,11.81 5.69,11.576L8.266,9L5.69,6.424C5.456,6.19 5.456,5.81 5.69,5.576Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M200,840q-33,0 -56.5,-23.5T120,760L120,200q0,-33 23.5,-56.5T200,120h261q20,0 30,12.5t10,27.5q0,15 -10.5,27.5T460,200L200,200v560h560L760,499q0,-20 12.5,-30t27.5,-10q15,0 27.5,10t12.5,30v261q0,33 -23.5,56.5T760,840L200,840ZM360,560v-97q0,-16 6,-30.5t17,-25.5l344,-344q12,-12 27,-18t30,-6q16,0 30.5,6T821,63l56,57q11,12 17,26.5t6,29.5q0,15 -5.5,29.5T877,232L553,556q-11,11 -25.5,17.5T497,580h-97q-17,0 -28.5,-11.5T360,540ZM440,500h56l232,-232 -28,-28 -29,-28 -231,231v57Z" />
</vector>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared_labels" path="labels/" />
</paths>
@@ -0,0 +1,38 @@
package app.boxmanifest
import app.boxmanifest.tree.BulkDelimiter
import app.boxmanifest.tree.parseBulkNodes
import org.junit.Assert.assertEquals
import org.junit.Test
class BulkNodeParserTest {
@Test
fun autoPrefersNewLinesAndPreservesCommasInNames() {
val result = parseBulkNodes("Hammer, large\nScrews\n\n Tape ", BulkDelimiter.AUTO)
assertEquals(BulkDelimiter.NEW_LINES, result.delimiter)
assertEquals(listOf("Hammer, large", "Screws", "Tape"), result.names)
}
@Test
fun autoUsesCommasWithoutNewLines() {
val result = parseBulkNodes("Hammer, Screws,, Tape", BulkDelimiter.AUTO)
assertEquals(BulkDelimiter.COMMAS, result.delimiter)
assertEquals(listOf("Hammer", "Screws", "Tape"), result.names)
}
@Test
fun explicitDelimiterOverridesDetection() {
val result = parseBulkNodes("One, Two\nThree", BulkDelimiter.COMMAS)
assertEquals(listOf("One", "Two\nThree"), result.names)
}
@Test
fun duplicateNamesArePreserved() {
val result = parseBulkNodes("Pen\nPen", BulkDelimiter.NEW_LINES)
assertEquals(listOf("Pen", "Pen"), result.names)
}
}
@@ -1,17 +0,0 @@
package app.boxmanifest
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,15 @@
package app.boxmanifest
import app.boxmanifest.labels.extractLookupCode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LookupCodeTest {
@Test
fun acceptsPrintedCodeOrCanonicalUrl() {
assertEquals("ABC234", extractLookupCode("abc234"))
assertEquals("ABC234", extractLookupCode("http://server.example/n/ABC234"))
assertNull(extractLookupCode("https://example.com/not-a-label"))
}
}
@@ -0,0 +1,36 @@
package app.boxmanifest
import app.boxmanifest.tree.pathTo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TreePathTest {
private val tree = ApiNode(
id = "root",
parentId = null,
name = "Root",
quantity = null,
children = listOf(
ApiNode(
id = "room",
parentId = "root",
name = "Room",
quantity = null,
children = listOf(
ApiNode("box", "room", "Box", null),
),
),
),
)
@Test
fun returnsStructuralPathToNode() {
assertEquals(listOf("root", "room", "box"), tree.pathTo("box")?.map { it.id })
}
@Test
fun returnsNullForMissingNode() {
assertNull(tree.pathTo("missing"))
}
}
+101
View File
@@ -0,0 +1,101 @@
# Box Manifest project plan
Status: working pre-release prototype
Last updated: 2026-08-26
## Product direction
Box Manifest is a self-hosted system for recording what exists and where it is
stored. The server owns the data and domain rules. Android is the primary
client during early development; the web client currently exists only as a
minimal diagnostic interface.
There are no users or compatibility obligations. API, schema, and client code
may change freely while the architecture is being established.
## Core model
The inventory is one rooted tree of universal nodes. The system does not
assign structural kinds such as location, container, or item. A node can
represent any physical or conceptual thing and can contain other nodes.
```text
Root
└── Apartment
└── Room
└── Shelf
└── Box
└── Pens (quantity: 10)
```
A node currently has:
- An internal opaque ID
- A stable, human-enterable lookup code
- One parent, except for Root
- A required name
- An optional non-negative integer quantity
- Zero or more children
Root and Unsorted are protected system nodes. Unsorted is the inbox for nodes
whose placement has not been decided.
## Settled hierarchy rules
- Moving a node with `WITH_SUBTREE` moves all descendants with it.
- `PROMOTE_CHILDREN` reparents direct children to the node's former parent.
- `CHILDREN_TO_UNSORTED` reparents direct children to Unsorted.
- Multi-node mutations are atomic.
- Moves that create cycles are rejected.
- Deletion is allowed only for empty, non-system nodes.
- Quantity is optional; missing and zero are distinct.
- Names need not be unique and are never identifiers.
These decisions are recorded in
[ADR 0001](decisions/0001-universal-node-tree.md).
## Current architecture
```text
Android ───┐
├── HTTP API ──> Go server ──> SQLite
Web ───────┘
```
- `server/`: Go standard-library HTTP handlers and SQLite persistence
- `app/`: Kotlin, Jetpack Compose, coroutines, and a small handwritten client
- `web/`: dependency-free diagnostic UI
- `api/`: OpenAPI contract and tree-import JSON Schema
- `docs/`: product and architecture decisions
The API is intentionally unversioned during pre-release development.
## Implemented vertical slices
- Tree creation, reading, editing, moving, and empty-only deletion
- Bulk sibling creation and multi-selection move/delete
- Android hierarchy navigation, breadcrumbs, and local search
- AI-oriented nested JSON import with preview and atomic commit
- Stable lookup codes and canonical `/n/{code}` routes
- Android QR-label preview, sharing, printing, and scanning
## Near-term direction
Continue validating the Android interaction model and the server API against
real inventory use. Prefer small end-to-end increments that exercise storage,
API behavior, and the Android client together. Keep the web UI diagnostic until
the core model and Android workflows have settled.
Before permanent QR labels are printed, make the server's externally reachable
canonical URL configurable. Before deployment work begins, establish explicit
configuration and migration conventions for the pre-release server.
## Quality expectations
- Database constraints and transactions protect tree integrity.
- Server behavior is covered with SQLite-backed tests.
- Android changes pass compilation, unit tests, and lint.
- Durable server data remains outside version control.
- QR codes contain stable lookup URLs, never names or hierarchy paths.
- Documentation describes implemented behavior separately from ideas under
discussion.
@@ -0,0 +1,69 @@
# ADR 0001: Universal node tree
Status: accepted
Date: 2026-08-25
## Context
The physical world does not provide a clean structural boundary between a
location, a container, and an item. A room contains a shelf, a box contains a
pen, and a toolbox can both be owned and contain tools. Encoding those words as
exclusive system types would impose rules that the hierarchy itself does not
need.
The project has no users or persisted data requiring compatibility.
## Decision
Represent the hierarchy as one rooted tree of universal nodes. Every node can
have children. The system assigns no structural type or role to a node.
The initial persistent model is deliberately small:
```text
Node
- id
- lookup_code
- parent_id
- name
- quantity?
```
- `id` is stable and opaque.
- `lookup_code` is a stable human-enterable key for labels and search; it is
not the internal identity.
- `parent_id` points to exactly one parent, except for the root.
- `name` is required and non-empty.
- `quantity` is an optional non-negative integer. Missing and zero are distinct.
- Sibling names may be duplicated; identity and paths never depend on names.
The server creates two protected nodes:
- `Root` is the sole node without a parent.
- `Unsorted` is a direct child of Root and acts as the inbox for nodes whose
placement has not been decided.
Root and Unsorted cannot be deleted or moved. Their displayed names are fixed
for now.
Moving a node requires one child-handling mode:
- `WITH_SUBTREE`: move the node with all descendants. This is the default.
- `PROMOTE_CHILDREN`: move the node, reparenting its direct children to the
node's former parent.
- `CHILDREN_TO_UNSORTED`: move the node, reparenting its direct children to
Unsorted.
All changes made by one move are atomic. A move that would create a cycle is
rejected. A node may be deleted only when it is empty; recursive deletion is
not supported.
## Consequences
- All clients can render the complete core model as a simple tree.
- The UI may use familiar words such as room, shelf, or box without making them
database types.
- A node can be both something the user owns and something that contains other
nodes.
- Stable IDs remain necessary because names can change and repeat.
- Additional metadata can be introduced without changing the tree abstraction.
+10 -7
View File
@@ -2,30 +2,33 @@
agp = "9.2.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
coroutines = "1.10.2"
codeScanner = "16.1.0"
zxing = "3.5.4"
androidxPrint = "1.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
google-code-scanner = { group = "com.google.android.gms", name = "play-services-code-scanner", version.ref = "codeScanner" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
androidx-print = { group = "androidx.print", name = "print", version.ref = "androidxPrint" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"box-manifest/server/internal/httpapi"
"box-manifest/server/internal/tree"
_ "modernc.org/sqlite"
)
func main() {
address := flag.String("address", ":8080", "HTTP listen address")
databasePath := flag.String("database", "data/box-manifest.db", "SQLite database path")
webPath := flag.String("web", "../web", "web client directory")
flag.Parse()
if err := os.MkdirAll(filepath.Dir(*databasePath), 0o755); err != nil {
log.Fatal(err)
}
db, err := sql.Open("sqlite", *databasePath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store := tree.NewStore(db)
if err := store.Initialize(context.Background()); err != nil {
log.Fatal(err)
}
if _, err := os.Stat(filepath.Join(*webPath, "index.html")); err != nil {
log.Fatalf("web client not found at %s: %v", *webPath, err)
}
mux := http.NewServeMux()
apiHandler := httpapi.New(store)
mux.Handle("/api/", apiHandler)
mux.Handle("/n/", apiHandler)
mux.Handle("/", http.FileServer(http.Dir(*webPath)))
server := &http.Server{
Addr: *address,
Handler: mux,
}
fmt.Printf("Box Manifest server listening on %s\n", *address)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
+17
View File
@@ -0,0 +1,17 @@
module box-manifest/server
go 1.25.0
require modernc.org/sqlite v1.57.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+50
View File
@@ -0,0 +1,50 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+307
View File
@@ -0,0 +1,307 @@
package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"box-manifest/server/internal/importer"
"box-manifest/server/internal/tree"
)
type Handler struct {
store *tree.Store
imports *importer.Service
}
func New(store *tree.Store) http.Handler {
h := &Handler{store: store, imports: importer.New(store)}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tree", h.getTree)
mux.HandleFunc("POST /api/nodes", h.createNode)
mux.HandleFunc("POST /api/nodes/bulk", h.createNodesBulk)
mux.HandleFunc("POST /api/nodes/bulk/move", h.moveNodesBulk)
mux.HandleFunc("POST /api/nodes/bulk/delete", h.deleteNodesBulk)
mux.HandleFunc("GET /api/nodes/{nodeId}", h.getNode)
mux.HandleFunc("GET /api/nodes/by-code/{lookupCode}", h.getNodeByLookupCode)
mux.HandleFunc("PATCH /api/nodes/{nodeId}", h.updateNode)
mux.HandleFunc("DELETE /api/nodes/{nodeId}", h.deleteNode)
mux.HandleFunc("POST /api/nodes/{nodeId}/move", h.moveNode)
mux.HandleFunc("POST /api/imports/tree/preview", h.previewTreeImport)
mux.HandleFunc("POST /api/imports/tree/commit", h.commitTreeImport)
mux.HandleFunc("GET /n/{lookupCode}", h.openCanonicalNode)
return mux
}
type createNodeRequest struct {
ParentID *string `json:"parentId"`
Name string `json:"name"`
Quantity *int64 `json:"quantity"`
}
type createNodesBulkRequest struct {
ParentID string `json:"parentId"`
Names []string `json:"names"`
}
type createNodesBulkResponse struct {
Nodes []tree.Node `json:"nodes"`
}
type moveNodesBulkRequest struct {
NodeIDs []string `json:"nodeIds"`
TargetParentID string `json:"targetParentId"`
ChildHandling tree.ChildHandling `json:"childHandling"`
}
type moveNodesBulkResponse struct {
Nodes []tree.Node `json:"nodes"`
}
type deleteNodesBulkRequest struct {
NodeIDs []string `json:"nodeIds"`
}
type previewTreeImportRequest struct {
ParentID string `json:"parentId"`
Manifest importer.Manifest `json:"manifest"`
}
type commitTreeImportRequest struct {
PlanID string `json:"planId"`
}
type commitTreeImportResponse struct {
Created importer.Summary `json:"created"`
}
type optionalInt64 struct {
Set bool
Value *int64
}
func (o *optionalInt64) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
return json.Unmarshal(data, &o.Value)
}
type updateNodeRequest struct {
Name *string `json:"name"`
Quantity optionalInt64 `json:"quantity"`
}
type moveNodeRequest struct {
TargetParentID string `json:"targetParentId"`
ChildHandling tree.ChildHandling `json:"childHandling"`
}
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (h *Handler) getTree(w http.ResponseWriter, r *http.Request) {
root, err := h.store.Tree(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, root)
}
func (h *Handler) createNode(w http.ResponseWriter, r *http.Request) {
var request createNodeRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, tree.ErrInvalid)
return
}
node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Quantity)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, node)
}
func (h *Handler) createNodesBulk(w http.ResponseWriter, r *http.Request) {
var request createNodesBulkRequest
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.ParentID) == "" {
writeError(w, tree.ErrInvalid)
return
}
nodes, err := h.store.CreateMany(r.Context(), request.ParentID, request.Names)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, createNodesBulkResponse{Nodes: nodes})
}
func (h *Handler) moveNodesBulk(w http.ResponseWriter, r *http.Request) {
var request moveNodesBulkRequest
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.TargetParentID) == "" {
writeError(w, tree.ErrInvalid)
return
}
nodes, err := h.store.MoveMany(
r.Context(), request.NodeIDs, request.TargetParentID, request.ChildHandling,
)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, moveNodesBulkResponse{Nodes: nodes})
}
func (h *Handler) deleteNodesBulk(w http.ResponseWriter, r *http.Request) {
var request deleteNodesBulkRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, tree.ErrInvalid)
return
}
if err := h.store.DeleteMany(r.Context(), request.NodeIDs); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) previewTreeImport(w http.ResponseWriter, r *http.Request) {
var request previewTreeImportRequest
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.ParentID) == "" {
writeError(w, tree.ErrInvalid)
return
}
preview, err := h.imports.Preview(r.Context(), request.ParentID, request.Manifest)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, preview)
}
func (h *Handler) commitTreeImport(w http.ResponseWriter, r *http.Request) {
var request commitTreeImportRequest
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.PlanID) == "" {
writeError(w, tree.ErrInvalid)
return
}
summary, err := h.imports.Commit(r.Context(), request.PlanID)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, commitTreeImportResponse{Created: summary})
}
func (h *Handler) getNode(w http.ResponseWriter, r *http.Request) {
node, err := h.store.Get(r.Context(), r.PathValue("nodeId"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func (h *Handler) getNodeByLookupCode(w http.ResponseWriter, r *http.Request) {
node, err := h.store.GetByLookupCode(r.Context(), r.PathValue("lookupCode"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func (h *Handler) openCanonicalNode(w http.ResponseWriter, r *http.Request) {
node, err := h.store.GetByLookupCode(r.Context(), r.PathValue("lookupCode"))
if err != nil {
writeError(w, err)
return
}
http.Redirect(w, r, "/?code="+node.LookupCode, http.StatusSeeOther)
}
func (h *Handler) updateNode(w http.ResponseWriter, r *http.Request) {
var request updateNodeRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, tree.ErrInvalid)
return
}
node, err := h.store.Update(r.Context(), r.PathValue("nodeId"), request.Name, tree.QuantityUpdate{
Set: request.Quantity.Set,
Value: request.Quantity.Value,
})
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func (h *Handler) deleteNode(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(r.Context(), r.PathValue("nodeId")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) moveNode(w http.ResponseWriter, r *http.Request) {
var request moveNodeRequest
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.TargetParentID) == "" {
writeError(w, tree.ErrInvalid)
return
}
node, err := h.store.Move(
r.Context(),
r.PathValue("nodeId"),
request.TargetParentID,
request.ChildHandling,
)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, node)
}
func decodeJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request body must contain exactly one JSON value")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
response := errorResponse{Code: "INTERNAL_ERROR", Message: "internal server error"}
switch {
case errors.Is(err, tree.ErrNotFound):
status = http.StatusNotFound
response = errorResponse{Code: "NOT_FOUND", Message: "node not found"}
case errors.Is(err, tree.ErrConflict):
status = http.StatusConflict
response = errorResponse{Code: "TREE_CONFLICT", Message: "operation conflicts with tree rules"}
case errors.Is(err, tree.ErrInvalid):
status = http.StatusBadRequest
response = errorResponse{Code: "INVALID_REQUEST", Message: "invalid request"}
}
writeJSON(w, status, response)
}
+182
View File
@@ -0,0 +1,182 @@
package httpapi
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"box-manifest/server/internal/tree"
_ "modernc.org/sqlite"
)
func TestNodeLifecycleThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
create := request(t, handler, http.MethodPost, "/api/nodes", `{"name":"Pens","quantity":12}`)
if create.Code != http.StatusCreated {
t.Fatalf("create: got %d: %s", create.Code, create.Body.String())
}
var node tree.Node
if err := json.Unmarshal(create.Body.Bytes(), &node); err != nil {
t.Fatal(err)
}
if node.ParentID == nil || *node.ParentID != tree.UnsortedID {
t.Fatalf("expected default Unsorted parent, got %#v", node)
}
if node.LookupCode == "" {
t.Fatal("created node has no lookup code")
}
lookup := request(t, handler, http.MethodGet, "/api/nodes/by-code/"+node.LookupCode, "")
if lookup.Code != http.StatusOK || !bytes.Contains(lookup.Body.Bytes(), []byte(node.ID)) {
t.Fatalf("lookup: got %d: %s", lookup.Code, lookup.Body.String())
}
canonical := request(t, handler, http.MethodGet, "/n/"+node.LookupCode, "")
if canonical.Code != http.StatusSeeOther || canonical.Header().Get("Location") != "/?code="+node.LookupCode {
t.Fatalf("canonical route: got %d, %q", canonical.Code, canonical.Header().Get("Location"))
}
update := request(t, handler, http.MethodPatch, "/api/nodes/"+node.ID, `{"quantity":null}`)
if update.Code != http.StatusOK {
t.Fatalf("update: got %d: %s", update.Code, update.Body.String())
}
if err := json.Unmarshal(update.Body.Bytes(), &node); err != nil {
t.Fatal(err)
}
if node.Quantity != nil {
t.Fatalf("expected quantity to be cleared, got %v", *node.Quantity)
}
treeResponse := request(t, handler, http.MethodGet, "/api/tree", "")
if treeResponse.Code != http.StatusOK {
t.Fatalf("tree: got %d: %s", treeResponse.Code, treeResponse.Body.String())
}
deleteResponse := request(t, handler, http.MethodDelete, "/api/nodes/"+node.ID, "")
if deleteResponse.Code != http.StatusNoContent {
t.Fatalf("delete: got %d: %s", deleteResponse.Code, deleteResponse.Body.String())
}
}
func TestBulkCreateThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
response := request(
t,
handler,
http.MethodPost,
"/api/nodes/bulk",
`{"parentId":"unsorted","names":["Hammer","Screws"]}`,
)
if response.Code != http.StatusCreated {
t.Fatalf("bulk create: got %d: %s", response.Code, response.Body.String())
}
var body struct {
Nodes []tree.Node `json:"nodes"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Nodes) != 2 || body.Nodes[0].Name != "Hammer" || body.Nodes[1].Name != "Screws" {
t.Fatalf("unexpected response: %#v", body.Nodes)
}
}
func TestBulkMoveAndDeleteThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
first := createNodeThroughHTTP(t, handler, tree.UnsortedID, "first")
second := createNodeThroughHTTP(t, handler, tree.UnsortedID, "second")
target := createNodeThroughHTTP(t, handler, tree.RootID, "target")
moveBody := `{"nodeIds":["` + first.ID + `","` + second.ID + `"],"targetParentId":"` + target.ID + `","childHandling":"WITH_SUBTREE"}`
moveResponse := request(t, handler, http.MethodPost, "/api/nodes/bulk/move", moveBody)
if moveResponse.Code != http.StatusOK {
t.Fatalf("bulk move: got %d: %s", moveResponse.Code, moveResponse.Body.String())
}
deleteBody := `{"nodeIds":["` + first.ID + `","` + second.ID + `"]}`
deleteResponse := request(t, handler, http.MethodPost, "/api/nodes/bulk/delete", deleteBody)
if deleteResponse.Code != http.StatusNoContent {
t.Fatalf("bulk delete: got %d: %s", deleteResponse.Code, deleteResponse.Body.String())
}
}
func TestTreeImportPreviewAndCommitThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
preview := request(
t,
handler,
http.MethodPost,
"/api/imports/tree/preview",
`{"parentId":"unsorted","manifest":{"nodes":[{"name":"Box A","children":[{"name":"Cable","quantity":2}]}]}}`,
)
if preview.Code != http.StatusOK {
t.Fatalf("preview: got %d: %s", preview.Code, preview.Body.String())
}
var previewBody struct {
PlanID string `json:"planId"`
}
if err := json.Unmarshal(preview.Body.Bytes(), &previewBody); err != nil {
t.Fatal(err)
}
commit := request(
t,
handler,
http.MethodPost,
"/api/imports/tree/commit",
`{"planId":"`+previewBody.PlanID+`"}`,
)
if commit.Code != http.StatusCreated {
t.Fatalf("commit: got %d: %s", commit.Code, commit.Body.String())
}
treeResponse := request(t, handler, http.MethodGet, "/api/tree", "")
if !bytes.Contains(treeResponse.Body.Bytes(), []byte(`"name":"Box A"`)) ||
!bytes.Contains(treeResponse.Body.Bytes(), []byte(`"name":"Cable"`)) {
t.Fatalf("imported tree is missing: %s", treeResponse.Body.String())
}
}
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
store := tree.NewStore(db)
if err := store.Initialize(context.Background()); err != nil {
t.Fatal(err)
}
return New(store)
}
func request(t *testing.T, handler http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, path, bytes.NewBufferString(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func createNodeThroughHTTP(t *testing.T, handler http.Handler, parentID, name string) tree.Node {
t.Helper()
body, err := json.Marshal(map[string]any{"parentId": parentID, "name": name})
if err != nil {
t.Fatal(err)
}
response := request(t, handler, http.MethodPost, "/api/nodes", string(body))
if response.Code != http.StatusCreated {
t.Fatalf("create %s: got %d: %s", name, response.Code, response.Body.String())
}
var node tree.Node
if err := json.Unmarshal(response.Body.Bytes(), &node); err != nil {
t.Fatal(err)
}
return node
}
+146
View File
@@ -0,0 +1,146 @@
package importer
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"sync"
"box-manifest/server/internal/tree"
)
const (
MaxNodes = 10_000
MaxDepth = 64
)
type Manifest struct {
Nodes []tree.ImportNode `json:"nodes"`
}
type Summary struct {
Nodes int `json:"nodes"`
MaximumDepth int `json:"maximumDepth"`
}
type Preview struct {
PlanID string `json:"planId"`
Summary Summary `json:"summary"`
Warnings []string `json:"warnings"`
Manifest Manifest `json:"manifest"`
}
type plan struct {
parentID string
manifest Manifest
committing bool
}
type Service struct {
store *tree.Store
mu sync.Mutex
plans map[string]*plan
}
func New(store *tree.Store) *Service {
return &Service{store: store, plans: make(map[string]*plan)}
}
func (s *Service) Preview(ctx context.Context, parentID string, manifest Manifest) (Preview, error) {
if _, err := s.store.Get(ctx, parentID); err != nil {
return Preview{}, err
}
normalized, summary, err := normalize(manifest)
if err != nil {
return Preview{}, err
}
planID, err := newPlanID()
if err != nil {
return Preview{}, err
}
s.mu.Lock()
s.plans[planID] = &plan{parentID: parentID, manifest: normalized}
s.mu.Unlock()
return Preview{
PlanID: planID,
Summary: summary,
Warnings: []string{},
Manifest: normalized,
}, nil
}
func (s *Service) Commit(ctx context.Context, planID string) (Summary, error) {
s.mu.Lock()
p, exists := s.plans[planID]
if !exists {
s.mu.Unlock()
return Summary{}, tree.ErrNotFound
}
if p.committing {
s.mu.Unlock()
return Summary{}, tree.ErrConflict
}
p.committing = true
s.mu.Unlock()
created, err := s.store.CreateTree(ctx, p.parentID, p.manifest.Nodes)
s.mu.Lock()
defer s.mu.Unlock()
if err != nil {
p.committing = false
return Summary{}, err
}
delete(s.plans, planID)
_, summary, _ := normalize(p.manifest)
summary.Nodes = created
return summary, nil
}
func normalize(manifest Manifest) (Manifest, Summary, error) {
if len(manifest.Nodes) == 0 {
return Manifest{}, Summary{}, tree.ErrInvalid
}
count := 0
maximumDepth := 0
var visit func([]tree.ImportNode, int) ([]tree.ImportNode, error)
visit = func(nodes []tree.ImportNode, depth int) ([]tree.ImportNode, error) {
if depth > MaxDepth {
return nil, tree.ErrInvalid
}
normalized := make([]tree.ImportNode, len(nodes))
for index, node := range nodes {
count++
if count > MaxNodes {
return nil, tree.ErrInvalid
}
name := strings.TrimSpace(node.Name)
if name == "" || node.Quantity != nil && *node.Quantity < 0 {
return nil, tree.ErrInvalid
}
children, err := visit(node.Children, depth+1)
if err != nil {
return nil, err
}
normalized[index] = tree.ImportNode{Name: name, Quantity: node.Quantity, Children: children}
if depth > maximumDepth {
maximumDepth = depth
}
}
return normalized, nil
}
nodes, err := visit(manifest.Nodes, 1)
if err != nil {
return Manifest{}, Summary{}, err
}
return Manifest{Nodes: nodes}, Summary{Nodes: count, MaximumDepth: maximumDepth}, nil
}
func newPlanID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}
+65
View File
@@ -0,0 +1,65 @@
package importer
import (
"context"
"database/sql"
"errors"
"testing"
"box-manifest/server/internal/tree"
_ "modernc.org/sqlite"
)
func TestPreviewNormalizesAndCommitIsOneTime(t *testing.T) {
ctx := context.Background()
store := testStore(t)
service := New(store)
quantity := int64(3)
preview, err := service.Preview(ctx, tree.UnsortedID, Manifest{Nodes: []tree.ImportNode{
{Name: " Box A ", Children: []tree.ImportNode{{Name: " Cables ", Quantity: &quantity}}},
}})
if err != nil {
t.Fatal(err)
}
if preview.Summary.Nodes != 2 || preview.Summary.MaximumDepth != 2 {
t.Fatalf("unexpected summary: %#v", preview.Summary)
}
if preview.Manifest.Nodes[0].Name != "Box A" || preview.Manifest.Nodes[0].Children[0].Name != "Cables" {
t.Fatalf("manifest was not normalized: %#v", preview.Manifest)
}
committed, err := service.Commit(ctx, preview.PlanID)
if err != nil {
t.Fatal(err)
}
if committed.Nodes != 2 {
t.Fatalf("unexpected commit summary: %#v", committed)
}
if _, err := service.Commit(ctx, preview.PlanID); !errors.Is(err, tree.ErrNotFound) {
t.Fatalf("expected consumed plan to be missing, got %v", err)
}
}
func TestInvalidManifestDoesNotCreatePlan(t *testing.T) {
service := New(testStore(t))
if _, err := service.Preview(context.Background(), tree.UnsortedID, Manifest{
Nodes: []tree.ImportNode{{Name: " "}},
}); !errors.Is(err, tree.ErrInvalid) {
t.Fatalf("expected invalid manifest, got %v", err)
}
}
func testStore(t *testing.T) *tree.Store {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
store := tree.NewStore(db)
if err := store.Initialize(context.Background()); err != nil {
t.Fatal(err)
}
return store
}
+655
View File
@@ -0,0 +1,655 @@
package tree
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
"strings"
)
const (
RootID = "root"
UnsortedID = "unsorted"
)
var (
ErrNotFound = errors.New("node not found")
ErrConflict = errors.New("operation conflicts with tree rules")
ErrInvalid = errors.New("invalid node data")
)
type Node struct {
ID string `json:"id"`
LookupCode string `json:"lookupCode"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Quantity *int64 `json:"quantity"`
}
type TreeNode struct {
Node
Children []*TreeNode `json:"children"`
}
type ImportNode struct {
Name string `json:"name"`
Quantity *int64 `json:"quantity,omitempty"`
Children []ImportNode `json:"children,omitempty"`
}
type ChildHandling string
const (
WithSubtree ChildHandling = "WITH_SUBTREE"
PromoteChildren ChildHandling = "PROMOTE_CHILDREN"
ChildrenToUnsorted ChildHandling = "CHILDREN_TO_UNSORTED"
)
type QuantityUpdate struct {
Set bool
Value *int64
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Initialize(ctx context.Context) error {
// A single connection keeps SQLite's connection-local pragmas predictable.
s.db.SetMaxOpenConns(1)
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
if _, err := s.db.ExecContext(ctx, `PRAGMA journal_mode = WAL`); err != nil {
return fmt.Errorf("enable WAL: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nodes(id) ON DELETE RESTRICT,
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
quantity INTEGER CHECK (quantity IS NULL OR quantity >= 0),
system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0, 1)),
CHECK (id <> parent_id)
)`); err != nil {
return fmt.Errorf("create nodes table: %w", err)
}
if _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS nodes_parent_id ON nodes(parent_id)`); err != nil {
return fmt.Errorf("create parent index: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO nodes (id, parent_id, name, system)
VALUES (?, NULL, 'Root', 1)
ON CONFLICT(id) DO NOTHING`, RootID); err != nil {
return fmt.Errorf("create root: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO nodes (id, parent_id, name, system)
VALUES (?, ?, 'Unsorted', 1)
ON CONFLICT(id) DO NOTHING`, UnsortedID, RootID); err != nil {
return fmt.Errorf("create unsorted: %w", err)
}
columns, err := tableColumns(ctx, tx, "nodes")
if err != nil {
return err
}
if !columns["lookup_code"] {
if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN lookup_code TEXT`); err != nil {
return fmt.Errorf("add lookup code: %w", err)
}
}
rows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE lookup_code IS NULL`)
if err != nil {
return err
}
var missing []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
missing = append(missing, id)
}
if err := rows.Close(); err != nil {
return err
}
for _, id := range missing {
code, err := newLookupCode(ctx, tx)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET lookup_code = ? WHERE id = ?`, code, id); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS nodes_lookup_code ON nodes(lookup_code)`); err != nil {
return fmt.Errorf("create lookup code index: %w", err)
}
return tx.Commit()
}
func (s *Store) Create(ctx context.Context, parentID *string, name string, quantity *int64) (Node, error) {
name = strings.TrimSpace(name)
if name == "" || quantity != nil && *quantity < 0 {
return Node{}, ErrInvalid
}
parent := UnsortedID
if parentID != nil {
parent = *parentID
}
if _, err := s.Get(ctx, parent); err != nil {
return Node{}, err
}
id, err := newID()
if err != nil {
return Node{}, fmt.Errorf("generate node id: %w", err)
}
code, err := newLookupCode(ctx, s.db)
if err != nil {
return Node{}, fmt.Errorf("generate lookup code: %w", err)
}
if _, err = s.db.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
id, code, parent, name, quantity,
); err != nil {
return Node{}, fmt.Errorf("create node: %w", err)
}
return s.Get(ctx, id)
}
func (s *Store) CreateMany(ctx context.Context, parentID string, names []string) ([]Node, error) {
if len(names) == 0 {
return nil, ErrInvalid
}
cleanNames := make([]string, len(names))
ids := make([]string, len(names))
for index, name := range names {
cleanNames[index] = strings.TrimSpace(name)
if cleanNames[index] == "" {
return nil, ErrInvalid
}
id, err := newID()
if err != nil {
return nil, fmt.Errorf("generate node id: %w", err)
}
ids[index] = id
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, parentID); err != nil {
return nil, err
}
for index := range cleanNames {
code, err := newLookupCode(ctx, tx)
if err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name) VALUES (?, ?, ?, ?)`,
ids[index], code, parentID, cleanNames[index],
); err != nil {
return nil, fmt.Errorf("create node: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
created := make([]Node, len(cleanNames))
for index := range cleanNames {
created[index], err = s.Get(ctx, ids[index])
if err != nil {
return nil, err
}
}
return created, nil
}
func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportNode) (int, error) {
if len(nodes) == 0 {
return 0, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, parentID); err != nil {
return 0, err
}
created := 0
var insert func(string, []ImportNode) error
insert = func(parent string, children []ImportNode) error {
for _, child := range children {
name := strings.TrimSpace(child.Name)
if name == "" || child.Quantity != nil && *child.Quantity < 0 {
return ErrInvalid
}
id, err := newID()
if err != nil {
return err
}
code, err := newLookupCode(ctx, tx)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
id, code, parent, name, child.Quantity,
); err != nil {
return err
}
created++
if err := insert(id, child.Children); err != nil {
return err
}
}
return nil
}
if err := insert(parentID, nodes); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return created, nil
}
func (s *Store) Get(ctx context.Context, id string) (Node, error) {
return scanNode(s.db.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id,
))
}
func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) {
return scanNode(s.db.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE lookup_code = ?`,
strings.ToUpper(strings.TrimSpace(code)),
))
}
func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes ORDER BY rowid`)
if err != nil {
return nil, err
}
defer rows.Close()
byID := make(map[string]*TreeNode)
ordered := make([]*TreeNode, 0)
for rows.Next() {
node, err := scanNode(rows)
if err != nil {
return nil, err
}
treeNode := &TreeNode{Node: node, Children: make([]*TreeNode, 0)}
byID[node.ID] = treeNode
ordered = append(ordered, treeNode)
}
if err := rows.Err(); err != nil {
return nil, err
}
root := byID[RootID]
if root == nil {
return nil, errors.New("root node is missing")
}
for _, node := range ordered {
if node.ID == RootID {
continue
}
if node.ParentID == nil || byID[*node.ParentID] == nil {
return nil, fmt.Errorf("node %q has an invalid parent", node.ID)
}
byID[*node.ParentID].Children = append(byID[*node.ParentID].Children, node)
}
return root, nil
}
func (s *Store) Update(ctx context.Context, id string, name *string, quantity QuantityUpdate) (Node, error) {
if name == nil && !quantity.Set {
return Node{}, ErrInvalid
}
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return Node{}, ErrInvalid
}
name = &trimmed
if id == RootID || id == UnsortedID {
return Node{}, ErrConflict
}
}
if quantity.Set && quantity.Value != nil && *quantity.Value < 0 {
return Node{}, ErrInvalid
}
if _, err := s.Get(ctx, id); err != nil {
return Node{}, err
}
if name != nil {
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ? WHERE id = ?`, *name, id); err != nil {
return Node{}, err
}
}
if quantity.Set {
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET quantity = ? WHERE id = ?`, quantity.Value, id); err != nil {
return Node{}, err
}
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id string) error {
if id == RootID || id == UnsortedID {
return ErrConflict
}
result, err := s.db.ExecContext(ctx, `
DELETE FROM nodes
WHERE id = ?
AND NOT EXISTS (SELECT 1 FROM nodes child WHERE child.parent_id = nodes.id)`, id)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 1 {
return nil
}
if _, err := s.Get(ctx, id); errors.Is(err, ErrNotFound) {
return ErrNotFound
}
return ErrConflict
}
func (s *Store) DeleteMany(ctx context.Context, ids []string) error {
if len(ids) == 0 || hasDuplicateIDs(ids) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if id == RootID || id == UnsortedID {
return ErrConflict
}
if _, err := getWith(ctx, tx, id); err != nil {
return err
}
var hasChildren int
if err := tx.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM nodes WHERE parent_id = ?)`, id,
).Scan(&hasChildren); err != nil {
return err
}
if hasChildren == 1 {
return ErrConflict
}
}
for _, id := range ids {
if _, err := tx.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) Move(ctx context.Context, id, targetParentID string, handling ChildHandling) (Node, error) {
if handling == "" {
handling = WithSubtree
}
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
return Node{}, ErrInvalid
}
if id == RootID || id == UnsortedID || id == targetParentID {
return Node{}, ErrConflict
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Node{}, err
}
defer tx.Rollback()
node, err := getWith(ctx, tx, id)
if err != nil {
return Node{}, err
}
if _, err = getWith(ctx, tx, targetParentID); err != nil {
return Node{}, err
}
cycle, err := isDescendant(ctx, tx, id, targetParentID)
if err != nil {
return Node{}, err
}
if cycle {
return Node{}, ErrConflict
}
switch handling {
case PromoteChildren:
if node.ParentID == nil {
return Node{}, ErrConflict
}
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, *node.ParentID, id); err != nil {
return Node{}, err
}
case ChildrenToUnsorted:
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
return Node{}, err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
return Node{}, err
}
if err = tx.Commit(); err != nil {
return Node{}, err
}
return s.Get(ctx, id)
}
func (s *Store) MoveMany(
ctx context.Context,
ids []string,
targetParentID string,
handling ChildHandling,
) ([]Node, error) {
if len(ids) == 0 || hasDuplicateIDs(ids) {
return nil, ErrInvalid
}
if handling == "" {
handling = WithSubtree
}
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
return nil, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, targetParentID); err != nil {
return nil, err
}
nodes := make([]Node, len(ids))
var commonParent string
for index, id := range ids {
if id == RootID || id == UnsortedID || id == targetParentID {
return nil, ErrConflict
}
node, err := getWith(ctx, tx, id)
if err != nil {
return nil, err
}
if node.ParentID == nil {
return nil, ErrConflict
}
if index == 0 {
commonParent = *node.ParentID
} else if *node.ParentID != commonParent {
return nil, ErrConflict
}
cycle, err := isDescendant(ctx, tx, id, targetParentID)
if err != nil {
return nil, err
}
if cycle {
return nil, ErrConflict
}
nodes[index] = node
}
for index, id := range ids {
switch handling {
case PromoteChildren:
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, commonParent, id); err != nil {
return nil, err
}
case ChildrenToUnsorted:
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
return nil, err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
return nil, err
}
nodes[index].ParentID = &targetParentID
}
if err := tx.Commit(); err != nil {
return nil, err
}
return nodes, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanNode(row scanner) (Node, error) {
var node Node
var parent sql.NullString
var quantity sql.NullInt64
if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &quantity); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Node{}, ErrNotFound
}
return Node{}, err
}
if parent.Valid {
node.ParentID = &parent.String
}
if quantity.Valid {
node.Quantity = &quantity.Int64
}
return node, nil
}
func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) {
return scanNode(tx.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id))
}
func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) {
var found int
err := tx.QueryRowContext(ctx, `
WITH RECURSIVE descendants(id) AS (
SELECT id FROM nodes WHERE parent_id = ?
UNION ALL
SELECT nodes.id FROM nodes JOIN descendants ON nodes.parent_id = descendants.id
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?)`, ancestorID, candidateID).Scan(&found)
return found == 1, err
}
func newID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}
type queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func newLookupCode(ctx context.Context, db queryer) (string, error) {
for attempt := 0; attempt < 20; attempt++ {
var value [5]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value[:])[:6]
var exists int
if err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE lookup_code = ?)`, code).Scan(&exists); err != nil {
return "", err
}
if exists == 0 {
return code, nil
}
}
return "", errors.New("could not generate unique lookup code")
}
func tableColumns(ctx context.Context, tx *sql.Tx, table string) (map[string]bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]bool)
for rows.Next() {
var cid int
var name, columnType string
var notNull, primaryKey int
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
return nil, err
}
columns[name] = true
}
return columns, rows.Err()
}
func hasDuplicateIDs(ids []string) bool {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if strings.TrimSpace(id) == "" {
return true
}
if _, exists := seen[id]; exists {
return true
}
seen[id] = struct{}{}
}
return false
}
+318
View File
@@ -0,0 +1,318 @@
package tree
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestInitializeCreatesProtectedTree(t *testing.T) {
store := newTestStore(t)
root, err := store.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
if root.ID != RootID || root.ParentID != nil || root.Name != "Root" {
t.Fatalf("unexpected root: %#v", root.Node)
}
if len(root.Children) != 1 || root.Children[0].ID != UnsortedID {
t.Fatalf("expected Unsorted beneath Root, got %#v", root.Children)
}
if root.LookupCode == "" || root.Children[0].LookupCode == "" || root.LookupCode == root.Children[0].LookupCode {
t.Fatalf("expected stable unique lookup codes: %#v", root)
}
}
func TestInitializeBackfillsLookupCodesInExistingDatabase(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE nodes (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nodes(id),
name TEXT NOT NULL,
quantity INTEGER,
system INTEGER NOT NULL DEFAULT 0
)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO nodes (id, name, system) VALUES ('root', 'Root', 1)`); err != nil {
t.Fatal(err)
}
store := NewStore(db)
if err := store.Initialize(context.Background()); err != nil {
t.Fatal(err)
}
root, err := store.Get(context.Background(), RootID)
if err != nil || root.LookupCode == "" {
t.Fatalf("existing node was not backfilled: %#v, %v", root, err)
}
}
func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
node := createTestNode(t, store, RootID, "Box")
code := node.LookupCode
newName := "Renamed box"
if _, err := store.Update(ctx, node.ID, &newName, QuantityUpdate{}); err != nil {
t.Fatal(err)
}
if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil {
t.Fatal(err)
}
found, err := store.GetByLookupCode(ctx, strings.ToLower(code))
if err != nil {
t.Fatal(err)
}
if found.ID != node.ID || found.LookupCode != code || found.Name != newName {
t.Fatalf("lookup identity changed: %#v", found)
}
}
func TestMoveWithSubtree(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
a := createTestNode(t, store, RootID, "A")
b := createTestNode(t, store, RootID, "B")
child := createTestNode(t, store, a.ID, "child")
moved, err := store.Move(ctx, a.ID, b.ID, WithSubtree)
if err != nil {
t.Fatal(err)
}
assertParent(t, moved, b.ID)
assertParent(t, mustGet(t, store, child.ID), a.ID)
}
func TestMovePromotesChildren(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
a := createTestNode(t, store, RootID, "A")
b := createTestNode(t, store, RootID, "B")
child := createTestNode(t, store, a.ID, "child")
if _, err := store.Move(ctx, a.ID, b.ID, PromoteChildren); err != nil {
t.Fatal(err)
}
assertParent(t, mustGet(t, store, child.ID), RootID)
}
func TestMoveSendsChildrenToUnsorted(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
a := createTestNode(t, store, RootID, "A")
b := createTestNode(t, store, RootID, "B")
child := createTestNode(t, store, a.ID, "child")
if _, err := store.Move(ctx, a.ID, b.ID, ChildrenToUnsorted); err != nil {
t.Fatal(err)
}
assertParent(t, mustGet(t, store, child.ID), UnsortedID)
}
func TestMoveRejectsCycle(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
a := createTestNode(t, store, RootID, "A")
child := createTestNode(t, store, a.ID, "child")
if _, err := store.Move(ctx, a.ID, child.ID, WithSubtree); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
assertParent(t, mustGet(t, store, a.ID), RootID)
assertParent(t, mustGet(t, store, child.ID), a.ID)
}
func TestDeleteOnlyAllowsEmptyNodes(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
parent := createTestNode(t, store, RootID, "parent")
child := createTestNode(t, store, parent.ID, "child")
if err := store.Delete(ctx, parent.ID); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict deleting non-empty node, got %v", err)
}
if err := store.Delete(ctx, child.ID); err != nil {
t.Fatal(err)
}
if err := store.Delete(ctx, parent.ID); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, parent.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected deleted node to be missing, got %v", err)
}
}
func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
newName := "Elsewhere"
for _, id := range []string{RootID, UnsortedID} {
if err := store.Delete(ctx, id); !errors.Is(err, ErrConflict) {
t.Errorf("delete %s: expected conflict, got %v", id, err)
}
if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) {
t.Errorf("move %s: expected conflict, got %v", id, err)
}
if _, err := store.Update(ctx, id, &newName, QuantityUpdate{}); !errors.Is(err, ErrConflict) {
t.Errorf("rename %s: expected conflict, got %v", id, err)
}
}
}
func TestCreateDefaultsToUnsortedAndAcceptsDuplicateNames(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
quantity := int64(0)
first, err := store.Create(ctx, nil, "Pens", &quantity)
if err != nil {
t.Fatal(err)
}
second, err := store.Create(ctx, nil, "Pens", nil)
if err != nil {
t.Fatal(err)
}
assertParent(t, first, UnsortedID)
assertParent(t, second, UnsortedID)
if first.Quantity == nil || *first.Quantity != 0 || second.Quantity != nil {
t.Fatalf("quantity did not preserve zero versus missing: %#v %#v", first, second)
}
}
func TestCreateManyIsOrderedAndAtomic(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
parent := createTestNode(t, store, RootID, "parent")
created, err := store.CreateMany(ctx, parent.ID, []string{" Hammer ", "Hammer", "Screws"})
if err != nil {
t.Fatal(err)
}
if len(created) != 3 || created[0].Name != "Hammer" || created[1].Name != "Hammer" || created[2].Name != "Screws" {
t.Fatalf("unexpected created nodes: %#v", created)
}
if _, err := store.CreateMany(ctx, parent.ID, []string{"Valid", " "}); !errors.Is(err, ErrInvalid) {
t.Fatalf("expected invalid bulk request, got %v", err)
}
tree, err := store.Tree(ctx)
if err != nil {
t.Fatal(err)
}
parentAfter := tree.findForTest(parent.ID)
if parentAfter == nil || len(parentAfter.Children) != 3 {
t.Fatalf("invalid request inserted a partial batch: %#v", parentAfter)
}
}
func TestMoveManyMovesSiblingSubtreesAtomically(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
source := createTestNode(t, store, RootID, "source")
target := createTestNode(t, store, RootID, "target")
first := createTestNode(t, store, source.ID, "first")
second := createTestNode(t, store, source.ID, "second")
child := createTestNode(t, store, first.ID, "child")
moved, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree)
if err != nil {
t.Fatal(err)
}
if len(moved) != 2 {
t.Fatalf("expected two moved nodes, got %d", len(moved))
}
assertParent(t, mustGet(t, store, first.ID), target.ID)
assertParent(t, mustGet(t, store, second.ID), target.ID)
assertParent(t, mustGet(t, store, child.ID), first.ID)
}
func TestMoveManyRejectsNodesFromDifferentParentsWithoutChanges(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
firstParent := createTestNode(t, store, RootID, "first parent")
secondParent := createTestNode(t, store, RootID, "second parent")
target := createTestNode(t, store, RootID, "target")
first := createTestNode(t, store, firstParent.ID, "first")
second := createTestNode(t, store, secondParent.ID, "second")
if _, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
assertParent(t, mustGet(t, store, first.ID), firstParent.ID)
assertParent(t, mustGet(t, store, second.ID), secondParent.ID)
}
func TestDeleteManyIsAtomicWhenAnyNodeIsNotEmpty(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
parent := createTestNode(t, store, RootID, "parent")
empty := createTestNode(t, store, parent.ID, "empty")
nonEmpty := createTestNode(t, store, parent.ID, "non-empty")
createTestNode(t, store, nonEmpty.ID, "child")
if err := store.DeleteMany(ctx, []string{empty.ID, nonEmpty.ID}); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
if _, err := store.Get(ctx, empty.ID); err != nil {
t.Fatalf("empty sibling was partially deleted: %v", err)
}
}
func newTestStore(t *testing.T) *Store {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
store := NewStore(db)
if err := store.Initialize(context.Background()); err != nil {
t.Fatal(err)
}
return store
}
func createTestNode(t *testing.T, store *Store, parentID, name string) Node {
t.Helper()
node, err := store.Create(context.Background(), &parentID, name, nil)
if err != nil {
t.Fatal(err)
}
return node
}
func mustGet(t *testing.T, store *Store, id string) Node {
t.Helper()
node, err := store.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return node
}
func assertParent(t *testing.T, node Node, parentID string) {
t.Helper()
if node.ParentID == nil || *node.ParentID != parentID {
t.Fatalf("node %s: expected parent %s, got %v", node.ID, parentID, node.ParentID)
}
}
func (node *TreeNode) findForTest(id string) *TreeNode {
if node.ID == id {
return node
}
for _, child := range node.Children {
if found := child.findForTest(id); found != nil {
return found
}
}
return nil
}
+193
View File
@@ -0,0 +1,193 @@
const state = {
root: null,
nodes: new Map(),
selectedId: null,
};
const elements = {
status: document.querySelector("#status"),
tree: document.querySelector("#tree"),
selected: document.querySelector("#selected"),
refresh: document.querySelector("#refresh"),
createForm: document.querySelector("#create-form"),
createParent: document.querySelector("#create-parent"),
createName: document.querySelector("#create-name"),
createQuantity: document.querySelector("#create-quantity"),
updateForm: document.querySelector("#update-form"),
updateName: document.querySelector("#update-name"),
updateQuantity: document.querySelector("#update-quantity"),
moveForm: document.querySelector("#move-form"),
moveParent: document.querySelector("#move-parent"),
moveChildren: document.querySelector("#move-children"),
deleteButton: document.querySelector("#delete"),
};
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: options.body ? { "Content-Type": "application/json" } : undefined,
});
if (response.status === 204) {
return null;
}
const body = await response.json();
if (!response.ok) {
throw new Error(body.message || `Request failed with status ${response.status}`);
}
return body;
}
async function loadTree() {
try {
state.root = await api("/api/tree");
state.nodes.clear();
indexNode(state.root);
const requestedCode = new URLSearchParams(window.location.search).get("code")?.toUpperCase();
if (requestedCode) {
state.selectedId = [...state.nodes.values()].find((node) => node.lookupCode === requestedCode)?.id || null;
}
if (state.selectedId && !state.nodes.has(state.selectedId)) {
state.selectedId = null;
}
render();
setStatus("");
} catch (error) {
setStatus(error.message);
}
}
function indexNode(node) {
state.nodes.set(node.id, node);
for (const child of node.children) {
indexNode(child);
}
}
function render() {
elements.tree.replaceChildren(renderNode(state.root));
renderSelection();
}
function renderNode(node) {
const list = document.createElement("ul");
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent = node.quantity === null ? node.name : `${node.name} (${node.quantity})`;
button.addEventListener("click", () => {
state.selectedId = node.id;
renderSelection();
});
item.append(button);
if (node.children.length > 0) {
for (const child of node.children) {
item.append(renderNode(child));
}
}
list.append(item);
return list;
}
function renderSelection() {
const node = state.nodes.get(state.selectedId);
const hasSelection = Boolean(node);
const protectedNode = node?.id === "root" || node?.id === "unsorted";
elements.selected.textContent = node ? `${node.name} [${node.lookupCode}]` : "None";
elements.createParent.textContent = `Parent: ${node?.name || "Unsorted"}`;
elements.updateName.value = node?.name || "";
elements.updateQuantity.value = node?.quantity ?? "";
for (const control of elements.updateForm.elements) {
control.disabled = !hasSelection || protectedNode;
}
for (const control of elements.moveForm.elements) {
control.disabled = !hasSelection || protectedNode;
}
elements.deleteButton.disabled = !hasSelection || protectedNode;
elements.moveParent.replaceChildren();
for (const candidate of state.nodes.values()) {
if (candidate.id === state.selectedId) {
continue;
}
const option = document.createElement("option");
option.value = candidate.id;
option.textContent = candidate.name;
elements.moveParent.append(option);
}
}
function quantityFrom(input) {
return input.value === "" ? null : Number(input.value);
}
elements.refresh.addEventListener("click", loadTree);
elements.createForm.addEventListener("submit", async (event) => {
event.preventDefault();
const request = {
name: elements.createName.value,
quantity: quantityFrom(elements.createQuantity),
};
if (state.selectedId) {
request.parentId = state.selectedId;
}
await perform(async () => {
const node = await api("/api/nodes", {
method: "POST",
body: JSON.stringify(request),
});
state.selectedId = node.id;
elements.createForm.reset();
});
});
elements.updateForm.addEventListener("submit", async (event) => {
event.preventDefault();
await perform(() => api(`/api/nodes/${state.selectedId}`, {
method: "PATCH",
body: JSON.stringify({
name: elements.updateName.value,
quantity: quantityFrom(elements.updateQuantity),
}),
}));
});
elements.moveForm.addEventListener("submit", async (event) => {
event.preventDefault();
await perform(() => api(`/api/nodes/${state.selectedId}/move`, {
method: "POST",
body: JSON.stringify({
targetParentId: elements.moveParent.value,
childHandling: elements.moveChildren.value,
}),
}));
});
elements.deleteButton.addEventListener("click", async () => {
const node = state.nodes.get(state.selectedId);
if (!node || !window.confirm(`Delete ${node.name}?`)) {
return;
}
await perform(async () => {
await api(`/api/nodes/${node.id}`, { method: "DELETE" });
state.selectedId = null;
});
});
async function perform(action) {
try {
setStatus("");
await action();
await loadTree();
} catch (error) {
setStatus(error.message);
}
}
function setStatus(message) {
elements.status.textContent = message;
}
loadTree();
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Box Manifest</title>
<script src="/app.js" defer></script>
</head>
<body>
<h1>Box Manifest</h1>
<p id="status" role="status"></p>
<section>
<h2>Tree</h2>
<button id="refresh" type="button">Refresh</button>
<div id="tree">Loading…</div>
</section>
<section>
<h2>Selected node</h2>
<p id="selected">None</p>
<form id="update-form">
<h3>Update</h3>
<label>
Name
<input id="update-name" required>
</label>
<label>
Quantity
<input id="update-quantity" type="number" min="0" step="1">
</label>
<button type="submit">Update selected node</button>
</form>
<form id="move-form">
<h3>Move</h3>
<label>
New parent
<select id="move-parent" required></select>
</label>
<label>
Children
<select id="move-children">
<option value="WITH_SUBTREE">Move with subtree</option>
<option value="PROMOTE_CHILDREN">Move direct children up</option>
<option value="CHILDREN_TO_UNSORTED">Move direct children to Unsorted</option>
</select>
</label>
<button type="submit">Move selected node</button>
</form>
<button id="delete" type="button">Delete selected empty node</button>
</section>
<section>
<form id="create-form">
<h2>Create child</h2>
<p id="create-parent">Parent: Unsorted</p>
<label>
Name
<input id="create-name" required>
</label>
<label>
Quantity
<input id="create-quantity" type="number" min="0" step="1">
</label>
<button type="submit">Create</button>
</form>
</section>
</body>
</html>