diff --git a/.gitignore b/.gitignore
index aa724b7..0c1c994 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index ca16a99..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8734bbb
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/api/openapi.yaml b/api/openapi.yaml
new file mode 100644
index 0000000..b1c7a79
--- /dev/null
+++ b/api/openapi.yaml
@@ -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
diff --git a/api/tree-import.schema.json b/api/tree-import.schema.json
new file mode 100644
index 0000000..decb992
--- /dev/null
+++ b/api/tree-import.schema.json
@@ -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" }
+ }
+ }
+ }
+ }
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5cde20d..eeba566 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/app/boxmanifest/ExampleInstrumentedTest.kt b/app/src/androidTest/java/app/boxmanifest/ExampleInstrumentedTest.kt
deleted file mode 100644
index 8483597..0000000
--- a/app/src/androidTest/java/app/boxmanifest/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 518af96..3fe4c1a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,16 +1,28 @@
-
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/app/boxmanifest/BoxManifestApi.kt b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt
new file mode 100644
index 0000000..a42e8bf
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt
@@ -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 = emptyList(),
+ val lookupCode: String = "",
+)
+
+data class ImportNode(
+ val name: String,
+ val quantity: Long?,
+ val children: List,
+)
+
+data class TreeImportPreview(
+ val planId: String,
+ val nodeCount: Int,
+ val maximumDepth: Int,
+ val warnings: List,
+ val nodes: List,
+)
+
+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): List {
+ 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, 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) {
+ 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() }
+ },
+ )
+}
diff --git a/app/src/main/java/app/boxmanifest/MainActivity.kt b/app/src/main/java/app/boxmanifest/MainActivity.kt
index cca0a5f..453f6e9 100644
--- a/app/src/main/java/app/boxmanifest/MainActivity.kt
+++ b/app/src/main/java/app/boxmanifest/MainActivity.kt
@@ -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")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/boxmanifest/data/NodeRepository.kt b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt
new file mode 100644
index 0000000..2ccc05d
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt
@@ -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) {
+ 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, targetParentId: String, childHandling: ChildHandling) {
+ api.moveNodes(ids, targetParentId, childHandling)
+ }
+
+ suspend fun delete(id: String) {
+ api.deleteNode(id)
+ }
+
+ suspend fun deleteMany(ids: Set) {
+ api.deleteNodes(ids)
+ }
+}
diff --git a/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt
new file mode 100644
index 0000000..272e185
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt
@@ -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)
+}
diff --git a/app/src/main/java/app/boxmanifest/tree/BulkNodeParser.kt b/app/src/main/java/app/boxmanifest/tree/BulkNodeParser.kt
new file mode 100644
index 0000000..432e025
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/tree/BulkNodeParser.kt
@@ -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,
+ 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,
+ )
+}
diff --git a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt
new file mode 100644
index 0000000..56c00d0
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt
@@ -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(null) }
+ var searchOpen by remember { mutableStateOf(false) }
+ var selectedIds by remember(current?.id) { mutableStateOf>(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) -> 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 =
+ 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,
+ onNavigateBreadcrumb: (String) -> Unit,
+ onOpenChild: (String) -> Unit,
+ selectedIds: Set,
+ 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,
+ 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"
+}
diff --git a/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt b/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt
new file mode 100644
index 0000000..807608f
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/tree/TreeUiState.kt
@@ -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 = 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
+ 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? {
+ 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 = listOf(this) + children.flatMap { it.allNodes() }
+
+fun ApiNode.descendantIds(): Set = children.flatMap { it.allNodes() }.mapTo(mutableSetOf()) { it.id }
diff --git a/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt
new file mode 100644
index 0000000..8479d70
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt
@@ -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 = _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) = 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, 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) = 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 create(modelClass: Class): T =
+ TreeViewModel(repository) as T
+ }
+ }
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/ActionIcons.kt b/app/src/main/java/app/boxmanifest/ui/components/ActionIcons.kt
new file mode 100644
index 0000000..0b6db80
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/ActionIcons.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/HierarchyPicker.kt b/app/src/main/java/app/boxmanifest/ui/components/HierarchyPicker.kt
new file mode 100644
index 0000000..d26d7e0
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/HierarchyPicker.kt
@@ -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,
+ 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,
+ expandedIds: Set,
+ depth: Int = 0,
+): List {
+ 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? {
+ if (id == targetId) return listOf(id)
+ for (child in children) {
+ val childPath = child.pathTo(targetId)
+ if (childPath != null) return listOf(id) + childPath
+ }
+ return null
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt b/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt
new file mode 100644
index 0000000..cb121c9
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/NavigationIcons.kt
@@ -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)
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/NodeBreadcrumbs.kt b/app/src/main/java/app/boxmanifest/ui/components/NodeBreadcrumbs.kt
new file mode 100644
index 0000000..a16f666
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/NodeBreadcrumbs.kt
@@ -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,
+ 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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt b/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt
new file mode 100644
index 0000000..162ea91
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/NodeRow.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/components/PropertyRow.kt b/app/src/main/java/app/boxmanifest/ui/components/PropertyRow.kt
new file mode 100644
index 0000000..4204eb2
--- /dev/null
+++ b/app/src/main/java/app/boxmanifest/ui/components/PropertyRow.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/theme/Color.kt b/app/src/main/java/app/boxmanifest/ui/theme/Color.kt
index e809425..711aa45 100644
--- a/app/src/main/java/app/boxmanifest/ui/theme/Color.kt
+++ b/app/src/main/java/app/boxmanifest/ui/theme/Color.kt
@@ -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)
\ No newline at end of file
+val DarkInk = Color(0xFFF1F1ED)
+val DarkPaper = Color(0xFF171717)
+val DarkMutedPaper = Color(0xFF292927)
+val DarkMutedInk = Color(0xFFAAAAA4)
diff --git a/app/src/main/java/app/boxmanifest/ui/theme/Theme.kt b/app/src/main/java/app/boxmanifest/ui/theme/Theme.kt
index c80d5cc..7ec3c1a 100644
--- a/app/src/main/java/app/boxmanifest/ui/theme/Theme.kt
+++ b/app/src/main/java/app/boxmanifest/ui/theme/Theme.kt
@@ -1,58 +1,43 @@
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,
typography = Typography,
content = content
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/app/boxmanifest/ui/theme/Type.kt b/app/src/main/java/app/boxmanifest/ui/theme/Type.kt
index 43f4362..03e8a8e 100644
--- a/app/src/main/java/app/boxmanifest/ui/theme/Type.kt
+++ b/app/src/main/java/app/boxmanifest/ui/theme/Type.kt
@@ -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
- )
- */
-)
\ No newline at end of file
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.sp,
+ ),
+)
diff --git a/app/src/main/res/drawable/ic_action_more_24.xml b/app/src/main/res/drawable/ic_action_more_24.xml
new file mode 100644
index 0000000..501de24
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_more_24.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_arrow_down_18.xml b/app/src/main/res/drawable/ic_arrow_down_18.xml
new file mode 100644
index 0000000..c9dd9dd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_down_18.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_arrow_right_18.xml b/app/src/main/res/drawable/ic_arrow_right_18.xml
new file mode 100644
index 0000000..893e815
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_right_18.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_back_24.xml b/app/src/main/res/drawable/ic_back_24.xml
new file mode 100644
index 0000000..3193160
--- /dev/null
+++ b/app/src/main/res/drawable/ic_back_24.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_clear_18.xml b/app/src/main/res/drawable/ic_clear_18.xml
new file mode 100644
index 0000000..7675797
--- /dev/null
+++ b/app/src/main/res/drawable/ic_clear_18.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_square.xml b/app/src/main/res/drawable/ic_edit_square.xml
new file mode 100644
index 0000000..3d20e25
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_square.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index 4df9255..0000000
--- a/app/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997..0000000
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..679beb7
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/test/java/app/boxmanifest/BulkNodeParserTest.kt b/app/src/test/java/app/boxmanifest/BulkNodeParserTest.kt
new file mode 100644
index 0000000..41e6910
--- /dev/null
+++ b/app/src/test/java/app/boxmanifest/BulkNodeParserTest.kt
@@ -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)
+ }
+}
diff --git a/app/src/test/java/app/boxmanifest/ExampleUnitTest.kt b/app/src/test/java/app/boxmanifest/ExampleUnitTest.kt
deleted file mode 100644
index d306a17..0000000
--- a/app/src/test/java/app/boxmanifest/ExampleUnitTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/app/boxmanifest/LookupCodeTest.kt b/app/src/test/java/app/boxmanifest/LookupCodeTest.kt
new file mode 100644
index 0000000..7c883be
--- /dev/null
+++ b/app/src/test/java/app/boxmanifest/LookupCodeTest.kt
@@ -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"))
+ }
+}
diff --git a/app/src/test/java/app/boxmanifest/TreePathTest.kt b/app/src/test/java/app/boxmanifest/TreePathTest.kt
new file mode 100644
index 0000000..561602f
--- /dev/null
+++ b/app/src/test/java/app/boxmanifest/TreePathTest.kt
@@ -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"))
+ }
+}
diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md
new file mode 100644
index 0000000..765e11a
--- /dev/null
+++ b/docs/PROJECT_PLAN.md
@@ -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.
diff --git a/docs/decisions/0001-universal-node-tree.md b/docs/decisions/0001-universal-node-tree.md
new file mode 100644
index 0000000..6c07185
--- /dev/null
+++ b/docs/decisions/0001-universal-node-tree.md
@@ -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.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 394ece7..724e40f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
-
diff --git a/server/cmd/box-manifest-server/main.go b/server/cmd/box-manifest-server/main.go
new file mode 100644
index 0000000..25b9ba0
--- /dev/null
+++ b/server/cmd/box-manifest-server/main.go
@@ -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)
+ }
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..b26ae9f
--- /dev/null
+++ b/server/go.mod
@@ -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
+)
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..3efffe8
--- /dev/null
+++ b/server/go.sum
@@ -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=
diff --git a/server/internal/httpapi/handler.go b/server/internal/httpapi/handler.go
new file mode 100644
index 0000000..e0a9573
--- /dev/null
+++ b/server/internal/httpapi/handler.go
@@ -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)
+}
diff --git a/server/internal/httpapi/handler_test.go b/server/internal/httpapi/handler_test.go
new file mode 100644
index 0000000..2c6048f
--- /dev/null
+++ b/server/internal/httpapi/handler_test.go
@@ -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
+}
diff --git a/server/internal/importer/service.go b/server/internal/importer/service.go
new file mode 100644
index 0000000..564d1c9
--- /dev/null
+++ b/server/internal/importer/service.go
@@ -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
+}
diff --git a/server/internal/importer/service_test.go b/server/internal/importer/service_test.go
new file mode 100644
index 0000000..11cdaa2
--- /dev/null
+++ b/server/internal/importer/service_test.go
@@ -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
+}
diff --git a/server/internal/tree/store.go b/server/internal/tree/store.go
new file mode 100644
index 0000000..8442744
--- /dev/null
+++ b/server/internal/tree/store.go
@@ -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, ¬Null, &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
+}
diff --git a/server/internal/tree/store_test.go b/server/internal/tree/store_test.go
new file mode 100644
index 0000000..39c76b1
--- /dev/null
+++ b/server/internal/tree/store_test.go
@@ -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
+}
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..401bfd7
--- /dev/null
+++ b/web/app.js
@@ -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();
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..73cfea1
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Box Manifest
+
+
+
+ Box Manifest
+
+
+
+ Tree
+
+ Loading…
+
+
+
+ Selected node
+ None
+
+
+
+
+
+
+
+
+
+
+