diff --git a/dbui/dbui.go b/dbui/dbui.go
index c3415a2..e74155c 100644
--- a/dbui/dbui.go
+++ b/dbui/dbui.go
@@ -56,6 +56,8 @@ func handler(db *bbolt.DB) http.Handler {
mux.HandleFunc("/api/put", func(w http.ResponseWriter, r *http.Request) { put(db, w, r) })
mux.HandleFunc("/api/delete", func(w http.ResponseWriter, r *http.Request) { del(db, w, r) })
mux.HandleFunc("/api/clearbucket", func(w http.ResponseWriter, r *http.Request) { clearBucket(db, w, r) })
+ mux.HandleFunc("/api/export", func(w http.ResponseWriter, r *http.Request) { exportBucket(db, w, r) })
+ mux.HandleFunc("/api/import", func(w http.ResponseWriter, r *http.Request) { importBucket(db, w, r) })
return mux
}
@@ -244,6 +246,80 @@ func clearBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "deleted": count})
}
+func exportBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
+ var bucket = r.URL.Query().Get("bucket")
+ if bucket == "" {
+ http.Error(w, "bucket is required", http.StatusBadRequest)
+ return
+ }
+ var rows = []kvRow{}
+ var err = db.View(func(tx *bbolt.Tx) error {
+ var b = tx.Bucket([]byte(bucket))
+ if b == nil { return errNoBucket }
+ return b.ForEach(func(k, v []byte) error {
+ rows = append(rows, kvRow{encodeField(k), encodeField(v)})
+ return nil
+ })
+ })
+ if err != nil {
+ if err == errNoBucket { http.Error(w, err.Error(), http.StatusNotFound); return }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]any{"rows": rows})
+}
+
+func importBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Bucket string `json:"bucket"`
+ Strategy string `json:"strategy"` // "skip" or "replace"
+ Rows []kvRow `json:"rows"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if body.Bucket == "" {
+ http.Error(w, "bucket is required", http.StatusBadRequest)
+ return
+ }
+ if body.Strategy != "skip" && body.Strategy != "replace" {
+ http.Error(w, "strategy must be 'skip' or 'replace'", http.StatusBadRequest)
+ return
+ }
+ var imported, skipped int
+ var err = db.Update(func(tx *bbolt.Tx) error {
+ var b = tx.Bucket([]byte(body.Bucket))
+ if b == nil { return errNoBucket }
+ for _, row := range body.Rows {
+ var key, kerr = decodeField(row.Key)
+ if kerr != nil { return kerr }
+ var value, verr = decodeField(row.Value)
+ if verr != nil { return verr }
+ var exists = b.Get(key) != nil
+ if exists && body.Strategy == "skip" {
+ skipped++
+ continue
+ }
+ if err := b.Put(key, value); err != nil { return err }
+ imported++
+ }
+ return nil
+ })
+ if err != nil {
+ var code = http.StatusBadRequest
+ if err == errNoBucket { code = http.StatusNotFound }
+ http.Error(w, err.Error(), code)
+ return
+ }
+ logging.Info("database UI: imported %d keys into %s (%d skipped)", imported, body.Bucket, skipped)
+ writeJSON(w, map[string]any{"ok": true, "imported": imported, "skipped": skipped})
+}
+
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
diff --git a/dbui/index.html b/dbui/index.html
index fc45f7d..729d39b 100644
--- a/dbui/index.html
+++ b/dbui/index.html
@@ -75,6 +75,19 @@
#status.err { color: #dc2626; }
#status.ok { color: #16a34a; }
.empty { color: var(--muted); text-align: center; padding: 40px 0; }
+ .dialog-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.45);
+ display: flex; align-items: center; justify-content: center; z-index: 100;
+ }
+ .dialog-overlay[hidden] { display: none; }
+ .dialog {
+ background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
+ padding: 20px 24px; min-width: 320px; max-width: 420px;
+ }
+ .dialog p { margin: 0 0 12px; }
+ .dialog label { display: block; margin-bottom: 6px; cursor: pointer; }
+ .dialog input[type="radio"] { min-width: 0; margin-right: 6px; }
+ .dialog-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
@@ -95,7 +108,7 @@ bitnsbot database
@@ -113,6 +126,21 @@ bitnsbot database
—
+
+
+
+
+
+
@@ -277,12 +305,127 @@ bitnsbot database
.catch(function(e) { status("clear failed: " + e, "err"); });
}
+ function csvEscape(s) {
+ if (s.indexOf('"') === -1 && s.indexOf(',') === -1 && s.indexOf('\n') === -1 && s.indexOf('\r') === -1) return s;
+ return '"' + s.replace(/"/g, '""') + '"';
+ }
+
+ function exportBucket() {
+ var bucket = $("bucket").value;
+ if (!bucket) { status("no bucket selected", "err"); return; }
+ status("exporting...", "");
+ fetch("api/export?bucket=" + encodeURIComponent(bucket))
+ .then(function(r) { if (!r.ok) throw new Error(r.status); return r.json(); })
+ .then(function(d) {
+ var lines = ["key,value"];
+ (d.rows || []).forEach(function(row) {
+ lines.push(csvEscape(row.key) + "," + csvEscape(row.value));
+ });
+ var csv = lines.join("\n");
+ var blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement("a");
+ a.href = url; a.download = bucket + ".csv";
+ document.body.appendChild(a); a.click();
+ document.body.removeChild(a); URL.revokeObjectURL(url);
+ status("exported " + (d.rows ? d.rows.length : 0) + " keys", "ok");
+ })
+ .catch(function(e) { status("export failed: " + e, "err"); });
+ }
+
+ function parseCSV(text) {
+ var rows = [];
+ var row = [];
+ var field = "";
+ var inQuotes = false;
+ for (var i = 0; i < text.length; i++) {
+ var ch = text[i];
+ if (inQuotes) {
+ if (ch === '"') {
+ if (i + 1 < text.length && text[i + 1] === '"') {
+ field += '"'; i++;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ field += ch;
+ }
+ } else {
+ if (ch === '"') {
+ inQuotes = true;
+ } else if (ch === ',') {
+ row.push(field); field = "";
+ } else if (ch === '\n') {
+ row.push(field); field = "";
+ rows.push(row); row = [];
+ } else if (ch !== '\r') {
+ field += ch;
+ }
+ }
+ }
+ if (field !== "" || row.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+ if (rows.length > 0 && rows[0].length === 2 && rows[0][0] === "key" && rows[0][1] === "value") {
+ rows.shift();
+ }
+ return rows;
+ }
+
+ function showImportDialog() {
+ var bucket = $("bucket").value;
+ if (!bucket) { status("no bucket selected", "err"); return; }
+ $("importBucketLabel").textContent = bucket;
+ $("importDialog").hidden = false;
+ }
+
+ function doImport() {
+ var bucket = $("bucket").value;
+ var strategy = document.querySelector('input[name="strategy"]:checked').value;
+ var file = $("importFileInput").files[0];
+ if (!file) { status("no file selected", "err"); return; }
+ status("importing...", "");
+ var reader = new FileReader();
+ reader.onload = function(e) {
+ var parsed = parseCSV(e.target.result);
+ var rows = [];
+ for (var i = 0; i < parsed.length; i++) {
+ if (parsed[i].length >= 2) {
+ rows.push({ key: parsed[i][0], value: parsed[i][1] });
+ }
+ }
+ fetch("api/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ bucket: bucket, strategy: strategy, rows: rows })
+ })
+ .then(function(r) {
+ if (!r.ok) return r.text().then(function(t) { throw new Error(t.trim() || r.status); });
+ return r.json();
+ })
+ .then(function(d) {
+ status("imported " + d.imported + " keys" + (d.skipped ? ", skipped " + d.skipped : ""), "ok");
+ page = 0; return loadView();
+ })
+ .catch(function(e) { status("import failed: " + e, "err"); });
+ };
+ reader.readAsText(file, "utf-8");
+ $("importDialog").hidden = true;
+ $("importFileInput").value = "";
+ }
+
$("view").addEventListener("click", function() { page = 0; loadView(); });
$("get").addEventListener("click", loadValue);
$("put").addEventListener("click", putValue);
$("prev").addEventListener("click", function() { if (page > 0) { page--; loadView(); } });
$("next").addEventListener("click", function() { page++; loadView(); });
$("clearBucket").addEventListener("click", clearBucket);
+ $("exportBtn").addEventListener("click", exportBucket);
+ $("importBtn").addEventListener("click", showImportDialog);
+ $("importCancel").addEventListener("click", function() { $("importDialog").hidden = true; });
+ $("importGo").addEventListener("click", function() { $("importFileInput").click(); });
+ $("importFileInput").addEventListener("change", doImport);
$("empty").hidden = false;
loadBuckets();