Windows commenting tool in python/wxpython

Adding a file/folder comment capability to Windows

This is something I wrote a few years ago in vb.Net and just recently ported to python/wxpython. As I keep discovering, just about everything is easier in python.

Windows does not have a commenting facility so I decided to write something simple. NTFS supports something called Alternate Data Streams (ADS). For example, when you write text to a file named myfile.txt, the text goes where you would expect. But you can create an ADS for that file by appending a colon and a name to the file name. So you can write text to the ADS myfile.txt:myads and the text, rather than being stored in the file, is stored as a file attribute.

Technical note - the file text is also a data stream but with a name of null so any named data stream is an alternate data stream. Confused yet?

Anyway, since you can create any name for an ADS, I used the name comment. When I want to create a comment for a file or folder I just add :comment and write to that.

The commenting utility consists of four parts:

  1. The commenting code
  2. The GUI code
  3. The registry patch
  4. The IMDB code (optional)

The actual comment interface code consists of four methods

  1. hasComment(item)
  2. getComment(item)
  3. setComment(item, comment)
  4. deleteComment(item)

I don't think explanation is necessary. Here is the code for comment.py

"""                                                                             
    Name:                                                                       

        Comment.py                                                              

    Description:                                                                

        A set of methods to maintain a :comment alternate data stream in files  
        and folders. Alternate data streams are a feature of NTFS and are not    
        available on other filing systems.                                      

    Audit:                                                                      

        2020-06-29  rj  original code                                           

"""

import os


def __ads__(item):
    """Returns the name of the comment ADS for the given file or folder"""
    return item + ":comment"


def hasComment(item):
    """Returns True if the given file or folder has a comment"""
    return os.path.exists(__ads__(item))


def getComment(item):
    """Returns the comment associated with the given file or folder"""
    return open(__ads__(item), 'r').read() if os.path.exists(item + ":comment") else ""


def setComment(item, comment):
    """Sets the comment for the given file or folder"""
    if os.path.exists(item):
        open(__ads__(item), 'w').write(comment)


def deleteComment(item):
    """Deletes the comment for the given file or folder"""
    if os.path.exists(__ads__(item)):
        os.remove(__ads__(item))


if __name__ == "__main__":

    file = 'comment.py'

    if hasComment(file):
        print(file, 'has comment', getComment(file))
    else:
        print(file, 'does not have a comment')
    setComment(file, "this is a comment")

    if hasComment(file):
        print(file, 'has comment', getComment(file))
    else:
        print(file, 'does not have a comment')

    deleteComment(file)

    if hasComment(file):
        print(file, 'has comment', getComment(file))
    else:
        print(file, 'does not have a comment')

The GUI consists of a window with two panels. The upper panel contains a word-wrapped text control with a vertical scrollbar (if needed). The lower panel contains two button, Save and IMDB. The first button is self-explanatory. The second button, which you are free to remove, is a hook into the Internet Movie DataBase. If you should happen to have files or folders named for movies or TV series, clicking on the IMDB button will attempt to fetch the IMDB info for that name. More on this later. The code for Comment.pyw is as follows:

"""
Name:

    Comment.pyw

Description:

    Allows the user to add a comment to a file or a folder. The comment is saved
    in an alternate data stream (ADS) named "comment". If the file/folder name is
    a string that corresponds to an IMDB entry then clicking the IMDB button will
    attempt to fetch the IMDB data for that string.

Usage:

    comment file/folder

    Running the Comment.reg file in this folder will add a "Comment" context menu
    item to Windows Explorer. Before running comment.reg, ensure that the path names
    for pythonw.exe and Comment.pyw match your system.

    If you intend to use the IMDB facility you must first go to omdbapi.com and get
    a free access key which you must assign to the variable, KEY in getIMDB.py.

Audit:

    2022-09-12  rj  original code

"""

import os
import sys
import wx

from comment import *
from getIMDB import *


class MyFrame(wx.Frame):
    def __init__(self, *args, **kwds):
        # begin wxGlade: MyFrame.__init__
        kwds["style"] = kwds.get("style", 0) | wx.CAPTION | wx.CLIP_CHILDREN | wx.CLOSE_BOX | wx.RESIZE_BORDER | wx.SYSTEM_MENU
        wx.Frame.__init__(self, *args, **kwds)

        self.SetSize((400, 300))
        self.SetTitle(sys.argv[1])

        # Define a GUI consisting of an upper panel for the comment and a lower
        # panel with Save and IMDB buttons.

        self.panel_1 = wx.Panel(self, wx.ID_ANY)

        sizer_1 = wx.BoxSizer(wx.VERTICAL)

        style = wx.HSCROLL | wx.TE_MULTILINE | wx.TE_PROCESS_ENTER | wx.TE_PROCESS_TAB | wx.TE_WORDWRAP
        style = wx.TE_MULTILINE | wx.TE_PROCESS_ENTER | wx.TE_PROCESS_TAB | wx.TE_WORDWRAP

        self.txtComment = wx.TextCtrl(self.panel_1, wx.ID_ANY, "", style=style)
        self.txtComment.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, ""))
        sizer_1.Add(self.txtComment, 1, wx.EXPAND, 0)

        sizer_2 = wx.BoxSizer(wx.HORIZONTAL)

        self.btnSave = wx.Button(self.panel_1, wx.ID_ANY, "Save")
        self.btnSave.SetToolTip("Save comment and close")
        sizer_2.Add(self.btnSave, 0, 0, 0)

        sizer_2.Add((20, 20), 1, 0, 0)

        self.btnIMDB = wx.Button(self.panel_1, wx.ID_ANY, "IMDB")
        self.btnIMDB.SetToolTip("Fetch info from IMDB")
        sizer_2.Add(self.btnIMDB, 0, 0, 0)

        sizer_1.Add(sizer_2, 0, wx.EXPAND, 0)

        self.panel_1.SetSizer(sizer_1)

        self.Layout()

        # Restore last window size and position

        self.LoadConfig()

        # Bind event handlers

        self.Bind(wx.EVT_BUTTON, self.evt_save, self.btnSave)
        self.Bind(wx.EVT_BUTTON, self.evt_imdb, self.btnIMDB)
        self.Bind(wx.EVT_CLOSE, self.evt_close)
        self.Bind(wx.EVT_TEXT, self.evt_text, self.txtComment)

        # Get file/folder name from command line
        self.item = sys.argv[1]
        self.btnSave.Enabled = False

        # Display comment if one exists        
        if hasComment(item):
            self.txtComment.SetValue(getComment(item))

    def LoadConfig(self):
        """Load the last run user settings"""
        self.config = os.path.splitext(__file__)[0] + ".ini"       
        try:
            with open(self.config,'r') as file:
                for line in file.read().splitlines():
                    exec(line)
        except: pass

    def SaveConfig(self):
        """Save the current user settings for the next run"""

        x,y = self.GetPosition()
        w,h = self.GetSize()

        with open(self.config,'w') as file:
            file.write('self.SetPosition((%d,%d))\n' % (x, y))
            file.write('self.SetSize((%d,%d))\n' % (w, h))

    def evt_imdb(self, event):
        """Set comment to IMDB data for the current file/folder"""
        self.txtComment.SetValue(getIMDB(self.item))
        event.Skip()

    def evt_text(self, event):
        """Comment has changed - Enable Save button"""
        self.btnSave.Enabled = True
        event.Skip()

    def evt_save(self, event):
        """Update the comment for the current file/folder"""
        comment = self.txtComment.GetValue().strip()
        if comment:
            setComment(self.item, self.txtComment.GetValue())
        else:
            deleteComment(item)

        self.Destroy()
        event.Skip()

    def evt_close(self, event):
        """Delete the file/folder comment if all blank or null"""
        if not self.txtComment.GetValue().strip():
            deleteComment(item)
        self.SaveConfig()
        event.Skip()


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__":
    if len(sys.argv) <= 2:
        item = sys.argv[1]
        if os.path.exists(item):
            app = MyApp(0)
            app.MainLoop()

The registry patch adds the context menu for files and folders. The given patch is configured for my system. You will have to modify the path strings to correspond to

  1. The location of pythonw.exe

  2. The location of Comment.pyw

    REGEDIT4

    [HKEY_CLASSES_ROOT*\shell\Comment]
    @="Comment"

    [HKEY_CLASSES_ROOT*\shell\Comment\command]
    @="\"C:\Users\rjdeg\AppData\Local\Programs\Python\Python310\pythonw.exe\" \"D:\apps\Comment\Comment.pyw\" \"%1\""

    [HKEY_CLASSES_ROOT\Directory\Shell\Comment]
    @="Comment"

    [HKEY_CLASSES_ROOT\Directory\Shell\Comment\command]
    @="\"C:\Users\rjdeg\AppData\Local\Programs\Python\Python310\pythonw.exe\" \"D:\apps\Comment\Comment.pyw\" \"%1\""

Finally, the code for getIMDB.py is as follows:

"""
Name:

    getIMDB.py

Description:

    Given a string (item), getIMDB will query the internet moovie database (IMDB)
    and attempt to return the info for any item matching that string.

Usage:

    info = getIMDB(item)

    Item can be a string such as "War of the Worlds". Note that there are multiple
    movies with this name. If you use "War of the Worlds [1953]" you should get
    the correct info (but it's not perfect).

    Info is returned formatted as (for example)

        Year:       1953
        Runtime:    85 min
        Genre:      Action, Sci-Fi, Thriller
        Rating:     7.0
        Director:   Byron Haskin
        IMDB ID:    tt0046534

        H.G. Wells' classic novel is brought to life in this tale of alien invasion. The residents of a small town in California are excited when a flaming meteor lands in the hills. Their joy is tempered somewhat when they discover that it has passengers who are not very friendly.

        Gene Barry
        Ann Robinson
        Les Tremayne

Note:

    In order to use this you must get a free key from omdbapi.com and modify the
    value of KEY, replacing ######## with your assigned key.

Audit:

    2022-09-12  rj  original code
"""

import requests
import json
import os

def getKey(info, key):
    if key in info:
        return info[key]
    else:
        return 'n/a'

def getIMDB(item):

    # Replace ######## with your key assigned from omdapi.com
    URL = "http://www.omdbapi.com/?r=JSON&plot=FULL"
    KEY = "&apikey=########"

    # item can contain the year as 'name [yyyy]' or 'name (yyyy)'

    item  = item.replace('(','[').replace(')',']')
    temp  = item.split('\\')[-1]
    movie = os.path.splitext(temp)[0]
    title = movie.split('[')[0].strip()

    try:
        year = item.split('[')[1].replace(']','')
    except:
        year = ''

    req = URL + "&t=" + title + "&y=" + year + KEY
    res = requests.get(req)

    if res.status_code == 200:
        info = json.loads(res.text)
        return (
            'Year:\t' + getKey(info,'Year')[:4]       + '\n'   +
            'Runtime:\t' + getKey(info,'Runtime')    + '\n'   +
            'Genre:\t' + getKey(info,'Genre')      + '\n'   +
            'Rating:\t' + getKey(info,'imdbRating') + '\n'   +
            'Director:\t' + getKey(info,'Director')   + '\n' +
            'IMDB ID:\t' + getKey(info,'imdbID') + '\n\n' +
            getKey(info,'Plot') + '\n\n' +
            getKey(info,'Actors').replace(', ', '\n')
        )
    else:
        return None

if __name__ == '__main__':
    name = r'd:\My Videos\War of the Worlds [1953'
    imdb = getIMDB(name)
    print(imdb)

You can modify the format of the returned string as you like.