From 188d6d1a3ec81ca98f82b807ba3bb848b9c5fb1e Mon Sep 17 00:00:00 2001 From: itwrx Date: Sun, 25 Jun 2023 16:34:23 -0500 Subject: [PATCH] initial commit --- CHANGELOG.md | 10 ++ ez_bkup.nim | 69 ++++++++++ icons.tar.gz | Bin 0 -> 4402 bytes icons/EZ-Bkup-icon.png | Bin 0 -> 2303 bytes icons/entity-edit-dark-theme.svg | 45 +++++++ icons/entity-edit.svg | 1 + models/routine.nim | 187 ++++++++++++++++++++++++++ shared.nim | 83 ++++++++++++ styles.css | 5 + views/app_menu_button.nim | 42 ++++++ views/edit_routine_dialog.nim | 41 ++++++ views/routine_editor.nim | 221 +++++++++++++++++++++++++++++++ views/routine_list.nim | 219 ++++++++++++++++++++++++++++++ 13 files changed, 923 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 ez_bkup.nim create mode 100644 icons.tar.gz create mode 100644 icons/EZ-Bkup-icon.png create mode 100644 icons/entity-edit-dark-theme.svg create mode 100644 icons/entity-edit.svg create mode 100644 models/routine.nim create mode 100644 shared.nim create mode 100644 styles.css create mode 100644 views/app_menu_button.nim create mode 100644 views/edit_routine_dialog.nim create mode 100644 views/routine_editor.nim create mode 100644 views/routine_list.nim diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9287ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.9.1 - 6-25-23 + +### Changed +- separate logging procs into Error and Info varieties. (ITwrx) + +### Added +- ask-pass path resolution for various distros. (ITwrx) +- log sqlite3 access error/dependancy requirement on failed create/read. (ITwrx) \ No newline at end of file diff --git a/ez_bkup.nim b/ez_bkup.nim new file mode 100644 index 0000000..e718d89 --- /dev/null +++ b/ez_bkup.nim @@ -0,0 +1,69 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import owlkettle +import "models/routine", "shared" +import views/[edit_routine_dialog, routine_list, app_menu_button] + +const APP_NAME = "EZ-Bkup" +let databasePath = appPath & "/ez-bkup.sqlite" + +viewable App: + ## The main application + routineModel: RoutineModel ## The RoutineModel that stores all routines. + +method view(app: AppState): Widget {.locks: "unknown".} = + result = gui: + Window: + title = APP_NAME + defaultSize = (1024, 768) + HeaderBar {.addTitlebar.}: + Icon {.addLeft.}: + name = "EZ-Bkup-icon" + pixelSize = 40 + margin = 4 + # Button to open the main menu + AppMenuButton {.addRight.}: + routineModel = app.routineModel + Box: + orient = OrientY + spacing = 6 + #don't use available vertical space from parent. Only what's needed for children. + Box(orient = OrientX) {.expand: false.}: + Label: + text = "Bkup Routines" + xAlign = 0.05 + useMarkup = true + #expand horizontally to push buttons to the right. + Box(orient = OrientX) {.expand: true.} + #expanding box containing buttons would fight the previous box for available horiz space placing buttons in the middle. + Box(orient = OrientX, spacing = 6, margin = 6) {.expand: false.}: + # Button to create a new routine + Button: + style = [ButtonSuggested] + text = "+ Routine" + proc clicked() = + ## Opens the EditRoutineDialog for creating a new routine + let (res, state) = app.open: gui: + EditRoutineDialog(mode = EditRoutineCreate) + if res.kind == DialogAccept: + # The "Create" button was clicked + app.routineModel.add(EditRoutineDialogState(state).routine) + + # Main content of the window: Contains the list of all routines. + Box: + orient = OrientY + margin = 6 + spacing = 12 + Frame: + RoutineList: + routineModel = app.routineModel + +when isMainModule: + # Entry point for the application. + # Loads the model from the database and starts the application. + let model = newRoutineModel(databasePath) + brew("org.itwrx.EZ-Bkup", gui(App(routineModel = model)), icons=["icons/"], stylesheets=[loadStylesheet("styles.css")]) + \ No newline at end of file diff --git a/icons.tar.gz b/icons.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..5c4e006239d65c9acb10d128fc9706836741627b GIT binary patch literal 4402 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 literal 0 HcmV?d00001 diff --git a/icons/entity-edit-dark-theme.svg b/icons/entity-edit-dark-theme.svg new file mode 100644 index 0000000..6697db5 --- /dev/null +++ b/icons/entity-edit-dark-theme.svg @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/icons/entity-edit.svg b/icons/entity-edit.svg new file mode 100644 index 0000000..2574d1d --- /dev/null +++ b/icons/entity-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/models/routine.nim b/models/routine.nim new file mode 100644 index 0000000..737d9e3 --- /dev/null +++ b/models/routine.nim @@ -0,0 +1,187 @@ +import std/[tables, strutils, sugar, hashes, algorithm, os] +from std/sequtils import toSeq +import tiny_sqlite +import "../shared" + +# We create a custom type for representing the ID of a routine. +# This way Nim's type system automatically prevents us from using it as a regular integer and +# from confusing it with the ID of another entity. + +type RoutineId* = distinct int64 + +proc `==`*(a, b: RoutineId): bool {.borrow.} +proc hash*(id: RoutineId): Hash {.borrow.} +proc `$`*(id: RoutineId): string = + result = "routine" & $int64(id) + +type Routine* = object + id*: RoutineId + name*: string + selected*: bool + #selByDef == selected by default. + selByDef*: bool + sources*: seq[string] + destinations*: seq[string] + +proc stringToSeq(commaSepString: string):seq[string] = + #return toSeq(commaSepString.split(',')) + var newSeq: seq[string] + if hasCommas(commaSepString) == true: + newSeq = toSeq(commaSepString.split(',')) + else: + newSeq = @[commaSepString] + + return newSeq + +proc seqToString(strSeq: seq[string]):string = + #return strSeq.join(",") + var newStr: string + if strSeq.len >= 1: + newStr = strSeq.join(",") + else: + newStr = "" + + return newStr + +#[proc matches*(routine: Routine, filter: string): bool = + ## Checks if the routine matches the given filter. + ## This function is used to search the list of routines. + filter.toLowerAscii() in toLowerAscii(routine.name)]# + +type RoutineModel* = ref object + ## Model for storing all routines. We model this as a ref object, so that changes + ## made by any widget are also known to all other widgets that use the model. + + db: DbConn + routines*: Table[RoutineId, Routine] + +proc cmpRoutines(a, b: Routine): int = + cmp(a.name, b.name) + + +#this is our stand-in for the search proc. returns a seq so we can iterate in routine_list. +proc routineSeq*(model: RoutineModel): seq[Routine] = + var routineSeq: seq[Routine] + ## Returns a seq of all routines. "id" is necessary to loop through table. + for id, routine in model.routines: + routineSeq.add(routine) + + routineSeq.sort(cmpRoutines, Ascending) + + #echo routineSeq + + return routineSeq + +let homePath = expandTilde("~") + +proc newRoutineModel*(path: string = homePath & "/.ez-bkup/ez-bkup.sqlite"): RoutineModel = + ## Load a RoutineModel from a database + + var db: DbConn + try: + db = openDatabase(path) + except: + writeErrorToLog("Unable to open and/or create sqlite3 database. Please make sure sqlite3 is installed.") + db = openDatabase("") + + # Create the Routine table + db.exec(""" + CREATE TABLE IF NOT EXISTS Routine( + id INTEGER PRIMARY KEY, + name TEXT, + selByDef INTEGER, + sources TEXT, + destinations TEXT + ) + """) + + # Load all existing routines into RoutineModel.routines + var routines = initTable[RoutineId, Routine]() + for row in db.iterate("SELECT id, name, selByDef, sources, destinations FROM Routine"): + let (id, name, selByDef, sources, destinations) = row.unpack((RoutineId, string, bool, string, string)) + + var sourcesSeq: seq[string] + #no sources exist in DB. + if sources == "": + sourcesSeq = @[] + else: + #convert "sources" comma-separated string to seq[string]. + sourcesSeq = stringToSeq(sources) + + var destinationsSeq: seq[string] + #no destinations exist in DB. + if destinations == "": + destinationsSeq = @[] + else: + #convert "destinations" comma-separated string to seq[string]. + destinationsSeq = stringToSeq(destinations) + + routines[id] = Routine(id: id, name: name, selByDef: selByDef, sources: sourcesSeq, destinations: destinationsSeq) + + #echo "newRoutineModel:" + #echo routines + + result = RoutineModel(db: db, routines: routines) + +proc add*(model: RoutineModel, routine: Routine) = + ## Adds a new routine to the model + + #convert routine.sources seq[string] to a comma-separated string to store as TEXT in sqlite. + let stringSources = seqToString(routine.sources) + + #convert routine.destinations seq[string] to a comma-separated string to store as TEXT in sqlite. + let stringDestinations = seqToString(routine.destinations) + + # Insert new routine into database + model.db.exec( + "INSERT INTO Routine (name, selByDef, sources, destinations) VALUES (?, ?, ?, ?)", + routine.name, routine.selByDef, stringSources, stringDestinations + ) + + # Insert new routine into RoutineModel.routines + let id = RoutineId(model.db.lastInsertRowId) + model.routines[id] = routine.dup(id = id) + +proc update*(model: RoutineModel, routine: Routine) = + ## Updates an existing routine. Routines are compared by their ID. + + #convert routine.sources seq[string] to a comma-separated string to store as TEXT in sqlite. + let stringSources = seqToString(routine.sources) + + #convert routine.destinations seq[string] to a comma-separated string to store as TEXT in sqlite. + let stringDestinations = seqToString(routine.destinations) + + # Update routine in database + model.db.exec( + "UPDATE Routine SET name = ?, selByDef = ?, sources = ?, destinations = ? WHERE id = ?", + routine.name, routine.selByDef, stringSources, stringDestinations, routine.id + ) + + # Update RoutineModel.routines + model.routines[routine.id] = routine + +#[proc search*(model: UserModel, filter: string): seq[User] {.locks: 0.} = + ## Returns a seq of all users that match the given filter. + for id, user in model.users: + if user.matches(filter): + result.add(user) + + result.sort((a, b: User) => cmp(a.lastName, b.lastName))]# + +proc delete*(model: RoutineModel, id: RoutineId) = + ## Deletes the routine with the given ID + + # Delete routine from database + model.db.exec("DELETE FROM Routine WHERE id = ?", id) + + # Update RoutineModel.routines + model.routines.del(id) + +proc clear*(model: RoutineModel) = + ## Deletes all routines + + # Delete all routines from database + model.db.exec("DELETE FROM Routine") + + # Update RoutineModel.routines + model.routines.clear() \ No newline at end of file diff --git a/shared.nim b/shared.nim new file mode 100644 index 0000000..7276958 --- /dev/null +++ b/shared.nim @@ -0,0 +1,83 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import logging, times, os, distros +#import "models/routine" + +#store the RoutineIds of ea. Routine selected for the current Bkup run. +#[type SelectedRoutine* = object + id*: RoutineId + name*: string + sources*: seq[string] + destinations*: seq[string] + +type CurrentRun* = object + status*: string]# + +let appPath* = getHomeDir() & ".ez-bkup" + +if not dirExists(appPath): + createDir(appPath) + +var logger = newFileLogger(appPath & "/errors.log") +let dt = now() +let nowDT = dt.format("M-d-YYYY h:mm:ss tt") +var logMsg: string + +proc writeErrorToLog*(logMsg: string) = + logger.log(lvlError, logMsg) + +proc writeInfoToLog*(logMsg: string) = + logger.log(lvlInfo, logMsg) + +proc hasCommas*(filename: string):bool = + ',' in filename + +proc getAskPassPath*(): string = + var askPassPath: string + if detectOs(Fedora): + if fileExists("/usr/libexec/openssh/ssh-askpass"): + askPassPath = "/usr/libexec/openssh/ssh-askpass" + else: + writeErrorToLog("No ssh-askpass binary found. Please run 'dnf install openssh-askpass'") + askPassPath = "" + elif detectOs(Ubuntu): + if fileExists("/usr/lib/openssh/gnome-ssh-askpass"): + askPassPath = "/usr/lib/openssh/gnome-ssh-askpass" + else: + writeErrorToLog("No ssh-askpass binary found. Please run 'sudo apt install ssh-askpass-gnome'.") + askPassPath = "" + #save this for a musl build. + #[elif detectOs(Alpine): + if fileExists("/usr/lib/ssh/gtk-ssh-askpass"): + askPassPath = "/usr/lib/ssh/gtk-ssh-askpass" + else: + writeErrorToLog("No ssh-askpass binary found. Please run 'apk add gtk-ssh-askpass'.") + askPassPath = ""]# + elif detectOs(ArchLinux): + if fileExists("/usr/lib/ssh/ssh-askpass"): + askPassPath = "/usr/lib/ssh/ssh-askpass" + else: + writeErrorToLog("No ssh-askpass binary found. Please run 'pacman -S x11-ssh-askpass', or similar.") + askPassPath = "" + elif detectOs(Linux): + if fileExists("/usr/libexec/openssh/ssh-askpass"): + askPassPath = "/usr/libexec/openssh/ssh-askpass" + elif fileExists("/usr/lib/openssh/gnome-ssh-askpass"): + askPassPath = "/usr/lib/openssh/gnome-ssh-askpass" + elif fileExists("/usr/lib/ssh/gtk-ssh-askpass"): + askPassPath = "/usr/lib/ssh/gtk-ssh-askpass" + elif fileExists("/usr/lib/ssh/ssh-askpass"): + askPassPath = "/usr/lib/ssh/ssh-askpass" + else: + writeErrorToLog("No ssh-askpass binary found. Please install an ssh-askpass package for your distro, and let us know if EZ-Bkup still can't detect it's location.") + askPassPath = "" + else: + writeErrorToLog("Your OS does not appear to be supported at this time. If you are getting this error and you are using a x86_64 gnu libc-based linux distribution please report this issue. Please include the path to your ssh-askpass binary, as well.") + askPassPath = "" + + return askPassPath + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c0b9d29 --- /dev/null +++ b/styles.css @@ -0,0 +1,5 @@ +.aboutdialog box stack {font-size: 22px;} +.aboutdialog box label {font-size: 20px;} +.aboutdialog headerbar label {font-size: 16px;} +.aboutdialog stack scrolledwindow viewport grid {font-size: 22px;} +.error {color: #ff6b6b;} diff --git a/views/app_menu_button.nim b/views/app_menu_button.nim new file mode 100644 index 0000000..7422a28 --- /dev/null +++ b/views/app_menu_button.nim @@ -0,0 +1,42 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import owlkettle +import ../models/routine + +viewable AppMenuButton: + ## A button that opens the main menu of the application + + routineModel: RoutineModel + +method view(button: AppMenuButtonState): Widget {.locks: "unknown".} = + result = gui: + MenuButton: + icon = "open-menu-symbolic" + + # A menu is created using the PopoverMenu widget. + # It allows us to create menus & submenus. + PopoverMenu: + Box: + orient = OrientY + Box(orient = OrientX, spacing=6, margin=6) {.expand: true.}: + Button: + icon = "help-about-symbolic" + style = [ButtonSuggested] + proc clicked() = + discard button.app.open: gui: + AboutDialog: + programName = "EZ-Bkup" + logo = "EZ-Bkup-icon" + style = [StyleClass("about-dialog")] + version = "0.9.0" + credits = @{ + "Created By": @["https://ITwrx.org"], + "License": @["GPLv3"] + } + Label: + text = "About EZ-Bkup" + +export AppMenuButton diff --git a/views/edit_routine_dialog.nim b/views/edit_routine_dialog.nim new file mode 100644 index 0000000..c46322b --- /dev/null +++ b/views/edit_routine_dialog.nim @@ -0,0 +1,41 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import owlkettle +import ../models/routine +import routine_editor + +type EditRoutineDialogMode* = enum + EditRoutineCreate = "Create" + EditRoutineUpdate = "Update" + +viewable EditRoutineDialog: + ## A dialog for editing a routine. We use the same dialog for creating and updating + ## a routine. Since we want to use different titles and labels for the buttons in each case, + ## the EditRoutineDialog.mode field specifies the purpose of the dialog. + + routine: Routine ## The routine being edited + mode: EditRoutineDialogMode ## Purpose of the dialog (create/update) + +method view(dialog: EditRoutineDialogState): Widget {.locks: "unknown".} = + result = gui: + Dialog: + title = $dialog.mode & " Routine" + defaultSize = (800, 600) + DialogButton {.addButton.}: + # Create / Update Button + text = $dialog.mode + style = [ButtonSuggested] + res = DialogAccept + DialogButton {.addButton.}: + text = "Cancel" + res = DialogCancel + # Content + RoutineEditor: + routine = dialog.routine + proc changed(routine: Routine) = + dialog.routine = routine + +export EditRoutineDialog, EditRoutineDialogState diff --git a/views/routine_editor.nim b/views/routine_editor.nim new file mode 100644 index 0000000..8f5afe8 --- /dev/null +++ b/views/routine_editor.nim @@ -0,0 +1,221 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import owlkettle +import "../models/routine", "../shared" + +viewable RoutineEditor: + ## A form for editing a Routine + + routine: Routine ## The routine that is being edited + + # Since Routine is passed by value, we need to notify the parent widget of + # any changes to the routine. The changed callback + proc changed(routine: Routine) + +method view(editor: RoutineEditorState): Widget {.locks: "unknown".} = + result = gui: + #must have top level container that everything else is inside of. + Box: + orient = OrientY + margin = 12 + spacing = 12 + Box {.expand: false.}: + orient = OrientX + spacing = 6 + Label {.expand: false.}: + text = "Name:" + xAlign = 0 + useMarkup = true + Entry {.expand: false.}: + text = editor.routine.name + xAlign = 0 + proc changed(text: string) = + editor.routine.name = text + # Call the changed callback + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Box {.expand: false.}: + orient = OrientX + spacing = 6 + Label {.expand: false.}: + #later, this may be "selected by default?", instead. + #"selected" checkboxes wiould be added to the list view. + #to allow the user to select which routines are used for a given run. + text = "Enabled?" + xAlign = 0 + useMarkup = true + Switch {.expand: false.}: + state = editor.routine.selByDef + proc changed(state: bool) = + editor.routine.selByDef = state + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Label {.expand: false.}: + text = "Sources:" + xAlign = 0 + useMarkup = true + Box {.expand: false.}: + spacing = 6 + Button {.expand: false.}: + text = "+ File" + style = [ButtonSuggested] + proc clicked() = + let (res, state) = editor.app.open: gui: + FileChooserDialog: + title = "Add Source Files to Routine" + action = FileChooserOpen + selectMultiple = true + DialogButton {.addButton.}: + text = "Cancel" + res = DialogCancel + DialogButton {.addButton.}: + text = "Confirm" + res = DialogAccept + style = [ButtonSuggested] + if res.kind == DialogAccept: + for filename in FileChooserDialogState(state).filenames: + if hasCommas(filename): + discard editor.app.open: gui: + MessageDialog: + message = "Commas in Source filename/path are not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + elif filename in editor.routine.sources: + discard editor.app.open: gui: + MessageDialog: + message = "Duplicate Routine Source filenames (" & filename & ") are not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + else: + editor.routine.sources.add(filename) + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Button {.expand: false.}: + text = "+ Folder" + style = [ButtonSuggested] + proc clicked() = + let (res, state) = editor.app.open: gui: + FileChooserDialog: + title = "Add Source Folders to Routine" + action = FileChooserSelectFolder + selectMultiple = true + DialogButton {.addButton.}: + text = "Cancel" + res = DialogCancel + DialogButton {.addButton.}: + text = "Confirm" + res = DialogAccept + style = [ButtonSuggested] + if res.kind == DialogAccept: + for filename in FileChooserDialogState(state).filenames: + if hasCommas(filename): + discard editor.app.open: gui: + MessageDialog: + message = "Commas in Source filename/path are not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + elif filename in editor.routine.sources: + discard editor.app.open: gui: + MessageDialog: + message = "Duplicate Routine Source (" & filename & ") not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + else: + editor.routine.sources.add(filename) + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Box {.expand: false.}: + orient = OrientY + margin = 6 + spacing = 6 + #routineSeq returns an empty seq when no sources exist. + if editor.routine.sources.len != 0: + for it, routineSource in editor.routine.sources: + Box: + orient = OrientX + spacing = 6 + Label: + text = routineSource + xAlign = 0 + Button {.expand: false.}: + icon = "user-trash-symbolic" + proc clicked() = + editor.routine.sources.delete(it) + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Label {.expand: false.}: + text = "Destinations:" + xAlign = 0 + useMarkup = true + Box {.expand: false.}: + spacing = 6 + Button {.expand: false.}: + text = "+ Folder" + style = [ButtonSuggested] + proc clicked() = + let (res, state) = editor.app.open: gui: + FileChooserDialog: + title = "Add Destination Folders to Routine" + action = FileChooserSelectFolder + selectMultiple = true + DialogButton {.addButton.}: + text = "Cancel" + res = DialogCancel + DialogButton {.addButton.}: + text = "Confirm" + res = DialogAccept + style = [ButtonSuggested] + if res.kind == DialogAccept: + for filename in FileChooserDialogState(state).filenames: + if hasCommas(filename): + discard editor.app.open: gui: + MessageDialog: + message = "Commas in Destination filename/path are not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + elif filename in editor.routine.destinations: + discard editor.app.open: gui: + MessageDialog: + message = "Duplicate Routine Destination (" & filename & ") not supported." + style = [StyleClass("error")] + DialogButton {.addButton.}: + text = "Ok" + res = DialogAccept + else: + editor.routine.destinations.add(filename) + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + Box {.expand: false.}: + orient = OrientY + margin = 6 + spacing = 6 + #routineSeq returns an empty seq when no destinations exist. + if editor.routine.destinations.len != 0: + for it, routineDestination in editor.routine.destinations: + Box: + orient = OrientX + spacing = 6 + Label: + text = routineDestination + xAlign = 0 + Button {.expand: false.}: + icon = "user-trash-symbolic" + proc clicked() = + editor.routine.destinations.delete(it) + if not editor.changed.isNil: + editor.changed.callback(editor.routine) + +export RoutineEditor \ No newline at end of file diff --git a/views/routine_list.nim b/views/routine_list.nim new file mode 100644 index 0000000..01e648b --- /dev/null +++ b/views/routine_list.nim @@ -0,0 +1,219 @@ +#[Copyright 2023 ITwrx. +This file is part of EZ-Bkup. +EZ-Bkup is released under the General Public License 3.0. +See COPYING or for details.]# + +import owlkettle +import std/osproc +import edit_routine_dialog +import "../models/routine", "../shared" + +##preload selectedRoutines using routines' selByDef field value from DB. +#var selectedRoutines: seq[SelectedRoutine] + +viewable RoutineList: + ## Displays a list of routines + + #filter: string ## Filter used to search for routines + routineModel: RoutineModel ## Model of all routines + #selectedRoutines: selectedRoutines + #selectedRoutines: seq[SelectedRoutine] + runStatus: string + + #proc changed(currentRun: CurrentRun) + proc changed(state: bool) + +method view(list: RoutineListState): Widget {.locks: "unknown".} = + result = gui: + ScrolledWindow: + Box: + orient = OrientY + if list.routineModel.routineSeq().len() > 0: + ListBox: + #for routine in list.model.search(list.filter): + for it, routine in list.routineModel.routineSeq(): + Box: + orient = OrientY + margin = 6 + spacing = 6 + Box: + orient = OrientX + margin = 6 + spacing = 6 + #[Switch {.expand: false.}: + state = routine.selByDef + proc changed(state: bool) = + #if state == true: + #list.selectedRoutines.ids.add(routine.id) + + if not list.changed.isNil: + list.changed.callback(list.selectedRoutines) ]# + #[CheckButton {.expand: false.}: + state = routine.selected + #if routine.selected == true: + #echo list.currentRun.selected + #list.currentRun.selected[it] = true + #state = t + #state = list.currentRun.selectedRoutines[it] + proc changed(state: bool) = + list.currentRun.selectedRoutines.add(routine.id) + if routine.id in list.currentRun.selectedRoutines: + = state + #echo "hehe" + #list.currentRun.selectedRoutines[it] = state ]# + + Label: + #text = "" & routine.name & "" + text = "" & routine.name & "" + xAlign = 0 # Align left + useMarkup = true + # Edit Button + Button {.expand: false.}: + #icon = "entity-edit" + icon = "entity-edit-dark-theme" + proc clicked() = + ## Opens the EditRoutineDialog for updating the existing routine + let (res, state) = list.app.open: gui: + EditRoutineDialog: + routine = routine + mode = EditRoutineUpdate + if res.kind == DialogAccept: + # The "Update" button was clicked + list.routineModel.update(EditRoutineDialogState(state).routine) + # Delete Button + Button {.expand: false.}: + icon = "user-trash-symbolic" + proc clicked() = + list.routineModel.delete(routine.id) + if routine.selByDef: + Box: + orient = OrientY + margin = 6 + spacing = 6 + Label: + text = "Sources:" + xAlign = 0 + useMarkup = true + #routineSeq returns an empty string when no sources exist. + if routine.sources.len != 0: + for it, routineSource in routine.sources: + Box: + orient = OrientX + spacing = 6 + margin = 6 + Label: + text = routineSource + xAlign = 0 + else: + Box: + orient = OrientX + spacing = 6 + margin = 6 + Label: + text = "Routine will be ignored. Source required." + xAlign = 0 + useMarkup = true + Label: + text = "Destinations:" + xAlign = 0 + useMarkup = true + #routineSeq returns an empty string when no destinations exist. + if routine.destinations.len != 0: + for it, routineDestination in routine.destinations: + Box: + orient = OrientX + spacing = 6 + margin = 6 + Label: + text = routineDestination + xAlign = 0 + else: + Box: + orient = OrientX + spacing = 6 + margin = 6 + Label: + text = "Routine will be ignored. Destination required." + xAlign = 0 + useMarkup = true + Box {.expand: false.}: + orient = OrientX + spacing = 6 + margin = 6 + Label {.expand: false.}: + text = "Status: " + xAlign = 0 + useMarkup = true + Label: + if list.runStatus != "": + text = list.runStatus + else: + text = "Waiting patiently..." + xAlign = 0 + margin = 6 + useMarkup = true + # Run Button + Button {.expand: false.}: + Box: + orient = OrientX + spacing = 6 + margin = 6 + Icon: + name = "media-floppy" + pixelSize = 40 + margin = 4 + Label {.expand: false.}: + text = "Run Bkup!" + xAlign = 0 + useMarkup = true + proc clicked() = + list.runStatus = "Running Bkup..." + var rsyncRun: tuple[output: string, exitCode: int] + var rsyncErrors: seq[string] + var routineRunCount = 0 + for routine in list.routineModel.routineSeq(): + #using this as "selected" for now. + if routine.selByDef == true: + #skip routines that don't have at least one source and one destination. + #list.routineModel.routineSeq() returns an empty seq so len != 0 when empty... + if routine.sources.len != 0 and routine.destinations.len != 0: + routineRuncount += 1 + for source in routine.sources: + for destination in routine.destinations: + list.runStatus = "Bkup " & source & " to " & destination & "..." + #try without requiring superuser privs by default. + rsyncRun = execCmdEx("rsync -aq " & source & " " & destination) + if rsyncRun.exitCode != 0: + #handle permission denied error. + if rsyncRun.exitCode == 23: + let rsyncRunCmd = "SUDO_ASKPASS=" & getAskPassPath() & " sudo -A rsync -aq " & source & " " & destination + rsyncRun = execCmdEx(rsyncRunCmd) + if rsyncRun.exitCode != 0: + rsyncErrors.add("EZ-Bkup's rsync process(es) returned error (" & $rsyncRun.output & ") while attempting to back up " & source & " to " & destination) + else: + rsyncErrors.add("EZ-Bkup's rsync process(es) returned error (" & $rsyncRun.output & ") while attempting to back up " & source & " to " & destination) + #makes the "Bkup Complete" msg below wait on the rsyncRun to finish. + if rsyncRun.exitCode == 1 or rsyncRun.exitCode == 0: + if rsyncErrors.len > 0: + list.runStatus = "Error! Please see the log at ~/.ez-bkup/errors.log" + for err in rsyncErrors: + writeErrorToLog(err) + echo err + elif routineRunCount == 0: + list.runStatus = "Meh. No Bkup Routines were run." + else: + list.runStatus = "Bkup Complete!" + else: + Box {.expand: false.}: + orient = OrientY + margin = 6 + spacing = 12 + Label: + text = "You don't have any Bkup Routines!!! 🙂" + xAlign = 0 + useMarkup = true + Label: + text = "Please click on the + Routine button above to create your first Routine." + xAlign = 0 + useMarkup = true +export RoutineList