From 84a88d1264b66ff91c82cafc89cedde188cb8bc0 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Wed, 26 Aug 2026 18:02:37 -0500 Subject: [PATCH] add qr finder --- README.md | 2 +- app/build.gradle.kts | 7 +- app/src/main/AndroidManifest.xml | 3 + .../app/boxmanifest/labels/FindNodeScreen.kt | 212 ++++++++++++++++++ .../app/boxmanifest/labels/LabelSupport.kt | 9 - .../java/app/boxmanifest/tree/TreeScreen.kt | 29 +-- docs/PROJECT_PLAN.md | 2 +- gradle/libs.versions.toml | 10 +- server/cmd/box-manifest-server/main.go | 6 +- web/app.js | 179 +++++++++++++-- web/index.html | 45 +++- 11 files changed, 452 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/app/boxmanifest/labels/FindNodeScreen.kt diff --git a/README.md b/README.md index 8734bbb..64e96e5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The project is pre-release and has no compatibility guarantee yet. - 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 +- QR-label preview, sharing, 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eeba566..bfeadd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,10 +50,15 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.google.code.scanner) implementation(libs.zxing.core) - implementation(libs.androidx.print) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.mlkit.vision) + implementation(libs.mlkit.barcode.scanning) testImplementation(libs.junit) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3fe4c1a..9a17de8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + Unit) { + val context = LocalContext.current + var cameraAllowed by remember { + mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) + } + var permissionRequested by remember { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + cameraAllowed = it + permissionRequested = true + } + + BackHandler(onBack = onDismiss) + LaunchedEffect(Unit) { + if (!cameraAllowed) permissionLauncher.launch(Manifest.permission.CAMERA) + } + + if (!cameraAllowed) { + Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Camera access is needed to find labels.", color = Color.White) + if (permissionRequested) { + Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) { Text("Allow camera") } + } + Button(onClick = onDismiss) { Text("Close") } + } + } + return + } + + FindCamera(target = target, onDismiss = onDismiss) +} + +@Composable +private fun FindCamera(target: ApiNode, onDismiss: () -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val executor = remember(context) { ContextCompat.getMainExecutor(context) } + val controller = remember(context) { LifecycleCameraController(context) } + val scanner = remember { createBarcodeScanner() } + var labels by remember { mutableStateOf(emptyList()) } + val found = labels.any { it.code == target.lookupCode } + var vibrated by remember(target.lookupCode) { mutableStateOf(false) } + + DisposableEffect(controller, scanner, lifecycleOwner) { + controller.setImageAnalysisAnalyzer( + executor, + MlKitAnalyzer( + listOf(scanner), + ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED, + executor, + ) { result: Result? -> + labels = result?.getValue(scanner).orEmpty().mapNotNull { barcode -> + val code = barcode.rawValue?.let(::extractLookupCode) ?: return@mapNotNull null + val bounds = barcode.boundingBox ?: return@mapNotNull null + VisibleLabel(bounds.left.toFloat(), bounds.top.toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), code) + } + }, + ) + controller.bindToLifecycle(lifecycleOwner) + onDispose { + controller.clearImageAnalysisAnalyzer() + controller.unbind() + scanner.close() + } + } + + LaunchedEffect(found) { + if (found && !vibrated) { + vibrate(context) + vibrated = true + } else if (!found) { + vibrated = false + } + } + + Box(Modifier.fillMaxSize().background(Color.Black)) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { previewContext -> + PreviewView(previewContext).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + this.controller = controller + } + }, + modifier = Modifier.fillMaxSize(), + ) + Canvas(Modifier.fillMaxSize()) { + labels.forEach { label -> + val isTarget = label.code == target.lookupCode + val color = if (isTarget) Color(0xFF42E77B) else Color.White.copy(alpha = 0.75f) + val topLeft = Offset(label.left, label.top) + val size = Size(label.right - label.left, label.bottom - label.top) + if (isTarget) drawRect(color.copy(alpha = 0.2f), topLeft, size) + drawRect(color, topLeft, size, style = Stroke(width = if (isTarget) 8f else 3f)) + } + } + Surface( + color = Color.Black.copy(alpha = 0.72f), + contentColor = Color.White, + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { BackIcon() } + Column { + Text("Find ${target.name}", style = MaterialTheme.typography.titleMedium) + Text(target.lookupCode, style = MaterialTheme.typography.bodySmall) + } + } + } + Surface( + color = if (found) Color(0xFF176B36) else Color.Black.copy(alpha = 0.72f), + contentColor = Color.White, + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter), + ) { + Text( + if (found) "Found ${target.name}" else "Point the camera at QR labels", + modifier = Modifier.padding(20.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + } +} + +private fun createBarcodeScanner(): BarcodeScanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(), +) + +private fun vibrate(context: Context) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java).defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(120) + } +} diff --git a/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt index 272e185..d3ca00b 100644 --- a/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt +++ b/app/src/main/java/app/boxmanifest/labels/LabelSupport.kt @@ -9,7 +9,6 @@ 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 @@ -75,14 +74,6 @@ fun shareLabel(context: Context, bitmap: Bitmap, lookupCode: String) { 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) diff --git a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt index 56c00d0..6ca6128 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt @@ -73,7 +73,7 @@ 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.FindNodeScreen import app.boxmanifest.labels.scanNodeCode import app.boxmanifest.labels.shareLabel import kotlinx.coroutines.launch @@ -92,10 +92,16 @@ fun TreeScreen( val activity = LocalContext.current as Activity var openSheet by remember { mutableStateOf(null) } var searchOpen by remember { mutableStateOf(false) } + var findTarget by remember { mutableStateOf(null) } var selectedIds by remember(current?.id) { mutableStateOf>(emptySet()) } val selectedNodes = current?.children.orEmpty().filter { it.id in selectedIds } val selectionCanDelete = selectedNodes.isNotEmpty() && selectedNodes.all { it.children.isEmpty() } + findTarget?.let { target -> + FindNodeScreen(target = target, onDismiss = { findTarget = null }) + return + } + BackHandler(enabled = state.canGoBack || openSheet != null || selectedIds.isNotEmpty() || searchOpen) { when { openSheet != null -> openSheet = null @@ -242,6 +248,10 @@ fun TreeScreen( node = node, onDismiss = { openSheet = null }, onLabel = { openSheet = OpenSheet.LABEL }, + onFind = { + findTarget = node + openSheet = null + }, onEdit = { openSheet = OpenSheet.EDIT }, onMove = { openSheet = OpenSheet.MOVE }, onDelete = { @@ -758,6 +768,7 @@ private fun ActionSheet( node: ApiNode, onDismiss: () -> Unit, onLabel: () -> Unit, + onFind: () -> Unit, onEdit: () -> Unit, onMove: () -> Unit, onDelete: () -> Unit, @@ -769,6 +780,7 @@ private fun ActionSheet( ) { Text(node.name, style = MaterialTheme.typography.titleLarge) OutlinedButton(onClick = onLabel, modifier = Modifier.fillMaxWidth()) { Text("Label") } + OutlinedButton(onClick = onFind, modifier = Modifier.fillMaxWidth()) { Text("Find") } OutlinedButton(onClick = onEdit, modifier = Modifier.fillMaxWidth()) { Text("Edit") } OutlinedButton(onClick = onMove, modifier = Modifier.fillMaxWidth()) { Text("Move") } OutlinedButton( @@ -803,19 +815,10 @@ private fun LabelSheet( modifier = Modifier.fillMaxWidth(), ) Text(node.lookupCode, fontWeight = FontWeight.Bold) - Row( + OutlinedButton( + onClick = { shareLabel(context, bitmap, node.lookupCode) }, 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") } - } + ) { Text("Share") } } } } diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 765e11a..094a545 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -77,7 +77,7 @@ The API is intentionally unversioned during pre-release development. - 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 +- Android QR-label preview, sharing, and scanning ## Near-term direction diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 724e40f..c842f56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,13 +9,15 @@ composeBom = "2026.02.01" coroutines = "1.10.2" codeScanner = "16.1.0" zxing = "3.5.4" -androidxPrint = "1.1.0" +camerax = "1.6.2" +mlkitBarcode = "17.3.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } 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-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } @@ -27,7 +29,11 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f 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" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +androidx-camera-mlkit-vision = { group = "androidx.camera", name = "camera-mlkit-vision", version.ref = "camerax" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/server/cmd/box-manifest-server/main.go b/server/cmd/box-manifest-server/main.go index 25b9ba0..cd22d2d 100644 --- a/server/cmd/box-manifest-server/main.go +++ b/server/cmd/box-manifest-server/main.go @@ -43,7 +43,11 @@ func main() { apiHandler := httpapi.New(store) mux.Handle("/api/", apiHandler) mux.Handle("/n/", apiHandler) - mux.Handle("/", http.FileServer(http.Dir(*webPath))) + webHandler := http.FileServer(http.Dir(*webPath)) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + webHandler.ServeHTTP(w, r) + })) server := &http.Server{ Addr: *address, diff --git a/web/app.js b/web/app.js index 401bfd7..ee0cc02 100644 --- a/web/app.js +++ b/web/app.js @@ -2,6 +2,7 @@ const state = { root: null, nodes: new Map(), selectedId: null, + importPreview: null, }; const elements = { @@ -20,6 +21,15 @@ const elements = { moveParent: document.querySelector("#move-parent"), moveChildren: document.querySelector("#move-children"), deleteButton: document.querySelector("#delete"), + importForm: document.querySelector("#import-form"), + importParent: document.querySelector("#import-parent"), + importManifest: document.querySelector("#import-manifest"), + importPreview: document.querySelector("#import-preview"), + importSummary: document.querySelector("#import-summary"), + importWarnings: document.querySelector("#import-warnings"), + importNormalized: document.querySelector("#import-normalized"), + importCommit: document.querySelector("#import-commit"), + importCancel: document.querySelector("#import-cancel"), }; async function api(path, options = {}) { @@ -32,7 +42,9 @@ async function api(path, options = {}) { } const body = await response.json(); if (!response.ok) { - throw new Error(body.message || `Request failed with status ${response.status}`); + throw new Error( + body.message || `Request failed with status ${response.status}`, + ); } return body; } @@ -42,9 +54,14 @@ async function loadTree() { state.root = await api("/api/tree"); state.nodes.clear(); indexNode(state.root); - const requestedCode = new URLSearchParams(window.location.search).get("code")?.toUpperCase(); + const requestedCode = new URLSearchParams(window.location.search) + .get("code") + ?.toUpperCase(); if (requestedCode) { - state.selectedId = [...state.nodes.values()].find((node) => node.lookupCode === requestedCode)?.id || null; + state.selectedId = + [...state.nodes.values()].find( + (node) => node.lookupCode === requestedCode, + )?.id || null; } if (state.selectedId && !state.nodes.has(state.selectedId)) { state.selectedId = null; @@ -66,6 +83,76 @@ function indexNode(node) { function render() { elements.tree.replaceChildren(renderNode(state.root)); renderSelection(); + renderImportParents(); + renderImportPreview(); +} + +function renderImportParents() { + const previous = + elements.importParent.value || state.selectedId || "unsorted"; + elements.importParent.replaceChildren(); + for (const node of state.nodes.values()) { + const option = document.createElement("option"); + option.value = node.id; + option.textContent = nodePath(node) + .map((part) => part.name) + .join(" / "); + elements.importParent.append(option); + } + elements.importParent.value = state.nodes.has(previous) + ? previous + : "unsorted"; +} + +function nodePath(node) { + const path = [node]; + let current = node; + while (current.parentId) { + current = state.nodes.get(current.parentId); + if (!current) break; + path.unshift(current); + } + return path; +} + +function renderImportPreview() { + const preview = state.importPreview; + elements.importPreview.hidden = !preview; + elements.importCommit.disabled = !preview; + if (!preview) { + elements.importSummary.textContent = ""; + elements.importWarnings.replaceChildren(); + elements.importNormalized.textContent = ""; + return; + } + elements.importSummary.textContent = `${preview.summary.nodes} nodes, maximum depth ${preview.summary.maximumDepth}`; + elements.importWarnings.replaceChildren(); + for (const warning of preview.warnings) { + const item = document.createElement("li"); + item.textContent = warning; + elements.importWarnings.append(item); + } + elements.importNormalized.replaceChildren( + renderImportNodes(preview.manifest.nodes), + ); +} + +function renderImportNodes(nodes) { + const list = document.createElement("ul"); + for (const node of nodes) { + const item = document.createElement("li"); + const label = document.createElement("strong"); + label.textContent = node.name; + item.append(label); + if (node.quantity !== undefined && node.quantity !== null) { + item.append(` — quantity ${node.quantity}`); + } + if (node.children?.length) { + item.append(renderImportNodes(node.children)); + } + list.append(item); + } + return list; } function renderNode(node) { @@ -73,7 +160,8 @@ function renderNode(node) { 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.textContent = + node.quantity === null ? node.name : `${node.name} (${node.quantity})`; button.addEventListener("click", () => { state.selectedId = node.id; renderSelection(); @@ -93,7 +181,9 @@ function renderSelection() { const hasSelection = Boolean(node); const protectedNode = node?.id === "root" || node?.id === "unsorted"; - elements.selected.textContent = node ? `${node.name} [${node.lookupCode}]` : "None"; + 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 ?? ""; @@ -145,24 +235,28 @@ elements.createForm.addEventListener("submit", async (event) => { 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), + 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, + 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 () => { @@ -176,6 +270,57 @@ elements.deleteButton.addEventListener("click", async () => { }); }); +elements.importForm.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + setStatus(""); + const manifest = JSON.parse(elements.importManifest.value); + state.importPreview = await api("/api/imports/tree/preview", { + method: "POST", + body: JSON.stringify({ + parentId: elements.importParent.value, + manifest, + }), + }); + renderImportPreview(); + } catch (error) { + state.importPreview = null; + renderImportPreview(); + setStatus( + error instanceof SyntaxError + ? `Invalid JSON: ${error.message}` + : error.message, + ); + } +}); + +elements.importManifest.addEventListener("input", clearImportPreview); +elements.importParent.addEventListener("change", clearImportPreview); +elements.importCancel.addEventListener("click", clearImportPreview); + +elements.importCommit.addEventListener("click", async () => { + const preview = state.importPreview; + if (!preview) return; + try { + setStatus(""); + const result = await api("/api/imports/tree/commit", { + method: "POST", + body: JSON.stringify({ planId: preview.planId }), + }); + state.importPreview = null; + elements.importManifest.value = ""; + await loadTree(); + setStatus(`Imported ${result.created.nodes} nodes`); + } catch (error) { + setStatus(error.message); + } +}); + +function clearImportPreview() { + state.importPreview = null; + renderImportPreview(); +} + async function perform(action) { try { setStatus(""); diff --git a/web/index.html b/web/index.html index 73cfea1..0d838c0 100644 --- a/web/index.html +++ b/web/index.html @@ -1,8 +1,8 @@ - - + + Box Manifest @@ -24,11 +24,11 @@

Update

@@ -44,7 +44,9 @@ @@ -59,14 +61,43 @@

Parent: Unsorted

+ +
+
+

Import tree

+ + + +
+ + +