Image File Tagging App in Python/wxPython

Reverend Jim

Requires:

  1. Python version 3.8 or newer
  2. wxPython version 4.0 or newer
  3. exif module
  4. Windows with NTFS file system

To ensure you have the required packages please run the following

  1. python -m pip install --upgrade pip
  2. pip install exif
  3. 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

  1. Typing new text in the current file name text area
  2. Deleting tags from the current tag list
  3. Adding tags from a common tag list

Let's have a look at the GUI.

tagger.jpg

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

  11. 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())
pritaeas commented: Nice! +17
56 Views
About the Author

I completed my Computer Science degree at the University of Manitoba in 1976. I did two and a half years of programming in medical research followed by twenty-nine years at Manitoba Hydro (electric utility). Most of that was spent on doing development and maintenance on an AGC/SCADA (real-time programming/process control) system. The last ten years of that was spent doing application and infrastructure support and development. I have programmed in FORTRAN (mostly), APL, PL/1, COBOL, Lisp, SNOBOL, ALGOL, Assembler (several flavours), C, C++, Paradox, VB, vbScript and more recently, Python. I am married with two grown children of whom I am very proud, and a most beautiful wife. I am currently retired (and loving it).

logicslab 15 Unverified User

Sir,
What's the purpose of Image File Tagging ?

Reverend Jim 3,708 Hi, I'm Jim, one of DaniWeb's moderators. Moderator Featured Poster

When my wife says, "can you find me the picture of Adam and Cooper at Shebandowan" I can bring up an "Everything" search window and type in those three terms. It immediately shows me all pictures that have those terms in any order in the file name. Try doing that without file tagging.

Think of it like hashtags but for local files.

rproffitt commented: #usefulthings +16
Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts learning and sharing knowledge.