#!/usr/bin/python3
# TODO: get rid of sqlite cursors; execute on connection
# TODO: add index to table creation
# TODO: why are there TWO sqlite.connect()??
# try auto-commit (isolation_level = "IMMEDIATE")
# try dict-parameters in sql statements
# TODO: pick up comment for cwd and display at the top somewhere, or maybe status line
""" a simple gui or command line app
to view and create/edit file comments
comments are stored in an SQLite3 database
default ~/.dirnotes.db
where possible, comments are duplicated in
xattr user.xdg.comment
some file systems don't allow xattr, and even linux
doesn't allow xattr on symlinks, so the database is
considered primary
these comments stick to the symlink, not the deref
nav tools are enabled, so you can double-click to go into a dir
"""
VERSION = "0.5"
helpMsg = f"""
Dirnotes |
| Version: {VERSION} |
Overview
This app allows you to add comments to files. The comments are stored in
a database, and where possible, saved in the xattr (hidden attributes)
field of the file system.
Double click on a comment to create or edit.
You can sort the directory listing by clicking on the column heading.
Double click on directory names to navigate the file system. Hover over
fields for more information.
xattr extended attributes
The xattr comment suffers from a few problems:
- is not implemented on FAT/VFAT/EXFAT file systems (some USB sticks)
- xattrs are not (by default) copied with the file when it's duplicated
or backedup (mv, rsync and tar work, ssh and scp don't)
- xattrs are not available for symlinks
- some programs which edit files do not preserve the xattrs during file-save (vim)
On the other hand, xattr comments can be bound directly to files on removable
media (as long as the disk format allows it).
When the database version of a comment differs from the xattr version,
the comment box gets a light yellow background.
"""
import sys,os,argparse,stat,getpass,shutil
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import Qt, pyqtSignal
import sqlite3, json, time
VERSION = "0.4"
xattr_comment = "user.xdg.comment"
xattr_author = "user.xdg.comment.author"
xattr_date = "user.xdg.comment.date"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
YEAR = 3600*25*365
## globals
mode_names = {"db":" ","xattr":""}
modes = ("db","xattr")
mode = "db"
global mainWindow, dbName
DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
# config
# we could store the config in the database, in a second table
# or in a .json file
DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
"database":"~/.dirnotes.db",
"start_mode":"xattr",
"options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
"options for start_mode":("db","xattr")
}
verbose = None
def print_v(*a):
if verbose:
print(*a)
class dnDataBase:
''' the database is flat
fileName: fully qualified name
st_mtime: a float
size: a long
comment: a string
comment_time: a float, the time of the comment save
author: the username that created the comment
the database is associated with a user, in the $HOME dir
'''
def __init__(self,dbFile):
'''try to open the database; if not found, create it'''
try:
self.db = sqlite3.connect(dbFile)
except sqlite3.OperationalError:
print(f"Database {dbFile} not found")
raise
try:
self.db.execute("select * from dirnotes")
except sqlite3.OperationalError:
print_v("Table %s created" % ("dirnotes"))
self.db.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
self.db_cursor.execute("create index dirnotes_i on dirnotes(name)")
# getData is only used by the restore-from-database.......consider deleting it
def getData(self, fileName):
c = self.db.execute("select * from dirnotes where name=? and comment<>'' order by comment_date desc",(os.path.abspath(fileName),))
return c.fetchone()
@staticmethod
def epochToDb(epoch):
return time.strftime(DATE_FORMAT,time.localtime(epoch))
@staticmethod
def DbToEpoch(dbTime):
return time.mktime(time.strptime(dbTime,DATE_FORMAT))
@staticmethod
def getShortDate(longDate):
now = time.time()
diff = now - longDate
if diff > YEAR:
fmt = "%b %e %Y"
else:
fmt = "%b %d %H:%M"
return time.strftime(fmt, time.localtime(longDate))
## one for each file
## and a special one for ".." parent directory
class FileObj():
FILE_IS_DIR = -1
FILE_IS_LINK = -2
FILE_IS_SOCKET = -3
def __init__(self, fileName):
self.fileName = os.path.abspath(fileName) # full path; dirs end WITHOUT a terminal /
self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
s = os.lstat(self.fileName)
self.date = s.st_mtime
if stat.S_ISDIR(s.st_mode):
self.size = FileObj.FILE_IS_DIR
if not self.displayName.endswith('/'):
self.displayName += '/'
elif stat.S_ISLNK(s.st_mode):
self.size = FileObj.FILE_IS_LINK
elif stat.S_ISSOCK(s.st_mode):
self.size = FileObj.FILE_IS_SOCKET
else:
self.size = s.st_size
self.xattrComment = ''
self.xattrAuthor = None
self.xattrDate = None
self.dbComment = ''
self.dbAuthor = None
self.dbDate = None
self.commentsDiffer = False
try:
self.xattrComment = os.getxattr(fileName, xattr_comment, follow_symlinks=False).decode()
self.xattrAuthor = os.getxattr(fileName, xattr_author, follow_symlinks=False).decode()
self.xattrDate = os.getxattr(fileName, xattr_date, follow_symlinks=False).decode()
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
except: # no xattr comment
pass
def getName(self):
return self.fileName
def getDisplayName(self):
return self.displayName
# with an already open database cursor
def loadDbComment(self,db):
c = db.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
a = c.fetchone()
if a:
self.dbComment, self.dbAuthor, self.dbDate = a
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
def getDbComment(self):
return self.dbComment
def getDbAuthor(self):
return self.dbAuthor
def getDbDate(self):
return self.dbDate
def setDbComment(self,db,newComment):
s = os.lstat(self.fileName)
try:
# TODO: copy from /g/test_file to /home/patb/project/dirnotes/r fails on database.commit()
print_v(f"setDbComment db {db}, file: {self.fileName}")
print_v("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
(self.fileName, s.st_mtime, s.st_size,
str(newComment), time.time(), getpass.getuser()))
db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
(self.fileName, s.st_mtime, s.st_size,
str(newComment), time.time(), getpass.getuser()))
print_v(f"setDbComment, execute done, about to commit()")
db.commit()
print_v(f"database write for {self.fileName}")
self.dbComment = newComment
except sqlite3.OperationalError:
print_v("database is locked or unwritable")
errorBox("the database that stores comments is locked or unwritable")
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
def getXattrComment(self):
return self.xattrComment
def getXattrAuthor(self):
return self.xattrAuthor
def getXattrDate(self):
print_v(f"someone accessed date on {self.fileName} {self.xattrDate}")
return self.xattrDate
def setXattrComment(self,newComment):
print_v(f"set comment {newComment} on file {self.fileName}")
try:
os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
self.xattrAuthor = getpass.getuser()
self.xattrDate = time.strftime(DATE_FORMAT) # alternatively, re-instantiate this FileObj
self.xattrComment = newComment
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
return True
# we need to move these cases out to a handler
except Exception as e:
if self.size == FileObj.FILE_IS_LINK:
errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
elif self.size == FileObj.FILE_IS_SOCKET:
errorBox("Linux does not allow comments on sockets; comment is stored in database")
elif os.access(self.fileName, os.W_OK)!=True:
errorBox("you don't appear to have write permissions on this file")
# change the listbox background to yellow
self.displayBox.notifyUnchanged()
elif "Errno 95" in str(e):
errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
self.commentsDiffer = True
return False
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
def getComment(self):
return self.getDbComment() if mode == "db" else self.getXattrComment()
def getOtherComment(self):
return self.getDbComment() if mode == "xattr" else self.getXattrComment()
def getDate(self):
return self.date
def getSize(self):
return self.size
def isDir(self):
return self.size == self.FILE_IS_DIR
def isLink(self):
return self.size == self.FILE_IS_LINK
class HelpWidget(QDialog):
def __init__(self, parent):
super(QDialog, self).__init__(parent)
self.layout = QVBoxLayout(self)
self.tb = QLabel(self)
self.tb.setWordWrap(True)
self.tb.setText(helpMsg)
self.tb.setFixedWidth(500)
self.pb = QPushButton('OK',self)
self.pb.setFixedWidth(200)
self.layout.addWidget(self.tb)
self.layout.addWidget(self.pb)
self.pb.clicked.connect(self.close)
self.show()
class errorBox(QDialog):
def __init__(self, text):
print_v(f"errorBox: {text}")
super(QDialog, self).__init__(mainWindow)
self.layout = QVBoxLayout(self)
self.tb = QLabel(self)
self.tb.setWordWrap(True)
self.tb.setFixedWidth(500)
self.tb.setText(text)
self.pb = QPushButton("OK",self)
self.layout.addWidget(self.tb)
self.layout.addWidget(self.pb)
self.pb.clicked.connect(self.close)
self.show()
icon = ["32 32 6 1", # the QPixmap constructor allows for str[]
" c None",
". c #666666",
"+ c #FFFFFF",
"@ c #848484",
"# c #000000",
"$ c #FCE883",
" ",
" ........ ",
" .++++++++. ",
" .+++++++++.................. ",
" .+++++++++++++++++++++++++++. ",
" .+++++++++++++++++++++++++++. ",
" .++..+......++@@@@@@@@@@@@@@@@@",
" .++..++++++++#################@",
" .+++++++++++#$$$$$$$$$$$$$$$$$#",
" .++..+.....+#$$$$$$$$$$$$$$$$$#",
" .++..+++++++#$$$$$$$$$$$$$$$$$#",
" .+++++++++++#$$#############$$#",
" .++..+.....+#$$$$$$$$$$$$$$$$$#",
" .++..+++++++#$$########$$$$$$$#",
" .+++++++++++#$$$$$$$$$$$$$$$$$#",
" .++..+.....+#$$$$$$$$$$$$$$$$$#",
" .++..++++++++#######$$$####### ",
" .++++++++++++++++++#$$#++++++ ",
" .++..+............+#$#++++++. ",
" .++..++++++++++++++##+++++++. ",
" .++++++++++++++++++#++++++++. ",
" .++..+............++++++++++. ",
" .++..+++++++++++++++++++++++. ",
" .+++++++++++++++++++++++++++. ",
" .++..+................++++++. ",
" .++..+++++++++++++++++++++++. ",
" .+++++++++++++++++++++++++++. ",
" .++..+................++++++. ",
" .++..+++++++++++++++++++++++. ",
" .+++++++++++++++++++++++++++. ",
" ........................... ",
" "]
# sortable TableWidgetItem, based on idea by Aledsandar
# http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
# NOTE: the QTableWidgetItem has setData() and data() which may allow data bonding
# in Qt5, data() binding is more awkward, so do it here
class SortableTableWidgetItem(QTableWidgetItem):
def __init__(self, text, sortValue, file_object):
QTableWidgetItem.__init__(self, text, QTableWidgetItem.UserType)
self.sortValue = sortValue
self.file_object = file_object
def __lt__(self, other):
return self.sortValue < other.sortValue
class DirNotes(QMainWindow):
''' the main window of the app
'''
def __init__(self, argFilename, db, start_mode, parent=None):
super(DirNotes,self).__init__(parent)
self.db = db
self.refilling = False
self.parent = parent
win = QWidget()
self.setCentralWidget(win)
lb = QTableWidget()
self.lb = lb
lb.setColumnCount(4)
lb.horizontalHeader().setSectionResizeMode( 3, QHeaderView.Stretch );
lb.verticalHeader().setDefaultSectionSize(20); # thinner rows
lb.verticalHeader().setVisible(False)
longPathName = os.path.abspath(argFilename)
print_v("longpathname is {}".format(longPathName))
if os.path.isdir(longPathName):
self.curPath = longPathName
filename = ''
else:
self.curPath, filename = os.path.split(longPathName)
print_v("working on <"+self.curPath+"> and <"+filename+">")
layout = QVBoxLayout()
copyIcon = QIcon.fromTheme('drive-harddisk-symbolic')
changeIcon = QIcon.fromTheme('emblem-synchronizing-symbolic')
topLayout = QHBoxLayout()
self.modeShow = QLabel(win)
topLayout.addWidget(self.modeShow)
bmode = QPushButton(changeIcon, "change mode",win)
topLayout.addWidget(bmode)
cf = QPushButton(copyIcon, "copy file",win)
topLayout.addWidget(cf)
layout.addLayout(topLayout)
layout.addWidget(lb)
win.setLayout(layout)
lb.itemChanged.connect(self.change)
lb.cellDoubleClicked.connect(self.double)
bmode.clicked.connect(self.switchMode)
cf.clicked.connect(self.copyFile)
lb.setHorizontalHeaderItem(0,QTableWidgetItem("File"))
lb.setHorizontalHeaderItem(1,QTableWidgetItem("Date/Time"))
lb.setHorizontalHeaderItem(2,QTableWidgetItem("Size"))
lb.setHorizontalHeaderItem(3,QTableWidgetItem("Comment"))
lb.setSortingEnabled(True)
self.refill()
lb.resizeColumnsToContents()
if len(filename)>0:
for i in range(lb.rowCount()):
if filename == lb.item(i,0).data(32).getDisplayName():
lb.setCurrentCell(i,3)
break
mb = self.menuBar()
mf = mb.addMenu('&File')
mf.addAction("Sort by name", self.sbn, "Ctrl+N")
mf.addAction("Sort by date", self.sbd, "Ctrl+D")
mf.addAction("Sort by size", self.sbs, "Ctrl+Z")
mf.addAction("Sort by comment", self.sbc, "Ctrl+.")
mf.addAction("Restore comment from database", self.restore_from_database, "Ctrl+R")
mf.addSeparator()
mf.addAction("Quit", self.close, QKeySequence.Quit)
mf.addAction("About", self.about, QKeySequence.HelpContents)
self.setWindowTitle("DirNotes Alt-F for menu Dir: "+self.curPath)
self.setMinimumSize(600,700)
self.setWindowIcon(QIcon(QPixmap(icon)))
lb.setFocus()
def closeEvent(self,e):
print("closing")
def sbd(self):
print("sort by date")
self.lb.sortItems(1,Qt.DescendingOrder)
def sbs(self):
print("sort by size")
self.lb.sortItems(2)
def sbn(self):
print("sort by name")
self.lb.sortItems(0)
def about(self):
HelpWidget(self)
def sbc(self):
print("sort by comment")
self.lb.sortItems(3,Qt.DescendingOrder)
def newDir(self):
print("change dir to "+self.dirLeft.currentPath())
def double(self,row,col):
print_v("double click {} {}".format(row, col))
fo = self.lb.item(row,0).file_object
if col==0 and fo.isDir():
print_v("double click on {}".format(fo.getName()))
self.curPath = fo.getName()
self.refill()
def copyFile(self):
# get current selection
r, c = self.lb.currentRow(), self.lb.currentColumn()
fo = self.lb.item(r,c).file_object
if not fo.isDir() and not fo.isLink(): # TODO: add check for socket
print_v(f"copy file {fo.getName()}")
# open the dir.picker
r = QFileDialog.getExistingDirectory(self.parent, "Select destination for FileCopy")
print_v(f"copy to {r}")
if r:
dest = os.path.join(r,fo.getDisplayName())
try:
shutil.copy2(fo.getName(), dest) # copy2 preserves the xattr
f = FileObj(dest) # can't make the FileObj until it exists
f.setDbComment(self.db,fo.getDbComment())
except:
errorBox(f"file copy to <{dest}> failed; check permissions")
pass
def refill(self):
self.refilling = True
self.lb.sortingEnabled = False
(self.modeShow.setText("View and edit file comments stored in extended attributes\n(xattr: user.xdg.comment)")
if mode=="xattr" else
self.modeShow.setText("View and edit file comments stored in the database \n(~/.dirnotes.db)"))
self.lb.clearContents()
small_font = QFont("",8)
dirIcon = QIcon.fromTheme('folder')
fileIcon = QIcon.fromTheme('text-x-generic')
linkIcon = QIcon.fromTheme('emblem-symbolic-link')
current, dirs, files = next(os.walk(self.curPath,followlinks=True))
dirs.sort()
files.sort()
if current != '/':
dirs.insert(0,"..")
d = dirs + files
self.lb.setRowCount(len(d))
#~ self.files = {}
self.files = []
# this is a list of all the file
#~ print("insert {} items into cleared table {}".format(len(d),current))
for i,name in enumerate(d):
this_file = FileObj(os.path.join(current,name))
this_file.loadDbComment(self.db)
print_v("FileObj created as {} and the db-comment is <{}>".format(this_file.displayName, this_file.dbComment))
#~ print("insert order check: {} {} {} {}".format(d[i],i,this_file.getName(),this_file.getDate()))
#~ self.files.update({this_file.getName(),this_file})
self.files = self.files + [this_file]
display_name = this_file.getDisplayName()
if this_file.getSize() == FileObj.FILE_IS_DIR:
item = SortableTableWidgetItem(display_name,' '+display_name, this_file) # directories sort first
else:
item = SortableTableWidgetItem(display_name,display_name, this_file)
item.setData(32,this_file) # keep a hidden copy of the file object
item.setToolTip(this_file.getName())
self.lb.setItem(i,0,item)
#lb.itemAt(i,0).setFlags(Qt.ItemIsEnabled) #NoItemFlags)
# get the comment from database & xattrs, either can fail
comment = this_file.getComment()
other_comment = this_file.getOtherComment()
ci = SortableTableWidgetItem(comment,'',this_file)
ci.setToolTip(f"comment: {comment}\ncomment date: {this_file.getDbDate()}\nauthor: {this_file.getDbAuthor()}")
if other_comment != comment:
ci.setBackground(QBrush(QColor(255,255,160)))
print_v("got differing comments <{}> and <{}>".format(comment, other_comment))
self.lb.setItem(i,3,ci)
dt = this_file.getDate()
da = SortableTableWidgetItem(dnDataBase.getShortDate(dt),dt,this_file)
da.setToolTip(time.strftime(DATE_FORMAT,time.localtime(dt)))
self.lb.setItem(i,1,da)
si = this_file.getSize()
if this_file.isDir():
sa = SortableTableWidgetItem('',0,this_file)
item.setIcon(dirIcon)
elif this_file.isLink():
sa = SortableTableWidgetItem('symlink',-1,this_file)
item.setIcon(linkIcon)
dst = os.path.realpath(this_file.getName())
sa.setToolTip(f"symlink: {dst}")
else:
sa = SortableTableWidgetItem(str(si),si,this_file)
item.setIcon(fileIcon)
sa.setTextAlignment(Qt.AlignRight)
self.lb.setItem(i,2,sa)
self.refilling = False
self.lb.sortingEnabled = True
self.lb.resizeColumnToContents(1)
def change(self,x):
if self.refilling:
return
print_v("debugging " + x.text() + " r:" + str(x.row()) + " c:" + str(x.column()))
print_v(" selected file: "+self.lb.item(x.row(),0).file_object.getName())
the_file = self.lb.item(x.row(),0).file_object
print_v(" and the row file is "+the_file.getName())
the_file.setDbComment(self.db,str(x.text()))
r = the_file.setXattrComment(str(x.text()))
if r:
the_file.setDbComment(self.db,x.text())
def switchMode(self):
global mode
mode = "xattr" if mode == "db" else "db"
self.refill()
# TODO: this may not be needed
def restore_from_database(self):
print("restore from database")
# retrieve the full path name
fileName = str(self.lb.item(self.lb.currentRow(),0).file_object.getName())
print("using filename: "+fileName)
existing_comment = str(self.lb.item(self.lb.currentRow(),3).text())
print("restore....existing="+existing_comment+"=")
if len(existing_comment) > 0:
m = QMessageBox()
m.setText("This file already has a comment. Overwrite?")
m.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel);
if m.exec_() != QMessageBox.Ok:
return
fo_row = self.db.getData(fileName)
if fo_row and len(fo_row)>1:
comment = fo_row[3]
print(fileName,fo_row[0],comment)
the_file = dn.files[self.lb.currentRow()]
the_file.setComment(comment)
self.lb.setItem(self.lb.currentRow(),3,QTableWidgetItem(comment))
def parse():
parser = argparse.ArgumentParser(description='dirnotes application')
parser.add_argument('dirname',metavar='dirname',type=str,
help='directory or file [default=current dir]',default=".",nargs='?')
#parser.add_argument('dirname2',help='comparison directory, shows two-dirs side-by-side',nargs='?')
parser.add_argument('-V','--version',action='version',version='%(prog)s '+VERSION)
parser.add_argument('-v','--verbose',action='count',help="verbose, almost debugging")
group = parser.add_mutually_exclusive_group()
group.add_argument( '-s','--sort-by-size',action='store_true')
group.add_argument( '-m','--sort-by-date',action='store_true')
parser.add_argument('-c','--config', dest='config_file',help="config file (json format; default ~/.dirnotes.json)")
parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
parser.add_argument('-d','--db', action='store_true',help="start up in database mode (default)")
return parser.parse_args()
if __name__=="__main__":
# TODO: delete this after debugging
#from PyQt5.QtCore import pyqtRemoveInputHook
#pyqtRemoveInputHook()
p = parse()
if len(p.dirname)>1 and p.dirname[-1]=='/':
p.dirname = p.dirname[:-1]
if os.path.isdir(p.dirname):
p.dirname = p.dirname + '/'
print_v(f"using {p.dirname}")
verbose = p.verbose
config_file = p.config_file if p.config_file else DEFAULT_CONFIG_FILE
config_file = os.path.expanduser(config_file)
config = DEFAULT_CONFIG
try:
with open(config_file,"r") as f:
config = json.load(f)
except json.JSONDecodeError:
print(f"problem reading config file {config_file}; check the .json syntax")
except FileNotFoundError:
print(f"config file {config_file} not found, using the default settings and writing a default")
try:
with open(config_file,"w") as f:
json.dump(config,f,indent=4)
except:
print(f"problem creating the file {config_file}")
print_v(f"here is the .json {repr(config)}")
dbName = os.path.expanduser(config["database"])
db = dnDataBase(dbName).db
xattr_comment = config["xattr_tag"]
xattr_author = xattr_comment + ".author"
xattr_date = xattr_comment + ".date"
mode = "xattr" if p.xattr else "db"
a = QApplication([])
mainWindow = DirNotes(p.dirname,db,config["start_mode"])
mainWindow.show()
if p.sort_by_date:
mainWindow.sbd()
if p.sort_by_size:
mainWindow.sbs()
a.exec_()
#xattr.setxattr(filename,COMMENT_KEY,commentText)
''' files from directories
use os.isfile()
os.isdir()
current, dirs, files = os.walk("path").next()
possible set folllowLinks=True'''
''' notes from the wdrm project
table showed
filename, size, date size, date, desc
at start, fills the list of all the files
skip the . entry
'''
''' should we also do user.xdg.tags="TagA,TagB" ?
user.charset
user.creator=application_name or user.xdg.creator
user.xdg.origin.url
user.xdg.language=[RFC3066/ISO639]
user.xdg.publisher
'''
''' TODO: add cut-copy-paste for comments '''
''' TODO: also need a way to display-&-restore comments from the database '''
''' TODO: implement startup -s and -m for size and date '''
''' TODO: add an icon for the app '''
''' TODO: create 'show comment history' popup '''
''' TODO: add dual-pane for file-move, file-copy '''
''' commandline xattr
getfattr -h (don't follow symlink) -d (dump all properties)
'''
''' if the args line contains a file, jump to it '''