Compare commits
393 Commits
qbolt-clas
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a1d0fa036 | |||
| 5b9963eb77 | |||
| ca7c827e75 | |||
| fd913fa1eb | |||
| 8e5f003e29 | |||
| 28afd05199 | |||
| 99e138aa94 | |||
| 518b66b270 | |||
| ee066ec7e7 | |||
| 638d2e69eb | |||
| 894f730706 | |||
| 4498542e71 | |||
| a1345f1c24 | |||
| 73bc1c050b | |||
| 6a5605e067 | |||
| f345860468 | |||
| 965a257ebb | |||
| fc9d1b54de | |||
| 0d04effe90 | |||
| ee1ff5582f | |||
| 029f79d800 | |||
| ea0ee78f2e | |||
| 9774a8690b | |||
| 3a66197c2c | |||
| a2badc6964 | |||
| aee1a7ede1 | |||
| 1b5c97abca | |||
| 1c37b71414 | |||
| e9bdbb8066 | |||
| 2a9f06588b | |||
| 8ee74e16c2 | |||
| 96630dc940 | |||
| 85e245658e | |||
| 1de694cb88 | |||
| 312898ab3b | |||
| 19e36ca615 | |||
| e1403f8e7d | |||
| eecdc2b5f2 | |||
| beff8bc323 | |||
| 06d939b06e | |||
| addabd85f8 | |||
| 85659885ab | |||
| f34f2f84dc | |||
| cbc14b261c | |||
| 4f29d531c1 | |||
| b1634e92d4 | |||
| 4ce9b753e9 | |||
| 1c2567dc3c | |||
| f2def1371b | |||
| 4a3e37cdc4 | |||
| f165853c6b | |||
| 93ce3d4a90 | |||
| 88346852b3 | |||
| d15d42c37e | |||
| 76581b9454 | |||
| 5c9f165aa7 | |||
| a576138428 | |||
| f7f3bfb035 | |||
| 53c90bf0dc | |||
| 2f3c956549 | |||
| 7c441ecc50 | |||
| f75a161acd | |||
| 0002c82594 | |||
| 13fedfa2f6 | |||
| 8b06ccef48 | |||
| 17d6b5172d | |||
| 708e8072ff | |||
| c8125d2c84 | |||
| a5138a51f3 | |||
| 02b5a8fd48 | |||
| b2118c9196 | |||
| 5aa76a465c | |||
| f3e729b023 | |||
| 5c7a99d16b | |||
| 4d5ca19b47 | |||
| a0e70636a1 | |||
| 13e139e023 | |||
| 1f7d03e67a | |||
| 0b1f662e99 | |||
| e5f36b0f66 | |||
| 0eccb12744 | |||
| f2d3240153 | |||
| 9fb4302000 | |||
| e3f94f1eba | |||
| 2259b3f455 | |||
| b4e8733798 | |||
| ace5e3e65e | |||
| e132500fd8 | |||
| a6bb412a34 | |||
| 51f2a69ed2 | |||
| 1db2d9781d | |||
| 9f662a7fa2 | |||
| 034bd8114a | |||
| 7242e8644b | |||
| 820285066b | |||
| 94c517a324 | |||
| 9fbc7b4ee6 | |||
| 421cabb7a5 | |||
| 910ef0dd9a | |||
| a9c6b135c7 | |||
| 02d4b918d6 | |||
| 0798547b39 | |||
| 3d114ec1a4 | |||
| 4913e36ad6 | |||
| bafabdf690 | |||
| 4f2381ee33 | |||
| d876013ade | |||
| e42eb6ace7 | |||
| 4789c8c706 | |||
| cad3e9d496 | |||
| fc63b992ca | |||
| 1877417327 | |||
| 13b5878fe5 | |||
| fe5d218291 | |||
| 2abca95d72 | |||
| 7228bc5ba7 | |||
| 3087ba498d | |||
| 847521541f | |||
| 1379e912e5 | |||
| 69e17868a4 | |||
| 45650ead8c | |||
| e1f8c40143 | |||
| f3496db2c7 | |||
| 13511a389a | |||
| 2c1faf98c7 | |||
| 9a42a4021d | |||
| d2587949d6 | |||
| 7c24b1f24a | |||
| 882517dffc | |||
| f98db57a97 | |||
| 7a68362149 | |||
| f716afee74 | |||
| aecab00e70 | |||
| 65d921ddde | |||
| 5d39d90044 | |||
| 6a685a2562 | |||
| d4ee91ed10 | |||
| 9fc11d8c61 | |||
| cb09e0eb84 | |||
| 12a60f57e3 | |||
| a3e32178ac | |||
| 9e76cb32fe | |||
| 63e47ae505 | |||
| 319da7c13c | |||
| e03c635e7b | |||
| b2b5f8ba54 | |||
| dcff85cfe5 | |||
| 65cbfec7af | |||
| c2c997e53f | |||
| 9d2afaf57c | |||
| 8b410bca89 | |||
| e11b5b2100 | |||
| f292780972 | |||
| 89bd3ed27a | |||
| 2a22e92be4 | |||
| 5d829befca | |||
| 2eb7385516 | |||
| 543f573c7f | |||
| 463daba2cf | |||
| 9588e5189e | |||
| 30716df112 | |||
| ad8af93545 | |||
| c306a6d1a5 | |||
| 6a90605bd1 | |||
| c69089841a | |||
| ddbc30ed01 | |||
| 292e13a3e0 | |||
| 7aa6703ee0 | |||
| f90f76c097 | |||
| 134d4cf290 | |||
| 773470b30c | |||
| f55ab455be | |||
| b77ea21378 | |||
| 002298b4ff | |||
| 293627ab17 | |||
| 14219d6d49 | |||
| 37a6f479b8 | |||
| 176c51549c | |||
| 17c0fb3332 | |||
| 4a4e8a76a3 | |||
| af77b83e27 | |||
| 6b187c4142 | |||
| da56321624 | |||
| 4447e149b9 | |||
| 5850ba8836 | |||
| 81206ae1f0 | |||
| 5be200c672 | |||
| 25d8af1043 | |||
| 6abb5b6159 | |||
| 1a20ca1a9c | |||
| 38c5b88055 | |||
| fb20955bf1 | |||
| 8f76b5858d | |||
| d8cad1e59e | |||
| 6bb1d0ab9f | |||
| 35e06396e1 | |||
| 649cff7178 | |||
| 8236078ace | |||
| 4e084b914f | |||
| fc9965d757 | |||
| ff315a8e1c | |||
| f4863923a5 | |||
| 7fe5fa02f6 | |||
| 18139ee11b | |||
| daa79bf0d6 | |||
| 541fe5b0a8 | |||
| 6cc8213490 | |||
| 5bf36d70c5 | |||
| 593df7abba | |||
| 5776226130 | |||
| fab96d4602 | |||
| abfa27d20e | |||
| 104456049d | |||
| 9ea060fda7 | |||
| d568b75530 | |||
| c0a11d936a | |||
| 994cef8357 | |||
| 82ddab1431 | |||
| d1b0b4986e | |||
| 25d1609220 | |||
| 2609223ea6 | |||
| 8be4c79556 | |||
| 5006ac6e91 | |||
| e2eb81da77 | |||
| 0f5bf963ba | |||
| 8612ad630e | |||
| 0dc90a546b | |||
| 5c9ad3bd9d | |||
| d6c6dd594c | |||
| 12fb79bb8b | |||
| 9003982da8 | |||
| 548f3dc68c | |||
| 43f334331b | |||
| cccb06caf0 | |||
| f257901965 | |||
| 8e36e00460 | |||
| 07a749bb03 | |||
| fb069ed3b2 | |||
| f5d2e69007 | |||
| 35c2b01843 | |||
| 858af50136 | |||
| 13647edc0d | |||
| 8b2c09d859 | |||
| 03c3a142df | |||
| 318e634d9b | |||
| a51d568a87 | |||
| 99c20e76de | |||
| bc09478278 | |||
| 18d782e7e5 | |||
| 4e6a359b10 | |||
| f42bdf219b | |||
| 2f2972f97e | |||
| 5cf6a838ae | |||
| 8e5e80af79 | |||
| 49890599ea | |||
| c541e8b941 | |||
| 877f291a1f | |||
| 6145320858 | |||
| 8296a2fec9 | |||
| 223d13be58 | |||
| eca27dcd4f | |||
| 0f2a3e021a | |||
| 90259fb2b9 | |||
| 7573cf0453 | |||
| 6dd0635c9e | |||
| ce3d08740f | |||
| 8f5e1054fb | |||
| 1ac96eb133 | |||
| 53e9b6555e | |||
| e1a9f187cb | |||
| ee3110162b | |||
| 35a83eb483 | |||
| 60add3be86 | |||
| 2f65ffdd70 | |||
| aad92d27e9 | |||
| 21151be8a3 | |||
| f78eec1872 | |||
| 8af27f8834 | |||
| 0d3b90b879 | |||
| 2b59efc410 | |||
| 7fbf2ef1ed | |||
| d7e3363173 | |||
| cecfc338d4 | |||
| 35f09fc072 | |||
| 2163b46907 | |||
| 81b6b08e7b | |||
| f31724a110 | |||
| 063a8ca837 | |||
| 1cfc94a42b | |||
| 053e07c319 | |||
| 0b91c379b8 | |||
| 7b4cc885f5 | |||
| 3b17ddd8a4 | |||
| 8d051a14e5 | |||
| 4735c391bd | |||
| 0866e5edac | |||
| 5c44dc5f54 | |||
| a7dd1ca340 | |||
| abcf7dbfe5 | |||
| d359f42b24 | |||
| 7cec5cee4c | |||
| be91cd54c6 | |||
| b141aaaa6c | |||
| 493ab846b9 | |||
| d3ebcb4666 | |||
| 50cf207eae | |||
| e5cbbb6822 | |||
| 18674568dd | |||
| 748dd96267 | |||
| c5578daa9f | |||
| 3bc7f539ad | |||
| 52677224c1 | |||
| 0a31eab9f2 | |||
| 011063597d | |||
| 71c182692a | |||
| e15af5a544 | |||
| ba7228ad44 | |||
| 650c9e7183 | |||
| 471737f421 | |||
| d2b9618da0 | |||
| f0f0ff7904 | |||
| 639da11ab3 | |||
| fc084d7190 | |||
| e2111017eb | |||
| ef70e5825a | |||
| 15b29b32ce | |||
| 975e120530 | |||
| 5b8883d31a | |||
| ce43f5765c | |||
| feffa67677 | |||
| a14e58297a | |||
| 2b309fbda7 | |||
| 70db402cdf | |||
| a82d5e6b26 | |||
| 28570b0b96 | |||
| 9bf84fa140 | |||
| eb34221620 | |||
| cc3ba4d9f0 | |||
| f79d17afed | |||
| d7c2282335 | |||
| 43002a9fde | |||
| bc33d26cfd | |||
| 38d9e6238f | |||
| a653ef8ca4 | |||
| cc336366c9 | |||
| 8f105183eb | |||
| b45faa2e73 | |||
| 7e5d17100d | |||
| a817e5fa21 | |||
| 3d185033f3 | |||
| 924957d00d | |||
| d674078071 | |||
| 5992d19906 | |||
| 8cac46e9f2 | |||
| db5f6816c5 | |||
| 3a4bdbde94 | |||
| 0363bc65f4 | |||
| a47898e099 | |||
| 1487b18a3a | |||
| 04ef53720f | |||
| bc82aacd57 | |||
| 6dcc1afd6b | |||
| 645ab29cdd | |||
| 4202b9b970 | |||
| 42bbe3957a | |||
| 5e2aae9032 | |||
| f54577f93f | |||
| f4d2d2ec39 | |||
| 5cd3f6c765 | |||
| 5d268d22af | |||
| 5e0422e10f | |||
| ef30a0d210 | |||
| 38847b3a7e | |||
| f432b52652 | |||
| f64522dfa1 | |||
| 79bce40581 | |||
| 3731ee1781 | |||
| 2481a1da20 | |||
| 1035086ed4 | |||
| 038eb44f48 | |||
| 841575700e | |||
| 617393b627 | |||
| 91f9c5fc30 | |||
| 6234f02ea6 | |||
| 8b1e7064e7 | |||
| 00a96bfe84 | |||
| 232a1dd0e8 | |||
| cb4b35b059 | |||
| f913b63c58 | |||
| d97c8872de | |||
| 78d4b90189 | |||
| e30e3e6138 | |||
| f22d149a66 |
19
.gitignore
vendored
@@ -1,16 +1,5 @@
|
|||||||
# development
|
testdata/
|
||||||
qbolt
|
qbolt
|
||||||
dummy-data/dummy-data*
|
qbolt.exe
|
||||||
|
qbolt.linux64.tar.xz
|
||||||
# temporary build files
|
qbolt.win64.zip
|
||||||
rsrc_windows_amd64.syso
|
|
||||||
windows-manifest.json
|
|
||||||
|
|
||||||
# release build files
|
|
||||||
build/qbolt
|
|
||||||
build/qbolt.exe
|
|
||||||
build/*.xz
|
|
||||||
build/*.zip
|
|
||||||
|
|
||||||
# local makefile definition scripts
|
|
||||||
make-*
|
|
||||||
228
CHANGELOG.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Unreleased vNext
|
||||||
|
|
||||||
|
- Badger, Bolt, BuntDB: Support full advanced configuration options
|
||||||
|
- Badger, Bolt: Show full paths to database file on hover
|
||||||
|
- SQLite CLI/SSH driver: Fix a syntax issue browsing tables, fix an issue if a table has zero rows
|
||||||
|
- Allow exporting configuration via new `--config-export` and `--config-import` command-line arguments
|
||||||
|
- UI: Show column's datatype in hover tooltip
|
||||||
|
- UI: Add context menu actions to data view
|
||||||
|
- UI: Allow setting cells to NULL
|
||||||
|
- UI: Detect integer Unix timestamps (seconds or milliseconds) and show local+UTC time in hover tooltip
|
||||||
|
- UI: Allow configuring the appearance - Qt Style, Density/Padding, and alternating row backgrounds
|
||||||
|
- UI: Reduce default padding in the main window
|
||||||
|
- UI: Update autoconfig library to v0.6.0: large configuration dialogs now scrollable, adds "reset to default" buttons
|
||||||
|
- UI: Show basic database properties when hovering connection in Connection Manager and in main tree view
|
||||||
|
- Connection Manager: Add `(2)` prefixes when saving new connections
|
||||||
|
- Fix an issue with uninitialized slice data appearing in binary data columns, affecting at least Badger
|
||||||
|
- Fix a cosmetic issue Connection Manager saying "fail to save" error messages if we fail to load
|
||||||
|
|
||||||
|
2026-01-04 v2.1.0
|
||||||
|
|
||||||
|
- SQLite: Support SSH tunnel
|
||||||
|
- SQLite: Strict type handling, support nullable columns, always rely on `rowid` as PK
|
||||||
|
- SQLite: Remove hardcoded LIMIT
|
||||||
|
- SQLite: Hide internal sqlite_sequence and sqlite_stat1 tables
|
||||||
|
- BuntDB: Initial support, including editing
|
||||||
|
- VoidDB: Initial support, including editing and multiple keyspace management
|
||||||
|
- Pogreb: Initial support, including compaction
|
||||||
|
- Fix an issue with SQL queries running twice
|
||||||
|
- Fix an issue with not showing multiple connection errors from the connection manager dialog
|
||||||
|
- Truncate large binary column display in grid
|
||||||
|
- UI: Add icons for SSH/Password connection options, for Bolt/Zip conversion tool
|
||||||
|
- UI: Show 'null' as grey in tables
|
||||||
|
- UI: Clickable links to Go.mod dependencies in 'Driver versions' data table
|
||||||
|
- UI: When connecting to a new DB, always start on the Properties tab
|
||||||
|
- UI: Allow toggling separate toolbar sections in context menu
|
||||||
|
- UI: Disable Query tab for non-queryable database types
|
||||||
|
- UI: Fix extra labels appearing when editing large binary columns
|
||||||
|
|
||||||
|
2025-12-20 v2.0.0
|
||||||
|
|
||||||
|
- Merge yvbolt and QBolt together
|
||||||
|
- App: Show application version in main window titlebar
|
||||||
|
|
||||||
|
2025-12-20 v0.11.0 (yvbolt)
|
||||||
|
|
||||||
|
- Badger: Change encryption configuration options (**BREAKING**)
|
||||||
|
- SSH Agent: Initial support, including lock/unlock
|
||||||
|
- Badger, SQLite: Fix "all files" spec when choosing an export file path
|
||||||
|
- Bolt: Support import/export as zip archive
|
||||||
|
- Support SSH agents when using SSH tunnels (e.g. for Redis or Mongo)
|
||||||
|
|
||||||
|
2025-12-17 v0.10.1 (yvbolt)
|
||||||
|
|
||||||
|
- Fix mixed Qt 5 / Qt 6 syntax
|
||||||
|
- Build release binaries with Go1.26
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.10.1)
|
||||||
|
|
||||||
|
2025-12-16 v0.10.0 (yvbolt)
|
||||||
|
|
||||||
|
- MongoDB: Initial support, including SSH tunnel, managing collections, traversing BSON documents, and querying
|
||||||
|
- LotusDB: Initial support, including editing
|
||||||
|
- Bitcask: Initial support, including editing and backup
|
||||||
|
- RoseDB: Initial support, including editing
|
||||||
|
- Badger: Fix k/v column display, support editing
|
||||||
|
- LMDB: Support editing, add warning for destructive actions
|
||||||
|
- LevelDB: Support editing
|
||||||
|
- Pebble: Support editing
|
||||||
|
- Starskey: Fix k/v column display, support editing
|
||||||
|
- Redis: Bump driver version v9.16.0 -> v9.17.2
|
||||||
|
- Bolt: Add warning for destructive actions
|
||||||
|
- Connection manager: When saving, use the database's preferred name
|
||||||
|
- App: Add hex viewer for binary data if it is not valid UTF-8
|
||||||
|
- App: Use virtualized table renderer
|
||||||
|
- App: Use multiple plug-in typed column stores in table backend
|
||||||
|
- App: Fix graphical flicker when editing connections on Windows
|
||||||
|
- App: Set up icon and exe properties on Windows
|
||||||
|
- App: Embed version number in release builds
|
||||||
|
- App: Show all driver versions as a virtual data table
|
||||||
|
- App: Show unsaved-changes warning also when changing database
|
||||||
|
- App: Fix rich text formatting appearing when pasting into the query window
|
||||||
|
|
||||||
|
2025-12-02 v0.9.0 (yvbolt)
|
||||||
|
|
||||||
|
- LMDB: Initial support, including multi-database mode, data editing, and managing child databases
|
||||||
|
- Starskey: Initial support
|
||||||
|
- Freedesktop.org Secret Service: Initial support, including unlocking collections and creating child collections
|
||||||
|
- App: Use global toolbar style
|
||||||
|
- App: Add connection manager, encrypting credentials with AEAD AES256-GCM using OS keychain
|
||||||
|
- App: Offer to save valid quick-connection to the connection manager
|
||||||
|
- App: Add 'About Qt' menu option
|
||||||
|
- App: Fixed file extension filter for database files with no extension
|
||||||
|
- App: Fixed background colour for Properties area on different OSes
|
||||||
|
- App: Fixed libpng warnings about greyscale image data for embedded logo images
|
||||||
|
- App: Fixed issue with non-UTF8 child database names
|
||||||
|
- Debconf: Improve navigation speed by splitting applications into virtual tables
|
||||||
|
- SSH: Redesign options to pick only one of the available auth methods
|
||||||
|
- Fixed running release binary on Debian 12
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.9.0)
|
||||||
|
|
||||||
|
2025-11-23 v0.8.0 (yvbolt)
|
||||||
|
|
||||||
|
- Port from [liblcl](https://github.com/ying32/liblcl) to [MIQT Qt 6](https://github.com/mappu/miqt)
|
||||||
|
- Badger: Upgrade v4.2.0 -> v4.8.0
|
||||||
|
- Pebble: Upgrade v1.0.0 -> v1.1.5
|
||||||
|
- SQLite: Upgrade v1.14.22 -> v1.14.32
|
||||||
|
- Redis: Upgrade v9.5.3 -> v9.16.0
|
||||||
|
- Bolt: Upgrade v1.4.0-alpha.1 -> v1.4.3
|
||||||
|
- Bolt: Fix child buckets appearing in data area
|
||||||
|
- Badger, Pebble, Debconf: Remove redundant "Data" navigation layer
|
||||||
|
- Badger: Support encrypted databases
|
||||||
|
- Badger: Support readonly databases
|
||||||
|
- Badger: Add context-menu actions for backup, restore, and compact
|
||||||
|
- Pebble: Support readonly databases
|
||||||
|
- LevelDB: Add LevelDB database integration
|
||||||
|
- Redis: Support SSH tunnel
|
||||||
|
- SQLite: Allow editing the primary key column
|
||||||
|
- App: New style connection dialog
|
||||||
|
- App: Updated keyboard shortcuts (Ctrl+O to open new connection, F5 to refresh, F9 to execute query)
|
||||||
|
- App: Add confirmation when refreshing the data table if there are uncommitted changes
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.8.0)
|
||||||
|
|
||||||
|
2025-05-04 1.1.0 (qbolt)
|
||||||
|
|
||||||
|
- New feature to import/export database as zip archive
|
||||||
|
- Upgrade to Qt 6
|
||||||
|
- Add keyboard shortcuts for refresh
|
||||||
|
- Improve High DPI support
|
||||||
|
- Rebuild artefacts with miqt v0.10.0, etcd-io/bbolt v1.4.0, go 1.23, Qt 6.8 (win64)
|
||||||
|
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.1.0)
|
||||||
|
|
||||||
|
2024-10-05 1.0.3 (qbolt)
|
||||||
|
|
||||||
|
- Port from hybrid Go/C++ to now using [MIQT](https://github.com/mappu/miqt)
|
||||||
|
- Switch Windows build to win64
|
||||||
|
- Rebuild artefacts with miqt v0.5.0, etcd-io/bbolt v1.3.11, go 1.19 (deb12), go 1.23 (win64)
|
||||||
|
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.3)
|
||||||
|
|
||||||
|
2024-07-18 v0.7.0 (yvbolt)
|
||||||
|
|
||||||
|
- SQLite, Bolt: Initial support for editing data (insert, per-cell update, delete)
|
||||||
|
- SQLite: Add context menu actions for compact (vacuum), export, and drop table
|
||||||
|
- App: New grid widget
|
||||||
|
- App: Add refresh button
|
||||||
|
- App: Bigger window size, use icons for toolbars, better UI colours for Windows
|
||||||
|
- App: Prevent submitting blank queries to database
|
||||||
|
- Refactor database interface and error handling
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.7.0)
|
||||||
|
|
||||||
|
2024-06-30 v0.6.0 (yvbolt)
|
||||||
|
|
||||||
|
- Debconf: Add as supported database
|
||||||
|
- SQLite: Support table names containing special characters
|
||||||
|
- SQLite: Improvements for experimental command-line driver
|
||||||
|
- Redis: Improve connection dialog window position
|
||||||
|
- App: Cosmetic fixes for frame borders, help dialog, and Windows fonts+colours
|
||||||
|
- Build: Change compression parameters for release builds
|
||||||
|
- Build: Compile CGO with -O2 for release builds
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.6.0)
|
||||||
|
|
||||||
|
2024-06-29 v0.5.0 (yvbolt)
|
||||||
|
|
||||||
|
- Pebble: Add as supported database
|
||||||
|
- Bolt: Support opening as readonly
|
||||||
|
- Bolt: Support creating new databases
|
||||||
|
- Bolt: Support adding/removing recursive child buckets
|
||||||
|
- SQLite: Support custom CLI driver that parses `/usr/bin/sqlite3 -json` output (experimental)
|
||||||
|
- Redis: Improve query parser to support quoted strings
|
||||||
|
- App: Support refreshing elements in nav tree
|
||||||
|
- App: Help menu option to show driver versions
|
||||||
|
- App: Add image icons for refresh and close context menu actions
|
||||||
|
- Build: Add makefile for cross-compiling release binaries
|
||||||
|
- [⬇️ Download here](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/tag/v0.5.0)
|
||||||
|
|
||||||
|
2024-06-23 v0.4.0 (yvbolt)
|
||||||
|
|
||||||
|
- Redis: Add as supported database
|
||||||
|
- Badger: Allow creating in-memory databases
|
||||||
|
- App: Allow selecting partial query text to execute
|
||||||
|
- App: Allow closing database connections from context menu
|
||||||
|
- App: Allow scrolling large content on Properties pane
|
||||||
|
- App: Preload recursive navigation
|
||||||
|
- App: Automatically switch to selected database when new connection is created
|
||||||
|
- App: Add help website link
|
||||||
|
- App: Add database logo images
|
||||||
|
|
||||||
|
2024-06-25 v0.3.0 (yvbolt)
|
||||||
|
|
||||||
|
- Badger: Add BadgerDB v4 as supported database
|
||||||
|
- SQLite: Add support for CGo-free SQLite driver under cross-compilation
|
||||||
|
- Bolt: Update Bolt to v1.4.0-alpha.1
|
||||||
|
- App: Add support for running custom queries
|
||||||
|
- App: Add status bar showing currently selected DB
|
||||||
|
- App: Fix missing icons in nav when selecting items
|
||||||
|
- App: Fix extra quotemarks when browsing string content of database
|
||||||
|
|
||||||
|
2024-06-08 v0.2.0 (yvbolt)
|
||||||
|
|
||||||
|
- SQLite: Add SQLite support (now requires CGo)
|
||||||
|
- App: Add images for menu and navigation items
|
||||||
|
|
||||||
|
2024-06-03 v0.1.0 (yvbolt)
|
||||||
|
|
||||||
|
- Initial public release
|
||||||
|
|
||||||
|
2020-04-12 1.0.2 (qbolt)
|
||||||
|
|
||||||
|
- Rebuild artefacts with etcd-io/bbolt v1.3.5, go 1.15, Qt 5.15, and new GCC versions
|
||||||
|
- Switch from hg to Git
|
||||||
|
- Use Go modules
|
||||||
|
- Add support for building Windows binary in Docker
|
||||||
|
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.2)
|
||||||
|
|
||||||
|
2017-06-19 1.0.1 (qbolt)
|
||||||
|
|
||||||
|
- Feature: Option to open database as read-only
|
||||||
|
- Fix an issue with support for bucket names and keys not surviving UTF-8 roundtrips (now binary-clean)
|
||||||
|
- Fix an issue with crashing when deleting a bucket other than the selected one
|
||||||
|
- Fix a cosmetic issue with application icon on Windows
|
||||||
|
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.1)
|
||||||
|
|
||||||
|
2017-05-21 1.0.0 (qbolt)
|
||||||
|
|
||||||
|
- Initial public release
|
||||||
|
- The project consists of two parts; a C binding (CGo) for the embeddable Bolt database engine, and a graphical interface built in C++/Qt that links to it.
|
||||||
|
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.0)
|
||||||
|
|
||||||
9
LICENSE
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright 2025 mappy
|
||||||
|
Copyright 2025 The QBolt Author(s)
|
||||||
|
Copyright 2025 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.
|
||||||
127
Makefile
@@ -1,67 +1,80 @@
|
|||||||
VERSION := 1.1.0
|
SHELL:=/bin/bash
|
||||||
GOFLAGS_L := -ldflags='-s -w -X main.Version=v$(VERSION)' -buildvcs=false -gcflags='-trimpath=$(CURDIR)' -asmflags='-trimpath=$(CURDIR)'
|
SOURCES := $(shell find . -name '*.go' -type f)
|
||||||
GOFLAGS_W := -ldflags='-s -w -X main.Version=v$(VERSION) -H windowsgui' -buildvcs=false --tags=windowsqtstatic -gcflags='-trimpath=$(CURDIR)' -asmflags='-trimpath=$(CURDIR)'
|
GIT_REV := $(shell git describe --exact-match --tags 2>/dev/null || printf "%s-%s" $$(git describe --tags --abbrev=0) $$(git rev-parse HEAD | head -c8))
|
||||||
SHELL := /bin/bash
|
.DEFAULT_GOAL := dist
|
||||||
GO := go
|
MIQT_UIC ?= ~/go/bin/miqt-uic
|
||||||
MIQT_DOCKER := miqt-docker
|
MIQT_RCC ?= ~/go/bin/miqt-rcc
|
||||||
MIQT_UIC := miqt-uic
|
MIQT_DOCKER ?= ~/go/bin/miqt-docker
|
||||||
MIQT_RCC := miqt-rcc
|
GO_WINRES ?= ~/go/bin/go-winres
|
||||||
GO_WINRES := go-winres
|
|
||||||
SOURCES := $(wildcard *.go *.ui *.qrc) resources.go resources.rcc mainwindow_ui.go itemwindow_ui.go rsrc_windows_amd64.syso
|
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: generate
|
||||||
all: build/qbolt build/qbolt.exe
|
generate:
|
||||||
|
/bin/bash -c '( echo "<RCC>" ; echo " <qresource prefix=\"/\">" ; for f in assets/* ; do echo " <file>$$f</file>" ; done ; echo " </qresource>" ; echo "</RCC>" ) > embed.qrc'
|
||||||
|
$(MIQT_UIC) -InFile mainwindow.ui -OutFile mainwindow.go -Qt6
|
||||||
|
$(MIQT_UIC) -InFile connectionDialog.ui -OutFile connectionDialog.go -Qt6
|
||||||
|
$(MIQT_UIC) -InFile connectionManagerDialog.ui -OutFile connectionManagerDialog.go -Qt6
|
||||||
|
$(MIQT_RCC) -Input embed.qrc -Qt6
|
||||||
|
|
||||||
|
.PHONY: designer
|
||||||
|
designer:
|
||||||
|
/usr/lib/qt6/bin/designer &
|
||||||
|
|
||||||
|
.PHONY: optimize-images
|
||||||
|
optimize-images:
|
||||||
|
# Strip iCCC colour chunks that libpng/Qt complain about at runtime
|
||||||
|
for f in assets/*.png ; do convert "$$f" -strip "$$f" ; done
|
||||||
|
optipng -quiet -o5 assets/*.png
|
||||||
|
make generate
|
||||||
|
|
||||||
|
qbolt: $(SOURCES)
|
||||||
|
# Target a debian-12 baseline build
|
||||||
|
sed -i -re 's/".+"/"$(GIT_REV)"/g' version.go
|
||||||
|
$(MIQT_DOCKER) linux64-go1.26-qt6.4-dynamic -minify-build
|
||||||
|
git checkout -- version.go
|
||||||
|
chmod 755 qbolt
|
||||||
|
upx --lzma qbolt
|
||||||
|
|
||||||
|
qbolt.exe: $(SOURCES)
|
||||||
|
sed -i -re 's/".+"/"$(GIT_REV)"/g' version.go
|
||||||
|
$(MIQT_DOCKER) win64-cross-go1.26-qt6.5-static -windows-build --tags=windowsqtstatic
|
||||||
|
git checkout -- version.go
|
||||||
|
$(GO_WINRES) patch --in winres.json --no-backup --product-version git-tag --file-version git-tag qbolt.exe
|
||||||
|
upx --lzma qbolt.exe
|
||||||
|
|
||||||
|
qbolt.apk: $(SOURCES)
|
||||||
|
$(MIQT_DOCKER) android-armv8a-go1.23-qt6.6-dynamic -android-build
|
||||||
|
|
||||||
|
qbolt.linux64.tar.xz: qbolt
|
||||||
|
rm -f qbolt.linux64.tar.xz
|
||||||
|
XZ_OPT='-T0 -9' tar caf qbolt.linux64.tar.xz --owner=0 --group=0 qbolt
|
||||||
|
|
||||||
|
qbolt.win64.zip: qbolt.exe
|
||||||
|
rm -f qbolt.win64.zip
|
||||||
|
zip -9 qbolt.win64.zip qbolt.exe
|
||||||
|
|
||||||
.PHONY: dist
|
.PHONY: dist
|
||||||
dist: build/qbolt-${VERSION}-windows-x86_64.zip build/qbolt-${VERSION}-debian12-x86_64.tar.xz
|
dist: qbolt.linux64.tar.xz qbolt.win64.zip
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -f qbolt
|
git checkout -- version.go
|
||||||
rm -rf build
|
rm -f qbolt.exe qbolt qbolt.linux64.tar.xz qbolt.win64.zip
|
||||||
mkdir -p build
|
|
||||||
touch build/.create_dir
|
|
||||||
rm -f windows-manifest.json
|
|
||||||
rm -f rsrc_windows_amd64.syso
|
|
||||||
rm -f resources.go
|
|
||||||
rm -f resources.rcc
|
|
||||||
|
|
||||||
# Generated files
|
#####
|
||||||
|
# Test databases in Docker
|
||||||
|
|
||||||
resources.rcc resources.go: resources.qrc
|
.PHONY: test-mongo
|
||||||
$(MIQT_RCC) -Qt6 -Input resources.qrc
|
test-mongo:
|
||||||
|
sudo docker run --rm -p 127.0.0.1:27017:27017 -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD=toor mongo:latest
|
||||||
|
|
||||||
mainwindow_ui.go: mainwindow.ui
|
.PHONY: test-redis
|
||||||
$(MIQT_UIC) -Qt6 -InFile mainwindow.ui -OutFile mainwindow_ui.go
|
test-redis:
|
||||||
|
sudo docker run --rm -p 127.0.0.1:6379:6379 redis:latest
|
||||||
|
|
||||||
itemwindow_ui.go: itemwindow.ui
|
.PHONY: test-etcd
|
||||||
$(MIQT_UIC) -Qt6 -InFile itemwindow.ui -OutFile itemwindow_ui.go
|
test-etcd:
|
||||||
|
# v3.5 series: last version to support both etcd v2 and v3 APIs
|
||||||
windows-manifest.json: windows-manifest.template.json Makefile
|
# Optional: can use `--experimental-enable-v2v3 ''` flag to map v2 API into the v3 namespace,
|
||||||
cat windows-manifest.template.json | sed -re 's_%VERSION%_$(VERSION)_' > windows-manifest.json
|
# otherwise they are separate storages
|
||||||
|
# Test URL: http://127.0.0.1:2379/
|
||||||
rsrc_windows_amd64.syso: windows-manifest.json
|
sudo docker run --rm -p 2379:2379 -p 2380:2380 gcr.io/etcd-development/etcd:v3.5.26 /usr/local/bin/etcd --name s1 --enable-v2 --experimental-enable-v2v3 'mapping-prefix-here' --data-dir /etcd-data --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://0.0.0.0:2379
|
||||||
$(GO_WINRES) make --in windows-manifest.json
|
|
||||||
rm rsrc_windows_386.syso || true # we do not build x86_32
|
|
||||||
|
|
||||||
# Linux release
|
|
||||||
|
|
||||||
build/qbolt: $(SOURCES)
|
|
||||||
CGO_CFLAGS='-Os -ffunction-sections -fdata-sections -flto=auto' CGO_CXXFLAGS='-Os -ffunction-sections -fdata-sections -flto=auto' CGO_LDFLAGS='-Wl,--gc-sections -flto=auto -fwhole-program' $(GO) build $(GOFLAGS_L) -o build/qbolt
|
|
||||||
upx build/qbolt
|
|
||||||
|
|
||||||
build/qbolt-${VERSION}-debian12-x86_64.tar.xz: build/qbolt
|
|
||||||
XZ_OPTS=-9e tar caf build/qbolt-${VERSION}-debian12-x86_64.tar.xz -C build qbolt --owner=0 --group=0
|
|
||||||
|
|
||||||
# Windows release (docker)
|
|
||||||
|
|
||||||
build/qbolt.exe: $(SOURCES)
|
|
||||||
# -flto causes internal compiler error
|
|
||||||
$(MIQT_DOCKER) win64-qt6-static /bin/bash -c "CGO_CFLAGS='-Os -ffunction-sections -fdata-sections' CGO_CXXFLAGS='-Os -ffunction-sections -fdata-sections' CGO_LDFLAGS='-Wl,--gc-sections -fwhole-program' go build $(GOFLAGS_W) -o build/qbolt.exe"
|
|
||||||
# Must be stripped before upx'ing - @ref https://github.com/msys2/MSYS2-packages/issues/454
|
|
||||||
# However this removes the rsrc, loses the icon and causes a Defender detection
|
|
||||||
# strip build/qbolt.exe
|
|
||||||
# upx build/qbolt.exe
|
|
||||||
|
|
||||||
build/qbolt-${VERSION}-windows-x86_64.zip: build/qbolt.exe
|
|
||||||
zip -9 -j build/qbolt-${VERSION}-windows-x86_64.zip build/qbolt.exe
|
|
||||||
|
|||||||
106
README.md
@@ -1,62 +1,68 @@
|
|||||||
# qbolt
|
# QBolt
|
||||||
|
|
||||||
A graphical database manager for BoltDB.
|
A graphical interface for multiple databases.
|
||||||
|
|
||||||
QBolt allows you to graphically view and edit the content of Bolt databases.
|
|
||||||
|
|
||||||
Written in Golang (Qt)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Open existing database or create new database
|
- Lightweight native desktop application, running on Linux, Windows, macOS, and Android
|
||||||
- Option to open database as readonly for concurrent use
|
- Supports many database types
|
||||||
- Create, list, edit and delete keys and buckets (including nested buckets)
|
- Connect to multiple databases at once
|
||||||
- Safe for use with arbitrary binary key/bucket names (new ones created in UTF-8)
|
- Browse table/bucket content
|
||||||
- View database and bucket statistics
|
- Use context menu to perform special table/bucket actions
|
||||||
- 100% Bolt compatibility via the real codebase
|
- Edit content, and add/delete rows for supported databases
|
||||||
- Tested working on both Windows and Linux
|
- "Set cell to null" via context menu
|
||||||
|
- View database/bucket statistics and metadata
|
||||||
|
- Run custom SQL queries
|
||||||
|
- Select text to run partial query
|
||||||
|
- Optimised grid renderer
|
||||||
|
- Uses virtual-scrolling and a type-safe column-store
|
||||||
|
- Safe handling for non-UTF8 key and data fields
|
||||||
|
- Hex viewer for binary data
|
||||||
|
- Detect integer Unix timestamps (seconds or milliseconds) and show local+UTC time in hover tooltip
|
||||||
|
- Configurable appearance (style, density, alternating row backgrounds)
|
||||||
|
- Connection Manager saves connections with AEAD AES256-GCM using OS keychain
|
||||||
|
- Command-line feature to import/export saved connections
|
||||||
|
- SSH tunnel for supported databases
|
||||||
|
|
||||||
|
## Supported databases
|
||||||
|
|
||||||
|
There are currently 19 supported databases:
|
||||||
|
|
||||||
|
Database |Read |Editing |Query |Connection options |Context menu actions
|
||||||
|
-------------|------|---------|------|--------------------|--------
|
||||||
|
Badger v4 |Yes |Yes |No |Encrypted, readonly, in-memory, advanced |Backup, restore, compact
|
||||||
|
Bitcask |Yes |Yes |No |Readonly, autorecovery |Backup
|
||||||
|
BuntDB |Yes |Yes |No |In-memory, advanced |Shrink
|
||||||
|
Bolt |Yes |Yes |No |Readonly, advanced |Create/delete child buckets, import/export as zip
|
||||||
|
Debconf |Yes |No |No | |
|
||||||
|
Etcd |Yes |Yes |No |v2/v3 |(v2) Create/delete child databases
|
||||||
|
Freedesktop.org Secret Service |Yes |No | No | |Unlock, create new collection
|
||||||
|
LevelDB |Yes |Yes |No |Readonly |
|
||||||
|
LMDB |Yes |Yes |No |Multi-DB, readonly |Create/delete child databases
|
||||||
|
LotusDB |Yes |Yes |No | |
|
||||||
|
MongoDB |Yes |No |Yes |SSH tunnel |Create/delete child databases and collections
|
||||||
|
Pebble |Yes |Yes |No |Readonly, in-memory |
|
||||||
|
Pogreb |Yes |Yes |No | |Compact
|
||||||
|
Redis |Yes |No |Yes |SSH tunnel, RESP v3 |
|
||||||
|
RoseDB |Yes |Yes |No | |
|
||||||
|
SQLite |Yes |Yes |Yes |**SSH tunnel**, CLI driver, in-memory |Vacuum, export
|
||||||
|
SSH Agent |Yes |No |No |Unix/TCP |Lock, unlock
|
||||||
|
Starskey |Yes |Yes |No |Compression |
|
||||||
|
VoidDB |Yes |Yes |No |Multi-keyspace |Create/delete child keyspaces
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Source code content of `qbolt-x.x.x-src.tar.gz` is released under the ISC license.
|
The code in this project is licensed under the ISC license (see `LICENSE` file for details) with the following caveats:
|
||||||
BoltDB is released under the MIT license.
|
|
||||||
The Windows binary is released under LGPL-3+ owing to the static copy of Qt.
|
|
||||||
|
|
||||||
## See also
|
- This project depends on third-party libraries under additional open source licenses.
|
||||||
|
- This project redistributes images from the [famfamfam/silk icon set](https://github.com/markjames/famfamfam-silk-icons) under the [CC-BY 2.5 license](http://creativecommons.org/licenses/by/2.5/).
|
||||||
|
- This project includes trademarked logo images for each supported database type.
|
||||||
|
- The Windows binary is released under LGPL-3+ owing to the static copy of Qt.
|
||||||
|
|
||||||
- BoltDB https://github.com/boltdb/bolt
|
## Download
|
||||||
|
|
||||||
|
Get the latest version from [the releases page »](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases)
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
2025-05-04 1.1.0
|
See [the full change history »](https://git.ivysaur.me/code.ivysaur.me/qbolt/src/branch/master/CHANGELOG.md)
|
||||||
- New feature to import/export database as zip archive
|
|
||||||
- Upgrade to Qt 6
|
|
||||||
- Add keyboard shortcuts for refresh
|
|
||||||
- Improve High DPI support
|
|
||||||
- Rebuild artefacts with miqt v0.10.0, etcd-io/bbolt v1.4.0, go 1.23, Qt 6.8 (win64)
|
|
||||||
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.1.0)
|
|
||||||
|
|
||||||
2024-10-05 1.0.3
|
|
||||||
- Port from hybrid Go/C++ to now using [MIQT](https://github.com/mappu/miqt)
|
|
||||||
- Switch Windows build to win64
|
|
||||||
- Rebuild artefacts with miqt v0.5.0, etcd-io/bbolt v1.3.11, go 1.19 (deb12), go 1.23 (win64)
|
|
||||||
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.3)
|
|
||||||
|
|
||||||
2020-04-12 1.0.2
|
|
||||||
- Rebuild artefacts with etcd-io/bbolt v1.3.5, go 1.15, Qt 5.15, and new GCC versions
|
|
||||||
- Switch from hg to Git
|
|
||||||
- Use Go modules
|
|
||||||
- Add support for building Windows binary in Docker
|
|
||||||
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.2)
|
|
||||||
|
|
||||||
2017-06-19 1.0.1
|
|
||||||
- Feature: Option to open database as read-only
|
|
||||||
- Fix an issue with support for bucket names and keys not surviving UTF-8 roundtrips (now binary-clean)
|
|
||||||
- Fix an issue with crashing when deleting a bucket other than the selected one
|
|
||||||
- Fix a cosmetic issue with application icon on Windows
|
|
||||||
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.1)
|
|
||||||
|
|
||||||
2017-05-21 1.0.0
|
|
||||||
- Initial public release
|
|
||||||
- The project consists of two parts; a C binding (CGo) for the embeddable Bolt database engine, and a graphical interface built in C++/Qt that links to it.
|
|
||||||
- [⬇ Download here](https://git.ivysaur.me/code.ivysaur.me/qbolt/releases/tag/v1.0.0)
|
|
||||||
|
|||||||
141
TODO
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
- Drag and drop database into UI (QBolt parity)
|
||||||
|
- Portable mode (portable.txt or portable/ dir)
|
||||||
|
- Syntax highlighting in editor
|
||||||
|
- Autorefresh
|
||||||
|
- Sshagent
|
||||||
|
- want to trigger an async refresh from inside the LDB after lock/unlock
|
||||||
|
- support adding/removing keys (will need per-row actions)
|
||||||
|
- SSH: knownhost parser is stricter than openssh, does not support hostname if there is known a knownhost for the IP address
|
||||||
|
- Bolt: import/export should support passworded zips
|
||||||
|
- Table: BSON view can't see data
|
||||||
|
- Table: quick filter
|
||||||
|
- QSortFilterProxyModel
|
||||||
|
- Cancellation
|
||||||
|
- Loading animations for connection + queries
|
||||||
|
- Delay rendering properties/data tab until tab is focused
|
||||||
|
- Mutation
|
||||||
|
- Debconf: Support insert/update/delete
|
||||||
|
- Redis: Support insert/update/delete
|
||||||
|
- SecretService: Support insert/update/delete
|
||||||
|
- Binary data viewer
|
||||||
|
- Detect jpg/png and show as image
|
||||||
|
- More DB types
|
||||||
|
- MySQL (& MariaDB/TiDB)
|
||||||
|
- Postgres
|
||||||
|
- CLI using psql
|
||||||
|
- Firebird/interbase (embedded and remote)
|
||||||
|
- Lungo: Mini embeddable Mongo - https://github.com/256dpi/lungo
|
||||||
|
- MSSQL (recursive navigation for instances)
|
||||||
|
- NutsDB https://github.com/nutsdb/nutsdb
|
||||||
|
- Sniper https://github.com/recoilme/sniper
|
||||||
|
- DuckDB https://github.com/duckdb/duckdb-go
|
||||||
|
- Other K/V stores from https://github.com/smallnest/kvbench
|
||||||
|
- Windows registry
|
||||||
|
- Allow entering path for quick navigation
|
||||||
|
- LDAP
|
||||||
|
- Dolt
|
||||||
|
- Memcache
|
||||||
|
- Listing all keys is not well supported, needs hacks
|
||||||
|
- Chai (built on Pebble) - https://github.com/chaisql/chai
|
||||||
|
- CloverDB (built on Bolt/Badger) - https://github.com/ostafen/clover
|
||||||
|
- APCu - need some sort of hook into the storage engine
|
||||||
|
- UnisonDB - https://github.com/ankur-anand/unisondb
|
||||||
|
- CSV file
|
||||||
|
- Allow querying with sqlite or duckDB?
|
||||||
|
- Parquet file
|
||||||
|
- Allow querying with duckDB?
|
||||||
|
- SSDB (Redis-compatible)
|
||||||
|
- Time-series DBs
|
||||||
|
- Prometheus
|
||||||
|
- VictoriaMetrics
|
||||||
|
- FrostDB https://github.com/polarsignals/frostdb
|
||||||
|
- https://dbdb.io/browse?programming=go-lang&q=
|
||||||
|
- Maxmind GeoIP MMDB format
|
||||||
|
- https://github.com/maxmind/mmdbwriter
|
||||||
|
- KeePass kdbx
|
||||||
|
- Not-quite-DBs
|
||||||
|
- IRC client
|
||||||
|
- Docker daemon (images, containers, ...)
|
||||||
|
- ssh known-hosts
|
||||||
|
- golang.org/x/crypto/ssh/knownhosts - already using this package
|
||||||
|
- Generic ODBC, database/sql, ...
|
||||||
|
- Other language DBs
|
||||||
|
- C, C++
|
||||||
|
- Berkeley BDB
|
||||||
|
- Tokyo Cabinet, Kyoto Cabinet, Tkrzw
|
||||||
|
- https://github.com/TerraTech/go-tokyocabinet needs pkg-config tokyocabinet
|
||||||
|
- https://github.com/estraier/tkrzw-go needs pkg-config tkrzw
|
||||||
|
- cdb (DJB's Constant Database)
|
||||||
|
- Other classic DBM (Samba tdb, GNU gdbm, ...)
|
||||||
|
- `/var/cache/man/index.db` is a gdbm file
|
||||||
|
- RocksDB
|
||||||
|
- https://github.com/tecbot/gorocksdb Go bindings, need pkg-config rocksdb
|
||||||
|
- Rust
|
||||||
|
- Stoolap https://github.com/stoolap/stoolap
|
||||||
|
- Rust, needs C binding layer https://github.com/mozilla/cbindgen
|
||||||
|
- JDBC Java databases
|
||||||
|
- H2, HSQLDB, Apache Derby
|
||||||
|
- SQLite CLI driver:
|
||||||
|
- Context support
|
||||||
|
- Write support
|
||||||
|
- Type handling for columns
|
||||||
|
- Binary data is losing its \uXXXX escaping and appearing as string
|
||||||
|
- Unix timestamps are appearing with scientific notation
|
||||||
|
- Configure binary path
|
||||||
|
- Error handling: if an error occurs, listing db tables has problems/shows separators
|
||||||
|
- https://ricardoanderegg.com/posts/sqlite-remote-explorer-gui/
|
||||||
|
- https://github.com/litements/litexplore
|
||||||
|
- Badger:
|
||||||
|
- v1/v2/v3 support
|
||||||
|
- option to use namespace separators for virtual buckets / isolate specific key ranges?
|
||||||
|
- SQLite:
|
||||||
|
- drop table doesn't autorefresh nav since callback is late
|
||||||
|
- more accurate type handling
|
||||||
|
- generated columns, hidden columns
|
||||||
|
- switch to table_xinfo
|
||||||
|
- https://sqlite.org/gencol.html - probably want to show
|
||||||
|
- https://sqlite.org/vtab.html#hiddencol hidden columns - probably want to leave hidden (option to show??)
|
||||||
|
- views
|
||||||
|
- other special objects
|
||||||
|
- triggers? udf functions?
|
||||||
|
- virtual tables, shadow tables
|
||||||
|
- toggle showing system tables
|
||||||
|
- e.g. sqlite_schema a.k.a. sqlite_master not present in default list; sqlite_sequence, is present
|
||||||
|
- both show in pragma_table_list()
|
||||||
|
- integrity checks / quick_check
|
||||||
|
- attach additional db to same connection ("schemas")
|
||||||
|
- view the contents of an index, using imposter tables - https://sqlite.org/imposter.html
|
||||||
|
- ~~autoincrement: if column is autoincrement and left blank on insert, do not populate in INSERT statement~~ works with implicitly null columns
|
||||||
|
- BUG: non-nullable columns are being detected as nullable
|
||||||
|
- LMDB: dupsort mode (duplicate keys / entries-per-key)
|
||||||
|
- MongoDB
|
||||||
|
- UI for replica sets, ssl certs, cluster, custom auth database
|
||||||
|
- SSH tunnel: error `ssh: tcpChan: deadline not supported` - needs workaround
|
||||||
|
- Backup/restore
|
||||||
|
- drop db/collection doesn't autorefresh nav since server is asynchronous
|
||||||
|
- VoidDB:
|
||||||
|
- drop multidb doesn't autorefresh nav
|
||||||
|
- SSH tunnel
|
||||||
|
- option to use external/system SSH
|
||||||
|
- Popup prompt for SSHkey password
|
||||||
|
- Dynamic SSH_AUTH_SOCK env instead of static
|
||||||
|
- SSH over Cockpit
|
||||||
|
- Performance
|
||||||
|
- Warning if data table is filtered to 1000 rows, or add pagination
|
||||||
|
- Context/interrupt slow queries
|
||||||
|
- Query history
|
||||||
|
- Query log
|
||||||
|
- Test suite
|
||||||
|
- `CREATE TABLE foo (id integer primary key, aaa text not null, bbb text not null);`
|
||||||
|
- Ability to convert database types
|
||||||
|
- Export all data from grid
|
||||||
|
- Export all data from all buckets within a DB
|
||||||
|
- Reconnect
|
||||||
|
- Connection manager: clone entry
|
||||||
|
- Lotus, Rose, Pebble, ...: Support other advanced options
|
||||||
|
- UI: Save appearance settings to file
|
||||||
|
- UI: Apply appearance settings to query result table
|
||||||
|
- UI: List all available QStyles
|
||||||
|
- Etcd:
|
||||||
|
- Support SSH tunnel
|
||||||
|
- Other actions, compaction, backup/restore, ...
|
||||||
BIN
assets/add.png
Normal file
|
After Width: | Height: | Size: 714 B |
BIN
assets/arrow_refresh.png
Normal file
|
After Width: | Height: | Size: 707 B |
BIN
assets/chart_bar.png
Normal file
|
After Width: | Height: | Size: 637 B |
BIN
assets/compress.png
Normal file
|
After Width: | Height: | Size: 892 B |
BIN
assets/connect.png
Normal file
|
After Width: | Height: | Size: 754 B |
BIN
rsrc/database.png → assets/database.png
Executable file → Normal file
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 337 B |
BIN
assets/database_add.png
Normal file
|
After Width: | Height: | Size: 685 B |
BIN
assets/database_delete.png
Normal file
|
After Width: | Height: | Size: 715 B |
BIN
assets/database_key.png
Normal file
|
After Width: | Height: | Size: 803 B |
BIN
assets/database_lightning.png
Normal file
|
After Width: | Height: | Size: 819 B |
BIN
assets/database_save.png
Normal file
|
After Width: | Height: | Size: 812 B |
BIN
assets/delete.png
Normal file
|
After Width: | Height: | Size: 692 B |
BIN
assets/disconnect.png
Normal file
|
After Width: | Height: | Size: 793 B |
BIN
assets/help.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
assets/key.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
assets/lightning.png
Normal file
|
After Width: | Height: | Size: 644 B |
BIN
assets/lightning_go.png
Normal file
|
After Width: | Height: | Size: 770 B |
BIN
assets/note_delete.png
Normal file
|
After Width: | Height: | Size: 639 B |
BIN
assets/page_key.png
Normal file
|
After Width: | Height: | Size: 907 B |
BIN
assets/pencil.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
assets/pencil_add.png
Normal file
|
After Width: | Height: | Size: 540 B |
BIN
assets/pencil_delete.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
assets/pencil_go.png
Normal file
|
After Width: | Height: | Size: 615 B |
BIN
assets/resultset_next.png
Normal file
|
After Width: | Height: | Size: 348 B |
BIN
assets/table.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
assets/table_add.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
assets/table_delete.png
Normal file
|
After Width: | Height: | Size: 645 B |
BIN
assets/table_save.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
assets/vendor_buntdb.png
Normal file
|
After Width: | Height: | Size: 611 B |
BIN
assets/vendor_cockroach.png
Normal file
|
After Width: | Height: | Size: 353 B |
BIN
assets/vendor_debian.png
Normal file
|
After Width: | Height: | Size: 622 B |
BIN
assets/vendor_dgraph.png
Normal file
|
After Width: | Height: | Size: 763 B |
BIN
assets/vendor_etcd.png
Normal file
|
After Width: | Height: | Size: 576 B |
BIN
assets/vendor_freedesktop.png
Normal file
|
After Width: | Height: | Size: 821 B |
BIN
assets/vendor_github.png
Normal file
|
After Width: | Height: | Size: 303 B |
BIN
assets/vendor_leveldb.png
Normal file
|
After Width: | Height: | Size: 624 B |
BIN
assets/vendor_lmdb.png
Normal file
|
After Width: | Height: | Size: 738 B |
BIN
assets/vendor_lotus.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/vendor_mongodb.png
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
assets/vendor_mysql.png
Normal file
|
After Width: | Height: | Size: 545 B |
BIN
assets/vendor_pogreb.png
Normal file
|
After Width: | Height: | Size: 980 B |
BIN
assets/vendor_qt.png
Normal file
|
After Width: | Height: | Size: 384 B |
BIN
assets/vendor_redis.png
Normal file
|
After Width: | Height: | Size: 825 B |
BIN
assets/vendor_riak.png
Normal file
|
After Width: | Height: | Size: 719 B |
BIN
assets/vendor_rosedb.png
Normal file
|
After Width: | Height: | Size: 970 B |
BIN
assets/vendor_sqlite.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
assets/vendor_ssh.png
Normal file
|
After Width: | Height: | Size: 377 B |
BIN
assets/vendor_starskey.png
Normal file
|
After Width: | Height: | Size: 203 B |
BIN
assets/vendor_voiddb.png
Normal file
|
After Width: | Height: | Size: 802 B |
223
bolt.go
@@ -1,223 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Bolt_Open(readOnly bool, path string) (*bolt.DB, error) {
|
|
||||||
opts := *bolt.DefaultOptions
|
|
||||||
opts.Timeout = 10 * time.Second
|
|
||||||
opts.ReadOnly = readOnly
|
|
||||||
|
|
||||||
return bolt.Open(path, os.FileMode(0644), &opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func walkBuckets(tx *bolt.Tx, browse []string) (*bolt.Bucket, error) {
|
|
||||||
bucket := tx.Bucket([]byte(browse[0]))
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.New("Unknown bucket")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 1; i < len(browse); i += 1 {
|
|
||||||
bucket = bucket.Bucket([]byte(browse[i]))
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.New("Unknown bucket")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bucket, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func withBrowse_ReadOnly(db *bolt.DB, browse []string, fn func(tx *bolt.Tx, bucket *bolt.Bucket) error) error {
|
|
||||||
if len(browse) == 0 {
|
|
||||||
// not a bucket
|
|
||||||
return errors.New("No bucket selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.View(func(tx *bolt.Tx) error {
|
|
||||||
|
|
||||||
bucket, err := walkBuckets(tx, browse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walked the bucket chain, now run the user callback
|
|
||||||
return fn(tx, bucket)
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_CreateBucket(db *bolt.DB, browse []string, newBucket string) error {
|
|
||||||
|
|
||||||
return db.Update(func(tx *bolt.Tx) error {
|
|
||||||
|
|
||||||
if len(browse) == 0 {
|
|
||||||
// Top-level bucket
|
|
||||||
_, err := tx.CreateBucket([]byte(newBucket))
|
|
||||||
return err
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Deeper bucket
|
|
||||||
bucket, err := walkBuckets(tx, browse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walked the bucket chain, now create the new bucket
|
|
||||||
_, err = bucket.CreateBucket([]byte(newBucket))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_DeleteBucket(db *bolt.DB, browse []string, delBucket string) error {
|
|
||||||
return db.Update(func(tx *bolt.Tx) error {
|
|
||||||
|
|
||||||
if len(browse) == 0 {
|
|
||||||
// Top-level bucket
|
|
||||||
return tx.DeleteBucket([]byte(delBucket))
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Deeper bucket
|
|
||||||
bucket, err := walkBuckets(tx, browse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walked the bucket chain, now delete the selected bucket
|
|
||||||
return bucket.DeleteBucket([]byte(delBucket))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_SetItem(db *bolt.DB, browse []string, key, val []byte) error {
|
|
||||||
if len(browse) == 0 {
|
|
||||||
return errors.New("Can't create top-level items")
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.Update(func(tx *bolt.Tx) error {
|
|
||||||
|
|
||||||
bucket, err := walkBuckets(tx, browse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return bucket.Put(key, val)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_DeleteItem(db *bolt.DB, browse []string, key []byte) error {
|
|
||||||
if len(browse) == 0 {
|
|
||||||
return errors.New("Can't create top-level items")
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.Update(func(tx *bolt.Tx) error {
|
|
||||||
|
|
||||||
bucket, err := walkBuckets(tx, browse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return bucket.Delete(key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_DBStats(db *bolt.DB) (string, error) {
|
|
||||||
jBytes, err := json.MarshalIndent(db.Stats(), "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_BucketStats(db *bolt.DB, browse []string) (string, error) {
|
|
||||||
var stats bolt.BucketStats
|
|
||||||
|
|
||||||
err := withBrowse_ReadOnly(db, browse, func(tx *bolt.Tx, bucket *bolt.Bucket) error {
|
|
||||||
stats = bucket.Stats()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jBytes, err := json.MarshalIndent(stats, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jBytes), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_ListBuckets(db *bolt.DB, browse []string, cb func(b string) error) error {
|
|
||||||
|
|
||||||
if len(browse) == 0 {
|
|
||||||
// root mode
|
|
||||||
return db.View(func(tx *bolt.Tx) error {
|
|
||||||
return tx.ForEach(func(k []byte, _ *bolt.Bucket) error {
|
|
||||||
return cb(string(k))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nested-mode
|
|
||||||
return withBrowse_ReadOnly(db, browse, func(tx *bolt.Tx, bucket *bolt.Bucket) error {
|
|
||||||
return bucket.ForEach(func(k, v []byte) error {
|
|
||||||
// non-nil v means it's a data item
|
|
||||||
if v == nil {
|
|
||||||
return cb(string(k))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListItemInfo struct {
|
|
||||||
Name []byte
|
|
||||||
DataLen int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_ListItems(db *bolt.DB, browse []string, cb func(ListItemInfo) error) error {
|
|
||||||
|
|
||||||
if len(browse) == 0 {
|
|
||||||
return errors.New("No bucket specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nested-mode
|
|
||||||
return withBrowse_ReadOnly(db, browse, func(tx *bolt.Tx, bucket *bolt.Bucket) error {
|
|
||||||
return bucket.ForEach(func(k, v []byte) error {
|
|
||||||
if v == nil {
|
|
||||||
return nil // nil v means it's a bucket, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
kcopy := make([]byte, len(k))
|
|
||||||
copy(kcopy, k)
|
|
||||||
|
|
||||||
return cb(ListItemInfo{kcopy, int64(len(v))})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_GetItem(db *bolt.DB, browse []string, key []byte) ([]byte, error) {
|
|
||||||
var ret []byte
|
|
||||||
|
|
||||||
err := withBrowse_ReadOnly(db, browse, func(tx *bolt.Tx, bucket *bolt.Bucket) error {
|
|
||||||
d := bucket.Get([]byte(key))
|
|
||||||
|
|
||||||
ret = make([]byte, len(d))
|
|
||||||
copy(ret, d)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_Close(db *bolt.DB) error {
|
|
||||||
return db.Close()
|
|
||||||
}
|
|
||||||
471
config.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBConnector interface {
|
||||||
|
Connect(context.Context) (loadedDatabase, error)
|
||||||
|
fmt.Stringer
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailedStringer interface {
|
||||||
|
DetailedString() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionConfig struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
|
||||||
|
Badger *badgerConnection `ylabel:"BadgerDB v4" yicon:":/assets/vendor_dgraph.png" json:",omitempty"`
|
||||||
|
Bitcask *bitcaskDBConnection `yicon:":/assets/vendor_riak.png" json:",omitempty"`
|
||||||
|
Bolt *boltConfig `yicon:":/assets/vendor_github.png" json:",omitempty"`
|
||||||
|
BuntDB *buntDBConnection `ylabel:"BuntDB" yicon:":/assets/vendor_buntdb.png" json:",omitempty"`
|
||||||
|
Etcd *etcdConn `ylabel:"etcd" yicon:":/assets/vendor_etcd.png" json:",omitempty"`
|
||||||
|
Debconf *debconfConnection `yicon:":/assets/vendor_debian.png" json:",omitempty"`
|
||||||
|
SecretService *secretServiceConnection `ylabel:"Freedesktop.org Secret Service" yicon:":/assets/vendor_freedesktop.png" json:",omitempty"`
|
||||||
|
LevelDB *leveldbConnection `ylabel:"LevelDB" yicon:":/assets/vendor_leveldb.png" json:",omitempty"`
|
||||||
|
LMDB *lmdbConnection `yicon:":/assets/vendor_lmdb.png" json:",omitempty"`
|
||||||
|
LotusDB *lotusDBConnection `ylabel:"LotusDB" yicon:":/assets/vendor_lotus.png" json:",omitempty"`
|
||||||
|
MongoDB *mongoConnection `ylabel:"MongoDB" yicon:":/assets/vendor_mongodb.png" json:",omitempty"`
|
||||||
|
Pebble *pebbleConnection `yicon:":/assets/vendor_cockroach.png" json:",omitempty"`
|
||||||
|
Pogreb *pogrebConn `yicon:":/assets/vendor_pogreb.png" json:",omitempty"`
|
||||||
|
Redis *redisConnectionOptions `yicon:":/assets/vendor_redis.png" json:",omitempty"`
|
||||||
|
RoseDB *roseDBConn `ylabel:"RoseDB" yicon:":/assets/vendor_rosedb.png" json:",omitempty"`
|
||||||
|
SQLite *sqliteConnection `ylabel:"SQLite" yicon:":/assets/vendor_sqlite.png" json:",omitempty"`
|
||||||
|
SSHAgent *sshAgentConn `yicon:":/assets/vendor_ssh.png" json:",omitempty"`
|
||||||
|
Starskey *starskeyConnection `yicon:":/assets/vendor_starskey.png" json:",omitempty"`
|
||||||
|
VoidDB *voidDBConn `ylabel:"VoidDB" yicon:":/assets/vendor_voiddb.png" json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnectionConfig() *ConnectionConfig {
|
||||||
|
return &ConnectionConfig{
|
||||||
|
Type: "Bolt", // favouritism
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon gets the Qt string name for the icon of this database by parsing the
|
||||||
|
// 'yicon' struct tag with reflection.
|
||||||
|
// If there is no selection or if it's misconfigured, falls back to a generic
|
||||||
|
// database icon.
|
||||||
|
func (cc *ConnectionConfig) Icon() string {
|
||||||
|
taginfo, ok := reflect.ValueOf(cc).Type().Elem().FieldByName(string(cc.Type))
|
||||||
|
if !ok {
|
||||||
|
return ":/assets/database.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
yicon, ok := taginfo.Tag.Lookup("yicon")
|
||||||
|
if !ok {
|
||||||
|
return ":/assets/database.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
return yicon
|
||||||
|
}
|
||||||
|
|
||||||
|
// String is used as the connection's name when (A) connecting directly, to show
|
||||||
|
// in the left-hand nav; and (B) when saving a new connection, the saved name
|
||||||
|
// It's usually a simple, single word or filename.
|
||||||
|
func (cc *ConnectionConfig) String() string {
|
||||||
|
if selection, err := cc.selection(); err == nil {
|
||||||
|
if stringer, ok := selection.(fmt.Stringer); ok {
|
||||||
|
if candidate := stringer.String(); candidate != "" && candidate != "." {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(cc.Type) == "" {
|
||||||
|
return "Not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(cc.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip is used as the hover tooltip in the connection manager.
|
||||||
|
// It can show more detail (e.g. full path spec).
|
||||||
|
// If there is no useful more detail, returns empty-string.
|
||||||
|
func (cc *ConnectionConfig) Tooltip() string {
|
||||||
|
|
||||||
|
// Try DetailedString() if there is such a method
|
||||||
|
if selection, err := cc.selection(); err == nil {
|
||||||
|
if getDetails, ok := selection.(DetailedStringer); ok {
|
||||||
|
if candidate := getDetails.DetailedString(); candidate != "" && candidate != "." {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise - just same as .String()
|
||||||
|
return cc.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cc *ConnectionConfig) selection() (DBConnector, error) {
|
||||||
|
selection := reflect.ValueOf(cc).Elem().FieldByName(string(cc.Type))
|
||||||
|
if !selection.IsValid() {
|
||||||
|
return nil, fmt.Errorf("Invalid database engine %q", cc.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
con, ok := selection.Interface().(DBConnector)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Can't connect to database on type %q (weird)", cc.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return con, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cc *ConnectionConfig) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
dbc, err := cc.selection()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid database engine %q", cc.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbc.Connect(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &ConnectionConfig{}
|
||||||
|
|
||||||
|
func (f *App) OnMnuConnectClick() {
|
||||||
|
config := NewConnectionConfig()
|
||||||
|
f.showConnectDialog(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) showConnectDialog(config *ConnectionConfig) {
|
||||||
|
|
||||||
|
// autoconfig.OpenDialog() does not give us a "yes/no" result back from the
|
||||||
|
// dialog, so we still have to custom construct the dialog and use the
|
||||||
|
// formlayout option instead
|
||||||
|
|
||||||
|
dlg := NewConnectDialogUi()
|
||||||
|
dlg.ConnectDialog.SetParent2(f.ui.MainWindow.QWidget, qt.Dialog)
|
||||||
|
dlg.ConnectDialog.SetModal(true)
|
||||||
|
dlg.ConnectDialog.SetAttribute(qt.WA_DeleteOnClose)
|
||||||
|
dlg.formLayout.SetSizeConstraint(qt.QLayout__SetMinAndMaxSize) // Expand dialog to fit form content
|
||||||
|
|
||||||
|
saver := autoconfig.MakeConfigArea(config, dlg.formLayout)
|
||||||
|
|
||||||
|
dlg.ConnectDialog.OnAccept(func(super func()) {
|
||||||
|
// Validate connection before closing
|
||||||
|
|
||||||
|
dlg.buttonBox.SetEnabled(false)
|
||||||
|
defer dlg.buttonBox.SetEnabled(true)
|
||||||
|
dlg.buttonBox.Repaint()
|
||||||
|
|
||||||
|
// Save changes from UI into struct
|
||||||
|
saver()
|
||||||
|
|
||||||
|
// Connect -> get ld
|
||||||
|
ctx := context.Background() // TODO do in background thread?
|
||||||
|
ld, err := config.Connect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = qt.QMessageBox_Critical(dlg.ConnectDialog.QWidget, APPNAME, fmt.Sprintf("Connecting to %s database: %s", config.Type, err.Error()))
|
||||||
|
// Prevent the dialog from closing: do not call super()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := config.String()
|
||||||
|
|
||||||
|
// Add ld to mainwindow
|
||||||
|
f.addTopLevelDatabaseConnection(ld, displayName, config.Tooltip())
|
||||||
|
|
||||||
|
// Connection OK
|
||||||
|
// Offer to save into connection-manager
|
||||||
|
if res := qt.QMessageBox_Question2(dlg.ConnectDialog.QWidget, APPNAME, "Connection successful. Save the details into Connection Manager?", qt.QMessageBox__Save, qt.QMessageBox__Ignore); res == int(qt.QMessageBox__Save) {
|
||||||
|
f.TrySaveIntoConnectionManager(config, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default accept behaviour is: setResult(Accepted), emits onFinished; && Hide()
|
||||||
|
super()
|
||||||
|
})
|
||||||
|
|
||||||
|
dlg.ConnectDialog.Open() // Modal, unlike .Show()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnMgrLoadError struct {
|
||||||
|
e error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConnMgrLoadError) Error() string {
|
||||||
|
return fmt.Sprintf("Failed to load saved connections: %s", c.e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConnMgrLoadError) Unwrap() error {
|
||||||
|
return c.e
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnMgrSaveError struct {
|
||||||
|
e error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConnMgrSaveError) Error() string {
|
||||||
|
return fmt.Sprintf("Failed to save connections: %s", c.e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConnMgrSaveError) Unwrap() error {
|
||||||
|
return c.e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) TrySaveIntoConnectionManager(cc *ConnectionConfig, displayName string) {
|
||||||
|
data, err := f.getConnectionManagerContents()
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrLoadError{err}.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = cc.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName = data.getNonConflictingName(displayName)
|
||||||
|
|
||||||
|
data.Entries = append(data.Entries, SavedConfigEntry{
|
||||||
|
Description: displayName,
|
||||||
|
Connection: *cc,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = f.saveConnectionManagerContents(data)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) OnMnuConnectionManagerClick() {
|
||||||
|
|
||||||
|
data, err := f.getConnectionManagerContents()
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrLoadError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dlg := NewconnectionManagerDialogUi()
|
||||||
|
dlg.connectionManagerDialog.SetParent2(f.ui.MainWindow.QWidget, qt.Dialog)
|
||||||
|
dlg.connectionManagerDialog.SetModal(true)
|
||||||
|
dlg.connectionManagerDialog.SetAttribute(qt.WA_DeleteOnClose)
|
||||||
|
dlg.treeWidget.SetRootIsDecorated(false)
|
||||||
|
dlg.treeWidget.SetSelectionMode(qt.QAbstractItemView__ExtendedSelection)
|
||||||
|
|
||||||
|
addEntryFor := func(entry SavedConfigEntry) {
|
||||||
|
itm := qt.NewQTreeWidgetItem()
|
||||||
|
itm.SetText(0, entry.Description)
|
||||||
|
itm.SetIcon(0, qt.NewQIcon4(entry.Connection.Icon()))
|
||||||
|
|
||||||
|
if tooltip := entry.Connection.Tooltip(); tooltip != "" && tooltip != entry.Description {
|
||||||
|
itm.SetToolTip(0, tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
dlg.treeWidget.AddTopLevelItem(itm)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshEntryFor := func(idx *qt.QModelIndex, entry SavedConfigEntry) {
|
||||||
|
itm := dlg.treeWidget.ItemFromIndex(idx)
|
||||||
|
itm.SetText(0, entry.Description) // Update label
|
||||||
|
itm.SetIcon(0, qt.NewQIcon4(entry.Connection.Icon())) // Update icon
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate entries
|
||||||
|
for _, entry := range data.Entries {
|
||||||
|
addEntryFor(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshButtonState := func() {
|
||||||
|
ct := len(dlg.treeWidget.SelectedItems())
|
||||||
|
|
||||||
|
dlg.connectBtn.SetEnabled(ct > 0)
|
||||||
|
dlg.connDelete.SetEnabled(ct > 0)
|
||||||
|
dlg.connEdit.SetEnabled(ct == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectToItem := func(itm *qt.QTreeWidgetItem) bool {
|
||||||
|
ctx := context.Background() // TODO do in background thread?
|
||||||
|
|
||||||
|
dlg.connectBtn.SetEnabled(false)
|
||||||
|
dlg.treeWidget.SetEnabled(false)
|
||||||
|
defer func() {
|
||||||
|
dlg.connectBtn.SetEnabled(true)
|
||||||
|
dlg.treeWidget.SetEnabled(true) // FIXME block the other buttons too!
|
||||||
|
}()
|
||||||
|
|
||||||
|
entry := data.Entries[dlg.treeWidget.IndexFromItem(itm).Row()]
|
||||||
|
|
||||||
|
ld, err := entry.Connection.Connect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = qt.QMessageBox_Critical(dlg.connectionManagerDialog.QWidget, APPNAME, fmt.Sprintf("Connecting to %s database: %s", entry.Connection.Type, err.Error()))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ld to mainwindow
|
||||||
|
// Don't use the displayName from the Connect() function - use the saved
|
||||||
|
// displayname instead
|
||||||
|
f.addTopLevelDatabaseConnection(ld, entry.Description, entry.Connection.Tooltip())
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dlg.treeWidget.OnItemDoubleClicked(func(itm *qt.QTreeWidgetItem, _ int) {
|
||||||
|
// connect to specific item
|
||||||
|
if connectToItem(itm) {
|
||||||
|
dlg.connectionManagerDialog.Accept()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dlg.connectBtn.OnClicked(func() {
|
||||||
|
// Based on selectedItems, not currentItem
|
||||||
|
itms := dlg.treeWidget.SelectedItems()
|
||||||
|
var ok bool = true
|
||||||
|
for _, itm := range itms {
|
||||||
|
itmConnectOK := connectToItem(itm) // connect to selected
|
||||||
|
ok = ok && itmConnectOK
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
dlg.connectionManagerDialog.Accept()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dlg.connDelete.SetEnabled(false)
|
||||||
|
dlg.connEdit.SetEnabled(false)
|
||||||
|
dlg.treeWidget.OnItemSelectionChanged(refreshButtonState)
|
||||||
|
|
||||||
|
dlg.connDelete.OnClicked(func() {
|
||||||
|
|
||||||
|
itms := dlg.treeWidget.SelectedItems()
|
||||||
|
for _, itm := range itms {
|
||||||
|
|
||||||
|
idx := dlg.treeWidget.IndexFromItem(itm).Row() // n.b. will dynamically change as the item is removed
|
||||||
|
itm.Delete()
|
||||||
|
|
||||||
|
data.Entries = slice_remove_index(data.Entries, idx)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// All changes made in both ui + data model
|
||||||
|
// Save data model changes
|
||||||
|
err := f.saveConnectionManagerContents(data)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dlg.connEdit.OnClicked(func() {
|
||||||
|
// Edit is based on selectedIndex, not currentIndex
|
||||||
|
items := dlg.treeWidget.SelectedItems()
|
||||||
|
if len(items) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := dlg.treeWidget.IndexFromItem(items[0])
|
||||||
|
|
||||||
|
row := idx.Row()
|
||||||
|
props := data.Entries[row]
|
||||||
|
|
||||||
|
autoconfig.OpenDialog(&props, dlg.connectionManagerDialog.QWidget, "Editing connection", func() {
|
||||||
|
data.Entries[row] = props
|
||||||
|
refreshEntryFor(idx, props)
|
||||||
|
|
||||||
|
err := f.saveConnectionManagerContents(data)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
dlg.connAdd.OnClicked(func() {
|
||||||
|
|
||||||
|
obj := SavedConfigEntry{
|
||||||
|
Description: "New connection (" + time.Now().Format(time.DateTime) + ")",
|
||||||
|
}
|
||||||
|
vv := NewConnectionConfig()
|
||||||
|
obj.Connection = *vv
|
||||||
|
|
||||||
|
autoconfig.OpenDialog(&obj, dlg.connectionManagerDialog.QWidget, "New connection", func() {
|
||||||
|
|
||||||
|
obj.Description = data.getNonConflictingName(obj.Description)
|
||||||
|
|
||||||
|
data.Entries = append(data.Entries, obj)
|
||||||
|
addEntryFor(obj)
|
||||||
|
|
||||||
|
err := f.saveConnectionManagerContents(data)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
miniMenu := qt.NewQMenu(dlg.toolsBtn.QWidget)
|
||||||
|
exportAction := miniMenu.AddActionWithText("Export connections...")
|
||||||
|
importAction := miniMenu.AddActionWithText("Import connections...")
|
||||||
|
dlg.toolsBtn.SetMenu(miniMenu)
|
||||||
|
dlg.toolsBtn.SetPopupMode(qt.QToolButton__InstantPopup)
|
||||||
|
|
||||||
|
exportAction.OnTriggered(func() {
|
||||||
|
saveAs := qt.QFileDialog_GetSaveFileName4(dlg.toolsBtn.QWidget, "Export connections...", "", "JSON files (*.json);;All files (*)")
|
||||||
|
if saveAs == "" {
|
||||||
|
return // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
jbytes, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(dlg.toolsBtn.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(saveAs, jbytes, 0600)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(dlg.toolsBtn.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK
|
||||||
|
qt.QMessageBox_Information(dlg.toolsBtn.QWidget, APPNAME, "The connections have been exported successfully.")
|
||||||
|
})
|
||||||
|
|
||||||
|
importAction.OnTriggered(func() {
|
||||||
|
loadFile := qt.QFileDialog_GetOpenFileName4(dlg.toolsBtn.QWidget, "Import connections...", "", "JSON files (*.json);;All files (*)")
|
||||||
|
if loadFile == "" {
|
||||||
|
return // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
jbytes, err := os.ReadFile(loadFile)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(dlg.toolsBtn.QWidget, APPNAME, ConnMgrLoadError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var importData SavedConfig
|
||||||
|
err = json.Unmarshal(jbytes, &importData)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(dlg.toolsBtn.QWidget, APPNAME, ConnMgrLoadError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to UI
|
||||||
|
for _, newEntry := range importData.Entries {
|
||||||
|
addEntryFor(newEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to data model
|
||||||
|
data.Entries = append(data.Entries, importData.Entries...)
|
||||||
|
|
||||||
|
// Save data model to disk
|
||||||
|
err = f.saveConnectionManagerContents(data)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, ConnMgrSaveError{err}.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
refreshButtonState()
|
||||||
|
|
||||||
|
dlg.connectionManagerDialog.Show()
|
||||||
|
}
|
||||||
232
configSave.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SavedConfigEntry struct {
|
||||||
|
Description string
|
||||||
|
Connection ConnectionConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type SavedConfig struct {
|
||||||
|
UserAgent string // APPNAME/{ver}
|
||||||
|
Entries []SavedConfigEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNonConflictingName returns a version of the input name that does not
|
||||||
|
// conflict with any existing saved entry.
|
||||||
|
// It adds " (2)", " (3)", etc. prefixes.
|
||||||
|
func (s *SavedConfig) getNonConflictingName(nameSuggest string) string {
|
||||||
|
existingNames := make(map[string]struct{}, len(s.Entries))
|
||||||
|
for _, e := range s.Entries {
|
||||||
|
existingNames[e.Description] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for {
|
||||||
|
i++ // starts at 1
|
||||||
|
|
||||||
|
testName := nameSuggest
|
||||||
|
if i > 1 {
|
||||||
|
testName += ` (` + strconv.Itoa(i) + `)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := existingNames[testName]; !ok {
|
||||||
|
return testName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
saveSettingsFilename = `settings.dat`
|
||||||
|
keychainUserName = `settings-encryption-key`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *App) getConnectionManagerContentsJson() ([]byte, error) {
|
||||||
|
cfgd, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Join(cfgd, APPNAME), 0700)
|
||||||
|
|
||||||
|
ciphertext, err := os.ReadFile(filepath.Join(cfgd, APPNAME, saveSettingsFilename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get decryption key from OS keychain
|
||||||
|
// Since we successfully loaded the file on disk, the keychain entry must exist
|
||||||
|
|
||||||
|
details, err := keyring.Get(APPNAME, keychainUserName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
// The encryption key is random 256 bytes, no need for a KDF
|
||||||
|
encryptionKeyBytes, err := hex.DecodeString(details)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cc, err := aes.NewCipher(encryptionKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cw, err := cipher.NewGCMWithRandomNonce(cc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cw.Open(ciphertext[:0], nil, ciphertext, nil) // @ref https://pkg.go.dev/crypto/cipher#AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) getConnectionManagerContents() (*SavedConfig, error) {
|
||||||
|
plaintext, err := f.getConnectionManagerContentsJson()
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// No file exists. Use blank
|
||||||
|
return &SavedConfig{
|
||||||
|
UserAgent: APPNAME,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret SavedConfig
|
||||||
|
err = json.Unmarshal(plaintext, &ret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before returning the configuration, sort the entries alphabetically
|
||||||
|
sort.Slice(ret.Entries, func(i, j int) bool {
|
||||||
|
// FIXME there is probably a slightly more efficient way of doing this
|
||||||
|
return strings.ToLower(ret.Entries[i].Description) < strings.ToLower(ret.Entries[j].Description)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Success
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) saveConnectionManagerContents(sc *SavedConfig) error {
|
||||||
|
|
||||||
|
// Force update the saved version
|
||||||
|
sc.UserAgent = APPNAME + `/` + appVersion // e.g. QBolt/v0.0.0-devel
|
||||||
|
|
||||||
|
// Marshal
|
||||||
|
|
||||||
|
plaintext, err := json.Marshal(sc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.saveConnectionManagerContentsJson(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) saveConnectionManagerContentsJson(plaintext []byte) error {
|
||||||
|
cfgd, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file exists already, we must have an existing encryption key
|
||||||
|
var exists bool
|
||||||
|
savePath := filepath.Join(cfgd, APPNAME, saveSettingsFilename)
|
||||||
|
if _, err := os.Stat(savePath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Join(cfgd, APPNAME), 0700)
|
||||||
|
exists = false
|
||||||
|
} else {
|
||||||
|
return err // some other real error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
details, err := keyring.Get(APPNAME, keychainUserName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, keyring.ErrNotFound) {
|
||||||
|
// Does not exist in keyring
|
||||||
|
// That is OK if there is no saved file yet / we can generate a random
|
||||||
|
// key. But if the file does already exist, this is fatal
|
||||||
|
if exists {
|
||||||
|
// File exists on disk but there is no encryption key to parse it
|
||||||
|
// Fatal
|
||||||
|
return fmt.Errorf(
|
||||||
|
"There is already a saved file on disk (%q), but there is no matching encryption key in the system keychain provider. Has your keychain been destroyed? To confirm, please delete the saved settings file.",
|
||||||
|
savePath,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Generate new
|
||||||
|
// 256 bits (32 bytes) of cryto-random data, and then hex encode
|
||||||
|
randBuff := make([]byte, 32)
|
||||||
|
n, err := rand.Read(randBuff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != len(randBuff) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
details = hex.EncodeToString(randBuff)
|
||||||
|
err = keyring.Set(APPNAME, keychainUserName, details)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Real keychain error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
|
||||||
|
encryptionKeyBytes, err := hex.DecodeString(details)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cc, err := aes.NewCipher(encryptionKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cw, err := cipher.NewGCMWithRandomNonce(cc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := cw.Seal(plaintext[:0], nil, plaintext, nil)
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
err = os.WriteFile(savePath, ciphertext, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save OK
|
||||||
|
return nil
|
||||||
|
}
|
||||||
69
connectionDialog.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Generated by miqt-uic. To update this file, edit the .ui file in
|
||||||
|
// Qt Designer, and then run 'go generate'.
|
||||||
|
//
|
||||||
|
//go:generate miqt-uic -InFile connectionDialog.ui -OutFile connectionDialog.go -Qt6
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConnectDialogUi struct {
|
||||||
|
ConnectDialog *qt.QDialog
|
||||||
|
gridLayout *qt.QGridLayout
|
||||||
|
buttonBox *qt.QDialogButtonBox
|
||||||
|
formWidget *qt.QWidget
|
||||||
|
formLayout *qt.QFormLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnectDialogUi creates all Qt widget classes for ConnectDialog.
|
||||||
|
func NewConnectDialogUi() *ConnectDialogUi {
|
||||||
|
ui := &ConnectDialogUi{}
|
||||||
|
ui.ConnectDialog = qt.NewQDialog(nil)
|
||||||
|
ConnectDialog__objectName := qt.NewQAnyStringView3("ConnectDialog")
|
||||||
|
ui.ConnectDialog.SetObjectName(*ConnectDialog__objectName)
|
||||||
|
ConnectDialog__objectName.Delete() // setter copied value
|
||||||
|
ui.ConnectDialog.Resize(385, 364)
|
||||||
|
icon0 := qt.NewQIcon()
|
||||||
|
icon0.AddFile4(":/assets/database_add.png", qt.NewQSize(), qt.QIcon__Normal, qt.QIcon__Off)
|
||||||
|
ui.ConnectDialog.SetWindowIcon(icon0)
|
||||||
|
ui.gridLayout = qt.NewQGridLayout(ui.ConnectDialog.QWidget)
|
||||||
|
gridLayout__objectName := qt.NewQAnyStringView3("gridLayout")
|
||||||
|
ui.gridLayout.SetObjectName(*gridLayout__objectName)
|
||||||
|
gridLayout__objectName.Delete() // setter copied value
|
||||||
|
ui.gridLayout.SetContentsMargins(11, 11, 11, 11)
|
||||||
|
ui.gridLayout.SetSpacing(6)
|
||||||
|
ui.buttonBox = qt.NewQDialogButtonBox(ui.ConnectDialog.QWidget)
|
||||||
|
buttonBox__objectName := qt.NewQAnyStringView3("buttonBox")
|
||||||
|
ui.buttonBox.SetObjectName(*buttonBox__objectName)
|
||||||
|
buttonBox__objectName.Delete() // setter copied value
|
||||||
|
ui.buttonBox.SetOrientation(qt.Horizontal)
|
||||||
|
ui.buttonBox.SetStandardButtons(qt.QDialogButtonBox__Cancel | qt.QDialogButtonBox__Ok)
|
||||||
|
|
||||||
|
ui.gridLayout.AddWidget2(ui.buttonBox.QWidget, 2, 0)
|
||||||
|
ui.formWidget = qt.NewQWidget(ui.ConnectDialog.QWidget)
|
||||||
|
formWidget__objectName := qt.NewQAnyStringView3("formWidget")
|
||||||
|
ui.formWidget.SetObjectName(*formWidget__objectName)
|
||||||
|
formWidget__objectName.Delete() // setter copied value
|
||||||
|
ui.formLayout = qt.NewQFormLayout(ui.formWidget)
|
||||||
|
formLayout__objectName := qt.NewQAnyStringView3("formLayout")
|
||||||
|
ui.formLayout.SetObjectName(*formLayout__objectName)
|
||||||
|
formLayout__objectName.Delete() // setter copied value
|
||||||
|
ui.formLayout.SetContentsMargins(0, 0, 0, 11)
|
||||||
|
ui.formLayout.SetSpacing(6)
|
||||||
|
|
||||||
|
ui.gridLayout.AddWidget2(ui.formWidget, 1, 0)
|
||||||
|
|
||||||
|
ui.buttonBox.OnAccepted(ui.ConnectDialog.Accept)
|
||||||
|
ui.buttonBox.OnRejected(ui.ConnectDialog.Reject)
|
||||||
|
|
||||||
|
ui.Retranslate()
|
||||||
|
|
||||||
|
return ui
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retranslate reapplies all text translations.
|
||||||
|
func (ui *ConnectDialogUi) Retranslate() {
|
||||||
|
ui.ConnectDialog.SetWindowTitle(qt.QCoreApplication_Tr("Connect..."))
|
||||||
|
}
|
||||||
85
connectionDialog.ui
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>ConnectDialog</class>
|
||||||
|
<widget class="QDialog" name="ConnectDialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>385</width>
|
||||||
|
<height>364</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Connect...</string>
|
||||||
|
</property>
|
||||||
|
<property name="windowIcon">
|
||||||
|
<iconset resource="embed.qrc">
|
||||||
|
<normaloff>:/assets/database_add.png</normaloff>:/assets/database_add.png</iconset>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
|
<item row="2" column="0">
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0">
|
||||||
|
<widget class="QWidget" name="formWidget" native="true">
|
||||||
|
<layout class="QFormLayout" name="formLayout">
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources>
|
||||||
|
<include location="embed.qrc"/>
|
||||||
|
</resources>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>ConnectDialog</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>ConnectDialog</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
||||||
136
connectionManagerDialog.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Generated by miqt-uic. To update this file, edit the .ui file in
|
||||||
|
// Qt Designer, and then run 'go generate'.
|
||||||
|
//
|
||||||
|
//go:generate miqt-uic -InFile connectionManagerDialog.ui -OutFile connectionManagerDialog.go -Qt6
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type connectionManagerDialogUi struct {
|
||||||
|
connectionManagerDialog *qt.QDialog
|
||||||
|
verticalLayout_2 *qt.QVBoxLayout
|
||||||
|
horizontalLayout *qt.QHBoxLayout
|
||||||
|
treeWidget *qt.QTreeWidget
|
||||||
|
verticalLayout *qt.QVBoxLayout
|
||||||
|
connAdd *qt.QToolButton
|
||||||
|
connEdit *qt.QToolButton
|
||||||
|
connDelete *qt.QToolButton
|
||||||
|
verticalSpacer *qt.QSpacerItem
|
||||||
|
horizontalLayout_2 *qt.QHBoxLayout
|
||||||
|
toolsBtn *qt.QToolButton
|
||||||
|
horizontalSpacer *qt.QSpacerItem
|
||||||
|
connectBtn *qt.QPushButton
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewconnectionManagerDialogUi creates all Qt widget classes for connectionManagerDialog.
|
||||||
|
func NewconnectionManagerDialogUi() *connectionManagerDialogUi {
|
||||||
|
ui := &connectionManagerDialogUi{}
|
||||||
|
ui.connectionManagerDialog = qt.NewQDialog(nil)
|
||||||
|
connectionManagerDialog__objectName := qt.NewQAnyStringView3("connectionManagerDialog")
|
||||||
|
ui.connectionManagerDialog.SetObjectName(*connectionManagerDialog__objectName)
|
||||||
|
connectionManagerDialog__objectName.Delete() // setter copied value
|
||||||
|
ui.connectionManagerDialog.Resize(332, 449)
|
||||||
|
ui.verticalLayout_2 = qt.NewQVBoxLayout(ui.connectionManagerDialog.QWidget)
|
||||||
|
verticalLayout_2__objectName := qt.NewQAnyStringView3("verticalLayout_2")
|
||||||
|
ui.verticalLayout_2.SetObjectName(*verticalLayout_2__objectName)
|
||||||
|
verticalLayout_2__objectName.Delete() // setter copied value
|
||||||
|
ui.verticalLayout_2.SetContentsMargins(11, 11, 11, 11)
|
||||||
|
ui.verticalLayout_2.SetSpacing(6)
|
||||||
|
ui.horizontalLayout = qt.NewQHBoxLayout2()
|
||||||
|
horizontalLayout__objectName := qt.NewQAnyStringView3("horizontalLayout")
|
||||||
|
ui.horizontalLayout.SetObjectName(*horizontalLayout__objectName)
|
||||||
|
horizontalLayout__objectName.Delete() // setter copied value
|
||||||
|
ui.horizontalLayout.SetContentsMargins(0, 0, 0, 0)
|
||||||
|
ui.horizontalLayout.SetSpacing(6)
|
||||||
|
ui.treeWidget = qt.NewQTreeWidget(ui.connectionManagerDialog.QWidget)
|
||||||
|
treeWidget__objectName := qt.NewQAnyStringView3("treeWidget")
|
||||||
|
ui.treeWidget.SetObjectName(*treeWidget__objectName)
|
||||||
|
treeWidget__objectName.Delete() // setter copied value
|
||||||
|
|
||||||
|
ui.horizontalLayout.AddWidget(ui.treeWidget.QWidget)
|
||||||
|
ui.verticalLayout = qt.NewQVBoxLayout2()
|
||||||
|
verticalLayout__objectName := qt.NewQAnyStringView3("verticalLayout")
|
||||||
|
ui.verticalLayout.SetObjectName(*verticalLayout__objectName)
|
||||||
|
verticalLayout__objectName.Delete() // setter copied value
|
||||||
|
ui.verticalLayout.SetContentsMargins(0, 0, 0, 0)
|
||||||
|
ui.verticalLayout.SetSpacing(6)
|
||||||
|
ui.connAdd = qt.NewQToolButton(ui.connectionManagerDialog.QWidget)
|
||||||
|
connAdd__objectName := qt.NewQAnyStringView3("connAdd")
|
||||||
|
ui.connAdd.SetObjectName(*connAdd__objectName)
|
||||||
|
connAdd__objectName.Delete() // setter copied value
|
||||||
|
icon0 := qt.NewQIcon()
|
||||||
|
icon0.AddFile4(":/assets/add.png", qt.NewQSize(), qt.QIcon__Normal, qt.QIcon__Off)
|
||||||
|
ui.connAdd.SetIcon(icon0)
|
||||||
|
ui.connAdd.SetAutoRaise(true)
|
||||||
|
|
||||||
|
ui.verticalLayout.AddWidget(ui.connAdd.QWidget)
|
||||||
|
ui.connEdit = qt.NewQToolButton(ui.connectionManagerDialog.QWidget)
|
||||||
|
connEdit__objectName := qt.NewQAnyStringView3("connEdit")
|
||||||
|
ui.connEdit.SetObjectName(*connEdit__objectName)
|
||||||
|
connEdit__objectName.Delete() // setter copied value
|
||||||
|
icon1 := qt.NewQIcon()
|
||||||
|
icon1.AddFile4(":/assets/pencil.png", qt.NewQSize(), qt.QIcon__Normal, qt.QIcon__Off)
|
||||||
|
ui.connEdit.SetIcon(icon1)
|
||||||
|
ui.connEdit.SetAutoRaise(true)
|
||||||
|
|
||||||
|
ui.verticalLayout.AddWidget(ui.connEdit.QWidget)
|
||||||
|
ui.connDelete = qt.NewQToolButton(ui.connectionManagerDialog.QWidget)
|
||||||
|
connDelete__objectName := qt.NewQAnyStringView3("connDelete")
|
||||||
|
ui.connDelete.SetObjectName(*connDelete__objectName)
|
||||||
|
connDelete__objectName.Delete() // setter copied value
|
||||||
|
icon2 := qt.NewQIcon()
|
||||||
|
icon2.AddFile4(":/assets/delete.png", qt.NewQSize(), qt.QIcon__Normal, qt.QIcon__Off)
|
||||||
|
ui.connDelete.SetIcon(icon2)
|
||||||
|
ui.connDelete.SetAutoRaise(true)
|
||||||
|
|
||||||
|
ui.verticalLayout.AddWidget(ui.connDelete.QWidget)
|
||||||
|
ui.verticalSpacer = qt.NewQSpacerItem4(20, 40, qt.QSizePolicy__Minimum, qt.QSizePolicy__Expanding)
|
||||||
|
|
||||||
|
ui.verticalLayout.AddItem(ui.verticalSpacer.QLayoutItem)
|
||||||
|
|
||||||
|
ui.horizontalLayout.AddLayout(ui.verticalLayout.QLayout)
|
||||||
|
|
||||||
|
ui.verticalLayout_2.AddLayout(ui.horizontalLayout.QLayout)
|
||||||
|
ui.horizontalLayout_2 = qt.NewQHBoxLayout2()
|
||||||
|
horizontalLayout_2__objectName := qt.NewQAnyStringView3("horizontalLayout_2")
|
||||||
|
ui.horizontalLayout_2.SetObjectName(*horizontalLayout_2__objectName)
|
||||||
|
horizontalLayout_2__objectName.Delete() // setter copied value
|
||||||
|
ui.horizontalLayout_2.SetContentsMargins(0, 0, 0, 0)
|
||||||
|
ui.horizontalLayout_2.SetSpacing(6)
|
||||||
|
ui.toolsBtn = qt.NewQToolButton(ui.connectionManagerDialog.QWidget)
|
||||||
|
toolsBtn__objectName := qt.NewQAnyStringView3("toolsBtn")
|
||||||
|
ui.toolsBtn.SetObjectName(*toolsBtn__objectName)
|
||||||
|
toolsBtn__objectName.Delete() // setter copied value
|
||||||
|
ui.toolsBtn.SetAutoRaise(true)
|
||||||
|
|
||||||
|
ui.horizontalLayout_2.AddWidget(ui.toolsBtn.QWidget)
|
||||||
|
ui.horizontalSpacer = qt.NewQSpacerItem4(40, 20, qt.QSizePolicy__Expanding, qt.QSizePolicy__Minimum)
|
||||||
|
|
||||||
|
ui.horizontalLayout_2.AddItem(ui.horizontalSpacer.QLayoutItem)
|
||||||
|
ui.connectBtn = qt.NewQPushButton(ui.connectionManagerDialog.QWidget)
|
||||||
|
connectBtn__objectName := qt.NewQAnyStringView3("connectBtn")
|
||||||
|
ui.connectBtn.SetObjectName(*connectBtn__objectName)
|
||||||
|
connectBtn__objectName.Delete() // setter copied value
|
||||||
|
icon3 := qt.NewQIcon()
|
||||||
|
icon3.AddFile4(":/assets/database_lightning.png", qt.NewQSize(), qt.QIcon__Normal, qt.QIcon__Off)
|
||||||
|
ui.connectBtn.SetIcon(icon3)
|
||||||
|
|
||||||
|
ui.horizontalLayout_2.AddWidget(ui.connectBtn.QWidget)
|
||||||
|
|
||||||
|
ui.verticalLayout_2.AddLayout(ui.horizontalLayout_2.QLayout)
|
||||||
|
|
||||||
|
ui.Retranslate()
|
||||||
|
|
||||||
|
return ui
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retranslate reapplies all text translations.
|
||||||
|
func (ui *connectionManagerDialogUi) Retranslate() {
|
||||||
|
ui.connectionManagerDialog.SetWindowTitle(qt.QCoreApplication_Tr("Connection Manager"))
|
||||||
|
ui.treeWidget.HeaderItem().SetText(0, qt.QTreeWidget_Tr("Connection"))
|
||||||
|
ui.toolsBtn.SetText(qt.QDialog_Tr("Tools"))
|
||||||
|
ui.connectBtn.SetText(qt.QDialog_Tr("Connect"))
|
||||||
|
}
|
||||||
124
connectionManagerDialog.ui
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>connectionManagerDialog</class>
|
||||||
|
<widget class="QDialog" name="connectionManagerDialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>332</width>
|
||||||
|
<height>449</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Connection Manager</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeWidget" name="treeWidget">
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>Connection</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="connAdd">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="embed.qrc">
|
||||||
|
<normaloff>:/assets/add.png</normaloff>:/assets/add.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="autoRaise">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="connEdit">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="embed.qrc">
|
||||||
|
<normaloff>:/assets/pencil.png</normaloff>:/assets/pencil.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="autoRaise">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="connDelete">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="embed.qrc">
|
||||||
|
<normaloff>:/assets/delete.png</normaloff>:/assets/delete.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="autoRaise">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="toolsBtn">
|
||||||
|
<property name="text">
|
||||||
|
<string>Tools</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoRaise">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="connectBtn">
|
||||||
|
<property name="text">
|
||||||
|
<string>Connect</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="embed.qrc">
|
||||||
|
<normaloff>:/assets/database_lightning.png</normaloff>:/assets/database_lightning.png</iconset>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources>
|
||||||
|
<include location="embed.qrc"/>
|
||||||
|
</resources>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
253
db_badger.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type badgerLoadedDatabase struct {
|
||||||
|
db *badger.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) DriverName() string {
|
||||||
|
return "BadgerDB v4"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
content := fmt.Sprintf("Table statistics: %#v", ld.db.Tables())
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// Badger always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
err := ld.db.View(func(txn *badger.Txn) error {
|
||||||
|
|
||||||
|
// Create iterator
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchSize = 64
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
item := it.Item()
|
||||||
|
k := item.Key()
|
||||||
|
err := item.Value(func(v []byte) error {
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *badgerLoadedDatabase) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return n.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return ApplyChanges_binColumn(f, txn.Set, txn.Delete)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return []contextAction{
|
||||||
|
{Name: "Export backup...", Callback: ld.ExportBackup},
|
||||||
|
{Name: "Import backup...", Callback: ld.ImportBackup},
|
||||||
|
{Name: "Compact", Callback: ld.CompactGC},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) ExportBackup(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
saveAs := qt.QFileDialog_GetSaveFileName4(sender.TreeWidget().QWidget, "Export backup...", "", "Badger database backups (*.bak);;All files (*)")
|
||||||
|
if saveAs == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fh, err := os.OpenFile(saveAs, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
_, err = ld.db.Backup(fh, 0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) ImportBackup(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
openPath := qt.QFileDialog_GetOpenFileName4(sender.TreeWidget().QWidget, "Import backup...", "", "Badger database backups (*.bak);;All files (*)")
|
||||||
|
if openPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fh, err := os.Open(openPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
return ld.db.Load(fh, 16) // concurrency - just a guess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) CompactGC(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
// Move data from value log into levels ->
|
||||||
|
err := ld.db.RunValueLogGC(0.0001) // Compact if we would save 0.1% of disk space
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RunValueLogGC: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> then flatten all levels
|
||||||
|
err = ld.db.Flatten(4)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Flatten: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *badgerLoadedDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &badgerLoadedDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &badgerLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type BadgerResettableOptions badger.Options
|
||||||
|
|
||||||
|
func (bao *BadgerResettableOptions) Reset() {
|
||||||
|
if bao == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := badger.DefaultOptions("")
|
||||||
|
opts.Logger = nil // Have to wipe this out otherwise we can't JSON marshal our struct
|
||||||
|
|
||||||
|
*bao = BadgerResettableOptions(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
type badgerConnection struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
Disk *struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
Readonly bool
|
||||||
|
Encryption *encryptionKey
|
||||||
|
} `json:",omitempty"`
|
||||||
|
Memory *struct{} `json:",omitempty"`
|
||||||
|
Advanced *struct {
|
||||||
|
*BadgerResettableOptions `ylabel:"Options"`
|
||||||
|
} `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptionKey struct {
|
||||||
|
Method autoconfig.EnumList `yenum:"Text;;Hex;;Passphrase (SHA256 KDF to AES-256)"`
|
||||||
|
Key autoconfig.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e encryptionKey) Get() ([]byte, error) {
|
||||||
|
switch e.Method {
|
||||||
|
case 0: // Text
|
||||||
|
return []byte(e.Key), nil
|
||||||
|
|
||||||
|
case 1: // Hex
|
||||||
|
// For Badger, the input must be 16/24/32 bytes for AES-128/192/256
|
||||||
|
// The library checks this, we don't need to
|
||||||
|
return hex.DecodeString(string(e.Key))
|
||||||
|
|
||||||
|
case 2: // Passphrase (SHA256 KDF to AES-256)
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(e.Key))
|
||||||
|
return hasher.Sum(nil), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Unsupported encoding method for encryption key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bdc *badgerConnection) String() string {
|
||||||
|
return filepath.Base(bdc.DetailedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bdc *badgerConnection) DetailedString() string {
|
||||||
|
if bdc.Type == "Disk" {
|
||||||
|
return string(bdc.Disk.Directory)
|
||||||
|
} else if bdc.Type == "Memory" {
|
||||||
|
return `:memory:` // SQLite-style naming
|
||||||
|
} else if bdc.Type == "Advanced" {
|
||||||
|
return bdc.Advanced.BadgerResettableOptions.Dir
|
||||||
|
}
|
||||||
|
return "" // unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &badgerConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (bdc *badgerConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
var opts badger.Options
|
||||||
|
|
||||||
|
// Basic options
|
||||||
|
|
||||||
|
if bdc.Type == "Disk" {
|
||||||
|
opts = badger.DefaultOptions(string(bdc.Disk.Directory))
|
||||||
|
opts.ReadOnly = bdc.Disk.Readonly
|
||||||
|
opts.MetricsEnabled = false
|
||||||
|
|
||||||
|
if bdc.Disk.Encryption != nil {
|
||||||
|
ehx, err := bdc.Disk.Encryption.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Loading encryption key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(len(ehx) == 16 || len(ehx) == 24 || len(ehx) == 32) {
|
||||||
|
return nil, fmt.Errorf("Encryption key must be 16/24/32 bytes long, got %d", len(ehx))
|
||||||
|
}
|
||||||
|
opts.EncryptionKey = ehx
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if bdc.Type == "Memory" {
|
||||||
|
opts = badger.DefaultOptions("").WithInMemory(true)
|
||||||
|
|
||||||
|
} else if bdc.Type == "Advanced" {
|
||||||
|
if bdc.Advanced.BadgerResettableOptions == nil {
|
||||||
|
return nil, errors.New("Options not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = badger.Options(*bdc.Advanced.BadgerResettableOptions)
|
||||||
|
|
||||||
|
// Reinstate default logger that we wiped out
|
||||||
|
tmpOpts := badger.DefaultOptions("")
|
||||||
|
opts.Logger = tmpOpts.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := badger.Open(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &badgerLoadedDatabase{db: db}, nil
|
||||||
|
}
|
||||||
119
db_bitcask.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"go.mills.io/bitcask/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bitcaskLdb struct {
|
||||||
|
db *bitcask.Bitcask
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) DriverName() string {
|
||||||
|
return "Bitcask"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
stats, err := ld.db.Stats()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Database: %s\n\nStats:\n%#v\n", ld.db.Path(), stats), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
cur := ld.db.Iterator()
|
||||||
|
defer cur.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
itm, err := cur.Next()
|
||||||
|
if err != nil {
|
||||||
|
if err == bitcask.ErrStopIteration {
|
||||||
|
break // OK
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data([]byte(itm.Key()), []byte(itm.Key()), []byte(itm.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *bitcaskLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
func(k, v []byte) error { return n.db.Put(bitcask.Key(k), bitcask.Value(v)) },
|
||||||
|
func(k []byte) error { return n.db.Delete(bitcask.Key(k)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) actionBackup(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
backupDir := qt.QFileDialog_GetExistingDirectory3(sender.TreeWidget().QWidget, APPNAME, "Select an output directory to backup to...")
|
||||||
|
if backupDir == "" {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.db.Backup(backupDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return []contextAction{
|
||||||
|
{"Backup...", ld.actionBackup},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *bitcaskLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &bitcaskLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &bitcaskLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type bitcaskDBConnection struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
Readonly bool
|
||||||
|
AutoRecovery bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *bitcaskDBConnection) Reset() {
|
||||||
|
c.AutoRecovery = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *bitcaskDBConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
db, err := bitcask.Open(
|
||||||
|
string(c.Directory),
|
||||||
|
bitcask.WithOpenReadonly(c.Readonly),
|
||||||
|
bitcask.WithAutoRecovery(c.AutoRecovery),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bitcaskLdb{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *bitcaskDBConnection) String() string {
|
||||||
|
return filepath.Base(string(c.Directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &bitcaskDBConnection{} // interface assertion
|
||||||
363
db_bolt.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"go.etcd.io/bbolt/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
type boltLoadedDatabase struct {
|
||||||
|
path string
|
||||||
|
db *bbolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type boltAdvancedOptions struct {
|
||||||
|
bbolt.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bao *boltAdvancedOptions) Reset() {
|
||||||
|
bao.Options = *bbolt.DefaultOptions
|
||||||
|
// Interfaces not JSON marshallable
|
||||||
|
bao.OpenFile = nil
|
||||||
|
bao.Logger = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bao *boltAdvancedOptions) String() string {
|
||||||
|
return "Configured" // Override bbolt.Options.String() for autoconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type boltConfig struct {
|
||||||
|
Path autoconfig.ExistingFile `yfilter:"Bolt database (*.db);;All files (*)"`
|
||||||
|
Readonly bool
|
||||||
|
AdvancedOptions *boltAdvancedOptions `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &boltConfig{} // interface assertion
|
||||||
|
|
||||||
|
func (bc *boltConfig) String() string {
|
||||||
|
ret := filepath.Base(string(bc.Path))
|
||||||
|
if bc.Readonly {
|
||||||
|
ret += " (read-only)"
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *boltConfig) DetailedString() string {
|
||||||
|
return string(bc.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *boltConfig) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
opts := bbolt.Options{
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
ReadOnly: bc.Readonly,
|
||||||
|
}
|
||||||
|
if bc.AdvancedOptions != nil {
|
||||||
|
// Use them instead
|
||||||
|
// Q? fixup OpenFile/Logger?
|
||||||
|
opts = bc.AdvancedOptions.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := bbolt.Open(string(bc.Path), 0644, &opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to load database '%s': %w", bc.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ld := &boltLoadedDatabase{
|
||||||
|
path: string(bc.Path),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) DriverName() string {
|
||||||
|
return "Bolt " + version.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
content := fmt.Sprintf("Selected database: %#v", ld.db.Stats())
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load properties
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return nil // Can't have data outside of the top bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// Bolt always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
err := ld.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
b := boltTargetBucket(tx, bucketPath)
|
||||||
|
if b == nil {
|
||||||
|
// no such bucket
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
|
||||||
|
// Nil values mean it's a bucket
|
||||||
|
// Hide from the data table, it will be shown in the nav instead
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyChanges_binColumn is a helper function to apply edits to K/V stores that
|
||||||
|
// can use a common abstraction.
|
||||||
|
// It always uses the "binColumn" type i.e. []byte.
|
||||||
|
func ApplyChanges_binColumn(f *tableState, Put func(k, v []byte) error, Delete func(k []byte) error) error {
|
||||||
|
|
||||||
|
// Columns are two binColumn
|
||||||
|
keyCol := f.columns[0].(*binColumn)
|
||||||
|
valCol := f.columns[1].(*binColumn)
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
for rowid, _ /*editcells*/ := range f.updateRows {
|
||||||
|
k_orig := f.primaryKeys[rowid]
|
||||||
|
k_new := keyCol.vals[rowid]
|
||||||
|
v := valCol.vals[rowid]
|
||||||
|
|
||||||
|
if !bytes.Equal(k_orig, k_new) {
|
||||||
|
// Editing the primary key
|
||||||
|
// Delete k_orig and only put in k_new
|
||||||
|
err := Delete(k_orig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Deleting cell %q: %w", formatUtf8(k_orig), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Put(k_new, []byte(v))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Updating cell %q: %w", formatUtf8(k_new), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete by key (affects rowids after re-render)
|
||||||
|
for rowid, _ := range f.deleteRows {
|
||||||
|
k := f.primaryKeys[rowid]
|
||||||
|
err := Delete(k)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Deleting cell %q: %w", formatUtf8(k), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert all new entries
|
||||||
|
for rowid, _ := range f.insertRows {
|
||||||
|
// Newly inserted rows will not have a valid value stashed in the
|
||||||
|
// f.primaryKeys slice
|
||||||
|
// Have to get it from the data content of the cell directly
|
||||||
|
|
||||||
|
k := keyCol.vals[rowid]
|
||||||
|
v := valCol.vals[rowid] // There's only one value cell
|
||||||
|
err := Put(k, v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Inserting cell %q: %w", formatUtf8(k), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *boltLoadedDatabase) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
if n.db.IsReadOnly() {
|
||||||
|
return errors.New("Database was opened read-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
return n.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
|
||||||
|
// Get current bucket handle
|
||||||
|
b := boltTargetBucket(tx, bucketPath)
|
||||||
|
|
||||||
|
return ApplyChanges_binColumn(f, b.Put, b.Delete)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
// In the bolt implementation, the nav is a recursive tree of child buckets
|
||||||
|
return boltChildBucketNames(ld.db, bucketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
ret := []contextAction{
|
||||||
|
{"Add bucket...", ld.AddChildBucket},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bucketPath) > 0 {
|
||||||
|
ret = append(ret, contextAction{"Delete bucket", ld.DeleteBucket})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
ret = append(ret, contextAction{"Export to .zip...", ld.exportToZip})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) exportToZip(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
// Popup for output file
|
||||||
|
savePath := qt.QFileDialog_GetSaveFileName4(sender.TreeWidget().QWidget, "Save backup as...", "", "Zip archive (*.zip);;All files (*)")
|
||||||
|
if savePath == "" {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.exportDatabaseToZip(savePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) AddChildBucket(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
bucketName := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new bucket:")
|
||||||
|
if bucketName == "" {
|
||||||
|
return nil // cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ld.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
parent := boltTargetBucket(tx, bucketPath)
|
||||||
|
if parent != nil {
|
||||||
|
_, err := parent.CreateBucket([]byte(bucketName))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level
|
||||||
|
_, err := tx.CreateBucket([]byte(bucketName))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error adding bucket: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) DeleteBucket(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to delete the bucket %q?", bucketPath[0])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ld.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
// Find parent of this bucket.
|
||||||
|
if len(bucketPath) >= 2 {
|
||||||
|
// child bucket
|
||||||
|
parent := boltTargetBucket(tx, bucketPath[0:len(bucketPath)-1])
|
||||||
|
return parent.DeleteBucket([]byte(bucketPath[len(bucketPath)-1]))
|
||||||
|
} else {
|
||||||
|
// top-level bucket
|
||||||
|
return tx.DeleteBucket([]byte(bucketPath[0]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error deleting bucket %q: %w", strings.Join(bucketPath, `/`), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &boltLoadedDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &boltLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// boltTargetBucket resolves the bucketPath to a specific bolt.Bucket.
|
||||||
|
// If the path is empty, this function returns <nil> and you must manually access
|
||||||
|
// the root bucket.
|
||||||
|
// If the bucket does not exist, this function returns <nil>. It does not create
|
||||||
|
// the bucket.
|
||||||
|
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("Root bucket %q: %w", path[0], ErrNavNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < len(path); i += 1 {
|
||||||
|
b = b.Bucket([]byte(path[i]))
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("Bucket %q: %w", strings.Join(path[0:i], `/`), ErrNavNotExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
230
db_bolt_zip.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ld *boltLoadedDatabase) exportDatabaseToZip(zippath string) error {
|
||||||
|
|
||||||
|
fh, err := os.OpenFile(zippath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error opening output file: %w", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(fh)
|
||||||
|
|
||||||
|
// Filenames in zip files cannot contain `/` characters. Mangle it
|
||||||
|
// TODO undo this transfomation on import(!)
|
||||||
|
safename := func(n string) string {
|
||||||
|
return strings.ReplaceAll(string(n), `/`, `__`)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ld.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
|
||||||
|
var process func(currentPath []string) error
|
||||||
|
process = func(currentPath []string) error {
|
||||||
|
|
||||||
|
// Create folder-entry for our own bucket
|
||||||
|
|
||||||
|
ourFolderName := path.Join(slice_apply(currentPath, safename)...)
|
||||||
|
|
||||||
|
ourBucket := zip.FileHeader{
|
||||||
|
Name: ourFolderName + `/`, // Trailing slash = directory
|
||||||
|
}
|
||||||
|
ourBucket.SetMode(fs.ModeDir | 0755)
|
||||||
|
_, err := zw.CreateHeader(&ourBucket)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file entries for all non-bucket children
|
||||||
|
|
||||||
|
b := boltTargetBucket(tx, currentPath)
|
||||||
|
var childBuckets []string
|
||||||
|
|
||||||
|
var c *bbolt.Cursor
|
||||||
|
if b != nil {
|
||||||
|
c = b.Cursor() // in bucket
|
||||||
|
} else {
|
||||||
|
c = tx.Cursor()
|
||||||
|
}
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
if v == nil {
|
||||||
|
// That's a bucket
|
||||||
|
childBuckets = append(childBuckets, string(k))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileItem := zip.FileHeader{
|
||||||
|
Name: path.Join(ourFolderName, safename(string(k))),
|
||||||
|
}
|
||||||
|
fileItem.SetMode(0644)
|
||||||
|
fileW, err := zw.CreateHeader(&fileItem)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.CopyN(fileW, bytes.NewReader(v), int64(len(v)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse for all bucket-type children
|
||||||
|
|
||||||
|
for _, childBucketName := range childBuckets {
|
||||||
|
process(slice_and(currentPath, childBucketName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return process([]string{})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = zw.Flush()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = zw.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fh.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *App) Bolt_ImportZipToDatabase_OnTriggered() {
|
||||||
|
zippath := qt.QFileDialog_GetOpenFileName4(f.ui.MainWindow.QWidget, "Select a zip archive to import...", "", "Zip archives (*.zip);;All files (*)")
|
||||||
|
if zippath == "" {
|
||||||
|
return // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
dbpath := qt.QFileDialog_GetSaveFileName4(f.ui.MainWindow.QWidget, "Select an output file to save as...", "", "Bolt database (*.db);;All files (*)")
|
||||||
|
if dbpath == "" {
|
||||||
|
return // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Bolt_ImportZipToDatabase(dbpath, zippath)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(f.ui.MainWindow.QWidget, APPNAME, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := qt.QMessageBox_Question2(f.ui.MainWindow.QWidget, APPNAME, "The import was successful. Would you like to open the Bolt database now?", qt.QMessageBox__Yes, qt.QMessageBox__No)
|
||||||
|
if res != int(qt.QMessageBox__Yes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config := NewConnectionConfig()
|
||||||
|
config.Type = "Bolt"
|
||||||
|
config.Bolt = &boltConfig{
|
||||||
|
Path: autoconfig.ExistingFile(dbpath),
|
||||||
|
Readonly: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
f.showConnectDialog(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bolt_ImportZipToDatabase(dbpath, zippath string) error {
|
||||||
|
|
||||||
|
db, err := bbolt.Open(dbpath, 0644, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Opening target database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
fh, err := os.OpenFile(zippath, os.O_RDONLY, 0400)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Opening input archive: %w", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
fstat, err := fh.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(fh, fstat.Size())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Reading zip file format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if strings.HasSuffix(zf.Name, `/`) || (zf.Mode()&fs.ModeDir) != 0 {
|
||||||
|
// Bucket
|
||||||
|
|
||||||
|
if zf.Name == `/` {
|
||||||
|
continue // virtual entry for top-level directory, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketPath := strings.Split(strings.TrimSuffix(zf.Name, `/`), `/`)
|
||||||
|
|
||||||
|
parentBucket := boltTargetBucket(tx, bucketPath[0:len(bucketPath)-1])
|
||||||
|
newBucketName := []byte(bucketPath[len(bucketPath)-1])
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if parentBucket == nil {
|
||||||
|
_, err = tx.CreateBucket(newBucketName) // at top level
|
||||||
|
} else {
|
||||||
|
_, err = parentBucket.CreateBucket(newBucketName) // child bucket
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Creating bucket %q: %w", zf.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Object
|
||||||
|
objectPath := strings.Split(zf.Name, `/`)
|
||||||
|
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentBucket := boltTargetBucket(tx, objectPath[0:len(objectPath)-1]) // Can't be nil, items always exist within a bucket
|
||||||
|
objectKey := []byte(objectPath[len(objectPath)-1])
|
||||||
|
|
||||||
|
err = parentBucket.Put(objectKey, content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
156
db_bunt.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"github.com/tidwall/buntdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type buntLdb struct {
|
||||||
|
db *buntdb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) DriverName() string {
|
||||||
|
return "BuntDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
idxInfo, err := ld.db.Indexes()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Indexes (%d):\n%s", len(idxInfo), "-"+strings.Join(idxInfo, "\n- ")+"\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// Interestingly, BuntDB internally uses string data, not []byte data
|
||||||
|
// That would stop us from using ApplyChanges_binColumn that only works with
|
||||||
|
// []byte data, so, fake []byte casts ourselves
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
err := ld.db.View(func(tx *buntdb.Tx) error {
|
||||||
|
return tx.Ascend("", func(k, v string) bool {
|
||||||
|
f.AddRow_PK_Data([]byte(k), []byte(k), []byte(v))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *buntLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
return n.db.Update(func(tx *buntdb.Tx) error {
|
||||||
|
return ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
func(k, v []byte) error {
|
||||||
|
_, _, err := tx.Set(string(k), string(v), nil)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
func(k []byte) error {
|
||||||
|
_, err := tx.Delete(string(k))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) doShrink(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
return ld.db.Shrink()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return []contextAction{
|
||||||
|
{"Shrink", ld.doShrink},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *buntLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &buntLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &buntLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type BuntDBLocation struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
|
||||||
|
Disk *struct {
|
||||||
|
File autoconfig.ExistingFile
|
||||||
|
} `json:",omitempty"`
|
||||||
|
Memory *struct{} `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type buntDBAdvancedOptions struct {
|
||||||
|
buntdb.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bao *buntDBAdvancedOptions) Reset() {
|
||||||
|
// buntdb does not expose the default configuration, it's only set in the
|
||||||
|
// DB .Open() constructor
|
||||||
|
// Extract the defaults out from a temporary `:memory:` database
|
||||||
|
|
||||||
|
if memDb, err := buntdb.Open(`:memory:`); err == nil {
|
||||||
|
defer memDb.Close()
|
||||||
|
_ = memDb.ReadConfig(&bao.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type buntDBConnection struct {
|
||||||
|
BuntDBLocation
|
||||||
|
AdvancedOptions *buntDBAdvancedOptions `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &buntDBConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (ldc *buntDBConnection) String() string {
|
||||||
|
if ldc.Disk != nil {
|
||||||
|
return filepath.Base(string(ldc.Disk.File))
|
||||||
|
} else {
|
||||||
|
return `:memory:`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ldc *buntDBConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
var path string
|
||||||
|
if ldc.Disk != nil {
|
||||||
|
path = string(ldc.Disk.File)
|
||||||
|
} else {
|
||||||
|
path = `:memory:` // Special string known by driver
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := buntdb.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ldc.AdvancedOptions != nil {
|
||||||
|
err = db.SetConfig(ldc.AdvancedOptions.Config)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buntLdb{db: db}, nil
|
||||||
|
}
|
||||||
127
db_debconf.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"qbolt/debconf"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type debconfLoadedDatabase struct {
|
||||||
|
db *debconf.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) DriverName() string {
|
||||||
|
return "debconf"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
content := fmt.Sprintf("Applications: %d\nUnique attributes: %d\n", len(ld.db.Entries), len(ld.db.AllColumnNames))
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return nil // No data at root level
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find application entry for bucketPath
|
||||||
|
|
||||||
|
appInfo, ok := ld.db.FindApplicationByName(bucketPath[0])
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid application %q", bucketPath[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process entries for specific application
|
||||||
|
|
||||||
|
indexes := make(map[string]int)
|
||||||
|
|
||||||
|
cols := make([]TableColumn, 0, len(ld.db.AllColumnNames))
|
||||||
|
for i := 0; i < len(ld.db.AllColumnNames); i++ {
|
||||||
|
cols = append(cols, &stringColumn{})
|
||||||
|
}
|
||||||
|
f.SetupColumns(cols, ld.db.AllColumnNames)
|
||||||
|
|
||||||
|
for i, cname := range ld.db.AllColumnNames {
|
||||||
|
indexes[cname] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range appInfo.Entries {
|
||||||
|
|
||||||
|
rpos := f.AddRow()
|
||||||
|
f.SetCell(rpos, 0, entry.Name)
|
||||||
|
|
||||||
|
for _, proppair := range entry.Properties {
|
||||||
|
f.SetCell(rpos, indexes[proppair[0]], proppair[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
ret := make([]string, 0, len(ld.db.Entries))
|
||||||
|
for _, app := range ld.db.Entries {
|
||||||
|
ret = append(ret, app.Name)
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return []string{}, nil // No further children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) Close() {
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &debconfLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type debconfConnection struct {
|
||||||
|
Database autoconfig.ExistingFile `yfilter:"Debconf database (*.dat);;All files (*)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &debconfConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (dc *debconfConnection) Reset() {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
dc.Database = "/var/cache/debconf/config.dat" // Prefill default path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dc *debconfConnection) String() string {
|
||||||
|
return filepath.Base(string(dc.Database))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dc *debconfConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
fh, err := os.OpenFile(string(dc.Database), os.O_RDONLY, 0400)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
db, err := debconf.Parse(fh)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &debconfLoadedDatabase{db: db}, nil
|
||||||
|
}
|
||||||
67
db_embeddedversions.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
type evLdb struct {
|
||||||
|
mods *debug.BuildInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) DriverName() string {
|
||||||
|
return APPNAME + " " + appVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s %s\n- %d package dependencies\n- Compiler: %s",
|
||||||
|
APPNAME, appVersion, len(ld.mods.Deps), ld.mods.GoVersion,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&goModuleColumn{}, &stringColumn{}, &stringColumn{}}, []string{"Library", "Version", "Hash"})
|
||||||
|
|
||||||
|
for _, dep := range ld.mods.Deps {
|
||||||
|
f.AddRowData(dep.Path, dep.Version, dep.Sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No further children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *evLdb) Close() {}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &evLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type evLdbConnection struct{}
|
||||||
|
|
||||||
|
var _ DBConnector = &evLdbConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (dc *evLdbConnection) String() string {
|
||||||
|
return APPNAME
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dc *evLdbConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
mods, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("Missing build info")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &evLdb{mods: mods}, nil
|
||||||
|
}
|
||||||
319
db_etcd.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
etcd2 "go.etcd.io/etcd/client/v2"
|
||||||
|
etcd3 "go.etcd.io/etcd/client/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////
|
||||||
|
// Client v3
|
||||||
|
///////////////////////////
|
||||||
|
|
||||||
|
type etcd3Ldb struct {
|
||||||
|
cl *etcd3.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) DriverName() string {
|
||||||
|
return "etcd v3"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil // No properties
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
ctx := context.TODO()
|
||||||
|
resp, err := ld.cl.Get(ctx, "", etcd3.WithPrefix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kv := range resp.Kvs {
|
||||||
|
// Strange - reading data, you get it as a []byte, but setting it, you use strings
|
||||||
|
f.AddRow_PK_Data(kv.Key, kv.Key, kv.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *etcd3Ldb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
// TODO use a transaction
|
||||||
|
|
||||||
|
put := func(k, v []byte) error {
|
||||||
|
_, err := n.cl.Put(ctx, string(k), string(v))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
del := func(k []byte) error {
|
||||||
|
_, err := n.cl.Delete(ctx, string(k))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyChanges_binColumn(f, put, del)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
// TODO: auth, members, ...
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // no actions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd3Ldb) Close() {
|
||||||
|
_ = ld.cl.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &etcd3Ldb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &etcd3Ldb{} // interface assertion
|
||||||
|
|
||||||
|
///////////////////////////
|
||||||
|
// Client v2
|
||||||
|
///////////////////////////
|
||||||
|
|
||||||
|
type etcd2Ldb struct {
|
||||||
|
cl etcd2.Client // interface, by value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) DriverName() string {
|
||||||
|
return "etcd v2"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil // No properties
|
||||||
|
}
|
||||||
|
|
||||||
|
// etcd2_dirPath always has a leading + trailing `/` character.
|
||||||
|
func etcd2_dirPath(bucketPath []string) string {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return `/`
|
||||||
|
}
|
||||||
|
return `/` + strings.Join(bucketPath, `/`) + `/`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// Etcd v2 always uses string types, not []byte types
|
||||||
|
// However we use []byte columns anyway to get access to ApplyChanges_binColumn
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
// In etcd v2, the bucketPath should always turn into something with a final /
|
||||||
|
dirPath := etcd2_dirPath(bucketPath)
|
||||||
|
|
||||||
|
ctx := context.TODO()
|
||||||
|
resp, err := etcd2.NewKeysAPI(ld.cl).Get(ctx, dirPath, nil) // TODO any way to request only non-directories here?
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Node.Dir {
|
||||||
|
return fmt.Errorf("Path %q expected directory, got data", dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range resp.Node.Nodes {
|
||||||
|
if child.Dir {
|
||||||
|
continue // Only looking for data here, not directories
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(child.Key, dirPath) {
|
||||||
|
return fmt.Errorf("Requested contents of %q but entry %q is missing expected prefix", dirPath, child.Key)
|
||||||
|
}
|
||||||
|
realKey := child.Key[len(dirPath):]
|
||||||
|
|
||||||
|
f.AddRow_PK_Data([]byte(realKey), []byte(realKey), []byte(child.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *etcd2Ldb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
ctx := context.TODO()
|
||||||
|
ka := etcd2.NewKeysAPI(n.cl)
|
||||||
|
|
||||||
|
dirPath := etcd2_dirPath(bucketPath) // Has trailing slash
|
||||||
|
|
||||||
|
// TODO use a transaction
|
||||||
|
|
||||||
|
put := func(k, v []byte) error {
|
||||||
|
_, err := ka.Set(ctx, dirPath+string(k), string(v), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
del := func(k []byte) error {
|
||||||
|
_, err := ka.Delete(ctx, dirPath+string(k), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyChanges_binColumn(f, put, del)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
|
||||||
|
// In etcd v2, the bucketPath should always turn into something with a final /
|
||||||
|
dirPath := etcd2_dirPath(bucketPath) // Has trailing slash
|
||||||
|
|
||||||
|
ctx := context.TODO()
|
||||||
|
resp, err := etcd2.NewKeysAPI(ld.cl).Get(ctx, dirPath, nil) // TODO any way to request only directories here?
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Node.Dir {
|
||||||
|
return nil, fmt.Errorf("Path %q expected directory, got data", dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret []string
|
||||||
|
|
||||||
|
for _, child := range resp.Node.Nodes {
|
||||||
|
if !child.Dir {
|
||||||
|
continue // Only looking for data here, not directories
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(child.Key, dirPath) {
|
||||||
|
return nil, fmt.Errorf("Requested contents of %q but entry %q is missing expected prefix", dirPath, child.Key)
|
||||||
|
}
|
||||||
|
realKey := child.Key[len(dirPath):]
|
||||||
|
|
||||||
|
ret = append(ret, realKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) createDirectory(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
dirName := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new directory:")
|
||||||
|
if dirName == "" {
|
||||||
|
return nil // cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.TODO()
|
||||||
|
ka := etcd2.NewKeysAPI(ld.cl)
|
||||||
|
|
||||||
|
newDirKey := etcd2_dirPath(bucketPath) + `/` + dirName
|
||||||
|
|
||||||
|
_, err := ka.Set(ctx, newDirKey, "", &etcd2.SetOptions{Dir: true})
|
||||||
|
return err
|
||||||
|
|
||||||
|
// TODO move the UI to select the newly created bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) deleteDirectory(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
|
||||||
|
dirKey := etcd2_dirPath(bucketPath)
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to delete the bucket %q and all its contents?", dirKey)) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.TODO()
|
||||||
|
ka := etcd2.NewKeysAPI(ld.cl)
|
||||||
|
|
||||||
|
_, err := ka.Delete(ctx, dirKey, &etcd2.DeleteOptions{Dir: true, Recursive: true})
|
||||||
|
return err
|
||||||
|
|
||||||
|
// TODO The app tries to refresh the non-existent bucket which results in an error
|
||||||
|
// Need a way to move the current selection up to the parent level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
ret := []contextAction{
|
||||||
|
contextAction{"Create directory...", ld.createDirectory},
|
||||||
|
}
|
||||||
|
if len(bucketPath) > 0 {
|
||||||
|
ret = append(ret, contextAction{"Delete directory...", ld.deleteDirectory})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *etcd2Ldb) Close() {
|
||||||
|
// v2 has nothing to close in the client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &etcd2Ldb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &etcd2Ldb{} // interface assertion
|
||||||
|
|
||||||
|
///////////////////////////
|
||||||
|
// Connection
|
||||||
|
///////////////////////////
|
||||||
|
|
||||||
|
type etcdConn struct {
|
||||||
|
Endpoints []string
|
||||||
|
Username string
|
||||||
|
Password autoconfig.Password
|
||||||
|
H1 autoconfig.Header `ylabel:"Protocol version:" json:",omitempty"`
|
||||||
|
Protocol struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
V3 *struct{} `ylabel:"v3" json:",omitempty"`
|
||||||
|
V2 *struct{} `ylabel:"v2" json:",omitempty"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &etcdConn{} // interface assertion
|
||||||
|
|
||||||
|
func (c *etcdConn) String() string {
|
||||||
|
if len(c.Endpoints) == 1 {
|
||||||
|
return c.Endpoints[0]
|
||||||
|
} else if len(c.Endpoints) > 1 {
|
||||||
|
return fmt.Sprintf("%d endpoints", c.Endpoints)
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *etcdConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
if c.Protocol.V3 != nil {
|
||||||
|
cfg := etcd3.Config{
|
||||||
|
Endpoints: c.Endpoints,
|
||||||
|
Username: c.Username,
|
||||||
|
Password: string(c.Password),
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
// TODO SSH tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := etcd3.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &etcd3Ldb{cl: cl}, nil
|
||||||
|
|
||||||
|
} else if c.Protocol.V2 != nil {
|
||||||
|
cfg := etcd2.Config{
|
||||||
|
Endpoints: c.Endpoints,
|
||||||
|
Username: c.Username,
|
||||||
|
Password: string(c.Password),
|
||||||
|
// TODO SSH tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := etcd2.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &etcd2Ldb{cl: cl}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("Unknown protocol version %q", c.Protocol.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
112
db_leveldb.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
"github.com/syndtr/goleveldb/leveldb"
|
||||||
|
"github.com/syndtr/goleveldb/leveldb/opt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type leveldbLoadedDatabase struct {
|
||||||
|
db *leveldb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) DriverName() string {
|
||||||
|
return "LevelDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
|
||||||
|
var s leveldb.DBStats
|
||||||
|
err := ld.db.Stats(&s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := fmt.Sprintf("LevelDB stats: %#v", s)
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// leveldb always uses Key + Value as the columns
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
itn := ld.db.NewIterator(nil, nil)
|
||||||
|
defer itn.Release()
|
||||||
|
|
||||||
|
for itn.Next() {
|
||||||
|
f.AddRow_PK_Data(itn.Key(), itn.Key(), itn.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *leveldbLoadedDatabase) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
txn, err := n.db.OpenTransaction()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OpenTransaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
func(k, v []byte) error { return txn.Put(k, v, nil) },
|
||||||
|
func(k []byte) error { return txn.Delete(k, nil) },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
txn.Discard()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return txn.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *leveldbLoadedDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &leveldbLoadedDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &leveldbLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type leveldbConnection struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
Readonly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &leveldbConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (pdc *leveldbConnection) String() string {
|
||||||
|
ret := filepath.Base(string(pdc.Directory))
|
||||||
|
if pdc.Readonly {
|
||||||
|
ret += " (read-only)"
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pdc *leveldbConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
var o opt.Options
|
||||||
|
o.ReadOnly = pdc.Readonly
|
||||||
|
|
||||||
|
db, err := leveldb.OpenFile(string(pdc.Directory), &o)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &leveldbLoadedDatabase{db: db}, nil
|
||||||
|
}
|
||||||
295
db_lmdb.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/ledgerwatch/lmdb-go/lmdb"
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lmdbDatabase struct {
|
||||||
|
db *lmdb.Env
|
||||||
|
isMulti bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) DriverName() string {
|
||||||
|
return lmdb.VersionString() // Already includes "LMDB" prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
info, err := ld.db.Info()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("LMDB info: %#v", info), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
|
||||||
|
if ld.isMulti && len(bucketPath) == 0 {
|
||||||
|
// In multi-database mode, the only things in the root DB are keys
|
||||||
|
// naming the child databases
|
||||||
|
// Show no data, it will be shown in the nav area instead
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMDB always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
err := ld.db.View(func(txn *lmdb.Txn) error {
|
||||||
|
var dbi lmdb.DBI
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
dbi, err = txn.OpenRoot(0)
|
||||||
|
} else {
|
||||||
|
dbi, err = txn.OpenDBI(bucketPath[0], 0)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// defer ld.db.CloseDBI(dbi)
|
||||||
|
|
||||||
|
itn, err := txn.OpenCursor(dbi)
|
||||||
|
defer itn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME is this correct?
|
||||||
|
kbuff := make([]byte, ld.db.MaxKeySize())
|
||||||
|
vbuff := make([]byte, 4096)
|
||||||
|
|
||||||
|
var op uint = lmdb.First
|
||||||
|
for {
|
||||||
|
key, val, err := itn.Get(kbuff, vbuff, op)
|
||||||
|
if err != nil {
|
||||||
|
if lmdb.IsNotFound(err) {
|
||||||
|
break // reached end of iteration
|
||||||
|
}
|
||||||
|
return err // a real error
|
||||||
|
}
|
||||||
|
|
||||||
|
op = lmdb.Next
|
||||||
|
f.AddRow_PK_Data(key, key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return ld.db.Update(func(txn *lmdb.Txn) error {
|
||||||
|
var dbi lmdb.DBI
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
dbi, err = txn.OpenRoot(0)
|
||||||
|
} else {
|
||||||
|
dbi, err = txn.OpenDBI(bucketPath[0], 0)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
func(k, v []byte) error { return txn.Put(dbi, k, v, 0) },
|
||||||
|
func(k []byte) error { return txn.Del(dbi, k, nil) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
// """To use named databases (with name != NULL), mdb_env_set_maxdbs() must
|
||||||
|
// be called before opening the environment. Database names are keys in the
|
||||||
|
// unnamed database, and may be read but not written."""
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 && ld.isMulti {
|
||||||
|
|
||||||
|
// Read all keys from root DB
|
||||||
|
var allKeys []string
|
||||||
|
err := ld.db.View(func(txn *lmdb.Txn) error {
|
||||||
|
dbi, err := txn.OpenRoot(0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
//defer ld.db.CloseDBI(dbi)
|
||||||
|
|
||||||
|
itn, err := txn.OpenCursor(dbi)
|
||||||
|
defer itn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kbuff := make([]byte, ld.db.MaxKeySize())
|
||||||
|
|
||||||
|
var op uint = lmdb.First
|
||||||
|
for {
|
||||||
|
key, _, err := itn.Get(kbuff, nil, op)
|
||||||
|
if err != nil {
|
||||||
|
if lmdb.IsNotFound(err) {
|
||||||
|
break // reached end of iteration
|
||||||
|
}
|
||||||
|
return err // a real error
|
||||||
|
}
|
||||||
|
|
||||||
|
op = lmdb.Next
|
||||||
|
|
||||||
|
allKeys = append(allKeys, string(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return allKeys, err
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) createChildDatabase(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
// Prompt for child database name
|
||||||
|
ok := false
|
||||||
|
childDbName := qt.QInputDialog_GetText4(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the child database:", qt.QLineEdit__Normal, "", &ok)
|
||||||
|
if !ok {
|
||||||
|
return nil // Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.db.Update(func(txn *lmdb.Txn) error {
|
||||||
|
_, err := txn.CreateDBI(childDbName)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) truncateAllContent(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to empty the child database %q?", bucketPath[0])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.drop(bucketPath[0], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) deleteChildDatabase(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to delete the child database %q?", bucketPath[0])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.drop(bucketPath[0], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) drop(multiDbName string, delet bool) error {
|
||||||
|
return ld.db.Update(func(txn *lmdb.Txn) error {
|
||||||
|
dbi, err := txn.OpenDBI(multiDbName, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return txn.Drop(dbi, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
|
||||||
|
if ld.isMulti {
|
||||||
|
// Allow create/delete databases
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return []contextAction{
|
||||||
|
contextAction{"Add child database...", ld.createChildDatabase},
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
return []contextAction{
|
||||||
|
contextAction{"Truncate and remove all contents...", ld.truncateAllContent},
|
||||||
|
contextAction{"Remove child database...", ld.deleteChildDatabase},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lmdbDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &lmdbDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &lmdbDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type lmdbConnection struct {
|
||||||
|
Storage struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
Directory *autoconfig.ExistingDirectory `json:",omitempty"`
|
||||||
|
File *struct {
|
||||||
|
Path autoconfig.ExistingFile `yfilter:"LMDB database (*.mdb);;All files (*)"`
|
||||||
|
} `json:",omitempty"`
|
||||||
|
}
|
||||||
|
MultiDB *struct {
|
||||||
|
Slots int
|
||||||
|
} `ylabel:"Multiple databases mode"`
|
||||||
|
Readonly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &lmdbConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (pdc *lmdbConnection) String() string {
|
||||||
|
if pdc.Storage.Directory != nil {
|
||||||
|
return filepath.Base(string(*pdc.Storage.Directory))
|
||||||
|
} else if pdc.Storage.File != nil {
|
||||||
|
return filepath.Base(string(pdc.Storage.File.Path))
|
||||||
|
}
|
||||||
|
return "" // unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pdc *lmdbConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
env, err := lmdb.NewEnv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var openPath string
|
||||||
|
var flags uint = 0
|
||||||
|
if pdc.Readonly {
|
||||||
|
flags |= lmdb.Readonly
|
||||||
|
}
|
||||||
|
if pdc.Storage.Directory != nil {
|
||||||
|
openPath = string(*pdc.Storage.Directory)
|
||||||
|
} else if pdc.Storage.File != nil {
|
||||||
|
openPath = string(pdc.Storage.File.Path)
|
||||||
|
flags |= lmdb.NoSubdir
|
||||||
|
}
|
||||||
|
|
||||||
|
isMulti := false
|
||||||
|
if pdc.MultiDB != nil {
|
||||||
|
env.SetMaxDBs(pdc.MultiDB.Slots)
|
||||||
|
isMulti = true
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.Open(openPath, flags, 0644)
|
||||||
|
if err != nil {
|
||||||
|
_ = env.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lmdbDatabase{db: env, isMulti: isMulti}, nil
|
||||||
|
}
|
||||||
94
db_lotusdb.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/lotusdblabs/lotusdb/v2"
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lotusLdb struct {
|
||||||
|
db *lotusdb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) DriverName() string {
|
||||||
|
return "LotusDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
cur, err := ld.db.NewIterator(lotusdb.IteratorOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("NewIterator: %w", err)
|
||||||
|
}
|
||||||
|
defer cur.Close()
|
||||||
|
|
||||||
|
for cur.Valid() {
|
||||||
|
f.AddRow_PK_Data(cur.Key(), cur.Key(), cur.Value())
|
||||||
|
cur.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *lotusLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
txn := n.db.NewBatch(lotusdb.DefaultBatchOptions)
|
||||||
|
|
||||||
|
err := ApplyChanges_binColumn(f, txn.Put, txn.Delete)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return txn.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *lotusLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &lotusLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &lotusLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type lotusDBConnection struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &lotusDBConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (ldc *lotusDBConnection) String() string {
|
||||||
|
return filepath.Base(string(ldc.Directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ldc *lotusDBConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
opts := lotusdb.DefaultOptions // copy
|
||||||
|
opts.DirPath = string(ldc.Directory)
|
||||||
|
|
||||||
|
db, err := lotusdb.Open(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lotusLdb{db: db}, nil
|
||||||
|
}
|
||||||
354
db_mongo.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MongoDB support
|
||||||
|
// To test: `make test-mongo`
|
||||||
|
|
||||||
|
type mongoLdb struct {
|
||||||
|
client *mongo.Client
|
||||||
|
sshc *ssh.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) DriverName() string {
|
||||||
|
return "MongoDB " + version.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return "", nil // no properties
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
// Database is selected
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
return "Database " + db.Name(), nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 2 {
|
||||||
|
// Collection is selected
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
coll := db.Collection(bucketPath[1])
|
||||||
|
|
||||||
|
info := "Database " + db.Name() + "\n"
|
||||||
|
info += "Collection " + coll.Name() + "\n"
|
||||||
|
|
||||||
|
// Document count
|
||||||
|
count, err := coll.EstimatedDocumentCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Estimating document count: %w", err)
|
||||||
|
}
|
||||||
|
info += fmt.Sprintf("Estimated document count: %d", count) + "\n"
|
||||||
|
|
||||||
|
// Index info
|
||||||
|
allIndexes, err := coll.Indexes().ListSpecifications(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Checking indexes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info += fmt.Sprintf("\nIndexes (%d):\n", len(allIndexes))
|
||||||
|
for _, idxInfo := range allIndexes {
|
||||||
|
info += fmt.Sprintf("- %q (namespace=%q, version=%d)\n", idxInfo.Name, idxInfo.Namespace, idxInfo.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return "", errors.New("??")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 || len(bucketPath) == 1 {
|
||||||
|
// Leave the table disabled
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 2 {
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&stringColumn{}, &bsonColumn{}}, []string{"_id", "Document"})
|
||||||
|
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
coll := db.Collection(bucketPath[1])
|
||||||
|
|
||||||
|
cur, err := coll.Find(ctx, bson.D{}) // An empty document as filter = find all results
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Find: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
return ld.populateRows(ctx, cur, f)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return errors.New("??")
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) populateRows(ctx context.Context, cur *mongo.Cursor, f *tableState) error {
|
||||||
|
|
||||||
|
for cur.Next(ctx) {
|
||||||
|
var result bson.D
|
||||||
|
if err := cur.Decode(&result); err != nil {
|
||||||
|
return fmt.Errorf("Decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document is always an ordered map[string]any.
|
||||||
|
// MongoDB enforces there is an "_id" key.
|
||||||
|
idValue, ok := bson_find_id(result)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("Surprised to find a document missing an '_id' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data([]byte(idValue), idValue, result)
|
||||||
|
}
|
||||||
|
if err := cur.Err(); err != nil {
|
||||||
|
return fmt.Errorf("Cursor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return ld.client.ListDatabaseNames(ctx, bson.D{})
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
return db.ListCollectionNames(ctx, bson.D{})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) actionNewDatabase(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dbName := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new database:")
|
||||||
|
if dbName == "" {
|
||||||
|
return nil // cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDB: databases just start to exist when you write to them
|
||||||
|
newDb := ld.client.Database(dbName)
|
||||||
|
return newDb.CreateCollection(ctx, "my.new.collection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) actionNewCollection(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
collName := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new collection:")
|
||||||
|
if collName == "" {
|
||||||
|
return nil // cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDB: databases just start to exist when you write to them
|
||||||
|
newDb := ld.client.Database(bucketPath[0])
|
||||||
|
return newDb.CreateCollection(ctx, collName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) actionDropDatabase(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to drop the database %q?", bucketPath[0])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
return db.Drop(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) actionDropCollection(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to delete the collection %q?", bucketPath[1])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
coll := db.Collection(bucketPath[1])
|
||||||
|
return coll.Drop(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) ExecQuery(query string, bucketPath []string, resultArea *tableState) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return errors.New("Please select a database first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := ld.client.Database(bucketPath[0])
|
||||||
|
|
||||||
|
// The query should be JSON, e.g.
|
||||||
|
// { "hello": 1 } or
|
||||||
|
// { "explain": { "count": "system.users" } } or
|
||||||
|
// { "listDatabases": 1 }
|
||||||
|
|
||||||
|
doc := bson.D{}
|
||||||
|
err := doc.UnmarshalJSON([]byte(query))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Parsing JSON query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME how to tell if the response will have a cursor or a singleResult?
|
||||||
|
|
||||||
|
if false {
|
||||||
|
// Cursor
|
||||||
|
cur, err := db.RunCommandCursor(ctx, doc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Running command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
return ld.populateRows(ctx, cur, resultArea)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Single result
|
||||||
|
|
||||||
|
res := db.RunCommand(ctx, doc)
|
||||||
|
|
||||||
|
response := bson.D{}
|
||||||
|
err = res.Decode(&response)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Decoding response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJson, err := response.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Decoding response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultArea.SetupColumns([]TableColumn{&binColumn{}}, []string{"Response"})
|
||||||
|
resultArea.AddRowData(responseJson)
|
||||||
|
resultArea.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// Top-level connection
|
||||||
|
return []contextAction{
|
||||||
|
{"Create database...", ld.actionNewDatabase},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
// Database selected
|
||||||
|
return []contextAction{
|
||||||
|
{"Create collection...", ld.actionNewCollection},
|
||||||
|
{"Drop database...", ld.actionDropDatabase},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 2 {
|
||||||
|
// Collection selected
|
||||||
|
return []contextAction{
|
||||||
|
{"Drop collection...", ld.actionDropCollection},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("???")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *mongoLdb) Close() {
|
||||||
|
_ = ld.client.Disconnect(context.Background())
|
||||||
|
if ld.sshc != nil {
|
||||||
|
_ = ld.sshc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &mongoLdb{} // interface assertion
|
||||||
|
var _ queryableLoadedDatabase = &mongoLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type mongoConnection struct {
|
||||||
|
Conn struct {
|
||||||
|
Mode autoconfig.OneOf
|
||||||
|
|
||||||
|
Connection_String *string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
SSH_Tunnel *SSHTunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &mongoConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (moc *mongoConnection) Reset() {
|
||||||
|
moc.Conn.Mode = "Connection_String"
|
||||||
|
moc.Conn.Connection_String = address_of("mongodb://localhost:27017")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (moc *mongoConnection) String() string {
|
||||||
|
return "MongoDB" // TODO could be improved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (moc *mongoConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
ctx, _ = context.WithTimeout(ctx, 5*time.Second) // shadow parent ctx
|
||||||
|
|
||||||
|
opts := options.Client().ApplyURI(string(*moc.Conn.Connection_String))
|
||||||
|
|
||||||
|
// Our used library supports all compressors
|
||||||
|
opts.SetCompressors([]string{"zstd", "snappy", "zlib"})
|
||||||
|
|
||||||
|
ret := mongoLdb{}
|
||||||
|
|
||||||
|
if moc.SSH_Tunnel != nil {
|
||||||
|
sshc, err := moc.SSH_Tunnel.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Dialer = sshc // interface implements DialContext()
|
||||||
|
ret.sshc = sshc
|
||||||
|
|
||||||
|
// The crypto/ssh library does not support deadlines over tcp tunnels
|
||||||
|
// Go-redis has a workaround for this, but go-mongo does not
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
client, err := mongo.Connect(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret.client = client
|
||||||
|
|
||||||
|
// Connect() does not block for server discovery. Check that the server really
|
||||||
|
// is reachable
|
||||||
|
|
||||||
|
err = client.Ping(ctx, readpref.Primary())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should be able to ListDatabases - there may be an authentication error
|
||||||
|
_, err = client.ListDatabaseNames(ctx, bson.D{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
24
db_none.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type noLoadedDatabase struct{}
|
||||||
|
|
||||||
|
func (n *noLoadedDatabase) DriverName() string {
|
||||||
|
return "No database selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *noLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "Open a database to get started...", nil
|
||||||
|
}
|
||||||
|
func (n *noLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *noLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *noLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *noLoadedDatabase) Close() {}
|
||||||
130
db_pebble.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/cockroachdb/pebble"
|
||||||
|
"github.com/cockroachdb/pebble/vfs"
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pebbleLoadedDatabase struct {
|
||||||
|
db *pebble.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) DriverName() string {
|
||||||
|
return "Pebble"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
content := fmt.Sprintf("Pebble metrics: %#v", ld.db.Metrics())
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// pebble always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
itr, err := ld.db.NewIterWithContext(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer itr.Close()
|
||||||
|
|
||||||
|
for itr.First(); itr.Valid(); itr.Next() {
|
||||||
|
k := itr.Key()
|
||||||
|
v, err := itr.ValueAndErr()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to load data for key %q: %w", formatAny(k), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *pebbleLoadedDatabase) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
txn := n.db.NewBatch()
|
||||||
|
|
||||||
|
err := ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
func(k, v []byte) error { return txn.Set(k, v, nil) },
|
||||||
|
func(k []byte) error { return txn.Delete(k, nil) },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txn.Commit(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pebbleLoadedDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &pebbleLoadedDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &pebbleLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type pebbleConnection struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
|
||||||
|
Disk *struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
Readonly bool
|
||||||
|
} `json:",omitempty"`
|
||||||
|
Memory *struct{} `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &pebbleConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (pdc *pebbleConnection) String() string {
|
||||||
|
if pdc.Disk != nil {
|
||||||
|
return filepath.Base(string(pdc.Disk.Directory))
|
||||||
|
} else {
|
||||||
|
return `:memory:` // SQLite-style naming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pdc *pebbleConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
opts := (&pebble.Options{}).EnsureDefaults()
|
||||||
|
|
||||||
|
if pdc.Disk != nil {
|
||||||
|
opts.ReadOnly = pdc.Disk.Readonly
|
||||||
|
|
||||||
|
db, err := pebble.Open(string(pdc.Disk.Directory), opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pebbleLoadedDatabase{db: db}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Memory != nil
|
||||||
|
db, err := pebble.Open("", &pebble.Options{FS: vfs.NewMem()})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pebbleLoadedDatabase{db: db}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
101
db_pogreb.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/akrylysov/pogreb"
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pogrebLdb struct {
|
||||||
|
db *pogreb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) DriverName() string {
|
||||||
|
return "Pogreb"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
m := ld.db.Metrics()
|
||||||
|
return fmt.Sprintf("Metrics: %#v\n", m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
itn := ld.db.Items()
|
||||||
|
for {
|
||||||
|
k, v, err := itn.Next()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pogreb.ErrIterationDone) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return err // Real error
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *pogrebLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return ApplyChanges_binColumn(f, n.db.Put, n.db.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) compactDB(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
info, err := ld.db.Compact()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
qt.QMessageBox_Information(sender.TreeWidget().QWidget, APPNAME,
|
||||||
|
fmt.Sprintf("Compaction completed successfully.\n\nStatistics: %#v", info))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return []contextAction{
|
||||||
|
{"Compact", ld.compactDB},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *pogrebLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &pogrebLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &pogrebLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type pogrebConn struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &pogrebConn{} // interface assertion
|
||||||
|
|
||||||
|
func (c *pogrebConn) String() string {
|
||||||
|
return filepath.Base(string(c.Directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pogrebConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
db, err := pogreb.Open(string(c.Directory), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pogrebLdb{db: db}, nil
|
||||||
|
}
|
||||||
275
db_redis.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"qbolt/lexer"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redisConnectionOptions struct {
|
||||||
|
Address autoconfig.AddressPort
|
||||||
|
Password autoconfig.Password
|
||||||
|
UseRespv3 bool `ylabel:"Use RESP v3 protocol"`
|
||||||
|
SSH_Tunnel *SSHTunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &redisConnectionOptions{} // interface assertion
|
||||||
|
|
||||||
|
func (config *redisConnectionOptions) String() string {
|
||||||
|
return config.Address.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *redisConnectionOptions) Reset() {
|
||||||
|
config.Address.Port = 6379
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *redisConnectionOptions) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
ld := &redisLoadedDatabase{
|
||||||
|
currentDb: 0,
|
||||||
|
maxDb: 1,
|
||||||
|
serverVersion: "<unknown>",
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := redis.Options{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", config.Address.Address, config.Address.Port),
|
||||||
|
Password: string(config.Password),
|
||||||
|
UnstableResp3: config.UseRespv3,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SSH_Tunnel != nil {
|
||||||
|
sshc, err := config.SSH_Tunnel.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// When redis wants to open a tcp conn, don't dial directly, dial via the SSH tunnel
|
||||||
|
// Setting 'Dialer' takes priority over Addr/Network fields.
|
||||||
|
// ssh.Client.DialContext has a matching signature to redis.Options.Dialer
|
||||||
|
opts.Dialer = sshc.DialContext
|
||||||
|
ld.sshc = sshc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch in a hook to remember current DB after keepalive reconnection
|
||||||
|
opts.DB = 0 // Default
|
||||||
|
opts.OnConnect = func(ctx context.Context, cn *redis.Conn) error {
|
||||||
|
return cn.Select(ctx, ld.currentDb).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient doesn't necessarily connect, so it can't throw an err
|
||||||
|
ld.db = redis.NewClient(&opts)
|
||||||
|
|
||||||
|
// Make an INFO request (mandatory)
|
||||||
|
info, err := ld.db.InfoMap(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("During INFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverInfo, ok := info["Server"]; ok {
|
||||||
|
if v, ok := serverInfo["redis_version"]; ok {
|
||||||
|
ld.serverVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List available databases (usually 1..16) with "0" as default
|
||||||
|
// If this fails, probably the target redis does not support multiple databases
|
||||||
|
// (e.g. Redis Cluster). Assume max=0.
|
||||||
|
if maxDatabases, err := ld.db.ConfigGet(ctx, "databases").Result(); err == nil {
|
||||||
|
// Got a result. Must parse it
|
||||||
|
m, err := strconv.ParseInt(maxDatabases["databases"], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("During CONFIG GET databases: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ld.maxDb = int(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type redisLoadedDatabase struct {
|
||||||
|
db *redis.Client
|
||||||
|
sshc *ssh.Client
|
||||||
|
|
||||||
|
currentDb int
|
||||||
|
maxDb int
|
||||||
|
serverVersion string // populated at connection-time from INFO command
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) DriverName() string {
|
||||||
|
return "Redis " + ld.serverVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// Top-level: Show info() on main Properties tab
|
||||||
|
infostr, err := ld.db.Info(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Retreiving database info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return infostr, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return "Database " + bucketPath[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// Leave data tab disabled (default behaviour)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
// One selected database
|
||||||
|
// Figure out its content
|
||||||
|
err := ld.db.Do(ctx, "SELECT", bucketPath[0]).Err()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Switching to database %q: %w", bucketPath[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allKeys, err := ld.db.Keys(ctx, "*").Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Listing keys in database %q: %w", bucketPath[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis always uses Key string, Type string, Value []byte as the columns
|
||||||
|
|
||||||
|
f.SetupColumns(
|
||||||
|
[]TableColumn{&binColumn{}, &stringColumn{}, &binColumn{}},
|
||||||
|
[]string{"Key", "Type", "Value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, key := range allKeys {
|
||||||
|
typeName, err := ld.db.Type(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Loading %q/%q: %w", bucketPath[0], key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rpos := f.AddRow()
|
||||||
|
f.SetCell(rpos, 0, key)
|
||||||
|
f.SetCell(rpos, 1, typeName)
|
||||||
|
|
||||||
|
switch typeName {
|
||||||
|
case "string":
|
||||||
|
val, err := ld.db.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Loading %q/%q: %w", bucketPath[0], key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.SetCell(rpos, 2, []byte(val))
|
||||||
|
|
||||||
|
case "hash":
|
||||||
|
val, err := ld.db.HGetAll(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Loading %q/%q: %w", bucketPath[0], key, err)
|
||||||
|
}
|
||||||
|
// It's a map[string]string
|
||||||
|
f.SetCell(rpos, 2, []byte(formatAny(val)))
|
||||||
|
|
||||||
|
case "lists":
|
||||||
|
fallthrough
|
||||||
|
case "sets":
|
||||||
|
fallthrough
|
||||||
|
case "sorted sets":
|
||||||
|
fallthrough
|
||||||
|
case "stream":
|
||||||
|
fallthrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
f.SetCell(rpos, 2, []byte("<<<other object type>>>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("Unexpected nav position %q", bucketPath)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
// ctx := context.Background()
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// Top-level: list of all child databases (usually 16x)
|
||||||
|
ret := make([]string, 0, ld.maxDb)
|
||||||
|
for i := 0; i < ld.maxDb; i++ {
|
||||||
|
ret = append(ret, fmt.Sprintf("%d", i))
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
// One selected database
|
||||||
|
// No child keys underneath it
|
||||||
|
return []string{}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("Unexpected nav position %q", bucketPath)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) ExecQuery(query string, _ []string, resultArea *tableState) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Need to parse the query into separate string+args fields for the protocol
|
||||||
|
fields, err := lexer.Fields(query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Parsing the query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields_boxed := box_interface(fields)
|
||||||
|
|
||||||
|
ret, err := ld.db.Do(ctx, fields_boxed...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("The redis query returned an error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultArea.SetupColumns([]TableColumn{&stringColumn{}}, []string{"Result"})
|
||||||
|
|
||||||
|
// The result is probably a single value or a string slice
|
||||||
|
switch ret := ret.(type) {
|
||||||
|
case []string:
|
||||||
|
// Multiple values
|
||||||
|
for _, single := range ret {
|
||||||
|
resultArea.AddRowData(single)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Single value
|
||||||
|
// Unknown object type
|
||||||
|
resultArea.AddRowData(formatAny(ret)) // formatUtf8
|
||||||
|
}
|
||||||
|
|
||||||
|
resultArea.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *redisLoadedDatabase) Close() {
|
||||||
|
if ld.sshc != nil {
|
||||||
|
_ = ld.sshc.Close() // TODO does this also SIGINT remote processes?
|
||||||
|
}
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &redisLoadedDatabase{} // interface assertion
|
||||||
|
var _ queryableLoadedDatabase = &redisLoadedDatabase{} // interface assertion
|
||||||
80
db_rosedb.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
"github.com/rosedblabs/rosedb/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roseLdb struct {
|
||||||
|
db *rosedb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) DriverName() string {
|
||||||
|
return "RoseDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
stats := ld.db.Stat()
|
||||||
|
return fmt.Sprintf("Statistics: %#v\n", stats), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
ld.db.Ascend(func(k, v []byte) (bool, error) {
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *roseLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return ApplyChanges_binColumn(f, n.db.Put, n.db.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No supported actions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *roseLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &roseLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &roseLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type roseDBConn struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &roseDBConn{} // interface assertion
|
||||||
|
|
||||||
|
func (c *roseDBConn) String() string {
|
||||||
|
return filepath.Base(string(c.Directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *roseDBConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
options := rosedb.DefaultOptions // copy
|
||||||
|
options.DirPath = string(c.Directory)
|
||||||
|
|
||||||
|
db, err := rosedb.Open(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &roseLdb{db: db}, nil
|
||||||
|
}
|
||||||
195
db_secretsvc_linux.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dbus "github.com/godbus/dbus/v5"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
secretservice "github.com/zalando/go-keyring/secret_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type secretServiceDb struct {
|
||||||
|
svc *secretservice.SecretService
|
||||||
|
session dbus.BusObject
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) DriverName() string {
|
||||||
|
return "FreeDesktop.org Secret Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil // No properties
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
const (
|
||||||
|
collectionBasePath = "/org/freedesktop/secrets/collection/"
|
||||||
|
itemInterface = "org.freedesktop.Secret.Item"
|
||||||
|
serviceName = "org.freedesktop.secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// No data
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
|
||||||
|
f.SetupColumns(
|
||||||
|
[]TableColumn{&stringColumn{}, &stringColumn{}, &stringColumn{}, &stringColumn{}, &stringColumn{}, &binColumn{}},
|
||||||
|
[]string{"ID", "Label", "Attributes", "ContentType", "Parameters", "Value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collection is selected
|
||||||
|
collection := ld.svc.GetCollection(bucketPath[0])
|
||||||
|
|
||||||
|
// Perform an empty search to find all items
|
||||||
|
allItems, err := ld.svc.SearchItems(collection, map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range allItems {
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
obj := ld.svc.Object(serviceName, item)
|
||||||
|
label, err := obj.GetProperty(itemInterface + ".Label")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Reading label %q: %w", item, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes: a map[string]string{}
|
||||||
|
// Is this optional?
|
||||||
|
// Calling attrs.String() gives a JSON representation
|
||||||
|
attrs, err := obj.GetProperty(itemInterface + ".Attributes")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Reading attributes %q: %w", item, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secr, err := ld.svc.GetSecret(item, ld.session.Path())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Reading secret %q: %w", item, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRowData(
|
||||||
|
string(item), // ID
|
||||||
|
label.Value().(string), // Label
|
||||||
|
attrs.String(), // Attributes (JSON)
|
||||||
|
secr.ContentType, // ContentType
|
||||||
|
string(secr.Parameters), // Parameters
|
||||||
|
secr.Value, // Value - []byte
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) listCollections() ([]string, error) {
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceName = "org.freedesktop.secrets"
|
||||||
|
servicePath = "/org/freedesktop/secrets"
|
||||||
|
collectionsInterface = "org.freedesktop.Secret.Service.Collections"
|
||||||
|
collectionBasePath = "/org/freedesktop/secrets/collection/"
|
||||||
|
)
|
||||||
|
|
||||||
|
obj := ld.svc.Conn.Object(serviceName, servicePath)
|
||||||
|
|
||||||
|
val, err := obj.GetProperty(collectionsInterface)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := val.Value().([]dbus.ObjectPath)
|
||||||
|
|
||||||
|
ret := make([]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
// They are expected to have {collectionBasePath} as prefix
|
||||||
|
colName := string(p)
|
||||||
|
if strings.HasPrefix(colName, collectionBasePath) {
|
||||||
|
colName = colName[len(collectionBasePath):]
|
||||||
|
}
|
||||||
|
ret = append(ret, colName)
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return ld.listCollections()
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) newCollection(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
name := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new collection:")
|
||||||
|
if name == "" {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ld.svc.CreateCollection(name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) unlockKeychain(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
if len(bucketPath) != 1 {
|
||||||
|
return errors.New("Invalid selection")
|
||||||
|
}
|
||||||
|
|
||||||
|
coll := ld.svc.GetCollection(bucketPath[0])
|
||||||
|
return ld.svc.Unlock(coll.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return []contextAction{
|
||||||
|
contextAction{"Create collection...", ld.newCollection},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
return []contextAction{
|
||||||
|
contextAction{"Unlock", ld.unlockKeychain},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, nil // Unreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *secretServiceDb) Close() {
|
||||||
|
_ = ld.svc.Close(ld.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &secretServiceDb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type secretServiceConnection struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &secretServiceConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (ssc *secretServiceConnection) String() string {
|
||||||
|
return "dbus://SessionBus/org.freedesktop.secrets"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ssc *secretServiceConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
svc, err := secretservice.NewSecretService()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := svc.OpenSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &secretServiceDb{svc: svc, session: session}, nil
|
||||||
|
}
|
||||||
25
db_secretsvc_other.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build !linux
|
||||||
|
// +build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type secretServiceConnection struct {
|
||||||
|
H1 autoconfig.Header `ylabel:"Not supported on this operating system"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &secretServiceConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (ssc *secretServiceConnection) String() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ssc *secretServiceConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
return nil, "", errors.New("Not supported on this operating system")
|
||||||
|
}
|
||||||
509
db_sqlite.go
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"qbolt/sqliteclidriver"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
mattn_sqlite3 "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqliteTablesCaption = "Tables"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteLoadedDatabase struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) DriverName() string {
|
||||||
|
if _, ok := ld.db.Driver().(*sqliteclidriver.SCDriver); ok {
|
||||||
|
return "SQLite (sqliteclidriver)"
|
||||||
|
}
|
||||||
|
|
||||||
|
ver1, _, _ := mattn_sqlite3.Version()
|
||||||
|
return "SQLite " + ver1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) Properties(bucketPath []string) (string, error) {
|
||||||
|
if len(bucketPath) == 0 || len(bucketPath) == 1 {
|
||||||
|
return "Please select...", nil // No properties
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
tableName := 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
|
||||||
|
return fmt.Sprintf("Selected table %q\n\nSchema:\n\n%s", tableName, schemaStmt), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 1 {
|
||||||
|
// Category (tables, ...)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if len(bucketPath) == 2 && bucketPath[0] == sqliteTablesCaption {
|
||||||
|
// Render for specific table
|
||||||
|
tableName := bucketPath[1]
|
||||||
|
|
||||||
|
// Select count(*) so we know to display a warning if there are too many entries
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
// Select * with small limit
|
||||||
|
datar, err := ld.db.Query(`SELECT rowid, * FROM [` + tableName + `]`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to load data for table %q: %w", tableName, err)
|
||||||
|
}
|
||||||
|
defer datar.Close()
|
||||||
|
|
||||||
|
err = populateRows(datar, f, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// We successfully populated the data grid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ??? unknown
|
||||||
|
return errors.New("?")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) ([]string, error) {
|
||||||
|
colr, err := ld.db.Query(`SELECT name FROM pragma_table_info( ? )`, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Query: %w", err)
|
||||||
|
}
|
||||||
|
defer colr.Close()
|
||||||
|
|
||||||
|
var ret []string
|
||||||
|
|
||||||
|
for colr.Next() {
|
||||||
|
|
||||||
|
var columnName string
|
||||||
|
err = colr.Scan(&columnName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Scan: %w", colr.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = append(ret, columnName)
|
||||||
|
}
|
||||||
|
if colr.Err() != nil {
|
||||||
|
return nil, colr.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateRows(rr *sql.Rows, dest *tableState, firstColumnIsExtraRowID bool) error {
|
||||||
|
|
||||||
|
numColumns := len(dest.columns)
|
||||||
|
|
||||||
|
var pfields []interface{} = nil
|
||||||
|
|
||||||
|
cNames, err := rr.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cTypes, err := rr.ColumnTypes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cNames) != len(cTypes) {
|
||||||
|
return errors.New("unexpected column metadata mismatch") // assert
|
||||||
|
}
|
||||||
|
|
||||||
|
numColumns = len(cNames)
|
||||||
|
|
||||||
|
rrMakeCtypes := []TableColumn{}
|
||||||
|
|
||||||
|
for i := 0; i < len(cTypes); i++ {
|
||||||
|
|
||||||
|
if firstColumnIsExtraRowID && i == 0 {
|
||||||
|
pfields = append(pfields, new(int64))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nullable, ok := cTypes[i].Nullable()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("can't tell if column is nullable?")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO support all SQLite column names/type-affinities
|
||||||
|
// TODO support nullable variants for all types here
|
||||||
|
// @ref https://www.sqlite.org/datatype3.html
|
||||||
|
|
||||||
|
switch cTypes[i].DatabaseTypeName() {
|
||||||
|
case "BLOB":
|
||||||
|
// Binary column
|
||||||
|
rrMakeCtypes = append(rrMakeCtypes, &binColumn{})
|
||||||
|
pfields = append(pfields, new([]byte))
|
||||||
|
|
||||||
|
case "INTEGER":
|
||||||
|
if nullable {
|
||||||
|
rrMakeCtypes = append(rrMakeCtypes, &sqlNullInt64Column{})
|
||||||
|
pfields = append(pfields, new(sql.NullInt64))
|
||||||
|
} else {
|
||||||
|
rrMakeCtypes = append(rrMakeCtypes, &int64Column{})
|
||||||
|
pfields = append(pfields, new(int64))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if nullable {
|
||||||
|
rrMakeCtypes = append(rrMakeCtypes, &sqlNullStringColumn{})
|
||||||
|
pfields = append(pfields, new(sql.NullString))
|
||||||
|
|
||||||
|
} else {
|
||||||
|
rrMakeCtypes = append(rrMakeCtypes, &stringColumn{})
|
||||||
|
pfields = append(pfields, new(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resetup table
|
||||||
|
|
||||||
|
if firstColumnIsExtraRowID && len(cNames) > 0 {
|
||||||
|
// Real SQLite driver: gives us back names even if 0 rows
|
||||||
|
// sqliteclidriver: Gives back no names if there were 0 rows
|
||||||
|
cNames = cNames[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
dest.SetupColumns(rrMakeCtypes, cNames)
|
||||||
|
|
||||||
|
for rr.Next() {
|
||||||
|
|
||||||
|
err := rr.Scan(pfields...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rpos := dest.AddRow()
|
||||||
|
|
||||||
|
if firstColumnIsExtraRowID {
|
||||||
|
dest.SetRowPrimaryKey(rpos, int64_to_binary8(*(pfields[0].(*int64))))
|
||||||
|
|
||||||
|
for i := 1; i < numColumns; i += 1 { // skip first column
|
||||||
|
interior := reflect.ValueOf(pfields[i]).Elem().Interface()
|
||||||
|
dest.SetCell(rpos, i-1, interior)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// all columns
|
||||||
|
for i := 0; i < numColumns; i += 1 {
|
||||||
|
interior := reflect.ValueOf(pfields[i]).Elem().Interface()
|
||||||
|
dest.SetCell(rpos, i, interior)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return rr.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) ExecQuery(query string, _ []string, resultArea *tableState) error {
|
||||||
|
rr, err := ld.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rr.Close()
|
||||||
|
|
||||||
|
err = populateRows(rr, resultArea, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resultArea.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowQueryContexter interface {
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *sqliteLoadedDatabase) ApplyChanges(f *tableState, bucketPath []string) (retErr error) {
|
||||||
|
|
||||||
|
if len(bucketPath) != 2 {
|
||||||
|
return errors.New("invalid selection")
|
||||||
|
}
|
||||||
|
tableName := bucketPath[1]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tx, err := n.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var commitOK bool = false
|
||||||
|
defer func() {
|
||||||
|
if !commitOK {
|
||||||
|
err := tx.Rollback()
|
||||||
|
if err != nil {
|
||||||
|
retErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// SQLite can only LIMIT 1 on update/delete if it was compiled with
|
||||||
|
// SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which isn't the case for the mattn
|
||||||
|
// cgo library
|
||||||
|
// Skip that, and just rely on primary key uniqueness
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
for aRow, editcells := range f.updateRows {
|
||||||
|
stmt := `UPDATE [` + tableName + `] SET `
|
||||||
|
params := []interface{}{}
|
||||||
|
|
||||||
|
for ct, cell := range editcells {
|
||||||
|
if ct > 0 {
|
||||||
|
stmt += `, `
|
||||||
|
}
|
||||||
|
stmt += `[` + f.columnLabels[cell] + `] = ?`
|
||||||
|
params = append(params, f.columns[cell].GetCell(aRow))
|
||||||
|
}
|
||||||
|
stmt += ` WHERE [rowid] = ?`
|
||||||
|
|
||||||
|
// Update by primary key (stored separately)
|
||||||
|
pkVal := binary8_to_int64(f.primaryKeys[aRow])
|
||||||
|
params = append(params, pkVal)
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, stmt, params...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Updating row %q: %w", pkVal, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete by key (affects rowids after re-render)
|
||||||
|
for aRow, _ := range f.deleteRows {
|
||||||
|
pkVal := binary8_to_int64(f.primaryKeys[aRow])
|
||||||
|
|
||||||
|
stmt := `DELETE FROM [` + tableName + `] WHERE [rowid] = ?`
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, stmt, pkVal)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Deleting row %q: %w", pkVal, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert all new entries
|
||||||
|
for aRow, _ := range f.insertRows {
|
||||||
|
stmt := `INSERT INTO [` + tableName + `] ([` + strings.Join(f.columnLabels, `], [`) + `]) VALUES (`
|
||||||
|
params := []interface{}{}
|
||||||
|
|
||||||
|
for colid := 0; colid < len(f.columnLabels); colid++ {
|
||||||
|
if colid > 0 {
|
||||||
|
stmt += `, `
|
||||||
|
}
|
||||||
|
stmt += "?"
|
||||||
|
params = append(params, f.columns[colid].GetCell(aRow))
|
||||||
|
}
|
||||||
|
stmt += `)`
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, stmt, params...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Inserting row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
commitOK = true // no need for rollback
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
// The top-level children are always:
|
||||||
|
return []string{sqliteTablesCaption}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bucketPath) == 1 && bucketPath[0] == sqliteTablesCaption {
|
||||||
|
// The sequence and stat1 tables are not marked as hidden tables
|
||||||
|
// They are created automatically when using (A) autoincrement and (B) ???.
|
||||||
|
|
||||||
|
rr, err := ld.db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' AND name <> 'sqlite_stat1' 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(bucketPath) == 2 {
|
||||||
|
return nil, nil // Never any deeper children
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unknown nav path %#v", bucketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) NavContext(bucketPath []string) (ret []contextAction, err error) {
|
||||||
|
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
ret = append(ret, contextAction{"Compact database", ld.CompactDatabase})
|
||||||
|
ret = append(ret, contextAction{"Export backup...", ld.ExportBackup})
|
||||||
|
}
|
||||||
|
if len(bucketPath) == 2 {
|
||||||
|
ret = append(ret, contextAction{"Drop table", ld.DropTable})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) CompactDatabase(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
_, err := ld.db.Exec(`VACUUM;`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) ExportBackup(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
// Popup for output file
|
||||||
|
savePath := qt.QFileDialog_GetSaveFileName4(sender.TreeWidget().QWidget, "Save backup as...", "", "SQLite database (*.db *.db3 *.sqlite *.sqlite3);;All files (*)")
|
||||||
|
if savePath == "" {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ld.db.Exec(`VACUUM INTO ?`, savePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) DropTable(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
if len(bucketPath) != 2 {
|
||||||
|
return errors.New("Invalid selection")
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
tableName := bucketPath[1]
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to drop the table %q?", tableName)) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ld.db.Exec(`DROP TABLE "` + tableName + `"`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sqliteLoadedDatabase) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &sqliteLoadedDatabase{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &sqliteLoadedDatabase{} // interface assertion
|
||||||
|
var _ queryableLoadedDatabase = &sqliteLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type sqliteConnection struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
Disk *struct {
|
||||||
|
Database autoconfig.ExistingFile `yfilter:"SQLite database (*.db *.db3 *.sqlite *.sqlite3);;All files (*)"`
|
||||||
|
CliDriver bool `ylabel:"Use experimental CLI driver"`
|
||||||
|
} `json:",omitempty"`
|
||||||
|
Memory *struct{} `json:",omitempty"`
|
||||||
|
SSH *struct {
|
||||||
|
SSHServer *SSHTunnel
|
||||||
|
Database string
|
||||||
|
} `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &sqliteConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (sc *sqliteConnection) String() string {
|
||||||
|
if sc.Disk != nil {
|
||||||
|
return filepath.Base(string(sc.Disk.Database))
|
||||||
|
} else if sc.Memory != nil {
|
||||||
|
return `:memory:`
|
||||||
|
} else if sc.SSH != nil {
|
||||||
|
return filepath.Base(string(sc.SSH.Database)) + " (SSH)"
|
||||||
|
}
|
||||||
|
return "" // unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sc *sqliteConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
if sc.Disk != nil {
|
||||||
|
driver := "sqlite3"
|
||||||
|
if sc.Disk.CliDriver {
|
||||||
|
driver = "sqliteclidriver"
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open(driver, string(sc.Disk.Database))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqliteLoadedDatabase{db: db}, nil
|
||||||
|
|
||||||
|
} else if sc.Memory != nil { // memory
|
||||||
|
db, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqliteLoadedDatabase{db: db}, nil
|
||||||
|
|
||||||
|
} else if sc.SSH != nil {
|
||||||
|
if sc.SSH.SSHServer == nil {
|
||||||
|
return nil, errors.New("Invalid configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := sc.SSH.SSHServer.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := sqliteclidriver.OpenSSH(cl, sc.SSH.Database)
|
||||||
|
|
||||||
|
return &sqliteLoadedDatabase{db: db}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("Invalid configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
185
db_sshagent.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sshAgentLdb struct {
|
||||||
|
conn agent.ExtendedAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) DriverName() string {
|
||||||
|
return "ssh-agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
keys, err := ld.conn.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&stringColumn{}, &stringColumn{}, &binColumn{}}, []string{"Comment", "Type", "Public Key"})
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
// The publicKey blob is the effective primary-key for DB manipulation
|
||||||
|
f.AddRow_PK_Data(key.Blob, key.Comment, key.Format, key.Blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) lockPrompt(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
parent := sender.TreeWidget().QWidget
|
||||||
|
props := encryptionKey{}
|
||||||
|
autoconfig.OpenDialog(&props, parent, "Enter lock password...", func() {
|
||||||
|
|
||||||
|
key, err := props.Get()
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(parent, APPNAME, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) == 0 {
|
||||||
|
// Cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ld.conn.Lock(key)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(parent, APPNAME, "Locking SSH agent: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil // n.b. refreshes now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) unlockPrompt(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
parent := sender.TreeWidget().QWidget
|
||||||
|
props := encryptionKey{}
|
||||||
|
autoconfig.OpenDialog(&props, parent, "Enter unlock password...", func() {
|
||||||
|
|
||||||
|
key, err := props.Get()
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(parent, APPNAME, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) == 0 {
|
||||||
|
// Cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ld.conn.Unlock(key)
|
||||||
|
if err != nil {
|
||||||
|
qt.QMessageBox_Warning(parent, APPNAME, "Unlocking SSH agent: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a refresh
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil // n.b. refreshes now, which may cause double-error if we are still locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return []contextAction{
|
||||||
|
{"Lock agent...", ld.lockPrompt},
|
||||||
|
{"Unlock agent...", ld.unlockPrompt},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *sshAgentLdb) Close() {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &sshAgentLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type sshAgentConn struct {
|
||||||
|
Type autoconfig.OneOf
|
||||||
|
Unix *autoconfig.ExistingFile `json:",omitempty"`
|
||||||
|
TCP *autoconfig.AddressPort `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &sshAgentConn{} // interface assertion
|
||||||
|
|
||||||
|
func (c *sshAgentConn) String() string {
|
||||||
|
return "SSH Agent" // TODO could be improved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sshAgentConn) Reset() {
|
||||||
|
|
||||||
|
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
|
||||||
|
if _, err := os.Stat(sshAuthSock); err == nil {
|
||||||
|
// File
|
||||||
|
c.Type = "Unix"
|
||||||
|
val := autoconfig.ExistingFile(sshAuthSock)
|
||||||
|
c.Unix = &val
|
||||||
|
|
||||||
|
} else if props, err := netip.ParseAddrPort(sshAuthSock); err == nil {
|
||||||
|
// IP:Port
|
||||||
|
c.Type = "TCP"
|
||||||
|
val := autoconfig.AddressPort{
|
||||||
|
Address: props.Addr().String(),
|
||||||
|
Port: int(props.Port()),
|
||||||
|
}
|
||||||
|
c.TCP = &val
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Can't parse env var
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sshAgentConn) getAgent() (agent.ExtendedAgent, error) {
|
||||||
|
|
||||||
|
if c.Unix != nil {
|
||||||
|
conn, err := net.Dial("unix", string(*c.Unix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Connecting to SSH agent %q: %w", c.Unix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.NewClient(conn), nil
|
||||||
|
|
||||||
|
} else if c.TCP != nil {
|
||||||
|
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", c.TCP.Address, c.TCP.Port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Connecting to SSH agent %q: %w", c.TCP.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.NewClient(conn), nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("No connection details specified")
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sshAgentConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
agent, err := c.getAgent()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sshAgentLdb{conn: agent}, nil
|
||||||
|
}
|
||||||
121
db_starskey.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
"github.com/starskey-io/starskey"
|
||||||
|
)
|
||||||
|
|
||||||
|
type starskeyLdb struct {
|
||||||
|
db *starskey.Starskey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) DriverName() string {
|
||||||
|
return "Starskey"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil // No properties
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
// Starskey always uses Key + Value as the columns
|
||||||
|
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
// It's possible to create a transaction in Starskey, but you can't enumerate keys
|
||||||
|
// within the transaction - doesn't really help us
|
||||||
|
|
||||||
|
var allKeys [][]byte
|
||||||
|
_, err := ld.db.FilterKeys(func(key []byte) bool {
|
||||||
|
allKeys = append(allKeys, slice_dup(key))
|
||||||
|
return false // don't get value in here
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("FilterKeys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range allKeys {
|
||||||
|
val, err := ld.db.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
// We get <nil, nil> if not found, so any error is a real error
|
||||||
|
return fmt.Errorf("Reading key %q: %w", string(key), err)
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
// Key not found
|
||||||
|
// The hack to use FilterKeys() means this can happen if the key is
|
||||||
|
// pointing to a tombstone
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data(key, key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *starskeyLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
return ApplyChanges_binColumn(f, n.db.Put, n.db.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *starskeyLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &starskeyLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &starskeyLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type starskeyConnection struct {
|
||||||
|
Directory autoconfig.ExistingDirectory
|
||||||
|
Compression autoconfig.EnumList `yenum:"No compression;;Snappy;;S2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &starskeyConnection{} // interface assertion
|
||||||
|
|
||||||
|
func (pdc *starskeyConnection) String() string {
|
||||||
|
return filepath.Base(string(pdc.Directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pdc *starskeyConnection) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
cfg := starskey.Config{
|
||||||
|
Permission: 0755,
|
||||||
|
Directory: string(pdc.Directory),
|
||||||
|
FlushThreshold: (1024 * 1024) * 24, // Upstream default (24 MiB)
|
||||||
|
MaxLevel: 3, // Upstream default
|
||||||
|
SizeFactor: 10, // Upstream default
|
||||||
|
}
|
||||||
|
|
||||||
|
if pdc.Compression == 0 {
|
||||||
|
} else if pdc.Compression == 1 {
|
||||||
|
cfg.Compression = true
|
||||||
|
cfg.CompressionOption = starskey.SnappyCompression
|
||||||
|
} else if pdc.Compression == 2 {
|
||||||
|
cfg.Compression = true
|
||||||
|
cfg.CompressionOption = starskey.S2Compression
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := starskey.Open(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &starskeyLdb{db: db}, nil
|
||||||
|
}
|
||||||
254
db_voiddb_linux.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"github.com/voidDB/voidDB"
|
||||||
|
"github.com/voidDB/voidDB/common"
|
||||||
|
"github.com/voidDB/voidDB/cursor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type voidLdb struct {
|
||||||
|
db *voidDB.Void
|
||||||
|
multiKeyspace bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) DriverName() string {
|
||||||
|
return "VoidDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) Properties(bucketPath []string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursor gets a VoidDB cursor for the given transaction and bucketPath.
|
||||||
|
func (ld *voidLdb) cursor(txn *voidDB.Txn, bucketPath []string) (*cursor.Cursor, error) {
|
||||||
|
if ld.multiKeyspace {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return nil, errors.New("No data at VoidDB root keyspace when in multi-keyspace mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
return txn.OpenCursor([]byte(bucketPath[0]))
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if len(bucketPath) != 0 {
|
||||||
|
return nil, errors.New("Invalid navigation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return txn.Cursor, nil // The Txn embeds a new cursor over the default keyspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) RenderForNav(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
f.SetupColumns([]TableColumn{&binColumn{}, &binColumn{}}, []string{"Key", "Value"})
|
||||||
|
|
||||||
|
if ld.multiKeyspace && len(bucketPath) == 0 {
|
||||||
|
// Nothing to see/do in the default keyspace, sorry
|
||||||
|
// Exit without calling .Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ld.db.View(func(tx *voidDB.Txn) error {
|
||||||
|
|
||||||
|
cur, err := ld.cursor(tx, bucketPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
k, v, err := cur.GetNext()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrorNotFound) {
|
||||||
|
break // Reached end
|
||||||
|
}
|
||||||
|
return fmt.Errorf("GetNext: %w", err) // Some other real error
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddRow_PK_Data(k, k, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Ready()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *voidLdb) ApplyChanges(f *tableState, bucketPath []string) error {
|
||||||
|
|
||||||
|
return n.db.Update(true, func(tx *voidDB.Txn) error {
|
||||||
|
|
||||||
|
cur, err := n.cursor(tx, bucketPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyChanges_binColumn(
|
||||||
|
f,
|
||||||
|
cur.Put,
|
||||||
|
func(k []byte) error {
|
||||||
|
// To delete keys in VoidDB, first move the cursor to the target
|
||||||
|
_, err := cur.Get(k)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Finding key %q for deletion: %w", formatUtf8(k), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cur.Del()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) NavChildren(bucketPath []string) ([]string, error) {
|
||||||
|
if len(bucketPath) == 0 && ld.multiKeyspace {
|
||||||
|
|
||||||
|
// In multi-db mode, the root keyspace is filled with pointers to
|
||||||
|
// other keyspaces
|
||||||
|
// Iterate the names
|
||||||
|
var rootNames []string
|
||||||
|
err := ld.db.View(func(tx *voidDB.Txn) error {
|
||||||
|
for {
|
||||||
|
k, _, err := tx.GetNext()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrorNotFound) {
|
||||||
|
return nil // Reached end
|
||||||
|
}
|
||||||
|
return fmt.Errorf("GetNext: %w", err) // Some other real error
|
||||||
|
}
|
||||||
|
|
||||||
|
rootNames = append(rootNames, string(k))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rootNames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}, nil // No children
|
||||||
|
}
|
||||||
|
|
||||||
|
// addKeyspace is valid only in multi-keyspace mode.
|
||||||
|
func (ld *voidLdb) addKeyspace(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
if len(bucketPath) != 0 {
|
||||||
|
return errors.New("Can only add keyspaces at the root")
|
||||||
|
}
|
||||||
|
|
||||||
|
ksName := qt.QInputDialog_GetText(sender.TreeWidget().QWidget, APPNAME, "Enter a name for the new keyspace:")
|
||||||
|
if ksName == "" {
|
||||||
|
return nil // cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.db.Update(true, func(tx *voidDB.Txn) error {
|
||||||
|
_, err := tx.OpenCursor([]byte(ksName))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteKeyspace is valid only in multi-keyspace mode. It deletes the target
|
||||||
|
// keyspace and all its content.
|
||||||
|
func (ld *voidLdb) deleteKeyspace(sender *qt.QTreeWidgetItem, bucketPath []string) error {
|
||||||
|
if len(bucketPath) != 1 {
|
||||||
|
return errors.New("Invalid navigation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !vcl_confirm_dialog(sender.TreeWidget().QWidget, fmt.Sprintf("Are you sure you want to delete the keyspace %q?", bucketPath[0])) {
|
||||||
|
return nil // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
return ld.db.Update(true, func(tx *voidDB.Txn) error {
|
||||||
|
// Open the keyspace
|
||||||
|
cur, err := tx.OpenCursor([]byte(bucketPath[0]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all its content
|
||||||
|
for {
|
||||||
|
_, _, err := cur.GetNext()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrorNotFound) {
|
||||||
|
break // Done
|
||||||
|
}
|
||||||
|
return fmt.Errorf("GetNext: %w", err) // real error
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cur.Del()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the root-level keyspace pointer
|
||||||
|
// In VoidDB you delete by moving the cursor to the target item first
|
||||||
|
_, err = tx.Get([]byte(bucketPath[0]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Del()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) NavContext(bucketPath []string) ([]contextAction, error) {
|
||||||
|
if ld.multiKeyspace {
|
||||||
|
if len(bucketPath) == 0 {
|
||||||
|
return []contextAction{
|
||||||
|
{"Add keyspace...", ld.addKeyspace},
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
return []contextAction{
|
||||||
|
{"Delete keyspace...", ld.deleteKeyspace},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil // No supported actions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *voidLdb) Close() {
|
||||||
|
_ = ld.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &voidLdb{} // interface assertion
|
||||||
|
var _ editableLoadedDatabase = &voidLdb{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
type voidDBConn struct {
|
||||||
|
Path autoconfig.ExistingFile
|
||||||
|
MultiKeyspace bool `ylabel:"Use multiple keyspaces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &voidDBConn{} // interface assertion
|
||||||
|
|
||||||
|
func (c *voidDBConn) String() string {
|
||||||
|
return filepath.Base(string(c.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *voidDBConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
|
||||||
|
const capacity = 1 << 40 // 1 TiB max size before returning ErrorFull
|
||||||
|
|
||||||
|
openFunc := voidDB.OpenVoid
|
||||||
|
if _, err := os.Stat(string(c.Path)); os.IsNotExist(err) {
|
||||||
|
openFunc = voidDB.NewVoid
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openFunc(string(c.Path), capacity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &voidLdb{db: db, multiKeyspace: c.MultiKeyspace}, nil
|
||||||
|
}
|
||||||
25
db_voiddb_other.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build !linux
|
||||||
|
// +build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/mappu/autoconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type voidDBConn struct {
|
||||||
|
H1 autoconfig.Header `ylabel:"Not supported on this operating system"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DBConnector = &voidDBConn{} // interface assertion
|
||||||
|
|
||||||
|
func (ssc *voidDBConn) String() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ssc *voidDBConn) Connect(ctx context.Context) (loadedDatabase, error) {
|
||||||
|
return nil, errors.New("Not supported on this operating system")
|
||||||
|
}
|
||||||
132
debconf/debconf.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package debconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultConfigDat = `/var/cache/debconf/config.dat`
|
||||||
|
DefaultPasswordsDat = `/var/cache/debconf/passwords.dat`
|
||||||
|
DefaultTemplatesDat = `/var/cache/debconf/templates.dat`
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Name string
|
||||||
|
Properties [][2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
Name string
|
||||||
|
Entries []Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
Entries []Application
|
||||||
|
AllColumnNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) FindApplicationByName(search string) (*Application, bool) {
|
||||||
|
for idx, app := range d.Entries {
|
||||||
|
if app.Name == search {
|
||||||
|
return &d.Entries[idx], true // TODO faster lookup?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(r io.Reader) (*Database, error) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
var wip Entry
|
||||||
|
var linenum int = 0
|
||||||
|
|
||||||
|
knownColumnNames := map[string]struct{}{
|
||||||
|
"Name": struct{}{},
|
||||||
|
}
|
||||||
|
var discoveredColumns []string = []string{"Name"}
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
linenum++
|
||||||
|
line := sc.Text()
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
if wip.Name != "" {
|
||||||
|
entries = append(entries, wip)
|
||||||
|
wip = Entry{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line[0] == ' ' {
|
||||||
|
// continuation of last text entry
|
||||||
|
if len(wip.Properties) == 0 {
|
||||||
|
return nil, fmt.Errorf("Continuation of nonexistent entry on line %d", linenum)
|
||||||
|
}
|
||||||
|
|
||||||
|
wip.Properties[len(wip.Properties)-1][1] += line[1:]
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// New pair on current element
|
||||||
|
key, rest, ok := strings.Cut(line, `:`)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Missing : on line %d", linenum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := knownColumnNames[key]; !ok {
|
||||||
|
knownColumnNames[key] = struct{}{}
|
||||||
|
discoveredColumns = append(discoveredColumns, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = strings.TrimLeft(rest, " \t")
|
||||||
|
|
||||||
|
if key == `Name` {
|
||||||
|
wip.Name = rest
|
||||||
|
} else {
|
||||||
|
wip.Properties = append(wip.Properties, [2]string{key, rest})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.Err() != nil {
|
||||||
|
return nil, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if wip.Name != "" {
|
||||||
|
entries = append(entries, wip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group all entries by Application
|
||||||
|
|
||||||
|
apps := make([]Application, 0)
|
||||||
|
appIndexes := make(map[string]int, 0)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
spos := strings.Index(entry.Name, `/`)
|
||||||
|
appName := entry.Name[0:spos]
|
||||||
|
|
||||||
|
idx, ok := appIndexes[appName]
|
||||||
|
if !ok {
|
||||||
|
appIndexes[appName] = len(apps)
|
||||||
|
apps = append(apps, Application{
|
||||||
|
Name: appName,
|
||||||
|
Entries: []Entry{entry},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
tmp := apps[idx]
|
||||||
|
tmp.Entries = append(tmp.Entries, entry)
|
||||||
|
apps[idx] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Database{
|
||||||
|
Entries: apps,
|
||||||
|
AllColumnNames: discoveredColumns,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
30
debconf/debconf_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package debconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDebconfParse(t *testing.T) {
|
||||||
|
src, err := os.Open(DefaultConfigDat)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
db, err := Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.Entries) == 0 {
|
||||||
|
t.Errorf("expected >0 entries, got %v", len(db.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.AllColumnNames) == 0 {
|
||||||
|
t.Errorf("expected >0 column names, got %v", len(db.AllColumnNames))
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
doc/image0.png
|
Before Width: | Height: | Size: 50 KiB |
BIN
doc/image1.png
|
Before Width: | Height: | Size: 90 KiB |
BIN
doc/image2.png
|
Before Width: | Height: | Size: 75 KiB |
BIN
doc/image3.png
|
Before Width: | Height: | Size: 59 KiB |
BIN
doc/screenshot-000.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
@@ -1,82 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func random_name() string {
|
|
||||||
ret := make([]byte, 12)
|
|
||||||
rand.Read(ret)
|
|
||||||
return string(ret)
|
|
||||||
//return fmt.Sprintf("%08x-%08x-%08x", rand.Int63(), rand.Int63(), rand.Int63())
|
|
||||||
}
|
|
||||||
|
|
||||||
func fill_bucket(tx *bolt.Tx, bucket *bolt.Bucket) error {
|
|
||||||
// fill with some basic items
|
|
||||||
for i := 0; i < 30; i += 1 {
|
|
||||||
err := bucket.Put([]byte(random_name()), []byte("SAMPLE CONTENT "+random_name()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1/20 (5%) chance of recursion
|
|
||||||
if rand.Intn(100) >= 95 {
|
|
||||||
|
|
||||||
for i := 0; i < 5; i += 1 {
|
|
||||||
|
|
||||||
child, err := bucket.CreateBucket([]byte(random_name()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fill_bucket(tx, child)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db, err := bolt.Open("sample.db", 0644, bolt.DefaultOptions)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Update(func(tx *bolt.Tx) error {
|
|
||||||
// top-level buckets
|
|
||||||
for i := 0; i < 50; i += 1 {
|
|
||||||
bucketName := random_name()
|
|
||||||
bucket, err := tx.CreateBucket([]byte(bucketName))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fill_bucket(tx, bucket)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
//go:generate miqt-rcc -Input "resources.qrc" -OutputGo "resources.go" -OutputRcc "resources.rcc" -Qt6
|
//go:generate miqt-rcc -Input "embed.qrc" -OutputGo "embed.go" -OutputRcc "embed.rcc" -Qt6
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
qt "github.com/mappu/miqt/qt6"
|
qt "github.com/mappu/miqt/qt6"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed resources.rcc
|
//go:embed embed.rcc
|
||||||
var _resourceRcc []byte
|
var _resourceRcc []byte
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
53
embed.qrc
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/">
|
||||||
|
<file>assets/add.png</file>
|
||||||
|
<file>assets/arrow_refresh.png</file>
|
||||||
|
<file>assets/chart_bar.png</file>
|
||||||
|
<file>assets/compress.png</file>
|
||||||
|
<file>assets/connect.png</file>
|
||||||
|
<file>assets/database.png</file>
|
||||||
|
<file>assets/database_add.png</file>
|
||||||
|
<file>assets/database_delete.png</file>
|
||||||
|
<file>assets/database_key.png</file>
|
||||||
|
<file>assets/database_lightning.png</file>
|
||||||
|
<file>assets/database_save.png</file>
|
||||||
|
<file>assets/delete.png</file>
|
||||||
|
<file>assets/disconnect.png</file>
|
||||||
|
<file>assets/help.png</file>
|
||||||
|
<file>assets/key.png</file>
|
||||||
|
<file>assets/lightning.png</file>
|
||||||
|
<file>assets/lightning_go.png</file>
|
||||||
|
<file>assets/note_delete.png</file>
|
||||||
|
<file>assets/page_key.png</file>
|
||||||
|
<file>assets/pencil.png</file>
|
||||||
|
<file>assets/pencil_add.png</file>
|
||||||
|
<file>assets/pencil_delete.png</file>
|
||||||
|
<file>assets/pencil_go.png</file>
|
||||||
|
<file>assets/resultset_next.png</file>
|
||||||
|
<file>assets/table.png</file>
|
||||||
|
<file>assets/table_add.png</file>
|
||||||
|
<file>assets/table_delete.png</file>
|
||||||
|
<file>assets/table_save.png</file>
|
||||||
|
<file>assets/vendor_buntdb.png</file>
|
||||||
|
<file>assets/vendor_cockroach.png</file>
|
||||||
|
<file>assets/vendor_debian.png</file>
|
||||||
|
<file>assets/vendor_dgraph.png</file>
|
||||||
|
<file>assets/vendor_etcd.png</file>
|
||||||
|
<file>assets/vendor_freedesktop.png</file>
|
||||||
|
<file>assets/vendor_github.png</file>
|
||||||
|
<file>assets/vendor_leveldb.png</file>
|
||||||
|
<file>assets/vendor_lmdb.png</file>
|
||||||
|
<file>assets/vendor_lotus.png</file>
|
||||||
|
<file>assets/vendor_mongodb.png</file>
|
||||||
|
<file>assets/vendor_mysql.png</file>
|
||||||
|
<file>assets/vendor_pogreb.png</file>
|
||||||
|
<file>assets/vendor_qt.png</file>
|
||||||
|
<file>assets/vendor_redis.png</file>
|
||||||
|
<file>assets/vendor_riak.png</file>
|
||||||
|
<file>assets/vendor_rosedb.png</file>
|
||||||
|
<file>assets/vendor_sqlite.png</file>
|
||||||
|
<file>assets/vendor_ssh.png</file>
|
||||||
|
<file>assets/vendor_starskey.png</file>
|
||||||
|
<file>assets/vendor_voiddb.png</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
167
export.go
@@ -1,167 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Bolt_ExportDatabaseToZip(dbpath, zippath string) error {
|
|
||||||
db, err := Bolt_Open(true, dbpath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error opening database: %w", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
fh, err := os.OpenFile(zippath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error opening output file: %w", err)
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
|
|
||||||
zw := zip.NewWriter(fh)
|
|
||||||
|
|
||||||
// Filenames in zip files cannot contain `/` characters. Mangle it
|
|
||||||
safename := func(n string) string {
|
|
||||||
return strings.ReplaceAll(string(n), `/`, `__`)
|
|
||||||
}
|
|
||||||
|
|
||||||
var process func(currentPath []string) error
|
|
||||||
process = func(currentPath []string) error {
|
|
||||||
return Bolt_ListBuckets(db, currentPath, func(bucket string) error {
|
|
||||||
|
|
||||||
// Create entry for our own bucket
|
|
||||||
|
|
||||||
ourBucket := zip.FileHeader{
|
|
||||||
Name: path.Join(path.Join(Apply(currentPath, safename)...), safename(bucket)) + `/`, // Trailing slash = directory
|
|
||||||
}
|
|
||||||
ourBucket.SetMode(fs.ModeDir | 0755)
|
|
||||||
_, err := zw.CreateHeader(&ourBucket)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Child pathspec
|
|
||||||
|
|
||||||
childPath := CopySliceAdd(currentPath, bucket)
|
|
||||||
|
|
||||||
// Create file entries for all non-bucket children
|
|
||||||
|
|
||||||
err = Bolt_ListItems(db, childPath, func(li ListItemInfo) error {
|
|
||||||
fileItem := zip.FileHeader{
|
|
||||||
Name: path.Join(path.Join(Apply(childPath, safename)...), safename(string(li.Name))),
|
|
||||||
}
|
|
||||||
fileItem.SetMode(0644)
|
|
||||||
fileW, err := zw.CreateHeader(&fileItem)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
buff, err := Bolt_GetItem(db, childPath, []byte(li.Name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.CopyN(fileW, bytes.NewReader(buff), li.DataLen)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse for all bucket-type children
|
|
||||||
|
|
||||||
process(childPath)
|
|
||||||
|
|
||||||
// Done
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
err = process([]string{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = zw.Flush()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = zw.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return fh.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bolt_ImportZipToDatabase(dbpath, zippath string) error {
|
|
||||||
|
|
||||||
db, err := Bolt_Open(false, dbpath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error opening target database: %w", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
fh, err := os.OpenFile(zippath, os.O_RDONLY, 0400)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error opening input archive: %w", err)
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
|
|
||||||
fstat, err := fh.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(fh, fstat.Size())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Reading zip file format: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, zf := range zr.File {
|
|
||||||
if strings.HasSuffix(zf.Name, `/`) || (zf.Mode()&fs.ModeDir) != 0 {
|
|
||||||
// Bucket
|
|
||||||
bucketPath := strings.Split(strings.TrimSuffix(zf.Name, `/`), `/`)
|
|
||||||
err = Bolt_CreateBucket(db, bucketPath[0:len(bucketPath)-1], bucketPath[len(bucketPath)-1])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Creating bucket %q: %w", zf.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Object
|
|
||||||
objectPath := strings.Split(zf.Name, `/`)
|
|
||||||
|
|
||||||
rc, err := zf.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := io.ReadAll(rc)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = Bolt_SetItem(db, objectPath[0:len(objectPath)-1], []byte(objectPath[len(objectPath)-1]), content)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rc.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
119
go.mod
@@ -1,12 +1,119 @@
|
|||||||
module code.ivysaur.me/qbolt
|
module qbolt
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.23.3
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mappu/miqt v0.10.0
|
github.com/akrylysov/pogreb v0.10.2
|
||||||
go.etcd.io/bbolt v1.4.0
|
github.com/cockroachdb/pebble v1.1.5
|
||||||
|
github.com/dgraph-io/badger/v4 v4.8.0
|
||||||
|
github.com/godbus/dbus/v5 v5.2.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/ledgerwatch/lmdb-go v1.18.2
|
||||||
|
github.com/lotusdblabs/lotusdb/v2 v2.1.0
|
||||||
|
github.com/mappu/autoconfig v0.6.1-0.20260124043120-621a5fcf917e
|
||||||
|
github.com/mappu/miqt v0.12.1-0.20251106063543-466b60e47edf
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
|
github.com/rosedblabs/rosedb/v2 v2.3.6
|
||||||
|
github.com/starskey-io/starskey v0.1.9
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/syndtr/goleveldb v1.0.0
|
||||||
|
github.com/tidwall/buntdb v1.3.2
|
||||||
|
github.com/voidDB/voidDB v0.1.18
|
||||||
|
github.com/zalando/go-keyring v0.2.6
|
||||||
|
go.etcd.io/bbolt v1.4.3
|
||||||
|
go.etcd.io/etcd/client/v2 v2.305.26
|
||||||
|
go.etcd.io/etcd/client/v3 v3.6.7
|
||||||
|
go.mills.io/bitcask/v2 v2.1.5
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.4.1
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.32.0 // indirect
|
require (
|
||||||
|
al.essio.dev/pkg/shellescape v1.6.0 // indirect
|
||||||
|
github.com/DataDog/zstd v1.5.7 // indirect
|
||||||
|
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cockroachdb/errors v1.12.0 // indirect
|
||||||
|
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect
|
||||||
|
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
|
||||||
|
github.com/cockroachdb/redact v1.1.6 // indirect
|
||||||
|
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect
|
||||||
|
github.com/coreos/go-semver v0.3.1 // indirect
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/getsentry/sentry-go v0.40.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/gofrs/flock v0.8.1 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/btree v1.1.2 // indirect
|
||||||
|
github.com/google/flatbuffers v25.9.23+incompatible // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.0.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/mattetti/filebuffer v1.0.1 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/rosedblabs/diskhash v0.0.0-20230910084041-289755737e2a // indirect
|
||||||
|
github.com/rosedblabs/wal v1.3.6 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
|
github.com/tidwall/btree v1.7.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.14.3 // indirect
|
||||||
|
github.com/tidwall/grect v0.1.4 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
|
github.com/tidwall/rtred v0.1.2 // indirect
|
||||||
|
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.etcd.io/etcd/api/v3 v3.6.7 // indirect
|
||||||
|
go.etcd.io/etcd/client/pkg/v3 v3.6.7 // indirect
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||||
|
google.golang.org/grpc v1.71.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||