Requires:
- Python version 3.8 or newer
- wxPython version 4.0 or newer
- exif module
- Windows with NTFS file system
To ensure you have the required packages please run the following
- python -m pip install --upgrade pip
- pip install exif
- pip install wxPython
I have lots of digitized photos on my computer. To keep track of them I use two programs. The first is called Everything Indexer. It's free, fast, and I use it daily. It maintains, in real time, a database of all files on your computer. The search window allows you to see a list, updated as you type, of all file names containing strings that you enter. Filters allow you to easily restrict the results to files of several pre-canned types (pictures, videos, executables, documents, etc.). Like Windows Explorer, it also supports a preview pane. I literally cannot say enough good things about it.
The other program is one I wrote myself. Without it (or something similar) Everything would be pointless (sorry for the pun).
Several programs are already available for tagging files, but most, or at least the useful ones, maintain a database in proprietary format which makes moving to another program difficult if not impossible. I used ACDC for years until it just got too bloated. I now use FastStone Image Viewer. It's small and fast, and except for rare cases (for which I use gimp), it handles all my imaging needs.
My program displays a list of all image files in a selected folder. It shows the selected file in a scaled panel plus two lists of tags, where tags are simply blank delimited strings generated from the selected file name. You can modify a file name by
- Typing new text in the current file name text area
- Deleting tags from the current tag list
- Adding tags from a common tag list
Let's have a look at the GUI.
-
Folder tree showing all drives and folder currently available. Selecting a folder will cause the file list (2) to show all image files in the selected folder.
-
A file list of all image files in the currently selected folder. This is a static list that is not updated with file changes outside the application. However, as you rename files within the app this list is updated (but not resorted) to reflect the changes.
-
A status bar displaying the original name of the file if it has been changed. The first time you change a file name with this app, the original name is saved in an alternate data stream named ":tagger_undo". This assumes that the disk containing the files has NTFS. A support script (which I will eventually incorporate into this app) can be used to restore the original name. This name will not be updated on successive renames so the original name should always be available.
-
A preview pane showing the currently selected image. If you move the mouse over the picture you will see the image's EXIF date/time stamp (if available) in a tooltip. You can zoom in and out using CTRL+Scroll Wheel.
-
The name (mostly) that you are building by modifying tags. This name will be combined with the index (if non-zero) and the original file extension when you do a save. Moving the mouse over this control will display the proposed new file name in a tooltip.
-
An index number. This number may change as you add and remove tags. If the current tag you are building would cause a file name conflict with an existing file then this index will be auto-incremented to generate a suffix of the form " (##)" to ensure the new file name is unique.
-
A list of current tags created by extracting all of the blank delimited strings in the original file name. This list is displayed in the order in which the tags appear in the file name. You can copy tags from here to the common tag list by drag&drop or by right clicking on a tag. Pressing DEL, CTRL+D or CTRL+X deletes the current tag.
-
A list of common tags that is maintained between runs. You can use this to keep tags that you plan to add to multiple files. This list is sorted. Copy tags to the current tag list by drag&drop or by right clicking on a tag. Pressing DEL, CTRL+D or CTRL+X deletes the current tag.
-
Digital cameras generate their own file names which I like to strip out. Clicking this will try to do that. You may need to add patterns (regular expressions) for other cameras. Click Strip (or use CTRL+1) to try to massage the new name.
-
If the current file has previously been renamed by this program, clicking Restore (or CTRL+R) will attempt to restore the original name. This will fail if a file already exists with that name.
-
Save the file name changes. This will automatically select the next file in the list. Click this or press CTRL+S.
If you press CTRL+H you will be taken to the Windows "My Pictures" folder. Please be advised that if the folder contains a large number of files it may take a few seconds to repopulate the file list.
Adding a tag to the current list will automatically update the new name (4) and possibly the index (5).
Almost all GUI panels are implemented with splitter panels so you can resize most areas as needed. Program settings and common tags are maintained between runs in the file Tagger.ini. Be very careful if you edit this file manually.
Hotkeys
**F1** - Help
**UP** - Select previous file
**DOWN** - Select next file
**CTRL+1** - Strip camera crud
**CTRL+S** - Save
**DEL** - Delete the currently selected tag from the currently selected list
**CTRL+X** - Same as DEL
**CTRL+R** - Restore the original file name
**CTRL+H** - Select the "My Pictures" folder
Most cameras save meta data in EXIF tags. This program uses the module "exif" to extract the date/time stamp if available.
This program is a work in progress. For example, it could benefit from more extensive error checking and further refactoring. Suggestions and bug reports are welcome.
The Code:
Tagger.py
"""
Name:
Tagger.pyw
Description:
This program implements an interface to easily maintain tags on image files.
A tag in a file name will be any string of characters delimited by a blank.
It is up to the user to avoid entering characters that are no valid in file
names.
The interface consists of three main panels. The left panel contains a tree
from which all available drives and folders may be browsed. Selecting a folder
from the tree will cause a list of all image files in that folder to be
displayed in a list below the folder tree.
Selecting a file from the file list will cause that image to be displayed
in the upper portion of the centre panel. At the bottom of the centre panel
the file name will be displayed in two parts, a base file name - no path, no
file extension, and no index number. An index number is the end portion of a
file of the form (##).
The right panel consists of an upper list containing all of the tags in the
currently selected file. The lower portion consists of a list of common tags
that is maintained between runs.
Tags can be added either by dragging tags from the common (lower) list to
the current (upper) list, or by manually typing them into the file name
below the displayed image. As new tags are added the file name and current
tag list are kept in sync.
Tags can also be copied between current and common lists by right clicking
If you see a tag in the current list that you want to add to the common
list you can drag it from the lower list to the upper list. Similarly, you
can delete a tag from either list by selecting it, then pressing the delete
key.
As you make changes to the displayed file name the index will automatically
be modified to avoid conflict with existing names in the file list.
The first time a file is renamed with tagger, the original file name is saved
in the alternate data stream ':tagger_undo'. If the current file has undo info
available, it can be restored by clicking Restore or CTRL+R. If you find you
have totally botched a bunch of renames you can undo them by running
tagger_undo.py from a command shell in the picture folder. Please note that
running this without specifying a file or wildcard pattern will undo ALL
renames that were ever done to ALL files in that folder.
Audit:
2021-07-13 rj original code
"""
TITLE = 'Image Tagger (v 1.2)'
ADS = ':tagger_undo'
import os
import re
import wx
import inspect
import ImagePanel as ip # Control to display selected image file
import GetSpecialFolder as gs # To determine Windows <My Pictures> folder
import perceivedType as pt # To determine (by extension) if file is an image file
from exif import Image # For reading EXIF datetime stamp
DEBUG = False
INSPECT = False
if INSPECT:
import wx.lib.mixins.inspection
def iam():
"""
Returns the name of the function that called this function. This
is useful for printing out debug information, for example, you only
need to code:
if DEBUG: print('enter',iam())
"""
return inspect.getouterframes(inspect.currentframe())[1].function
class MyTarget(wx.TextDropTarget):
"""
Drag & drop implementation between two list controls in single column
report mode. The two lists must have a custom property, 'type' with the
values 'curr' and 'comm' (for this app meaning current and common tags).
"""
def __init__(self, srce, dest):
wx.TextDropTarget.__init__(self)
self.srce = srce
self.dest = dest
if DEBUG: print(f'create target {srce.Name=} {dest.Name=}')
def OnDropText(self, x, y, text):
if DEBUG: print(iam(),f'{self.srce.Name=} {self.dest.Name=} {text=}')
if self.dest.Name in ('curr', 'comm'):
if self.dest.FindItem(-1,text) == -1:
self.dest.InsertItem(self.dest.ItemCount, text)
return True
class MyFrame(wx.Frame):
def __init__(self, *args, **kwds):
kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE
wx.Frame.__init__(self, *args, **kwds)
self.SetSize((1400, 800))
self.SetTitle(TITLE)
self.status = self.CreateStatusBar(2)
self.status.SetStatusWidths([100, -1])
# split1 contains the folder/file controls on the left and all else on the right
self.split1 = wx.SplitterWindow(self, wx.ID_ANY)
self.split1.SetMinimumPaneSize(250)
self.split1_pane_1 = wx.Panel(self.split1, wx.ID_ANY)
sizer_1 = wx.BoxSizer(wx.HORIZONTAL)
# split2 contains the folder tree on the top, and the file list on the bottom
self.split2 = wx.SplitterWindow(self.split1_pane_1, wx.ID_ANY)
self.split2.SetMinimumPaneSize(200)
sizer_1.Add(self.split2, 1, wx.EXPAND, 0)
self.split2_pane_1 = wx.Panel(self.split2, wx.ID_ANY)
sizer_2 = wx.BoxSizer(wx.HORIZONTAL)
self.folders = wx.GenericDirCtrl(self.split2_pane_1, wx.ID_ANY, style=wx.DIRCTRL_DIR_ONLY)
sizer_2.Add(self.folders, 1, wx.EXPAND, 0)
self.split2_pane_2 = wx.Panel(self.split2, wx.ID_ANY)
sizer_3 = wx.BoxSizer(wx.HORIZONTAL)
self.lstFiles = wx.ListCtrl(self.split2_pane_2, wx.ID_ANY, style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL)
self.lstFiles.AppendColumn('', width=600)
self.lstFiles.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Courier New"))
sizer_3.Add(self.lstFiles, 1, wx.EXPAND, 0)
self.split1_pane_2 = wx.Panel(self.split1, wx.ID_ANY)
sizer_4 = wx.BoxSizer(wx.HORIZONTAL)
# split3 contains the image display and controls on the left, and the current/common tags on the right
self.split3 = wx.SplitterWindow(self.split1_pane_2, wx.ID_ANY)
self.split3.SetMinimumPaneSize(150)
sizer_4.Add(self.split3, 1, wx.EXPAND, 0)
self.split3_pane_1 = wx.Panel(self.split3, wx.ID_ANY)
sizer_5 = wx.BoxSizer(wx.VERTICAL)
self.pnlImage = ip.ImagePanel(self.split3_pane_1, wx.ID_ANY)
sizer_5.Add(self.pnlImage, 1, wx.EXPAND, 0)
sizer_6 = wx.BoxSizer(wx.HORIZONTAL)
sizer_5.Add(sizer_6, 0, wx.EXPAND, 0)
self.btnStrip = wx.Button(self.split3_pane_1, wx.ID_ANY, "Strip")
self.btnStrip.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.btnStrip.SetToolTip('Click or CTRL+1 to remove HH-MM, DSC*, IMG* tags')
sizer_6.Add(self.btnStrip, 1, wx.ALL | wx.EXPAND, 4)
sizer_6.Add((10,-1), 0, 0, 0)
self.btnRestore = wx.Button(self.split3_pane_1, wx.ID_ANY, "Restore")
self.btnRestore.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.btnRestore.SetToolTip('Click or CTRL+R to restore original name')
self.btnRestore.Disable()
sizer_6.Add(self.btnRestore, 1, wx.ALL | wx.EXPAND, 4)
sizer_6.Add((10,-1), 0, 0, 0)
self.btnSave = wx.Button(self.split3_pane_1, wx.ID_ANY, "Save")
self.btnSave.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.btnSave.SetToolTip ('Click or CTRL+S to save file name changes')
sizer_6.Add(self.btnSave, 1, wx.ALL | wx.EXPAND, 4)
#Delete tag available by hotkey only
self.btnDelete = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Delete')
self.btnDelete.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_DeleteTag, self.btnDelete)
#Home available by hotkey only
self.btnHome = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Home')
self.btnHome.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_Home, self.btnHome)
#Next and prev available by hotkey only
self.btnPrev = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Prev')
self.btnPrev.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_Prev, self.btnPrev)
self.btnNext = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Next')
self.btnNext.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_Next, self.btnNext)
#Help available by hotkey only
self.btnHelp = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Help')
self.btnHelp.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_Help, self.btnHelp)
sizer_7 = wx.BoxSizer(wx.HORIZONTAL)
sizer_5.Add(sizer_7, 0, wx.EXPAND, 0)
self.txtName = wx.TextCtrl(self.split3_pane_1, wx.ID_ANY, "", style=wx.TE_PROCESS_ENTER)
self.txtName.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.txtName.SetMinSize((400, -1))
sizer_7.Add(self.txtName, 1, wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 4)
self.txtIndex = wx.TextCtrl(self.split3_pane_1, wx.ID_ANY, "")
self.txtIndex.SetMinSize((40, -1))
self.txtIndex.SetMaxSize((40, -1))
self.txtIndex.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
sizer_7.Add(self.txtIndex, 0, wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 4)
self.split3_pane_2 = wx.Panel(self.split3, wx.ID_ANY)
sizer_8 = wx.BoxSizer(wx.HORIZONTAL)
# split4 contains the current tags on the top and the common tags on the bottom
self.split4 = wx.SplitterWindow(self.split3_pane_2, wx.ID_ANY)
self.split4.SetMinimumPaneSize(20)
sizer_8.Add(self.split4, 1, wx.EXPAND, 0)
self.split4_pane_1 = wx.Panel(self.split4, wx.ID_ANY)
sizer_9 = wx.BoxSizer(wx.HORIZONTAL)
self.lstCurr = wx.ListCtrl(self.split4_pane_1, wx.ID_ANY, name='curr', style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL)
self.lstCurr.AppendColumn('', width=600)
self.lstCurr.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.lstCurr.SetToolTip ('Drag to lower pane or right-click to save in common tags\n(DEL or CTRL-X to delete tag)')
sizer_9.Add(self.lstCurr, 1, wx.EXPAND, 0)
self.split4_pane_2 = wx.Panel(self.split4, wx.ID_ANY)
sizer_10 = wx.BoxSizer(wx.HORIZONTAL)
self.lstComm = wx.ListCtrl(self.split4_pane_2, wx.ID_ANY, name='comm', style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_SORT_ASCENDING)
self.lstComm.AppendColumn('', width=600)
self.lstComm.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
self.lstComm.SetToolTip ('Drag to upper pane or right-click to add to current tags\n(DEL or CTRL-X to delete tag)')
sizer_10.Add(self.lstComm, 1, wx.EXPAND, 0)
self.split4_pane_2.SetSizer(sizer_10)
self.split4_pane_1.SetSizer(sizer_9)
self.split4.SplitHorizontally(self.split4_pane_1, self.split4_pane_2)
self.split3_pane_2.SetSizer(sizer_8)
self.split3_pane_1.SetSizer(sizer_5)
self.split3.SplitVertically(self.split3_pane_1, self.split3_pane_2)
self.split1_pane_2.SetSizer(sizer_4)
self.split2_pane_2.SetSizer(sizer_3)
self.split2_pane_1.SetSizer(sizer_2)
self.split2.SplitHorizontally(self.split2_pane_1, self.split2_pane_2)
self.split1_pane_1.SetSizer(sizer_1)
self.split1.SplitVertically(self.split1_pane_1, self.split1_pane_2)
self.Layout()
self.Bind(wx.EVT_DIRCTRL_SELECTIONCHANGED, self.evt_FolderSelected, self.folders)
self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.evt_FileSelected, self.lstFiles)
self.Bind(wx.EVT_BUTTON, self.evt_Strip, self.btnStrip)
self.Bind(wx.EVT_BUTTON, self.evt_Restore, self.btnRestore)
self.Bind(wx.EVT_BUTTON, self.evt_Save, self.btnSave)
self.Bind(wx.EVT_TEXT, self.evt_NameChanged, self.txtName)
self.Bind(wx.EVT_TEXT_ENTER, self.evt_TextEnter, self.txtName)
self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstCurr)
self.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.evt_RightClick, self.lstCurr)
self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstCurr)
self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded, self.lstCurr)
self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstComm)
self.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.evt_RightClick, self.lstComm)
self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstComm)
self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded, self.lstComm)
self.Bind(wx.EVT_CLOSE, self.evt_Close, self)
#Define drag & drop
dtCurr = MyTarget(self.lstCurr, self.lstComm)
self.lstComm.SetDropTarget(dtCurr)
self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstCurr)
self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded, self.lstCurr)
self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstCurr)
dtComm = MyTarget(self.lstComm, self.lstCurr)
self.lstCurr.SetDropTarget(dtComm)
self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstComm)
self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstComm)
#Define hotkeys
hotkeys = [wx.AcceleratorEntry() for i in range(9)]
hotkeys[0].Set(wx.ACCEL_NORMAL, wx.WXK_DELETE, self.btnDelete.Id)
hotkeys[1].Set(wx.ACCEL_CTRL, ord('X'), self.btnDelete.Id)
hotkeys[2].Set(wx.ACCEL_CTRL, ord('S'), self.btnSave.Id)
hotkeys[3].Set(wx.ACCEL_CTRL, ord('1'), self.btnStrip.Id)
hotkeys[4].Set(wx.ACCEL_CTRL, ord('R'), self.btnRestore.Id)
hotkeys[5].Set(wx.ACCEL_CTRL, ord('H'), self.btnHome.Id)
hotkeys[6].Set(wx.ACCEL_NORMAL, wx.WXK_DOWN, self.btnNext.Id)
hotkeys[7].Set(wx.ACCEL_NORMAL, wx.WXK_UP, self.btnPrev.Id)
hotkeys[8].Set(wx.ACCEL_NORMAL, wx.WXK_F1, self.btnHelp.Id)
accel = wx.AcceleratorTable(hotkeys)
self.SetAcceleratorTable(accel)
#Define state indicators
self.currfile = None #current unqualified file name
self.currindx = None #index of select file in file list
self.currextn = None #current file extension
self.currbase = None #current unqualified base file name (no extension)
self.fullfile = None #current fully qualified file name
self.original = None #original file name from ADS if available
#Set default config
self.currpath = gs.myPictures()
self.split1.SetSashPosition(300)
self.split2.SetSashPosition(300)
self.split3.SetSashPosition(900)
self.split4.SetSashPosition(400)
#Load last used config
self.LoadConfig()
self.folders.ExpandPath(self.currpath)
if INSPECT: wx.lib.inspection.InspectionTool().Show()
def LoadConfig(self):
"""Load the settings from the previous run."""
if DEBUG: print('enter',iam())
self.config = os.path.splitext(__file__)[0] + ".ini"
if DEBUG: print(f'LoadConfig {self.config=}')
try:
with open(self.config,'r') as file:
for line in file.read().splitlines():
if DEBUG: print(line)
#Disable event handling during common tag restore
self.EvtHandlerEnabled = False
exec(line)
self.EvtHandlerEnabled = True
except:
print("Error during ini read")
self.EvtHandlerEnabled = True
def SaveConfig(self):
"""Save the current settings for the next run"""
if DEBUG: print('enter',iam())
x,y = self.GetPosition()
w,h = self.GetSize()
with open(self.config,'w') as file:
file.write('#Window size and position\n\n')
file.write('self.SetPosition((%d,%d))\n' % (x,y))
file.write('self.SetSize((%d,%d))\n' % (w,h))
file.write('\n#Splitter settings\n\n')
file.write('self.split1.SetSashPosition(%d)\n' % (self.split1.GetSashPosition()))
file.write('self.split2.SetSashPosition(%d)\n' % (self.split2.GetSashPosition()))
file.write('self.split3.SetSashPosition(%d)\n' % (self.split3.GetSashPosition()))
file.write('self.split4.SetSashPosition(%d)\n' % (self.split4.GetSashPosition()))
file.write('\n#Last folder\n\n')
file.write('self.currpath = "%s\"\n' % self.currpath.replace("\\","/"))
file.write('\n#Common tags\n\n')
for indx in range(self.lstComm.ItemCount):
line = 'self.lstComm.InsertItem(%d,"%s")\n' % (indx,self.lstComm.GetItemText(indx))
file.write(line)
def evt_FolderSelected(self, event):
"""User selected a folder from the directory tree"""
if DEBUG: print('enter',iam())
self.lstFiles.DeleteAllItems() #Clear file list
self.pnlImage.Clear() #Clear displayed image
self.txtName.Clear() #Clear new name
self.txtIndex.Value = '0' #Reset index
self.lstCurr.DeleteAllItems() #Clear current tags
#reset current state indicators
self.currpath = self.folders.GetPath()
self.currfile = None
self.currindx = None
self.currextn = None
self.currbase = None
self.fullfile = None
#read image files from new current folder
self.RefreshFileList()
self.Select(0)
event.Skip()
def evt_FileSelected(self, event):
"""User selected a file from the file list"""
if DEBUG: print('enter',iam())
#Update state indicators
file,indx = self.GetSelected(self.lstFiles)
self.currfile = file
self.currindx = indx
self.currextn = os.path.splitext(self.currfile)[-1]
self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
self.currbase = os.path.splitext(self.currfile)[0]
self.original = self.GetOriginalName()
self.SetStatus()
self.pnlImage.Load(self.fullfile)
self.GetNameFromFile()
self.RefreshTags()
self.pnlImage.bmpImage.SetToolTip(self.GetExifDate(self.fullfile))
event.Skip()
def evt_Strip(self, event):
"""Strip HH-MM and DSC/IMG tags"""
if DEBUG: print('enter',iam())
name = self.txtName.Value
name = re.sub(' \d\d\-\d\d', '', name) #HH-MM time tag
name = re.sub(' DSC\d+', '', name) #Sonk camera tag
name = re.sub(' DSCF\d+', '', name) #Fuji camera tag
name = re.sub(' IMG_\d+_\d+', '', name) #FIGO cell phone
name = re.sub(' IMG_\d+', '', name) #other camera tag
#If name starts with yyyy-mm-dd, make sure it is followed by a space
if re.search('^\d\d\d\d\-\d\d\-\d\d', name):
if len(name) > 10 and name[10] != ' ':
name = name[:10] + ' ' + name[10:]
#Add a trailing space so user doesn't have to when adding tags
if name[-1] != ' ': name += ' '
self.txtName.Value = name
self.txtName.SetFocus()
self.txtName.SetInsertionPointEnd()
event.Skip()
def evt_Home(self, event):
"""Select My Pictures special folder"""
if DEBUG: print('enter',iam())
self.currpath = gs.myPictures()
self.folders.ExpandPath(self.currpath)
event.Skip()
def evt_Restore(self, event):
"""Restore original name if available"""
if DEBUG: print('enter',iam())
if not self.original:
return
oldname = self.fullfile
newname = os.path.join(self.currpath, self.original)
try:
#Restore original name and remove undo ADS
os.rename(oldname, newname)
ads = newname + ADS
os.remove(newname + ADS)
#Update state variables
self.currfile = os.path.split(newname)[-1]
self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
self.currbase = os.path.splitext(self.currfile)[0]
self.currextn = os.path.splitext(self.currfile)[-1]
self.original = ''
self.SetStatus()
self.GetNameFromFile()
##Update file list
self.lstFiles.SetItemText(self.currindx, self.currfile)
except OSError:
self.Message('Could not restore original name. Undo info invalid')
except FileExistsError:
self.Message('Could not restore original name. A file with that name already exists')
event.Skip()
def evt_Save(self, event):
"""Rename the image file using new name plus index (if non-zero)"""
if DEBUG: print('enter',iam())
if self.txtName.Value == '':
self.Message('New name can not be blank')
return
oldname = self.fullfile
newname = os.path.join(self.currpath, self.CreateNewName())
if DEBUG: print(f'{oldname=}\n{newname=}\n')
try:
os.rename(oldname, newname)
#Save original file name for undo
ads = newname + ADS
if not os.path.isfile(ads):
if DEBUG: print('save original name to',ads)
open(ads, 'w').write(self.currfile)
self.original = self.currfile
self.SetStatus()
self.currfile = self.CreateNewName()
self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
self.currbase = os.path.splitext(self.currfile)[0]
self.currextn = os.path.splitext(self.currfile)[-1]
#Update the file list with the new name
self.lstFiles.SetItemText(self.currindx, self.CreateNewName())
self.SelectNext()
except OSError:
self.Message('The new name is not a valid file name')
except FileExistsError:
self.Message('A file with that name already exists')
event.Skip()
def evt_NameChanged(self, event):
"""The new name has been changed either by dragging a tag from the common tag list
or by manually typing in the new name text control. Because refreshing the current
tag list can cause removal of extra blanks in the new name, we must disable events
when calling RefreshTags from within this handler."""
if DEBUG: print('enter',iam())
if DEBUG: print(f'{self.txtName.Value=}')
self.EvtHandlerEnabled = False
ip = self.txtName.GetInsertionPoint()
self.RefreshTags()
self.txtName.SetInsertionPoint(ip)
self.RecalcIndex()
self.EvtHandlerEnabled = True
try: self.txtName.SetToolTip('NEW NAME: ' + self.CreateNewName())
except: pass
event.Skip()
def evt_RightClick(self, event):
"""Common tag item right clicked"""
if DEBUG: print('enter',iam())
if self.currfile:
srce = event.GetEventObject()
text = self.GetSelected(srce)[0]
dest = self.lstComm if srce == self.lstCurr else self.lstCurr
#copy from srce to dest if not already in list
if dest.FindItem(-1,text) == -1:
dest.InsertItem(srce.ItemCount, text)
event.Skip()
def evt_StartDrag(self, event):
"""Starting drag from current or common tags control"""
if DEBUG: print('enter',iam())
obj = event.GetEventObject() #get the control initiating the drag
text = obj.GetItemText(event.GetIndex()) #get the currently selected text
tobj = wx.TextDataObject(text) #convert it to a text object
srce = wx.DropSource(obj) #create a drop source object
srce.SetData(tobj) #save text object in the new object
srce.DoDragDrop(True) #complete the drag/drop
event.Skip()
def evt_DeleteTag(self, event):
"""Delete the tag from whichever list control currently has focus"""
if DEBUG: print('enter',iam())
if self.lstCurr.HasFocus():
#delete from the current tag list
text,indx = self.GetSelected(self.lstCurr)
self.lstCurr.DeleteItem(indx)
self.RefreshName()
elif self.lstComm.HasFocus():
#delete from the common tag list
text,indx = self.GetSelected(self.lstComm)
self.lstComm.DeleteItem(indx)
else:
return
event.Skip()
def evt_TagDeleted(self, event):
if DEBUG: print('enter',iam())
self.RefreshName()
event.Skip()
def evt_TagAdded(self, event):
if DEBUG: print('enter',iam())
self.RefreshName()
event.Skip()
def evt_Close(self, event):
if DEBUG: print('enter',iam())
self.SaveConfig()
event.Skip()
def evt_TextEnter(self, event):
if DEBUG: print('enter',iam())
event.Skip()
def evt_Prev(self, event):
if DEBUG: print('enter',iam())
self.SelectPrevious()
event.Skip()
def evt_Next(self, event):
if DEBUG: print('enter',iam())
self.SelectNext()
event.Skip()
def evt_Help(self, event):
self.Message(self.Help())
event.Skip()
def RefreshFileList(self):
"""Refresh the file list by reading all image files in the current folder"""
if DEBUG: print('enter',iam())
self.lstFiles.DeleteAllItems()
for item in os.scandir(self.currpath):
if pt.isImage(item.name):
self.lstFiles.InsertItem(self.lstFiles.ItemCount, item.name)
self.btnRestore.Disable()
def Select(self, indx):
"""Select the file with the given zero-relative index"""
if DEBUG: print('enter',iam())
if indx < 0 or indx >= self.lstFiles.ItemCount:
return
if (focus := self.lstFiles.FocusedItem) != indx:
#unselect the current item
self.lstFiles.SetItemState(focus, 0, wx.LIST_STATE_SELECTED)
#select the new item
self.lstFiles.Focus(indx)
self.lstFiles.SetItemState(indx, wx.LIST_STATE_SELECTED, wx.LIST_STATE_SELECTED)
def SelectPrevious(self):
"""Select the previous file in the list if available"""
if DEBUG: print('enter',iam())
self.Select(self.lstFiles.FocusedItem - 1)
def SelectNext(self):
"""Select the next file in the list if available"""
if DEBUG: print('enter',iam())
self.Select(self.lstFiles.FocusedItem + 1)
def RefreshTags(self):
"""Rebuild current tag list from new file name"""
if DEBUG: print('enter',iam())
self.EvtHandlerEnabled = False
self.lstCurr.DeleteAllItems()
for tag in self.txtName.Value.split():
if self.lstCurr.FindItem(-1,tag) == -1:
self.lstCurr.InsertItem(self.lstCurr.ItemCount,tag)
self.EvtHandlerEnabled = True
def RefreshName(self):
"""Rebuild the new name by combining all tags in the current tag list"""
if DEBUG: print('enter',iam())
#combine all list items separated by one space
name = ''
for indx in range(self.lstCurr.ItemCount):
name += self.lstCurr.GetItemText(indx) + ' '
#disable events to prevent infinite recursion
self.EvtHandlerEnabled = False
self.txtName.Value = name
self.EvtHandlerEnabled = True
self.RecalcIndex()
self.txtName.SetFocus()
self.txtName.SetInsertionPointEnd()
def GetSelected(self, lst):
"Returns (text,index) of the currently selected item in a list control"""
indx = lst.GetFocusedItem()
text = lst.GetItemText(indx)
return text, indx
def GetNameFromFile(self):
"""
Given a base file name (no path & no extension), strip off
any index information at the end of the name (an integer enclosed
in parentheses) and copy what is left to the NewName control. Any
index found goes to the Index control.
"""
if DEBUG: print('enter',iam())
if (m := re.search('\(\d*\)$', self.currbase)):
self.txtIndex.Value = self.currbase[m.start()+1:-1]
self.txtName.Value = self.currbase[:m.start()-1].strip() + ' '
else:
self.txtIndex.Value = '0'
self.txtName.Value = self.currbase + ' '
def RecalcIndex(self):
"""Calculate an index to ensure unique file name"""
if DEBUG: print('enter', iam())
if self.txtName.Value == '': return
#Look for the first free file name starting with index = 0
self.txtIndex.Value = '0'
while self.FileExists(self.CreateNewName()):
if DEBUG: print('trying',self.CreateNewName())
self.txtIndex.Value = str(int(self.txtIndex.Value) + 1)
def CreateNewName(self):
"""Create a new name by combining the displayed new name with the index"""
if DEBUG: print('enter', iam())
indx = int(self.txtIndex.Value)
if indx != 0:
name = self.txtName.Value.strip() + (' (%02d)' % indx) + self.currextn.lower()
else:
name = self.txtName.Value.strip() + self.currextn.lower()
return name.replace(' ',' ')
def FileExists(self, file):
"""
Scans the current file list (except for the currently selected
file) and returns True if the given file is in the list.
"""
if DEBUG: print(f'look for {file=}')
for i in range(self.lstFiles.ItemCount):
if i != self.currindx:
if file.lower() == self.lstFiles.GetItemText(i).lower():
return True
return False
def GetExifDate(self, file):
"""Returns the EXIF data/time stamp if found in the file"""
try:
with open(file, 'rb') as fh:
img = Image(fh)
return 'EXIF date/time: ' + img.datetime
except:
return 'No EXIF data'
def GetOriginalName(self):
"""Get original name if available"""
ads = self.fullfile + ADS
if os.path.isfile(ads):
with open(ads) as fh:
return fh.read()
else:
return ''
def SetStatus(self):
if self.original:
self.status.SetStatusText('Original Name:', 0)
self.status.SetStatusText(self.original, 1)
self.btnRestore.Enable()
else:
self.status.SetStatusText('No undo', 0)
self.status.SetStatusText('', 1)
self.btnRestore.Disable()
def Message(self, text):
wx.MessageBox(text, TITLE, wx.OK)
def Help(self):
return"""
Tags from the selected file are displayed in the current (upper right panel) list. The
lower right panel contains commonly used tags. Both lists can be modified by
1. dragging tags from one to the other
2. pressing DEL or CTRL+X to delete
Deleting a tag from the current list will remove it fron the edited file name. Typing
in the edited file name will update the current tag list. Changes to the common tag list
will be retained for future use.
The original file name is saved and may be restored by either
1. clicking Restore
2. pressing CTRL+R
The file will not be renamed until you either
1. Click Save
2. press CTRL-S
You will not be prompted to apply unsaved changes.
Hotkeys are:
Arrow Up - select previous file
Arrow Down - select next file
CTRL+1 - strip camera crud
CTRL+S - save file name changes
CTRL+X - delete selected current or common tag
CTRL+R - restore original file name
CTRL+H - select home (My Pictures) folder
"""
class MyApp(wx.App):
def OnInit(self):
self.frame = MyFrame(None, wx.ID_ANY, "")
self.SetTopWindow(self.frame)
self.frame.Show()
return True
if __name__ == "__main__":
app = MyApp(0)
app.MainLoop()
perceivedType.py
"""
Name:
perceivedType.py
Description:
This is a set of methods that use the Windows registry to return a string
describing how Windows interprets the given file. The current methods will
return a string description as provided by Windows, or "unknown" if Windows
does not have an associated file type. The auxiliary functions return True
or False for tests for specific file types.
Auxiliary Functions:
isVideo(file) - returns True if PerceivedType = "video"
isAudio(file) - returns True if PerceivedType = "audio"
isImage(file) - returns True if PerceivedType = "image"
isText (file) - returns True if PerceivedType = "text"
Parameters:
file:str a file name
degug:bool print debug info if True (default=False)
Audit:
2021-07-17 rj original code
"""
import os
import winreg
def perceivedType(file: str, debug: bool = False) -> str:
"""Returns the windows registry perceived type string for the given file"""
if debug:
print(f'\nchecking {file=}')
try:
key = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, os.path.splitext(file)[-1])
inf = winreg.QueryInfoKey(key)
for i in range(0, inf[1]):
res = winreg.EnumValue(key, i)
if debug:
print(f' {res=}')
if res[0] == 'PerceivedType':
return res[1].lower()
except:
pass
return "unknown"
def isVideo(file: str) -> str: return perceivedType(file) == 'video'
def isAudio(file: str) -> str: return perceivedType(file) == 'audio'
def isImage(file: str) -> str: return perceivedType(file) == 'image'
def isText(file: str) -> str: return perceivedType(file) == 'text'
if __name__ == '__main__':
for file in ('file.avi', 'file.mov', 'file.txt', 'file.jpg', 'file.mp3', 'file.pdf', 'file.xyz'):
print('Perceived type of "%s" is %s' % (file, perceivedType(file, debug=True)))
ImagePanel.py
"""
Name:
ImagePanel.py
Description:
A panel containing a wx.StaticBitmap control that can be used to display
an image. The image is scale to fit inside the panel while maintaining the
image's original aspect ratio. The image size is recaulated whenever the
panel is resized.
You can zoom in/out using CTRL+Scroll Wheel. The image is displayed in a
panel with scroll bars. If zoomed in you can scroll to see portions of the
image that are off the display.
Methods:
Load(file) - load and display the image from the given file
Clear() - clear the display
All common image formats are supported.
Audit:
2021-07-20 rj original code
"""
import wx
#import wx.lib.mixins.inspection
import wx.lib.scrolledpanel as scrolled
class ImagePanel(scrolled.ScrolledPanel):
"""
This control implements a basic image viewer. As the control is
resized the image is resized (aspect preserved) to fill the panel.
Methods:
Load(filename) display the image from the given file
Clear() clear the displayed image
"""
def __init__(self, parent, id=wx.ID_ANY,
pos=wx.DefaultPosition,
size=wx.DefaultSize,
style=wx.BORDER_SUNKEN
):
super().__init__(parent, id, pos, size, style=style)
self.bmpImage = wx.StaticBitmap(self, wx.ID_ANY)
sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer.Add(self.bmpImage, 1, wx.EXPAND, 0)
self.SetSizer(sizer)
self.bitmap = None # loaded image in bitmap format
self.image = None # loaded image in image format
self.aspect = None # loaded image aspect ratio
self.zoom = 1.0 # zoom factor
self.blank = wx.Bitmap(1, 1)
self.Bind(wx.EVT_SIZE, self.OnSize)
self.Bind(wx.EVT_MOUSEWHEEL, self.OnMouseWheel)
self.SetupScrolling()
# wx.lib.inspection.InspectionTool().Show()
def OnSize(self, event):
"""When panel is resized, scale the image to fit"""
self.ScaleToFit()
event.Skip()
def OnMouseWheel(self, event):
"""zoom in/out on CTRL+scroll"""
m = wx.GetMouseState()
if m.ControlDown():
delta = 0.1 * event.GetWheelRotation() / event.GetWheelDelta()
self.zoom = max(1, self.zoom + delta)
self.ScaleToFit()
event.Skip()
def Load(self, file: str) -> None:
"""Load the image file into the control for display"""
self.bitmap = wx.Bitmap(file, wx.BITMAP_TYPE_ANY)
self.image = wx.Bitmap.ConvertToImage(self.bitmap)
self.aspect = self.image.GetSize()[1] / self.image.GetSize()[0]
self.zoom = 1.0
self.bmpImage.SetBitmap(self.bitmap)
self.ScaleToFit()
def Clear(self):
"""Set the displayed image to blank"""
self.bmpImage.SetBitmap(self.blank)
self.zoom = 1.0
def ScaleToFit(self) -> None:
"""
Scale the image to fit in the container while maintaining
the original aspect ratio.
"""
if self.image:
# get container (c) dimensions
cw, ch = self.GetSize()
# calculate new (n) dimensions with same aspect ratio
nw = cw
nh = int(nw * self.aspect)
# if new height is too large then recalculate sizes to fit
if nh > ch:
nh = ch
nw = int(nh / self.aspect)
# Apply zoom
nh = int(nh * self.zoom)
nw = int(nw * self.zoom)
# scale the image to new dimensions and display
image = self.image.Scale(nw, nh)
self.bmpImage.SetBitmap(image.ConvertToBitmap())
self.Layout()
if self.zoom > 1.0:
self.ShowScrollBars = True
self.SetupScrolling()
else:
self.ShowScrollBars = False
self.SetupScrolling()
if __name__ == "__main__":
app = wx.App()
frame = wx.Frame(None)
panel = ImagePanel(frame)
frame.SetSize(800, 625)
frame.Show()
panel.Load('D:\\test.jpg')
app.MainLoop()
GetSpecialFolder.py
from win32com.shell import shell, shellcon
#def myDocuments():
# return shell.SHGetFolderPath(0, shellcon.CSIDL_MYDOCUMENTS, None, 0)
def myMusic():
return shell.SHGetFolderPath(0, shellcon.CSIDL_MYMUSIC, None, 0)
def myPictures():
return shell.SHGetFolderPath(0, shellcon.CSIDL_MYPICTURES, None, 0)
def myVideos():
return shell.SHGetFolderPath(0, shellcon.CSIDL_MYVIDEO, None, 0)
if __name__ == "__main__":
#myDocuments()
print(myMusic())
print(myPictures())
print(myVideos())