#!/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: 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 '''