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

+ +
+

Update

+ + + +
+ +
+

Move

+ + + +
+ + +
+ +
+
+

Create child

+

Parent: Unsorted

+ + + +
+
+ +