| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014 | 
							- #!/usr/bin/env python3 
 
- # TODO: write color scheme
 
- # TODO: re-read date/author to xattr after an edit
 
- # TODO: consider adding h,j,k,l movement
 
- # TODO: change move command to 'v', change mode to 'm', drop copy-comments
 
- # TODO: bug: enter db mode, type E to edit a comment, we get the xattr version!!
 
- # TODO: try to clear a comment, left with ' '
 
- # 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, argparse
 
- import json
 
- VERSION = "1.9"
 
- # these may be different on MacOS
 
- 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"
 
- 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_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
 
- #  
 
- # 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
 
- ### 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
 
- verbose = None
 
- def print_d(*a):
 
-   if verbose:
 
-     print(*a)
 
- 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, 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()
 
-     
 
-     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")
 
-     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.box() 
 
-     h,w = self.win.getmaxyx()
 
-     self.win.addnstr(0,3,os.path.realpath(self.curdir),w-4)
 
-     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')
 
-     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
 
-       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()}")
 
-     f = files[y]  
 
-     self.file_pad.addstr(y,0,' ' * (self.w-2))
 
-     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(mode) or ''
 
-     other   = f.getOtherComment(mode) 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 or '       ',self.w-self.sep3-2)
 
-       self.file_pad.attroff(COLOR_HELP)
 
-     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()
 
- ## to hold the FileObj collection
 
- class Files():
 
-   def __init__(self,directory,db):
 
-     self.db = db
 
-     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)
 
-     try:
 
-       current, dirs, non_dirs = next(os.walk(directory))
 
-     except:
 
-       errorBox(f"{directory} is not a valid directory")
 
-       raise
 
-     if current != '/':
 
-       dirs.insert(0,"..")
 
-     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.getDisplayName() == '../':
 
-       return "\x00"
 
-     if a.isDir():
 
-       return ' ' + a.getDisplayName()
 
-     # else:
 
-     return a.getDisplayName()
 
-   def sortDate(a):
 
-     if a.getDisplayName() == '../':
 
-       return 0
 
-     return a.getDate()
 
-   def sortSize(a):
 
-     if a.getDisplayName() == '../':
 
-       return -2
 
-     if a.isDir() or a.isLink() or a.isSock():
 
-       return -1
 
-     return a.getSize()
 
-   def sortComment(a):
 
-     return a.getComment(mode) or '~'
 
-   sortFunc = sortName
 
-   def sort(self):
 
-     self.files.sort(key = Files.sortFunc)
 
-   def getCurDir(self):
 
-     return self.directory
 
-   def getMasterComment(self):
 
-     return self.directory.getComment(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):
 
-   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)
 
-   
 
- # >>> snip here <<<
 
- #============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
 
- import getpass, time, stat, shutil
 
- DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
 
- # config
 
- #    we could store the config in the database, in a second table
 
- #    or in a .json file
 
- DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
 
-   "database":"~/.local/share/dirnotes/dirnotes.db",
 
-   "start_mode":"xattr",
 
-   "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
 
-   "options for start_mode":("db","xattr")
 
- }
 
- class ConfigLoader:    # singleton
 
-   def __init__(self, configFile):
 
-     configFile = os.path.expanduser(configFile)
 
-     try:
 
-       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:
 
-         os.makedirs(os.path.dirname(configFile),exist_ok = True)
 
-         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:
 
-       print_d(f"Database {dbFile} not found")
 
-       try: 
 
-         os.makedirs(os.path.dirname(dbFile), exist_ok = True)
 
-         self.db = sqlite3.connect(dbFile)
 
-       except (sqlite3.OperationalError, PermissionError):
 
-         printd(f"Failed to create {dbFile}, aborting")
 
-         raise
 
-     # create new table if it doesn't exist
 
-     try:
 
-       self.db.execute("select * from dirnotes")
 
-     except sqlite3.OperationalError:
 
-       self.db.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
 
-       self.db.execute("create index dirnotes_i on dirnotes(name)") 
 
-       print_d(f"Table dirnotes created")
 
-       # at this point, if a shared database is required, somebody needs to set perms to 0o666
 
-   
 
-     self.writable = True
 
-     try:
 
-       self.db.execute("pragma user_verson=0")
 
-     except sqlite3.OperationalError:
 
-       self.writable = False
 
- DATE_FORMAT   = "%Y-%m-%d %H:%M:%S"
 
- 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):
 
-     """ returns the absolute pathname """
 
-     return self.fileName
 
-   def getDisplayName(self):
 
-     """ returns just the basename of the file; dirs end in / """
 
-     return self.displayName
 
-   def getDbData(self):
 
-     """ returns (comment, author, comment_date) """
 
-     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.getDbData()[0]
 
-   def getXattrData(self):
 
-     """ returns (comment, author, comment_date) """
 
-     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):
 
-     # 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:
 
-       print_d(f"setDbComment db {self.db}, file: {self.fileName}")
 
-       self.db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
 
-           (self.fileName, s.st_mtime, s.st_size,
 
-           str(newComment), time.time(), getpass.getuser()))
 
-       self.db.commit()
 
-       self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
 
-     except sqlite3.OperationalError:
 
-       print_d("database is locked or unwritable")
 
-       errorBox("the database that stores comments is locked or unwritable")
 
-   def setXattrComment(self,newComment):
 
-     print_d(f"set comment {newComment} on file {self.fileName}")
 
-     try:
 
-       os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
 
-       os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
 
-       os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=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:
 
-       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(f"you don't appear to have write permissions on this file: {self.fileName}")
 
-         # change the listbox background to yellow
 
-       elif "Errno 95" in str(e):
 
-         errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
 
-       return False
 
-   def getComment(self,mode):
 
-     """ returns the comment for the given mode """
 
-     return self.getDbComment() if mode == "db"    else self.getXattrComment()
 
-   def getOtherComment(self,mode):
 
-     return self.getDbComment() if mode == "xattr" else self.getXattrComment()
 
-   def getData(self,mode):
 
-     """ returns (comment, author, comment_date) for the given mode """
 
-     return self.getDbData()    if mode == "db"    else self.getXattrData()
 
-   def getOtherData(self,mode):
 
-     """ returns (comment, author, comment_date) for the 'other' mode """
 
-     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 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, dest, doMove = False):
 
-     """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
 
-     # NOTE: this method copies the xattr (comment + old author + old date) 
 
-     #       but creates new db (comment + this author + new date)
 
-     if os.path.isdir(dest):
 
-       dest = os.path.join(destDir,self.displayName)
 
-     try:
 
-       print_d("try copy from",self.fileName,"to",dest)
 
-       # shutil methods preserve dates & chmod/chown & xattr
 
-       if doMove:
 
-         shutil.move(self.fileName, dest)
 
-       else:
 
-         shutil.copy2(self.fileName, dest)  
 
-       # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError 
 
-     except:
 
-       errorBox(f"file copy/move to <{dest}> failed; check permissions")
 
-       return
 
-     # and copy the database record
 
-     f = FileObj(dest, self.db)
 
-     f.setDbComment(self.getDbComment())
 
-   def moveFile(self, dest):
 
-     """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
 
-     self.copyFile(dest, doMove = True)
 
- # >>> snip here <<<
 
-     
 
- ##########  dest directory picker ###############
 
- # returns None if the user hits <esc>
 
- #     the dir_pad contents are indexed from 0,0, matching self.fs
 
- class showDirectoryPicker:
 
-   def __init__(self,starting_dir,title):
 
-     self.selected = None
 
-     self.title = title
 
-     self.starting_dir = self.cwd = os.path.abspath(starting_dir)
 
-     # draw the perimeter...it doesn't change  
 
-     self.W = curses.newwin(20,60,5,5)
 
-     self.W.bkgd(' ',COLOR_HELP)
 
-     self.h, self.w = self.W.getmaxyx()
 
-     self.W.keypad(True)
 
-     #self.W.clear()
 
-     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()
 
-     self.fill()
 
-     inDialog = True
 
-     selected = ''
 
-     while inDialog:
 
-       c = self.W.getch()
 
-       y,x = self.dir_pad.getyx()
 
-       if c == curses.KEY_UP:
 
-         if y==0:
 
-           continue 
 
-         y -= 1
 
-         self.dir_pad.move(y,0)
 
-         if y < self.first_visible:
 
-           self.first_visible = y 
 
-         self.refresh()
 
-       elif c == curses.KEY_DOWN:
 
-         if y == len(self.fs)-1:
 
-           continue
 
-         y += 1
 
-         self.dir_pad.move(y,0)
 
-         if y-self.first_visible > self.h-3:
 
-           self.first_visible += 1
 
-         self.refresh()
 
-       elif c == CMD_CD:
 
-         # cd to new dir and refill
 
-         if y==0 and self.fs[0].startswith('<use'):    # current dir
 
-           self.selected = self.cwd
 
-           inDialog = False
 
-         else:
 
-           self.cwd = os.path.abspath(self.cwd + '/' + self.fs[y])
 
-           #logging.info(f"change dir to {self.cwd}")
 
-           self.fill()   # throw away the old self.dir_pad
 
-       elif c == CMD_ESC:
 
-         inDialog = False
 
-     del self.W
 
-   def value(self):
 
-     logging.info(f"dir picker returns {self.selected}")
 
-     return self.selected
 
-   def refresh(self):
 
-     y,x = self.W.getbegyx()
 
-     self.dir_pad.refresh(self.first_visible,0, x+1,y+1, x+self.h-2,y+self.w-2)
 
-   def fill(self):
 
-     # change to os.path.walk() and just use the directories
 
-     # self.fs is the list of candidates, prefixed by "use this" and ".."
 
-     d, self.fs, _ = next(os.walk(self.cwd))
 
-     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)}")
 
-     
 
-     # create a pad big enough to hold all the entries
 
-     self.pad_height = max(self.h-2,len(self.fs))
 
-     self.dir_pad = curses.newpad(self.pad_height, self.w - 2)
 
-     self.dir_pad.bkgdset(' ',curses.color_pair(CP_BODY))
 
-     self.dir_pad.clear()
 
-     self.first_visible = 0
 
-     # and fill it with strings
 
-     for i,f in enumerate(self.fs):
 
-       self.dir_pad.addnstr(i,0,f,self.w-2)
 
-     self.dir_pad.move(0,0)
 
-     self.refresh()
 
- ########### 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.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 :(
 
-   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
 
- 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)
 
-   c,a,d = f.getData(mode)   # 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 or "<not set>",h.getmaxyx()[1]-13)
 
-   h.addstr(3,12,a or "<not set>")
 
-   h.addstr(4,12,d or "<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, cwd, database_file, start_file):
 
-   global files, edit_done, mode
 
-   global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
 
-   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}")
 
-   db = DnDataBase(database_file).db
 
-   files = Files(cwd,db)
 
-   logging.info(f"got files, len={len(files)}")
 
-   mywin = Pane(w,cwd,files,start_file = start_file)
 
-     
 
-   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') or c == ord('S'):
 
-         Files.sortFunc = Files.sortSize
 
-       elif c == ord('n') or c == ord('N'):
 
-         Files.sortFunc = Files.sortName
 
-       elif c == ord('d') or c == ord('D'):
 
-         Files.sortFunc = Files.sortDate
 
-       elif c == ord('c') or c == ord('C'):
 
-         Files.sortFunc = Files.sortComment
 
-       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 = "db" if mode=="xattr" else "xattr"
 
-       mywin.refill()
 
-       mywin.refresh()
 
-     elif c == CMD_RELOAD:
 
-       where = files.getCurDir().fileName
 
-       files = Files(where,db)
 
-       mywin = Pane(w,where,files)
 
-     elif c == CMD_CD:
 
-       f = files[mywin.cursor]
 
-       if f.isDir():
 
-         cwd = f.getName()
 
-         print_d(f"CD change to {cwd}")
 
-         files = Files(cwd,db)
 
-         mywin = Pane(w,cwd,files)
 
-         # TODO: should this simply re-fill() the existing Pane instead of destroy?
 
-     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.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.getName()}")
 
-       existing_comment = f.getXattrComment()
 
-       edit_sub.addstr(0,0,existing_comment or '') 
 
-       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.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(4,1,"esc to cancel")
 
-       cp_cmnt_ask.refresh()
 
-        
 
-       c = cp_cmnt_ask.getch()
 
-       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:
 
-           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()
 
-     elif c == CMD_COPY:
 
-       if files[mywin.cursor].getDisplayName() == "../":
 
-         continue
 
-       if files[mywin.cursor].isDir():
 
-         errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Copy not allowed")
 
-       else:
 
-         dest_dir = showDirectoryPicker(cwd,"Select folder for copy").value()
 
-         if dest_dir:
 
-           files[mywin.cursor].copyFile(dest_dir)
 
-       mywin.refresh() 
 
-     elif c == CMD_MOVE:
 
-       if files[mywin.cursor].getDisplayName() == "../":
 
-         continue
 
-       if files[mywin.cursor].isDir():
 
-         errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Move not allowed")
 
-       else:
 
-         dest_dir = showDirectoryPicker(cwd,"Select folder for move").value()
 
-         if dest_dir:
 
-           files[mywin.cursor].moveFile(dest_dir)
 
-           files = Files(cwd,db)
 
-           mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
 
-       mywin.refresh()   # to clean up the errorBox or FolderPicker
 
-         
 
-     elif c == curses.KEY_RESIZE:
 
-       mywin.resize()
 
-     #mywin.refresh()
 
- 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('-d','--db', action='store_true',help="start up in database mode")
 
-   parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
 
-   parser.add_argument('directory', type=str, default='.', nargs='?',  help="directory or file to start")
 
-   args = parser.parse_args()
 
-   logging.info(args)
 
-   config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
 
-   if args.db:
 
-     config.mode = "db"
 
-   if args.xattr:
 
-     config.mode = "xattr"
 
-   # print(repr(config))
 
-   # print("start_mode",config["start_mode"])
 
-   
 
-   return args, config
 
- curses_running = False
 
- args, config = pre_main()
 
- 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_running = True
 
- curses.wrapper(main, cwd or '.', database_name, start_file)
 
 
  |