add zip roundtrip conversion feature

This commit is contained in:
mappu 2025-04-17 21:26:27 +12:00
parent cbfc038839
commit 9ac26467c0
5 changed files with 272 additions and 8 deletions

167
export.go Normal file

@ -0,0 +1,167 @@
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
}

@ -2,8 +2,8 @@ package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
qt "github.com/mappu/miqt/qt6"
@ -40,6 +40,8 @@ func NewMainWindow() *MainWindow {
this.ui.actionNew_database.OnTriggered(this.on_actionNew_database_triggered)
this.ui.actionOpen_database.OnTriggered(this.on_actionOpen_database_triggered)
this.ui.actionOpen_database_as_read_only.OnTriggered(this.on_actionOpen_database_as_read_only_triggered)
this.ui.actionExport_database_as_zip.OnTriggered(this.onactionExport_database_as_zip_triggered)
this.ui.actionCreate_database_from_zip.OnTriggered(this.onactionCreate_database_from_zip_triggered)
this.ui.actionExit.OnTriggered(this.on_actionExit_triggered)
this.ui.actionAbout_Qt.OnTriggered(this.on_actionAbout_Qt_triggered)
this.ui.actionAbout_qbolt.OnTriggered(this.on_actionAbout_qbolt_triggered)
@ -134,13 +136,53 @@ func (this *MainWindow) openDatabase(file string, readOnly bool) {
this.ui.bucketTree.ExpandItem(top)
}
func getDisplayName(qba string) string {
if qba == "" {
return "<empty>"
func (this *MainWindow) onactionExport_database_as_zip_triggered() {
dbPath := qt.QFileDialog_GetOpenFileName2(this.Widget(), "Select bolt database...")
if dbPath == "" {
return
}
ret := strconv.Quote(qba)
return ret[1 : len(ret)-1]
zipPath := qt.QFileDialog_GetSaveFileName4(this.Widget(), "Save as...", "", "Zip files (*.zip)")
if zipPath == "" {
return
}
err := Bolt_ExportDatabaseToZip(dbPath, zipPath)
if err != nil {
this.alert(fmt.Sprintf("Error exporting database as zip: %s", err.Error()))
return
}
this.alert("Exported as zip successfully.")
}
func (this *MainWindow) onactionCreate_database_from_zip_triggered() {
zipPath := qt.QFileDialog_GetOpenFileName4(this.Widget(), "Select zip archive...", "", "Zip files (*.zip)")
if zipPath == "" {
return
}
dbPath := qt.QFileDialog_GetSaveFileName2(this.Widget(), "Save as...")
if dbPath == "" {
return
}
// Qt popped up a message saying 'will overwrite existing'
// Make that true
if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) {
this.alert(fmt.Sprintf("Error removing existing database for overwrite: %s", err.Error()))
return
}
err := Bolt_ImportZipToDatabase(dbPath, zipPath)
if err != nil {
this.alert(fmt.Sprintf("Error importing database from zip %q: %s", zipPath, err.Error()))
return
}
this.alert("Imported zip to database successfully.")
}
func (this *MainWindow) refreshBucketTree(itm *qt.QTreeWidgetItem) {

@ -269,17 +269,27 @@
<x>0</x>
<y>0</y>
<width>668</width>
<height>29</height>
<height>22</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>&amp;File</string>
</property>
<widget class="QMenu" name="menuConvert">
<property name="title">
<string>Convert</string>
</property>
<addaction name="actionExport_database_as_zip"/>
<addaction name="actionCreate_database_from_zip"/>
</widget>
<addaction name="actionNew_database"/>
<addaction name="actionOpen_database"/>
<addaction name="actionOpen_database_as_read_only"/>
<addaction name="separator"/>
<addaction name="menuConvert"/>
<addaction name="separator"/>
<addaction name="separator"/>
<addaction name="actionExit"/>
</widget>
<widget class="QMenu" name="menuHelp">
@ -407,6 +417,16 @@
<string>Open database as read-only...</string>
</property>
</action>
<action name="actionExport_database_as_zip">
<property name="text">
<string>Export database as zip</string>
</property>
</action>
<action name="actionCreate_database_from_zip">
<property name="text">
<string>Create database from zip</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<resources>

@ -36,6 +36,7 @@ type MainWindowUi struct {
DeleteDataButton *qt.QPushButton
menuBar *qt.QMenuBar
menuFile *qt.QMenu
menuConvert *qt.QMenu
menuHelp *qt.QMenu
menuView *qt.QMenu
mainToolBar *qt.QToolBar
@ -51,6 +52,8 @@ type MainWindowUi struct {
actionNew_database *qt.QAction
actionAdd_bucket *qt.QAction
actionOpen_database_as_read_only *qt.QAction
actionExport_database_as_zip *qt.QAction
actionCreate_database_from_zip *qt.QAction
}
// NewMainWindowUi creates all Qt widget classes for MainWindow.
@ -110,6 +113,10 @@ func NewMainWindowUi() *MainWindowUi {
ui.actionOpen_database_as_read_only = qt.NewQAction()
ui.actionExport_database_as_zip = qt.NewQAction()
ui.actionCreate_database_from_zip = qt.NewQAction()
ui.centralWidget = qt.NewQWidget(ui.MainWindow.QWidget)
ui.gridLayout = qt.NewQGridLayout(ui.centralWidget)
@ -217,13 +224,20 @@ func NewMainWindowUi() *MainWindowUi {
ui.MainWindow.SetCentralWidget(ui.centralWidget) // Set central widget
ui.menuBar = qt.NewQMenuBar(ui.MainWindow.QWidget)
ui.menuBar.Resize(668, 29)
ui.menuBar.Resize(668, 22)
ui.menuFile = qt.NewQMenu(ui.menuBar.QWidget)
ui.menuConvert = qt.NewQMenu(ui.menuFile.QWidget)
ui.menuConvert.QWidget.AddAction(ui.actionExport_database_as_zip)
ui.menuConvert.QWidget.AddAction(ui.actionCreate_database_from_zip)
ui.menuFile.QWidget.AddAction(ui.actionNew_database)
ui.menuFile.QWidget.AddAction(ui.actionOpen_database)
ui.menuFile.QWidget.AddAction(ui.actionOpen_database_as_read_only)
ui.menuFile.AddSeparator()
ui.menuFile.AddMenu(ui.menuConvert)
ui.menuFile.AddSeparator()
ui.menuFile.AddSeparator()
ui.menuFile.QWidget.AddAction(ui.actionExit)
ui.menuHelp = qt.NewQMenu(ui.menuBar.QWidget)
@ -272,6 +286,8 @@ func (ui *MainWindowUi) Retranslate() {
ui.actionNew_database.SetText(qt.QMainWindow_Tr("&New database..."))
ui.actionAdd_bucket.SetText(qt.QMainWindow_Tr("Add bucket..."))
ui.actionOpen_database_as_read_only.SetText(qt.QMainWindow_Tr("Open database as read-only..."))
ui.actionExport_database_as_zip.SetText(qt.QMainWindow_Tr("Export database as zip"))
ui.actionCreate_database_from_zip.SetText(qt.QMainWindow_Tr("Create database from zip"))
ui.bucketTree.HeaderItem().SetText(0, qt.QTreeWidget_Tr("Bucket"))
ui.databaseTabWidget.SetTabText(ui.databaseTabWidget.IndexOf(ui.databasePropertiesTab), qt.QTabWidget_Tr("Database"))
ui.databasePropertiesArea.SetPlainText(qt.QWidget_Tr("No selection"))
@ -282,6 +298,7 @@ func (ui *MainWindowUi) Retranslate() {
ui.AddDataButton.SetText(qt.QWidget_Tr("Add..."))
ui.DeleteDataButton.SetText(qt.QWidget_Tr("Delete..."))
ui.menuFile.SetTitle(qt.QMenuBar_Tr("&File"))
ui.menuConvert.SetTitle(qt.QMenu_Tr("Convert"))
ui.menuHelp.SetTitle(qt.QMenuBar_Tr("Help"))
ui.menuView.SetTitle(qt.QMenuBar_Tr("&View"))
}

18
util.go

@ -20,3 +20,21 @@ func ReverseSlice[S ~[]E, E any](s S) {
s[i], s[j] = s[j], s[i]
}
}
// CopySliceAdd copies a slice and adds one more thing on the end.
func CopySliceAdd[T comparable](base []T, and T) []T {
ret := make([]T, len(base)+1)
copy(ret, base)
ret[len(ret)-1] = and
return ret
}
// Apply creates a new slice where every element of the input arr is transformed.
func Apply[T comparable](arr []T, transform func(T) T) []T {
ret := make([]T, len(arr))
for i := 0; i < len(arr); i++ {
ret[i] = transform(arr[i])
}
return ret
}