Easy extract / compress for Thunar (extract here, extract to separate folder etc.)

Write tutorials for Linux Mint here
More tutorials on https://github.com/orgs/linuxmint/discu ... /tutorials and (archive) on https://community.linuxmint.com/tutorial
Forum rules
Don't add support questions to tutorials; start your own topic in the appropriate sub-forum instead. Before you post read forum rules
Post Reply
random0
Level 1
Level 1
Posts: 24
Joined: Mon Feb 28, 2022 10:11 am

Easy extract / compress for Thunar (extract here, extract to separate folder etc.)

Post by random0 »

These scripts were specifically written for Thunar file manager as it's the only file manager I like and use on any DE. However, it is possible to port them to other file managers too.

Simply put, none of the extract and compress GUIs / file manager add-ons I've tried do it for me. They all have weird things and inconsistencies / limitations here and there. These scripts aim to stop that, and allow easy single and bulk extractions / compresses with any number, or mixture of zip, rar, 7z etc. files. It also works with multi-part rar files and password protected rar and zip files.

I originally wrote this for myself, but realized it could be useful to other people, and decided to post it. Plus the potential feedback. If anyone wants to use this of course, modify it, whatever, go ahead.
Install dependencies:

Code: Select all

sudo apt install -y thunar zip unar unrar gnome-terminal
Optional. If you have thunar-archive-plugin installed, it could be a good idea to uninstall it, as this is supposed to replace it. Otherwise you'd have 2 options to extract:

Code: Select all

sudo apt purge -y thunar-archive-plugin
Also, make sure you're running unrar and unar versions similar to mine. Unrar is version 6.11 and unar is version 1.10.7
If running an older version of unrar in particular, which I just tested (5.61), there are a few things missing and this won't work.

Open Thunar and add the following 3 custom actions:


Basic
Name: Extract Inner
Description: Extract here
Command: gnome-terminal --title "EXTRACTING IN: %d" -- python3 "$HOME/.config/Thunar/cust-scripts/extract.py" "inner" %F
Appearance Conditions
*.rar;*.zip;*.7z;*.tar.gz;*.tar.bz2;*.zipx
Other Files

Basic
Name: Extract Outer
Description: Extract each to separate folder
Command: gnome-terminal --title "EXTRACTING IN: %d" -- python3 "$HOME/.config/Thunar/cust-scripts/extract.py" "outer" %F
Appearance Conditions
*.rar;*.zip;*.7z;*.tar.gz;*.tar.bz2;*.zipx
Other Files

Basic
Name: Zip
Description: Zip selected file/s
Command: gnome-terminal --title "ZIPPING FILES IN %d" -- python3 "$HOME/.config/Thunar/cust-scripts/zip-files.py" %F
Appearance Conditions
*
Everything checked

Create the scripts

These are the scripts that the custom actions above will be using in order to work.
There are two scripts: "extract.py" and "zip-files.py".
They are saved in "~/.config/Thunar/cust-scripts/"

So to make the folder:

Code: Select all

mkdir -p "$HOME/.config/Thunar/cust-scripts"
The scripts you'll have to create yourself. Navigate to the folder, create new document, paste the text and name it the way it should be.

extract.py

Code: Select all

# CALL SYNTAX: python3 "script name" "dir structure" "filepath1" "filepath2"...
# sys.argv[0] is the name of the script
# sys.argv[1] the dir structure: "inner"/"outer"
# sys.argv[2:] the filepaths

# Unar is used to handle everything except ".rar" files. Unrar handles ".rar" files exclusevly
    # When a password protected file is extracted with a wrong password, both modules create 0 bite files
    # This affects duplicate action and can mess things up.
    # That is why, each archive is extracted in a temp folder, and the temp folder is emptied after every extraction

#########################################################

import os, sys, re, subprocess

#########################################################
# GLOBALS

NUM_FILES = None
DIR_STRUCTURE = None
PARENT_FOLDER = None
TEMP_FOLDER = None
RM_TEMP_FOLDER_CONTENTS_COMMAND = None
MOVE_FILES_FROM_TEMP_COMMAND = None
RM_TEMP_FOLDER_COMMAND = None
FILE_PATHS_ARR = None
DUPE_ACTION = None
PASSWORD = None
BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS = None
ERRORS = None

#########################################################
# RUN

def run():
    init()
    extraction_process()
    display_errors()
    
#########################################################
# MAIN

def init():
    global NUM_FILES, DIR_STRUCTURE, TEMP_FOLDER, RM_TEMP_FOLDER_CONTENTS_COMMAND, RM_TEMP_FOLDER_COMMAND, PARENT_FOLDER, FILE_PATHS_ARR, PASSWORD, BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS, ERRORS
    
    NUM_FILES = len(sys.argv) - 2
    if NUM_FILES <= 0: input("Not enough arguments provided. Press Enter to exit."); quit()

    if sys.argv[1] == "inner" or sys.argv[1] == "outer": DIR_STRUCTURE = sys.argv[1]
    else: input("The dir structure argument is wrong. Press Enter to exit."); quit()

    PARENT_FOLDER = path_fixer(os.path.dirname(sys.argv[2]))
    if not os.access(PARENT_FOLDER, os.W_OK): input("Write permission missing. Press Enter to exit."); quit()

    TEMP_FOLDER = get_unused_path(f"{PARENT_FOLDER}/.tempExtract")
    os.system(f"mkdir \"{TEMP_FOLDER}\"")
    RM_TEMP_FOLDER_CONTENTS_COMMAND = f"rm -r \"{TEMP_FOLDER}/\"* > /dev/null 2>&1"
    RM_TEMP_FOLDER_COMMAND = f"rm -r \"{TEMP_FOLDER}\" > /dev/null 2>&1"

    move_command_generator()
    
    FILE_PATHS_ARR = []
    for filePath in sys.argv[2:]: FILE_PATHS_ARR.append(path_fixer(filePath))

    PASSWORD = "test"
    BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS = False
    ERRORS = ""

def extraction_process():
    global ERRORS
    fileCounter = 1
    for filePath in FILE_PATHS_ARR:
        if not os.path.exists(filePath): ERRORS += f"{os.path.basename(filePath)}:\nFile removed before extraction\n\n"; continue
        if re.match(r"^.+\.part[0-9]+\.rar$", filePath) and not filePath.endswith("part1.rar"): continue
        single_archive_handler(filePath, fileCounter)
        fileCounter += 1   
    os.system(RM_TEMP_FOLDER_COMMAND)

def display_errors():
    global ERRORS
    os.system("clear")
    ERRORS = ERRORS.strip()
    if ERRORS == "": return
    print(f"OPERATION COMPLETE WITH ERRORS:\n\n{ERRORS}\n\n")
    input("Press enter to close")

#########################################################
# EXTRACTING SINGLE ARCHIVE

def single_archive_handler(filePath, fileCounter):
    global ERRORS

    while 1==1:
        os.system(RM_TEMP_FOLDER_CONTENTS_COMMAND)
        os.system("clear")
        print(f"EXTRACTING: {fileCounter}/{NUM_FILES}")
        
        extractCommand = extract_command_generator(filePath)
        extractCommandOutput = subprocess.run(extractCommand, shell=True, text=True, capture_output=True)
        
        errMsg = extractCommandOutput.stderr.strip()
        if errMsg:
            if is_wrong_pass_msg(errMsg):
                os.system(RM_TEMP_FOLDER_CONTENTS_COMMAND)
                errMsg = "Wrong password. Archive skipped."
                if get_wrong_pass_user_input(filePath) == "new pass entered": continue
            ERRORS += f"{os.path.basename(filePath)}:\n{errMsg}\n\n"

        if dupes_found(TEMP_FOLDER) and not DUPE_ACTION:
            dupe_action_user_input()
            move_command_generator()

        os.system(MOVE_FILES_FROM_TEMP_COMMAND)
        break

#########################################################
# COMMAND GENERATORS

def extract_command_generator(filePath):
    command = ""
    fileExt = filePath.split(".")[-1]
    isRar = True if fileExt == "rar" else False
    outputDir = get_outer_output_folder_unrar(filePath) if isRar and DIR_STRUCTURE == "outer" else TEMP_FOLDER

    if isRar:
        command = f"unrar x -op\"{outputDir}\" -p\"{PASSWORD}\" \"{filePath}\" > /dev/null" # unrar doesnt have a quite mode (I just need errors)
    else:
        unarInnerOuterFlag = "-force-directory" if DIR_STRUCTURE == "outer" else "-no-directory"
        command = f"unar -quiet -output-directory \"{outputDir}\" {unarInnerOuterFlag} -password \"{PASSWORD}\" \"{filePath}\""

    return command

def move_command_generator():
    global MOVE_FILES_FROM_TEMP_COMMAND

    command = "cp --recursive --link"
    if DUPE_ACTION == "skip": command += " --no-clobber"
    elif DUPE_ACTION == "rename": command += " --backup=numbered"
    elif DUPE_ACTION == "overwrite": command += " --force"
    command += f" \"{TEMP_FOLDER}/\"* \"{PARENT_FOLDER}/\" > /dev/null 2>&1"
    MOVE_FILES_FROM_TEMP_COMMAND = command
        
#########################################################
# USER INPUTS

def dupe_action_user_input():
    global DUPE_ACTION
    os.system("clear")
    while 1==1:
        userInput = input("DUPLICATES FOUND (this will apply to future duplicates):\n(1) Skip [default]\n(2) Rename\n(3) Overwrite\n").strip()
        os.system("clear")
        if userInput == "1" or userInput == "": DUPE_ACTION = "skip"; break
        elif userInput == "2": DUPE_ACTION = "rename"; break
        elif userInput == "3": DUPE_ACTION = "overwrite"; break
        else: print("*** Invalid input. Try again.")

def get_wrong_pass_user_input(filePath):
    global BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS

    if BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS: return "skip"

    os.system("clear")

    while 1==1:
        userInput = input(f"PASSWORD NEEDED FOR \"{os.path.basename(filePath)}\"\n(1) Enter password [default]\n(2) Skip archive\n(3) Skip all remaining\n").strip()
        os.system("clear")
        if userInput == "1" or userInput == "": password_user_input(); return "new pass entered"
        elif userInput == "2": return "skip"
        elif userInput == "3": BLOCK_EXTRACTION_TIME_PASSWORD_POPUPS = True; return "skip"
        else: print("*** Invalid input. Try again.")

def password_user_input():
    global PASSWORD
    os.system("clear")
    userInput = input("ENTER PASSWORD TO USE (or leave empty)\n").strip()
    os.system("clear")
    if userInput != "": PASSWORD = userInput

#########################################################
# GENERAL FUNCTIONS

# if the path provided ends with "/", it removes it
def path_fixer(filePath):
    if filePath.endswith("/"): filePath = filePath[:-1]
    return filePath

# given the path of a file/folder, it returns one that doesn't exist (numbered if original one exists)
def get_unused_path(filePath):
    if not os.path.exists(filePath): return filePath
    counter = 1
    while 1==1:
        newFilePath = f"{filePath} ({counter})"
        if not os.path.exists(newFilePath): return newFilePath
        counter += 1

# with unrar (for rar files), the outer functionality needs to be implemented by setting the output folder (to one with the archive's name)
def get_outer_output_folder_unrar(filePath):
    fileNameWoExt = os.path.basename(filePath)[:-4]
    if re.match(r"^.+\.part[0-9]+$", fileNameWoExt): fileNameWoExt = ".".join(fileNameWoExt.split(".")[:-1]) # remove .part
    return f"{TEMP_FOLDER}/{fileNameWoExt}"

# unrar and unar have different error messages, but with both, the first line of the error output contains "wrong password"
def is_wrong_pass_msg(errMsg):
    return True if "wrong password" in errMsg.split("\n")[0].strip() else False

# recursivly find duplicates (temp vs orig parent folder). Call using the TEMP FOLDER variable
def dupes_found(folderPath):
    dupes = False
    itemsInCurTempFolderArr = os.listdir(folderPath)
    for itemName in itemsInCurTempFolderArr:
        itemPath = f"{folderPath}/{itemName}"
        itemPathInParent = itemPath.replace(f"/{os.path.basename(TEMP_FOLDER)}", "")
        if os.path.isfile(itemPath) and os.path.exists(itemPathInParent): dupes = True
        elif os.path.isdir(itemPath): dupes = dupes_found(itemPath)
        if dupes == True: break
    return dupes
    
run()
zip-files.py

Code: Select all

# CALL SYNTAX: python3 "script name" "filepath1" "filename2"...
# sys.argv[0] is the name of the script
# sys.argv[1:] the filenames

# The "zip" module requires you to use the parent directory and only include filenames for the zips you want to extract

#########################################################

import os, sys, re, subprocess

#########################################################

NUM_FILES = None
FILE_NAMES_ARR = None
BASE_DIR = None
ZIP_OUTPUT_METHOD = None
SINGLE_ZIP_NAME_TEMP_WO_EXT = None # This is needed when doing a single zip. It's temp because it may change when dupes are scanned.
PASSWORD = None
DUPE_ACTION = None
FILENAMES_DICT = None # {"zipname1":"filename", "zipname2":'"filename1" "filename2"' ...}
ERRORS = None

#########################################################

def run():
    init()
    zip_files()
    if not ERRORS: return
    os.system("clear")
    print(f"OPERATION COMPLETE WITH ERRORS:\n\n{ERRORS}\n\n")
    input("Press any key and click enter to close")

#########################################################

def init():
    global NUM_FILES, FILE_NAMES_ARR, BASE_DIR, ZIP_OUTPUT_METHOD, SINGLE_ZIP_NAME_TEMP_WO_EXT, PASSWORD, FILENAMES_DICT, DUPE_ACTION, ERRORS

    # Validation 1 + Set 1
    NUM_FILES = len(sys.argv) - 1
    if NUM_FILES <= 0: input("Not enough arguments provided. Press Enter to exit."); quit()

    # Validation 2 + Set 2 & 3
    FILE_NAMES_ARR = []
    fileParentDir = None
    for filePath in sys.argv[1:]:
        filePath = path_fixer(filePath)
        fileParentDirTemp = os.path.dirname(filePath)
        if not fileParentDir: fileParentDir = fileParentDirTemp # for the initial run
        if fileParentDirTemp != fileParentDir: input("Files provided have different parent folders. Press Enter to exit."); quit()
        fileParentDir = fileParentDirTemp
        if not BASE_DIR: BASE_DIR = fileParentDir
        FILE_NAMES_ARR.append(os.path.basename(filePath))
    
    # Validation 3
    if not os.access(BASE_DIR, os.W_OK): input("Write permission missing. Press Enter to exit."); quit()

    # Set 4
    ZIP_OUTPUT_METHOD = "single" if NUM_FILES == 1 else get_zip_output_method_user_input()

    # Set 5
    if ZIP_OUTPUT_METHOD == "single":
        SINGLE_ZIP_NAME_TEMP_WO_EXT = FILE_NAMES_ARR[0] if NUM_FILES == 1 else get_single_zip_name_user_input()

    # Set 6
    PASSWORD = get_zip_password_user_input()

    # Set 7 & 8
    FILENAMES_DICT = {}
    zipFilesWoExtArr = [SINGLE_ZIP_NAME_TEMP_WO_EXT] if SINGLE_ZIP_NAME_TEMP_WO_EXT else FILE_NAMES_ARR
    for fileName in zipFilesWoExtArr:
        zipName = f"{fileName}.zip"
        if os.path.exists(f"{BASE_DIR}/{zipName}"):
            if not DUPE_ACTION: DUPE_ACTION = get_dupe_action_user_input()
            if DUPE_ACTION == "skip": continue
            elif DUPE_ACTION == "replace": os.system(f"rm \"{BASE_DIR}/{zipName}\" > /dev/null 2>&1")
            else: zipName = get_unused_zip_name(fileName)
        if ZIP_OUTPUT_METHOD == "single": FILENAMES_DICT[zipName] = array_to_str_with_quotes_and_spaces(FILE_NAMES_ARR)
        else: FILENAMES_DICT[zipName] = f"\"{fileName}\""

    # Set 9
    ERRORS = ""

# -------------------------------------------------------
# HELPERS

# if the path provided ends with "/", it removes it
def path_fixer(filePath):
    if filePath.endswith("/"): filePath = filePath[:-1]
    return filePath

def get_zip_output_method_user_input():
    os.system("clear")
    while 1==1:
        userInput = input("PUT FILES:\n(1) In a single zip file [default]\n(2) Each in a separate zip\n").strip()
        os.system("clear")
        if userInput == "1" or userInput == "": return "single"
        elif userInput == "2": return "separate"
        else: print("*** Invalid input. Try again.")

def get_single_zip_name_user_input():
    os.system("clear")
    while 1==1:
        userInput = input("NAME OF ZIP FILE (no extension) (can't contain: <>:\"/\?):\n").strip()
        os.system("clear")
        if re.match(r"^.*(<|>|:|\"|\/|\\|\||\?).*$", userInput) or userInput == "": print("*** Invalid name. Try again.")
        else: return userInput

def get_zip_password_user_input():
    os.system("clear")
    userInput = input("ENTER PASSWORD TO USE (or leave empty)\n").strip()
    os.system("clear")
    return userInput

def get_dupe_action_user_input():
    os.system("clear")
    while 1==1:
        userInput = input("DUPLICATE ZIP/s FOUND. ACTION TO TAKE:\n(1) Skip [default]\n(2) Rename\n(3) Replace\n").strip()
        os.system("clear")
        if userInput == "1" or userInput == "": return "skip"
        elif userInput == "2": return "rename"
        elif userInput == "3": return "replace"
        else: print("*** Invalid input. Try again.")

def get_unused_zip_name(fileName):
    counter = 1
    while 1==1:
        newZipName = f"{fileName} ({counter}).zip"
        if not os.path.exists(f"{BASE_DIR}/{newZipName}"): return newZipName
        counter += 1

# ["one", "two"] => '"one" "two"'
# Used when adding multiple files in a single zip, to put them in the exact format needed for the zip command
def array_to_str_with_quotes_and_spaces(arr):
    outputStr = ""
    for item in arr: outputStr += f"\"{item}\" "
    return outputStr.strip()


#########################################################

def zip_files():
    global ERRORS

    os.chdir(BASE_DIR) # python equivalent of cd - needed for zip once again - output dir and filenames only
    zipFileCounter = 1
    for zipName in FILENAMES_DICT: # zipName = the key
        os.system("clear")
        #input(zipName)
        #input(FILENAMES_DICT[zipName])
        if ZIP_OUTPUT_METHOD == "single": print(f"Adding files to {zipName}...")
        elif ZIP_OUTPUT_METHOD == "separate": print(f"Working on zip file {zipFileCounter}/{len(FILENAMES_DICT)}...")
        
        commandOutput = None
        if PASSWORD: commandOutput = subprocess.run(f"zip -q -r --password \"{PASSWORD}\" \"{zipName}\" {FILENAMES_DICT[zipName]}", shell=True, text=True, capture_output=True)
        else: commandOutput = subprocess.run(f"zip -q -r \"{zipName}\" {FILENAMES_DICT[zipName]}", shell=True, text=True, capture_output=True)
        
        if commandOutput.stdout.strip(): ERRORS += f"*** {zipName}:\n{commandOutput.stdout}\n\n" # for some reason zip outputs errors in stdout instead of stderr
        zipFileCounter += 1

run()

Status

The extract.py script is supposedly finished. I tested most of it. The only thing I haven't been able to figure out is when files are renamed, it adds the number at the end of the file (after the extension). This is using the "cp --backup=numbered" command. I know that if I implement the rename / skip / overwrite functionality manually I can solve this easily, but if there is something built into Linux that can do it for me, it would be better obviously.

The zip-files.py script is also supposedly finished.

That is of course, assuming no bugs are found. If you find any bugs, please post them here (or post updated script), so I can update the scripts in the op when I have the time.

Python is super easy to test. Just add "input()" lines in key place with whatever value. For example input("hello") or input(someVariable). This will present the message inside of input and pause everything.
Post Reply

Return to “Tutorials”