2 Commits 22fdfe93ab ... aad00e1b2b

Author SHA1 Message Date
  Pat Beirne aad00e1b2b c_dirnotes: added database creation 1 year ago
  Pat Beirne a33aa29cf2 -a 1 year ago
2 changed files with 861 additions and 4 deletions
  1. 851 0
      c_dirnotes.py
  2. 10 4
      dirnotes

+ 851 - 0
c_dirnotes.py

@@ -0,0 +1,851 @@
+#!/usr/bin/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: add file copy/move/del
+# TODO: add database access & preference
+# TODO: re-read date/author to xattr after an edit
+
+# scroll
+# up/down - change focus, at limit: move 1 line,
+# pgup/down - move by (visible_range - 1), leave focus on the remaining element
+# home/end - top/bottom, focus on first/last
+
+# three main classes:
+#   Pane: smart curses window cluster: main, status & scrolling pad
+#   FileObj: a file with its xattr-comment and db-comment data
+#   Files: a collection of FileObjs, sortable
+
+import os, time, stat, sys, shutil
+
+import time, math
+import curses, sqlite3, curses.textpad
+import logging, getpass
+
+VERSION = "1.4"
+# these may be different on MacOS
+XATTR_COMMENT = "user.xdg.comment"
+XATTR_AUTHOR = "user.xdg.comment.author"
+XATTR_DATE = "user.xdg.comment.date"
+COMMENT_OWNER = os.getlogin()
+
+# convert the ~/ form to a fully qualified path
+DATABASE_NAME = "~/.dirnotes.db"
+DATABASE_NAME = os.path.expanduser(DATABASE_NAME)   # doesn't deref symlinks
+
+MODE_DATABASE = 0
+MODE_XATTR = 1
+mode_names = ["<Database mode> ","<Xattr mode>"]
+mode = MODE_XATTR
+
+### commands
+CMD_COPY   = ord('c')  # open dialog to copy-with-comment
+CMD_DETAIL = ord('d')  # open dialog
+CMD_EDIT   = ord('e')  # open dialog for typing & <esc> or <enter>
+CMD_HELP   = ord('h')  # open dialog
+CMD_MOVE   = ord('m')  # open dialog to move-with-comment
+CMD_QUIT   = ord('q')
+CMD_RELOAD = ord('r')  # reload
+CMD_SORT   = ord('s')  # open dialog for N,S,D,C
+CMD_CMNT_CP= ord('C')  # open dialog to copy comments accept 1 or a or <esc>
+CMD_MODE   = ord('M')  # switch between xattr and database mode
+CMD_ESC    = 27
+CMD_CD     = ord('\n')
+
+# other options will be stored in database at ~/.dirnotes.db or /etc/dirnotes.db
+#   - option to use MacOSX xattr labels
+#  
+
+# at first launch (neither database is found), give the user a choice of
+# ~/.dirnotes.db or /var/lib/dirnotes.db
+# at usage time, check for ~/.dirnotes.db first
+
+# there should be a copy db -> xattr (one file or all-in-dir)
+#   and copy xattr -> db (one file or all-in-dir)
+
+# file copy/move will copy the comments IN BOTH DB AND XATTR
+
+# file comments will ALWAYS be written to both xattrs & database
+#   access failure is shown once per directory
+
+### colors
+
+CP_TITLE  = 1
+CP_BODY   = 2
+CP_FOCUS  = 3
+CP_ERROR  = 4
+CP_HELP   = 5
+CP_DIFFER = 6
+COLOR_DIFFER = COLOR_TITLE = COLOR_BODY = COLOR_FOCUS = COLOR_ERROR = COLOR_HELP = None
+
+COLOR_THEME = ''' { "heading": ("yellow","blue"),
+  "body":("white","blue"),
+  "focus":("black","cyan") }
+'''
+
+now = time.time()
+YEAR = 3600*24*365
+
+class Pane:
+  ''' holds the whole display: handles file list directly,
+      fills a child pad with the file info,
+        draws scroll bar
+        defers the status line to a child 
+        draws a border
+      line format: filename=30%, size=7, date=12, comment=rest
+      line 1=current directory + border
+      line 2...h-4 = filename
+      line h-3 = border
+      line h-2 = status
+      line h-1 = border
+      column 0, sep1, sep2, sep3 and w-1 are borders w.r.t. pad
+      filename starts in column 1 (border in 0)
+
+      most methods take y=0..h-1 where y is the line number WITHIN the borders
+  '''
+  def __init__(self, win, curdir, files):
+    self.curdir = curdir
+    self.cursor = None
+    self.first_visible = 0
+    self.nFiles = len(files)
+    
+    self.h, self.w = win.getmaxyx()
+    
+    self.main_win = win                               # whole screen
+    self.win = win.subwin(self.h-1,self.w,0,0)        # upper window, for border
+    self.statusbar = win.subwin(1,self.w,self.h-1,0)  # status at the bottom
+    self.pad_height = max(self.nFiles,self.h-4)
+    self.file_pad = curses.newpad(self.pad_height,self.w)
+    self.file_pad.keypad(True)
+
+    self.win.bkgdset(' ',curses.color_pair(CP_BODY))
+    self.statusbar.bkgdset(' ',curses.color_pair(CP_BODY))
+    self.file_pad.bkgdset(' ',curses.color_pair(CP_BODY))
+    self.resize()
+
+    logging.info("made the pane")
+
+  def resize(self):   # and refill
+    logging.info("got to resize")
+    self.h, self.w = self.main_win.getmaxyx()
+    self.sep1 = self.w // 3
+    self.sep2 = self.sep1 + 8
+    self.sep3 = self.sep2 + 13
+    self.win.resize(self.h-1,self.w)
+    self.statusbar.resize(1,self.w)
+    self.statusbar.mvwin(self.h-1,0)
+    self.pad_height = max(len(files),self.h-4)
+    self.pad_visible = self.h-4
+    self.file_pad.resize(self.pad_height+1,self.w-2)
+    self.refill()
+    self.refresh()
+
+  def refresh(self):
+    self.win.refresh()
+ 
+    if self.some_comments_differ:
+      self.setStatus("The xattr and database comments differ where shown in green")
+    #  time.sleep(2)
+
+    self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
+ 
+  def refill(self):
+    self.win.bkgdset(' ',curses.color_pair(CP_BODY))
+    self.win.erase()
+    self.win.border()  # TODO: or .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
+    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')
+    self.win.addstr(1,self.sep2+4,'Date')
+    self.win.addstr(1,self.sep3+2,'Comments')
+    self.win.attroff(COLOR_BODY)
+
+    self.some_comments_differ = False
+    # now fill the file_pad
+    for i,f in enumerate(files):
+      self.fill_line(i)    # fill the file_pad
+    if self.nFiles < self.pad_height:
+      for i in range(self.nFiles, self.pad_height):
+        self.file_pad.addstr(i,0,' ' * (self.w-2))
+
+    # and display the file_pan
+    if self.cursor == None:
+      self.cursor = 0
+    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==MODE_XATTR else dbComment
+    if dbComment != xattrComment:
+      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.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)
+    self.file_pad.vline(y,self.sep3-1,curses.ACS_VLINE,1)
+
+  def unfocus_line(self):
+    self.fill_line(self.cursor)
+  def focus_line(self):
+    self.file_pad.attron(COLOR_FOCUS)
+    self.fill_line(self.cursor)
+    self.file_pad.attroff(COLOR_FOCUS)
+
+  def line_move(self,direction):
+    # try a move first
+    new_cursor = self.cursor + direction
+    if new_cursor < 0:
+      new_cursor = 0
+    if new_cursor >= self.nFiles:
+      new_cursor = self.nFiles - 1
+    if new_cursor == self.cursor:
+      return
+    # then adjust the window
+    if new_cursor < self.first_visible:
+      self.first_visible = new_cursor
+      self.file_pad.redrawwin()
+    if new_cursor >= self.first_visible + self.pad_visible - 1:
+      self.first_visible = new_cursor - self.pad_visible + 1
+      self.file_pad.redrawwin()
+    self.unfocus_line()
+    self.cursor = new_cursor
+    self.focus_line()
+    self.file_pad.move(self.cursor,0)   # just move the flashing cursor
+    self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
+    
+  def setStatus(self,data):
+    h,w = self.statusbar.getmaxyx()
+    self.statusbar.clear()
+    self.statusbar.attron(curses.A_REVERSE)
+    self.statusbar.addstr(0,0,mode_names[mode])
+    self.statusbar.attroff(curses.A_REVERSE)
+    y,x = self.statusbar.getyx()
+    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)
+
+    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()
+
+    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)")
+
+    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")
+
+
+  def sortName(a):
+    if a.getFileName() == '..':
+      return "\x00"
+    return a.getFileName()
+
+  def sortDate(a):
+    if a.getFileName() == '..':
+      return 0
+    return a.getDate()
+
+  def sortSize(a):
+    if a.getFileName() == '..':
+      return 0
+    return a.getSize()
+
+  def getCurDir(self):
+    return self.directory
+  def getMasterComment(self):
+    return self.directory.xattrComment if mode==MODE_XATTR else self.directory.dbComment
+
+  sort_mode = sortName
+  def sort(self):
+    self.files.sort(key = Files.sort_mode)
+
+  ## accessors ##
+  def __len__(self):
+    return len(self.files)
+  def __getitem__(self, i):
+    return self.files[i]
+  def __iter__(self):
+    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
+  
+
+## 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
+    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 = float(os.getxattr(fileName, XATTR_DATE, follow_symlinks=False).decode())
+      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
+    except:  # no xattr comment
+      pass
+
+  def getName(self):
+    return self.fileName
+  def getFileName(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
+  
+  def getDbComment(self):
+    return self.dbComment
+  def getDbAuthor(self):
+    return self.dbAuthor
+  def getDbDate(self):
+    return self.dbDate
+  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()
+    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, 
+          str(newComment), time.time(), getpass.getuser()))
+      self.db.commit()
+      logging.info(f"database write for {self.fileName}")
+      self.dbComment = newComment
+    except sqlite3.OperationalError:
+      logging.info("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):
+    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}")
+    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(str(time.time()),'utf8'),follow_symlinks=False)
+      self.xattrAuthor = getpass.getuser()
+      self.xattrDate = time.time()      # alternatively, re-instantiate this FileObj
+      self.xattrComment = newComment
+      self.commentsDiffer = True if self.xattrComment == self.dbComment else False
+      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")
+      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 getDate(self):
+   return self.date
+  def getSize(self):
+    return self.size
+  def isDir(self):
+    return self.size == self.FILE_IS_DIR
+
+##########  dest folder picker ###############
+# returns None if the user hits <esc>
+class showFolderPicker:
+  def __init__(self,starting_dir,title):
+    self.W = curses.newwin(20,60,5,5)
+    self.W.bkgd(' ',COLOR_HELP)
+    self.W.keypad(True)
+    self.title = title
+    self.starting_dir = starting_dir
+    self.cwd = starting_dir
+    self.fill()
+    self.selected = None
+
+    indialog = True
+    selected = ''
+    while indialog:
+      c = self.W.getch()
+      y,x = self.W.getyx()
+      if c == curses.KEY_UP:
+        if y>1: self.W.move(y-1,1)
+      elif c == curses.KEY_DOWN:
+        if y<len(self.fs)+1: self.W.move(y+1,1)
+      elif c == CMD_CD:
+        # cd to new dir and refill
+        if y==1 and self.fs[0].startswith('<'):    # current dir
+          self.selected = self.cwd
+          indialog = False
+        else:
+          self.cwd = self.cwd + '/' + self.fs[y-1]
+          self.cwd = os.path.realpath(self.cwd)
+          #logging.info(f"change dir to {self.cwd}")
+          self.fill()
+      elif c == CMD_ESC:
+        indialog = False
+    del self.W
+
+  def value(self):
+    #logging.info(f"dir picker returns {self.selected}")
+    return self.selected
+
+  def fill(self):
+    h, w = self.W.getmaxyx()
+    self.W.clear()
+    self.W.border()
+    self.W.addnstr(0,1,self.title,w-2)
+    self.W.addstr(h-1,1,"<Enter> to select or change dir, <esc> to exit")
+    self.fs = os.listdir(self.cwd)
+    self.fs = [a for a in self.fs if os.path.isdir(a)]
+    self.fs.sort()
+    if self.cwd != '/':
+      self.fs.insert(0,"..")
+    if self.cwd != self.starting_dir:
+      self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
+    for i,f in enumerate(self.fs):
+      self.W.addnstr(i+1,1,f,w-2)
+    self.W.move(1,1)
+
+
+########### comment management code #################
+
+# paint a dialog window with a border and contents
+#  discard the 1st line, use the next line to set the width
+def paint_dialog(b_color,data):
+  lines = data.split('\n')[1:]
+  n = len(lines[0])
+  w = curses.newwin(len(lines)+2,n+3,5,5)
+  w.bkgd(' ',b_color)
+  w.clear()
+  w.border()
+  for i,d in enumerate(lines):
+    w.addnstr(i+1,1,d,n)
+  #w.refresh I don't know why this isn't needed :(
+  return w
+
+help_string = """
+Dirnotes   add descriptions to files  
+           uses xattrs and a database
+           version %s
+ h   help window (h1/h2 for more help)
+ e   edit file description
+ d   see file+comment details
+ s   sort
+ q   quit
+ M   switch between xattr & database
+ C   copy comment between modes
+ p   preferences/settings [not impl]
+ c   copy file
+ m   move file
+<enter> to enter directory""" % (VERSION,)
+def show_help():
+  w = paint_dialog(COLOR_HELP,help_string)
+  c = w.getch()
+  del w
+  if c==ord('1'):
+    show_help1()
+  if c==ord('2'):
+    show_help2()
+
+help1_string = """
+Dirnotes stores its comments in the xattr property of files 
+where it can, and in a database.
+
+XATTR
+=====
+The xattr comments are attached to the 'user.xdg.comment' 
+property.  If you copy/move/tar the file, there are often 
+options to move the xattrs with the file. 
+
+The xattr comments don't always work. For example, you may 
+not have write permission on a file. Or you may be using 
+an exFat/fuse filesystem that doesn't support xattr. You 
+cannot add xattr comments to symlinks.
+
+DATABASE
+========
+The database isvstored at ~/.dirnotes.db using sqlite3.
+The comments are indexed by the realpath(filename), which 
+may change if you use external drives and use varying 
+mountpoints.
+
+These comments will not move with a file unless you use the
+move/copy commands inside this program.
+
+The database allows you to add comments to files you don't 
+own, or which are read-only.
+
+When the comments in the two systems differ, the comment is
+highlighted in green. The 'M' command lets you view either
+xattr or database comments. The 'C' command allows you to 
+copy comments between xattr and database."""
+def show_help1():
+  w = paint_dialog(COLOR_HELP,help1_string)
+  c = w.getch()
+  del w
+
+help2_string = """
+The comments are also stored with the date-of-the-comment and
+the username of the comment's author. The 'd' key will 
+display that info.
+
+Optionally, the database can be stored at 
+  /var/lib/dirnotes/dirnotes.db 
+which allows access to all users (not implimented)"""
+def show_help2():
+  w = paint_dialog(COLOR_HELP,help2_string)
+  c = w.getch()
+  del w
+
+#TODO: fix this to paint_dialog
+sort_string = """
+Select sort order: 
+ 
+  Name
+  Date
+  Size
+  Comment"""
+def show_sort():
+  h = paint_dialog(COLOR_HELP,sort_string)
+  h.attron(COLOR_TITLE)
+  h.addstr(3,3,"N") or h.addstr(4,3,"D") or h.addstr(5,3,"S") or h.addstr(6,3,"C")
+  h.attroff(COLOR_TITLE)
+  h.refresh()
+  c = h.getch()
+  del h
+  return c
+
+detail_string = """
+Comments detail:                                          
+  Comment: 
+  Author: 
+  Date:  """
+
+def show_detail(f):
+  global mode
+  h = paint_dialog(COLOR_HELP,detail_string)
+  if mode==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()
+  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>")
+  h.refresh()
+  c = h.getch()
+  del h
+  return c
+
+
+## used by the comment editor to pick up <ENTER> and <ESC>
+edit_done = False
+def edit_fn(c):
+  global edit_done
+  if c==ord('\n'):
+    edit_done = True
+    return 7
+  if c==27:
+    return 7
+  return c
+
+def main(w):
+  global files, edit_done, mode
+  global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
+  logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
+  logging.info("starting curses dirnotes")
+  curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
+  curses.init_pair(CP_BODY,  curses.COLOR_WHITE,curses.COLOR_BLUE)
+  curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
+  curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
+  curses.init_pair(CP_HELP,  curses.COLOR_WHITE,curses.COLOR_CYAN)
+  curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
+
+  COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
+  COLOR_BODY  = curses.color_pair(CP_BODY)
+  COLOR_FOCUS = curses.color_pair(CP_FOCUS)
+  COLOR_ERROR = curses.color_pair(CP_ERROR)
+  COLOR_HELP  = curses.color_pair(CP_HELP)  
+  COLOR_DIFFER = curses.color_pair(CP_DIFFER)
+  logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
+
+  if len(sys.argv) > 1:
+    cwd = sys.argv[1]
+  else:
+    cwd = os.getcwd()
+  files = Files(cwd)
+  logging.info(f"got files, len={len(files)}")
+
+  mywin = Pane(w,cwd,files)
+    
+  showing_edit = False
+
+  while True:
+    c = mywin.file_pad.getch(mywin.cursor,1)
+    
+    if c == CMD_QUIT or c == CMD_ESC:
+      break
+
+    elif c == CMD_HELP:
+      show_help()
+      mywin.refresh()
+
+    elif c == CMD_SORT:
+      c = show_sort()
+      if c == ord('s'):
+        Files.sort_mode = Files.sortSize
+      elif c == ord('n'):
+        Files.sort_mode = Files.sortName
+      elif c == ord('d'):
+        Files.sort_mode = Files.sortDate
+      files.sort()
+      mywin.refill()
+      mywin.refresh()
+
+    elif c == curses.KEY_UP:
+      mywin.line_move(-1)
+    elif c == curses.KEY_DOWN:
+      mywin.line_move(1)
+    elif c == curses.KEY_PPAGE:
+      mywin.line_move(-mywin.pad_visible+1)
+    elif c == curses.KEY_NPAGE:
+      mywin.line_move(mywin.pad_visible-1)
+    elif c == curses.KEY_HOME:
+      mywin.line_move(-len(files)+1)
+    elif c == curses.KEY_END:
+      mywin.line_move(len(files)-1)
+
+    elif c == CMD_DETAIL:
+      show_detail(files[mywin.cursor])
+      mywin.refresh()
+
+    elif c == CMD_MODE:
+      mode = MODE_DATABASE if mode==MODE_XATTR else MODE_XATTR
+      mywin.refill()
+      mywin.refresh()
+
+    elif c == CMD_RELOAD:
+      where = files.getCurDir().fileName
+      files = Files(where)
+      mywin = Pane(w,where,files)
+
+    elif c == CMD_CD:
+      f = files[mywin.cursor]
+      if f.isDir():
+        cwd = f.getName()
+        logging.info(f"CD change to {cwd}")
+        files = Files(cwd)
+        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.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()}")
+      existing_comment = f.getXattrComment()
+      edit_sub.addstr(0,0,existing_comment) 
+      text = curses.textpad.Textbox(edit_sub)
+      edit_window.refresh()
+
+      comment = text.edit(edit_fn).strip()  
+
+      logging.info(f"comment: {comment} and flag-ok {edit_done}")
+      if edit_done:
+        comment = comment.replace('\n',' ')
+        logging.info(f"got a new comment as '{comment}'")
+        edit_done = False
+        f.setXattrComment(comment)
+        f.setDbComment(comment)
+        logging.info(f"set file {f.fileName} with comment <{comment}>")
+        mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
+      del text, edit_sub, edit_window
+      mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
+      mywin.statusbar.redrawwin()
+      mywin.focus_line()
+      mywin.refresh()
+
+    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.addstr(1,1,"Copy comments to ==> ")
+      cp_cmnt_ask.addstr(1,22,"database" if mode==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(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==MODE_XATTR:
+          if f.getXattrComment():
+            f.setDbComment(f.getXattrComment())
+        else:
+          if f.getDbComment():
+            f.setXattrComment(f.getDbComment())
+      mywin.refill()
+      mywin.refresh()
+
+    elif c == CMD_COPY:
+      if files[mywin.cursor].displayName == "..":
+        continue
+      if os.path.isdir(files[mywin.cursor].fileName):
+        errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Copy not allowed")
+        continue
+      dest_dir = showFolderPicker(cwd,"Select folder for copy").value()
+      if dest_dir:
+        #errorBox(f"copy cmd to {dest_dir}")
+        src = cwd + '/' + files[mywin.cursor].displayName
+        dest = dest_dir + '/' + files[mywin.cursor].displayName
+        # copy2 preserves dates & chmod/chown & xattr
+        logging.info(f"copy from {src} to {dest_dir}")
+        shutil.copy2(src, dest_dir)
+        # and copy the database record
+        f = FileObj(dest)
+        f.setDbComment(files[mywin.cursor].getDbComment())
+      mywin.refresh()
+
+    elif c == CMD_MOVE:
+      if files[mywin.cursor].displayName == "..":
+        continue
+      if os.path.isdir(files[mywin.cursor].fileName):
+        errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Move not allowed")
+        continue
+      dest_dir = showFolderPicker(cwd,"Select folder for move").value()
+      if dest_dir:
+        #errorBox(f"move cmd to {dest_dir}")      
+        src = cwd + '/' + files[mywin.cursor].displayName
+        dest = dest_dir + '/' + files[mywin.cursor].displayName
+        # move preserves dates & chmod/chown & xattr
+        logging.info(f"move from {src} to {dest_dir}")
+        shutil.move(src, dest_dir)
+        # and copy the database record
+        f = FileObj(dest)
+        f.setDbComment(files[mywin.cursor].getDbComment())
+        files = Files(cwd)
+        mywin = Pane(w,cwd,files)
+
+    elif c == curses.KEY_RESIZE:
+      mywin.resize()
+    #mywin.refresh()
+
+curses.wrapper(main)
+
+# dirnotes database is name, date, size, comment, comment_date, author

+ 10 - 4
dirnotes

@@ -6,7 +6,6 @@ comments are stored in an SQLite3 database
 	default ~/.dirnotes.db
 	default ~/.dirnotes.db
 where possible, comments are duplicated in 
 where possible, comments are duplicated in 
 	xattr user.xdg.comment
 	xattr user.xdg.comment
-	depends on python-pyxattr
 
 
 	some file systems don't allow xattr, and even linux
 	some file systems don't allow xattr, and even linux
 	doesn't allow xattr on symlinks, so the database is
 	doesn't allow xattr on symlinks, so the database is
@@ -48,7 +47,7 @@ import sys,os,argparse,stat
 from PyQt5.QtGui import *
 from PyQt5.QtGui import *
 from PyQt5.QtWidgets import *
 from PyQt5.QtWidgets import *
 from PyQt5.QtCore import Qt
 from PyQt5.QtCore import Qt
-import xattr, sqlite3, time
+import sqlite3, time
 
 
 VERSION = "0.2"
 VERSION = "0.2"
 COMMENT_KEY = "user.xdg.comment"
 COMMENT_KEY = "user.xdg.comment"
@@ -159,7 +158,7 @@ class FileObj():
 			self.size = s.st_size
 			self.size = s.st_size
 		self.xattrComment = ''
 		self.xattrComment = ''
 		try:
 		try:
-			self.xattrComment = xattr.get(fileName,COMMENT_KEY,nofollow=True).decode()
+			self.xattrComment = os.getxattr(fileName,COMMENT_KEY,follow_symlinks=False).decode()
 		except Exception as e:
 		except Exception as e:
 			#print("comment read on %s failed, execption %s" % (self.fileName,e)) 
 			#print("comment read on %s failed, execption %s" % (self.fileName,e)) 
 			pass
 			pass
@@ -174,7 +173,7 @@ class FileObj():
 	def setXattrComment(self,newComment):
 	def setXattrComment(self,newComment):
 		self.xattrComment = newComment
 		self.xattrComment = newComment
 		try:
 		try:
-			xattr.set(self.fileName,COMMENT_KEY,self.xattrComment,nofollow=True)
+			os.setxattr(self.fileName,COMMENT_KEY,bytes(self.xattrComment,'utf8'),follow_symlinks=False)
 			return True
 			return True
 		# we need to move these cases out to a handler 
 		# we need to move these cases out to a handler 
 		except Exception as e:
 		except Exception as e:
@@ -498,6 +497,13 @@ user.xdg.publisher
 
 
 ''' TODO: also need a way to display-&-restore comments from the database '''
 ''' TODO: also need a way to display-&-restore comments from the database '''
 
 
+''' TODO: implement startup -s and -m for size and date '''
+
+''' TODO: add an icon for the app '''
+
+''' TODO: create 'show comment history' popup '''
+
+''' TODO: add dual-pane for file-move, file-copy '''
 	
 	
 ''' commandline xattr
 ''' commandline xattr
 getfattr -h (don't follow symlink) -d (dump all properties)
 getfattr -h (don't follow symlink) -d (dump all properties)