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)
   * [PROGRAMMER NOTES](#programmer-notes)
     * [MacOS](#macos)
-  * [DEVELOPMENT STATUS](#status)
+  * [DEVELOPMENT STATUS](#development-status)
+  * [QUESTIONS](#questions)
 
 ## SYNOPSIS
 
@@ -41,14 +42,20 @@ which may be handy for scripting. This all can also do maintenance on the databa
 
 ## 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.
 
-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 
 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>) 
 > * 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 
 
@@ -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 
 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:
 
@@ -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"
 
-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.
 
-## DEVELOPMENT STATUS{#status}
+## DEVELOPMENT STATUS
 
 Each app is a standalone file. That means there is a lot of redundancy between 
 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.  
 
 
-QUESTIONS:
+## QUESTIONS:
 
 There are several open-ended questions that need to be answered. 
 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? 
 
-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?
 
 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
 
 comments are stored in an SQLite3 database
-  default ~/.dirnotes.db
+  default ~/.local/share/dirnotes/dirnotes.db
 where possible, comments are duplicated in 
   xattr user.xdg.comment
 
@@ -17,11 +17,11 @@ where possible, comments are duplicated in
 
   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>
 <td align=right>Version: {VERSION}</td></tr></table>
@@ -81,21 +81,23 @@ mode       = "db"
 global mainWindow, dbName
 
 verbose = None
-def print_v(*a):
+def print_d(*a):
   if verbose:
     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
 #    we could store the config in the database, in a second table
 #    or in a .json file
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
-  "database":"~/.dirnotes.db",
+  "database":"~/.local/share/dirnotes/dirnotes.db",
   "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")
 }
 
@@ -112,6 +114,7 @@ class ConfigLoader:    # singleton
       errorBox(f"config file {configFile} not found; using the default settings")
       config = DEFAULT_CONFIG
       try:
+        os.makedirs(os.path.dirname(configFile),exist_ok = True)
         with open(configFile,"w") as f:
           json.dump(config,f,indent=4)
       except:
@@ -132,7 +135,7 @@ class DnDataBase:
     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: 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-)
   '''
@@ -141,16 +144,21 @@ class DnDataBase:
     try:
       self.db = sqlite3.connect(dbFile)
     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:
       self.db.execute("select * from dirnotes")
     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 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
   
     self.writable = True
@@ -159,6 +167,7 @@ class DnDataBase:
     except sqlite3.OperationalError:
       self.writable = False
 
+DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 class UiHelper:
   @staticmethod
   def epochToDb(epoch):
@@ -207,7 +216,7 @@ class FileObj:
     """ returns the absolute pathname """
     return self.fileName
   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
 
   def getDbData(self):
@@ -241,18 +250,18 @@ class FileObj:
     #  return
     s = os.lstat(self.fileName)
     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.fileName, s.st_mtime, s.st_size,
           str(newComment), time.time(), getpass.getuser()))
       self.db.commit()
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
     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")
 
   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:
       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)
@@ -266,21 +275,22 @@ class FileObj:
       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")
+        errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
         # change the listbox background to yellow
       elif "Errno 95" in str(e):
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
       return False
 
   def getComment(self,mode):
+    """ returns the comment for the given mode """
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self,mode):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
   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()
   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()
 
   def getDate(self):
@@ -294,38 +304,35 @@ class FileObj:
   def isSock(self):
     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) 
     #       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:
-      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:
-      errorBox(f"file copy to <{dest}> failed; check permissions")
+      errorBox(f"file copy/move to <{dest}> failed; check permissions")
       return
+    # and copy the database record
     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:
-      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):
   def __init__(self, parent):
     super(QDialog, self).__init__(parent)
-    layout = QVBoxLayout(self)
 
     tb = QLabel(self)
     tb.setWordWrap(True)
@@ -335,8 +342,9 @@ class HelpWidget(QDialog):
     pb.setFixedWidth(200)
     kb = QPushButton('Keyboard Help',self)
 
+    layout = QVBoxLayout(self)
     layout.addWidget(tb)
-    lowerBox = QHBoxLayout(self)
+    lowerBox = QHBoxLayout()
     lowerBox.addWidget(pb)
     lowerBox.addWidget(kb)
     layout.addLayout(lowerBox)
@@ -350,12 +358,13 @@ class HelpWidget(QDialog):
 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 = QVBoxLayout(self)
     layout.addWidget(tb)
     layout.addWidget(pb)
     pb.clicked.connect(self.close)
@@ -363,7 +372,7 @@ class KeyboardHelpWidget(QDialog):
 
 class errorBox(QDialog):
   def __init__(self, text):
-    print_v(f"errorBox: {text}")
+    print_d(f"errorBox: {text}")
     super(QDialog, self).__init__(mainWindow)
     self.layout = QVBoxLayout(self)
     self.tb = QLabel(self)
@@ -397,7 +406,7 @@ keyboardHelpMsg = """
 <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.
+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[]
@@ -464,13 +473,13 @@ class DirNotes(QMainWindow):
     self.parent = parent
 
     longPathName = os.path.abspath(argFilename)
-    print_v("longpathname is {}".format(longPathName))
+    print_d("longpathname is {}".format(longPathName))
     if os.path.isdir(longPathName):
       self.curPath = longPathName
       filename = ''
     else:
       self.curPath, filename = os.path.split(longPathName)
-    print_v("working on <"+self.curPath+"> and <"+filename+">")
+    print_d("working on <"+self.curPath+"> and <"+filename+">")
 
     win = QWidget()
     self.setCentralWidget(win)
@@ -479,7 +488,7 @@ class DirNotes(QMainWindow):
     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 size", self.sbs, "Ctrl+S")
     mf.addAction("Sort by comment", self.sbc, "Ctrl+T")
     mf.addSeparator()
     mf.addAction("Change mode", self.switchMode, "Ctrl+M")
@@ -542,25 +551,25 @@ class DirNotes(QMainWindow):
     lb.setFocus()
 
   def sbd(self):
-    print_v("sort by date")
+    print_d("sort by date")
     self.lb.sortItems(1,Qt.DescendingOrder)
   def sbs(self):
-    print_v("sort by size")
+    print_d("sort by size")
     self.lb.sortItems(2)
   def sbn(self):
-    print_v("sort by name")
+    print_d("sort by name")
     self.lb.sortItems(0)
   def sbc(self):
-    print_v("sort by comment")
+    print_d("sort by comment")
     self.lb.sortItems(3)
   def about(self):
     HelpWidget(self)
 
   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
     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.refill()
   def keyPressEvent(self,e):
@@ -582,11 +591,11 @@ class DirNotes(QMainWindow):
     r, c = self.lb.currentRow(), self.lb.currentColumn()
     fo = self.lb.item(r,c).file_object
     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
       d = QFileDialog.getExistingDirectory(self.parent, pickerTitle)
       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)
   def copyFile(self):
     self.copyMoveFile('copy',"Select destination for FileCopy")
@@ -625,7 +634,7 @@ class DirNotes(QMainWindow):
     #~ print("insert {} items into cleared table {}".format(len(d),current))
     for i,name in enumerate(d):
       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()))
       display_name = this_file.getDisplayName()
       if this_file.isDir():
@@ -643,7 +652,7 @@ class DirNotes(QMainWindow):
       ci.setToolTip(f"comment: {comment}\ncomment date: {cdate}\nauthor: {auth}")
       if other_comment != comment:
         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)
       self.lb.setItem(i,3,ci)
 
@@ -681,8 +690,8 @@ class DirNotes(QMainWindow):
     if self.refilling:
       return
     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.setXattrComment(str(x.text())) 
 
@@ -728,12 +737,12 @@ if __name__=="__main__":
     p.dirname = p.dirname[:-1]
   if os.path.isdir(p.dirname):
     p.dirname = p.dirname + '/'
-  print_v(f"using {p.dirname}")
+  print_d(f"using {p.dirname}")
   verbose = p.verbose
   
   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 
   db = DnDataBase(dbName).db
   xattr_comment = config.xattr_comment
@@ -754,11 +763,6 @@ if __name__=="__main__":
   if p.sort_by_date:
     mainWindow.sbd()
   mainWindow.show()
-  
-  if p.sort_by_date:
-    mainWindow.sbd()
-  if p.sort_by_size:
-    mainWindow.sbs()
 
   a.exec_()
   

+ 9 - 8
dirnotes-cli

@@ -314,22 +314,23 @@ def file_display(f, listall, json, minimal):
     print_d(f"list file details {fn}")
     c,a,d = f.getData(mode)
     c1,a1,d1 = f.getOtherData(mode)
+    diffFlag = '*' if c and (c != c1) else ''
 
     if c or listall:
-        if c and (c != c1):
-            c += '*'
         if not json:
             if minimal:
-                print(f"{c}")
+                print(f"{c}{diffFlag}")
             elif verbose:
-                print(f"{f.getName()}: {repr(c)}, {repr(a)}, {repr(d)}")
+                print(f"{f.getName()}: {repr(c)}{diffFlag}, {repr(a)}, {repr(d)}")
             else:
-                print(f"{fn}: {repr(c)}")
+                print(f"{fn}: {repr(c)}{diffFlag}")
         else:
+            entry = {"file":fn, "comment":c}
             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):
     db = f.db

+ 47 - 40
dirnotes-tui

@@ -79,7 +79,7 @@ now = time.time()
 YEAR = 3600*24*365
 
 verbose = None
-def print_v(*a):
+def print_d(*a):
   if verbose:
     print(*a)
 
@@ -324,17 +324,19 @@ def errorBox(string):
     print(string)
     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
 #    we could store the config in the database, in a second table
 #    or in a .json file
 DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
-  "database":"~/.dirnotes.db",
+  "database":"~/.local/share/dirnotes/dirnotes.db",
   "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")
 }
 
@@ -351,6 +353,7 @@ class ConfigLoader:    # singleton
       errorBox(f"config file {configFile} not found; using the default settings")
       config = DEFAULT_CONFIG
       try:
+        os.makedirs(os.path.dirname(configFile),exist_ok = True)
         with open(configFile,"w") as f:
           json.dump(config,f,indent=4)
       except:
@@ -371,7 +374,7 @@ class DnDataBase:
     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: 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-)
   '''
@@ -380,16 +383,21 @@ class DnDataBase:
     try:
       self.db = sqlite3.connect(dbFile)
     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:
       self.db.execute("select * from dirnotes")
     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 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
   
     self.writable = True
@@ -398,6 +406,7 @@ class DnDataBase:
     except sqlite3.OperationalError:
       self.writable = False
 
+DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 class UiHelper:
   @staticmethod
   def epochToDb(epoch):
@@ -446,7 +455,7 @@ class FileObj:
     """ returns the absolute pathname """
     return self.fileName
   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
 
   def getDbData(self):
@@ -480,18 +489,18 @@ class FileObj:
     #  return
     s = os.lstat(self.fileName)
     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.fileName, s.st_mtime, s.st_size,
           str(newComment), time.time(), getpass.getuser()))
       self.db.commit()
       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
     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")
 
   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:
       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)
@@ -505,21 +514,22 @@ class FileObj:
       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")
+        errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
         # change the listbox background to yellow
       elif "Errno 95" in str(e):
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
       return False
 
   def getComment(self,mode):
+    """ returns the comment for the given mode """
     return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self,mode):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
   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()
   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()
 
   def getDate(self):
@@ -533,34 +543,31 @@ class FileObj:
   def isSock(self):
     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) 
     #       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)
-      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:
-      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:
-      ErrorBox(f"file move to <{dest}> failed; check permissions")
+      errorBox(f"file copy/move to <{dest}> failed; check permissions")
       return
     # 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 ###############
 # returns None if the user hits <esc>
@@ -870,7 +877,7 @@ def main(w, cwd, database_file, start_file):
       f = files[mywin.cursor]
       if f.isDir():
         cwd = f.getName()
-        print_v(f"CD change to {cwd}")
+        print_d(f"CD change to {cwd}")
         files = Files(cwd,db)
         mywin = Pane(w,cwd,files)
         # TODO: should this simply re-fill() the existing Pane instead of destroy?