add qr finder

This commit is contained in:
2026-08-26 18:02:37 -05:00
parent f39868c2a3
commit 84a88d1264
11 changed files with 452 additions and 52 deletions
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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)
}
+3
View File
@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
@@ -0,0 +1,212 @@
package app.boxmanifest.labels
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ImageAnalysis
import androidx.camera.mlkit.vision.MlKitAnalyzer
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.camera.mlkit.vision.MlKitAnalyzer.Result
import app.boxmanifest.ApiNode
import app.boxmanifest.ui.components.BackIcon
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
private data class VisibleLabel(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val code: String,
)
@Composable
fun FindNodeScreen(target: ApiNode, onDismiss: () -> 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<VisibleLabel>()) }
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)
}
}
@@ -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)
@@ -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<OpenSheet?>(null) }
var searchOpen by remember { mutableStateOf(false) }
var findTarget by remember { mutableStateOf<ApiNode?>(null) }
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() }
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") }
}
}
}
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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" }
+5 -1
View File
@@ -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,
+162 -17
View File
@@ -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("");
+38 -7
View File
@@ -1,8 +1,8 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
@@ -24,11 +24,11 @@
<h3>Update</h3>
<label>
Name
<input id="update-name" required>
<input id="update-name" required />
</label>
<label>
Quantity
<input id="update-quantity" type="number" min="0" step="1">
<input id="update-quantity" type="number" min="0" step="1" />
</label>
<button type="submit">Update selected node</button>
</form>
@@ -44,7 +44,9 @@
<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>
<option value="CHILDREN_TO_UNSORTED">
Move direct children to Unsorted
</option>
</select>
</label>
<button type="submit">Move selected node</button>
@@ -59,14 +61,43 @@
<p id="create-parent">Parent: Unsorted</p>
<label>
Name
<input id="create-name" required>
<input id="create-name" required />
</label>
<label>
Quantity
<input id="create-quantity" type="number" min="0" step="1">
<input id="create-quantity" type="number" min="0" step="1" />
</label>
<button type="submit">Create</button>
</form>
</section>
<section>
<form id="import-form">
<h2>Import tree</h2>
<label>
Parent
<select id="import-parent" required></select>
</label>
<label>
JSON manifest
<textarea
id="import-manifest"
rows="16"
cols="72"
required
></textarea>
</label>
<button type="submit">Preview import</button>
</form>
<div id="import-preview" hidden>
<h3>Import preview</h3>
<p id="import-summary"></p>
<ul id="import-warnings"></ul>
<div id="import-normalized"></div>
<button id="import-commit" type="button">Commit import</button>
<button id="import-cancel" type="button">Cancel preview</button>
</div>
</section>
</body>
</html>