initial Box Manifest server and Android client
This commit is contained in:
@@ -7,9 +7,13 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deploymentTargetSelector.xml
|
||||||
|
/.idea/runConfigurations.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
optimization {
|
optimization {
|
||||||
enable = false
|
enable = false
|
||||||
@@ -41,16 +43,17 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.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)
|
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)
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Manifest">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
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
|
import app.boxmanifest.ui.theme.ManifestTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -19,29 +19,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ManifestTheme {
|
ManifestTheme {
|
||||||
|
val treeViewModel: TreeViewModel = viewModel(factory = TreeViewModel.factory())
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
Greeting(
|
TreeScreen(
|
||||||
name = "Android",
|
viewModel = treeViewModel,
|
||||||
modifier = Modifier.padding(innerPadding)
|
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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val Ink = Color(0xFF171717)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val Paper = Color(0xFFFCFCFA)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val MutedPaper = Color(0xFFF1F1ED)
|
||||||
|
val MutedInk = Color(0xFF6F6F6A)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val DarkInk = Color(0xFFF1F1ED)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val DarkPaper = Color(0xFF171717)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val DarkMutedPaper = Color(0xFF292927)
|
||||||
|
val DarkMutedInk = Color(0xFFAAAAA4)
|
||||||
|
|||||||
@@ -1,54 +1,39 @@
|
|||||||
package app.boxmanifest.ui.theme
|
package app.boxmanifest.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = DarkInk,
|
||||||
secondary = PurpleGrey80,
|
onPrimary = DarkPaper,
|
||||||
tertiary = Pink80
|
background = DarkPaper,
|
||||||
|
onBackground = DarkInk,
|
||||||
|
surface = DarkPaper,
|
||||||
|
onSurface = DarkInk,
|
||||||
|
surfaceVariant = DarkMutedPaper,
|
||||||
|
onSurfaceVariant = DarkMutedInk,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Ink,
|
||||||
secondary = PurpleGrey40,
|
onPrimary = Paper,
|
||||||
tertiary = Pink40
|
background = Paper,
|
||||||
|
onBackground = Ink,
|
||||||
/* Other default colors to override
|
surface = Paper,
|
||||||
background = Color(0xFFFFFBFE),
|
onSurface = Ink,
|
||||||
surface = Color(0xFFFFFBFE),
|
surfaceVariant = MutedPaper,
|
||||||
onPrimary = Color.White,
|
onSurfaceVariant = MutedInk,
|
||||||
onSecondary = Color.White,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
onBackground = Color(0xFF1C1B1F),
|
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ManifestTheme(
|
fun ManifestTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
// Dynamic color is available on Android 12+
|
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
darkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
|
|||||||
@@ -8,27 +8,32 @@ import androidx.compose.ui.unit.sp
|
|||||||
|
|
||||||
// Set of Material typography styles to start with
|
// Set of Material typography styles to start with
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 38.sp,
|
||||||
|
letterSpacing = (-0.5).sp,
|
||||||
|
),
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.sp,
|
||||||
)
|
),
|
||||||
/* Other default text styles to override
|
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
lineHeight = 28.sp,
|
lineHeight = 28.sp,
|
||||||
letterSpacing = 0.sp
|
letterSpacing = 0.sp,
|
||||||
),
|
),
|
||||||
labelSmall = TextStyle(
|
bodySmall = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 11.sp,
|
fontSize = 14.sp,
|
||||||
lineHeight = 16.sp,
|
lineHeight = 20.sp,
|
||||||
letterSpacing = 0.5.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"
|
agp = "9.2.1"
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
|
||||||
espressoCore = "3.5.1"
|
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
codeScanner = "16.1.0"
|
||||||
|
zxing = "3.5.4"
|
||||||
|
androidxPrint = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
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 = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
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-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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
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