Browse Source

dirnotes-tui: did a lot of TODO's
tested most errorBox() calls
modify errorBox so it works before initscr() is called
toss out the MacOS code
add start_file code to highlight a specify file from the command line
display a highlighted comment field (comments_are_different) even if the comment is ''
change the file scanner from os.listdir() to os.path.walk()

Pat Beirne 1 year ago
parent
commit
4523c13c0f
2 changed files with 288 additions and 271 deletions
  1. 1 0
      dirnotes
  2. 287 271
      dirnotes-tui

+ 1 - 0
dirnotes

@@ -723,6 +723,7 @@ if __name__=="__main__":
     mode = "db"
 
   a = QApplication([])
+  # TODO: add 'mode' as an argument to contructor; add setMode() as a method
   mainWindow = DirNotes(p.dirname,db)
   if p.sort_by_size:
     mainWindow.sbs()

+ 287 - 271
dirnotes-tui

@@ -1,7 +1,5 @@
 #!/usr/bin/env python3 
 
-# TOTO: when changing directories, keep the sort order?
-# TODO: fix the 'reload' function....need 'this_dir' in Files class
 # TODO: write color scheme
 # TODO: re-read date/author to xattr after an edit
 # TODO: consider adding h,j,k,l movement
@@ -24,7 +22,7 @@ import curses, sqlite3, curses.textpad
 import logging, getpass, argparse
 import json
 
-VERSION = "1.7"
+VERSION = "1.8"
 # these may be different on MacOS
 xattr_comment = "user.xdg.comment"
 xattr_author  = "user.xdg.comment.author"
@@ -63,18 +61,6 @@ CMD_CD     = ord('\n')
 # ~/.dirnotes.db or /var/lib/dirnotes.db
 # at usage time, check for ~/.dirnotes.db first
 
-DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
-
-# config
-#    we could store the config in the database, in a second table
-#    or in a .json file
-DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
-  "database":"~/.dirnotes.db",
-  "start_mode":"xattr",
-  "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
-  "options for start_mode":("db","xattr")
-}
-
 ### colors
 
 CP_TITLE  = 1
@@ -93,38 +79,10 @@ COLOR_THEME = ''' { "heading": ("yellow","blue"),
 now = time.time()
 YEAR = 3600*24*365
 
-#
-# deal with the odd aspects of MacOS
-#
-#   xattr is loaded as a separate library, with different argument names
-#   to set/get the comments in Finder, we need to call osascript
-#
-
-if sys.platform == "darwin":
-  # we get xattr from PyPi
-  try:
-    import xattr
-  except:
-    print("This program requires the 'xattr' package. Please use 'pip3 install xattr' to install the package")
-    sys.exit(1)
-  def do_setxattr(file_name, attr_name, attr_content):
-    if attr_name.endswith("omment"):
-      p = Popen(['osascript','-'] + f'tell application "Finder" to set comment of (POSIX file "{file_name}" as alias) to "{attr_content}"')
-      p.communicate()
-    else:
-      xattr.setxattr(file_name, attr_name, plistlib.dumps(attr_content))
-
-  os.setxattr = do_setxattr
-  #os.setxattr = xattr.setxattr
-  def do_getxattr(file_name, attr_name):
-    attr_data = xattr.getxattr(file_name, attr_name)
-    if attr_name.endswith("omment"):
-      return plistlib.loads(attr_data)
-    else:
-      return attr_data
-  os.getxattr = xattr.getxattr
-else:
-  pass
+verbose = None
+def print_v(*a):
+  if verbose:
+    print(*a)
 
 class Pane:
   ''' holds the whole display: handles file list directly,
@@ -143,11 +101,12 @@ class Pane:
 
       most methods take y=0..h-1 where y is the line number WITHIN the borders
   '''
-  def __init__(self, win, curdir, files):
+  def __init__(self, win, curdir, files, start_file = None):
     self.curdir = curdir
     self.cursor = None
     self.first_visible = 0
     self.nFiles = len(files)
+    self.start_file = start_file
     
     self.h, self.w = win.getmaxyx()
     
@@ -185,18 +144,20 @@ class Pane:
  
     if self.some_comments_differ:
       self.setStatus("The xattr and database comments differ where shown in green")
-    #  time.sleep(2)
+    else:
+      self.setStatus("")
 
     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))
     self.win.erase()
-    self.win.border()  # TODO: or .box() ?
+    self.win.box() 
     h,w = self.win.getmaxyx()
     self.win.addnstr(0,3,os.path.realpath(self.curdir),w-4)
-    n = len(files.getMasterComment())
-    self.win.addnstr(0,w-n-1,files.getMasterComment(),w-n-1)  # TODO: fix
+    mc = files.getMasterComment()
+    if mc:
+      self.win.addnstr(0,w-len(mc)-1,files.getMasterComment(),w-len(mc)-1)
     self.win.attron(COLOR_TITLE | curses.A_BOLD)
     self.win.addstr(1,1,'Name'.center(self.sep1-1))
     self.win.addstr(1,self.sep1+2,'Size')
@@ -215,27 +176,31 @@ class Pane:
     # and display the file_pan
     if self.cursor == None:
       self.cursor = 0
+      if self.start_file:  # if the command line had a file, find it and highlight it....once
+        for i,f in enumerate(files):
+          if f.getDisplayName() == self.start_file:
+            self.cursor = i
+      self.start_file = None
     self.focus_line()
 
   def fill_line(self,y):
     #logging.info(f"about to add {self.w-2} spaces at {y} to the file_pad size: {self.file_pad.getmaxyx()}")
-    # TODO: why do we have to have one extra line in the pad?
     f = files[y]  
     self.file_pad.addstr(y,0,' ' * (self.w-2))
-    self.file_pad.addnstr(y,0,f.getFileName(),self.sep1-1)
-    self.file_pad.addstr(y,self.sep1,makeSize(f.size))
-    self.file_pad.addstr(y,self.sep2,makeDate(f.date))
-
-    dbComment = f.getDbComment()
-    xattrComment = f.getXattrComment()
-    comment = xattrComment if mode=="xattr" else dbComment
-    if dbComment != xattrComment:
+    self.file_pad.addnstr(y,0,f.getDisplayName(),self.sep1-1)
+    self.file_pad.addstr(y,self.sep1,UiHelper.getShortSize(f))
+    self.file_pad.addstr(y,self.sep2,UiHelper.getShortDate(f.date))
+
+    comment = f.getComment() or ''
+    other   = f.getOtherComment() or ''
+    logging.info(f"file_line, comments are <{comment}> and <{other}>  differ_flag:{self.some_comments_differ}")
+    if comment == other:
+      self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
+    else:
       self.some_comments_differ = True
       self.file_pad.attron(COLOR_HELP)
-      self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
+      self.file_pad.addnstr(y,self.sep3,comment or '       ',self.w-self.sep3-2)
       self.file_pad.attroff(COLOR_HELP)
-    else:
-      self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
 
     self.file_pad.vline(y,self.sep1-1,curses.ACS_VLINE,1)
     self.file_pad.vline(y,self.sep2-1,curses.ACS_VLINE,1)
@@ -280,76 +245,47 @@ class Pane:
     self.statusbar.addnstr(" " + data,w-x-1)
     self.statusbar.refresh()
 
-# two helpers to format the date & size visuals
-def makeDate(when):
-  ''' arg when is epoch seconds in localtime '''
-  diff = now - when
-  if diff > YEAR:
-    fmt = "%b %e  %Y"
-  else:
-    fmt = "%b %d %H:%M"
-  return time.strftime(fmt, time.localtime(when))
-
-def makeSize(size):
-  if size == FileObj.FILE_IS_DIR:
-    return " <DIR> "
-  elif size == FileObj.FILE_IS_LINK:
-    return " <LINK>"
-  log = int((math.log10(size+1)-2)/3)
-  s = " KMGTE"[log]
-  base = int(size/math.pow(10,log*3))
-  return f"{base}{s}".strip().rjust(7)
-  
-
 ## to hold the FileObj collection
 
 class Files():
-  def __init__(self,directory):
-    self.directory = FileObj(directory)
+  def __init__(self,directory,db):
+    self.db = db
 
-    self.files = []
-    if directory != '/':
-      self.files.append(FileObj(directory + "/.."))
-    # TODO: switch to os.scandir()
-    f = os.listdir(directory)
-    for i in f:
-      self.files.append(FileObj(directory + '/' + i))
-    self.sort()
+    if not os.path.isdir(directory):
+      errorBox(f"the command line argument: {directory} is not a directory; starting in the current directory")
+      directory = '.'
+    self.directory = FileObj(directory,self.db)
 
-    self.db = None
     try:
-      self.db = sqlite3.connect(database_name)
-      c = self.db.cursor()
-      c.execute("select * from dirnotes")
-    except sqlite3.OperationalError:
-      # TODO: problem with database....create one?
-      c.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
+      current, dirs, non_dirs = next(os.walk(directory))
+    except:
+      errorBox(f"{directory} is not a valid directory")
+      raise
+    if current != '/':
+      dirs.insert(0,"..")
 
-    try:
-      c = self.db.cursor()
-      self.directory.loadDbComment(c)
-      for f in self.files:
-        f.loadDbComment(c)
-    except sqlite3.OperationalError:
-      errorBox("serial problem with the database")
+    self.files = []
+    for f in dirs + non_dirs:
+      self.files.append(FileObj(os.path.join(current,f),self.db))
+    self.sort()
 
 
   def sortName(a):
     ''' when sorting by name put the .. and other <dir> entries first '''
-    if a.getFileName() == '..':
+    if a.getDisplayName() == '../':
       return "\x00"
     if a.isDir():
-      return ' ' + a.getFileName()
+      return ' ' + a.getDisplayName()
     # else:
-    return a.getFileName()
+    return a.getDisplayName()
 
   def sortDate(a):
-    if a.getFileName() == '..':
+    if a.getDisplayName() == '../':
       return 0
     return a.getDate()
 
   def sortSize(a):
-    if a.getFileName() == '..':
+    if a.getDisplayName() == '../':
       return 0
     return a.getSize()
 
@@ -374,129 +310,236 @@ class Files():
     return self.files.__iter__()
       
 def errorBox(string):
-  werr = curses.newwin(3,len(string)+8,5,5)
-  werr.bkgd(' ',COLOR_ERROR)
-  werr.clear()
-  werr.border()
-  werr.addstr(1,1,string)
-  werr.getch()  # any key
-  del werr
+  if curses_running:
+    werr = curses.newwin(3,len(string)+8,5,5)
+    werr.bkgd(' ',COLOR_ERROR)
+    werr.clear()
+    werr.box()
+    werr.addstr(1,1,string)
+    werr.timeout(3000)
+    werr.getch()  # any key
+    del werr
+  else:
+    print(string)
+    time.sleep(3)
   
+############# the dnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
 
-## one for each file
-## a special one called .. exists for the parent
-class FileObj():
-  FILE_IS_DIR = -1
-  FILE_IS_LINK = -2
-  def __init__(self, fileName):
-    self.fileName = os.path.realpath(fileName)
-    self.displayName = '..' if fileName.endswith('/..') else os.path.split(fileName)[1] 
-    s = os.lstat(fileName)
-    self.date = s.st_mtime
-    if stat.S_ISDIR(s.st_mode):
-      self.size = FileObj.FILE_IS_DIR
-    elif stat.S_ISLNK(s.st_mode):
-      self.size = FileObj.FILE_IS_LINK
-    else:
-      self.size = s.st_size
-    self.xattrComment = ''
-    self.xattrAuthor = None
-    self.xattrDate = None
-    self.dbComment = ''
-    self.dbAuthor = None
-    self.dbDate = None
-    self.commentsDiffer = False
+DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
+
+# config
+#    we could store the config in the database, in a second table
+#    or in a .json file
+DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
+  "database":"~/.dirnotes.db",
+  "start_mode":"xattr",
+  "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
+  "options for start_mode":("db","xattr")
+}
+
+class ConfigLoader:    # singleton
+  def __init__(self, configFile):
+    configFile = os.path.expanduser(configFile)
     try:
-      self.xattrComment = os.getxattr(fileName, xattr_comment, follow_symlinks=False).decode()
-      self.xattrAuthor  = os.getxattr(fileName, xattr_author, follow_symlinks=False).decode()
-      self.xattrDate    = os.getxattr(fileName, xattr_date, follow_symlinks=False).decode()
-      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
-    except:  # no xattr comment
-      pass
+      with open(configFile,"r") as f:
+        config = json.load(f)
+    except json.JSONDecodeError:
+      errorBox(f"problem reading config file {configFile}; check the JSON syntax")
+      config = DEFAULT_CONFIG
+    except FileNotFoundError:
+      errorBox(f"config file {configFile} not found; using the default settings")
+      config = DEFAULT_CONFIG
+      try:
+        with open(configFile,"w") as f:
+          json.dump(config,f,indent=4)
+      except:
+        errorBox(f"problem creating the config file {configFile}")
+    self.dbName = os.path.expanduser(config["database"])
+    self.mode = config["start_mode"]    # can get over-ruled by the command line options
+    self.xattr_comment = config["xattr_tag"]
+
+class DnDataBase:
+  ''' the database is flat
+    fileName: fully qualified name
+    st_mtime: a float
+    size: a long
+    comment: a string
+    comment_time: a float, the time of the comment save
+    author: the username that created the comment
+
+    this object: 1) finds or creates the database
+      2) determine if it's readonly
+
+    TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/) 
+    TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
+      make it 0666 permissions (rw-rw-rw-)
+  '''
+  def __init__(self,dbFile):
+    '''try to open the database; if not found, create it'''
+    try:
+      self.db = sqlite3.connect(dbFile)
+    except sqlite3.OperationalError:
+      logging.error(f"Database {dbFile} not found")
+      raise
+ 
+    # create new database if it doesn't exist
+    try:
+      self.db.execute("select * from dirnotes")
+    except sqlite3.OperationalError:
+      print_v(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)") 
+      # at this point, if a shared database is required, somebody needs to set perms to 0o666
+  
+    self.writable = True
+    try:
+      self.db.execute("pragma user_verson=0")
+    except sqlite3.OperationalError:
+      self.writable = False
+
+class UiHelper:
+  @staticmethod
+  def epochToDb(epoch):
+    return time.strftime(DATE_FORMAT,time.localtime(epoch))
+  @staticmethod
+  def DbToEpoch(dbTime):
+    return time.mktime(time.strptime(dbTime,DATE_FORMAT))
+  @staticmethod
+  def getShortDate(longDate):
+    now = time.time()
+    diff = now - longDate
+    if diff > YEAR:
+      fmt = "%b %e %Y"
+    else:
+      fmt = "%b %e %H:%M"
+    return time.strftime(fmt, time.localtime(longDate))
+  @staticmethod
+  def getShortSize(fo):
+    if fo.isDir():
+      return " <DIR> "
+    elif fo.isLink():
+      return " <LINK>"
+    size = fo.getSize()
+    log = int((math.log10(size+1)-2)/3)
+    s = " KMGTE"[log]
+    base = int(size/math.pow(10,log*3))
+    return f"{base}{s}".strip().rjust(7)
+
+
+## one for each file
+## and a special one for ".." parent directory
+class FileObj:
+  """  The FileObj knows about both kinds of comments. """
+  def __init__(self, fileName, db):
+    self.fileName = os.path.abspath(fileName)     # full path; dirs end WITHOUT a terminal /
+    self.stat = os.lstat(self.fileName)
+    self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
+    if self.isDir():
+      if not self.displayName.endswith('/'):
+        self.displayName += '/'
+    self.date = self.stat.st_mtime
+    self.size = self.stat.st_size
+    self.db = db
 
   def getName(self):
     return self.fileName
-  def getFileName(self):
+  def getDisplayName(self):
     return self.displayName
 
-  # with an already open database cursor
-  def loadDbComment(self,c):
-    c.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
-    a = c.fetchone()
-    if a:
-      self.dbComment, self.dbAuthor, self.dbDate = a
-      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
-  
+  # new methods; no caching
+  def getDbData(self):
+    if not hasattr(self,'dbCommentAuthorDate'):
+      cad = self.db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(self.fileName,)).fetchone()
+      self.dbCommentAuthorDate = cad if cad else (None, None, None)
+    return self.dbCommentAuthorDate
   def getDbComment(self):
-    return self.dbComment
-  def getDbAuthor(self):
-    return self.dbAuthor
-  def getDbDate(self):
-    return self.dbDate
+    return self.getDbData()[0]
+
+  def getXattrData(self):
+    if not hasattr(self,'xattrCommentAuthorDate'):
+      c = a = d = None
+      try:
+        c = os.getxattr(self.fileName, xattr_comment, follow_symlinks=False).decode()
+        a = os.getxattr(self.fileName, xattr_author, follow_symlinks=False).decode()
+        d = os.getxattr(self.fileName, xattr_date, follow_symlinks=False).decode()
+      except:  # no xattr comment
+        pass
+      self.xattrCommentAuthorDate = c,a,d
+    return self.xattrCommentAuthorDate
+  def getXattrComment(self):
+    return self.getXattrData()[0]
+
   def setDbComment(self,newComment):
-    try:
-      self.db = sqlite3.connect(database_name)
-    except sqlite3.OperationalError:
-      logging.info(f"database {database_name} not found")
-      raise OperationalError
-    c = self.db.cursor()
+    # how are we going to hook this?
+    #if not self.db.writable:
+    #  errorBox("The database is readonly; you cannot add or edit comments")
+    #  return
     s = os.lstat(self.fileName)
     try:
-      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, 
+      print_v(f"setDbComment db {self.db}, file: {self.fileName}")
+      self.db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
+          (self.fileName, s.st_mtime, s.st_size,
           str(newComment), time.time(), getpass.getuser()))
       self.db.commit()
-      logging.info(f"database write for {self.fileName}")
-      self.dbComment = newComment
+      self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
     except sqlite3.OperationalError:
-      logging.info("database is locked or unwritable")
+      print_v("database is locked or unwritable")
       errorBox("the database that stores comments is locked or unwritable")
-      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
-    
-  def getXattrComment(self):
-    return self.xattrComment
-  def getXattrAuthor(self):
-    return self.xattrAuthor
-  def getXattrDate(self):
-    # TODO: make sure it's a string, not an int
-    logging.info(f"someone accessed date on {self.fileName} {self.xattrDate}")
-    return self.xattrDate
+
   def setXattrComment(self,newComment):
-    logging.info(f"set comment {newComment} on file {self.fileName}")
+    print_v(f"set comment {newComment} on file {self.fileName}")
     try:
       os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
       os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
       os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
-      self.xattrAuthor = getpass.getuser()
-      self.xattrDate = time.strftime(DATE_FORMAT)      # alternatively, re-instantiate this FileObj
-      self.xattrComment = newComment
-      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
+      self.xattrCommentAuthorDate = newComment, getpass.getuser(), time.strftime(DATE_FORMAT) 
       return True
     # we need to move these cases out to a handler 
     except Exception as e:
-      errorBox("problem setting the comment on file %s" % self.getName())
-      errorBox("error "+repr(e))
-      ## todo: elif file.is_sym() the kernel won't allow comments on symlinks....stored in database
-      if self.size == FileObj.FILE_IS_LINK:
-        errorBox("Linux does not allow comments on symlinks; comment is stored in database")
+      if self.isLink():
+        errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
+      elif self.isSock():
+        errorBox("Linux does not allow comments on sockets; comment is stored in database")
       elif os.access(self.fileName, os.W_OK)!=True:
         errorBox("you don't appear to have write permissions on this file")
         # change the listbox background to yellow
-        self.displayBox.notifyUnchanged()               
       elif "Errno 95" in str(e):
         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
       return False
 
   def getComment(self):
-    return self.getDbComment() if mode == "db" else self.getXattrComment()
+    return self.getDbComment() if mode == "db"    else self.getXattrComment()
   def getOtherComment(self):
     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
+  def getData(self):
+    return self.getDbData()    if mode == "db"    else self.getXattrData()
+  def getOtherData(self):
+    return self.getDbData()    if mode == "xattr" else self.getXattrData()
+
   def getDate(self):
     return self.date
   def getSize(self):
     return self.size
   def isDir(self):
-    return self.size == self.FILE_IS_DIR
+    return stat.S_ISDIR(self.stat.st_mode)
+  def isLink(self):
+    return stat.S_ISLNK(self.stat.st_mode)
+  def isSock(self):
+    return stat.S_ISSOCK(self.stat.st_mode)
+
+  def copyFile(self, destDir):
+    # NOTE: this method copies the xattr (comment + old author + old date) 
+    #       but creates new db (comment + this author + new date)
+    if stat.S_ISREG(self.stat.st_mode):
+      dest = os.path.join(destDir,self.displayName)
+      try:
+        print("try copy from",self.fileName,"to",dest)
+        shutil.copy2(self.fileName, dest)
+      except:
+        errorBox(f"file copy to <{dest}> failed; check permissions")
+      f = FileObj(dest, self.db)
+      print("dest object created",repr(f))
+      f.setDbComment(self.getDbComment())
 
 ##########  dest folder picker ###############
 # returns None if the user hits <esc>
@@ -513,7 +556,7 @@ class showFolderPicker:
     self.h, self.w = self.W.getmaxyx()
     self.W.keypad(True)
     #self.W.clear()
-    self.W.border()
+    self.W.box()
     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()
@@ -595,7 +638,7 @@ def paint_dialog(b_color,data):
   w = curses.newwin(len(lines)+2,n+3,5,5)
   w.bkgd(' ',b_color)
   w.clear()
-  w.border()
+  w.box()
   for i,d in enumerate(lines):
     w.addnstr(i+1,1,d,n)
   #w.refresh I don't know why this isn't needed :(
@@ -675,8 +718,6 @@ def show_help2():
   c = w.getch()
   del w
 
-#TODO: fix this to paint_dialog
-#TODO: fix to allow upper/lower case responses
 sort_string = """
 Select sort order: 
  
@@ -703,16 +744,8 @@ Comments detail:
 def show_detail(f):
   global mode
   h = paint_dialog(COLOR_HELP,detail_string)
-  if mode=="xattr":
-    h.addstr(1,20,"from xattrs")
-    c = f.getXattrComment()
-    a = f.getXattrAuthor()
-    d = time.ctime(f.getXattrDate())
-  else:
-    h.addstr(1,20,"from database")
-    c = f.getDbComment()
-    a = f.getDbAuthor()
-    d = f.getDbDate()
+  c,a,d = f.getData()   # get all three, depending on the current mode
+  h.addstr(1,20,"from xattrs" if mode=="xattr" else "from database")
   h.addnstr(2,12,c,h.getmaxyx()[1]-13)
   h.addstr(3,12,a if a else "<not set>")
   h.addstr(4,12,d if d else "<not set>")
@@ -733,7 +766,7 @@ def edit_fn(c):
     return 7
   return c
 
-def main(w, cwd):
+def main(w, cwd, database_file, start_file):
   global files, edit_done, mode
   global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
 
@@ -752,10 +785,12 @@ def main(w, cwd):
   COLOR_DIFFER = curses.color_pair(CP_DIFFER)
   logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
 
-  files = Files(cwd)
+  db = DnDataBase(database_file).db
+
+  files = Files(cwd,db)
   logging.info(f"got files, len={len(files)}")
 
-  mywin = Pane(w,cwd,files)
+  mywin = Pane(w,cwd,files,start_file = start_file)
     
   showing_edit = False
 
@@ -807,7 +842,7 @@ def main(w, cwd):
 
     elif c == CMD_RELOAD:
       where = files.getCurDir().fileName
-      files = Files(where)
+      files = Files(where,db)
       mywin = Pane(w,where,files)
 
     elif c == CMD_CD:
@@ -815,22 +850,22 @@ def main(w, cwd):
       if f.isDir():
         cwd = f.getName()
         logging.info(f"CD change to {cwd}")
-        files = Files(cwd)
+        files = Files(cwd,db)
         mywin = Pane(w,cwd,files)
         # TODO: should this simply re-fill() the existing Pane instead of destroy?
 
     elif c == CMD_EDIT:
       showing_edit = True
       edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
-      edit_window.border()
+      edit_window.box()
       edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
       he,we = edit_window.getmaxyx()
       edit_sub = edit_window.derwin(3,we-2,1,1)
       
       f = files[mywin.cursor]
-      mywin.setStatus(f"Edit file: {f.getFileName()}")
+      mywin.setStatus(f"Edit file: {f.getName()}")
       existing_comment = f.getXattrComment()
-      edit_sub.addstr(0,0,existing_comment) 
+      edit_sub.addstr(0,0,existing_comment or '') 
       text = curses.textpad.Textbox(edit_sub)
       edit_window.refresh()
 
@@ -854,30 +889,28 @@ def main(w, cwd):
     elif c == CMD_CMNT_CP:
       # copy comments to the other mode
       cp_cmnt_ask = curses.newwin(6,40,5,5)
-      cp_cmnt_ask.border()
+      cp_cmnt_ask.box()
       cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
       cp_cmnt_ask.addstr(1,22,"database" if mode=="xattr" else "xattr")
-      cp_cmnt_ask.addstr(2,1,"1  just this file")
-      cp_cmnt_ask.addstr(3,1,"a  all files with comments")
+      cp_cmnt_ask.addstr(2,1," 1  just this file")
+      cp_cmnt_ask.addstr(3,1," a  all files with comments")
       cp_cmnt_ask.addstr(4,1,"esc to cancel")
       cp_cmnt_ask.refresh()
        
       c = cp_cmnt_ask.getch()
-      # esc
-      if c!=ord('1') and c!=ord('a') and c!=ord('A'):
-        continue
-      # copy comments for one file or all
-      if c==ord('1'):
-        collection = [files[mywin.cursor]]
-      else:
-        collection = files
-      for f in collection:
-        if mode=="xattr":
-          if f.getXattrComment():
-            f.setDbComment(f.getXattrComment())
+      if c in (ord('1'), ord('a'), ord('A')):
+        # copy comments for one file or all
+        if c==ord('1'):
+          collection = [files[mywin.cursor]]
         else:
-          if f.getDbComment():
-            f.setXattrComment(f.getDbComment())
+          collection = files
+        for f in collection:
+          if mode=="xattr":
+            if f.getXattrComment():
+              f.setDbComment(f.getXattrComment())
+          else:
+            if f.getDbComment():
+              f.setXattrComment(f.getDbComment())
       mywin.refill()
       mywin.refresh()
 
@@ -896,7 +929,7 @@ def main(w, cwd):
         logging.info(f"copy from {src} to {dest_dir}")
         shutil.copy2(src, dest_dir)
         # and copy the database record
-        f = FileObj(dest)
+        f = FileObj(dest,files.db) 
         f.setDbComment(files[mywin.cursor].getDbComment())
       mywin.refresh() 
 
@@ -915,9 +948,9 @@ def main(w, cwd):
         logging.info(f"move from {src} to {dest_dir}")
         shutil.move(src, dest_dir)
         # and copy the database record
-        f = FileObj(dest)
+        f = FileObj(dest,files.db)
         f.setDbComment(files[mywin.cursor].getDbComment())  
-        files = Files(cwd)
+        files = Files(cwd,db)
         mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
 
     elif c == curses.KEY_RESIZE:
@@ -932,49 +965,32 @@ def pre_main():
   parser = argparse.ArgumentParser(description="Add comments to files")
   parser.add_argument('-c','--config',  dest='config_file', help="config file (json format)")
   parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
-  parser.add_argument('directory', type=str, default='.', nargs='?',  help="directory to start")
+  parser.add_argument('directory', type=str, default='.', nargs='?',  help="directory or file to start")
   args = parser.parse_args()
   logging.info(args)
 
-  if args.config_file:
-    config_file = args.config_file
-  else:
-    config_file = DEFAULT_CONFIG_FILE
-  config_file = os.path.expanduser(config_file)
-
-  config = DEFAULT_CONFIG
-  try:
-    with open(config_file,"r") as f:
-      config = json.load(f)
-  except json.JSONDecodeError:
-    print(f"problem reading the config file {config_file}")
-    printf("please check the .json syntax")
-    time.sleep(2)
-  except FileNotFoundError:
-    print(f"config file {config_file} not found, using default settings & creating a default")
-    try:
-      with open(config_file,"w") as f:
-        json.dump(config,f,indent=4)
-    except:
-      print(f"problem creating file {config_file}")
-    time.sleep(2)
+  config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
 
   # print(repr(config))
   # print("start_mode",config["start_mode"])
   
   return args, config
 
+curses_running = False
 args, config = pre_main()
 
-mode = config["start_mode"]
-xattr_comment = config["xattr_tag"]
-xattr_author  = config["xattr_tag"] + ".author"
-xattr_date    = config["xattr_tag"] + ".date"
-# change ~ tilde to username
-database_name = os.path.expanduser(config["database"])
-cwd = args.directory
+mode = config.mode
+xattr_comment = config.xattr_comment
+xattr_author  = config.xattr_comment + ".author"
+xattr_date    = config.xattr_comment + ".date"
+database_name = config.dbName
+if os.path.isdir(args.directory):
+  cwd, start_file = args.directory, None
+else:
+  cwd, start_file = os.path.split(args.directory)
 
-curses.wrapper(main, cwd)
+curses_running = True
+curses.wrapper(main, cwd or '.', database_name, start_file)
 
 # dirnotes database is name, date, size, comment, comment_date, author