"Browse"-button on directory-selection dialogs
This commit is contained in:
parent
a1c5fbf7ea
commit
845b7e9c9a
4 changed files with 115 additions and 51 deletions
|
@ -1,5 +1,9 @@
|
||||||
# Changelog Backuppy
|
# Changelog Backuppy
|
||||||
|
|
||||||
|
## [0.8] - 2021-05-07
|
||||||
|
### Added
|
||||||
|
- Introduce "Browse"-button on directory-selection dialogs
|
||||||
|
|
||||||
## [0.7] - 2021-05-06
|
## [0.7] - 2021-05-06
|
||||||
### Added
|
### Added
|
||||||
- Reworked "install.sh" to call "install.py"
|
- Reworked "install.sh" to call "install.py"
|
||||||
|
|
76
install.py
76
install.py
|
@ -1,9 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
project: Backuppy
|
project: Backuppy
|
||||||
version: 0.7
|
version: 0.8
|
||||||
file: install.py
|
file: install.py
|
||||||
summary: main entry python file
|
summary: python installer-script in CLI-mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
|
@ -17,23 +17,28 @@ from languages import german
|
||||||
|
|
||||||
# local globals
|
# local globals
|
||||||
# ----------------------
|
# ----------------------
|
||||||
VERSION: str = "0.7"
|
VERSION: str = "0.8"
|
||||||
# ----------------------
|
EMAIL = "fotocoder@joschu.ch"
|
||||||
MYDIR = os.getcwd()
|
|
||||||
EXCLUDE_FILE = "exclude.txt"
|
EXCLUDE_FILE = "exclude.txt"
|
||||||
BACKUPPY_SCRIPT = "Backuppy.sh"
|
BACKUPPY_SCRIPT = "Backuppy.sh"
|
||||||
|
# ----------------------
|
||||||
|
MYDIR = os.getcwd()
|
||||||
|
EXCLUDE: bool = False
|
||||||
SHELL = os.environ.get("SHELL")
|
SHELL = os.environ.get("SHELL")
|
||||||
HOME = os.environ.get("HOME")
|
HOME = os.environ.get("HOME")
|
||||||
LANG_EN: str = "English"
|
LANG_EN = "English"
|
||||||
LANG_DE: str = "German"
|
LANG_DE = "German"
|
||||||
LANGUAGE: str = None
|
LANGUAGE = LANG_EN
|
||||||
RSYNC_CMD: str = None
|
RSYNC_CMD: str = None
|
||||||
EMAIL: str = "fotocoder@joschu.ch"
|
|
||||||
|
|
||||||
def set_language(language):
|
def set_language(language):
|
||||||
global LANGUAGE
|
global LANGUAGE
|
||||||
LANGUAGE = language
|
LANGUAGE = language
|
||||||
|
|
||||||
|
def set_exclude(exclude_flag):
|
||||||
|
global EXCLUDE
|
||||||
|
EXCLUDE = exclude_flag
|
||||||
|
|
||||||
def trace(message_txt):
|
def trace(message_txt):
|
||||||
""" Print a formatted message to std out. """
|
""" Print a formatted message to std out. """
|
||||||
print("[ OK ] " + message_txt)
|
print("[ OK ] " + message_txt)
|
||||||
|
@ -46,7 +51,7 @@ def get_lang_text(search_str: str):
|
||||||
return_str = eval("german." + search_str)
|
return_str = eval("german." + search_str)
|
||||||
return return_str
|
return return_str
|
||||||
|
|
||||||
def install_cli_main():
|
def main_install_cli():
|
||||||
language = input("Hello, first of all, which language do you prefer: German [DE] or English [EN]?\n> ")
|
language = input("Hello, first of all, which language do you prefer: German [DE] or English [EN]?\n> ")
|
||||||
if language.upper() == "DE":
|
if language.upper() == "DE":
|
||||||
set_language(LANG_DE)
|
set_language(LANG_DE)
|
||||||
|
@ -69,9 +74,10 @@ def install_cli_main():
|
||||||
# asks if you want to exclude files/directories from backup and creates an exclude file in case of Yes
|
# asks if you want to exclude files/directories from backup and creates an exclude file in case of Yes
|
||||||
exclude = input(get_lang_text("excludefile1") + "\n> ")
|
exclude = input(get_lang_text("excludefile1") + "\n> ")
|
||||||
if exclude.upper() in ("J", "Y"):
|
if exclude.upper() in ("J", "Y"):
|
||||||
|
set_exclude(True)
|
||||||
print(get_lang_text("excludefile2") + "\n")
|
print(get_lang_text("excludefile2") + "\n")
|
||||||
os.environ["BUPY_CREATE_EXCLUDE"] = "True"
|
|
||||||
else:
|
else:
|
||||||
|
set_exclude(False)
|
||||||
print(get_lang_text("excludefile3") + "\n")
|
print(get_lang_text("excludefile3") + "\n")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
@ -94,7 +100,7 @@ def install_cli_main():
|
||||||
exclude_file = os.path.join(MYDIR, EXCLUDE_FILE)
|
exclude_file = os.path.join(MYDIR, EXCLUDE_FILE)
|
||||||
|
|
||||||
RSYNC_CMD = f"rsync -aqp --exclude-from={exclude_file} {sourcedir} {targetdir}"
|
RSYNC_CMD = f"rsync -aqp --exclude-from={exclude_file} {sourcedir} {targetdir}"
|
||||||
os.environ["BUPY_RSYNC_CMD"] = RSYNC_CMD
|
|
||||||
print(f"{RSYNC_CMD}")
|
print(f"{RSYNC_CMD}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
@ -103,25 +109,25 @@ def install_cli_main():
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
print(get_lang_text("outro2") + " " + EMAIL)
|
print(get_lang_text("outro2") + " " + EMAIL)
|
||||||
|
|
||||||
|
return True, EXCLUDE, RSYNC_CMD
|
||||||
|
|
||||||
def create_exclude_file(directory, exclude_file):
|
def create_exclude_file(directory, exclude_file):
|
||||||
exclude_file = os.path.join(directory, exclude_file)
|
exclude_file = os.path.join(directory, exclude_file)
|
||||||
with open(exclude_file, "w") as fExclude:
|
with open(exclude_file, "w") as fExclude:
|
||||||
trace(f"creating exclude-file '{exclude_file}'.")
|
trace(f"creating exclude-file '{exclude_file}'.")
|
||||||
fExclude.write("\n")
|
fExclude.write("\n")
|
||||||
|
|
||||||
return exclude_file
|
def create_alias(shell, home_dir, directory, backuppy_script):
|
||||||
|
|
||||||
def create_alias(directory, backuppy_script):
|
|
||||||
# alias entry in .bashrc or .zshrc
|
# alias entry in .bashrc or .zshrc
|
||||||
backuppy_script = os.path.join(directory, backuppy_script)
|
backuppy_script = os.path.join(directory, backuppy_script)
|
||||||
alias_str = f"alias backuppy='sudo {backuppy_script}'"
|
alias_str = f"alias backuppy='sudo {backuppy_script}'"
|
||||||
|
|
||||||
# Check for installed ZSH
|
# Check for installed ZSH
|
||||||
if SHELL.upper().find("ZSH") > 0:
|
if shell.upper().find("ZSH") > 0:
|
||||||
rc_filepath = os.path.join(HOME, ".zshrc")
|
rc_filepath = os.path.join(home_dir, ".zshrc")
|
||||||
# Check for installed BASH
|
# Check for installed BASH
|
||||||
if SHELL.upper().find("BASH") > 0:
|
if shell.upper().find("BASH") > 0:
|
||||||
rc_filepath = os.path.join(HOME, ".bashrc")
|
rc_filepath = os.path.join(home_dir, ".bashrc")
|
||||||
# Append our alias if not already existing
|
# Append our alias if not already existing
|
||||||
if os.path.isfile(rc_filepath):
|
if os.path.isfile(rc_filepath):
|
||||||
fileRc = open(rc_filepath, "r") # open file in read mode
|
fileRc = open(rc_filepath, "r") # open file in read mode
|
||||||
|
@ -145,29 +151,37 @@ def create_backuppy_script(directory, backuppy_script, rsync_cmd):
|
||||||
|
|
||||||
os.chmod(backuppy_file, 0o777) # make file executable
|
os.chmod(backuppy_file, 0o777) # make file executable
|
||||||
|
|
||||||
def do_the_install():
|
def do_the_install(is_exclude: bool, rsync_cmd: str):
|
||||||
""" Does the things with our environment variables. """
|
""" Creates scripts and entries based on environment variables. """
|
||||||
|
|
||||||
is_exclude = os.environ.get("BUPY_CREATE_EXCLUDE")
|
if is_exclude:
|
||||||
rsync_cmd = os.environ.get("BUPY_RSYNC_CMD")
|
create_exclude_file(MYDIR, EXCLUDE_FILE)
|
||||||
|
|
||||||
if is_exclude == "True":
|
if rsync_cmd:
|
||||||
exclude_file = create_exclude_file(MYDIR, EXCLUDE_FILE)
|
create_backuppy_script(MYDIR, BACKUPPY_SCRIPT, rsync_cmd)
|
||||||
create_alias(MYDIR, BACKUPPY_SCRIPT)
|
create_alias(SHELL, HOME, MYDIR, BACKUPPY_SCRIPT)
|
||||||
create_backuppy_script(MYDIR, BACKUPPY_SCRIPT, rsync_cmd)
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
trace(f"Starting Backuppy install.py v{VERSION}")
|
trace(f"Starting Backuppy install.py v{VERSION}")
|
||||||
|
is_finalized = False
|
||||||
|
|
||||||
if argv and argv[0] == "--gui":
|
if argv and argv[0] == "--gui":
|
||||||
import install_gui
|
from install_gui import main_install_gui
|
||||||
trace("Starting GUI-version.\n")
|
trace("Starting GUI-version.\n")
|
||||||
install_gui.main() # collect user input via GUI and store in env. variables
|
is_finalized, is_exclude, rsync_cmd = main_install_gui() # collect user input via GUI and store in env. variables
|
||||||
|
|
||||||
else:
|
else:
|
||||||
trace("Starting CLI-version.\n")
|
trace("Starting CLI-version.\n")
|
||||||
install_cli_main() # collect user input via CLI and store in env. variables
|
is_finalized, is_exclude, rsync_cmd = main_install_cli() # collect user input via CLI and store in env. variables
|
||||||
|
if is_finalized:
|
||||||
|
print("CLI finalized.")
|
||||||
|
if is_exclude:
|
||||||
|
print("exclude is true.")
|
||||||
|
if rsync_cmd:
|
||||||
|
print("rsync command returned: " + rsync_cmd)
|
||||||
|
|
||||||
do_the_install()
|
if is_finalized:
|
||||||
|
do_the_install(is_exclude, rsync_cmd)
|
||||||
|
|
||||||
trace("Ending Backuppy install.py")
|
trace("Ending Backuppy install.py")
|
||||||
|
|
||||||
|
|
16
install.sh
16
install.sh
|
@ -1,14 +1,28 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# """
|
||||||
|
# project: Backuppy
|
||||||
|
# version: 0.8
|
||||||
|
# file: install.sh
|
||||||
|
# summary: main entry shell script
|
||||||
|
# """
|
||||||
|
|
||||||
# Check if graphical installer should be executed
|
# Check if graphical installer should be executed
|
||||||
if [ "$1" == "--gui" ]; then
|
if [ "$1" == "--gui" ]; then
|
||||||
|
# Check if PIP ist installed
|
||||||
if ! command -v pip3> /dev/null
|
if ! command -v pip3> /dev/null
|
||||||
then
|
then
|
||||||
echo "Please install PIP on your system for the graphical version of Backuppy!"
|
echo "Please install PIP on your system for the graphical version of Backuppy!"
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
pip3 install pyside2
|
# Check if PIP module "PySide2" is installed
|
||||||
|
if ! pip list | grep PySide2> /dev/null
|
||||||
|
then
|
||||||
|
# Install PySide2
|
||||||
|
pip3 install PySide2
|
||||||
|
fi
|
||||||
|
# Launch python installer in GUI mode
|
||||||
python3 -B install.py "$1"
|
python3 -B install.py "$1"
|
||||||
else
|
else
|
||||||
|
# Launch python installer in CLI mode
|
||||||
python3 -B install.py
|
python3 -B install.py
|
||||||
fi
|
fi
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
project: Backuppy
|
project: Backuppy
|
||||||
version: 0.7
|
version: 0.8
|
||||||
file: install_gui.py
|
file: install_gui.py
|
||||||
summary: main entry python file
|
summary: python installer-script in GUI-mode (needs PySide2)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
|
@ -19,6 +19,8 @@ class BackuppyWizard(QtWidgets.QWizard):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.setWindowTitle(get_lang_text("intromsg1"))
|
||||||
|
|
||||||
self.addPage(Page01(self))
|
self.addPage(Page01(self))
|
||||||
self.addPage(Page02(self))
|
self.addPage(Page02(self))
|
||||||
self.addPage(Page03(self))
|
self.addPage(Page03(self))
|
||||||
|
@ -30,14 +32,14 @@ class BackuppyWizard(QtWidgets.QWizard):
|
||||||
self.addPage(Page09(self))
|
self.addPage(Page09(self))
|
||||||
self.addPage(Page10(self))
|
self.addPage(Page10(self))
|
||||||
|
|
||||||
self.setWindowTitle(english.intromsg1)
|
|
||||||
self.resize(640, 480)
|
self.resize(640, 480)
|
||||||
|
|
||||||
|
|
||||||
class Page01(QtWidgets.QWizardPage):
|
class Page01(QtWidgets.QWizardPage):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.label = QtWidgets.QLabel("Hello, first of all, which language do you prefer: English or German?")
|
self.label = QtWidgets.QLabel("Hello, first of all, which language do you prefer: English or German?")
|
||||||
# self.comboBox = QIComboBox(self)
|
|
||||||
self.comboBox = QtWidgets.QComboBox(self)
|
self.comboBox = QtWidgets.QComboBox(self)
|
||||||
self.comboBox.addItem(LANG_EN, LANG_EN)
|
self.comboBox.addItem(LANG_EN, LANG_EN)
|
||||||
self.comboBox.addItem(LANG_DE, LANG_DE)
|
self.comboBox.addItem(LANG_DE, LANG_DE)
|
||||||
|
@ -66,6 +68,8 @@ class Page02(QtWidgets.QWizardPage):
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def initializePage(self):
|
def initializePage(self):
|
||||||
|
self.setWindowTitle(get_lang_text("intromsg1"))
|
||||||
|
|
||||||
self.label1.setText(get_lang_text("languagepack"))
|
self.label1.setText(get_lang_text("languagepack"))
|
||||||
self.label2.setText(get_lang_text("intromsg2"))
|
self.label2.setText(get_lang_text("intromsg2"))
|
||||||
|
|
||||||
|
@ -97,13 +101,13 @@ class Page03(QtWidgets.QWizardPage):
|
||||||
self.radio1.setChecked(True)
|
self.radio1.setChecked(True)
|
||||||
|
|
||||||
def validatePage(self):
|
def validatePage(self):
|
||||||
global EXCLUDE
|
|
||||||
if self.radio1.isChecked():
|
if self.radio1.isChecked():
|
||||||
EXCLUDE = True
|
set_exclude(True)
|
||||||
else:
|
else:
|
||||||
EXCLUDE = False
|
set_exclude(False)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Page04(QtWidgets.QWizardPage):
|
class Page04(QtWidgets.QWizardPage):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
@ -116,7 +120,6 @@ class Page04(QtWidgets.QWizardPage):
|
||||||
global EXCLUDE
|
global EXCLUDE
|
||||||
if EXCLUDE:
|
if EXCLUDE:
|
||||||
self.label.setText(get_lang_text("excludefile2"))
|
self.label.setText(get_lang_text("excludefile2"))
|
||||||
os.environ["BUPY_CREATE_EXCLUDE"] = "True"
|
|
||||||
else:
|
else:
|
||||||
self.label.setText(get_lang_text("excludefile3"))
|
self.label.setText(get_lang_text("excludefile3"))
|
||||||
|
|
||||||
|
@ -125,20 +128,33 @@ class Page05(QtWidgets.QWizardPage):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.label1 = QtWidgets.QLabel()
|
self.label1 = QtWidgets.QLabel()
|
||||||
self.label2 = QtWidgets.QLabel()
|
self.label2 = QtWidgets.QLabel()
|
||||||
self.edit = QtWidgets.QLineEdit()
|
self.efSourceDir = QtWidgets.QLineEdit()
|
||||||
|
self.pbBrowse = QtWidgets.QPushButton("Browse")
|
||||||
|
self.pbBrowse.clicked.connect(self.on_pbBrowse_clicked)
|
||||||
layout = QtWidgets.QVBoxLayout()
|
layout = QtWidgets.QVBoxLayout()
|
||||||
layout.addWidget(self.label1)
|
layout.addWidget(self.label1)
|
||||||
layout.addWidget(self.label2)
|
layout.addWidget(self.label2)
|
||||||
layout.addWidget(self.edit)
|
|
||||||
|
hLayout = QtWidgets.QHBoxLayout()
|
||||||
|
hLayout.addWidget(self.efSourceDir)
|
||||||
|
hLayout.addWidget(self.pbBrowse)
|
||||||
|
|
||||||
|
layout.addLayout(hLayout)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def initializePage(self):
|
def initializePage(self):
|
||||||
self.label1.setText(get_lang_text("srcdir1"))
|
self.label1.setText(get_lang_text("srcdir1"))
|
||||||
self.label2.setText(get_lang_text("srcdir2"))
|
self.label2.setText(get_lang_text("srcdir2"))
|
||||||
|
|
||||||
|
def on_pbBrowse_clicked(self):
|
||||||
|
options = QtWidgets.QFileDialog.Options() | QtWidgets.QFileDialog.ShowDirsOnly
|
||||||
|
dirName = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Directory", None, options)
|
||||||
|
self.efSourceDir.setText(dirName)
|
||||||
|
|
||||||
def validatePage(self):
|
def validatePage(self):
|
||||||
global SOURCEDIR
|
global SOURCEDIR
|
||||||
SOURCEDIR = self.edit.text()
|
SOURCEDIR = self.efSourceDir.text()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -166,18 +182,31 @@ class Page07(QtWidgets.QWizardPage):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.label1 = QtWidgets.QLabel()
|
self.label1 = QtWidgets.QLabel()
|
||||||
self.edit = QtWidgets.QLineEdit()
|
self.efTargetDir = QtWidgets.QLineEdit()
|
||||||
|
self.pbBrowse = QtWidgets.QPushButton("Browse")
|
||||||
|
self.pbBrowse.clicked.connect(self.on_pbBrowse_clicked)
|
||||||
layout = QtWidgets.QVBoxLayout()
|
layout = QtWidgets.QVBoxLayout()
|
||||||
layout.addWidget(self.label1)
|
layout.addWidget(self.label1)
|
||||||
layout.addWidget(self.edit)
|
|
||||||
|
hLayout = QtWidgets.QHBoxLayout()
|
||||||
|
hLayout.addWidget(self.efTargetDir)
|
||||||
|
hLayout.addWidget(self.pbBrowse)
|
||||||
|
|
||||||
|
layout.addLayout(hLayout)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def initializePage(self):
|
def initializePage(self):
|
||||||
self.label1.setText(get_lang_text("targetdir1"))
|
self.label1.setText(get_lang_text("targetdir1"))
|
||||||
|
|
||||||
|
def on_pbBrowse_clicked(self):
|
||||||
|
options = QtWidgets.QFileDialog.Options() | QtWidgets.QFileDialog.ShowDirsOnly
|
||||||
|
dirName = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Directory", None, options)
|
||||||
|
self.efTargetDir.setText(dirName)
|
||||||
|
|
||||||
def validatePage(self):
|
def validatePage(self):
|
||||||
global TARGETDIR
|
global TARGETDIR
|
||||||
TARGETDIR = self.edit.text()
|
TARGETDIR = self.efTargetDir.text()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -205,7 +234,6 @@ class Page08(QtWidgets.QWizardPage):
|
||||||
exclude_file = os.path.join(MYDIR, EXCLUDE_FILE)
|
exclude_file = os.path.join(MYDIR, EXCLUDE_FILE)
|
||||||
|
|
||||||
RSYNC_CMD = f"rsync -aqp --exclude-from={exclude_file} {SOURCEDIR} {TARGETDIR}"
|
RSYNC_CMD = f"rsync -aqp --exclude-from={exclude_file} {SOURCEDIR} {TARGETDIR}"
|
||||||
os.environ["BUPY_RSYNC_CMD"] = RSYNC_CMD
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -239,6 +267,7 @@ class Page10(QtWidgets.QWizardPage):
|
||||||
layout.addWidget(self.label2)
|
layout.addWidget(self.label2)
|
||||||
layout.addWidget(self.label3)
|
layout.addWidget(self.label3)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
self.setFinalPage(True)
|
self.setFinalPage(True)
|
||||||
|
|
||||||
def initializePage(self):
|
def initializePage(self):
|
||||||
|
@ -248,8 +277,11 @@ class Page10(QtWidgets.QWizardPage):
|
||||||
self.label3.setText(urlLink)
|
self.label3.setText(urlLink)
|
||||||
self.label3.setOpenExternalLinks(True)
|
self.label3.setOpenExternalLinks(True)
|
||||||
|
|
||||||
|
def validatePage(self):
|
||||||
|
return True, EXCLUDE, RSYNC_CMD
|
||||||
|
|
||||||
def main():
|
|
||||||
|
def main_install_gui():
|
||||||
app = QtWidgets.QApplication()
|
app = QtWidgets.QApplication()
|
||||||
wizard = BackuppyWizard()
|
wizard = BackuppyWizard()
|
||||||
wizard.show()
|
wizard.show()
|
||||||
|
@ -257,4 +289,4 @@ def main():
|
||||||
app.exec_()
|
app.exec_()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main_install_gui()
|
||||||
|
|
Loading…
Reference in a new issue