Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 841575700e | |||
| 617393b627 | |||
| 91f9c5fc30 | |||
| 6234f02ea6 | |||
| 8b1e7064e7 | |||
| 00a96bfe84 | |||
| 232a1dd0e8 | |||
| cb4b35b059 | |||
| f913b63c58 | |||
| d97c8872de |
36
README.md
@@ -1,21 +1,41 @@
|
|||||||
# yvbolt
|
# yvbolt
|
||||||
|
|
||||||
A graphical browser for [Bolt databases](https://github.com/etcd-io/bbolt) using [GoVCL](https://z-kit.cc/en/).
|
A graphical browser for multiple databases using [GoVCL](https://z-kit.cc/en/).
|
||||||
|
|
||||||
This is an experimental application and you should generally prefer to use [qbolt](https://code.ivysaur.me/qbolt).
|
This is an experimental application and you should generally prefer to use [qbolt](https://code.ivysaur.me/qbolt).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Supports Bolt databases using the upstream etcd.io/bbolt library
|
- Native desktop application, running on Linux, Windows, and macOS
|
||||||
- Browse database content
|
- Supported databases:
|
||||||
- Recursive bucket support
|
- Bolt
|
||||||
- Safe handling for non-UTF8 key and data fields
|
- Full compatibility via the upstream [etcd-io/bbolt](https://github.com/etcd-io/bbolt) library
|
||||||
- No CGO for easy cross-compilation
|
- Browse database content
|
||||||
- Permissive ISC license
|
- Recursive bucket support
|
||||||
|
- Safe handling for non-UTF8 key and data fields
|
||||||
|
- SQLite
|
||||||
|
- Browse table content
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The code in this project is licensed under the ISC license (see `LICENSE` file for details).
|
||||||
|
|
||||||
|
This project redistributes images from the famfamfam/silk icon set under the [CC-BY 2.5 license](http://creativecommons.org/licenses/by/2.5/).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. `go build`
|
1. `CGO_ENABLED=1 go build`
|
||||||
2. [Download liblcl](https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip) for your platform, or [compile it yourself](https://github.com/ying32/liblcl) (tested with v2.2.3)
|
2. [Download liblcl](https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip) for your platform, or [compile it yourself](https://github.com/ying32/liblcl) (tested with v2.2.3)
|
||||||
3. Place the liblcl library file in the same directory as `yvbolt`
|
3. Place the liblcl library file in the same directory as `yvbolt`
|
||||||
4. Run `yvbolt` and use the main menu to open a database
|
4. Run `yvbolt` and use the main menu to open a database
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
2024-06-08 v0.2.0
|
||||||
|
|
||||||
|
- Add SQLite support (now requires CGo)
|
||||||
|
- Add images for menu and navigation items
|
||||||
|
|
||||||
|
2024-06-03 v0.1.0
|
||||||
|
|
||||||
|
- Initial public release
|
||||||
|
|||||||
BIN
assets/database.png
Normal file
|
After Width: | Height: | Size: 390 B |
BIN
assets/database_add.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
assets/database_delete.png
Normal file
|
After Width: | Height: | Size: 659 B |
BIN
assets/database_save.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
assets/table.png
Normal file
|
After Width: | Height: | Size: 566 B |
BIN
assets/table_add.png
Normal file
|
After Width: | Height: | Size: 663 B |
BIN
assets/table_delete.png
Normal file
|
After Width: | Height: | Size: 660 B |
BIN
assets/table_save.png
Normal file
|
After Width: | Height: | Size: 723 B |
190
bolt.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ying32/govcl/vcl"
|
||||||
|
"github.com/ying32/govcl/vcl/types"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type boltLoadedDatabase struct {
|
||||||
|
displayName string
|
||||||
|
path string
|
||||||
|
db *bbolt.DB
|
||||||
|
nav *vcl.TTreeNode
|
||||||
|
|
||||||
|
arena []*navData // keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) DisplayName() string {
|
||||||
|
return ld.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) RootElement() *vcl.TTreeNode {
|
||||||
|
return ld.nav
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) Keepalive(ndata *navData) {
|
||||||
|
ld.arena = append(ld.arena, ndata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||||
|
|
||||||
|
// Load properties
|
||||||
|
|
||||||
|
bucketDisplayName := strings.Join(ndata.bucketPath, `/`)
|
||||||
|
content := fmt.Sprintf("Selected database: %#v\n\n\nSelected bucket: %q\n", ld.db.Stats(), bucketDisplayName)
|
||||||
|
f.propertiesBox.SetText(content)
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
|
||||||
|
f.contentBox.SetEnabled(false)
|
||||||
|
f.contentBox.Clear()
|
||||||
|
|
||||||
|
// Bolt always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.contentBox.Columns().Clear()
|
||||||
|
colKey := f.contentBox.Columns().Add()
|
||||||
|
colKey.SetCaption("Key")
|
||||||
|
colKey.SetWidth(MY_WIDTH)
|
||||||
|
colKey.SetAlignment(types.TaLeftJustify)
|
||||||
|
colVal := f.contentBox.Columns().Add()
|
||||||
|
colVal.SetCaption("Value")
|
||||||
|
|
||||||
|
err := 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 (ld *boltLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
|
||||||
|
// In the bolt implementation, the nav is a recursive tree of child buckets
|
||||||
|
return boltChildBucketNames(ld.db, ndata.bucketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &boltLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
func (f *TMainForm) boltAddDatabaseFromFile(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
|
||||||
|
}
|
||||||
|
|
||||||
|
ld := &boltLoadedDatabase{
|
||||||
|
path: path,
|
||||||
|
displayName: filepath.Base(path),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
ld.nav = f.Buckets.Items().Add(nil, ld.displayName)
|
||||||
|
ld.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding
|
||||||
|
ld.nav.SetImageIndex(imgDatabase)
|
||||||
|
navData := &navData{
|
||||||
|
ld: ld,
|
||||||
|
childrenLoaded: false, // will be loaded dynamically
|
||||||
|
bucketPath: []string{}, // empty = root
|
||||||
|
}
|
||||||
|
ld.nav.SetData(unsafe.Pointer(navData))
|
||||||
|
|
||||||
|
f.dbs = append(f.dbs, ld)
|
||||||
|
|
||||||
|
ld.Keepalive(navData)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
22
format.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatUtf8(in []byte) string {
|
||||||
|
if !utf8.Valid(in) {
|
||||||
|
return fmt.Sprintf("<<Invalid UTF-8 %q>>", in)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAny(in interface{}) string {
|
||||||
|
if _, ok := in.([]byte); ok {
|
||||||
|
return "<<binary>>"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%#v", in)
|
||||||
|
}
|
||||||
1
go.mod
@@ -3,6 +3,7 @@ module yvbolt
|
|||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/ying32/govcl v2.2.3+incompatible // indirect
|
github.com/ying32/govcl v2.2.3+incompatible // indirect
|
||||||
go.etcd.io/bbolt v1.3.10 // indirect
|
go.etcd.io/bbolt v1.3.10 // indirect
|
||||||
golang.org/x/sys v0.4.0 // indirect
|
golang.org/x/sys v0.4.0 // indirect
|
||||||
|
|||||||
2
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/ying32/govcl v2.2.3+incompatible h1:Iyfcl26yNE1USm+3uG+btQyhkoFIV18+VITrUdHu8Lw=
|
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=
|
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 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
|
||||||
|
|||||||
50
images.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/ying32/govcl/vcl"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/*
|
||||||
|
var assetsFs embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
imgDatabase = 0
|
||||||
|
imgDatabaseAdd = 1
|
||||||
|
imgDatabaseDelete = 2
|
||||||
|
imgDatabaseSave = 3
|
||||||
|
imgTable = 4
|
||||||
|
imgTableAdd = 5
|
||||||
|
imgTableDelete = 6
|
||||||
|
imgTableSave = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadImages(owner vcl.IComponent) *vcl.TImageList {
|
||||||
|
|
||||||
|
mustLoad := func(n string) *vcl.TBitmap {
|
||||||
|
imgData, err := assetsFs.ReadFile(n)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
png := vcl.NewPngImage()
|
||||||
|
png.LoadFromBytes(imgData)
|
||||||
|
|
||||||
|
ret := vcl.NewBitmap()
|
||||||
|
ret.Assign(png)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
ilist := vcl.NewImageList(owner)
|
||||||
|
ilist.Add(mustLoad("assets/database.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/database_add.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/database_delete.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/database_save.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/table.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/table_add.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/table_delete.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/table_save.png"), nil)
|
||||||
|
return ilist
|
||||||
|
|
||||||
|
}
|
||||||
21
loadedDatabase.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ying32/govcl/vcl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadedDatabase is a DB-agnostic interface for each loaded database.
|
||||||
|
type loadedDatabase interface {
|
||||||
|
DisplayName() string
|
||||||
|
RootElement() *vcl.TTreeNode
|
||||||
|
RenderForNav(f *TMainForm, ndata *navData)
|
||||||
|
NavChildren(ndata *navData) ([]string, error)
|
||||||
|
Keepalive(ndata *navData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// navData is the .Data() pointer for each TTreeNode in the left-hand tree.
|
||||||
|
type navData struct {
|
||||||
|
ld loadedDatabase
|
||||||
|
childrenLoaded bool
|
||||||
|
bucketPath []string
|
||||||
|
}
|
||||||
232
main.go
@@ -2,43 +2,31 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"os"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
_ "github.com/ying32/govcl/pkgs/winappres" // Extra _syso files for Windows
|
_ "github.com/ying32/govcl/pkgs/winappres" // Extra _syso files for Windows
|
||||||
"github.com/ying32/govcl/vcl"
|
"github.com/ying32/govcl/vcl"
|
||||||
"github.com/ying32/govcl/vcl/types"
|
"github.com/ying32/govcl/vcl/types"
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type loadedDatabase struct {
|
const (
|
||||||
displayName string
|
MY_SPACING = 6
|
||||||
path string
|
MY_WIDTH = 180
|
||||||
db *bbolt.DB
|
)
|
||||||
nav *vcl.TTreeNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type navData struct {
|
|
||||||
ld *loadedDatabase
|
|
||||||
childrenLoaded bool
|
|
||||||
bucketPath []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TMainForm struct {
|
type TMainForm struct {
|
||||||
*vcl.TForm
|
*vcl.TForm
|
||||||
|
|
||||||
|
ImageList *vcl.TImageList
|
||||||
Menu *vcl.TMainMenu
|
Menu *vcl.TMainMenu
|
||||||
Buckets *vcl.TTreeView
|
Buckets *vcl.TTreeView
|
||||||
Tabs *vcl.TPageControl
|
Tabs *vcl.TPageControl
|
||||||
propertiesBox *vcl.TMemo
|
propertiesBox *vcl.TMemo
|
||||||
contentBox *vcl.TListView
|
contentBox *vcl.TListView
|
||||||
|
|
||||||
dbs []loadedDatabase
|
dbs []loadedDatabase
|
||||||
arena []*navData // keepalive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -50,8 +38,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||||
const MY_SPACING = 6
|
f.ImageList = loadImages(f)
|
||||||
const MY_WIDTH = 180
|
|
||||||
|
|
||||||
f.SetCaption("yvbolt")
|
f.SetCaption("yvbolt")
|
||||||
|
|
||||||
@@ -59,16 +46,40 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
mnuFile.SetCaption("File")
|
mnuFile.SetCaption("File")
|
||||||
|
|
||||||
mnuFileOpen := vcl.NewMenuItem(mnuFile)
|
mnuFileOpen := vcl.NewMenuItem(mnuFile)
|
||||||
mnuFileOpen.SetCaption("Open...")
|
mnuFileOpen.SetCaption("Open Bolt database...")
|
||||||
|
mnuFileOpen.SetImageIndex(imgDatabaseAdd)
|
||||||
mnuFileOpen.SetShortCutFromString("Ctrl+O")
|
mnuFileOpen.SetShortCutFromString("Ctrl+O")
|
||||||
mnuFileOpen.SetOnClick(f.OnMnuFileOpenClick)
|
mnuFileOpen.SetOnClick(f.OnMnuFileOpenClick)
|
||||||
mnuFile.Add(mnuFileOpen)
|
mnuFile.Add(mnuFileOpen)
|
||||||
|
|
||||||
|
mnuFileSqliteOpen := vcl.NewMenuItem(mnuFile)
|
||||||
|
mnuFileSqliteOpen.SetCaption("Open SQLite database...")
|
||||||
|
mnuFileSqliteOpen.SetImageIndex(imgDatabaseAdd)
|
||||||
|
mnuFileSqliteOpen.SetOnClick(f.OnMnuFileSqliteOpenClick)
|
||||||
|
mnuFile.Add(mnuFileSqliteOpen)
|
||||||
|
|
||||||
|
mnuFileSqliteMemory := vcl.NewMenuItem(mnuFile)
|
||||||
|
mnuFileSqliteMemory.SetCaption("New SQLite in-memory database")
|
||||||
|
mnuFileSqliteMemory.SetImageIndex(imgDatabaseAdd)
|
||||||
|
mnuFileSqliteMemory.SetOnClick(f.OnMnuFileSqliteMemoryClick)
|
||||||
|
mnuFile.Add(mnuFileSqliteMemory)
|
||||||
|
|
||||||
|
mnuSep := vcl.NewMenuItem(mnuFile)
|
||||||
|
mnuSep.SetCaption("-") // Creates separator
|
||||||
|
mnuFile.Add(mnuSep)
|
||||||
|
|
||||||
|
mnuFileExit := vcl.NewMenuItem(mnuFile)
|
||||||
|
mnuFileExit.SetCaption("Exit")
|
||||||
|
mnuFileExit.SetOnClick(f.OnMnuFileExitClick)
|
||||||
|
mnuFile.Add(mnuFileExit)
|
||||||
|
|
||||||
f.Menu = vcl.NewMainMenu(f)
|
f.Menu = vcl.NewMainMenu(f)
|
||||||
|
f.Menu.SetImages(f.ImageList)
|
||||||
f.Menu.Items().Add(mnuFile)
|
f.Menu.Items().Add(mnuFile)
|
||||||
|
|
||||||
f.Buckets = vcl.NewTreeView(f)
|
f.Buckets = vcl.NewTreeView(f)
|
||||||
f.Buckets.SetParent(f)
|
f.Buckets.SetParent(f)
|
||||||
|
f.Buckets.SetImages(f.ImageList)
|
||||||
f.Buckets.SetAlign(types.AlLeft)
|
f.Buckets.SetAlign(types.AlLeft)
|
||||||
f.Buckets.SetWidth(MY_WIDTH)
|
f.Buckets.SetWidth(MY_WIDTH)
|
||||||
f.Buckets.SetReadOnly(true) // prevent click to rename on nodes
|
f.Buckets.SetReadOnly(true) // prevent click to rename on nodes
|
||||||
@@ -108,25 +119,37 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
|
f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
|
||||||
f.contentBox.SetAutoWidthLastColumn(true)
|
f.contentBox.SetAutoWidthLastColumn(true)
|
||||||
f.contentBox.SetReadOnly(true)
|
f.contentBox.SetReadOnly(true)
|
||||||
colKey := f.contentBox.Columns().Add()
|
f.contentBox.Columns().Clear()
|
||||||
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) {
|
func (f *TMainForm) OnMnuFileOpenClick(sender vcl.IObject) {
|
||||||
dlg := vcl.NewOpenDialog(f)
|
dlg := vcl.NewOpenDialog(f)
|
||||||
dlg.SetTitle("Select a database file...")
|
dlg.SetTitle("Select a database file...")
|
||||||
dlg.SetFilter("Bolt database|*.db|SQLite database|*.db3|All files|*.*")
|
dlg.SetFilter("Bolt database|*.db|All files|*.*")
|
||||||
ret := dlg.Execute() // Fake blocking
|
ret := dlg.Execute() // Fake blocking
|
||||||
if ret {
|
if ret {
|
||||||
f.addDatabaseFromFile(dlg.FileName())
|
f.boltAddDatabaseFromFile(dlg.FileName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *TMainForm) OnMnuFileSqliteOpenClick(sender vcl.IObject) {
|
||||||
|
dlg := vcl.NewOpenDialog(f)
|
||||||
|
dlg.SetTitle("Select a database file...")
|
||||||
|
dlg.SetFilter("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*")
|
||||||
|
ret := dlg.Execute() // Fake blocking
|
||||||
|
if ret {
|
||||||
|
f.sqliteAddDatabaseFromFile(dlg.FileName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *TMainForm) OnMnuFileSqliteMemoryClick(sender vcl.IObject) {
|
||||||
|
f.sqliteAddDatabaseFromFile(`:memory:`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *TMainForm) OnMnuFileExitClick(sender vcl.IObject) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
|
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
|
||||||
|
|
||||||
if node.Data() == nil {
|
if node.Data() == nil {
|
||||||
@@ -135,118 +158,7 @@ func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ndata := (*navData)(node.Data())
|
ndata := (*navData)(node.Data())
|
||||||
|
ndata.ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
|
||||||
// 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) {
|
func (f *TMainForm) OnNavExpanding(sender vcl.IObject, node *vcl.TTreeNode, allowExpansion *bool) {
|
||||||
@@ -264,7 +176,7 @@ func (f *TMainForm) OnNavExpanding(sender vcl.IObject, node *vcl.TTreeNode, allo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the child buckets from this point under the element
|
// Find the child buckets from this point under the element
|
||||||
nextBucketNames, err := boltChildBucketNames(ndata.ld.db, ndata.bucketPath)
|
nextBucketNames, err := ndata.ld.NavChildren(ndata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vcl.ShowMessage(fmt.Sprintf("Failed to find child buckets under %q: %s", strings.Join(ndata.bucketPath, `/`), err.Error()))
|
vcl.ShowMessage(fmt.Sprintf("Failed to find child buckets under %q: %s", strings.Join(ndata.bucketPath, `/`), err.Error()))
|
||||||
*allowExpansion = false
|
*allowExpansion = false
|
||||||
@@ -292,36 +204,10 @@ func (f *TMainForm) OnNavExpanding(sender vcl.IObject, node *vcl.TTreeNode, allo
|
|||||||
navData.bucketPath = append(navData.bucketPath, ndata.bucketPath...)
|
navData.bucketPath = append(navData.bucketPath, ndata.bucketPath...)
|
||||||
navData.bucketPath = append(navData.bucketPath, bucketName)
|
navData.bucketPath = append(navData.bucketPath, bucketName)
|
||||||
node.SetData(unsafe.Pointer(navData))
|
node.SetData(unsafe.Pointer(navData))
|
||||||
f.arena = append(f.arena, navData) // keepalive
|
|
||||||
|
ndata.ld.Keepalive(navData)
|
||||||
}
|
}
|
||||||
|
|
||||||
*allowExpansion = true
|
*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
|
|
||||||
}
|
|
||||||
|
|||||||
215
sqlite.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/ying32/govcl/vcl"
|
||||||
|
"github.com/ying32/govcl/vcl/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqliteTablesCaption = "Tables"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteLoadedDatabase struct {
|
||||||
|
displayName string
|
||||||
|
path string
|
||||||
|
db *sql.DB
|
||||||
|
nav *vcl.TTreeNode
|
||||||
|
|
||||||
|
arena []*navData // keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) DisplayName() string {
|
||||||
|
return ld.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) RootElement() *vcl.TTreeNode {
|
||||||
|
return ld.nav
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) Keepalive(ndata *navData) {
|
||||||
|
ld.arena = append(ld.arena, ndata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||||
|
|
||||||
|
if len(ndata.bucketPath) == 0 {
|
||||||
|
// Top-level
|
||||||
|
f.propertiesBox.SetText("Please select...")
|
||||||
|
f.contentBox.SetEnabled(false)
|
||||||
|
f.contentBox.Clear()
|
||||||
|
|
||||||
|
} else if len(ndata.bucketPath) == 1 {
|
||||||
|
// Category (tables, ...)
|
||||||
|
f.propertiesBox.SetText("Please select...")
|
||||||
|
f.contentBox.SetEnabled(false)
|
||||||
|
f.contentBox.Clear()
|
||||||
|
|
||||||
|
} else if len(ndata.bucketPath) == 2 && ndata.bucketPath[0] == sqliteTablesCaption {
|
||||||
|
// Render for specific table
|
||||||
|
tableName := ndata.bucketPath[1]
|
||||||
|
|
||||||
|
// Get some basic properties
|
||||||
|
r := ld.db.QueryRow(`SELECT sql FROM sqlite_schema WHERE name = ?;`, tableName)
|
||||||
|
var schemaStmt string
|
||||||
|
err := r.Scan(&schemaStmt)
|
||||||
|
if err != nil {
|
||||||
|
schemaStmt = fmt.Sprintf("* Failed to describe table %q: %s", tableName, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display table properties
|
||||||
|
f.propertiesBox.SetText(fmt.Sprintf("Selected table %q\n\nSchema:\n\n%s", tableName, schemaStmt))
|
||||||
|
|
||||||
|
f.contentBox.SetEnabled(false)
|
||||||
|
f.contentBox.Clear()
|
||||||
|
|
||||||
|
// Load column details
|
||||||
|
// Use SELECT form instead of common PRAGMA table_info so we can just get names
|
||||||
|
// We could possibly get this from the main data select, but this will
|
||||||
|
// work even when there are 0 results
|
||||||
|
colr, err := ld.db.Query(`SELECT name FROM pragma_table_info(?)`, tableName)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer colr.Close()
|
||||||
|
|
||||||
|
f.contentBox.Columns().Clear()
|
||||||
|
numColumns := 0
|
||||||
|
for colr.Next() {
|
||||||
|
|
||||||
|
var columnName string
|
||||||
|
err = colr.Scan(&columnName)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessageFmt("Failed to read column names for table %q: %s", tableName, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
col := f.contentBox.Columns().Add()
|
||||||
|
col.SetCaption(columnName)
|
||||||
|
col.SetWidth(MY_WIDTH)
|
||||||
|
col.SetAlignment(types.TaLeftJustify)
|
||||||
|
|
||||||
|
numColumns++
|
||||||
|
}
|
||||||
|
if colr.Err() != nil {
|
||||||
|
vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
colr.Close() // will be double-closed
|
||||||
|
|
||||||
|
// Select * with small limit
|
||||||
|
datar, err := ld.db.Query(`SELECT * FROM ` + tableName + ` LIMIT 1000`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer datar.Close()
|
||||||
|
|
||||||
|
for datar.Next() {
|
||||||
|
fields := make([]interface{}, numColumns)
|
||||||
|
pfields := make([]interface{}, numColumns)
|
||||||
|
for i := 0; i < numColumns; i += 1 {
|
||||||
|
pfields[i] = &fields[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
err = datar.Scan(pfields...)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataEntry := f.contentBox.Items().Add()
|
||||||
|
dataEntry.SetCaption(formatAny(fields[0]))
|
||||||
|
for i := 1; i < len(fields); i += 1 {
|
||||||
|
dataEntry.SubItems().Add(formatAny(fields[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// We successfully populated the data grid
|
||||||
|
f.contentBox.SetEnabled(true)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ??? unknown
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
|
||||||
|
|
||||||
|
if len(ndata.bucketPath) == 0 {
|
||||||
|
// The top-level children are always:
|
||||||
|
return []string{sqliteTablesCaption}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ndata.bucketPath) == 1 && ndata.bucketPath[0] == sqliteTablesCaption {
|
||||||
|
rr, err := ld.db.Query(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name ASC;`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rr.Close()
|
||||||
|
|
||||||
|
var gather []string
|
||||||
|
for rr.Next() {
|
||||||
|
var tableName string
|
||||||
|
err = rr.Scan(&tableName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gather = append(gather, tableName)
|
||||||
|
}
|
||||||
|
if rr.Err() != nil {
|
||||||
|
return nil, rr.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return gather, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ndata.bucketPath) == 2 {
|
||||||
|
return nil, nil // Never any deeper children
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unknown nav path %#v", ndata.bucketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &sqliteLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
func (f *TMainForm) sqliteAddDatabaseFromFile(path string) {
|
||||||
|
|
||||||
|
// TODO load in background thread to stop blocking the UI
|
||||||
|
db, err := sql.Open("sqlite3", path)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessage(fmt.Sprintf("Failed to load database '%s': %s", path, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ld := &sqliteLoadedDatabase{
|
||||||
|
path: path,
|
||||||
|
displayName: filepath.Base(path),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
ld.nav = f.Buckets.Items().Add(nil, ld.displayName)
|
||||||
|
ld.nav.SetImageIndex(imgDatabase)
|
||||||
|
ld.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding
|
||||||
|
navData := &navData{
|
||||||
|
ld: ld,
|
||||||
|
childrenLoaded: false, // will be loaded dynamically
|
||||||
|
bucketPath: []string{}, // empty = root
|
||||||
|
}
|
||||||
|
ld.nav.SetData(unsafe.Pointer(navData))
|
||||||
|
|
||||||
|
f.dbs = append(f.dbs, ld)
|
||||||
|
|
||||||
|
ld.Keepalive(navData)
|
||||||
|
}
|
||||||