From f22d149a669a4dcb7718fa8c69f383758cee99b9 Mon Sep 17 00:00:00 2001 From: mappu Date: Mon, 3 Jun 2024 16:49:04 +1200 Subject: [PATCH] initial commit --- .gitignore | 3 + LICENSE | 8 ++ README.md | 3 + go.mod | 9 ++ go.sum | 6 + main.go | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 356 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b7227b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +liblcl* +yvbolt + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e6458eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +ISC License + +Copyright 2024 mappy +Copyright 2024 The yvbolt Author(s) + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6986825 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# yvbolt + +A graphical database browser using GoVCL. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be3ad01 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module yvbolt + +go 1.19 + +require ( + github.com/ying32/govcl v2.2.3+incompatible // indirect + go.etcd.io/bbolt v1.3.10 // indirect + golang.org/x/sys v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d4ca1e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/ying32/govcl v2.2.3+incompatible h1:Iyfcl26yNE1USm+3uG+btQyhkoFIV18+VITrUdHu8Lw= +github.com/ying32/govcl v2.2.3+incompatible/go.mod h1:yZVtbJ9Md1nAVxtHKIriKZn4K6TQYqI1en3sN/m9FJ8= +go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= +go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b4056a7 --- /dev/null +++ b/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + "unsafe" + + _ "github.com/ying32/govcl/pkgs/winappres" // Extra _syso files for Windows + "github.com/ying32/govcl/vcl" + "github.com/ying32/govcl/vcl/types" + + "go.etcd.io/bbolt" +) + +type loadedDatabase struct { + displayName string + path string + db *bbolt.DB + nav *vcl.TTreeNode +} + +type navData struct { + ld *loadedDatabase + childrenLoaded bool + bucketPath []string +} + +type TMainForm struct { + *vcl.TForm + Menu *vcl.TMainMenu + Buckets *vcl.TTreeView + Tabs *vcl.TPageControl + propertiesBox *vcl.TMemo + contentBox *vcl.TListView + + dbs []loadedDatabase + arena []*navData // keepalive +} + +var ( + mainForm *TMainForm +) + +func main() { + vcl.RunApp(&mainForm) +} + +func (f *TMainForm) OnFormCreate(sender vcl.IObject) { + const MY_SPACING = 6 + const MY_WIDTH = 180 + + f.SetCaption("yvbolt") + + mnuFile := vcl.NewMenuItem(f) + mnuFile.SetCaption("File") + + mnuFileOpen := vcl.NewMenuItem(mnuFile) + mnuFileOpen.SetCaption("Open...") + mnuFileOpen.SetShortCutFromString("Ctrl+O") + mnuFileOpen.SetOnClick(f.OnMnuFileOpenClick) + mnuFile.Add(mnuFileOpen) + + f.Menu = vcl.NewMainMenu(f) + f.Menu.Items().Add(mnuFile) + + f.Buckets = vcl.NewTreeView(f) + f.Buckets.SetParent(f) + f.Buckets.SetAlign(types.AlLeft) + f.Buckets.SetWidth(MY_WIDTH) + f.Buckets.SetReadOnly(true) // prevent click to rename on nodes + f.Buckets.SetOnExpanding(f.OnNavExpanding) + f.Buckets.SetOnChange(f.OnNavChange) + + hsplit := vcl.NewSplitter(f) + hsplit.SetParent(f) + hsplit.SetAlign(types.AlLeft) + hsplit.SetLeft(1) // Just needs to be further "over" than f.Buckets for auto-alignment + + f.Tabs = vcl.NewPageControl(f) + f.Tabs.SetParent(f) + f.Tabs.SetAlign(types.AlClient) + + propertiesTab := vcl.NewTabSheet(f.Tabs) + propertiesTab.SetParent(f.Tabs) + propertiesTab.SetCaption("Properties") + + f.propertiesBox = vcl.NewMemo(propertiesTab) + f.propertiesBox.SetParent(propertiesTab) + f.propertiesBox.BorderSpacing().SetAround(MY_SPACING) + f.propertiesBox.SetAlign(types.AlClient) // fill remaining space + f.propertiesBox.SetReadOnly(true) + f.propertiesBox.SetEnabled(false) + f.propertiesBox.SetBorderStyle(types.BsNone) + f.propertiesBox.SetText("Open a database to get started...") + + dataTab := vcl.NewTabSheet(f.Tabs) + dataTab.SetParent(f.Tabs) + dataTab.SetCaption("Data") + + f.contentBox = vcl.NewListView(dataTab) + f.contentBox.SetParent(dataTab) + f.contentBox.BorderSpacing().SetAround(MY_SPACING) + f.contentBox.SetAlign(types.AlClient) // fill remaining space + f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns + f.contentBox.SetAutoWidthLastColumn(true) + f.contentBox.SetReadOnly(true) + colKey := f.contentBox.Columns().Add() + colKey.SetCaption("Key") + colKey.SetWidth(MY_WIDTH) + colKey.SetAlignment(types.TaLeftJustify) + colVal := f.contentBox.Columns().Add() + colVal.SetCaption("Value") + +} + +func (f *TMainForm) OnMnuFileOpenClick(sender vcl.IObject) { + dlg := vcl.NewOpenDialog(f) + dlg.SetTitle("Select a database file...") + dlg.SetFilter("Bolt database|*.db|SQLite database|*.db3|All files|*.*") + ret := dlg.Execute() // Fake blocking + if ret { + f.addDatabaseFromFile(dlg.FileName()) + } +} + +func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) { + + if node.Data() == nil { + vcl.ShowMessage("unexpected nil data") + return + } + + ndata := (*navData)(node.Data()) + + // Load properties + + bucketDisplayName := strings.Join(ndata.bucketPath, `/`) + content := fmt.Sprintf("Selected database: %#v\n\n\nSelected bucket: %q\n", ndata.ld.db.Stats(), bucketDisplayName) + f.propertiesBox.SetText(content) + + // Load data + + f.contentBox.SetEnabled(false) + f.contentBox.Clear() + + err := ndata.ld.db.View(func(tx *bbolt.Tx) error { + b := boltTargetBucket(tx, ndata.bucketPath) + if b == nil { + // no such bucket + return nil + } + + // Valid + f.contentBox.Clear() + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + dataEntry := f.contentBox.Items().Add() + dataEntry.SetCaption(formatUtf8(k)) + dataEntry.SubItems().Add(formatUtf8(v)) + } + return nil + }) + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error())) + return + } + + // Valid + f.contentBox.SetEnabled(true) +} + +func formatUtf8(in []byte) string { + if !utf8.Valid(in) { + return fmt.Sprintf("<>", in) + } + + return string(in) +} + +func boltTargetBucket(tx *bbolt.Tx, path []string) *bbolt.Bucket { + + // If we are already deep in buckets, go directly there to find children + if len(path) == 0 { + return nil + } + + b := tx.Bucket([]byte(path[0])) + if b == nil { + return nil // unexpectedly missing + } + + for i := 1; i < len(path); i += 1 { + b = b.Bucket([]byte(path[i])) + if b == nil { + return nil // unexpectedly missing + } + } + + return b // OK +} + +func boltChildBucketNames(db *bbolt.DB, path []string) ([]string, error) { + var nextBucketNames []string + + err := db.View(func(tx *bbolt.Tx) error { + + // If we are already deep in buckets, go directly there to find children + if len(path) > 0 { + b := tx.Bucket([]byte(path[0])) + if b == nil { + return fmt.Errorf("Unexpected missing root bucket %q", path[0]) + } + + for i := 1; i < len(path); i += 1 { + b = b.Bucket([]byte(path[i])) + if b == nil { + return fmt.Errorf("Unexpected missing bucket %q", strings.Join(path[0:i], `/`)) + } + } + + // Find child buckets of this bucket + b.ForEachBucket(func(bucketName []byte) error { + nextBucketNames = append(nextBucketNames, string(bucketName)) + return nil + }) + + } else { + // Find root bucket names + return tx.ForEach(func(bucketName []byte, _ *bbolt.Bucket) error { + nextBucketNames = append(nextBucketNames, string(bucketName)) + return nil + }) + } + + // OK + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(nextBucketNames) + + return nextBucketNames, nil +} + +func (f *TMainForm) OnNavExpanding(sender vcl.IObject, node *vcl.TTreeNode, allowExpansion *bool) { + + if node.Data() == nil { + vcl.ShowMessage("unexpected nil data") + *allowExpansion = false + return + } + + ndata := (*navData)(node.Data()) + + if ndata.childrenLoaded { + return + } + + // Find the child buckets from this point under the element + nextBucketNames, err := boltChildBucketNames(ndata.ld.db, ndata.bucketPath) + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to find child buckets under %q: %s", strings.Join(ndata.bucketPath, `/`), err.Error())) + *allowExpansion = false + return + } + + ndata.childrenLoaded = true // don't repeat this work + + if len(nextBucketNames) == 0 { + node.SetHasChildren(false) + *allowExpansion = false + + } else { + + // Populate LCL child nodes + for _, bucketName := range nextBucketNames { + + node := f.Buckets.Items().AddChild(node, formatUtf8([]byte(bucketName))) + node.SetHasChildren(true) // dynamically populate in OnNavExpanding + navData := &navData{ + ld: ndata.ld, + childrenLoaded: false, // will be loaded dynamically + bucketPath: []string{}, // empty = root + } + navData.bucketPath = append(navData.bucketPath, ndata.bucketPath...) + navData.bucketPath = append(navData.bucketPath, bucketName) + node.SetData(unsafe.Pointer(navData)) + f.arena = append(f.arena, navData) // keepalive + } + + *allowExpansion = true + } +} + +func (f *TMainForm) addDatabaseFromFile(path string) { + // TODO load in background thread to stop blocking the UI + db, err := bbolt.Open(path, 0644, &bbolt.Options{Timeout: 1 * time.Second}) + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to load database '%s': %s", path, err.Error())) + return + } + + entry := loadedDatabase{ + path: path, + displayName: filepath.Base(path), + db: db, + } + + entry.nav = f.Buckets.Items().Add(nil, entry.displayName) + entry.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding + navData := &navData{ + ld: &entry, + childrenLoaded: false, // will be loaded dynamically + bucketPath: []string{}, // empty = root + } + entry.nav.SetData(unsafe.Pointer(navData)) + + f.dbs = append(f.dbs, entry) + f.arena = append(f.arena, navData) // keepalive +}