initial Box Manifest server and Android client
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
-11
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ android {
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
optimization {
|
||||
enable = false
|
||||
@@ -41,16 +43,17 @@ dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.google.code.scanner)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.androidx.print)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("app.boxmanifest", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Manifest">
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode_ui" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class ApiNode(
|
||||
val id: String,
|
||||
val parentId: String?,
|
||||
val name: String,
|
||||
val quantity: Long?,
|
||||
val children: List<ApiNode> = emptyList(),
|
||||
val lookupCode: String = "",
|
||||
)
|
||||
|
||||
data class ImportNode(
|
||||
val name: String,
|
||||
val quantity: Long?,
|
||||
val children: List<ImportNode>,
|
||||
)
|
||||
|
||||
data class TreeImportPreview(
|
||||
val planId: String,
|
||||
val nodeCount: Int,
|
||||
val maximumDepth: Int,
|
||||
val warnings: List<String>,
|
||||
val nodes: List<ImportNode>,
|
||||
)
|
||||
|
||||
enum class ChildHandling {
|
||||
WITH_SUBTREE,
|
||||
PROMOTE_CHILDREN,
|
||||
CHILDREN_TO_UNSORTED,
|
||||
}
|
||||
|
||||
class BoxManifestApi(
|
||||
private val baseUrl: String = "http://127.0.0.1:8080",
|
||||
) {
|
||||
fun canonicalNodeUrl(lookupCode: String): String = "$baseUrl/n/$lookupCode"
|
||||
|
||||
suspend fun getTree(): ApiNode = request("GET", "/api/tree").toNode()
|
||||
|
||||
suspend fun createNode(parentId: String?, name: String, quantity: Long?): ApiNode {
|
||||
val body = JSONObject()
|
||||
.put("name", name)
|
||||
.put("quantity", quantity ?: JSONObject.NULL)
|
||||
if (parentId != null) body.put("parentId", parentId)
|
||||
return request("POST", "/api/nodes", body).toNode()
|
||||
}
|
||||
|
||||
suspend fun createNodes(parentId: String, names: List<String>): List<ApiNode> {
|
||||
val values = org.json.JSONArray()
|
||||
names.forEach(values::put)
|
||||
val response = request(
|
||||
"POST",
|
||||
"/api/nodes/bulk",
|
||||
JSONObject().put("parentId", parentId).put("names", values),
|
||||
)
|
||||
val nodes = response.getJSONArray("nodes")
|
||||
return List(nodes.length()) { index -> nodes.getJSONObject(index).toNode() }
|
||||
}
|
||||
|
||||
suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview {
|
||||
val manifest = JSONObject(manifestJson)
|
||||
val response = request(
|
||||
"POST",
|
||||
"/api/imports/tree/preview",
|
||||
JSONObject().put("parentId", parentId).put("manifest", manifest),
|
||||
)
|
||||
val summary = response.getJSONObject("summary")
|
||||
val warningsJson = response.getJSONArray("warnings")
|
||||
val nodesJson = response.getJSONObject("manifest").getJSONArray("nodes")
|
||||
return TreeImportPreview(
|
||||
planId = response.getString("planId"),
|
||||
nodeCount = summary.getInt("nodes"),
|
||||
maximumDepth = summary.getInt("maximumDepth"),
|
||||
warnings = List(warningsJson.length()) { warningsJson.getString(it) },
|
||||
nodes = List(nodesJson.length()) { nodesJson.getJSONObject(it).toImportNode() },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun commitTreeImport(planId: String) {
|
||||
request(
|
||||
"POST",
|
||||
"/api/imports/tree/commit",
|
||||
JSONObject().put("planId", planId),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateNode(id: String, name: String, quantity: Long?): ApiNode =
|
||||
request(
|
||||
"PATCH",
|
||||
"/api/nodes/$id",
|
||||
JSONObject()
|
||||
.put("name", name)
|
||||
.put("quantity", quantity ?: JSONObject.NULL),
|
||||
).toNode()
|
||||
|
||||
suspend fun moveNode(id: String, targetParentId: String, handling: ChildHandling): ApiNode =
|
||||
request(
|
||||
"POST",
|
||||
"/api/nodes/$id/move",
|
||||
JSONObject()
|
||||
.put("targetParentId", targetParentId)
|
||||
.put("childHandling", handling.name),
|
||||
).toNode()
|
||||
|
||||
suspend fun moveNodes(ids: Set<String>, targetParentId: String, handling: ChildHandling) {
|
||||
val values = org.json.JSONArray()
|
||||
ids.forEach(values::put)
|
||||
request(
|
||||
"POST",
|
||||
"/api/nodes/bulk/move",
|
||||
JSONObject()
|
||||
.put("nodeIds", values)
|
||||
.put("targetParentId", targetParentId)
|
||||
.put("childHandling", handling.name),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteNode(id: String) {
|
||||
request("DELETE", "/api/nodes/$id", expectBody = false)
|
||||
}
|
||||
|
||||
suspend fun deleteNodes(ids: Set<String>) {
|
||||
val values = org.json.JSONArray()
|
||||
ids.forEach(values::put)
|
||||
request(
|
||||
"POST",
|
||||
"/api/nodes/bulk/delete",
|
||||
JSONObject().put("nodeIds", values),
|
||||
expectBody = false,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: JSONObject? = null,
|
||||
expectBody: Boolean = true,
|
||||
): JSONObject = withContext(Dispatchers.IO) {
|
||||
val connection = URL(baseUrl + path).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = 5_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.bufferedWriter().use { it.write(body.toString()) }
|
||||
}
|
||||
|
||||
val status = connection.responseCode
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (status !in 200..299) {
|
||||
val message = runCatching { JSONObject(text).getString("message") }
|
||||
.getOrDefault("Request failed with status $status")
|
||||
error(message)
|
||||
}
|
||||
if (!expectBody || text.isBlank()) JSONObject() else JSONObject(text)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toNode(): ApiNode {
|
||||
val childValues = optJSONArray("children")
|
||||
val children = if (childValues == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(childValues.length()) { index -> childValues.getJSONObject(index).toNode() }
|
||||
}
|
||||
return ApiNode(
|
||||
id = getString("id"),
|
||||
lookupCode = getString("lookupCode"),
|
||||
parentId = if (isNull("parentId")) null else getString("parentId"),
|
||||
name = getString("name"),
|
||||
quantity = if (isNull("quantity")) null else getLong("quantity"),
|
||||
children = children,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.toImportNode(): ImportNode {
|
||||
val childrenJson = optJSONArray("children")
|
||||
return ImportNode(
|
||||
name = getString("name"),
|
||||
quantity = if (isNull("quantity")) null else getLong("quantity"),
|
||||
children = if (childrenJson == null) emptyList() else {
|
||||
List(childrenJson.length()) { childrenJson.getJSONObject(it).toImportNode() }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.boxmanifest.tree.TreeScreen
|
||||
import app.boxmanifest.tree.TreeViewModel
|
||||
import app.boxmanifest.ui.theme.ManifestTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -19,29 +19,14 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ManifestTheme {
|
||||
val treeViewModel: TreeViewModel = viewModel(factory = TreeViewModel.factory())
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
TreeScreen(
|
||||
viewModel = treeViewModel,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
ManifestTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package app.boxmanifest.data
|
||||
|
||||
import app.boxmanifest.ApiNode
|
||||
import app.boxmanifest.BoxManifestApi
|
||||
import app.boxmanifest.ChildHandling
|
||||
import app.boxmanifest.TreeImportPreview
|
||||
|
||||
class NodeRepository(
|
||||
private val api: BoxManifestApi = BoxManifestApi(),
|
||||
) {
|
||||
suspend fun loadTree(): ApiNode = api.getTree()
|
||||
|
||||
suspend fun createChild(parentId: String, name: String, quantity: Long?) {
|
||||
api.createNode(parentId, name, quantity)
|
||||
}
|
||||
|
||||
suspend fun createChildren(parentId: String, names: List<String>) {
|
||||
api.createNodes(parentId, names)
|
||||
}
|
||||
|
||||
suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview =
|
||||
api.previewTreeImport(parentId, manifestJson)
|
||||
|
||||
suspend fun commitTreeImport(planId: String) {
|
||||
api.commitTreeImport(planId)
|
||||
}
|
||||
|
||||
suspend fun update(id: String, name: String, quantity: Long?) {
|
||||
api.updateNode(id, name, quantity)
|
||||
}
|
||||
|
||||
suspend fun move(id: String, targetParentId: String, childHandling: ChildHandling) {
|
||||
api.moveNode(id, targetParentId, childHandling)
|
||||
}
|
||||
|
||||
suspend fun moveMany(ids: Set<String>, targetParentId: String, childHandling: ChildHandling) {
|
||||
api.moveNodes(ids, targetParentId, childHandling)
|
||||
}
|
||||
|
||||
suspend fun delete(id: String) {
|
||||
api.deleteNode(id)
|
||||
}
|
||||
|
||||
suspend fun deleteMany(ids: Set<String>) {
|
||||
api.deleteNodes(ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package app.boxmanifest.labels
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.print.PrintHelper
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
private val lookupCodePattern = Regex("^[A-Z2-7]{6}$")
|
||||
|
||||
fun createLabelBitmap(name: String, breadcrumb: String, lookupCode: String, canonicalUrl: String): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(1200, 800, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK }
|
||||
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textSize = 68f
|
||||
canvas.drawText(name.take(30), 64f, 105f, paint)
|
||||
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
paint.textSize = 34f
|
||||
paint.color = Color.DKGRAY
|
||||
canvas.drawText(breadcrumb.take(58), 64f, 165f, paint)
|
||||
|
||||
val matrix = QRCodeWriter().encode(
|
||||
canonicalUrl,
|
||||
BarcodeFormat.QR_CODE,
|
||||
500,
|
||||
500,
|
||||
mapOf(EncodeHintType.MARGIN to 1),
|
||||
)
|
||||
val qr = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
|
||||
for (y in 0 until matrix.height) {
|
||||
for (x in 0 until matrix.width) {
|
||||
qr.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
canvas.drawBitmap(qr, 64f, 225f, paint)
|
||||
|
||||
paint.color = Color.BLACK
|
||||
paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
|
||||
paint.textSize = 72f
|
||||
canvas.drawText(lookupCode, 650f, 455f, paint)
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
paint.textSize = 28f
|
||||
paint.color = Color.DKGRAY
|
||||
canvas.drawText("Lookup code", 650f, 505f, paint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun shareLabel(context: Context, bitmap: Bitmap, lookupCode: String) {
|
||||
val directory = File(context.cacheDir, "labels").apply { mkdirs() }
|
||||
val file = File(directory, "box-manifest-$lookupCode.png")
|
||||
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share label"))
|
||||
}
|
||||
|
||||
fun printLabel(context: Context, bitmap: Bitmap, name: String) {
|
||||
PrintHelper(context).apply {
|
||||
colorMode = PrintHelper.COLOR_MODE_MONOCHROME
|
||||
scaleMode = PrintHelper.SCALE_MODE_FIT
|
||||
orientation = PrintHelper.ORIENTATION_LANDSCAPE
|
||||
}.printBitmap("Box Manifest — $name", bitmap)
|
||||
}
|
||||
|
||||
fun scanNodeCode(activity: Activity, onCode: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val options = GmsBarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.enableAutoZoom()
|
||||
.build()
|
||||
GmsBarcodeScanning.getClient(activity, options).startScan()
|
||||
.addOnSuccessListener { barcode ->
|
||||
val code = barcode.rawValue?.let(::extractLookupCode)
|
||||
if (code == null) onError("That QR code is not a Box Manifest label") else onCode(code)
|
||||
}
|
||||
.addOnFailureListener { onError(it.message ?: "Could not open QR scanner") }
|
||||
}
|
||||
|
||||
fun extractLookupCode(value: String): String? {
|
||||
val normalized = value.trim().uppercase()
|
||||
if (lookupCodePattern.matches(normalized)) return normalized
|
||||
return normalized.substringAfterLast('/').substringBefore('?').takeIf(lookupCodePattern::matches)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package app.boxmanifest.tree
|
||||
|
||||
enum class BulkDelimiter(val label: String) {
|
||||
AUTO("Auto"),
|
||||
NEW_LINES("New lines"),
|
||||
COMMAS("Commas"),
|
||||
}
|
||||
|
||||
data class BulkParseResult(
|
||||
val names: List<String>,
|
||||
val delimiter: BulkDelimiter,
|
||||
)
|
||||
|
||||
fun parseBulkNodes(input: String, requestedDelimiter: BulkDelimiter): BulkParseResult {
|
||||
val delimiter = when (requestedDelimiter) {
|
||||
BulkDelimiter.AUTO -> if ('\n' in input || '\r' in input) {
|
||||
BulkDelimiter.NEW_LINES
|
||||
} else {
|
||||
BulkDelimiter.COMMAS
|
||||
}
|
||||
else -> requestedDelimiter
|
||||
}
|
||||
val parts = when (delimiter) {
|
||||
BulkDelimiter.NEW_LINES -> input.split(Regex("\\r?\\n|\\r"))
|
||||
BulkDelimiter.COMMAS -> input.split(',')
|
||||
BulkDelimiter.AUTO -> error("Auto delimiter must be resolved")
|
||||
}
|
||||
return BulkParseResult(
|
||||
names = parts.map(String::trim).filter(String::isNotEmpty),
|
||||
delimiter = delimiter,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
|
||||
package app.boxmanifest.tree
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.boxmanifest.ApiNode
|
||||
import app.boxmanifest.BoxManifestApi
|
||||
import app.boxmanifest.ChildHandling
|
||||
import app.boxmanifest.ImportNode
|
||||
import app.boxmanifest.TreeImportPreview
|
||||
import app.boxmanifest.ui.components.NodeRow
|
||||
import app.boxmanifest.ui.components.PropertyRow
|
||||
import app.boxmanifest.ui.components.HierarchyPicker
|
||||
import app.boxmanifest.ui.components.BackIcon
|
||||
import app.boxmanifest.ui.components.CloseIcon
|
||||
import app.boxmanifest.ui.components.MoreIcon
|
||||
import app.boxmanifest.ui.components.NodeBreadcrumbs
|
||||
import app.boxmanifest.ui.components.NewNodeIcon
|
||||
import app.boxmanifest.ui.components.SearchIcon
|
||||
import app.boxmanifest.labels.createLabelBitmap
|
||||
import app.boxmanifest.labels.printLabel
|
||||
import app.boxmanifest.labels.scanNodeCode
|
||||
import app.boxmanifest.labels.shareLabel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class OpenSheet { CREATE, IMPORT, EDIT, ACTIONS, LABEL, MOVE, MULTI_MOVE, MULTI_DELETE }
|
||||
|
||||
@Composable
|
||||
fun TreeScreen(
|
||||
viewModel: TreeViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val current = state.currentNode
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val activity = LocalContext.current as Activity
|
||||
var openSheet by remember { mutableStateOf<OpenSheet?>(null) }
|
||||
var searchOpen by remember { mutableStateOf(false) }
|
||||
var selectedIds by remember(current?.id) { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val selectedNodes = current?.children.orEmpty().filter { it.id in selectedIds }
|
||||
val selectionCanDelete = selectedNodes.isNotEmpty() && selectedNodes.all { it.children.isEmpty() }
|
||||
|
||||
BackHandler(enabled = state.canGoBack || openSheet != null || selectedIds.isNotEmpty() || searchOpen) {
|
||||
when {
|
||||
openSheet != null -> openSheet = null
|
||||
searchOpen -> searchOpen = false
|
||||
selectedIds.isNotEmpty() -> selectedIds = emptySet()
|
||||
else -> viewModel.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let {
|
||||
snackbarHost.showSnackbar(it)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
NodeTopBar(
|
||||
canGoBack = state.canGoBack,
|
||||
protectedNode = current?.id == "root" || current?.id == "unsorted",
|
||||
onBack = viewModel::goBack,
|
||||
onRefresh = viewModel::refresh,
|
||||
onActions = { openSheet = OpenSheet.ACTIONS },
|
||||
selectedCount = selectedIds.size,
|
||||
selectionCanDelete = selectionCanDelete,
|
||||
onCancelSelection = { selectedIds = emptySet() },
|
||||
onMoveSelection = { openSheet = OpenSheet.MULTI_MOVE },
|
||||
onDeleteSelection = { openSheet = OpenSheet.MULTI_DELETE },
|
||||
)
|
||||
if (state.loading && current == null) {
|
||||
Text("Loading…", modifier = Modifier.padding(20.dp))
|
||||
} else if (current != null) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
NodeScreen(
|
||||
node = current,
|
||||
breadcrumbs = state.breadcrumbs,
|
||||
onNavigateBreadcrumb = viewModel::openBreadcrumb,
|
||||
onOpenChild = viewModel::openNode,
|
||||
selectedIds = selectedIds,
|
||||
onToggleSelection = { id ->
|
||||
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
|
||||
},
|
||||
onStartSelection = { id -> selectedIds = setOf(id) },
|
||||
)
|
||||
if (selectedIds.isEmpty()) {
|
||||
FloatingActionButton(
|
||||
onClick = { searchOpen = true },
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp).size(48.dp),
|
||||
shape = CircleShape,
|
||||
containerColor = Color(0xFF363636),
|
||||
contentColor = Color.White,
|
||||
) { SearchIcon(Modifier.size(24.dp)) }
|
||||
FloatingActionButton(
|
||||
onClick = { openSheet = OpenSheet.CREATE },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(20.dp).size(48.dp),
|
||||
shape = CircleShape,
|
||||
containerColor = Color(0xFF363636),
|
||||
contentColor = Color.White,
|
||||
) { NewNodeIcon(Modifier.size(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackbarHost)
|
||||
|
||||
if (searchOpen && state.root != null) {
|
||||
SearchDialog(
|
||||
root = state.root!!,
|
||||
onDismiss = { searchOpen = false },
|
||||
onOpen = { id ->
|
||||
viewModel.openBreadcrumb(id)
|
||||
searchOpen = false
|
||||
},
|
||||
onScan = {
|
||||
scanNodeCode(
|
||||
activity = activity,
|
||||
onCode = { code ->
|
||||
val node = state.root?.allNodes()?.find { it.lookupCode == code }
|
||||
if (node == null) {
|
||||
coroutineScope.launch { snackbarHost.showSnackbar("No node has code $code") }
|
||||
} else {
|
||||
viewModel.openBreadcrumb(node.id)
|
||||
searchOpen = false
|
||||
}
|
||||
},
|
||||
onError = { message -> coroutineScope.launch { snackbarHost.showSnackbar(message) } },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (openSheet) {
|
||||
OpenSheet.CREATE -> current?.let { node ->
|
||||
QuickAddDialog(
|
||||
parentName = node.name,
|
||||
onDismiss = { openSheet = null },
|
||||
onAddOne = { name, quantity ->
|
||||
viewModel.createChild(name, quantity)
|
||||
openSheet = null
|
||||
},
|
||||
onAddMultiple = { names ->
|
||||
viewModel.createChildren(names)
|
||||
openSheet = null
|
||||
},
|
||||
onImportTree = {
|
||||
viewModel.clearImportPreview()
|
||||
openSheet = OpenSheet.IMPORT
|
||||
},
|
||||
)
|
||||
}
|
||||
OpenSheet.IMPORT -> current?.let { node ->
|
||||
ImportTreeDialog(
|
||||
parentName = node.name,
|
||||
preview = state.importPreview,
|
||||
loading = state.loading,
|
||||
onDismiss = {
|
||||
viewModel.clearImportPreview()
|
||||
openSheet = null
|
||||
},
|
||||
onPreview = viewModel::previewTreeImport,
|
||||
onCommit = { planId ->
|
||||
viewModel.commitTreeImport(planId)
|
||||
openSheet = null
|
||||
},
|
||||
onEdit = viewModel::clearImportPreview,
|
||||
)
|
||||
}
|
||||
OpenSheet.EDIT -> current?.let { node ->
|
||||
NodeEditorSheet(
|
||||
title = "Edit",
|
||||
initialName = node.name,
|
||||
initialQuantity = node.quantity,
|
||||
actionLabel = "Save",
|
||||
onDismiss = { openSheet = null },
|
||||
onSubmit = { name, quantity ->
|
||||
viewModel.updateCurrent(name, quantity)
|
||||
openSheet = null
|
||||
},
|
||||
)
|
||||
}
|
||||
OpenSheet.ACTIONS -> current?.let { node ->
|
||||
ActionSheet(
|
||||
node = node,
|
||||
onDismiss = { openSheet = null },
|
||||
onLabel = { openSheet = OpenSheet.LABEL },
|
||||
onEdit = { openSheet = OpenSheet.EDIT },
|
||||
onMove = { openSheet = OpenSheet.MOVE },
|
||||
onDelete = {
|
||||
viewModel.deleteCurrent()
|
||||
openSheet = null
|
||||
},
|
||||
)
|
||||
}
|
||||
OpenSheet.LABEL -> current?.let { node ->
|
||||
LabelSheet(
|
||||
node = node,
|
||||
breadcrumb = state.breadcrumbs.joinToString(" / ") { it.name },
|
||||
onDismiss = { openSheet = null },
|
||||
)
|
||||
}
|
||||
OpenSheet.MOVE -> current?.let { node ->
|
||||
MoveSheet(
|
||||
node = node,
|
||||
root = state.root,
|
||||
onDismiss = { openSheet = null },
|
||||
onMove = { target, handling ->
|
||||
viewModel.moveCurrent(target, handling)
|
||||
openSheet = null
|
||||
},
|
||||
)
|
||||
}
|
||||
OpenSheet.MULTI_MOVE -> state.root?.let { root ->
|
||||
MultiMoveSheet(
|
||||
nodes = selectedNodes,
|
||||
root = root,
|
||||
onDismiss = { openSheet = null },
|
||||
onMove = { target, handling ->
|
||||
viewModel.moveMany(selectedIds, target, handling)
|
||||
selectedIds = emptySet()
|
||||
openSheet = null
|
||||
},
|
||||
)
|
||||
}
|
||||
OpenSheet.MULTI_DELETE -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = { openSheet = null },
|
||||
title = { Text("Delete ${selectedIds.size} nodes?") },
|
||||
text = { Text("Only empty nodes can be deleted. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
viewModel.deleteMany(selectedIds)
|
||||
selectedIds = emptySet()
|
||||
openSheet = null
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { openSheet = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickAddDialog(
|
||||
parentName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAddOne: (String, Long?) -> Unit,
|
||||
onAddMultiple: (List<String>) -> Unit,
|
||||
onImportTree: () -> Unit,
|
||||
) {
|
||||
var input by remember { mutableStateOf("") }
|
||||
var quantity by remember { mutableStateOf("") }
|
||||
var addMultiple by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val parsed = remember(input) { parseBulkNodes(input, BulkDelimiter.AUTO) }
|
||||
val hasDelimiter = remember(input) { input.any { it == ',' || it == '\n' || it == '\r' } }
|
||||
val canAddMultiple = hasDelimiter && parsed.names.size > 1
|
||||
val validQuantity = quantity.isBlank() || quantity.toLongOrNull()?.let { it >= 0 } == true
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
LaunchedEffect(canAddMultiple) {
|
||||
if (!canAddMultiple) addMultiple = false
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.92f),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("New", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Inside: $parentName", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text("Type or dictate a name") },
|
||||
minLines = 3,
|
||||
maxLines = 6,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
if (canAddMultiple) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { addMultiple = !addMultiple },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = addMultiple, onCheckedChange = { addMultiple = it })
|
||||
Column {
|
||||
Text("Add as ${parsed.names.size} separate nodes")
|
||||
Text(
|
||||
"Detected ${parsed.delimiter.label.lowercase()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addMultiple) {
|
||||
Column {
|
||||
parsed.names.take(4).forEach { name ->
|
||||
Text("• $name", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (parsed.names.size > 4) {
|
||||
Text(
|
||||
"+ ${parsed.names.size - 4} more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = quantity,
|
||||
onValueChange = { quantity = it },
|
||||
label = { Text("Quantity (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
OutlinedButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = {
|
||||
if (addMultiple) {
|
||||
onAddMultiple(parsed.names)
|
||||
} else {
|
||||
onAddOne(input.trim(), quantity.takeIf(String::isNotBlank)?.toLong())
|
||||
}
|
||||
},
|
||||
enabled = input.isNotBlank() && validQuantity && (!addMultiple || parsed.names.isNotEmpty()),
|
||||
) { Text(if (addMultiple) "Add ${parsed.names.size}" else "Add") }
|
||||
}
|
||||
Text(
|
||||
"Import a tree manifest",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable(onClick = onImportTree).padding(vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ImportPreviewRow(val node: ImportNode, val depth: Int)
|
||||
|
||||
@Composable
|
||||
private fun ImportTreeDialog(
|
||||
parentName: String,
|
||||
preview: TreeImportPreview?,
|
||||
loading: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPreview: (String) -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
) {
|
||||
var manifestJson by remember {
|
||||
mutableStateOf(
|
||||
"""{
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Box A",
|
||||
"children": [
|
||||
{ "name": "Example item", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(preview) {
|
||||
if (preview == null) {
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onDismiss) { BackIcon(Modifier.size(24.dp)) }
|
||||
Text("Import tree", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Text("Inside: $parentName", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (preview == null) {
|
||||
Text(
|
||||
"Paste a JSON manifest produced by an AI or another tool.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = manifestJson,
|
||||
onValueChange = { manifestJson = it },
|
||||
label = { Text("Tree manifest JSON") },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { onPreview(manifestJson) },
|
||||
enabled = manifestJson.isNotBlank() && !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (loading) "Validating…" else "Preview import") }
|
||||
} else {
|
||||
Text(
|
||||
"${preview.nodeCount} nodes · maximum depth ${preview.maximumDepth}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
preview.warnings.forEach { warning -> Text("Warning: $warning") }
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(preview.nodes.flatMap { it.previewRows() }) { row ->
|
||||
Text(
|
||||
text = buildString {
|
||||
append(row.node.name)
|
||||
row.node.quantity?.let { append(" ($it)") }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (row.depth * 16).dp, top = 4.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
OutlinedButton(onClick = onEdit, enabled = !loading) { Text("Edit") }
|
||||
Button(
|
||||
onClick = { onCommit(preview.planId) },
|
||||
enabled = !loading,
|
||||
) { Text(if (loading) "Importing…" else "Import ${preview.nodeCount}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImportNode.previewRows(depth: Int = 0): List<ImportPreviewRow> =
|
||||
listOf(ImportPreviewRow(this, depth)) + children.flatMap { it.previewRows(depth + 1) }
|
||||
|
||||
private data class SearchResult(val node: ApiNode, val location: String)
|
||||
|
||||
@Composable
|
||||
private fun SearchDialog(
|
||||
root: ApiNode,
|
||||
onDismiss: () -> Unit,
|
||||
onOpen: (String) -> Unit,
|
||||
onScan: () -> Unit,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val results = remember(root, query) {
|
||||
if (query.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
root.allNodes()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.id != "root" && (
|
||||
it.name.contains(query.trim(), ignoreCase = true) ||
|
||||
it.lookupCode.contains(query.trim(), ignoreCase = true)
|
||||
)
|
||||
}
|
||||
.map { node ->
|
||||
val ancestors = root.pathTo(node.id).orEmpty().dropLast(1)
|
||||
SearchResult(node, ancestors.joinToString(" / ") { it.name })
|
||||
}
|
||||
.sortedBy { it.node.name.lowercase() }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.8f)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search") },
|
||||
leadingIcon = { SearchIcon(Modifier.size(20.dp)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
OutlinedButton(onClick = onScan, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Text("Scan QR code")
|
||||
}
|
||||
when {
|
||||
query.isBlank() -> Unit
|
||||
results.isEmpty() -> Text(
|
||||
"No results",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
items(results, key = { it.node.id }) { result ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpen(result.node.id) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
result.node.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
"${result.location} · ${result.node.lookupCode}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeTopBar(
|
||||
canGoBack: Boolean,
|
||||
protectedNode: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onActions: () -> Unit,
|
||||
selectedCount: Int,
|
||||
selectionCanDelete: Boolean,
|
||||
onCancelSelection: () -> Unit,
|
||||
onMoveSelection: () -> Unit,
|
||||
onDeleteSelection: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selectedCount > 0) {
|
||||
IconButton(onClick = onCancelSelection) { CloseIcon(Modifier.size(19.dp)) }
|
||||
Text("$selectedCount selected")
|
||||
} else {
|
||||
if (canGoBack) {
|
||||
IconButton(onClick = onBack) { BackIcon(Modifier.size(24.dp)) }
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (selectedCount > 0) {
|
||||
Text("Move", modifier = Modifier.clickable(onClick = onMoveSelection).padding(10.dp))
|
||||
Text(
|
||||
"Delete",
|
||||
color = if (selectionCanDelete) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable(enabled = selectionCanDelete, onClick = onDeleteSelection).padding(10.dp),
|
||||
)
|
||||
} else {
|
||||
Text("Refresh", modifier = Modifier.clickable(onClick = onRefresh).padding(12.dp))
|
||||
if (!protectedNode) {
|
||||
IconButton(onClick = onActions) { MoreIcon(Modifier.size(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeScreen(
|
||||
node: ApiNode,
|
||||
breadcrumbs: List<ApiNode>,
|
||||
onNavigateBreadcrumb: (String) -> Unit,
|
||||
onOpenChild: (String) -> Unit,
|
||||
selectedIds: Set<String>,
|
||||
onToggleSelection: (String) -> Unit,
|
||||
onStartSelection: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
NodeBreadcrumbs(
|
||||
nodes = breadcrumbs,
|
||||
onNavigate = onNavigateBreadcrumb,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
node.quantity?.let { PropertyRow("Quantity", it.toString()) }
|
||||
}
|
||||
if (node.children.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Nothing here yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(node.children, key = { it.id }) { child ->
|
||||
NodeRow(
|
||||
node = child,
|
||||
onClick = {
|
||||
if (selectedIds.isEmpty()) onOpenChild(child.id) else if (child.id != "unsorted") onToggleSelection(child.id)
|
||||
},
|
||||
onLongClick = if (child.id == "root" || child.id == "unsorted") null else {
|
||||
{ onStartSelection(child.id) }
|
||||
},
|
||||
selected = child.id in selectedIds,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeEditorSheet(
|
||||
title: String,
|
||||
initialName: String,
|
||||
initialQuantity: Long?,
|
||||
actionLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSubmit: (String, Long?) -> Unit,
|
||||
) {
|
||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||
var quantity by remember(initialQuantity) { mutableStateOf(initialQuantity?.toString().orEmpty()) }
|
||||
val validQuantity = quantity.isBlank() || quantity.toLongOrNull()?.let { it >= 0 } == true
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = quantity,
|
||||
onValueChange = { quantity = it },
|
||||
label = { Text("Quantity (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { onSubmit(name.trim(), quantity.takeIf { it.isNotBlank() }?.toLong()) },
|
||||
enabled = name.isNotBlank() && validQuantity,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionSheet(
|
||||
node: ApiNode,
|
||||
onDismiss: () -> Unit,
|
||||
onLabel: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onMove: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(node.name, style = MaterialTheme.typography.titleLarge)
|
||||
OutlinedButton(onClick = onLabel, modifier = Modifier.fillMaxWidth()) { Text("Label") }
|
||||
OutlinedButton(onClick = onEdit, modifier = Modifier.fillMaxWidth()) { Text("Edit") }
|
||||
OutlinedButton(onClick = onMove, modifier = Modifier.fillMaxWidth()) { Text("Move") }
|
||||
OutlinedButton(
|
||||
onClick = onDelete,
|
||||
enabled = node.children.isEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (node.children.isEmpty()) "Delete" else "Delete (must be empty)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelSheet(
|
||||
node: ApiNode,
|
||||
breadcrumb: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val canonicalUrl = remember(node.lookupCode) { BoxManifestApi().canonicalNodeUrl(node.lookupCode) }
|
||||
val bitmap = remember(node.name, breadcrumb, node.lookupCode) {
|
||||
createLabelBitmap(node.name, breadcrumb, node.lookupCode, canonicalUrl)
|
||||
}
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Label", style = MaterialTheme.typography.titleLarge)
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "Label preview for ${node.name}",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(node.lookupCode, fontWeight = FontWeight.Bold)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { shareLabel(context, bitmap, node.lookupCode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Share") }
|
||||
Button(
|
||||
onClick = { printLabel(context, bitmap, node.name) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Print") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoveSheet(
|
||||
node: ApiNode,
|
||||
root: ApiNode?,
|
||||
onDismiss: () -> Unit,
|
||||
onMove: (String, ChildHandling) -> Unit,
|
||||
) {
|
||||
val forbidden = remember(node) { node.descendantIds() + node.id }
|
||||
var targetId by remember { mutableStateOf(node.parentId.orEmpty()) }
|
||||
var handling by remember { mutableStateOf(ChildHandling.WITH_SUBTREE) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Move ${node.name}", style = MaterialTheme.typography.titleLarge)
|
||||
root?.let {
|
||||
HierarchyPicker(
|
||||
root = it,
|
||||
selectedId = targetId,
|
||||
forbiddenIds = forbidden,
|
||||
onSelect = { selected -> targetId = selected },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Children", style = MaterialTheme.typography.titleMedium)
|
||||
ChildHandling.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { handling = option },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = handling == option, onClick = { handling = option })
|
||||
Text(option.label())
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { onMove(targetId, handling) },
|
||||
enabled = targetId.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Move") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MultiMoveSheet(
|
||||
nodes: List<ApiNode>,
|
||||
root: ApiNode,
|
||||
onDismiss: () -> Unit,
|
||||
onMove: (String, ChildHandling) -> Unit,
|
||||
) {
|
||||
val forbidden = remember(nodes) {
|
||||
nodes.flatMapTo(mutableSetOf()) { node -> node.descendantIds() + node.id }
|
||||
}
|
||||
var targetId by remember { mutableStateOf(nodes.firstOrNull()?.parentId.orEmpty()) }
|
||||
var handling by remember { mutableStateOf(ChildHandling.WITH_SUBTREE) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Move ${nodes.size} nodes", style = MaterialTheme.typography.titleLarge)
|
||||
HierarchyPicker(
|
||||
root = root,
|
||||
selectedId = targetId,
|
||||
forbiddenIds = forbidden,
|
||||
onSelect = { selected -> targetId = selected },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Children of selected nodes", style = MaterialTheme.typography.titleMedium)
|
||||
ChildHandling.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { handling = option },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = handling == option, onClick = { handling = option })
|
||||
Text(option.label())
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { onMove(targetId, handling) },
|
||||
enabled = nodes.isNotEmpty() && targetId.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Move ${nodes.size} nodes") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChildHandling.label(): String = when (this) {
|
||||
ChildHandling.WITH_SUBTREE -> "Bring everything inside"
|
||||
ChildHandling.PROMOTE_CHILDREN -> "Leave contents in the current parent"
|
||||
ChildHandling.CHILDREN_TO_UNSORTED -> "Send contents to Unsorted"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package app.boxmanifest.tree
|
||||
|
||||
import app.boxmanifest.ApiNode
|
||||
import app.boxmanifest.TreeImportPreview
|
||||
|
||||
data class TreeUiState(
|
||||
val root: ApiNode? = null,
|
||||
val currentNodeId: String = "root",
|
||||
val backStack: List<String> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val importPreview: TreeImportPreview? = null,
|
||||
) {
|
||||
val currentNode: ApiNode?
|
||||
get() = root?.find(currentNodeId)
|
||||
|
||||
val canGoBack: Boolean
|
||||
get() = backStack.isNotEmpty()
|
||||
|
||||
val breadcrumbs: List<ApiNode>
|
||||
get() = root?.pathTo(currentNodeId).orEmpty()
|
||||
}
|
||||
|
||||
fun ApiNode.find(id: String): ApiNode? {
|
||||
if (this.id == id) return this
|
||||
return children.firstNotNullOfOrNull { it.find(id) }
|
||||
}
|
||||
|
||||
fun ApiNode.pathTo(id: String): List<ApiNode>? {
|
||||
if (this.id == id) return listOf(this)
|
||||
for (child in children) {
|
||||
val childPath = child.pathTo(id)
|
||||
if (childPath != null) return listOf(this) + childPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun ApiNode.allNodes(): List<ApiNode> = listOf(this) + children.flatMap { it.allNodes() }
|
||||
|
||||
fun ApiNode.descendantIds(): Set<String> = children.flatMap { it.allNodes() }.mapTo(mutableSetOf()) { it.id }
|
||||
@@ -0,0 +1,160 @@
|
||||
package app.boxmanifest.tree
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.boxmanifest.ChildHandling
|
||||
import app.boxmanifest.data.NodeRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TreeViewModel(
|
||||
private val repository: NodeRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TreeUiState())
|
||||
val state: StateFlow<TreeUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() = perform { repository.loadTree() }
|
||||
|
||||
fun openNode(id: String) {
|
||||
val current = _state.value
|
||||
if (current.root?.find(id) == null || id == current.currentNodeId) return
|
||||
_state.update {
|
||||
it.copy(currentNodeId = id, backStack = it.backStack + it.currentNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
fun openBreadcrumb(id: String) {
|
||||
val root = _state.value.root ?: return
|
||||
val path = root.pathTo(id) ?: return
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentNodeId = id,
|
||||
backStack = path.dropLast(1).map(app.boxmanifest.ApiNode::id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun goBack(): Boolean {
|
||||
val current = _state.value
|
||||
val destination = current.backStack.lastOrNull() ?: return false
|
||||
_state.update {
|
||||
it.copy(currentNodeId = destination, backStack = it.backStack.dropLast(1))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createChild(name: String, quantity: Long?) = perform {
|
||||
repository.createChild(_state.value.currentNodeId, name, quantity)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun createChildren(names: List<String>) = perform {
|
||||
repository.createChildren(_state.value.currentNodeId, names)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun previewTreeImport(manifestJson: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true, error = null, importPreview = null) }
|
||||
runCatching {
|
||||
repository.previewTreeImport(_state.value.currentNodeId, manifestJson)
|
||||
}.onSuccess { preview ->
|
||||
_state.update { it.copy(loading = false, importPreview = preview) }
|
||||
}.onFailure { failure ->
|
||||
_state.update {
|
||||
it.copy(loading = false, error = failure.message ?: "Import preview failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearImportPreview() {
|
||||
_state.update { it.copy(importPreview = null) }
|
||||
}
|
||||
|
||||
fun commitTreeImport(planId: String) {
|
||||
_state.update { it.copy(importPreview = null) }
|
||||
perform {
|
||||
repository.commitTreeImport(planId)
|
||||
repository.loadTree()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCurrent(name: String, quantity: Long?) = perform {
|
||||
repository.update(_state.value.currentNodeId, name, quantity)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun moveCurrent(targetParentId: String, handling: ChildHandling) = perform {
|
||||
repository.move(_state.value.currentNodeId, targetParentId, handling)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun moveMany(ids: Set<String>, targetParentId: String, handling: ChildHandling) = perform {
|
||||
repository.moveMany(ids, targetParentId, handling)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun deleteCurrent() {
|
||||
val current = _state.value.currentNode ?: return
|
||||
val destination = current.parentId ?: return
|
||||
perform(targetNodeId = destination, resetBackStack = true) {
|
||||
repository.delete(current.id)
|
||||
repository.loadTree()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMany(ids: Set<String>) = perform {
|
||||
repository.deleteMany(ids)
|
||||
repository.loadTree()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private fun perform(
|
||||
targetNodeId: String? = null,
|
||||
resetBackStack: Boolean = false,
|
||||
action: suspend () -> app.boxmanifest.ApiNode,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { action() }
|
||||
.onSuccess { root ->
|
||||
_state.update { previous ->
|
||||
val requested = targetNodeId ?: previous.currentNodeId
|
||||
val validTarget = root.find(requested)?.id ?: root.id
|
||||
previous.copy(
|
||||
root = root,
|
||||
currentNodeId = validTarget,
|
||||
backStack = if (resetBackStack) emptyList() else previous.backStack,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { failure ->
|
||||
_state.update {
|
||||
it.copy(loading = false, error = failure.message ?: "Request failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(repository: NodeRepository = NodeRepository()): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
TreeViewModel(repository) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
|
||||
@Composable
|
||||
fun SearchIcon(modifier: Modifier = Modifier) {
|
||||
val color = LocalContentColor.current
|
||||
Canvas(modifier = modifier.semantics { contentDescription = "Search" }) {
|
||||
val stroke = size.minDimension * 0.08f
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = size.minDimension * 0.28f,
|
||||
center = Offset(size.width * 0.43f, size.height * 0.43f),
|
||||
style = Stroke(stroke),
|
||||
)
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(size.width * 0.64f, size.height * 0.64f),
|
||||
end = Offset(size.width * 0.86f, size.height * 0.86f),
|
||||
strokeWidth = stroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewNodeIcon(modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(app.boxmanifest.R.drawable.ic_edit_square),
|
||||
contentDescription = "New node",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.boxmanifest.ApiNode
|
||||
|
||||
private data class HierarchyRow(val node: ApiNode, val depth: Int)
|
||||
|
||||
@Composable
|
||||
fun HierarchyPicker(
|
||||
root: ApiNode,
|
||||
selectedId: String,
|
||||
forbiddenIds: Set<String>,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expandedIds by remember(root, selectedId, forbiddenIds) {
|
||||
mutableStateOf(root.pathTo(selectedId).orEmpty().dropLast(1).toSet() + root.id)
|
||||
}
|
||||
val rows = remember(root, forbiddenIds, expandedIds) {
|
||||
root.visibleRows(forbiddenIds, expandedIds)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier.heightIn(max = 280.dp)) {
|
||||
items(rows, key = { it.node.id }) { row ->
|
||||
val children = row.node.children.filterNot { it.id in forbiddenIds }
|
||||
val expanded = row.node.id in expandedIds
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = if (row.node.id == selectedId) {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
)
|
||||
.clickable { onSelect(row.node.id) }
|
||||
.padding(start = (row.depth * 16).dp, end = 8.dp, top = 3.dp, bottom = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.size(32.dp).clickable(enabled = children.isNotEmpty()) {
|
||||
expandedIds = if (expanded) expandedIds - row.node.id else expandedIds + row.node.id
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
children.isEmpty() -> Unit
|
||||
expanded -> DownChevronIcon(
|
||||
Modifier.size(18.dp),
|
||||
contentDescription = "Collapse ${row.node.name}",
|
||||
)
|
||||
else -> RightChevronIcon(
|
||||
Modifier.size(18.dp),
|
||||
contentDescription = "Expand ${row.node.name}",
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = row.node.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
RadioButton(
|
||||
selected = row.node.id == selectedId,
|
||||
onClick = { onSelect(row.node.id) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApiNode.visibleRows(
|
||||
forbiddenIds: Set<String>,
|
||||
expandedIds: Set<String>,
|
||||
depth: Int = 0,
|
||||
): List<HierarchyRow> {
|
||||
if (id in forbiddenIds) return emptyList()
|
||||
val ownRow = listOf(HierarchyRow(this, depth))
|
||||
if (id !in expandedIds) return ownRow
|
||||
return ownRow + children.flatMap { it.visibleRows(forbiddenIds, expandedIds, depth + 1) }
|
||||
}
|
||||
|
||||
private fun ApiNode.pathTo(targetId: String): List<String>? {
|
||||
if (id == targetId) return listOf(id)
|
||||
for (child in children) {
|
||||
val childPath = child.pathTo(targetId)
|
||||
if (childPath != null) return listOf(id) + childPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import app.boxmanifest.R
|
||||
|
||||
@Composable
|
||||
fun RightChevronIcon(modifier: Modifier = Modifier, contentDescription: String? = null) =
|
||||
ManifestIcon(R.drawable.ic_arrow_right_18, contentDescription, modifier)
|
||||
|
||||
@Composable
|
||||
fun DownChevronIcon(modifier: Modifier = Modifier, contentDescription: String? = null) =
|
||||
ManifestIcon(R.drawable.ic_arrow_down_18, contentDescription, modifier)
|
||||
|
||||
@Composable
|
||||
fun BackIcon(modifier: Modifier = Modifier, contentDescription: String? = "Back") =
|
||||
ManifestIcon(R.drawable.ic_back_24, contentDescription, modifier)
|
||||
|
||||
@Composable
|
||||
fun CloseIcon(modifier: Modifier = Modifier, contentDescription: String? = "Close") =
|
||||
ManifestIcon(R.drawable.ic_clear_18, contentDescription, modifier)
|
||||
|
||||
@Composable
|
||||
fun MoreIcon(modifier: Modifier = Modifier, contentDescription: String? = "More options") =
|
||||
ManifestIcon(R.drawable.ic_action_more_24, contentDescription, modifier)
|
||||
|
||||
@Composable
|
||||
private fun ManifestIcon(
|
||||
@DrawableRes drawable: Int,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Icon(painterResource(drawable), contentDescription, modifier)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.boxmanifest.ApiNode
|
||||
|
||||
@Composable
|
||||
fun NodeBreadcrumbs(
|
||||
nodes: List<ApiNode>,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(nodes, scrollState.maxValue) {
|
||||
scrollState.scrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(scrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
nodes.forEachIndexed { index, node ->
|
||||
if (index > 0) {
|
||||
RightChevronIcon(Modifier.size(12.dp))
|
||||
}
|
||||
val current = index == nodes.lastIndex
|
||||
Text(
|
||||
text = node.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (current) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !current) { onNavigate(node.id) }
|
||||
.padding(vertical = 6.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.boxmanifest.ApiNode
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun NodeRow(
|
||||
node: ApiNode,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(if (selected) "✓" else node.name.firstOrNull()?.uppercase() ?: "•")
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = node.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val details = buildList {
|
||||
node.quantity?.let { add("Quantity $it") }
|
||||
if (node.children.isNotEmpty()) add("${node.children.size} inside")
|
||||
}.joinToString(" · ")
|
||||
if (details.isNotEmpty()) {
|
||||
Text(
|
||||
text = details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
RightChevronIcon(Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package app.boxmanifest.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PropertyRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package app.boxmanifest.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
val Ink = Color(0xFF171717)
|
||||
val Paper = Color(0xFFFCFCFA)
|
||||
val MutedPaper = Color(0xFFF1F1ED)
|
||||
val MutedInk = Color(0xFF6F6F6A)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val DarkInk = Color(0xFFF1F1ED)
|
||||
val DarkPaper = Color(0xFF171717)
|
||||
val DarkMutedPaper = Color(0xFF292927)
|
||||
val DarkMutedInk = Color(0xFFAAAAA4)
|
||||
|
||||
@@ -1,54 +1,39 @@
|
||||
package app.boxmanifest.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
primary = DarkInk,
|
||||
onPrimary = DarkPaper,
|
||||
background = DarkPaper,
|
||||
onBackground = DarkInk,
|
||||
surface = DarkPaper,
|
||||
onSurface = DarkInk,
|
||||
surfaceVariant = DarkMutedPaper,
|
||||
onSurfaceVariant = DarkMutedInk,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
primary = Ink,
|
||||
onPrimary = Paper,
|
||||
background = Paper,
|
||||
onBackground = Ink,
|
||||
surface = Paper,
|
||||
onSurface = Ink,
|
||||
surfaceVariant = MutedPaper,
|
||||
onSurfaceVariant = MutedInk,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ManifestTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
|
||||
@@ -8,27 +8,32 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 38.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M6,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
|
||||
<path android:fillColor="#FF000000" android:pathData="M12,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
|
||||
<path android:fillColor="#FF000000" android:pathData="M18,12m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="18"
|
||||
android:viewportHeight="18">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M4,7L9,12L14,7"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="1.5" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="18"
|
||||
android:viewportHeight="18">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M6.22,14.789C5.927,14.508 5.927,14.052 6.22,13.771L11.189,9L6.22,4.229C5.927,3.948 5.927,3.492 6.22,3.211C6.513,2.93 6.987,2.93 7.28,3.211L13.311,9L7.28,14.789C6.987,15.07 6.513,15.07 6.22,14.789Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M15,20L7,12L15,4"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="2" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="19dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="19"
|
||||
android:viewportHeight="18">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M17.115,9C17.115,13.418 13.533,17 9.115,17C4.696,17 1.115,13.418 1.115,9C1.115,4.582 4.696,1 9.115,1C13.533,1 17.115,4.582 17.115,9ZM5.69,5.576C5.925,5.341 6.305,5.341 6.539,5.576L9.115,8.151L11.691,5.576C11.925,5.341 12.305,5.341 12.539,5.576C12.773,5.81 12.773,6.19 12.539,6.424L9.963,9L12.539,11.576C12.773,11.81 12.773,12.19 12.539,12.424C12.305,12.659 11.925,12.659 11.691,12.424L9.115,9.849L6.539,12.424C6.305,12.659 5.925,12.659 5.69,12.424C5.456,12.19 5.456,11.81 5.69,11.576L8.266,9L5.69,6.424C5.456,6.19 5.456,5.81 5.69,5.576Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M200,840q-33,0 -56.5,-23.5T120,760L120,200q0,-33 23.5,-56.5T200,120h261q20,0 30,12.5t10,27.5q0,15 -10.5,27.5T460,200L200,200v560h560L760,499q0,-20 12.5,-30t27.5,-10q15,0 27.5,10t12.5,30v261q0,33 -23.5,56.5T760,840L200,840ZM360,560v-97q0,-16 6,-30.5t17,-25.5l344,-344q12,-12 27,-18t30,-6q16,0 30.5,6T821,63l56,57q11,12 17,26.5t6,29.5q0,15 -5.5,29.5T877,232L553,556q-11,11 -25.5,17.5T497,580h-97q-17,0 -28.5,-11.5T360,540ZM440,500h56l232,-232 -28,-28 -29,-28 -231,231v57Z" />
|
||||
</vector>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="shared_labels" path="labels/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import app.boxmanifest.tree.BulkDelimiter
|
||||
import app.boxmanifest.tree.parseBulkNodes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BulkNodeParserTest {
|
||||
@Test
|
||||
fun autoPrefersNewLinesAndPreservesCommasInNames() {
|
||||
val result = parseBulkNodes("Hammer, large\nScrews\n\n Tape ", BulkDelimiter.AUTO)
|
||||
|
||||
assertEquals(BulkDelimiter.NEW_LINES, result.delimiter)
|
||||
assertEquals(listOf("Hammer, large", "Screws", "Tape"), result.names)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoUsesCommasWithoutNewLines() {
|
||||
val result = parseBulkNodes("Hammer, Screws,, Tape", BulkDelimiter.AUTO)
|
||||
|
||||
assertEquals(BulkDelimiter.COMMAS, result.delimiter)
|
||||
assertEquals(listOf("Hammer", "Screws", "Tape"), result.names)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitDelimiterOverridesDetection() {
|
||||
val result = parseBulkNodes("One, Two\nThree", BulkDelimiter.COMMAS)
|
||||
|
||||
assertEquals(listOf("One", "Two\nThree"), result.names)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateNamesArePreserved() {
|
||||
val result = parseBulkNodes("Pen\nPen", BulkDelimiter.NEW_LINES)
|
||||
|
||||
assertEquals(listOf("Pen", "Pen"), result.names)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import app.boxmanifest.labels.extractLookupCode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LookupCodeTest {
|
||||
@Test
|
||||
fun acceptsPrintedCodeOrCanonicalUrl() {
|
||||
assertEquals("ABC234", extractLookupCode("abc234"))
|
||||
assertEquals("ABC234", extractLookupCode("http://server.example/n/ABC234"))
|
||||
assertNull(extractLookupCode("https://example.com/not-a-label"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.boxmanifest
|
||||
|
||||
import app.boxmanifest.tree.pathTo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TreePathTest {
|
||||
private val tree = ApiNode(
|
||||
id = "root",
|
||||
parentId = null,
|
||||
name = "Root",
|
||||
quantity = null,
|
||||
children = listOf(
|
||||
ApiNode(
|
||||
id = "room",
|
||||
parentId = "root",
|
||||
name = "Room",
|
||||
quantity = null,
|
||||
children = listOf(
|
||||
ApiNode("box", "room", "Box", null),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun returnsStructuralPathToNode() {
|
||||
assertEquals(listOf("root", "room", "box"), tree.pathTo("box")?.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsNullForMissingNode() {
|
||||
assertNull(tree.pathTo("missing"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# Box Manifest project plan
|
||||
|
||||
Status: working pre-release prototype
|
||||
Last updated: 2026-08-26
|
||||
|
||||
## Product direction
|
||||
|
||||
Box Manifest is a self-hosted system for recording what exists and where it is
|
||||
stored. The server owns the data and domain rules. Android is the primary
|
||||
client during early development; the web client currently exists only as a
|
||||
minimal diagnostic interface.
|
||||
|
||||
There are no users or compatibility obligations. API, schema, and client code
|
||||
may change freely while the architecture is being established.
|
||||
|
||||
## Core model
|
||||
|
||||
The inventory is one rooted tree of universal nodes. The system does not
|
||||
assign structural kinds such as location, container, or item. A node can
|
||||
represent any physical or conceptual thing and can contain other nodes.
|
||||
|
||||
```text
|
||||
Root
|
||||
└── Apartment
|
||||
└── Room
|
||||
└── Shelf
|
||||
└── Box
|
||||
└── Pens (quantity: 10)
|
||||
```
|
||||
|
||||
A node currently has:
|
||||
|
||||
- An internal opaque ID
|
||||
- A stable, human-enterable lookup code
|
||||
- One parent, except for Root
|
||||
- A required name
|
||||
- An optional non-negative integer quantity
|
||||
- Zero or more children
|
||||
|
||||
Root and Unsorted are protected system nodes. Unsorted is the inbox for nodes
|
||||
whose placement has not been decided.
|
||||
|
||||
## Settled hierarchy rules
|
||||
|
||||
- Moving a node with `WITH_SUBTREE` moves all descendants with it.
|
||||
- `PROMOTE_CHILDREN` reparents direct children to the node's former parent.
|
||||
- `CHILDREN_TO_UNSORTED` reparents direct children to Unsorted.
|
||||
- Multi-node mutations are atomic.
|
||||
- Moves that create cycles are rejected.
|
||||
- Deletion is allowed only for empty, non-system nodes.
|
||||
- Quantity is optional; missing and zero are distinct.
|
||||
- Names need not be unique and are never identifiers.
|
||||
|
||||
These decisions are recorded in
|
||||
[ADR 0001](decisions/0001-universal-node-tree.md).
|
||||
|
||||
## Current architecture
|
||||
|
||||
```text
|
||||
Android ───┐
|
||||
├── HTTP API ──> Go server ──> SQLite
|
||||
Web ───────┘
|
||||
```
|
||||
|
||||
- `server/`: Go standard-library HTTP handlers and SQLite persistence
|
||||
- `app/`: Kotlin, Jetpack Compose, coroutines, and a small handwritten client
|
||||
- `web/`: dependency-free diagnostic UI
|
||||
- `api/`: OpenAPI contract and tree-import JSON Schema
|
||||
- `docs/`: product and architecture decisions
|
||||
|
||||
The API is intentionally unversioned during pre-release development.
|
||||
|
||||
## Implemented vertical slices
|
||||
|
||||
- Tree creation, reading, editing, moving, and empty-only deletion
|
||||
- Bulk sibling creation and multi-selection move/delete
|
||||
- Android hierarchy navigation, breadcrumbs, and local search
|
||||
- AI-oriented nested JSON import with preview and atomic commit
|
||||
- Stable lookup codes and canonical `/n/{code}` routes
|
||||
- Android QR-label preview, sharing, printing, and scanning
|
||||
|
||||
## Near-term direction
|
||||
|
||||
Continue validating the Android interaction model and the server API against
|
||||
real inventory use. Prefer small end-to-end increments that exercise storage,
|
||||
API behavior, and the Android client together. Keep the web UI diagnostic until
|
||||
the core model and Android workflows have settled.
|
||||
|
||||
Before permanent QR labels are printed, make the server's externally reachable
|
||||
canonical URL configurable. Before deployment work begins, establish explicit
|
||||
configuration and migration conventions for the pre-release server.
|
||||
|
||||
## Quality expectations
|
||||
|
||||
- Database constraints and transactions protect tree integrity.
|
||||
- Server behavior is covered with SQLite-backed tests.
|
||||
- Android changes pass compilation, unit tests, and lint.
|
||||
- Durable server data remains outside version control.
|
||||
- QR codes contain stable lookup URLs, never names or hierarchy paths.
|
||||
- Documentation describes implemented behavior separately from ideas under
|
||||
discussion.
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR 0001: Universal node tree
|
||||
|
||||
Status: accepted
|
||||
Date: 2026-08-25
|
||||
|
||||
## Context
|
||||
|
||||
The physical world does not provide a clean structural boundary between a
|
||||
location, a container, and an item. A room contains a shelf, a box contains a
|
||||
pen, and a toolbox can both be owned and contain tools. Encoding those words as
|
||||
exclusive system types would impose rules that the hierarchy itself does not
|
||||
need.
|
||||
|
||||
The project has no users or persisted data requiring compatibility.
|
||||
|
||||
## Decision
|
||||
|
||||
Represent the hierarchy as one rooted tree of universal nodes. Every node can
|
||||
have children. The system assigns no structural type or role to a node.
|
||||
|
||||
The initial persistent model is deliberately small:
|
||||
|
||||
```text
|
||||
Node
|
||||
- id
|
||||
- lookup_code
|
||||
- parent_id
|
||||
- name
|
||||
- quantity?
|
||||
```
|
||||
|
||||
- `id` is stable and opaque.
|
||||
- `lookup_code` is a stable human-enterable key for labels and search; it is
|
||||
not the internal identity.
|
||||
- `parent_id` points to exactly one parent, except for the root.
|
||||
- `name` is required and non-empty.
|
||||
- `quantity` is an optional non-negative integer. Missing and zero are distinct.
|
||||
- Sibling names may be duplicated; identity and paths never depend on names.
|
||||
|
||||
The server creates two protected nodes:
|
||||
|
||||
- `Root` is the sole node without a parent.
|
||||
- `Unsorted` is a direct child of Root and acts as the inbox for nodes whose
|
||||
placement has not been decided.
|
||||
|
||||
Root and Unsorted cannot be deleted or moved. Their displayed names are fixed
|
||||
for now.
|
||||
|
||||
Moving a node requires one child-handling mode:
|
||||
|
||||
- `WITH_SUBTREE`: move the node with all descendants. This is the default.
|
||||
- `PROMOTE_CHILDREN`: move the node, reparenting its direct children to the
|
||||
node's former parent.
|
||||
- `CHILDREN_TO_UNSORTED`: move the node, reparenting its direct children to
|
||||
Unsorted.
|
||||
|
||||
All changes made by one move are atomic. A move that would create a cycle is
|
||||
rejected. A node may be deleted only when it is empty; recursive deletion is
|
||||
not supported.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All clients can render the complete core model as a simple tree.
|
||||
- The UI may use familiar words such as room, shelf, or box without making them
|
||||
database types.
|
||||
- A node can be both something the user owns and something that contains other
|
||||
nodes.
|
||||
- Stable IDs remain necessary because names can change and repeat.
|
||||
- Additional metadata can be introduced without changing the tree abstraction.
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package tree
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestInitializeCreatesProtectedTree(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
root, err := store.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.ID != RootID || root.ParentID != nil || root.Name != "Root" {
|
||||
t.Fatalf("unexpected root: %#v", root.Node)
|
||||
}
|
||||
if len(root.Children) != 1 || root.Children[0].ID != UnsortedID {
|
||||
t.Fatalf("expected Unsorted beneath Root, got %#v", root.Children)
|
||||
}
|
||||
if root.LookupCode == "" || root.Children[0].LookupCode == "" || root.LookupCode == root.Children[0].LookupCode {
|
||||
t.Fatalf("expected stable unique lookup codes: %#v", root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeBackfillsLookupCodesInExistingDatabase(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES nodes(id),
|
||||
name TEXT NOT NULL,
|
||||
quantity INTEGER,
|
||||
system INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO nodes (id, name, system) VALUES ('root', 'Root', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(db)
|
||||
if err := store.Initialize(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := store.Get(context.Background(), RootID)
|
||||
if err != nil || root.LookupCode == "" {
|
||||
t.Fatalf("existing node was not backfilled: %#v, %v", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
node := createTestNode(t, store, RootID, "Box")
|
||||
code := node.LookupCode
|
||||
newName := "Renamed box"
|
||||
if _, err := store.Update(ctx, node.ID, &newName, QuantityUpdate{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := store.GetByLookupCode(ctx, strings.ToLower(code))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.ID != node.ID || found.LookupCode != code || found.Name != newName {
|
||||
t.Fatalf("lookup identity changed: %#v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveWithSubtree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
a := createTestNode(t, store, RootID, "A")
|
||||
b := createTestNode(t, store, RootID, "B")
|
||||
child := createTestNode(t, store, a.ID, "child")
|
||||
|
||||
moved, err := store.Move(ctx, a.ID, b.ID, WithSubtree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertParent(t, moved, b.ID)
|
||||
assertParent(t, mustGet(t, store, child.ID), a.ID)
|
||||
}
|
||||
|
||||
func TestMovePromotesChildren(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
a := createTestNode(t, store, RootID, "A")
|
||||
b := createTestNode(t, store, RootID, "B")
|
||||
child := createTestNode(t, store, a.ID, "child")
|
||||
|
||||
if _, err := store.Move(ctx, a.ID, b.ID, PromoteChildren); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertParent(t, mustGet(t, store, child.ID), RootID)
|
||||
}
|
||||
|
||||
func TestMoveSendsChildrenToUnsorted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
a := createTestNode(t, store, RootID, "A")
|
||||
b := createTestNode(t, store, RootID, "B")
|
||||
child := createTestNode(t, store, a.ID, "child")
|
||||
|
||||
if _, err := store.Move(ctx, a.ID, b.ID, ChildrenToUnsorted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertParent(t, mustGet(t, store, child.ID), UnsortedID)
|
||||
}
|
||||
|
||||
func TestMoveRejectsCycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
a := createTestNode(t, store, RootID, "A")
|
||||
child := createTestNode(t, store, a.ID, "child")
|
||||
|
||||
if _, err := store.Move(ctx, a.ID, child.ID, WithSubtree); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
assertParent(t, mustGet(t, store, a.ID), RootID)
|
||||
assertParent(t, mustGet(t, store, child.ID), a.ID)
|
||||
}
|
||||
|
||||
func TestDeleteOnlyAllowsEmptyNodes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
parent := createTestNode(t, store, RootID, "parent")
|
||||
child := createTestNode(t, store, parent.ID, "child")
|
||||
|
||||
if err := store.Delete(ctx, parent.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict deleting non-empty node, got %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Delete(ctx, parent.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(ctx, parent.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected deleted node to be missing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
newName := "Elsewhere"
|
||||
|
||||
for _, id := range []string{RootID, UnsortedID} {
|
||||
if err := store.Delete(ctx, id); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("delete %s: expected conflict, got %v", id, err)
|
||||
}
|
||||
if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("move %s: expected conflict, got %v", id, err)
|
||||
}
|
||||
if _, err := store.Update(ctx, id, &newName, QuantityUpdate{}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("rename %s: expected conflict, got %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultsToUnsortedAndAcceptsDuplicateNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
quantity := int64(0)
|
||||
first, err := store.Create(ctx, nil, "Pens", &quantity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.Create(ctx, nil, "Pens", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertParent(t, first, UnsortedID)
|
||||
assertParent(t, second, UnsortedID)
|
||||
if first.Quantity == nil || *first.Quantity != 0 || second.Quantity != nil {
|
||||
t.Fatalf("quantity did not preserve zero versus missing: %#v %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateManyIsOrderedAndAtomic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
parent := createTestNode(t, store, RootID, "parent")
|
||||
|
||||
created, err := store.CreateMany(ctx, parent.ID, []string{" Hammer ", "Hammer", "Screws"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created) != 3 || created[0].Name != "Hammer" || created[1].Name != "Hammer" || created[2].Name != "Screws" {
|
||||
t.Fatalf("unexpected created nodes: %#v", created)
|
||||
}
|
||||
|
||||
if _, err := store.CreateMany(ctx, parent.ID, []string{"Valid", " "}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid bulk request, got %v", err)
|
||||
}
|
||||
tree, err := store.Tree(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parentAfter := tree.findForTest(parent.ID)
|
||||
if parentAfter == nil || len(parentAfter.Children) != 3 {
|
||||
t.Fatalf("invalid request inserted a partial batch: %#v", parentAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveManyMovesSiblingSubtreesAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
source := createTestNode(t, store, RootID, "source")
|
||||
target := createTestNode(t, store, RootID, "target")
|
||||
first := createTestNode(t, store, source.ID, "first")
|
||||
second := createTestNode(t, store, source.ID, "second")
|
||||
child := createTestNode(t, store, first.ID, "child")
|
||||
|
||||
moved, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(moved) != 2 {
|
||||
t.Fatalf("expected two moved nodes, got %d", len(moved))
|
||||
}
|
||||
assertParent(t, mustGet(t, store, first.ID), target.ID)
|
||||
assertParent(t, mustGet(t, store, second.ID), target.ID)
|
||||
assertParent(t, mustGet(t, store, child.ID), first.ID)
|
||||
}
|
||||
|
||||
func TestMoveManyRejectsNodesFromDifferentParentsWithoutChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
firstParent := createTestNode(t, store, RootID, "first parent")
|
||||
secondParent := createTestNode(t, store, RootID, "second parent")
|
||||
target := createTestNode(t, store, RootID, "target")
|
||||
first := createTestNode(t, store, firstParent.ID, "first")
|
||||
second := createTestNode(t, store, secondParent.ID, "second")
|
||||
|
||||
if _, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
assertParent(t, mustGet(t, store, first.ID), firstParent.ID)
|
||||
assertParent(t, mustGet(t, store, second.ID), secondParent.ID)
|
||||
}
|
||||
|
||||
func TestDeleteManyIsAtomicWhenAnyNodeIsNotEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
parent := createTestNode(t, store, RootID, "parent")
|
||||
empty := createTestNode(t, store, parent.ID, "empty")
|
||||
nonEmpty := createTestNode(t, store, parent.ID, "non-empty")
|
||||
createTestNode(t, store, nonEmpty.ID, "child")
|
||||
|
||||
if err := store.DeleteMany(ctx, []string{empty.ID, nonEmpty.ID}); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, empty.ID); err != nil {
|
||||
t.Fatalf("empty sibling was partially deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
store := NewStore(db)
|
||||
if err := store.Initialize(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func createTestNode(t *testing.T, store *Store, parentID, name string) Node {
|
||||
t.Helper()
|
||||
node, err := store.Create(context.Background(), &parentID, name, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func mustGet(t *testing.T, store *Store, id string) Node {
|
||||
t.Helper()
|
||||
node, err := store.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func assertParent(t *testing.T, node Node, parentID string) {
|
||||
t.Helper()
|
||||
if node.ParentID == nil || *node.ParentID != parentID {
|
||||
t.Fatalf("node %s: expected parent %s, got %v", node.ID, parentID, node.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func (node *TreeNode) findForTest(id string) *TreeNode {
|
||||
if node.ID == id {
|
||||
return node
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
if found := child.findForTest(id); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+193
@@ -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();
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Box Manifest</title>
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Box Manifest</h1>
|
||||
<p id="status" role="status"></p>
|
||||
|
||||
<section>
|
||||
<h2>Tree</h2>
|
||||
<button id="refresh" type="button">Refresh</button>
|
||||
<div id="tree">Loading…</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Selected node</h2>
|
||||
<p id="selected">None</p>
|
||||
|
||||
<form id="update-form">
|
||||
<h3>Update</h3>
|
||||
<label>
|
||||
Name
|
||||
<input id="update-name" required>
|
||||
</label>
|
||||
<label>
|
||||
Quantity
|
||||
<input id="update-quantity" type="number" min="0" step="1">
|
||||
</label>
|
||||
<button type="submit">Update selected node</button>
|
||||
</form>
|
||||
|
||||
<form id="move-form">
|
||||
<h3>Move</h3>
|
||||
<label>
|
||||
New parent
|
||||
<select id="move-parent" required></select>
|
||||
</label>
|
||||
<label>
|
||||
Children
|
||||
<select id="move-children">
|
||||
<option value="WITH_SUBTREE">Move with subtree</option>
|
||||
<option value="PROMOTE_CHILDREN">Move direct children up</option>
|
||||
<option value="CHILDREN_TO_UNSORTED">Move direct children to Unsorted</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Move selected node</button>
|
||||
</form>
|
||||
|
||||
<button id="delete" type="button">Delete selected empty node</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<form id="create-form">
|
||||
<h2>Create child</h2>
|
||||
<p id="create-parent">Parent: Unsorted</p>
|
||||
<label>
|
||||
Name
|
||||
<input id="create-name" required>
|
||||
</label>
|
||||
<label>
|
||||
Quantity
|
||||
<input id="create-quantity" type="number" min="0" step="1">
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user