4 Commits f086eba491 ... cf650c44ee

Author SHA1 Message Date
  Pat Beirne cf650c44ee rename apps, add man page for dirnotes-cli 1 year ago
  Pat Beirne 0833acd067 add dncli to the family 1 year ago
  Pat Beirne c0397934e2 added config file in .json format 1 year ago
  Pat Beirne afd8b1c151 moved dirWidget function into dirnotes, makes for easier install 1 year ago
6 changed files with 643 additions and 202 deletions
  1. 0 82
      cleanup.py
  2. 0 60
      dirWidget.py
  3. 94 2
      dirnotes
  4. 205 0
      dirnotes-cli
  5. 186 0
      dirnotes-cli.md
  6. 158 58
      dirnotes-tui

+ 0 - 82
cleanup.py

@@ -1,82 +0,0 @@
-#!/usr/bin/python3
-
-""" Maintenance up the database used by dirnotes and c_dirnotes
-- view the history of file comments
-- delete the history of file comments
-"""
-
-import sqlite3, os, sys
-import logging
-VERSION = "0.1"
-
-dbname = "~/.dirnotes.db"
-dbname = os.path.expanduser(dbname)
-
-def main(summary, view, delete):
-  try:
-    d = sqlite3.connect(dbname)
-  except sqlite3.OperationalError:
-    print("database not found, or permission-locked; cleanup aborted")
-    return
-  c = d.cursor()
-  if summary:
-    c = d.cursor()
-    total,distinct = c.execute("select count(name),count(distinct name) from dirnotes;").fetchone()
-    print(f"this database has entries for {distinct} files, and {total-distinct} history entries")
-
-  if view or delete:
-    c.execute("select distinct name from dirnotes;")
-    for f in c:
-      # note, f is a tuple of (name,)
-      c1 = d.cursor()
-      if view:
-        history = c1.execute("select comment_date,author,comment from dirnotes where name=? order by comment_date desc",f).fetchall()
-        if len(history)>1:
-          print(f"filename: {f[0]} has a history of comments:")
-          first = True
-          for lin in history:
-            print(f"{'->' if first else '  '}  date: {lin[0]} author: {lin[1]}\n    comment: {lin[2]}")
-            first = False
-
-      if delete:
-        most_recent = c1.execute("select comment_date from dirnotes where name=? order by comment_date desc",f).fetchone()[0]
-        logging.info(f"working on file: {f} with most-recent-date of {most_recent}")
-        c1.execute("delete from dirnotes where name=? and comment_date <> ?",(f[0],most_recent))
-
-  if delete:
-    total,distinct = c.execute("select count(name),count(distinct name) from dirnotes;").fetchone()
-    print(f"this database has been cleaned to {distinct} files, and {total-distinct} history entries")
-    d.commit()
-  d.close()
-
-
-view = False
-delete = False
-summary = True  # default
-
-if __name__ == "__main__":
-  logging.basicConfig(filename="/tmp/dirnotes_clear.log", level=logging.DEBUG)
-  logging.info("starting the db mainenance tool")
-  if len(sys.argv)>1:
-    if "-h" in sys.argv or "--help" in sys.argv:
-      print(f"Usage: {sys.argv[0]}    maintenance for the database used by dirnotes & c_dirnotes")
-      print("      -d     delete comment history")
-      print("      -s     view comment summary")
-      print("      -v     view comment history")
-      sys.exit(1)
-    if "-v" in sys.argv:
-      view = True
-    if "-s" in sys.argv:
-      summary = True
-    if "-d" in sys.argv:
-      delete = True
-  if delete:
-    print("Cleanup of the dirnotes database located at ~/.dirnotes.db")
-    c = input("Proceed? (Y/N)")
-    if c in ("yY"):
-      main(summary, view, delete)
-    else:
-      print("nothing done")
-  else:
-    main(summary, view, delete)
-

+ 0 - 60
dirWidget.py

@@ -1,60 +0,0 @@
-#!/usr/bin/python
-''' a widget that shows only a dir listing
- '''
-
-import sys,os,argparse
-from PyQt4.QtGui import *
-from PyQt4 import QtGui, QtCore
-
-class DirWidget(QListWidget):
-	''' a simple widget that shows a list of directories, staring
-	at the directory passed into the constructor
-
-	a mouse click or 'enter' key will send a 'selected' signal to
-	anyone who connects to this.
-
-	the .. parent directory is shown for all dirs except /
-
-	the most interesting parts of the interface are:
-	constructor - send in the directory to view
-	method - currentPath() returns text of current path
-	signal - selected calls a slot with a single arg: new path
-	'''
-
-	selected = QtCore.pyqtSignal(str)
-	def __init__(self, directory='.', parent=None):
-		super(DirWidget,self).__init__(parent)
-		self.directory = directory
-		self.refill()
-		self.itemActivated.connect(self.selectionByLWI)
-		# it would be nice to pick up single-mouse-click for selection as well
-		# but that seems to be a system-preferences global
-	def selectionByLWI(self, li):
-		self.directory = os.path.abspath(self.directory + '/' + str(li.text()))
-		self.refill()
-		self.selected.emit(self.directory)
-	def refill(self):
-		current,dirs,files =  os.walk(self.directory,followlinks=True).next()
-		dirs.sort()
-		if '/' not in dirs:
-			dirs = ['..'] + dirs
-		self.clear()
-		for d in dirs:
-			li = QListWidgetItem(d,self)
-	def currentPath(self):
-		return self.directory
-		
-if __name__=="__main__":
-	def alert(some_text):
-		os.system('notify-send -t 1500 "file: '+str(some_text) + '"')
-
-	a = QApplication([])
-
-	qmw = QMainWindow()
-	dw = DirWidget(parent=qmw)
-	dw.selected.connect(alert)
-	
-	qmw.setCentralWidget(dw)
-	qmw.show()
-	
-	a.exec_()

+ 94 - 2
dirnotes

@@ -1,4 +1,6 @@
 #!/usr/bin/python3
+# TODO: add author to access
+# TODO: convert dates to strings
 """ a simple gui or command line app
 to view and create/edit file comments
 
@@ -46,10 +48,10 @@ import sys,os,argparse,stat
 #~ from dirWidget import DirWidget
 from PyQt5.QtGui import *
 from PyQt5.QtWidgets import *
-from PyQt5.QtCore import Qt
+from PyQt5.QtCore import Qt, pyqtSignal
 import sqlite3, time
 
-VERSION = "0.2"
+VERSION = "0.3"
 COMMENT_KEY = "user.xdg.comment"
 DATABASE_NAME = "~/.dirnotes.db"
 # convert the ~/ form to a fully qualified path
@@ -218,6 +220,94 @@ class HelpWidget(QDialog):
 		self.pb.pressed.connect(self.close)
 		self.show()
 
+def loadWindowIcon():  # TODO: change border/folder to dark grey, no shadow, smaller
+  icon = ["32 32 6 1",
+"   c None",
+".  c #666666",
+"+  c #FFFFFF",
+"@  c #848484",
+"#  c #000000",
+"$  c #FCE883",
+"                                ",
+"  ........                      ",
+" .++++++++.                     ",
+" .+++++++++..................   ",
+" .+++++++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+......++@@@@@@@@@@@@@@@@@",
+" .++..++++++++#################@",
+" .+++++++++++#$$$$$$$$$$$$$$$$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..+++++++#$$$$$$$$$$$$$$$$$#",
+" .+++++++++++#$$#############$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..+++++++#$$########$$$$$$$#",
+" .+++++++++++#$$$$$$$$$$$$$$$$$#",
+" .++..+.....+#$$$$$$$$$$$$$$$$$#",
+" .++..++++++++#######$$$####### ",
+" .++++++++++++++++++#$$#++++++  ",
+" .++..+............+#$#++++++.  ",
+" .++..++++++++++++++##+++++++.  ",
+" .++++++++++++++++++#++++++++.  ",
+" .++..+............++++++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+................++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+" .++..+................++++++.  ",
+" .++..+++++++++++++++++++++++.  ",
+" .+++++++++++++++++++++++++++.  ",
+"  ...........................   ",
+"                                "]
+  return QPixmap(icon)
+
+''' a widget that shows only a dir listing
+ '''
+
+#import sys,os,argparse
+#from PyQt4.QtGui import *
+#from PyQt4 import QtGui, QtCore
+
+class DirWidget(QListWidget):
+	''' a simple widget that shows a list of directories, staring
+	at the directory passed into the constructor
+
+	a mouse click or 'enter' key will send a 'selected' signal to
+	anyone who connects to this.
+
+	the .. parent directory is shown for all dirs except /
+
+	the most interesting parts of the interface are:
+	constructor - send in the directory to view
+	method - currentPath() returns text of current path
+	signal - selected calls a slot with a single arg: new path
+	'''
+
+	selected = pyqtSignal(str)
+	def __init__(self, directory='.', parent=None):
+		super(DirWidget,self).__init__(parent)
+		self.directory = directory
+		self.refill()
+		self.itemActivated.connect(self.selectionByLWI)
+		# it would be nice to pick up single-mouse-click for selection as well
+		# but that seems to be a system-preferences global
+	def selectionByLWI(self, li):
+		self.directory = os.path.abspath(self.directory + '/' + str(li.text()))
+		self.refill()
+		self.selected.emit(self.directory)
+	def refill(self):
+		current,dirs,files =  os.walk(self.directory,followlinks=True).next()
+		dirs.sort()
+		if '/' not in dirs:
+			dirs = ['..'] + dirs
+		self.clear()
+		for d in dirs:
+			li = QListWidgetItem(d,self)
+	def currentPath(self):
+		return self.directory
+		
+
 # sortable TableWidgetItem, based on idea by Aledsandar
 # http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
 # NOTE: the QTableWidgetItem has setData() and data() which may allow data bonding
@@ -317,6 +407,8 @@ class DirNotes(QMainWindow):
 		#~ QShortcut(QKeySequence("Ctrl+Q"), self, self.close)	
 		self.setWindowTitle("DirNotes   Alt-F for menu  Dir: "+self.curPath)
 		self.setMinimumSize(600,700)
+		wi = loadWindowIcon()
+		self.setWindowIcon(QIcon(wi))
 		lb.setFocus()
 	def closeEvent(self,e):
 		print("closing")

+ 205 - 0
dirnotes-cli

@@ -0,0 +1,205 @@
+#!/usr/bin/python3
+# TODO: get rid of the -n option; multiple files are prefixed by 'filename:', single files aren't
+
+VERSION = "0.3"
+
+import os, sys, argparse, xattr, json, sqlite3
+
+answer_json = []
+verbose = 0
+db = None
+xattr_comment = "user.xdg.comment"
+xattr_author  = "user.xdg.comment.author"
+xattr_date    = "user.xdg.comment.date"
+
+#======= debugging/verbose ===========
+def print_d(*a):
+  if verbose > 1:
+    print('>>',*a)
+def print_v(*a):
+  if verbose:
+    print('>',*a)
+
+#============= the functions that are called from the main.loop ===============
+
+def file_copy(f,target,target_is_dir,force):
+  print_d(f"call file_copy with args={target},{target_is_dir} and {force}")
+  dest = target if not target_is_dir else target+'/'+os.path.basename(f)
+  if os.path.exists(dest) and not force:
+    go = input("The copy target <<" + dest + ">> exists. Overwrite? (y or n) ")
+    if go != 'y' and go != 'Y':
+      return
+  print_d(f"copy from {f} to {dest}")
+
+def file_zap(f,all_flag):
+  print_d(f"zapping the comment history of {f}")
+  if all_flag:
+    print_d("zapping the entire database")
+
+def file_modify_comment(f, create, append, erase):
+  print_d(f"modify the comment on file {f} with extra={(create,append,erase)}")
+  if not os.path.exists(f):
+    print(f"the target file does not exist; please check the spelling of the file: {f}")
+    # sys.exit() here?
+
+def file_display(f, listall, history, json, minimal):
+  print_d(f"list file details {f}")
+  x_comment = None
+  try:
+    x_comment = xattr.getxattr(f,xattr_comment).decode()
+    x_author  = xattr.getxattr(f,xattr_author).decode()
+    x_date    = xattr.getxattr(f,xattr_date).decode()
+  except:
+    pass
+  full_f = os.path.realpath(f)
+
+  d_comment = getDbComment(full_f)
+  if d_comment:
+    d_comment, d_author, d_date = d_comment
+  print_d(f"for file {f}, database comment is <{d_comment}>, xattr comment is <{x_comment}>")
+
+  if os.path.isdir(f):
+    f = f+'/'
+  if x_comment or listall:
+    if x_comment and (d_comment != x_comment):
+      x_comment += '*'
+    if not json:
+      if minimal:
+        print(f"{x_comment}")
+      else:
+        print(f"{f}: {x_comment}")
+    else:
+      if verbose:
+        answer_json.append( {"file":f,"comment":x_comment,"author":x_author,"date":x_date } )
+      else:
+        answer_json.append( {"file":f,"comment":x_comment} )
+
+def getDbComment(full_filename):
+  global db
+  print_d(f"db access for {full_filename}")
+  c = db.cursor()
+  c.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(full_filename,))
+  a = c.fetchone()
+  if a:
+    return a[0:3]
+
+def openDb():
+  global db
+  dbName = "~/.dirnotes.db"
+  dbName = os.path.expanduser(dbName)
+  db = sqlite3.connect(dbName)
+  try:
+    c = db.cursor()
+    c.execute("select * from dirnotes")
+  except sqlite3.OperationalError:
+    c.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
+  return db 
+
+def main(args):
+  parser = argparse.ArgumentParser(description="Display or add comments to files",
+    epilog="Some options conflict. Use only one of: -l -c -a -H -e -z -Z and one of -d -x")
+  parser.add_argument('-V',"--version", action="version",   version=f"dncli ver:{VERSION}")
+  parser.add_argument('-v',"--verbose", action='count',     help="verbose, almost debugging; do not use in scripts",default=0)
+  parser.add_argument('-j',"--json",    action="store_true",help="output in JSON format")
+  pars_m = parser.add_mutually_exclusive_group()
+  pars_m.add_argument('-l',"--listall", action="store_true",help="list all files, including those without comments")
+  parser.add_argument('-d',"--db",      action="store_true",help="list comments from database")
+  parser.add_argument('-x',"--xattr",   action="store_true",help="list comments from xattr")
+  parser.add_argument('-n',"--minimal", action="store_true",help="output only comments; useful in scripting")
+  parser.add_argument('-H',"--history", action="store_true",help="output the history of comments for a file")
+  pars_m.add_argument('-c',"--create",  metavar="comment",  help="add a comment to a file")
+  pars_m.add_argument('-a',"--append",  metavar="comment",  help="append to a comment on a file, separator=';'")
+  pars_m.add_argument('-C',"--copy",    action="store_true",help="copy a file with its comments")
+  parser.add_argument('-y',"--cp_force",action="store_true",help="copy over existing files")
+  pars_m.add_argument('-e',"--erase",   action="store_true",help="erase the comment on a file")
+  pars_m.add_argument('-z',"--zap",     action="store_true",help="clear the comment history on a file")
+  pars_m.add_argument('-Z',"--zapall",   action="store_true",help="clear the comment history in the entire database")
+  parser.add_argument('file_list',nargs='*',help="file(s); list commands may omit this")
+  args = parser.parse_args()
+
+  # default is to display all files that have comments
+
+  # major modes are: display (<none> -l -H), add-comment (-a -c -e), clear-history(-z -Z), copy (-C)
+  # determine the major mode, then apply an appropriate function over the file_list
+  
+  args.display = not (args.create or args.append or args.copy or args.erase or args.zap or args.zapall)
+  if args.cp_force and not args.copy:
+    print("the -y/--cp_force options can only be used with the -C/--copy command")
+    sys.exit(3)   
+  if args.json and not args.display:
+    print("the -j/--json option can only be used with the display modes")
+    sys.exit(4)
+  if args.minimal and not args.display:
+    print("the -n/--minimal option only applies to the display modes")
+    sys.exit(5)
+  if args.history and not args.display:
+    print("the -H/--history option only applies to the display modes")
+    sys.exit(5)
+  if args.xattr and (args.zap or args.zapall):
+    print("the -x/--xattr option doesn't apply to the -z/--zap and -Z/--zapall commands")
+    sys.exit(7)
+
+  global verbose
+  verbose = args.verbose
+  
+  #====== 1) build the file list =============
+
+  files = args.file_list
+  # for the list commands, auto-fill the file list with the current directory
+  if not files and args.display:
+    files = os.listdir(".")
+    files.sort()
+  # other command require explicity file lists, but 'dncli -c "new comment" *' will work
+  if not files:
+    print("please specify a file or files to use")
+    sys.exit(10)
+  print_d("got the files:", files)
+
+  #======= 2) build the function
+  if args.create or args.append or args.erase:
+    print_d(f"create/append/erase: {args.create} . {args.append} . {args.erase}")
+    func = file_modify_comment
+    loop_args = (args.create, args.append, args.erase)
+  elif args.zap or args.zapall:
+    print_d(f"got a zap command {args.zap} . {args.zapall}")
+    func = file_zap
+    loop_args = (args.zapall,)
+    if args.zapall:
+      files = (1,)
+  elif args.copy:
+    print_d(f"got a copy command to {args.copy}")
+    # the last item on the file list is the target
+    n_files = len(files)
+    if n_files < 2:
+      print_d(f"the copy command requires at least two arguments, the last one is the destination")
+      sys.exit(1)
+    target = files[-1]
+    target_is_directory = os.path.isdir(target)
+    files = files[:-1]
+    print_d(f"copy from {files} to {target}")
+    if n_files > 2 and not target_is_directory:
+      print_d(f"multiple copy files must go to a target directory")
+      sys.exit(3)
+    func = file_copy
+    loop_args = (target, target_is_directory, args.cp_force)
+  else:
+    assert(args.display)
+    print_v(f"list files using {'database' if args.db else 'xattr'} priority")
+    print_d(f"display command with option: {args.listall} and {args.history} and {args.json} and {args.minimal}")
+    loop_args = (args.listall, args.history, args.json, args.minimal)
+    func = file_display
+
+  #====== 3) loop on the list, execute the function =============
+  global db
+  db = openDb()
+
+  for f in files:
+    func(f,*loop_args)
+
+  if answer_json:
+    print(json.dumps(answer_json))
+
+if __name__ == "__main__":
+  main(sys.argv)
+
+

+ 186 - 0
dirnotes-cli.md

@@ -0,0 +1,186 @@
+---
+title: dirnotes-cli
+section: 1
+header: User Manual
+footer: dirnotes-cli 1.0
+date: Sept 26, 2022
+---
+
+# NAME
+
+dirnotes-cli - view and manage Dirnotes file annotations
+
+# SYNOPSIS
+
+<!--- the indent below is using unicode hard space u00a0, and space at EOL  --->   
+    **dirnotes-cli** [-h] [--help] [-V] [--version]   
+
+  List:   
+    **dirnotes-cli** [-v] [-j] [-l] [-d|-x]  
+    **dirnotes-cli** [-v] [-j] [-n] [-d|-x] [filename]...   
+    **dirnotes-cli** [-v] [-j] -H [filename]...  
+  
+  Create:  
+    **dirnotes-cli** -c "comment" filename...  
+    **dirnotes-cli** -a "comment" filename...  
+    **dirnotes-cli** -e filename...
+
+  FileCopy:  
+    **dirnotes-cli** -C src-file... <dest-file | dest-dir>  
+    **dirnotes-cli** -M src-file... <dest-file | dest-dir>
+
+  Cleanup:  
+    **dirnotes-cli** -z [filename]...   
+    **dirnotes-cli** -Z
+
+# DESCRIPTION
+
+The **dirnotes** family of apps allows you to add a descriptive comment to 
+a file. The descriptions are stored in two places:
+
+  * in the xattr properties of the file
+  * in a database located in the user's home directory
+
+
+The *list* commands will display the comment from the database or xattr (as
+determined by the config file, below, or as specified with **-d**/**-x** options). If the database and xattr comments differ, the
+comment will be terminated by a '\*' character. The _-H_ option displays the history 
+of comments from the database.
+
+The output of the *list* commands can be in .json format (_-j_) , and can optionally 
+display the comment creator and the comment date (_-v_)
+
+The *create* commands will attempt to store the file comments in both the xattr of the file,
+and in the database. If either of these fail, they fail silently. Use the _-c_ to create a comment, use _-a_ to append to an existing comment, and _-e_ to erase a comment.
+
+The *filecopy* commands will copy the file to a destination, and preserve the file
+comments. [See notes below about LIMITATIONS]
+
+The *cleanup* commands can clean up the history of comments in the database.
+
+# LIMITATIONS
+
+The file comments are located in two locations: a database, and in the xattr properties
+of the file. Each of these storage locations has its own benefits and limitations:
+
+## xattr
+
+Comments stored in the xattr properties can be copied/moved with the file, if you
+use the correct options for _cp_. The _mv_ utility automatically preserves _xattr_.
+Other programs can also be coerced into perserving _xattr_ properties:
+
+* rsync
+* tar
+* mksquashfs
+
+Not all file systems support xattr properties (vfat/exfat does not). 
+
+The current implementation of _sshfs_ and _scp_ do not support the copy of _xattr_ properties. 
+If you are using _sshfs_, you can use the 'dirnotes' programs to copy a file, and the database comments will work properly.
+If you want to copy files to a remote machine and include the _xattr_ comments, use _rsync_ with the _-X_ option. Or _tar_ of course.
+
+Some editing apps (like _vim_) will create a new file when saving the data, which orphans the _xattr_ comments. For these apps, use the _database_ system.
+
+
+## database
+
+Comments stored in the database work for all filesystem types (including vfat/exfat/sshfs)
+Comments are personalized to the _current user_. 
+Another user on the same system will not see these comments.
+
+Files are indexed by their complete path name. Removable filesystems should be
+mounted in a consistent way, so that the complete path name is reproducable.
+
+Comments stored in the database do not travel with the files when
+ they are moved or copied, unless using the dirnotes-family of tools.
+
+
+# OPTIONS
+  
+**-c**
+: add a comment to a file
+
+**-C**
+: attempt to copy the file(s) and comments to a destination; if multiple files are copied, the destination must be a directory
+
+**-d**
+: use database comments as the primary source; cannot be used with **-x**
+
+**-e**
+: erase the comments on a file
+
+**-h** **--help**
+: display help and usage
+
+**-H**
+: output the history of comments for this file(s)
+
+**-i**  
+: add a comment to a file; if the comment is not in the command, accept it from stdin
+
+**-j**
+: output (to stdio) the file comment in .json format 
+
+**-l**
+: list all files, including those without _dirnotes_ comments
+
+**-M**
+: move the file(s) and comments to the destination; if multiple files are moved, the destination must be a directory
+
+**-n**
+: output only the comment; this may be useful for scripting
+
+**-v**
+: also include the comment author and comment date in the output
+
+**-V** **--version**
+: display version number
+
+**-x**
+: use xattr comments as the primary source; connot be used with **-d**
+
+**-z**
+: erase history comments associated with this file; keep the current comment; if no file is specified, erase the history comments for all files in the current directory
+
+**-Z**
+: erase all the historic comments in the user's database
+
+# EXAMPLES
+
+To display the comment for a file:
+
+> $ dirnotes-cli filename.txt  
+> filename.txt: notes on the car repair
+
+To extract _only_ the comment from a file, use the _-n_ option (useful for scripts):
+
+> $ dirnotes-cli -n filename.txt  
+> notes on the car repair
+
+Or, in json format:
+
+> $ dirnotes-cli -j filename.txt  
+> [{"file": "filename.txt", "comment": "notes on the car repair"}]
+
+
+
+# SEE ALSO
+
+dirnotes, dirnotes-tui, dirnotes-cli
+
+The **dirnotes** program provides a GUI for accessing the files descriptions.
+
+The **dirnotes-tui** program provides a **curses** based (terminal) access to the descriptions.
+
+The **dirnotes-cli** program provides command line access, and can be scripted.
+
+# CONFIGURATION FILE
+
+By default, the file **~/.dirnotes.conf** will be used to load the user's config. There are
+three attributes described in that file that are important:
+
+> * xattr_tag  
+> * database  
+> * start_mode
+
+

+ 158 - 58
c_dirnotes.py → dirnotes-tui

@@ -1,11 +1,10 @@
-#!/usr/bin/python3 
+#!/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: add file copy/move/del
-# TODO: add database access & preference
 # TODO: re-read date/author to xattr after an edit
+# TODO: consider adding h,j,k,l movement
 
 # scroll
 # up/down - change focus, at limit: move 1 line,
@@ -21,38 +20,40 @@ import os, time, stat, sys, shutil
 
 import time, math
 import curses, sqlite3, curses.textpad
-import logging, getpass
+import logging, getpass, argparse
+import json
 
-VERSION = "1.4"
+VERSION = "1.7"
 # 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()
+xattr_comment = "user.xdg.comment"
+xattr_author  = "user.xdg.comment.author"
+xattr_date    = "user.xdg.comment.date"
+DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 
 # convert the ~/ form to a fully qualified path
-DATABASE_NAME = "~/.dirnotes.db"
-DATABASE_NAME = os.path.expanduser(DATABASE_NAME)   # doesn't deref symlinks
+# database_name = "~/.dirnotes.db"
 
-MODE_DATABASE = 0
-MODE_XATTR = 1
-mode_names = ["<Database mode> ","<Xattr mode>"]
-mode = MODE_XATTR
+mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"}
+modes = ("db","xattr")
+mode = "db"
 
 ### 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_MODE   = ord('M')  # switch between xattr and database mode
 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')
 
+# file comments will ALWAYS be written to both xattrs & database
+#   access failure is shown once per directory
+
 # other options will be stored in database at ~/.dirnotes.db or /etc/dirnotes.db
 #   - option to use MacOSX xattr labels
 #  
@@ -61,13 +62,17 @@ CMD_CD     = ord('\n')
 # ~/.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)
+DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
 
-# 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
+# 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
 
@@ -87,6 +92,39 @@ 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
+
 class Pane:
   ''' holds the whole display: handles file list directly,
       fills a child pad with the file info,
@@ -189,7 +227,7 @@ class Pane:
 
     dbComment = f.getDbComment()
     xattrComment = f.getXattrComment()
-    comment = xattrComment if mode==MODE_XATTR else dbComment
+    comment = xattrComment if mode=="xattr" else dbComment
     if dbComment != xattrComment:
       self.some_comments_differ = True
       self.file_pad.attron(COLOR_HELP)
@@ -279,7 +317,7 @@ class Files():
 
     self.db = None
     try:
-      self.db = sqlite3.connect(DATABASE_NAME)
+      self.db = sqlite3.connect(database_name)
       c = self.db.cursor()
       c.execute("select * from dirnotes")
     except sqlite3.OperationalError:
@@ -296,8 +334,12 @@ class Files():
 
 
   def sortName(a):
+    ''' when sorting by name put the .. and other <dir> entries first '''
     if a.getFileName() == '..':
       return "\x00"
+    if a.isDir():
+      return ' ' + a.getFileName()
+    # else:
     return a.getFileName()
 
   def sortDate(a):
@@ -310,15 +352,18 @@ class Files():
       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
+  def sortComment(a):
+    return a.getComment()
 
   sort_mode = sortName
   def sort(self):
     self.files.sort(key = Files.sort_mode)
 
+  def getCurDir(self):
+    return self.directory
+  def getMasterComment(self):
+    return self.directory.getComment()
+
   ## accessors ##
   def __len__(self):
     return len(self.files)
@@ -361,9 +406,9 @@ class FileObj():
     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.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
@@ -389,9 +434,9 @@ class FileObj():
     return self.dbDate
   def setDbComment(self,newComment):
     try:
-      self.db = sqlite3.connect(DATABASE_NAME)
+      self.db = sqlite3.connect(database_name)
     except sqlite3.OperationalError:
-      logging.info(f"database {DATABASE_NAME} not found")
+      logging.info(f"database {database_name} not found")
       raise OperationalError
     c = self.db.cursor()
     s = os.lstat(self.fileName)
@@ -417,11 +462,11 @@ class FileObj():
   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)
+      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.time()      # alternatively, re-instantiate this FileObj
+      self.xattrDate = time.strftime(DATE_FORMAT)      # alternatively, re-instantiate this FileObj
       self.xattrComment = newComment
       self.commentsDiffer = True if self.xattrComment == self.dbComment else False
       return True
@@ -440,8 +485,12 @@ class FileObj():
         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()
+  def getOtherComment(self):
+    return self.getDbComment() if mode == "xattr" else self.getXattrComment()
   def getDate(self):
-   return self.date
+    return self.date
   def getSize(self):
     return self.size
   def isDir(self):
@@ -596,17 +645,18 @@ def show_help2():
   del w
 
 #TODO: fix this to paint_dialog
+#TODO: fix to allow upper/lower case responses
 sort_string = """
 Select sort order: 
  
-  Name
-  Date
-  Size
-  Comment"""
+  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.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()
@@ -622,7 +672,7 @@ Comments detail:
 def show_detail(f):
   global mode
   h = paint_dialog(COLOR_HELP,detail_string)
-  if mode==MODE_XATTR:
+  if mode=="xattr":
     h.addstr(1,20,"from xattrs")
     c = f.getXattrComment()
     a = f.getXattrAuthor()
@@ -652,11 +702,10 @@ def edit_fn(c):
     return 7
   return c
 
-def main(w):
+def main(w, cwd):
   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)
@@ -672,10 +721,6 @@ def main(w):
   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)}")
 
@@ -695,12 +740,14 @@ def main(w):
 
     elif c == CMD_SORT:
       c = show_sort()
-      if c == ord('s'):
+      if c == ord('s') or c == ord('S'):
         Files.sort_mode = Files.sortSize
-      elif c == ord('n'):
+      elif c == ord('n') or c == ord('N'):
         Files.sort_mode = Files.sortName
-      elif c == ord('d'):
+      elif c == ord('d') or c == ord('D'):
         Files.sort_mode = Files.sortDate
+      elif c == ord('c') or c == ord('C'):
+        Files.sort_mode = Files.sortComment
       files.sort()
       mywin.refill()
       mywin.refresh()
@@ -723,7 +770,7 @@ def main(w):
       mywin.refresh()
 
     elif c == CMD_MODE:
-      mode = MODE_DATABASE if mode==MODE_XATTR else MODE_XATTR
+      mode = "db" if mode=="xattr" else "xattr"
       mywin.refill()
       mywin.refresh()
 
@@ -778,7 +825,7 @@ def main(w):
       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(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(4,1,"esc to cancel")
@@ -794,7 +841,7 @@ def main(w):
       else:
         collection = files
       for f in collection:
-        if mode==MODE_XATTR:
+        if mode=="xattr":
           if f.getXattrComment():
             f.setDbComment(f.getXattrComment())
         else:
@@ -846,6 +893,59 @@ def main(w):
       mywin.resize()
     #mywin.refresh()
 
-curses.wrapper(main)
+def pre_main():
+  # done before we switch to curses mode
+  logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
+  logging.info("starting curses dirnotes")
+
+  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")
+  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)
+
+  # print(repr(config))
+  # print("start_mode",config["start_mode"])
+  
+  return args, config
+
+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
+
+curses.wrapper(main, cwd)
 
 # dirnotes database is name, date, size, comment, comment_date, author
+
+# symlinks: follow_symlinks should always be True, because symlinks in Linux
+#    can't have xattr....it appears to be the same in darwin