Browse Source

lots of cleanup in dirnotes-gui; eliminate cursor; only open the database once; use the QFileDialog as a dir-picker; implement fileCopy; add a dir-picker to dirnotes-tui; add a desktop icon and .destop file

Pat Beirne 1 year ago
parent
commit
b63ec7ebc5
6 changed files with 163 additions and 133 deletions
  1. 4 1
      README.md
  2. 48 102
      dirnotes
  3. 1 0
      dirnotes-cli
  4. 59 30
      dirnotes-tui
  5. 9 0
      dirnotes.desktop
  6. 42 0
      dirnotes.xpm

+ 4 - 1
README.md

@@ -54,7 +54,7 @@ By default, the file **~/.dirnotes.conf** will be used to load the user's config
 This is a JSON file, with three attributes that are important:
 
 > * xattr_tag (default: <code>usr.xdg.comment</code>)
-> * database (default: <code>~/.dirnotes.db</code>) 
+> * database (default: <code>~/.dirnotes.db</code>, sensible alt: <code>/var/lib/dirnotes.db</code>) 
 > * start_mode (_xattr_ or _db_ priority)
 
 The _config_file_ should be auto-generated the first time one of the **dirnotes** apps is run.
@@ -87,6 +87,8 @@ If you want to copy files to a remote machine and include the _xattr_ comments,
 
 Some editing apps (like _vim_) will create a new file when saving the data, which orphans the _xattr_ comments. For these apps, use the _database_ system.  
 
+Removable disk devices (usb sticks) which are formatted with a Linux-based filesystem (ext2/3/4, btrfs, xfs, zfs) will carry the _xattr_ comments embedded in the filesystem metadata, and are portable to anther computer.
+
 ## database
 
 Comments stored in the database work for all filesystem types (including vfat/exfat/sshfs)
@@ -100,6 +102,7 @@ mounted in a consistent way, so that the complete path name is reproducable.
 Comments stored in the database do not travel with the files when
 they are moved or copied, unless using the **dirnotes** family of tools. 
 
+The _database_ comments that are stored in <code>~/.dirnotes.db</code> are inherently associated with a single user. If the _database_ is located in <code>/var/lib/dirnotes.db</code>, it is shared by all the users in the system. The comment with the 'most recent timestamp' wins.
 
 ## PROGRAMMER NOTES
 

+ 48 - 102
dirnotes

@@ -1,4 +1,9 @@
 #!/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
@@ -18,7 +23,7 @@ nav tools are enabled, so you can double-click to go into a dir
 
 """
 
-VERSION = "0.4"
+VERSION = "0.5"
 
 helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
 <td align=right>Version: {VERSION}</td></tr></table>
@@ -52,8 +57,7 @@ media (as long as the disk format allows it).
 the comment box gets a light yellow background.
 """
 
-import sys,os,argparse,stat,getpass
-#~ from dirWidget import DirWidget
+import sys,os,argparse,stat,getpass,shutil
 from PyQt5.QtGui import *
 from PyQt5.QtWidgets import *
 from PyQt5.QtCore import Qt, pyqtSignal
@@ -108,30 +112,16 @@ class dnDataBase:
     except sqlite3.OperationalError:
       print(f"Database {dbFile} not found")
       raise
-    self.db_cursor = self.db.cursor()
     try:
-      self.db_cursor.execute("select * from dirnotes")
+      self.db.execute("select * from dirnotes")
     except sqlite3.OperationalError:
       print_v("Table %s created" % ("dirnotes"))
-      self.db_cursor.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
-    
+      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):
-    self.db_cursor.execute("select * from dirnotes where name=? and comment<>'' order by comment_date desc",(os.path.abspath(fileName),))
-    return self.db_cursor.fetchone()
-  def setData(self, fileName, comment):
-    s = os.lstat(fileName)
-    print_v ("params: %s %s %d %s %s" % ((os.path.abspath(fileName), 
-        dnDataBase.epochToDb(s.st_mtime), s.st_size, comment, 
-        dnDataBase.epochToDb(time.time()))))
-    try:
-      self.db_cursor.execute("insert into dirnotes values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
-        (os.path.abspath(fileName), s.st_mtime, s.st_size, str(comment), time.time(), getpass.getuser()))
-      self.db.commit()
-    except sqlite3.OperationalError:
-      print("database is locked or unwriteable")
-      errorBox("database is locked or unwriteable")
-      #TODO: put up a message box for locked database
-
+    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):
@@ -157,13 +147,14 @@ class FileObj():
   FILE_IS_LINK   = -2
   FILE_IS_SOCKET = -3
   def __init__(self, fileName):
-    self.fileName = os.path.abspath(fileName)
-    self.displayName = os.path.split(fileName)[1]
+    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
-      self.displayName += '/'
+      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):
@@ -187,13 +178,13 @@ class FileObj():
 
   def getName(self):
     return self.fileName
-  def getFileName(self):
+  def getDisplayName(self):
     return self.displayName
 
   # with an already open database cursor
-  def loadDbComment(self,cursor):
-    cursor.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
-    a = cursor.fetchone()
+  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
@@ -204,19 +195,19 @@ class FileObj():
     return self.dbAuthor
   def getDbDate(self):
     return self.dbDate
-  def setDbComment(self,newComment):
-    try:
-      self.db = sqlite3.connect(dbName)
-    except sqlite3.OperationalError:
-      print_v(f"database {dbName} not found")
-      raise
-    c = self.db.cursor()
+  def setDbComment(self,db,newComment):
     s = os.lstat(self.fileName)
     try:
-      c.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
-          (os.path.abspath(self.fileName), s.st_mtime, s.st_size,
+      # 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()))
-      self.db.commit()
+      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:
@@ -341,47 +332,6 @@ icon = ["32 32 6 1",  # the QPixmap constructor allows for str[]
 "  ...........................   ",
 "                                "]
 
-''' a widget that shows only a dir listing
- '''
-
-class DirWidget(QListWidget):
-  ''' a simple widget that shows a list of directories, staring
-  at the directory passed into the constructor
-
-  a mouse click or 'enter' key will send a 'selected' signal to
-  anyone who connects to this.
-
-  the .. parent directory is shown for all dirs except /
-
-  the most interesting parts of the interface are:
-  constructor - send in the directory to view
-  method - currentPath() returns text of current path
-  signal - selected calls a slot with a single arg: new path
-  '''
-
-  selected = pyqtSignal(str)
-  def __init__(self, directory='.', parent=None):
-    super(DirWidget,self).__init__(parent)
-    self.directory = directory
-    self.refill()
-    self.itemActivated.connect(self.selectionByLWI)
-    # it would be nice to pick up single-mouse-click for selection as well
-    # but that seems to be a system-preferences global
-  def selectionByLWI(self, li):
-    self.directory = os.path.abspath(self.directory + '/' + str(li.text()))
-    self.refill()
-    self.selected.emit(self.directory)
-  def refill(self):
-    current,dirs,files = next(os.walk(self.directory,followlinks=True))
-    dirs.sort()
-    if '/' not in dirs:
-      dirs = ['..'] + dirs
-    self.clear()
-    for d in dirs:
-      li = QListWidgetItem(d,self)
-  def currentPath(self):
-    return self.directory
-    
 
 # sortable TableWidgetItem, based on idea by Aledsandar
 # http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
@@ -457,7 +407,7 @@ class DirNotes(QMainWindow):
     
     if len(filename)>0:
       for i in range(lb.rowCount()):
-        if filename == lb.item(i,0).data(32).getFileName():
+        if filename == lb.item(i,0).data(32).getDisplayName():
           lb.setCurrentCell(i,3)
           break
     
@@ -509,19 +459,16 @@ class DirNotes(QMainWindow):
     if not fo.isDir() and not fo.isLink():  # TODO: add check for socket 
       print_v(f"copy file {fo.getName()}")
       # open the dir.picker
-      # TODO: move all this out to a function
-      qd = QDialog(self.parent)
-      qd.setWindowTitle("Select destination for FileCopy")
-      qd.setWindowModality(Qt.ApplicationModal)
-      dw = DirWidget('.',qd)
-      d_ok = QPushButton('select')
-      d_ok.setDefault(True)
-      d_ok.clicked.connect(QDialog.accept)
-      d_nope = QPushButton('cancel')
-      d_nope.clicked.connect(QDialog.reject)
-      # if returns from <enter>, copy the file and comments
-      r = qd.exec() 
+      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):
@@ -550,13 +497,13 @@ class DirNotes(QMainWindow):
     # 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(current+'/'+name)
-      this_file.loadDbComment(self.db.db_cursor)
+      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.getFileName()
+      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:
@@ -569,7 +516,7 @@ class DirNotes(QMainWindow):
       # get the comment from database & xattrs, either can fail
       comment = this_file.getComment()
       other_comment = this_file.getOtherComment()
-      ci = QTableWidgetItem(comment)
+      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)))
@@ -607,11 +554,10 @@ 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(str(x.text()))
+    the_file.setDbComment(self.db,str(x.text()))
     r = the_file.setXattrComment(str(x.text())) 
-    # TODO: change this to FileObj.setDbComment()
     if r:
-      self.db.setData(the_file.getName(),x.text())
+      the_file.setDbComment(self.db,x.text())
 
   def switchMode(self):
     global mode
@@ -686,7 +632,7 @@ if __name__=="__main__":
   
   print_v(f"here is the .json {repr(config)}")
   dbName = os.path.expanduser(config["database"])
-  db = dnDataBase(dbName)
+  db = dnDataBase(dbName).db
   xattr_comment = config["xattr_tag"]
   xattr_author  = xattr_comment + ".author"
   xattr_date    = xattr_comment + ".date"

+ 1 - 0
dirnotes-cli

@@ -1,4 +1,5 @@
 #!/usr/bin/python3
+# TODO starting with a dir shoild list all-files
 # TODO: get rid of the -n option; multiple files are prefixed by 'filename:', single files aren't
 
 VERSION = "0.3"

+ 59 - 30
dirnotes-tui

@@ -186,7 +186,7 @@ class Pane:
       self.setStatus("The xattr and database comments differ where shown in green")
     #  time.sleep(2)
 
-    self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
+    self.file_pad.refresh(self.first_visible,0, 2,1, self.h-3,self.w-2)
  
   def refill(self):
     self.win.bkgdset(' ',curses.color_pair(CP_BODY))
@@ -498,61 +498,90 @@ class FileObj():
 
 ##########  dest folder picker ###############
 # returns None if the user hits <esc>
+#     the dir_pad contents are indexed from 0,0, matching self.fs
 class showFolderPicker:
   def __init__(self,starting_dir,title):
+    self.selected = None
+    self.title = title
+    self.starting_dir = self.cwd = os.path.abspath(starting_dir)
+
+    # draw the perimeter...it doesn't change  
     self.W = curses.newwin(20,60,5,5)
     self.W.bkgd(' ',COLOR_HELP)
+    self.h, self.w = self.W.getmaxyx()
     self.W.keypad(True)
-    self.title = title
-    self.starting_dir = starting_dir
-    self.cwd = starting_dir
+    #self.W.clear()
+    self.W.border()
+    self.W.addnstr(0,1,self.title,self.w-2)
+    self.W.addstr(self.h-1,1,"<Enter> to select or change dir, <esc> to exit")
+    self.W.refresh()
+
     self.fill()
-    self.selected = None
 
-    indialog = True
+    inDialog = True
     selected = ''
-    while indialog:
+    while inDialog:
       c = self.W.getch()
-      y,x = self.W.getyx()
+      y,x = self.dir_pad.getyx()
       if c == curses.KEY_UP:
-        if y>1: self.W.move(y-1,1)
+        if y==0:
+          continue 
+        y -= 1
+        self.dir_pad.move(y,0)
+        if y < self.first_visible:
+          self.first_visible = y 
+        self.refresh()
       elif c == curses.KEY_DOWN:
-        if y<len(self.fs)+1: self.W.move(y+1,1)
+        if y == len(self.fs)-1:
+          continue
+        y += 1
+        self.dir_pad.move(y,0)
+        if y-self.first_visible > self.h-3:
+          self.first_visible += 1
+        self.refresh()
       elif c == CMD_CD:
         # cd to new dir and refill
-        if y==1 and self.fs[0].startswith('<'):    # current dir
+        if y==0 and self.fs[0].startswith('<use'):    # current dir
           self.selected = self.cwd
-          indialog = False
+          inDialog = False
         else:
-          self.cwd = self.cwd + '/' + self.fs[y-1]
-          self.cwd = os.path.realpath(self.cwd)
+          self.cwd = os.path.abspath(self.cwd + '/' + self.fs[y])
           #logging.info(f"change dir to {self.cwd}")
-          self.fill()
+          self.fill()   # throw away the old self.dir_pad
       elif c == CMD_ESC:
-        indialog = False
+        inDialog = False
     del self.W
 
   def value(self):
-    #logging.info(f"dir picker returns {self.selected}")
+    logging.info(f"dir picker returns {self.selected}")
     return self.selected
 
+  def refresh(self):
+    y,x = self.W.getbegyx()
+    self.dir_pad.refresh(self.first_visible,0, x+1,y+1, x+self.h-2,y+self.w-2)
+
   def fill(self):
-    h, w = self.W.getmaxyx()
-    self.W.clear()
-    self.W.border()
-    self.W.addnstr(0,1,self.title,w-2)
-    self.W.addstr(h-1,1,"<Enter> to select or change dir, <esc> to exit")
-    self.fs = os.listdir(self.cwd)
-    self.fs = [a for a in self.fs if os.path.isdir(a)]
+    # change to os.path.walk() and just use the directories
+    # self.fs is the list of candidates, prefixed by "use this" and ".."
+    d, self.fs, _ = next(os.walk(self.cwd))
     self.fs.sort()
     if self.cwd != '/':
       self.fs.insert(0,"..")
     if self.cwd != self.starting_dir:
       self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
-    for i,f in enumerate(self.fs):
-      self.W.addnstr(i+1,1,f,w-2)
-    self.W.move(1,1)
+    
+    # create a pad big enough to hold all the entries
+    self.pad_height = max(self.h-2,len(self.fs))
+    self.dir_pad = curses.newpad(self.pad_height, self.w - 2)
+    self.dir_pad.bkgdset(' ',curses.color_pair(CP_BODY))
+    self.dir_pad.clear()
+    self.first_visible = 0
 
+    # and fill it with strings
+    for i,f in enumerate(self.fs):
+      self.dir_pad.addnstr(i,0,f,self.w-2)
+    self.dir_pad.move(0,0)
+    self.refresh()
 
 ########### comment management code #################
 
@@ -867,7 +896,7 @@ def main(w, cwd):
         # and copy the database record
         f = FileObj(dest)
         f.setDbComment(files[mywin.cursor].getDbComment())
-      mywin.refresh()
+      mywin.refresh() 
 
     elif c == CMD_MOVE:
       if files[mywin.cursor].displayName == "..":
@@ -885,9 +914,9 @@ def main(w, cwd):
         shutil.move(src, dest_dir)
         # and copy the database record
         f = FileObj(dest)
-        f.setDbComment(files[mywin.cursor].getDbComment())
+        f.setDbComment(files[mywin.cursor].getDbComment())  
         files = Files(cwd)
-        mywin = Pane(w,cwd,files)
+        mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
 
     elif c == curses.KEY_RESIZE:
       mywin.resize()

+ 9 - 0
dirnotes.desktop

@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Encoding=UTF-8
+Name=Dirnotes
+Comment=add comments to file
+Icon=/home/patb/.local/share/icons/dirnotes.xpm
+Exec=/home/patb/.local/bin/dirnotes
+Terminal=false
+Categories=Tags;Describing;Application

+ 42 - 0
dirnotes.xpm

@@ -0,0 +1,42 @@
+/* XPM */
+static const char *const dirnotes[] = {
+"32 32 6 1",  
+"   c None",
+".  c #666666",
+"+  c #FFFFFF",
+"@  c #848484",
+"#  c #000000",
+"$  c #FCE883",
+"                                ",
+"  ........                      ",
+" .++++++++.                     ",
+" .+++++++++..................   ",
+" .+++++++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+......++@@@@@@@@@@@@@@@@@",
+" .++..++++++++#################@",
+" .+++++++++++#$$$$$$$$$$$$$$$$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..+++++++#$$$$$$$$$$$$$$$$$#",
+" .+++++++++++#$$#############$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..+++++++#$$########$$$$$$$#",
+" .+++++++++++#$$$$$$$$$$$$$$$$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..++++++++#######$$$####### ",
+" .++++++++++++++++++#$$#++++++  ",
+" .++..+............+#$#++++++.  ",
+" .++..++++++++++++++##+++++++.  ",
+" .++++++++++++++++++#++++++++.  ",
+" .++..+............++++++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+................++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+................++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+"  ...........................   ",
+"                                "
+};