Browse Source

All three apps updated with newest library.
Database is now at ~/.local/share/dirnotes/dirnotes.db
and the config is at ~/.config/dirnotes/dirnotes.conf
Fixed double-write in dirnotes (gui) (it was re-entering .change())

Pat Beirne 1 year ago
parent
commit
62027a2729
4 changed files with 156 additions and 128 deletions
  1. 28 12
      README.md
  2. 72 68
      dirnotes
  3. 9 8
      dirnotes-cli
  4. 47 40
      dirnotes-tui

+ 28 - 12
README.md

@@ -10,7 +10,8 @@ Table of Contents
   * [LIMITATIONS](#limitations)
   * [LIMITATIONS](#limitations)
   * [PROGRAMMER NOTES](#programmer-notes)
   * [PROGRAMMER NOTES](#programmer-notes)
     * [MacOS](#macos)
     * [MacOS](#macos)
-  * [DEVELOPMENT STATUS](#status)
+  * [DEVELOPMENT STATUS](#development-status)
+  * [QUESTIONS](#questions)
 
 
 ## SYNOPSIS
 ## SYNOPSIS
 
 
@@ -41,14 +42,20 @@ which may be handy for scripting. This all can also do maintenance on the databa
 
 
 ## USAGE
 ## USAGE
 
 
-The <code>**dirnotes**</code> program displays usage and keystoke info when you press _F1_. The <code>**dirnotes-tui**</code> program display onscreen usage when you press the 'h' key, or _F1_. 
+The <code>**dirnotes**</code> program displays usage and keystoke info 
+when you press _F1_. The <code>**dirnotes-tui**</code> program display 
+onscreen usage when you press the 'h' key, or _F1_. 
 The <code>**dirnotes-cli**</code> program has a man page.
 The <code>**dirnotes-cli**</code> program has a man page.
 
 
-In short, you navigate <code>**dirnotes**</code> and <code>**dirnotes-tui**</code> by using the up/down arrow keys, <enter> to enter into a directory. 
-The **-tui** version accepts _e_ for edit, 
-_s_ for sort, _M_ to change between xattr/database priority.
+In short, you navigate <code>**dirnotes**</code> and 
+<code>**dirnotes-tui**</code> by using the up/down arrow keys, 
+<enter> to enter into a directory. 
+The **-tui** version accepts _e_ for edit, _s_ for sort, _M_ to change 
+between xattr/database priority.
 
 
-The **<code>dirnotes-cli</code>** has options for _-l_ list and _-c_ create a comment. 
+The **<code>dirnotes-cli</code>** has options 
+for _-l_ list and _-c_ create a comment. See also <code>dirnotes-cli.1</code> 
+man page.
 
 
 All three apps in the **dirnotes** family have the ability to 
 All three apps in the **dirnotes** family have the ability to 
 copy/move files from the current directory, keeping the comments intact. 
 copy/move files from the current directory, keeping the comments intact. 
@@ -76,7 +83,8 @@ This is a JSON file, with three attributes that are important:
 > * database (default: <code>~/.local/share/dirnotes/dirnotes.db</code>, sensible alt: <code>/var/lib/dirnotes.db</code>) 
 > * database (default: <code>~/.local/share/dirnotes/dirnotes.db</code>, sensible alt: <code>/var/lib/dirnotes.db</code>) 
 > * start_mode (_xattr_ or _db_ display priority)
 > * start_mode (_xattr_ or _db_ display priority)
 
 
-The _config_file_ should be auto-generated the first time one of the **dirnotes** apps is run.
+The _config_file_ should be auto-generated the first time one of 
+the **dirnotes** apps is run.
 
 
 ## LIMITATIONS 
 ## LIMITATIONS 
 
 
@@ -210,7 +218,7 @@ There was _no_ consideration given for language translation. Email [me](mail:pat
 All these apps only accomadate a single line comment. An embedded newline will 
 All these apps only accomadate a single line comment. An embedded newline will 
 cause unpredictable behaviour. 
 cause unpredictable behaviour. 
 
 
-### MacOS {#macos}
+### MacOS
 
 
 The **MacOS** inherently supports file comments. The Finder app manages most of the user activity. It handles file comments in a similar manner to **Dirnotes**. Comments are stored in two places:
 The **MacOS** inherently supports file comments. The Finder app manages most of the user activity. It handles file comments in a similar manner to **Dirnotes**. Comments are stored in two places:
 
 
@@ -222,11 +230,15 @@ The **MacOS** inherently supports file comments. The Finder app manages most of
  
  
 The user can examine the file comments by opening the GetInfo dialog, and scrolling down to "Comment"
 The user can examine the file comments by opening the GetInfo dialog, and scrolling down to "Comment"
 
 
-If the Finder is used to copy/move files, the comments are moved properly to both destinations. If you use the os to copy/move the files, you can ask that the xattr properties get moved, but the .DS-Store file will not be updated. That means the Finder will not see file comments on the destination file.
+If the Finder is used to copy/move files, the comments are moved properly 
+to both destinations. If you use the os to copy/move the files, 
+you can ask that the xattr properties get moved, but the .DS-Store 
+file will not be updated. That means the Finder will not see 
+file comments on the destination file.
 
 
 **MacOS** has AppleScript, by which you can ask the Finder to perform the file copy/move. In this case, the comments are moved properly.
 **MacOS** has AppleScript, by which you can ask the Finder to perform the file copy/move. In this case, the comments are moved properly.
 
 
-## DEVELOPMENT STATUS{#status}
+## DEVELOPMENT STATUS
 
 
 Each app is a standalone file. That means there is a lot of redundancy between 
 Each app is a standalone file. That means there is a lot of redundancy between 
 the three apps. And there _may_ be some inconsistency.
 the three apps. And there _may_ be some inconsistency.
@@ -241,7 +253,7 @@ the three apps. And there _may_ be some inconsistency.
   The _qt-gui_ app is working pretty well.  
   The _qt-gui_ app is working pretty well.  
 
 
 
 
-QUESTIONS:
+## QUESTIONS:
 
 
 There are several open-ended questions that need to be answered. 
 There are several open-ended questions that need to be answered. 
 Does anyone have an opinion?
 Does anyone have an opinion?
@@ -250,9 +262,13 @@ Does anyone have an opinion?
 
 
 2. Is it ok to put the config file and database file buried in ~/.config and ~/.local? 
 2. Is it ok to put the config file and database file buried in ~/.config and ~/.local? 
 
 
-These directories exist on computers with a gui/windowing system installed, but don't neccessarily exist on headless servers. Perhaps the default locations should be in the user directory? (~/.dirnotes.conf and ~/.dirnotes.db)
+These directories exist on computers with a gui/windowing system installed, 
+but don't neccessarily exist on headless servers. 
+Perhaps the default locations should be in the user directory? 
+(~/.dirnotes.conf and ~/.dirnotes.db)
 
 
 3. Who needs translations?
 3. Who needs translations?
 
 
 4. Does anybody have a better edit-window for CURSES?
 4. Does anybody have a better edit-window for CURSES?
 
 
+5. Is anyone interested in the MacOS version?

+ 72 - 68
dirnotes

@@ -7,7 +7,7 @@
 to view and create/edit file comments
 to view and create/edit file comments
 
 
 comments are stored in an SQLite3 database
 comments are stored in an SQLite3 database
-  default ~/.dirnotes.db
+  default ~/.local/share/dirnotes/dirnotes.db
 where possible, comments are duplicated in 
 where possible, comments are duplicated in 
   xattr user.xdg.comment
   xattr user.xdg.comment
 
 
@@ -17,11 +17,11 @@ where possible, comments are duplicated in
 
 
   these comments stick to the symlink, not the deref
   these comments stick to the symlink, not the deref
 
 
-nav tools are enabled, so you can double-click to go into a dir
+  you can double-click to go into a dir
 
 
 """
 """
 
 
-VERSION = "0.8"
+VERSION = "0.9"
 
 
 helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
 helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
 <td align=right>Version: {VERSION}</td></tr></table>
 <td align=right>Version: {VERSION}</td></tr></table>
@@ -81,21 +81,23 @@ mode       = "db"
 global mainWindow, dbName
 global mainWindow, dbName
 
 
 verbose = None
 verbose = None
-def print_v(*a):
+def print_d(*a):
   if verbose:
   if verbose:
     print(*a)
     print(*a)
 
 
-############# the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
+# >>> snip here <<<
+#============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
+import getpass, time, stat, shutil
 
 
-DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
+DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
 
 
 # config
 # config
 #    we could store the config in the database, in a second table
 #    we could store the config in the database, in a second table
 #    or in a .json file
 #    or in a .json file
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
-  "database":"~/.dirnotes.db",
+  "database":"~/.local/share/dirnotes/dirnotes.db",
   "start_mode":"xattr",
   "start_mode":"xattr",
-  "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
+  "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
   "options for start_mode":("db","xattr")
   "options for start_mode":("db","xattr")
 }
 }
 
 
@@ -112,6 +114,7 @@ class ConfigLoader:    # singleton
       errorBox(f"config file {configFile} not found; using the default settings")
       errorBox(f"config file {configFile} not found; using the default settings")
       config = DEFAULT_CONFIG
       config = DEFAULT_CONFIG
       try:
       try:
+        os.makedirs(os.path.dirname(configFile),exist_ok = True)
         with open(configFile,"w") as f:
         with open(configFile,"w") as f:
           json.dump(config,f,indent=4)
           json.dump(config,f,indent=4)
       except:
       except:
@@ -132,7 +135,7 @@ class DnDataBase:
     this object: 1) finds or creates the database
     this object: 1) finds or creates the database
       2) determine if it's readonly
       2) determine if it's readonly
 
 
-    TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/) 
+    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/)
     TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
       make it 0666 permissions (rw-rw-rw-)
       make it 0666 permissions (rw-rw-rw-)
   '''
   '''
@@ -141,16 +144,21 @@ class DnDataBase:
     try:
     try:
       self.db = sqlite3.connect(dbFile)
       self.db = sqlite3.connect(dbFile)
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      logging.error(f"Database {dbFile} not found")
-      raise
- 
-    # create new database if it doesn't exist
+      print_d(f"Database {dbFile} not found")
+      try: 
+        os.makedirs(os.path.dirname(dbFile), exist_ok = True)
+        self.db = sqlite3.connect(dbFile)
+      except (sqlite3.OperationalError, PermissionError):
+        printd(f"Failed to create {dbFile}, aborting")
+        raise
+
+    # create new table if it doesn't exist
     try:
     try:
       self.db.execute("select * from dirnotes")
       self.db.execute("select * from dirnotes")
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      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.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
       self.db.execute("create index dirnotes_i on dirnotes(name)") 
       self.db.execute("create index dirnotes_i on dirnotes(name)") 
+      print_d(f"Table dirnotes created")
       # at this point, if a shared database is required, somebody needs to set perms to 0o666
       # at this point, if a shared database is required, somebody needs to set perms to 0o666
   
   
     self.writable = True
     self.writable = True
@@ -159,6 +167,7 @@ class DnDataBase:
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
       self.writable = False
       self.writable = False
 
 
+DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 class UiHelper:
 class UiHelper:
   @staticmethod
   @staticmethod
   def epochToDb(epoch):
   def epochToDb(epoch):
@@ -207,7 +216,7 @@ class FileObj:
     """ returns the absolute pathname """
     """ returns the absolute pathname """
     return self.fileName
     return self.fileName
   def getDisplayName(self):
   def getDisplayName(self):
-    """ returns just this basename of the file; dirs end in / """
+    """ returns just the basename of the file; dirs end in / """
     return self.displayName
     return self.displayName
 
 
   def getDbData(self):
   def getDbData(self):
@@ -241,18 +250,18 @@ class FileObj:
     #  return
     #  return
     s = os.lstat(self.fileName)
     s = os.lstat(self.fileName)
     try:
     try:
-      print_v(f"setDbComment db {self.db}, file: {self.fileName}")
+      print_d(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.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,
           (self.fileName, s.st_mtime, s.st_size,
           str(newComment), time.time(), getpass.getuser()))
           str(newComment), time.time(), getpass.getuser()))
       self.db.commit()
       self.db.commit()
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      print_v("database is locked or unwritable")
+      print_d("database is locked or unwritable")
       errorBox("the database that stores comments is locked or unwritable")
       errorBox("the database that stores comments is locked or unwritable")
 
 
   def setXattrComment(self,newComment):
   def setXattrComment(self,newComment):
-    print_v(f"set comment {newComment} on file {self.fileName}")
+    print_d(f"set comment {newComment} on file {self.fileName}")
     try:
     try:
       os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
       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_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
@@ -266,21 +275,22 @@ class FileObj:
       elif self.isSock():
       elif self.isSock():
         errorBox("Linux does not allow comments on sockets; comment is stored in database")
         errorBox("Linux does not allow comments on sockets; comment is stored in database")
       elif os.access(self.fileName, os.W_OK)!=True:
       elif os.access(self.fileName, os.W_OK)!=True:
-        errorBox("you don't appear to have write permissions on this file")
+        errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
         # change the listbox background to yellow
         # change the listbox background to yellow
       elif "Errno 95" in str(e):
       elif "Errno 95" in str(e):
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
       return False
       return False
 
 
   def getComment(self,mode):
   def getComment(self,mode):
+    """ returns the comment for the given mode """
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self,mode):
   def getOtherComment(self,mode):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
   def getData(self,mode):
   def getData(self,mode):
-    """ returns (comment, author, comment_date) """
+    """ returns (comment, author, comment_date) for the given mode """
     return self.getDbData()    if mode == "db"    else self.getXattrData()
     return self.getDbData()    if mode == "db"    else self.getXattrData()
   def getOtherData(self,mode):
   def getOtherData(self,mode):
-    """ returns (comment, author, comment_date) """
+    """ returns (comment, author, comment_date) for the 'other' mode """
     return self.getDbData()    if mode == "xattr" else self.getXattrData()
     return self.getDbData()    if mode == "xattr" else self.getXattrData()
 
 
   def getDate(self):
   def getDate(self):
@@ -294,38 +304,35 @@ class FileObj:
   def isSock(self):
   def isSock(self):
     return stat.S_ISSOCK(self.stat.st_mode)
     return stat.S_ISSOCK(self.stat.st_mode)
 
 
-  def copyFile(self, destDir):
+  def copyFile(self, dest, doMove = False):
+    """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
     # NOTE: this method copies the xattr (comment + old author + old date) 
     # NOTE: this method copies the xattr (comment + old author + old date) 
     #       but creates new db (comment + this author + new date)
     #       but creates new db (comment + this author + new date)
-    dest = os.path.join(destDir,self.displayName)
+    if os.path.isdir(dest):
+      dest = os.path.join(destDir,self.displayName)
     try:
     try:
-      print_v("try copy from",self.fileName,"to",dest)
-      shutil.copy2(self.fileName, dest)
+      print_d("try copy from",self.fileName,"to",dest)
+      # shutil methods preserve dates & chmod/chown & xattr
+      if doMove:
+        shutil.move(self.fileName, dest)
+      else:
+        shutil.copy2(self.fileName, dest)  
+      # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError 
     except:
     except:
-      errorBox(f"file copy to <{dest}> failed; check permissions")
+      errorBox(f"file copy/move to <{dest}> failed; check permissions")
       return
       return
+    # and copy the database record
     f = FileObj(dest, self.db)
     f = FileObj(dest, self.db)
     f.setDbComment(self.getDbComment())
     f.setDbComment(self.getDbComment())
-  def moveFile(self, destDir):
-    # NOTE: this method moves the xattr (comment + old author + old date)
-    #       but creates new db (comment + this author + new date)
-    src  = self.fileName
-    dest = os.path.join(destDir, self.displayName)
-    # move preserves dates & chmod/chown & xattr
-    print_v(f"move from {self.fileName} to {destDir}")
-    try:
-      shutil.move(src, dest)
-    except:
-      ErrorBox(f"file move to <{dest}> failed; check permissions")
-      return
-    # and copy the database record
-    f = FileObj(dest,self.db)
-    f.setDbComment(self.getDbComment())  
+  def moveFile(self, dest):
+    """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
+    self.copyFile(dest, doMove = True)
+
+# >>> snip here <<<
 
 
 class HelpWidget(QDialog):
 class HelpWidget(QDialog):
   def __init__(self, parent):
   def __init__(self, parent):
     super(QDialog, self).__init__(parent)
     super(QDialog, self).__init__(parent)
-    layout = QVBoxLayout(self)
 
 
     tb = QLabel(self)
     tb = QLabel(self)
     tb.setWordWrap(True)
     tb.setWordWrap(True)
@@ -335,8 +342,9 @@ class HelpWidget(QDialog):
     pb.setFixedWidth(200)
     pb.setFixedWidth(200)
     kb = QPushButton('Keyboard Help',self)
     kb = QPushButton('Keyboard Help',self)
 
 
+    layout = QVBoxLayout(self)
     layout.addWidget(tb)
     layout.addWidget(tb)
-    lowerBox = QHBoxLayout(self)
+    lowerBox = QHBoxLayout()
     lowerBox.addWidget(pb)
     lowerBox.addWidget(pb)
     lowerBox.addWidget(kb)
     lowerBox.addWidget(kb)
     layout.addLayout(lowerBox)
     layout.addLayout(lowerBox)
@@ -350,12 +358,13 @@ class HelpWidget(QDialog):
 class KeyboardHelpWidget(QDialog):
 class KeyboardHelpWidget(QDialog):
   def __init__(self, parent):
   def __init__(self, parent):
     super(QDialog, self).__init__(parent)
     super(QDialog, self).__init__(parent)
-    layout = QVBoxLayout(self)
     tb = QLabel(self)
     tb = QLabel(self)
     tb.setWordWrap(True)
     tb.setWordWrap(True)
     tb.setText(keyboardHelpMsg)
     tb.setText(keyboardHelpMsg)
     tb.setFixedWidth(500)
     tb.setFixedWidth(500)
     pb = QPushButton('OK',self)
     pb = QPushButton('OK',self)
+
+    layout = QVBoxLayout(self)
     layout.addWidget(tb)
     layout.addWidget(tb)
     layout.addWidget(pb)
     layout.addWidget(pb)
     pb.clicked.connect(self.close)
     pb.clicked.connect(self.close)
@@ -363,7 +372,7 @@ class KeyboardHelpWidget(QDialog):
 
 
 class errorBox(QDialog):
 class errorBox(QDialog):
   def __init__(self, text):
   def __init__(self, text):
-    print_v(f"errorBox: {text}")
+    print_d(f"errorBox: {text}")
     super(QDialog, self).__init__(mainWindow)
     super(QDialog, self).__init__(mainWindow)
     self.layout = QVBoxLayout(self)
     self.layout = QVBoxLayout(self)
     self.tb = QLabel(self)
     self.tb = QLabel(self)
@@ -397,7 +406,7 @@ keyboardHelpMsg = """
 <tr><td>Ctrl+Q</td><td>quit the app</td></tr>
 <tr><td>Ctrl+Q</td><td>quit the app</td></tr>
 </table>
 </table>
 <p>
 <p>
-NOTE: In edit mode, Ctrl+C, Ctrl+V and Ctrl+P work for cut, copy and paste.
+NOTE: In edit mode, Ctrl+X, Ctrl+C and Ctrl+V work for cut, copy and paste.
 """
 """
 
 
 icon = ["32 32 6 1",  # the QPixmap constructor allows for str[]
 icon = ["32 32 6 1",  # the QPixmap constructor allows for str[]
@@ -464,13 +473,13 @@ class DirNotes(QMainWindow):
     self.parent = parent
     self.parent = parent
 
 
     longPathName = os.path.abspath(argFilename)
     longPathName = os.path.abspath(argFilename)
-    print_v("longpathname is {}".format(longPathName))
+    print_d("longpathname is {}".format(longPathName))
     if os.path.isdir(longPathName):
     if os.path.isdir(longPathName):
       self.curPath = longPathName
       self.curPath = longPathName
       filename = ''
       filename = ''
     else:
     else:
       self.curPath, filename = os.path.split(longPathName)
       self.curPath, filename = os.path.split(longPathName)
-    print_v("working on <"+self.curPath+"> and <"+filename+">")
+    print_d("working on <"+self.curPath+"> and <"+filename+">")
 
 
     win = QWidget()
     win = QWidget()
     self.setCentralWidget(win)
     self.setCentralWidget(win)
@@ -479,7 +488,7 @@ class DirNotes(QMainWindow):
     mf = mb.addMenu('&File')
     mf = mb.addMenu('&File')
     mf.addAction("Sort by name", self.sbn, "Ctrl+N")
     mf.addAction("Sort by name", self.sbn, "Ctrl+N")
     mf.addAction("Sort by date", self.sbd, "Ctrl+D")
     mf.addAction("Sort by date", self.sbd, "Ctrl+D")
-    mf.addAction("Sort by size", self.sbs, "Ctrl+Z")
+    mf.addAction("Sort by size", self.sbs, "Ctrl+S")
     mf.addAction("Sort by comment", self.sbc, "Ctrl+T")
     mf.addAction("Sort by comment", self.sbc, "Ctrl+T")
     mf.addSeparator()
     mf.addSeparator()
     mf.addAction("Change mode", self.switchMode, "Ctrl+M")
     mf.addAction("Change mode", self.switchMode, "Ctrl+M")
@@ -542,25 +551,25 @@ class DirNotes(QMainWindow):
     lb.setFocus()
     lb.setFocus()
 
 
   def sbd(self):
   def sbd(self):
-    print_v("sort by date")
+    print_d("sort by date")
     self.lb.sortItems(1,Qt.DescendingOrder)
     self.lb.sortItems(1,Qt.DescendingOrder)
   def sbs(self):
   def sbs(self):
-    print_v("sort by size")
+    print_d("sort by size")
     self.lb.sortItems(2)
     self.lb.sortItems(2)
   def sbn(self):
   def sbn(self):
-    print_v("sort by name")
+    print_d("sort by name")
     self.lb.sortItems(0)
     self.lb.sortItems(0)
   def sbc(self):
   def sbc(self):
-    print_v("sort by comment")
+    print_d("sort by comment")
     self.lb.sortItems(3)
     self.lb.sortItems(3)
   def about(self):
   def about(self):
     HelpWidget(self)
     HelpWidget(self)
 
 
   def double(self,row,col):
   def double(self,row,col):
-    print_v("double click {} {}".format(row, col))
+    print_d("double click {} {}".format(row, col))
     fo = self.lb.item(row,0).file_object
     fo = self.lb.item(row,0).file_object
     if col==0 and fo.isDir():
     if col==0 and fo.isDir():
-      print_v("double click on {}".format(fo.getName()))
+      print_d("double click on {}".format(fo.getName()))
       self.curPath = fo.getName()
       self.curPath = fo.getName()
       self.refill()
       self.refill()
   def keyPressEvent(self,e):
   def keyPressEvent(self,e):
@@ -582,11 +591,11 @@ class DirNotes(QMainWindow):
     r, c = self.lb.currentRow(), self.lb.currentColumn()
     r, c = self.lb.currentRow(), self.lb.currentColumn()
     fo = self.lb.item(r,c).file_object
     fo = self.lb.item(r,c).file_object
     if not fo.isDir() and not fo.isLink() and not fo.isSock(): 
     if not fo.isDir() and not fo.isLink() and not fo.isSock(): 
-      print_v(f"{'copy' if doCopy=='copy' else 'move'} file {fo.getName()}")
+      print_d(f"{'copy' if doCopy=='copy' else 'move'} file {fo.getName()}")
       # open the dir.picker
       # open the dir.picker
       d = QFileDialog.getExistingDirectory(self.parent, pickerTitle)
       d = QFileDialog.getExistingDirectory(self.parent, pickerTitle)
       if d:
       if d:
-        print_v(f"senf file to {d}")
+        print_d(f"senf file to {d}")
         fo.copyFile(d) if doCopy=='copy' else fo.moveFile(d)
         fo.copyFile(d) if doCopy=='copy' else fo.moveFile(d)
   def copyFile(self):
   def copyFile(self):
     self.copyMoveFile('copy',"Select destination for FileCopy")
     self.copyMoveFile('copy',"Select destination for FileCopy")
@@ -625,7 +634,7 @@ class DirNotes(QMainWindow):
     #~ print("insert {} items into cleared table {}".format(len(d),current))
     #~ print("insert {} items into cleared table {}".format(len(d),current))
     for i,name in enumerate(d):
     for i,name in enumerate(d):
       this_file = FileObj(os.path.join(current,name),self.db)
       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_d("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()))
       #~ print("insert order check: {} {} {} {}".format(d[i],i,this_file.getName(),this_file.getDate()))
       display_name = this_file.getDisplayName()
       display_name = this_file.getDisplayName()
       if this_file.isDir():
       if this_file.isDir():
@@ -643,7 +652,7 @@ class DirNotes(QMainWindow):
       ci.setToolTip(f"comment: {comment}\ncomment date: {cdate}\nauthor: {auth}")
       ci.setToolTip(f"comment: {comment}\ncomment date: {cdate}\nauthor: {auth}")
       if other_comment != comment:
       if other_comment != comment:
         ci.setBackground(self.differBrush)
         ci.setBackground(self.differBrush)
-        print_v("got differing comments <{}> and <{}>".format(comment, other_comment))
+        print_d("got differing comments <{}> and <{}>".format(comment, other_comment))
       ci.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled)
       ci.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled)
       self.lb.setItem(i,3,ci)
       self.lb.setItem(i,3,ci)
 
 
@@ -681,8 +690,8 @@ class DirNotes(QMainWindow):
     if self.refilling:
     if self.refilling:
       return
       return
     the_file = self.lb.item(x.row(),0).file_object
     the_file = self.lb.item(x.row(),0).file_object
-    print_v(f"debugging {x.text()} r:{str(x.row())} c:{str(x.column())}")
-    print_v(f"      selected file: {the_file.getName()}")
+    print_d(f"debugging {x.text()} r:{str(x.row())} c:{str(x.column())}")
+    print_d(f"      selected file: {the_file.getName()}  new text: >{x.text()}<")
     the_file.setDbComment(str(x.text()))
     the_file.setDbComment(str(x.text()))
     the_file.setXattrComment(str(x.text())) 
     the_file.setXattrComment(str(x.text())) 
 
 
@@ -728,12 +737,12 @@ if __name__=="__main__":
     p.dirname = p.dirname[:-1]
     p.dirname = p.dirname[:-1]
   if os.path.isdir(p.dirname):
   if os.path.isdir(p.dirname):
     p.dirname = p.dirname + '/'
     p.dirname = p.dirname + '/'
-  print_v(f"using {p.dirname}")
+  print_d(f"using {p.dirname}")
   verbose = p.verbose
   verbose = p.verbose
   
   
   config = ConfigLoader(p.config_file or DEFAULT_CONFIG_FILE)
   config = ConfigLoader(p.config_file or DEFAULT_CONFIG_FILE)
   
   
-  print_v(f"here is the .json {repr(config)}")
+  print_d(f"here is the .json {repr(config)}")
   dbName = config.dbName 
   dbName = config.dbName 
   db = DnDataBase(dbName).db
   db = DnDataBase(dbName).db
   xattr_comment = config.xattr_comment
   xattr_comment = config.xattr_comment
@@ -754,11 +763,6 @@ if __name__=="__main__":
   if p.sort_by_date:
   if p.sort_by_date:
     mainWindow.sbd()
     mainWindow.sbd()
   mainWindow.show()
   mainWindow.show()
-  
-  if p.sort_by_date:
-    mainWindow.sbd()
-  if p.sort_by_size:
-    mainWindow.sbs()
 
 
   a.exec_()
   a.exec_()
   
   

+ 9 - 8
dirnotes-cli

@@ -314,22 +314,23 @@ def file_display(f, listall, json, minimal):
     print_d(f"list file details {fn}")
     print_d(f"list file details {fn}")
     c,a,d = f.getData(mode)
     c,a,d = f.getData(mode)
     c1,a1,d1 = f.getOtherData(mode)
     c1,a1,d1 = f.getOtherData(mode)
+    diffFlag = '*' if c and (c != c1) else ''
 
 
     if c or listall:
     if c or listall:
-        if c and (c != c1):
-            c += '*'
         if not json:
         if not json:
             if minimal:
             if minimal:
-                print(f"{c}")
+                print(f"{c}{diffFlag}")
             elif verbose:
             elif verbose:
-                print(f"{f.getName()}: {repr(c)}, {repr(a)}, {repr(d)}")
+                print(f"{f.getName()}: {repr(c)}{diffFlag}, {repr(a)}, {repr(d)}")
             else:
             else:
-                print(f"{fn}: {repr(c)}")
+                print(f"{fn}: {repr(c)}{diffFlag}")
         else:
         else:
+            entry = {"file":fn, "comment":c}
             if verbose:
             if verbose:
-                answer_json.append( {"file":f.getName(),"comment":c,"author":a,"date":d } )
-            else:
-                answer_json.append( {"file":fn,"comment":c} )
+                entry.update({"file":f.getName(),"author":a, "date": d})
+            if diffFlag:
+                entry["diffFlag"] = True
+            answer_json.append(entry)
 
 
 def file_history(f,json):
 def file_history(f,json):
     db = f.db
     db = f.db

+ 47 - 40
dirnotes-tui

@@ -79,7 +79,7 @@ now = time.time()
 YEAR = 3600*24*365
 YEAR = 3600*24*365
 
 
 verbose = None
 verbose = None
-def print_v(*a):
+def print_d(*a):
   if verbose:
   if verbose:
     print(*a)
     print(*a)
 
 
@@ -324,17 +324,19 @@ def errorBox(string):
     print(string)
     print(string)
     time.sleep(3)
     time.sleep(3)
   
   
-############# the dnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
+# >>> snip here <<<
+#============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
+import getpass, time, stat, shutil
 
 
-DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
+DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
 
 
 # config
 # config
 #    we could store the config in the database, in a second table
 #    we could store the config in the database, in a second table
 #    or in a .json file
 #    or in a .json file
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
-  "database":"~/.dirnotes.db",
+  "database":"~/.local/share/dirnotes/dirnotes.db",
   "start_mode":"xattr",
   "start_mode":"xattr",
-  "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
+  "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
   "options for start_mode":("db","xattr")
   "options for start_mode":("db","xattr")
 }
 }
 
 
@@ -351,6 +353,7 @@ class ConfigLoader:    # singleton
       errorBox(f"config file {configFile} not found; using the default settings")
       errorBox(f"config file {configFile} not found; using the default settings")
       config = DEFAULT_CONFIG
       config = DEFAULT_CONFIG
       try:
       try:
+        os.makedirs(os.path.dirname(configFile),exist_ok = True)
         with open(configFile,"w") as f:
         with open(configFile,"w") as f:
           json.dump(config,f,indent=4)
           json.dump(config,f,indent=4)
       except:
       except:
@@ -371,7 +374,7 @@ class DnDataBase:
     this object: 1) finds or creates the database
     this object: 1) finds or creates the database
       2) determine if it's readonly
       2) determine if it's readonly
 
 
-    TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/) 
+    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/)
     TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
       make it 0666 permissions (rw-rw-rw-)
       make it 0666 permissions (rw-rw-rw-)
   '''
   '''
@@ -380,16 +383,21 @@ class DnDataBase:
     try:
     try:
       self.db = sqlite3.connect(dbFile)
       self.db = sqlite3.connect(dbFile)
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      logging.error(f"Database {dbFile} not found")
-      raise
- 
-    # create new database if it doesn't exist
+      print_d(f"Database {dbFile} not found")
+      try: 
+        os.makedirs(os.path.dirname(dbFile), exist_ok = True)
+        self.db = sqlite3.connect(dbFile)
+      except (sqlite3.OperationalError, PermissionError):
+        printd(f"Failed to create {dbFile}, aborting")
+        raise
+
+    # create new table if it doesn't exist
     try:
     try:
       self.db.execute("select * from dirnotes")
       self.db.execute("select * from dirnotes")
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      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.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
       self.db.execute("create index dirnotes_i on dirnotes(name)") 
       self.db.execute("create index dirnotes_i on dirnotes(name)") 
+      print_d(f"Table dirnotes created")
       # at this point, if a shared database is required, somebody needs to set perms to 0o666
       # at this point, if a shared database is required, somebody needs to set perms to 0o666
   
   
     self.writable = True
     self.writable = True
@@ -398,6 +406,7 @@ class DnDataBase:
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
       self.writable = False
       self.writable = False
 
 
+DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 class UiHelper:
 class UiHelper:
   @staticmethod
   @staticmethod
   def epochToDb(epoch):
   def epochToDb(epoch):
@@ -446,7 +455,7 @@ class FileObj:
     """ returns the absolute pathname """
     """ returns the absolute pathname """
     return self.fileName
     return self.fileName
   def getDisplayName(self):
   def getDisplayName(self):
-    """ returns just this basename of the file; dirs end in / """
+    """ returns just the basename of the file; dirs end in / """
     return self.displayName
     return self.displayName
 
 
   def getDbData(self):
   def getDbData(self):
@@ -480,18 +489,18 @@ class FileObj:
     #  return
     #  return
     s = os.lstat(self.fileName)
     s = os.lstat(self.fileName)
     try:
     try:
-      print_v(f"setDbComment db {self.db}, file: {self.fileName}")
+      print_d(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.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,
           (self.fileName, s.st_mtime, s.st_size,
           str(newComment), time.time(), getpass.getuser()))
           str(newComment), time.time(), getpass.getuser()))
       self.db.commit()
       self.db.commit()
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
     except sqlite3.OperationalError:
     except sqlite3.OperationalError:
-      print_v("database is locked or unwritable")
+      print_d("database is locked or unwritable")
       errorBox("the database that stores comments is locked or unwritable")
       errorBox("the database that stores comments is locked or unwritable")
 
 
   def setXattrComment(self,newComment):
   def setXattrComment(self,newComment):
-    print_v(f"set comment {newComment} on file {self.fileName}")
+    print_d(f"set comment {newComment} on file {self.fileName}")
     try:
     try:
       os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
       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_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
@@ -505,21 +514,22 @@ class FileObj:
       elif self.isSock():
       elif self.isSock():
         errorBox("Linux does not allow comments on sockets; comment is stored in database")
         errorBox("Linux does not allow comments on sockets; comment is stored in database")
       elif os.access(self.fileName, os.W_OK)!=True:
       elif os.access(self.fileName, os.W_OK)!=True:
-        errorBox("you don't appear to have write permissions on this file")
+        errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
         # change the listbox background to yellow
         # change the listbox background to yellow
       elif "Errno 95" in str(e):
       elif "Errno 95" in str(e):
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
       return False
       return False
 
 
   def getComment(self,mode):
   def getComment(self,mode):
+    """ returns the comment for the given mode """
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self,mode):
   def getOtherComment(self,mode):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
   def getData(self,mode):
   def getData(self,mode):
-    """ returns (comment, author, comment_date) """
+    """ returns (comment, author, comment_date) for the given mode """
     return self.getDbData()    if mode == "db"    else self.getXattrData()
     return self.getDbData()    if mode == "db"    else self.getXattrData()
   def getOtherData(self,mode):
   def getOtherData(self,mode):
-    """ returns (comment, author, comment_date) """
+    """ returns (comment, author, comment_date) for the 'other' mode """
     return self.getDbData()    if mode == "xattr" else self.getXattrData()
     return self.getDbData()    if mode == "xattr" else self.getXattrData()
 
 
   def getDate(self):
   def getDate(self):
@@ -533,34 +543,31 @@ class FileObj:
   def isSock(self):
   def isSock(self):
     return stat.S_ISSOCK(self.stat.st_mode)
     return stat.S_ISSOCK(self.stat.st_mode)
 
 
-  def copyFile(self, destDir):
+  def copyFile(self, dest, doMove = False):
+    """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
     # NOTE: this method copies the xattr (comment + old author + old date) 
     # NOTE: this method copies the xattr (comment + old author + old date) 
     #       but creates new db (comment + this author + new date)
     #       but creates new db (comment + this author + new date)
-    if stat.S_ISREG(self.stat.st_mode):
+    if os.path.isdir(dest):
       dest = os.path.join(destDir,self.displayName)
       dest = os.path.join(destDir,self.displayName)
-      try:
-        print_v("try copy from",self.fileName,"to",dest)
-        shutil.copy2(self.fileName, dest)
-      except:
-        errorBox(f"file copy to <{dest}> failed; check permissions")
-        return
-      f = FileObj(dest, self.db)
-      f.setDbComment(self.getDbComment())
-  def moveFile(self, destDir):
-    # NOTE: this method moves the xattr (comment + old author + old date)
-    #       but creates new db (comment + this author + new date)
-    src  = self.fileName
-    dest = os.path.join(destDir, self.displayName)
-    # move preserves dates & chmod/chown & xattr
-    print_v(f"move from {self.fileName} to {destDir}")
     try:
     try:
-      shutil.move(src, dest)
+      print_d("try copy from",self.fileName,"to",dest)
+      # shutil methods preserve dates & chmod/chown & xattr
+      if doMove:
+        shutil.move(self.fileName, dest)
+      else:
+        shutil.copy2(self.fileName, dest)  
+      # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError 
     except:
     except:
-      ErrorBox(f"file move to <{dest}> failed; check permissions")
+      errorBox(f"file copy/move to <{dest}> failed; check permissions")
       return
       return
     # and copy the database record
     # and copy the database record
-    f = FileObj(dest,files.db)
-    f.setDbComment(self.getDbComment())  
+    f = FileObj(dest, self.db)
+    f.setDbComment(self.getDbComment())
+  def moveFile(self, dest):
+    """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
+    self.copyFile(dest, doMove = True)
+
+# >>> snip here <<<
     
     
 ##########  dest directory picker ###############
 ##########  dest directory picker ###############
 # returns None if the user hits <esc>
 # returns None if the user hits <esc>
@@ -870,7 +877,7 @@ def main(w, cwd, database_file, start_file):
       f = files[mywin.cursor]
       f = files[mywin.cursor]
       if f.isDir():
       if f.isDir():
         cwd = f.getName()
         cwd = f.getName()
-        print_v(f"CD change to {cwd}")
+        print_d(f"CD change to {cwd}")
         files = Files(cwd,db)
         files = Files(cwd,db)
         mywin = Pane(w,cwd,files)
         mywin = Pane(w,cwd,files)
         # TODO: should this simply re-fill() the existing Pane instead of destroy?
         # TODO: should this simply re-fill() the existing Pane instead of destroy?