initial commit
This commit is contained in:
commit
f22d149a66
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
liblcl*
|
||||
yvbolt
|
||||
|
8
LICENSE
Normal file
8
LICENSE
Normal file
@ -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.
|
9
go.mod
Normal file
9
go.mod
Normal file
@ -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
|
||||
)
|
6
go.sum
Normal file
6
go.sum
Normal file
@ -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=
|
327
main.go
Normal file
327
main.go
Normal file
@ -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("<<Invalid UTF-8 %q>>", 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user