class REDTabImportExportImport(QWidget, Ui_REDTabImportExportImport):
    def __init__(self):
        QWidget.__init__(self)

        self.setupUi(self)

        self.session             = None # Set from REDTabImportExport
        self.script_manager      = None # Set from REDTabImportExport
        self.image_version       = None # Set from REDTabImportExport
        self.refresh_in_progress = False
        self.progress            = None
        self.chunked_uploader    = None

        self.button_browse_archive.clicked.connect(self.browse_archive)
        self.edit_archive.textChanged.connect(self.update_ui_state)
        self.tree_programs.setColumnWidth(0, 150)
        self.tree_programs.setColumnWidth(1, 150)
        self.tree_programs.setColumnWidth(2, 150)
        self.tree_programs.itemSelectionChanged.connect(self.update_ui_state)
        self.button_refresh_programs.clicked.connect(self.refresh_program_list)
        self.button_import.clicked.connect(self.import_archive)

        self.update_ui_state()

    def tab_on_focus(self):
        QTimer.singleShot(1, self.refresh_program_list)

    def tab_off_focus(self):
        pass

    def tab_destroy(self):
        pass

    def update_ui_state(self):
        if self.refresh_in_progress:
            self.edit_archive.setEnabled(False)
            self.button_browse_archive.setEnabled(False)
            self.progress_refresh_programs.setVisible(True)
            self.button_refresh_programs.setText('Refreshing...')
            self.button_refresh_programs.setEnabled(False)
            self.button_import.setEnabled(False)
        else:
            self.edit_archive.setEnabled(True)
            self.button_browse_archive.setEnabled(True)
            self.progress_refresh_programs.setVisible(False)
            self.button_refresh_programs.setText('Refresh')
            self.button_refresh_programs.setEnabled(len(self.edit_archive.text()) > 0)
            self.button_import.setEnabled(len(self.tree_programs.selectedItems()) > 0)

    def browse_archive(self):
        if len(self.edit_archive.text()) > 0:
            last_directory = os.path.dirname(os.path.realpath(self.edit_archive.text()))
        else:
            last_directory = get_home_path()

        filename = get_open_file_name(get_main_window(), 'Open Archive', last_directory, '*.tfrba')

        if len(filename) > 0:
            self.edit_archive.setText(filename)
            self.refresh_program_list()

    def refresh_program_list(self):
        filename = self.edit_archive.text()

        if len(filename) == 0:
            return

        def refresh_async():
            try:
                a = tarfile.open(filename, 'r:gz')
            except Exception as e:
                return [], 'Could not open archive:\n\n{0}'.format(e)

            with contextlib.closing(a):
                try:
                    v = a.extractfile('tfrba-version')
                except Exception as e:
                    return [], 'Could not extract tfrba-version:\n\n{0}'.format(e)

                version = v.read()
                v.close()

                if version != b'1':
                    return [], 'Unknown tfrba-version {0}'.format(version)

                programs = {}

                for member in a.getnames():
                    if member.startswith('programs/') and member.endswith('/program.conf'):
                        try:
                            c = a.extractfile(member)
                        except Exception as e:
                            return [], 'Could not extract {0}:\n\n{1}'.format(member, e)

                        conf = list(map(lambda line: line.decode('utf-8'), c.readlines()))
                        c.close()

                        name         = '<unknown>'
                        identifier   = member.split('/')[1]
                        language     = '<unknown>'
                        first_upload = 0

                        for line in conf:
                            if line.startswith('custom.name ='):
                                try:
                                    name = line[len('custom.name ='):].strip().encode('ascii').decode('unicode_escape')
                                except:
                                    pass
                            elif line.startswith('custom.language ='):
                                try:
                                    language = Constants.get_language_display_name(line[len('custom.language ='):].strip().encode('ascii').decode('unicode_escape'))
                                except:
                                    pass
                            elif line.startswith('custom.first_upload ='):
                                try:
                                    first_upload = int(line[len('custom.first_upload ='):].strip())
                                except:
                                    pass

                        programs[identifier] = [name, identifier, language, 'New', first_upload]

                try:
                    existing_programs = get_lite_programs(self.session)
                except Exception as e:
                    return [], 'Could not get existing program list:\n\n{0}'.format(e)

                for existing_program in existing_programs:
                    identifier = existing_program.identifier

                    if identifier in programs:
                        programs[identifier][3] = 'Existing'

                return programs.values(), None

        def cb_success(result):
            programs, message = result

            if message != None:
                QMessageBox.critical(get_main_window(), 'Import Error', message)
            else:
                sorted_programs = {}

                for program in programs:
                    first_upload = program[4]

                    if first_upload in sorted_programs:
                        sorted_programs[first_upload][program[1]] = program
                    else:
                        sorted_programs[first_upload] = {program[1]: program}

                for first_upload in sorted(sorted_programs.keys()):
                    for identifier in sorted(sorted_programs[first_upload].keys()):
                        program = sorted_programs[first_upload][identifier]
                        item    = QTreeWidgetItem(program[0:4])

                        self.tree_programs.addTopLevelItem(item)

                        item.setSelected(True)

            self.refresh_in_progress = False
            self.update_ui_state()
            self.tree_programs.setFocus()

        def cb_error(error):
            pass # FIXME: report error

        self.refresh_in_progress = True
        self.update_ui_state()
        self.tree_programs.invisibleRootItem().takeChildren()

        async_call(refresh_async, None, cb_success, cb_error, pass_exception_to_error_callback=True)

    def import_archive(self):
        source_path = self.edit_archive.text()

        if len(source_path) == 0:
            return

        script_instance_ref = [None]

        def progress_canceled():
            chunked_uploader = self.chunked_uploader

            if chunked_uploader != None:
                chunked_uploader.canceled = True

            script_instance = script_instance_ref[0]

            if script_instance != None:
                self.script_manager.abort_script(script_instance)

        self.progress = ExpandingProgressDialog(self)
        self.progress.set_progress_text_visible(False)
        self.progress.setModal(True)
        self.progress.setWindowTitle('Import Archive')
        self.progress.setLabelText('Step 1 of 4: Creating import directory')
        self.progress.setRange(0, 0)
        self.progress.setAutoClose(False)
        self.progress.canceled.connect(progress_canceled)
        self.progress.show()

        selected_identifiers = []

        for selected_item in self.tree_programs.selectedItems():
            selected_identifiers.append(selected_item.text(1))

        import_directory_ref = [None]

        def extract_archive():
            def cb_import_extract(result):
                script_instance = script_instance_ref[0]

                if script_instance != None:
                    aborted = script_instance.abort
                else:
                    aborted = False

                script_instance_ref[0] = None

                if aborted:
                    return

                if not report_script_result(result, 'Import Error', 'Could not extract archive',
                                            before_message_box=self.progress.close):
                    return

                def cb_restart_reboot_shutdown(result):
                    self.progress.close()

                    report_script_result(result, 'Import Error', 'Could not reboot RED Brick to finish program import')

                # step 4/4: reboot
                self.progress.setLabelText('Step 4 of 4: Rebooting RED Brick')
                self.progress.setRange(0, 0)

                self.script_manager.execute_script('restart_reboot_shutdown_systemd',
                                                   cb_restart_reboot_shutdown, ['1'])

                def close_progress():
                    # use a closure to capture self and ansure that it's safe
                    # to call this even if the tab was official destroyed already
                    self.progress.close()

                QTimer.singleShot(1500, close_progress)

            # step 3/4: extract uploaded archive
            self.progress.setLabelText('Step 3 of 4: Extracting archive')
            self.progress.setRange(0, 0)

            script_instance_ref[0] = self.script_manager.execute_script('import_extract', cb_import_extract,
                                                                        [import_directory_ref[0]] + selected_identifiers)

        def cb_import_directory(result):
            script_instance = script_instance_ref[0]

            if script_instance != None:
                aborted = script_instance.abort
            else:
                aborted = False

            script_instance_ref[0] = None

            if aborted:
                return

            if not report_script_result(result, 'Import Error', 'Could not create import directory',
                                        before_message_box=self.progress.close):
                return

            # step 2/4: upload archive to temporary import directory
            import_directory_ref[0] = result.stdout.strip()
            target_path             = posixpath.join(import_directory_ref[0], 'archive.tfrba')
            self.chunked_uploader   = ChunkedUploader(self, extract_archive)

            if not self.chunked_uploader.prepare(source_path):
                return

            try:
                target_file = REDFile(self.session).open(target_path, REDFile.FLAG_WRITE_ONLY | REDFile.FLAG_CREATE | REDFile.FLAG_NON_BLOCKING | REDFile.FLAG_EXCLUSIVE, 0o644, 1000, 1000) # FIXME: async_call
            except (Error, REDError) as e:
                QMessageBox.information(get_main_window(), 'Import Error',
                                        'Could not open target file {0}: {1}'.format(target_path, e))
                return

            self.progress.setLabelText('Step 2 of 4: Uploading archive')
            self.progress.set_progress_text_visible(True)
            self.chunked_uploader.start(target_path, target_file)

        # step 1/4: create temporary import directory
        script_instance_ref[0] = self.script_manager.execute_script('import_directory', cb_import_directory, execute_as_user=True)
Example #2
0
class REDTabImportExportExport(QWidget, Ui_REDTabImportExportExport):
    def __init__(self):
        QWidget.__init__(self)

        self.setupUi(self)

        self.session             = None # Set from REDTabImportExport
        self.script_manager      = None # Set from REDTabImportExport
        self.image_version       = None # Set from REDTabImportExport
        self.first_tab_on_focus  = True
        self.refresh_in_progress = False
        self.last_directory      = get_home_path()
        self.progress            = None
        self.chunked_downloader  = None

        self.tree_programs.setColumnWidth(0, 150)
        self.tree_programs.setColumnWidth(1, 150)
        self.tree_programs.itemSelectionChanged.connect(self.update_ui_state)
        self.button_refresh_programs.clicked.connect(self.refresh_program_list)
        self.button_export.clicked.connect(self.export_archive)

        self.update_ui_state()

    def tab_on_focus(self):
        QTimer.singleShot(1, self.refresh_program_list)

    def tab_off_focus(self):
        pass

    def tab_destroy(self):
        pass

    def update_ui_state(self):
        if self.refresh_in_progress:
            self.progress_refresh_programs.setVisible(True)
            self.button_refresh_programs.setText('Refreshing...')
            self.button_refresh_programs.setEnabled(False)
            self.button_export.setEnabled(False)
        else:
            self.progress_refresh_programs.setVisible(False)
            self.button_refresh_programs.setText('Refresh')
            self.button_refresh_programs.setEnabled(True)
            self.button_export.setEnabled(len(self.tree_programs.selectedItems()) > 0)

    def refresh_program_list(self):
        def refresh_async():
            return get_lite_programs(self.session)

        def cb_success(programs):
            sorted_programs = {}

            for program in programs:
                first_upload = program.cast_custom_option_value('first_upload', int, 0)

                if first_upload in sorted_programs:
                    sorted_programs[first_upload][program.identifier] = program
                else:
                    sorted_programs[first_upload] = {program.identifier: program}

            for first_upload in sorted(sorted_programs.keys()):
                for identifier in sorted(sorted_programs[first_upload].keys()):
                    program  = sorted_programs[first_upload][identifier]
                    language = program.cast_custom_option_value('language', str, '<unknown>')

                    try:
                        language = Constants.get_language_display_name(language)
                    except:
                        pass

                    item = QTreeWidgetItem([program.cast_custom_option_value('name', str, '<unknown>'), identifier, language])

                    self.tree_programs.addTopLevelItem(item)
                    item.setSelected(True)

            self.refresh_in_progress = False
            self.update_ui_state()
            self.tree_programs.setFocus()

        def cb_error(error):
            pass # FIXME: report error

        self.refresh_in_progress = True
        self.update_ui_state()
        self.tree_programs.invisibleRootItem().takeChildren()

        async_call(refresh_async, None, cb_success, cb_error, pass_exception_to_error_callback=True)

    def export_archive(self):
        #FIXME: fromTime_t is obsolete: https://doc.qt.io/qt-5/qdatetime-obsolete.html#toTime_t
        timestamp   = QDateTime.fromTime_t(int(time.time())).toString('yyyyMMdd-HHmmss')
        target_path = os.path.join(self.last_directory, 'red-brick-export-{0}.tfrba'.format(timestamp))
        target_path = get_save_file_name(get_main_window(), 'Save Archive', target_path, 'Tinkerforge RED-Brick Archive(*.tfrba)')

        if len(target_path) == 0:
            return

        self.last_directory = os.path.split(target_path)[0]
        script_instance_ref = [None]

        def progress_canceled():
            script_instance = script_instance_ref[0]

            if script_instance != None:
                self.script_manager.abort_script(script_instance)

            chunked_downloader = self.chunked_downloader

            if chunked_downloader != None:
                chunked_downloader.canceled = True

        self.progress = ExpandingProgressDialog(self)
        self.progress.set_progress_text_visible(False)
        self.progress.setModal(True)
        self.progress.setWindowTitle('Export Archive')
        self.progress.setLabelText('Step 1 of 2: Archiving selected programs')
        self.progress.setRange(0, 0)
        self.progress.setAutoClose(False)
        self.progress.canceled.connect(progress_canceled)
        self.progress.show()

        selected_identifiers = []

        for selected_item in self.tree_programs.selectedItems():
            selected_identifiers.append(selected_item.text(1))

        def cb_export(result):
            script_instance = script_instance_ref[0]

            if script_instance != None:
                aborted = script_instance.abort
            else:
                aborted = False

            script_instance_ref[0] = None

            if aborted:
                return

            if not report_script_result(result, 'Export Error', 'Could not archive selected programs',
                                        before_message_box=self.progress.close):
                return

            # step 2/2: download created archive
            source_path             = posixpath.join(result.stdout.strip(), 'archive.tfrba')
            self.chunked_downloader = ChunkedDownloader(self)

            if not self.chunked_downloader.prepare(source_path):
                return

            self.progress.setLabelText('Step 2 of 2: Downloading archive')
            self.progress.set_progress_text_visible(True)
            self.chunked_downloader.start(target_path)

        # step 1/2: run export script to create archive
        script_instance_ref[0] = self.script_manager.execute_script('export', cb_export, selected_identifiers)
Example #3
0
class REDTabImportExportImport(QWidget, Ui_REDTabImportExportImport):
    def __init__(self):
        QWidget.__init__(self)

        self.setupUi(self)

        self.session = None  # Set from REDTabImportExport
        self.script_manager = None  # Set from REDTabImportExport
        self.image_version = None  # Set from REDTabImportExport
        self.refresh_in_progress = False
        self.progress = None
        self.chunked_uploader = None

        self.button_browse_archive.clicked.connect(self.browse_archive)
        self.edit_archive.textChanged.connect(self.update_ui_state)
        self.tree_programs.setColumnWidth(0, 150)
        self.tree_programs.setColumnWidth(1, 150)
        self.tree_programs.setColumnWidth(2, 150)
        self.tree_programs.itemSelectionChanged.connect(self.update_ui_state)
        self.button_refresh_programs.clicked.connect(self.refresh_program_list)
        self.button_import.clicked.connect(self.import_archive)

        self.update_ui_state()

    def tab_on_focus(self):
        QTimer.singleShot(1, self.refresh_program_list)

    def tab_off_focus(self):
        pass

    def tab_destroy(self):
        pass

    def update_ui_state(self):
        if self.refresh_in_progress:
            self.edit_archive.setEnabled(False)
            self.button_browse_archive.setEnabled(False)
            self.progress_refresh_programs.setVisible(True)
            self.button_refresh_programs.setText('Refreshing...')
            self.button_refresh_programs.setEnabled(False)
            self.button_import.setEnabled(False)
        else:
            self.edit_archive.setEnabled(True)
            self.button_browse_archive.setEnabled(True)
            self.progress_refresh_programs.setVisible(False)
            self.button_refresh_programs.setText('Refresh')
            self.button_refresh_programs.setEnabled(
                len(self.edit_archive.text()) > 0)
            self.button_import.setEnabled(
                len(self.tree_programs.selectedItems()) > 0)

    def browse_archive(self):
        if len(self.edit_archive.text()) > 0:
            last_directory = os.path.dirname(
                os.path.realpath(self.edit_archive.text()))
        else:
            last_directory = get_home_path()

        filename = get_open_file_name(
            get_main_window(), 'Open Archive', last_directory,
            'Tinkerforge RED-Brick Archives(*.tfrba);;All Files(*)')

        if len(filename) > 0:
            self.edit_archive.setText(filename)
            self.refresh_program_list()

    def refresh_program_list(self):
        filename = self.edit_archive.text()

        if len(filename) == 0:
            return

        def refresh_async():
            try:
                a = tarfile.open(filename, 'r:gz')
            except Exception as e:
                return [], 'Could not open archive:\n\n{0}'.format(e)

            with contextlib.closing(a):
                try:
                    v = a.extractfile('tfrba-version')
                except Exception as e:
                    return [], 'Could not extract tfrba-version:\n\n{0}'.format(
                        e)

                version = v.read()
                v.close()

                if version != b'1':
                    return [], 'Unknown tfrba-version {0}'.format(version)

                programs = {}

                for member in a.getnames():
                    if member.startswith('programs/') and member.endswith(
                            '/program.conf'):
                        try:
                            c = a.extractfile(member)
                        except Exception as e:
                            return [], 'Could not extract {0}:\n\n{1}'.format(
                                member, e)

                        conf = list(
                            map(lambda line: line.decode('utf-8'),
                                c.readlines()))
                        c.close()

                        name = '<unknown>'
                        identifier = member.split('/')[1]
                        language = '<unknown>'
                        first_upload = 0

                        for line in conf:
                            if line.startswith('custom.name ='):
                                try:
                                    name = line[len('custom.name ='):].strip(
                                    ).encode('ascii').decode('unicode_escape')
                                except:
                                    pass
                            elif line.startswith('custom.language ='):
                                try:
                                    language = Constants.get_language_display_name(
                                        line[len('custom.language ='):].strip(
                                        ).encode('ascii').decode(
                                            'unicode_escape'))
                                except:
                                    pass
                            elif line.startswith('custom.first_upload ='):
                                try:
                                    first_upload = int(
                                        line[len('custom.first_upload ='
                                                 ):].strip())
                                except:
                                    pass

                        programs[identifier] = [
                            name, identifier, language, 'New', first_upload
                        ]

                try:
                    existing_programs = get_lite_programs(self.session)
                except Exception as e:
                    return [], 'Could not get existing program list:\n\n{0}'.format(
                        e)

                for existing_program in existing_programs:
                    identifier = existing_program.identifier

                    if identifier in programs:
                        programs[identifier][3] = 'Existing'

                return programs.values(), None

        def cb_success(result):
            programs, message = result

            if message != None:
                QMessageBox.critical(get_main_window(), 'Import Error',
                                     message)
            else:
                sorted_programs = {}

                for program in programs:
                    first_upload = program[4]

                    if first_upload in sorted_programs:
                        sorted_programs[first_upload][program[1]] = program
                    else:
                        sorted_programs[first_upload] = {program[1]: program}

                for first_upload in sorted(sorted_programs.keys()):
                    for identifier in sorted(
                            sorted_programs[first_upload].keys()):
                        program = sorted_programs[first_upload][identifier]
                        item = QTreeWidgetItem(program[0:4])

                        self.tree_programs.addTopLevelItem(item)

                        item.setSelected(True)

            self.refresh_in_progress = False
            self.update_ui_state()
            self.tree_programs.setFocus()

        def cb_error(error):
            pass  # FIXME: report error

        self.refresh_in_progress = True
        self.update_ui_state()
        self.tree_programs.invisibleRootItem().takeChildren()

        async_call(refresh_async,
                   None,
                   cb_success,
                   cb_error,
                   pass_exception_to_error_callback=True)

    def import_archive(self):
        source_path = self.edit_archive.text()

        if len(source_path) == 0:
            return

        script_instance_ref = [None]

        def progress_canceled():
            chunked_uploader = self.chunked_uploader

            if chunked_uploader != None:
                chunked_uploader.canceled = True

            script_instance = script_instance_ref[0]

            if script_instance != None:
                self.script_manager.abort_script(script_instance)

        self.progress = ExpandingProgressDialog(self)
        self.progress.set_progress_text_visible(False)
        self.progress.setModal(True)
        self.progress.setWindowTitle('Import Archive')
        self.progress.setLabelText('Step 1 of 4: Creating import directory')
        self.progress.setRange(0, 0)
        self.progress.setAutoClose(False)
        self.progress.canceled.connect(progress_canceled)
        self.progress.show()

        selected_identifiers = []

        for selected_item in self.tree_programs.selectedItems():
            selected_identifiers.append(selected_item.text(1))

        import_directory_ref = [None]

        def extract_archive():
            def cb_import_extract(result):
                script_instance = script_instance_ref[0]

                if script_instance != None:
                    aborted = script_instance.abort
                else:
                    aborted = False

                script_instance_ref[0] = None

                if aborted:
                    return

                if not report_script_result(
                        result,
                        'Import Error',
                        'Could not extract archive',
                        before_message_box=self.progress.close):
                    return

                def cb_restart_reboot_shutdown(result):
                    self.progress.close()

                    report_script_result(
                        result, 'Import Error',
                        'Could not reboot RED Brick to finish program import')

                # step 4/4: reboot
                self.progress.setLabelText('Step 4 of 4: Rebooting RED Brick')
                self.progress.setRange(0, 0)

                self.script_manager.execute_script(
                    'restart_reboot_shutdown_systemd',
                    cb_restart_reboot_shutdown, ['1'])

                def close_progress():
                    # use a closure to capture self and ansure that it's safe
                    # to call this even if the tab was official destroyed already
                    self.progress.close()

                QTimer.singleShot(1500, close_progress)

            # step 3/4: extract uploaded archive
            self.progress.setLabelText('Step 3 of 4: Extracting archive')
            self.progress.setRange(0, 0)

            script_instance_ref[0] = self.script_manager.execute_script(
                'import_extract', cb_import_extract,
                [import_directory_ref[0]] + selected_identifiers)

        def cb_import_directory(result):
            script_instance = script_instance_ref[0]

            if script_instance != None:
                aborted = script_instance.abort
            else:
                aborted = False

            script_instance_ref[0] = None

            if aborted:
                return

            if not report_script_result(
                    result,
                    'Import Error',
                    'Could not create import directory',
                    before_message_box=self.progress.close):
                return

            # step 2/4: upload archive to temporary import directory
            import_directory_ref[0] = result.stdout.strip()
            target_path = posixpath.join(import_directory_ref[0],
                                         'archive.tfrba')
            self.chunked_uploader = ChunkedUploader(self, extract_archive)

            if not self.chunked_uploader.prepare(source_path):
                return

            try:
                target_file = REDFile(self.session).open(
                    target_path, REDFile.FLAG_WRITE_ONLY | REDFile.FLAG_CREATE
                    | REDFile.FLAG_NON_BLOCKING | REDFile.FLAG_EXCLUSIVE,
                    0o644, 1000, 1000)  # FIXME: async_call
            except (Error, REDError) as e:
                QMessageBox.information(
                    get_main_window(), 'Import Error',
                    'Could not open target file {0}: {1}'.format(
                        target_path, e))
                return

            self.progress.setLabelText('Step 2 of 4: Uploading archive')
            self.progress.set_progress_text_visible(True)
            self.chunked_uploader.start(target_path, target_file)

        # step 1/4: create temporary import directory
        script_instance_ref[0] = self.script_manager.execute_script(
            'import_directory', cb_import_directory, execute_as_user=True)
class REDTabImportExportExport(QWidget, Ui_REDTabImportExportExport):
    def __init__(self):
        QWidget.__init__(self)

        self.setupUi(self)

        self.session             = None # Set from REDTabImportExport
        self.script_manager      = None # Set from REDTabImportExport
        self.image_version       = None # Set from REDTabImportExport
        self.first_tab_on_focus  = True
        self.refresh_in_progress = False
        self.last_directory      = get_home_path()
        self.progress            = None
        self.chunked_downloader  = None

        self.tree_programs.setColumnWidth(0, 150)
        self.tree_programs.setColumnWidth(1, 150)
        self.tree_programs.itemSelectionChanged.connect(self.update_ui_state)
        self.button_refresh_programs.clicked.connect(self.refresh_program_list)
        self.button_export.clicked.connect(self.export_archive)

        self.update_ui_state()

    def tab_on_focus(self):
        QTimer.singleShot(1, self.refresh_program_list)

    def tab_off_focus(self):
        pass

    def tab_destroy(self):
        pass

    def update_ui_state(self):
        if self.refresh_in_progress:
            self.progress_refresh_programs.setVisible(True)
            self.button_refresh_programs.setText('Refreshing...')
            self.button_refresh_programs.setEnabled(False)
            self.button_export.setEnabled(False)
        else:
            self.progress_refresh_programs.setVisible(False)
            self.button_refresh_programs.setText('Refresh')
            self.button_refresh_programs.setEnabled(True)
            self.button_export.setEnabled(len(self.tree_programs.selectedItems()) > 0)

    def refresh_program_list(self):
        def refresh_async():
            return get_lite_programs(self.session)

        def cb_success(programs):
            sorted_programs = {}

            for program in programs:
                first_upload = program.cast_custom_option_value('first_upload', int, 0)

                if first_upload in sorted_programs:
                    sorted_programs[first_upload][program.identifier] = program
                else:
                    sorted_programs[first_upload] = {program.identifier: program}

            for first_upload in sorted(sorted_programs.keys()):
                for identifier in sorted(sorted_programs[first_upload].keys()):
                    program  = sorted_programs[first_upload][identifier]
                    language = program.cast_custom_option_value('language', str, '<unknown>')

                    try:
                        language = Constants.get_language_display_name(language)
                    except:
                        pass

                    item = QTreeWidgetItem([program.cast_custom_option_value('name', str, '<unknown>'), identifier, language])

                    self.tree_programs.addTopLevelItem(item)
                    item.setSelected(True)

            self.refresh_in_progress = False
            self.update_ui_state()
            self.tree_programs.setFocus()

        def cb_error(error):
            pass # FIXME: report error

        self.refresh_in_progress = True
        self.update_ui_state()
        self.tree_programs.invisibleRootItem().takeChildren()

        async_call(refresh_async, None, cb_success, cb_error, pass_exception_to_error_callback=True)

    def export_archive(self):
        #FIXME: fromTime_t is obsolete: https://doc.qt.io/qt-5/qdatetime-obsolete.html#toTime_t
        timestamp   = QDateTime.fromTime_t(int(time.time())).toString('yyyyMMdd-HHmmss')
        target_path = os.path.join(self.last_directory, 'red-brick-export-{0}.tfrba'.format(timestamp))
        target_path = get_save_file_name(get_main_window(), 'Save Archive', target_path, '*.tfrba')

        if len(target_path) == 0:
            return

        self.last_directory = os.path.split(target_path)[0]
        script_instance_ref = [None]

        def progress_canceled():
            script_instance = script_instance_ref[0]

            if script_instance != None:
                self.script_manager.abort_script(script_instance)

            chunked_downloader = self.chunked_downloader

            if chunked_downloader != None:
                chunked_downloader.canceled = True

        self.progress = ExpandingProgressDialog(self)
        self.progress.set_progress_text_visible(False)
        self.progress.setModal(True)
        self.progress.setWindowTitle('Export Archive')
        self.progress.setLabelText('Step 1 of 2: Archiving selected programs')
        self.progress.setRange(0, 0)
        self.progress.setAutoClose(False)
        self.progress.canceled.connect(progress_canceled)
        self.progress.show()

        selected_identifiers = []

        for selected_item in self.tree_programs.selectedItems():
            selected_identifiers.append(selected_item.text(1))

        def cb_export(result):
            script_instance = script_instance_ref[0]

            if script_instance != None:
                aborted = script_instance.abort
            else:
                aborted = False

            script_instance_ref[0] = None

            if aborted:
                return

            if not report_script_result(result, 'Export Error', 'Could not archive selected programs',
                                        before_message_box=self.progress.close):
                return

            # step 2/2: download created archive
            source_path             = posixpath.join(result.stdout.strip(), 'archive.tfrba')
            self.chunked_downloader = ChunkedDownloader(self)

            if not self.chunked_downloader.prepare(source_path):
                return

            self.progress.setLabelText('Step 2 of 2: Downloading archive')
            self.progress.set_progress_text_visible(True)
            self.chunked_downloader.start(target_path)

        # step 1/2: run export script to create archive
        script_instance_ref[0] = self.script_manager.execute_script('export', cb_export, selected_identifiers)