Files
MultiPar/alpha/tool/par2_rename.py
2023-03-20 14:09:49 +09:00

469 lines
17 KiB
Python

import os
import re
import sys
import glob
import struct
import hashlib
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
# Initialize global variables
folder_path = "./"
set_id = None
old_name = []
# Search PAR files of same base name
def search_par_file(file_path):
global folder_path
# Check file type by filename extension
file_name = os.path.basename(file_path)
file_base, file_ext = os.path.splitext(file_name)
if file_ext.lower() != '.par2':
label_status.config(text= "Selected one isn't PAR2 file.")
return
# Save directory of selected file
#print("path=", file_path)
folder_path = os.path.dirname(file_path)
#print("path=", folder_path)
if folder_path == "":
folder_path = '.'
# Clear list of PAR files
old_name.clear()
listbox_list1.delete(0, tk.END)
listbox_list2.delete(0, tk.END)
button_name.config(state=tk.DISABLED)
button_save.config(state=tk.DISABLED)
# Compare filename in case insensitive
base_name = file_base.lower()
# Remove ".vol#-#", ".vol#+#", or ".vol_#" at the last
base_name = re.sub(r'[.]vol\d*[-+_]\d+$', "", base_name)
#print("base=", base_name)
listbox_list1.insert(tk.END, file_name)
file_count = 1
# Search other PAR2 files of same base name
for another_path in glob.glob(glob.escape(folder_path + "/" + base_name) + "*.par2"):
#print("path=", another_path)
another_name = os.path.basename(another_path)
if another_name == file_name:
continue
listbox_list1.insert(tk.END, another_name)
file_count += 1
# Ready to read PAR2 files
button_read.config(state=tk.NORMAL)
label_status.config(text= "There are " + str(file_count) + " PAR2 files of same base name.")
# Select file to search other PAR files
def button_file_clicked():
global folder_path
# Show file selecting dialog
file_type = [("PAR2 file", "*.par2"), ('All files', '*')]
file_path = filedialog.askopenfilename(filetypes=file_type, initialdir=folder_path, title="Select a PAR2 file to search others")
if file_path == "":
return
search_par_file(file_path)
# Read filenames in a PAR file
def button_read_clicked():
global folder_path, set_id
# Clear list of source files
set_id = None
old_name.clear()
listbox_list2.delete(0, tk.END)
# Get name of a selected file.
# If not selected, get the first file.
indices = listbox_list1.curselection()
if len(indices) == 1:
file_name = listbox_list1.get(indices[0])
else:
file_name = listbox_list1.get(0)
label_status.config(text= "Reading " + file_name + " ...")
# Open the selected PAR file and read the first 2 MB.
# Main packet and File Description packet would be smaller than 1 MB.
f = open(folder_path + "/" + file_name, 'rb')
data = f.read(2097152)
data_size = len(data)
# Initialize
file_count = 0
count_now = 0
# Search PAR2 packets
offset = 0
while offset + 64 < data_size:
if data[offset : offset + 8] == b'PAR2\x00PKT':
# Packet size
packet_size = struct.unpack_from('Q', data, offset + 8)[0]
#print("size=", packet_size)
# If a packet is larger than buffer size, just ignore it.
if offset + packet_size > data_size:
offset += 8
continue
# Checksum of the packet
hash = hashlib.md5(data[offset + 32 : offset + packet_size]).digest()
#print(hash)
if data[offset + 16 : offset + 32] == hash:
#print("MD5 is same.")
# Set ID
if set_id == None:
set_id = data[offset + 32 : offset + 48]
#print("Set ID=", set_id)
elif set_id != data[offset + 32 : offset + 48]:
#print("Set ID is different.")
offset += packet_size
continue
# Packet type
if data[offset + 48 : offset + 64] == b'PAR 2.0\x00Main\x00\x00\x00\x00':
#print("Main packet")
file_count = struct.unpack_from('I', data, offset + 72)[0]
#print("Number of source files=", file_count)
if file_count == 0:
break
elif count_now == file_count:
break
elif data[offset + 48 : offset + 64] == b'PAR 2.0\x00FileDesc':
#print("File Description packet")
# Remove padding null bytes
name_end = packet_size
while data[offset + name_end - 1] == 0:
name_end -= 1
#print("Filename length=", name_end - 64 - 56)
file_name = data[offset + 120 : offset + name_end].decode("UTF-8")
#print("Filename=", file_name)
# Ignore same name, if the name exists in the list already.
if file_name in old_name:
offset += packet_size
continue
old_name.append(file_name)
listbox_list2.insert(tk.END, file_name)
count_now += 1
if count_now == file_count:
break
offset += packet_size
else:
offset += 8
else:
offset += 1
# When it reaches to half, read next 1 MB from the PAR file.
if offset >= 1048576:
#print("data_size=", data_size, "offset=", offset)
data = data[offset : data_size] + f.read(1048576)
data_size = len(data)
offset = 0
# Close file
f.close()
if file_count > 0:
button_name.config(state=tk.NORMAL)
else:
button_name.config(state=tk.DISABLED)
button_save.config(state=tk.DISABLED)
label_status.config(text= "The PAR2 file includes " + str(file_count) + " source files.")
# Edit a name of a source file
def button_name_clicked():
# Get current name of renaming file.
# If not selected, show error message.
indices = listbox_list2.curselection()
if len(indices) == 1:
current_name = listbox_list2.get(indices[0])
else:
label_status.config(text= "Select a source file to rename.")
return
# Get new name and check invalid characters
new_name = entry_edit.get()
if new_name == "":
label_status.config(text= "Enter new name in text box.")
return
elif new_name == current_name:
label_status.config(text= "New name is same as old one.")
return
elif "'" + new_name + "'" in s_list2.get():
label_status.config(text= "New name is same as another filename.")
return
elif '\\' in new_name:
label_status.config(text= "Filename cannot include \\.")
return
elif ':' in new_name:
label_status.config(text= "Filename cannot include :.")
return
elif '*' in new_name:
label_status.config(text= "Filename cannot include *.")
return
elif '?' in new_name:
label_status.config(text= "Filename cannot include ?.")
return
elif '\"' in new_name:
label_status.config(text= "Filename cannot include \".")
return
elif '<' in new_name:
label_status.config(text= "Filename cannot include <.")
return
elif '>' in new_name:
label_status.config(text= "Filename cannot include >.")
return
elif '|' in new_name:
label_status.config(text= "Filename cannot include |.")
return
# Add new name and remove old one
index = indices[0]
listbox_list2.insert(index + 1, new_name)
listbox_list2.delete(index)
label_status.config(text= "Renamed from \"" + current_name + "\" to \"" + new_name + "\"")
# Only when names are changed, it's possible to save.
index = 0
for name_one in old_name:
if listbox_list2.get(index) != name_one:
index = -1
break
index += 1
if index < 0:
button_save.config(state=tk.NORMAL)
else:
button_save.config(state=tk.DISABLED)
# Save as new PAR files
def button_save_clicked():
global folder_path, set_id
# Allocate 64 KB buffer for File Description packet
buffer = bytearray(65536)
rename_count = 0
# For each PAR file
file_index = 0
file_count = listbox_list1.size()
while file_index < file_count:
file_name = listbox_list1.get(file_index)
file_index += 1
#print("Target=", file_name)
# Open a PAR file
fr = open(folder_path + "/" + file_name, 'rb')
data = fr.read(2097152)
data_size = len(data)
# Name of new PAR files have prefix "new_".
fw = open(folder_path + "/new_" + file_name, 'wb')
# Search PAR2 packets
offset = 0
while offset + 64 < data_size:
if data[offset : offset + 8] == b'PAR2\x00PKT':
# Packet size
packet_size = struct.unpack_from('Q', data, offset + 8)[0]
#print("size=", packet_size)
# If a packet is larger than buffer size, just ignore it.
if offset + packet_size > data_size:
fw.write(data[offset : offset + 8])
offset += 8
continue
# Checksum of the packet
hash = hashlib.md5(data[offset + 32 : offset + packet_size]).digest()
#print(hash)
if data[offset + 16 : offset + 32] == hash:
#print("MD5 is same.")
# Set ID
if set_id != data[offset + 32 : offset + 48]:
#print("Set ID is different.")
fw.write(data[offset : offset + packet_size])
offset += packet_size
continue
# Packet type
index = -1
if data[offset + 48 : offset + 64] == b'PAR 2.0\x00FileDesc':
#print("File Description packet")
# Remove padding null bytes
name_end = packet_size
while data[offset + name_end - 1] == 0:
name_end -= 1
#print("Filename length=", name_end - 64 - 56)
name_one = data[offset + 120 : offset + name_end].decode("UTF-8")
#print("Filename=", name_one)
if name_one in old_name:
index = old_name.index(name_one)
#print("index=", index)
new_name = listbox_list2.get(index)
if new_name != name_one:
# Copy from old packet
buffer[0 : 120] = data[offset : offset + 120]
# Set new name
#print("New name=", new_name, "old name=", name_one)
name_byte = new_name.encode("UTF-8")
name_len = len(name_byte)
#print("byte=", name_byte, "len=", name_len)
buffer[120 : 120 + name_len] = name_byte
# Padding null bytes
while name_len % 4 != 0:
buffer[120 + name_len] = 0
name_len += 1
#print("padded len=", name_len)
# Update packet size
size_byte = struct.pack('Q', 120 + name_len)
buffer[8 : 16] = size_byte
#print("packet=", buffer[0 : 120 + name_len])
# Update checksum of packet
hash = hashlib.md5(buffer[32 : 120 + name_len]).digest()
buffer[16 : 32] = hash
# Write new packet
fw.write(buffer[0 : 120 + name_len])
rename_count += 1
# When filename isn't changed.
else:
index = -2
# Write packet with current data
if index < 0:
fw.write(data[offset : offset + packet_size])
offset += packet_size
else:
fw.write(data[offset : offset + 8])
offset += 8
else:
fw.write(data[offset : offset + 1])
offset += 1
# When it reaches to half, read next.
if offset >= 1048576:
#print("data_size=", data_size, "offset=", offset)
data = data[offset : data_size] + fr.read(1048576)
data_size = len(data)
offset = 0
# Close file
fr.close()
fw.close()
label_status.config(text= "Modified " + str(rename_count) + " packets in " + str(file_count) + " PAR2 files.")
# Window size and title
root = tk.Tk()
root.title('PAR2 Rename')
root.minsize(width=480, height=240)
# Centering window
init_width = 640
init_height = 480
init_left = (root.winfo_screenwidth() - init_width) // 2
init_top = (root.winfo_screenheight() - init_height) // 2
root.geometry('{}x{}+{}+{}'.format(init_width, init_height, init_left, init_top))
#root.geometry("640x480")
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
# Body
frame_body = ttk.Frame(root, padding=(2,6,2,2))
frame_body.grid(row=0, column=0, sticky=(tk.E,tk.W,tk.S,tk.N))
frame_body.rowconfigure(0, weight=1)
frame_body.columnconfigure(0, weight=1)
frame_body.columnconfigure(1, weight=1)
# List of PAR files
frame_list1 = ttk.Frame(frame_body, padding=(6,2,6,6), relief='groove')
frame_list1.grid(row=0, column=0, padx=4, sticky=(tk.E,tk.W,tk.S,tk.N))
frame_list1.columnconfigure(0, weight=1)
frame_list1.rowconfigure(1, weight=1)
frame_top1 = ttk.Frame(frame_list1, padding=(0,4,0,3))
frame_top1.grid(row=0, column=0, columnspan=2, sticky=(tk.E,tk.W))
button_file = ttk.Button(frame_top1, text="File", width=9, command=button_file_clicked)
button_file.pack(side=tk.LEFT, padx=2)
button_read = ttk.Button(frame_top1, text="Read", width=9, command=button_read_clicked, state=tk.DISABLED)
button_read.pack(side=tk.LEFT, padx=2)
button_save = ttk.Button(frame_top1, text="Save", width=9, command=button_save_clicked, state=tk.DISABLED)
button_save.pack(side=tk.LEFT, padx=2)
s_list1 = tk.StringVar()
listbox_list1 = tk.Listbox(frame_list1, listvariable=s_list1, activestyle=tk.NONE)
listbox_list1.grid(row=1, column=0, sticky=(tk.E,tk.W,tk.S,tk.N))
scrollbar_list1 = ttk.Scrollbar(frame_list1, orient=tk.VERTICAL, command=listbox_list1.yview)
scrollbar_list1.grid(row=1, column=1, sticky=(tk.N, tk.S))
listbox_list1["yscrollcommand"] = scrollbar_list1.set
# List of source files
frame_list2 = ttk.Frame(frame_body, padding=(6,2,6,6), relief='groove')
frame_list2.grid(row=0, column=1, padx=4, sticky=(tk.E,tk.W,tk.S,tk.N))
frame_list2.columnconfigure(0, weight=1)
frame_list2.rowconfigure(1, weight=1)
frame_top2 = ttk.Frame(frame_list2, padding=(0,4,0,3))
frame_top2.grid(row=0, column=0, columnspan=2, sticky=(tk.E,tk.W))
s_edit2 = tk.StringVar()
entry_edit = ttk.Entry(frame_top2,textvariable=s_edit2)
entry_edit.grid(row=0, column=0, padx=2, sticky=(tk.E,tk.W))
frame_top2.columnconfigure(0, weight=1)
button_name = ttk.Button(frame_top2, text="Rename", width=9, command=button_name_clicked, state=tk.DISABLED)
button_name.grid(row=0, column=1, padx=2)
s_list2 = tk.StringVar()
listbox_list2 = tk.Listbox(frame_list2, listvariable=s_list2, activestyle=tk.NONE)
listbox_list2.grid(row=1, column=0, sticky=(tk.E,tk.W,tk.S,tk.N))
scrollbar_list2 = ttk.Scrollbar(frame_list2, orient=tk.VERTICAL, command=listbox_list2.yview)
scrollbar_list2.grid(row=1, column=1, sticky=(tk.N, tk.S))
listbox_list2["yscrollcommand"] = scrollbar_list2.set
# Status text
frame_foot = ttk.Frame(root)
frame_foot.grid(row=1, column=0, sticky=(tk.E,tk.W))
label_status = ttk.Label(frame_foot, text='Select a PAR2 file to rename included files.', width=100)
label_status.pack(side=tk.LEFT, padx=2)
# When file is specified in command-line
if len(sys.argv) > 1:
file_path = sys.argv[1]
if os.path.isfile(file_path):
#file_path = os.path.abspath(file_path)
search_par_file(file_path)
# Show window
root.mainloop()