|
@@ -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
|