#!/usr/bin/env python # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (C) 2011, Paul Lutus * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU General Public License as published by * # * the Free Software Foundation; either version 2 of the License, or * # * (at your option) any later version. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU General Public License for more details. * # * * # * You should have received a copy of the GNU General Public License * # * along with this program; if not, write to the * # * Free Software Foundation, Inc., * # * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * # *************************************************************************** # version date: 10/03/2011 VERSION = '3.2' import os, re, sys, signal import shutil, platform import pango import gtk import webbrowser class Icon: icon = [ "32 32 17 1", " c None", ". c #070905", "+ c #1D1C18", "@ c #302E2B", "# c #582E08", "$ c #623511", "% c #4A4946", "& c #904B11", "* c #8B5F41", "= c #666865", "- c #A07451", "; c #848481", "> c #A7A49F", ", c #B9BBB8", "' c #C4C6C3", ") c #CDCFCC", "! c #E2E4E1", " %==% ", " !!!! ", " %!!!!% ", " ;!; =,!!!!,= ;!; ", " ;!!!)!!!!!!!!)!!!; ", " !!!!!!!!!!!!!!!!!! ", " >!!!!!))))))!!!!!> ", " )!!!)!!!!!!)!!!) ", " =!!!)!!!!!!!))!)); ", " >)!)))!,;;,!))))'> ", " %,')))))), ,))''))',% ", " ='')))))); ;)',))))'= ", " =''');@@== ==@@;))''= ", " =,'';%;;%% %%;;%;'''= ", " %=>@=;%%+>==>+%%;=@>=% ", " @@@+..+%++%+..+@@@ ", " .@=>==@.%%>>%%.@==>=@. ", " .=>;;>;@.==%%==.@;>;;>=. ", " .=;;>>'>%...%%...%>'>>;;=. ", " +;%%%=@@=%......%=@@=%%%;+ ", " .@@@@@%%++%%....%%++%%@@@@@. ", " +@%====%@.+%@;;@%+.@%====%@+ ", " .@=%+.+==@..+=))=+..@==+.+%=@. ", " +%++@%@++%..++;;++..%++@%@++%+ ", ".@@+****-#@+.=+ +=.+@#*-***+@@.", ".@++$**--$...@@ @@...$->**$++@.", ".@+##*&&&&.+..+ +..+.&&&&&%#+@.", ".@@#&-$&&#+@. .@+#&&$$-#@@.", " .%@$&&&$+%. .%+$&&&$@%. ", " .@=%@@@%=@. .@=%@@@%=@. ", " .+%=;=%.. ..%=;=%+. ", " ..... ..... " ] # a convenience class for # communicating with gtk controls class ControlInterface: # pretend enumeration IS_BOOL,IS_COMBO,IS_STRING,IS_WINDOW,IS_UNKNOWN = list(range(5)) def __init__(self,obj,name): self.inst = obj self.name = name self.combo_array = None typ = type(obj) if(typ == gtk.RadioButton or typ == gtk.CheckButton): self.cat = ControlInterface.IS_BOOL elif(typ == gtk.ComboBox): # if no cell renderer if(len(obj.get_cells()) == 0): # Create a text cell renderer cell = gtk.CellRendererText () obj.pack_start(cell) obj.add_attribute (cell, "text", 0) self.cat = ControlInterface.IS_COMBO elif(typ == gtk.Entry): self.cat = ControlInterface.IS_STRING elif(typ == gtk.Window): self.cat = ControlInterface.IS_WINDOW else: self.cat = ControlInterface.IS_UNKNOWN def read(self): if(self.cat == ControlInterface.IS_BOOL): return self.inst.get_active() elif(self.cat == ControlInterface.IS_COMBO): return self.inst.get_active_text() elif(self.cat == ControlInterface.IS_STRING): return self.inst.get_text() elif(self.cat == ControlInterface.IS_WINDOW): self.inst.grab_focus() return self.inst.get_size() else: return None def write(self,v): if(self.cat == ControlInterface.IS_BOOL): self.inst.set_active(str(v) == 'True') elif(self.cat == ControlInterface.IS_COMBO): if(v in self.combo_array): index = self.combo_array.index(v) self.inst.set_active(index) else: self.inst.set_active(0) elif(self.cat == ControlInterface.IS_STRING): self.inst.set_text(v) elif(self.cat == ControlInterface.IS_WINDOW): x,y = re.findall('\d+',v) self.inst.resize(int(x),int(y)) else: raise Exception("Trying to write data to unknown control type ", self.inst) def load_combo_list(self,ca): assert(self.cat == ControlInterface.IS_COMBO) # keep this list for later access self.combo_array = ca self.inst.get_model().clear() for s in ca: self.inst.append_text(s.strip()) # ConfigurationManager handles the task of # loading and saving user settings class ConfigManager: def __init__(self,inst): self.parent = inst # config file located at (user home dir)/.classname self.configpath = os.path.expanduser("~/." + self.parent.__class__.__name__) self.read_widgets() # do this only once def read_widgets(self): # an unbelievable hack made necessary by # someone unwilling to fix a year-old bug with open(self.parent.xmlname) as f: data = f.read() array = re.findall(r'(?s) id="(.*?)"',data) for name in array: # only interested in names starting with 'k_' if re.search(r'^k_',name): obj = self.parent.builder.get_object(name) ci = ControlInterface(obj,name) # create parent reference with same name setattr(self.parent,name,ci) def read_config(self): if(os.path.exists(self.configpath)): with open(self.configpath) as f: for line in f.readlines(): name,value = re.search(r'^\s*(.*?)\s*=\s*(.*?)\s*$',line).groups() ci = getattr(self.parent,name,None) if(ci != None): ci.write(value) else: print("no object named %s" % name) def write_config(self): with open(self.configpath,'w') as f: for name in dir(self.parent): if re.search(r'^k_',name): ci = getattr(self.parent,name,None) if(ci != None and ci.read() != None): f.write("%s = %s\n" % (ci.name,ci.read())) # the main class class SearchReplaceGlobalPy: def __init__(self): # BEGIN user choices if(platform.system() == 'Windows'): self.editor = 'notepad' self.viewer = 'rundll32.exe %SystemRoot%\system32\shimgvw.dll,ImageView_Fullscreen' elif(platform.system() == 'Linux'): self.editor = 'kwrite' self.viewer = 'gwenview' # other platforms should fill in their own values here else: print('Error: didn\'t detect platform: %s' % platform.system()) # this bumps file times by (epsilon) seconds # when saved without "Update" option enabled self.epsilon = 1 # END user choices self.program_name = self.__class__.__name__ self.title = self.program_name + ' ' + VERSION self.builder = gtk.Builder() self.xmlname = "search_replace_global310.glade" self.builder.add_from_file(self.xmlname) self.running = False self.config = ConfigManager(self) self.config.read_config() self.mainwindow = self.k_search_replace_global.inst self.mainwindow.set_icon(gtk.gdk.pixbuf_new_from_xpm_data(Icon.icon)) self.mainwindow.set_title(self.title) # don't fail if the help file isn't present try: data = self.read_file('search_replace_global_help.txt') data = re.sub('\[version\]',VERSION,data) data = re.sub('\[ini_file\]',self.config.configpath,data) self.k_help_text.inst.get_buffer().set_text(data) except: None font = pango.FontDescription("Monospace,%d" % 10) for item in (self.k_found_text,self.k_changed_text,self.k_help_text): item.inst.set_editable(False) item.inst.modify_font(font) item.inst.set_left_margin(8) # set up to exit gracefully on some signals signal.signal(signal.SIGTERM, self.close) signal.signal(signal.SIGINT, self.close) self.connect_task() self.running = True def read_file(self,path): with open(path) as f: return f.read() # write_file has the option of preserving # the original file date and time # if epsilon_time >= 0, update the file with original time + epsilon_time # if epsilon_time < 0, update the file with present time def write_file(self,path,data, epsilon_time = -1): times = False if(epsilon_time >= 0 and os.path.exists(path)): # get original file times stat = os.stat(path) # bump times by epsilon seconds times = (stat.st_atime+epsilon_time,stat.st_mtime+epsilon_time) with open(path,'w') as f: f.write(data) if(times): # restore the original file time # + epsilon seconds os.utime(path,times) def connect_task(self): connect_list = ( (self.k_quit_button.inst,'clicked',self.close), # must be 'unrealize', not 'destroy' to be # able to capture window size in configuration (self.mainwindow,'unrealize',self.close), (self.k_browse_button.inst,'clicked',self.browse_for_directory), (self.k_scan_button.inst,'clicked',self.scan_only), (self.k_search_button.inst,'clicked',self.scan_search), (self.k_search_button.inst,'clicked',self.scan_search), (self.k_replace_button.inst,'clicked',self.scan_search_replace), (self.k_rehearse_button.inst,'clicked',self.scan_search_rehearse), (self.k_undo_button.inst,'clicked',self.undo_action), (self.k_erase_button.inst,'clicked',self.erase_action), (self.k_online_button.inst,'clicked',self.online), ) for tup in connect_list: tup[0].connect(tup[1],tup[2]) # set up to open clicked file names for w in (self.k_found_text,self.k_changed_text): w.inst.connect('enter-notify-event',self.mouse_cursor_control) w.inst.connect('button-press-event',self.mouse_press_event) def online(self, *args): webbrowser.open('http://arachnoid.com/python/searchReplaceGlobal', autoraise = True) # make local variables of checkbox settings def get_option_settings(self): self.scan_subdirs = self.k_subdirs_checkbox.inst.get_active() self.replace_global = self.k_global_checkbox.inst.get_active() self.match_case = self.k_case_checkbox.inst.get_active() self.match_dotall = self.k_dotall_checkbox.inst.get_active() self.match_multiline = self.k_multiline_checkbox.inst.get_active() self.match_reverse = self.k_reverse_checkbox.inst.get_active() self.update = self.k_update_checkbox.inst.get_active() # change to hand cursor in text # fields that allow selections def mouse_cursor_control(self,obj,evt): cursor = gtk.gdk.Cursor(gtk.gdk.HAND1) child_win = obj.get_window(gtk.TEXT_WINDOW_TEXT) old_cursor = child_win.get_cursor() if(old_cursor != cursor): child_win.set_cursor(cursor) # lauch graphic viewer or text editor def mouse_press_event(self,obj,evt): p = obj.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT,int(evt.x),int(evt.y)) if(p != None): la = obj.get_line_at_y(p[1])[0] lb = la.copy() lb.forward_to_line_end() path = la.get_text(lb).strip() if(re.search('(?i)\.(jpe?g|bmp|gif|png|xpm|cpt)$',path)): os.system('%s %s &' % (self.viewer,path)) elif(re.search('(?i)\.(glade|txt|cpp|c|h|xml|java|py|sh|hs|html|js|css)$',path)): os.system('%s %s &' % (self.editor,path)) def regex_options(self): option = (re.IGNORECASE,0)[self.match_case] | \ (0,re.MULTILINE)[self.match_multiline] | \ (0,re.DOTALL)[self.match_dotall] return option def create_file_list(self): source_paths = [] self.get_option_settings() bpath = self.k_search_path_entry.inst.get_text() if(self.scan_subdirs): for root,dirs,files in os.walk(bpath): for fn in files: source_paths.append(os.path.join(root,fn)) else: for fn in os.listdir(bpath): path = os.path.join(bpath,fn) if(os.path.isfile(path)): source_paths.append(path) return source_paths # scan for filename pattern only def scan_only(self,*args): self.scan_search_replace_inner() # scan and search for content pattern def scan_search(self,*args): self.scan_search_replace_inner(True) # scan and rehearse replacement (no commit) def scan_search_rehearse(self,*args): self.scan_search_replace_inner(True,True) # scan, then search and replace content # pattern with replacement pattern def scan_search_replace(self,*args): self.get_option_settings() if(self.match_reverse): self.message_dialog("Cannot replace with reverse option set.") return if(self.message_dialog('Warning: this option replaces file contents. OK to proceed?',True, True)): self.scan_search_replace_inner(True,True,True) # the core search and replace function def scan_search_replace_inner(self,search = False, replace = False, commit = False): try: paths = [] replaced = [] file_count = 0 match_count = 0 total_matches = 0 total_replaces = 0 self.get_option_settings() fn_filter = self.k_file_filter_entry.inst.get_text() fn_search = re.compile(fn_filter) cont_filter = self.k_search_entry.inst.get_text() cont_search = re.compile(cont_filter,self.regex_options()) replace_filter = self.k_replace_entry.inst.get_text() source_paths = self.create_file_list() for path in source_paths: # if filename pattern appears anywhere if(fn_search.search(path)): file_count += 1 # if search mode enabled if(search): data = self.read_file(path) match_obj = cont_search.findall(data) length = len(match_obj) match = length > 0 if(match ^ self.match_reverse): match_count += 1 paths.append(path) total_matches += length # if replace mode enabled if(replace): output = cont_search.sub(replace_filter,data,(1,0)[self.replace_global]) if(output != data): total_replaces += 1 # if the user really means it if(commit): # make a backup that has file's original time # so the "Undo" action will recreate the # original file and its time shutil.copy2(path,path + '~') # overwrite original file with new data self.write_file(path,output, (self.epsilon,-1)[self.update]) replaced.append(path) else: # scan only paths.append(path) s = 'file pattern matched: %d' % (file_count) if(search): s += ', content matched: %d, total matches: %d' % (match_count,total_matches) if(replace): sub = ('would be replaced','files replaced')[commit] s += ', %s: %d' % (sub,total_replaces) self.k_status_bar.inst.set_text(s) self.k_found_text.inst.get_buffer().set_text('\n'.join(sorted(paths))) self.k_changed_text.inst.get_buffer().set_text('\n'.join(sorted(replaced))) except Exception as e: self.message_dialog('Search/replace error: %s' % str(e)) # restore original file content using backups def undo_action(self,*args): if(self.message_dialog("Replace originals with backup files?",True)): count = 0 source_paths = self.create_file_list() for rpath in source_paths: if(re.search('~$',rpath)): path = re.sub('(.*)~$','\\1',rpath) if(os.path.exists(path)): shutil.move(rpath,path) count += 1 self.k_status_bar.inst.set_text('Restored %d files from backups.' % count) self.k_changed_text.inst.get_buffer().set_text('') # erase backup files def erase_action(self,*args): if(self.message_dialog("Delete backup files (name~) on search path?",True)): count = 0 source_paths = self.create_file_list() for rpath in source_paths: if(re.search('~$',rpath)): os.remove(rpath) count += 1 self.k_status_bar.inst.set_text('Deleted %d backup files.' % count) # a convenient message dialog function def message_dialog(self,message,inquiry = False,set_default_no = False): if(inquiry): dlg = gtk.MessageDialog( parent = None, flags = gtk.DIALOG_MODAL, type = gtk.MESSAGE_QUESTION, buttons=gtk.BUTTONS_YES_NO ) else: dlg = gtk.MessageDialog( parent = None, flags = gtk.DIALOG_MODAL, type = gtk.MESSAGE_INFO, buttons = gtk.BUTTONS_OK ) dlg.set_markup(message) dlg.set_title(self.title) if(inquiry and set_default_no): dlg.set_default_response(gtk.RESPONSE_NO) response = dlg.run() response = (response == gtk.RESPONSE_YES or response == gtk.RESPONSE_OK) dlg.destroy() # print(response) return(response) # user directory selection dialog def browse_for_directory(self,*args): dlg = gtk.FileChooserDialog(title=None,action=gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER, buttons=(gtk.STOCK_CANCEL,gtk.RESPONSE_CANCEL,gtk.STOCK_OPEN,gtk.RESPONSE_OK)) dlg.set_current_folder(self.k_search_path_entry.inst.get_text()) response = dlg.run() if(response == gtk.RESPONSE_OK): self.k_search_path_entry.inst.set_text(dlg.get_filename()) dlg.destroy() def close(self,*args): self.running = False self.config.write_config() gtk.main_quit() # end of SearchReplaceGlobalPy class if __name__ == "__main__": app=SearchReplaceGlobalPy() gtk.main()