From 9ac26467c0a0cf0b9a37890d430b9532d95a1cfd Mon Sep 17 00:00:00 2001 From: mappu Date: Thu, 17 Apr 2025 21:26:27 +1200 Subject: [PATCH] add zip roundtrip conversion feature --- export.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++ mainwindow.go | 54 +++++++++++++-- mainwindow.ui | 22 ++++++- mainwindow_ui.go | 19 +++++- util.go | 18 +++++ 5 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 export.go diff --git a/export.go b/export.go new file mode 100644 index 0000000..acb35b7 --- /dev/null +++ b/export.go @@ -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 +} diff --git a/mainwindow.go b/mainwindow.go index 75ea6fd..73cee4c 100644 --- a/mainwindow.go +++ b/mainwindow.go @@ -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 "" +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) { diff --git a/mainwindow.ui b/mainwindow.ui index 1e39ecc..f67bf6e 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -269,17 +269,27 @@ 0 0 668 - 29 + 22 &File + + + Convert + + + + + + + @@ -407,6 +417,16 @@ Open database as read-only... + + + Export database as zip + + + + + Create database from zip + + diff --git a/mainwindow_ui.go b/mainwindow_ui.go index 6f63687..e60b5c9 100644 --- a/mainwindow_ui.go +++ b/mainwindow_ui.go @@ -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")) } diff --git a/util.go b/util.go index 7418981..c806888 100644 --- a/util.go +++ b/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 +}