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
Code: Select all
sudo apt purge -y thunar-archive-plugin
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"
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()
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.