Browse Source

Major changes to the gui app
-refactor FileObj
-change lstat mode tests to functions .isDir() .isLink()
-rewrite helpscreen for gui, add keyboard help screen
-add info bar to the GUI, so we can see the comment on THIS dir
-highlight "different" comments, even if they're empty
-give icons to files, directories, links, sockets and the two pushbuttons

Pat Beirne 1 year ago
parent
commit
97f5d57164
2 changed files with 292 additions and 218 deletions
  1. 290 218
      dirnotes
  2. 2 0
      dirnotes-tui

+ 290 - 218
dirnotes

@@ -1,10 +1,8 @@
 #!/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
+
+# TODO: XDG compatibility: config in ~/.config/dirnotes/dirnotes.conf and data in ~/.local/share/dirnotes/dirnotes.db
+# TODO: clearing a comment out to '' doesn't seem to work on both sides
+
 """ a simple gui or command line app
 to view and create/edit file comments
 
@@ -23,7 +21,7 @@ nav tools are enabled, so you can double-click to go into a dir
 
 """
 
-VERSION = "0.5"
+VERSION = "0.7"
 
 helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
 <td align=right>Version: {VERSION}</td></tr></table>
@@ -40,8 +38,8 @@ field of the file system.
 <p> Double click on directory names to navigate the file system. Hover over
 fields for more information.
 
-<h3>xattr extended attributes</h3>
-The xattr comment suffers from a few problems:
+<h4>xattr extended attributes</h4>
+The <i>xattr</i> comment suffers from a few problems:
 <ul>
   <li>is not implemented on FAT/VFAT/EXFAT file systems (some USB sticks)
   <li>xattrs are not (by default) copied with the file when it's duplicated 
@@ -53,17 +51,22 @@ or backedup (<i>mv, rsync</i> and <i>tar</i> work, <i>ssh</i> and <i>scp</i> don
 On the other hand, <i>xattr</i> comments can be bound directly to files on removable
 media (as long as the disk format allows it).
 
+<h4>database comments</h4>
+<p><i>Database</i> comments can be applied to any file, even read-only files and executables.
+
 <p>When the <i>database</i> version of a comment differs from the <i>xattr</i> version, 
 the comment box gets a light yellow background.
+
+<p>To edit a comment, first select it; to replace the comment, just type over it; to edit the comment,
+double-click the mouse, or hit &lt;Enter&gt;.
 """
 
-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
+import sys,os,time,argparse,stat,getpass,shutil,logging
+from   PyQt5.QtGui     import *
+from   PyQt5.QtWidgets import *
+from   PyQt5.QtCore    import Qt, pyqtSignal
+import sqlite3, json
 
-VERSION = "0.4"
 xattr_comment = "user.xdg.comment"
 xattr_author  = "user.xdg.comment.author"
 xattr_date    = "user.xdg.comment.date"
@@ -72,8 +75,8 @@ YEAR          = 3600*25*365
 
 ## globals
 mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"} 
-modes = ("db","xattr") 
-mode = "db" 
+modes      = ("db","xattr") 
+mode       = "db" 
 
 global mainWindow, dbName
 
@@ -94,6 +97,7 @@ def print_v(*a):
   if verbose:
     print(*a)
 
+############# the dnDataBase and FileObj code is shared with other dirnotes programs
 class dnDataBase:
   ''' the database is flat
     fileName: fully qualified name
@@ -103,25 +107,35 @@ class dnDataBase:
     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
+    this object: 1) finds or creates the database
+      2) determine if it's readonly
+
+    TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/) 
+    TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
+      make it 0666 permissions (rw-rw-rw-)
   '''
   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")
+      logging.error(f"Database {dbFile} not found")
       raise
+ 
+    # create new database if it doesn't exist
     try:
       self.db.execute("select * from dirnotes")
     except sqlite3.OperationalError:
-      print_v("Table %s created" % ("dirnotes"))
+      print_v(f"Table dirnotes created")
       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()
+      self.db.execute("create index dirnotes_i on dirnotes(name)") 
+      # at this point, if a shared database is required, somebody needs to set perms to 0o666
+  
+    self.writable = True
+    try:
+      self.db.execute("pragma user_verson=0")
+    except sqlite3.OperationalError:
+      self.writable = False
 
   @staticmethod
   def epochToDb(epoch):
@@ -136,145 +150,162 @@ class dnDataBase:
     if diff > YEAR:
       fmt = "%b %e %Y"
     else:
-      fmt = "%b %d %H:%M"
+      fmt = "%b %e %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
+  """  The FileObj knows about both kinds of comments. """
+  def __init__(self, fileName, db):
+    self.fileName = os.path.abspath(fileName)     # full path; dirs end WITHOUT a terminal /
+    self.stat = os.lstat(self.fileName)
+    self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
+    if self.isDir():
       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
+    self.date = self.stat.st_mtime
+    self.size = self.stat.st_size
+    self.db = db
 
   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
-
+  # new methods; no caching
+  def getDbData(self):
+    if not hasattr(self,'dbCommentAuthorDate'):
+      cad = self.db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(self.fileName,)).fetchone()
+      self.dbCommentAuthorDate = cad if cad else (None, None, None)
+    return self.dbCommentAuthorDate
   def getDbComment(self):
-    return self.dbComment
-  def getDbAuthor(self):
-    return self.dbAuthor
-  def getDbDate(self):
-    return self.dbDate
-  def setDbComment(self,db,newComment):
+    return self.getDbData()[0]
+
+  def getXattrData(self):
+    if not hasattr(self,'xattrCommentAuthorDate'):
+      c = a = d = None
+      try:
+        c = os.getxattr(self.fileName, xattr_comment, follow_symlinks=False).decode()
+        a = os.getxattr(self.fileName, xattr_author, follow_symlinks=False).decode()
+        d = os.getxattr(self.fileName, xattr_date, follow_symlinks=False).decode()
+      except:  # no xattr comment
+        pass
+      self.xattrCommentAuthorDate = c,a,d
+    return self.xattrCommentAuthorDate
+  def getXattrComment(self):
+    return self.getXattrData()[0]
+
+  def setDbComment(self,newComment):
+    # how are we going to hook this?
+    #if not self.db.writable:
+    #  errorBox("The database is readonly; you cannot add or edit comments")
+    #  return
     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'),?)",
+      print_v(f"setDbComment db {self.db}, file: {self.fileName}")
+      self.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
+      self.db.commit()
+      self.dbCommentAuthorDate = newComment, getpass.getuser(), dnDataBase.epochToDb(time.time())
     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
+      self.xattrCommentAuthorDate = newComment, getpass.getuser(), time.strftime(DATE_FORMAT) 
       return True
     # we need to move these cases out to a handler 
     except Exception as e:
-      if self.size == FileObj.FILE_IS_LINK:
+      if self.isLink():
         errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
-      elif self.size == FileObj.FILE_IS_SOCKET:
+      elif self.isSock():
         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()
+    return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
+  def getData(self):
+    return self.getDbData()    if mode == "db"    else self.getXattrData()
+  def getOtherData(self):
+    return self.getDbData()    if mode == "xattr" else self.getXattrData()
+
   def getDate(self):
     return self.date
   def getSize(self):
     return self.size
   def isDir(self):
-    return self.size == self.FILE_IS_DIR
+    return stat.S_ISDIR(self.stat.st_mode)
   def isLink(self):
-    return self.size == self.FILE_IS_LINK
+    return stat.S_ISLNK(self.stat.st_mode)
+  def isSock(self):
+    return stat.S_ISSOCK(self.stat.st_mode)
+
+  def copyFile(self, destDir):
+    # NOTE: this method copies the xattr (comment + old author + old date) 
+    #       but creates new db (comment + this author + new date)
+    if stat.S_ISREG(self.stat.st_mode):
+      dest = os.path.join(destDir,self.displayName)
+      try:
+        print("try copy from",self.fileName,"to",dest)
+        shutil.copy2(self.fileName, dest)
+      except:
+        errorBox(f"file copy to <{dest}> failed; check permissions")
+      f = FileObj(dest, self.db)
+      print("dest object created",repr(f))
+      f.setDbComment(self.getDbComment())
 
 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)
+    layout = QVBoxLayout(self)
+
+    tb = QLabel(self)
+    tb.setWordWrap(True)
+    tb.setText(helpMsg)
+    tb.setFixedWidth(500)
+    pb = QPushButton('OK',self)
+    pb.setFixedWidth(200)
+    kb = QPushButton('Keyboard Help',self)
+
+    layout.addWidget(tb)
+    lowerBox = QHBoxLayout(self)
+    lowerBox.addWidget(pb)
+    lowerBox.addWidget(kb)
+    layout.addLayout(lowerBox)
+
+    pb.clicked.connect(self.close)
+    kb.clicked.connect(self.showKeyboardHelp)
+    self.show()
+  def showKeyboardHelp(self):
+    KeyboardHelpWidget(self)
+
+class KeyboardHelpWidget(QDialog):
+  def __init__(self, parent):
+    super(QDialog, self).__init__(parent)
+    layout = QVBoxLayout(self)
+    tb = QLabel(self)
+    tb.setWordWrap(True)
+    tb.setText(keyboardHelpMsg)
+    tb.setFixedWidth(500)
+    pb = QPushButton('OK',self)
+    layout.addWidget(tb)
+    layout.addWidget(pb)
+    pb.clicked.connect(self.close)
     self.show()
 
 class errorBox(QDialog):
@@ -292,6 +323,30 @@ class errorBox(QDialog):
     self.pb.clicked.connect(self.close)
     self.show()
 
+keyboardHelpMsg = """
+<h2>Keyboard Shortcuts</h2>
+<p>
+<table width=100%>
+<tr><td><i>Arrows</i></td><td>normal movement through the table</td></tr>
+<tr><td>Ctrl+N</td><td>sort the listing by filename</td></tr>
+<tr><td>Ctrl+D</td><td>sort the listing by date</td></tr>
+<tr><td>Ctrl+S</td><td>sort the listing by size</td></tr>
+<tr><td>Ctrl+T</td><td>sort the listing by comment</td></tr>
+<tr><td>Ctrl+M</td><td>toggle between <i>database</i> and <i>xattr</i> views</td></tr>
+<tr><td>Alt+C </td><td>copy the file <i>and its comments</i></td></tr>
+<tr><td>Alt+M </td><td>copy the file <i>and its comments</i></td></tr>
+
+<tr><td>1st column: <i>any letter</i></td><td>jump to file beginning with that letter</td></tr>
+<tr><td>1st column: &lt;Enter&gt;    </td><td>change directory</td></tr>
+<tr><td>4th column: <i>any letter</i></td><td>create a comment; replace any existing comment</td></tr>
+<tr><td>4th column: &lt;Enter&gt;    </td><td>open an existing comment for edit</td></tr>
+
+<tr><td>Ctrl+Q</td><td>quit the app</td></tr>
+</table>
+<p>
+NOTE: In edit mode, Ctrl+C, Ctrl+V and Ctrl+P work for cut, copy and paste.
+"""
+
 icon = ["32 32 6 1",  # the QPixmap constructor allows for str[]
 "   c None",
 ".  c #666666",
@@ -336,35 +391,25 @@ icon = ["32 32 6 1",  # the QPixmap constructor allows for str[]
 # 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
+#    we could use the Qt.DisplayRole/Qt.EditRole for display, and Qt.UserRole for sorting
 # 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)
+    QTableWidgetItem.__init__(self, text, QTableWidgetItem.UserType + 1)
     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):
+  def __init__(self, argFilename, db, 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):
@@ -373,21 +418,49 @@ class DirNotes(QMainWindow):
     else:
       self.curPath, filename = os.path.split(longPathName)
     print_v("working on <"+self.curPath+"> and <"+filename+">")
+
+    win = QWidget()
+    self.setCentralWidget(win)
     
-    layout = QVBoxLayout()
+    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+T")
+    mf.addSeparator()
+    mf.addAction("Change mode", self.switchMode, "Ctrl+M")
+    mf.addAction("Copy file", self.copyFile, "Alt+C")
+    mf.addAction("Move file", self.moveFile, "Alt+V")
+    mf.addSeparator()
+    mf.addAction("Quit", self.close, QKeySequence.Quit)
+    mf.addAction("About", self.about, QKeySequence.HelpContents)
 
-    copyIcon = QIcon.fromTheme('drive-harddisk-symbolic')
-    changeIcon = QIcon.fromTheme('emblem-synchronizing-symbolic')
+    self.setWindowTitle("==DirNotes==   Dir: "+self.curPath)
+    self.setMinimumSize(600,700)
+    self.setWindowIcon(QIcon(QPixmap(icon)))
 
-    topLayout = QHBoxLayout()
+    lb = QTableWidget()
+    self.lb = lb
+    lb.setColumnCount(4)
+    lb.horizontalHeader().setSectionResizeMode( 3, QHeaderView.Stretch );
+    lb.verticalHeader().setDefaultSectionSize(20);  # thinner rows
+    lb.verticalHeader().setVisible(False)
+    
     self.modeShow = QLabel(win)
+    copyIcon   = QIcon.fromTheme('edit-copy')
+    changeIcon = QIcon.fromTheme('emblem-synchronizing')
+    bmode = QPushButton(changeIcon, "change mode (ctrl+m)",win)
+    cf    = QPushButton(copyIcon, "copy file",win)
+    self.thisDirLabel = QLabel(win)
+
+    layout = QVBoxLayout()
+    topLayout = QHBoxLayout()
     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(self.thisDirLabel)
     layout.addWidget(lb)
     win.setLayout(layout)
     
@@ -405,46 +478,28 @@ class DirNotes(QMainWindow):
     self.refill()
     lb.resizeColumnsToContents()
     
-    if len(filename)>0:
+    if filename:
       for i in range(lb.rowCount()):
-        if filename == lb.item(i,0).data(32).getDisplayName():
-          lb.setCurrentCell(i,3)
+        if filename == lb.item(i,0).file_object.getDisplayName():
+          lb.setCurrentCell(i,0)
           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")
+    print_v("sort by date")
     self.lb.sortItems(1,Qt.DescendingOrder)
   def sbs(self):
-    print("sort by size")
+    print_v("sort by size")
     self.lb.sortItems(2)
   def sbn(self):
-    print("sort by name")
+    print_v("sort by name")
     self.lb.sortItems(0)
+  def sbc(self):
+    print_v("sort by comment")
+    self.lb.sortItems(3)
   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
@@ -452,38 +507,55 @@ class DirNotes(QMainWindow):
       print_v("double click on {}".format(fo.getName()))
       self.curPath = fo.getName()
       self.refill()
+  def keyPressEvent(self,e):
+    if e.key() in (Qt.Key_Return, Qt.Key_Enter): 
+      col = self.lb.currentColumn()
+      fo = self.lb.item(self.lb.currentRow(),0).file_object
+      if col==0 and fo and fo.isDir():
+        self.curPath = fo.getName()
+        self.refill()
+        return
+      if col==3:
+        self.lb.editItem(self.lb.currentItem())
+        return
+    #self.lb.superKeyEvent(e)
+    super().keyPressEvent(e)
+
   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 
+    if not fo.isDir() and not fo.isLink() and not fo.isSock(): 
       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
+        print_v(f"copy to {r}")
+        fo.copyFile(r)
+  def moveFile(self):
+    # TODO: write this
+    print_v("moveFile not yet written")
       
   def refill(self):
     self.refilling = True
     self.lb.sortingEnabled = False
+    self.directory = FileObj(self.curPath,self.db)
     
-    (self.modeShow.setText("View and edit file comments stored in extended attributes\n(xattr: user.xdg.comment)") 
+    self.thisDirLabel.setText(f'<table width=100%><tr><th><b>{self.directory.getDisplayName()}</b></th><th style"text-align:right;">{self.directory.getComment()}</th></tr></table>')
+    (self.modeShow.setText("<i>Showing comments stored in extended attributes</i><br>(xattr: user.xdg.comment)") 
       if mode=="xattr" else 
-      self.modeShow.setText("View and edit file comments stored in the database \n(~/.dirnotes.db)"))
+      self.modeShow.setText("<i>Showing comments from the database</i><br>(~/.dirnotes.db)"))
     self.lb.clearContents()
-    small_font = QFont("",8)
-    dirIcon = QIcon.fromTheme('folder')
+    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))
+    sockIcon = QIcon.fromTheme('emblem-shared')
+
+    try:
+      current, dirs, files = next(os.walk(self.curPath,followlinks=True))
+    except:
+      print(f"{self.curPath} is not a valid directory")
+      sys.exit(1)
     dirs.sort()
     files.sort()
     
@@ -492,57 +564,57 @@ class DirNotes(QMainWindow):
     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))
+      this_file = FileObj(os.path.join(current,name),self.db)
+      print_v("FileObj created as {} and the db-comment is <{}>".format(this_file.displayName, this_file.getDbComment))
       #~ 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:
+      if this_file.isDir():
         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())
+      item.setFlags(Qt.ItemIsEnabled)
       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()
+      comment, auth, cdate = this_file.getData()
       other_comment = this_file.getOtherComment()
-      ci = SortableTableWidgetItem(comment,'',this_file)
-      ci.setToolTip(f"comment: {comment}\ncomment date: {this_file.getDbDate()}\nauthor: {this_file.getDbAuthor()}")
+      ci = SortableTableWidgetItem(comment,comment or '~',this_file)
+      ci.setToolTip(f"comment: {comment}\ncomment date: {cdate}\nauthor: {auth}")
       if other_comment != comment:
         ci.setBackground(QBrush(QColor(255,255,160)))
         print_v("got differing comments <{}> and <{}>".format(comment, other_comment))
+      ci.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled)
       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)))
+      da.setFlags(Qt.ItemIsEnabled)
       self.lb.setItem(i,1,da)
 
       si = this_file.getSize()
       if this_file.isDir():
         sa = SortableTableWidgetItem('',0,this_file)
-        item.setIcon(dirIcon)
+        sa.setIcon(dirIcon)
       elif this_file.isLink():
         sa = SortableTableWidgetItem('symlink',-1,this_file)
-        item.setIcon(linkIcon)
+        sa.setIcon(linkIcon)
         dst = os.path.realpath(this_file.getName())
         sa.setToolTip(f"symlink: {dst}")
+      elif this_file.isSock():
+        sa = SortableTableWidgetItem('socket',-1,this_file)
+        sa.setIcon(sockIcon)
       else:
         sa = SortableTableWidgetItem(str(si),si,this_file)
-        item.setIcon(fileIcon)
+        sa.setIcon(fileIcon)
       sa.setTextAlignment(Qt.AlignRight)
+      sa.setFlags(Qt.ItemIsEnabled)
       self.lb.setItem(i,2,sa)
 
+    self.lb.setCurrentCell(0,0)
     self.refilling = False
     self.lb.sortingEnabled = True
     self.lb.resizeColumnToContents(1)
@@ -554,15 +626,20 @@ class DirNotes(QMainWindow):
     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())
+    the_file.setDbComment(str(x.text()))
+    the_file.setXattrComment(str(x.text())) 
+    if the_file.getComment() == the_file.getOtherComment():
+      x.setBackground(QBrush(QColor(255,255,255)))
+    else:
+      x.setBackground(QBrush(QColor(255,255,160)))
 
   def switchMode(self):
     global mode
     mode = "xattr" if mode == "db" else "db" 
+    row,column = self.lb.currentRow(), self.lb.currentColumn()
     self.refill()
+    self.lb.setCurrentCell(row,column)
+    self.lb.setFocus(True)
 
   # TODO: this may not be needed
   def restore_from_database(self):
@@ -637,10 +714,20 @@ if __name__=="__main__":
   xattr_author  = xattr_comment + ".author"
   xattr_date    = xattr_comment + ".date"
 
-  mode = "xattr" if p.xattr else "db"
+  mode = "db"
+  if "start_mode" in config and config["start_mode"] in modes:
+    mode = config["start_mode"] 
+  if p.xattr:
+    mode = "xattr" 
+  if p.db:
+    mode = "db"
 
   a = QApplication([])
-  mainWindow = DirNotes(p.dirname,db,config["start_mode"])
+  mainWindow = DirNotes(p.dirname,db)
+  if p.sort_by_size:
+    mainWindow.sbs()
+  if p.sort_by_date:
+    mainWindow.sbd()
   mainWindow.show()
   
   if p.sort_by_date:
@@ -650,21 +737,6 @@ if __name__=="__main__":
 
   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
@@ -674,19 +746,19 @@ 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 '''
+
+''' CODING NOTES:
+  in FileObj, the long absolute name always ends without a /
+    the short display name ends with a / if it's a directory
+  dates are always in YYYY-MM-DD HH:MM:SS format
+    these can be sorted
+'''

+ 2 - 0
dirnotes-tui

@@ -5,6 +5,7 @@
 # TODO: write color scheme
 # TODO: re-read date/author to xattr after an edit
 # TODO: consider adding h,j,k,l movement
+# TODO: change move command to 'v', change mode to 'm', drop copy-comments
 
 # scroll
 # up/down - change focus, at limit: move 1 line,
@@ -457,6 +458,7 @@ class FileObj():
   def getXattrAuthor(self):
     return self.xattrAuthor
   def getXattrDate(self):
+    # TODO: make sure it's a string, not an int
     logging.info(f"someone accessed date on {self.fileName} {self.xattrDate}")
     return self.xattrDate
   def setXattrComment(self,newComment):