#!/usr/bin/env python3
# TOTO: when changing directories, keep the sort order?
# TODO: fix the 'reload' function....need 'this_dir' in Files class
# TODO: write color scheme
# TODO: re-read date/author to xattr after an edit
# TODO: consider adding h,j,k,l movement
# scroll
# up/down - change focus, at limit: move 1 line,
# 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.7"
# 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"
# convert the ~/ form to a fully qualified path
# database_name = "~/.dirnotes.db"
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
DEFAULT_CONFIG_FILE = "~/.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":"~/.dirnotes.db",
"start_mode":"xattr",
"options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
"options for start_mode":("db","xattr")
}
### 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
#
# deal with the odd aspects of MacOS
#
# xattr is loaded as a separate library, with different argument names
# to set/get the comments in Finder, we need to call osascript
#
if sys.platform == "darwin":
# we get xattr from PyPi
try:
import xattr
except:
print("This program requires the 'xattr' package. Please use 'pip3 install xattr' to install the package")
sys.exit(1)
def do_setxattr(file_name, attr_name, attr_content):
if attr_name.endswith("omment"):
p = Popen(['osascript','-'] + f'tell application "Finder" to set comment of (POSIX file "{file_name}" as alias) to "{attr_content}"')
p.communicate()
else:
xattr.setxattr(file_name, attr_name, plistlib.dumps(attr_content))
os.setxattr = do_setxattr
#os.setxattr = xattr.setxattr
def do_getxattr(file_name, attr_name):
attr_data = xattr.getxattr(file_name, attr_name)
if attr_name.endswith("omment"):
return plistlib.loads(attr_data)
else:
return attr_data
os.getxattr = xattr.getxattr
else:
pass
class Pane:
''' holds the whole display: handles file list directly,
fills a child pad with the file info,
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=="xattr" else dbComment
if dbComment != xattrComment:
self.some_comments_differ = True
self.file_pad.attron(COLOR_HELP)
self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
self.file_pad.attroff(COLOR_HELP)
else:
self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
self.file_pad.vline(y,self.sep1-1,curses.ACS_VLINE,1)
self.file_pad.vline(y,self.sep2-1,curses.ACS_VLINE,1)
self.file_pad.vline(y,self.sep3-1,curses.ACS_VLINE,1)
def unfocus_line(self):
self.fill_line(self.cursor)
def focus_line(self):
self.file_pad.attron(COLOR_FOCUS)
self.fill_line(self.cursor)
self.file_pad.attroff(COLOR_FOCUS)
def line_move(self,direction):
# try a move first
new_cursor = self.cursor + direction
if new_cursor < 0:
new_cursor = 0
if new_cursor >= self.nFiles:
new_cursor = self.nFiles - 1
if new_cursor == self.cursor:
return
# then adjust the window
if new_cursor < self.first_visible:
self.first_visible = new_cursor
self.file_pad.redrawwin()
if new_cursor >= self.first_visible + self.pad_visible - 1:
self.first_visible = new_cursor - self.pad_visible + 1
self.file_pad.redrawwin()
self.unfocus_line()
self.cursor = new_cursor
self.focus_line()
self.file_pad.move(self.cursor,0) # just move the flashing cursor
self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
def setStatus(self,data):
h,w = self.statusbar.getmaxyx()
self.statusbar.clear()
self.statusbar.attron(curses.A_REVERSE)
self.statusbar.addstr(0,0,mode_names[mode])
self.statusbar.attroff(curses.A_REVERSE)
y,x = self.statusbar.getyx()
self.statusbar.addnstr(" " + data,w-x-1)
self.statusbar.refresh()
# two helpers to format the date & size visuals
def makeDate(when):
''' arg when is epoch seconds in localtime '''
diff = now - when
if diff > YEAR:
fmt = "%b %e %Y"
else:
fmt = "%b %d %H:%M"
return time.strftime(fmt, time.localtime(when))
def makeSize(size):
if size == FileObj.FILE_IS_DIR:
return " <DIR> "
elif size == FileObj.FILE_IS_LINK:
return " <LINK>"
log = int((math.log10(size+1)-2)/3)
s = " KMGTE"[log]
base = int(size/math.pow(10,log*3))
return f"{base}{s}".strip().rjust(7)
## to hold the FileObj collection
class Files():
def __init__(self,directory):
self.directory = FileObj(directory)
self.files = []
if directory != '/':
self.files.append(FileObj(directory + "/.."))
# TODO: switch to os.scandir()
f = os.listdir(directory)
for i in f:
self.files.append(FileObj(directory + '/' + i))
self.sort()
self.db = None
try:
self.db = sqlite3.connect(database_name)
c = self.db.cursor()
c.execute("select * from dirnotes")
except sqlite3.OperationalError:
# TODO: problem with database....create one?
c.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
try:
c = self.db.cursor()
self.directory.loadDbComment(c)
for f in self.files:
f.loadDbComment(c)
except sqlite3.OperationalError:
errorBox("serial problem with the database")
def sortName(a):
''' when sorting by name put the .. and other <dir> entries first '''
if a.getFileName() == '..':
return "\x00"
if a.isDir():
return ' ' + a.getFileName()
# else:
return a.getFileName()
def sortDate(a):
if a.getFileName() == '..':
return 0
return a.getDate()
def sortSize(a):
if a.getFileName() == '..':
return 0
return a.getSize()
def sortComment(a):
return a.getComment()
sort_mode = sortName
def sort(self):
self.files.sort(key = Files.sort_mode)
def getCurDir(self):
return self.directory
def getMasterComment(self):
return self.directory.getComment()
## accessors ##
def __len__(self):
return len(self.files)
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 = 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(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
self.xattrAuthor = getpass.getuser()
self.xattrDate = time.strftime(DATE_FORMAT) # alternatively, re-instantiate this FileObj
self.xattrComment = newComment
self.commentsDiffer = True if self.xattrComment == self.dbComment else False
return True
# 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 getComment(self):
return self.getDbComment() if mode == "db" else self.getXattrComment()
def getOtherComment(self):
return self.getDbComment() if mode == "xattr" else self.getXattrComment()
def getDate(self):
return self.date
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
#TODO: fix to allow upper/lower case responses
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=="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, cwd):
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}")
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') or c == ord('S'):
Files.sort_mode = Files.sortSize
elif c == ord('n') or c == ord('N'):
Files.sort_mode = Files.sortName
elif c == ord('d') or c == ord('D'):
Files.sort_mode = Files.sortDate
elif c == ord('c') or c == ord('C'):
Files.sort_mode = Files.sortComment
files.sort()
mywin.refill()
mywin.refresh()
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)
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=="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=="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()
def pre_main():
# done before we switch to curses mode
logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
logging.info("starting curses dirnotes")
parser = argparse.ArgumentParser(description="Add comments to files")
parser.add_argument('-c','--config', dest='config_file', help="config file (json format)")
parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
parser.add_argument('directory', type=str, default='.', nargs='?', help="directory to start")
args = parser.parse_args()
logging.info(args)
if args.config_file:
config_file = args.config_file
else:
config_file = DEFAULT_CONFIG_FILE
config_file = os.path.expanduser(config_file)
config = DEFAULT_CONFIG
try:
with open(config_file,"r") as f:
config = json.load(f)
except json.JSONDecodeError:
print(f"problem reading the config file {config_file}")
printf("please check the .json syntax")
time.sleep(2)
except FileNotFoundError:
print(f"config file {config_file} not found, using default settings & creating a default")
try:
with open(config_file,"w") as f:
json.dump(config,f,indent=4)
except:
print(f"problem creating file {config_file}")
time.sleep(2)
# print(repr(config))
# print("start_mode",config["start_mode"])
return args, config
args, config = pre_main()
mode = config["start_mode"]
xattr_comment = config["xattr_tag"]
xattr_author = config["xattr_tag"] + ".author"
xattr_date = config["xattr_tag"] + ".date"
# change ~ tilde to username
database_name = os.path.expanduser(config["database"])
cwd = args.directory
curses.wrapper(main, cwd)
# dirnotes database is name, date, size, comment, comment_date, author
# symlinks: follow_symlinks should always be True, because symlinks in Linux
# can't have xattr....it appears to be the same in darwin