add qr finder
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user