|  | @@ -0,0 +1,843 @@
 | 
	
		
			
				|  |  | +#!/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))
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    try:
 | 
	
		
			
				|  |  | +      db = sqlite3.connect(DATABASE_NAME)
 | 
	
		
			
				|  |  | +      c = db.cursor()
 | 
	
		
			
				|  |  | +      self.directory.loadDbComment(c)
 | 
	
		
			
				|  |  | +      for f in self.files:
 | 
	
		
			
				|  |  | +        f.loadDbComment(c)
 | 
	
		
			
				|  |  | +    except sqlite3.OperationalError:
 | 
	
		
			
				|  |  | +      # TODO: problem with database....create one?
 | 
	
		
			
				|  |  | +      pass
 | 
	
		
			
				|  |  | +    self.sort()
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +  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
 |