12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010 |
- #!/usr/bin/env python3
- # TODO: write color scheme
- # TODO: re-read date/author to xattr after an edit
- # TODO: consider adding h,j,k,l movement
- # TODO: change move command to 'v', change mode to 'm', drop copy-comments
- # TODO: bug: enter db mode, type E to edit a comment, we get the xattr version!!
- # TODO: try to clear a comment, left with ' '
- # scroll
- # up/down - change focus, at limit: move 1 line,
- # pgup/down - move by (visible_range - 1), leave focus on the remaining element
- # home/end - top/bottom, focus on first/last
- # three main classes:
- # Pane: smart curses window cluster: main, status & scrolling pad
- # FileObj: a file with its xattr-comment and db-comment data
- # Files: a collection of FileObjs, sortable
- import sys, time
- import curses, curses.textpad
- import logging, argparse
- VERSION = "1.9"
- # these may be different on MacOS
- xattr_comment = "user.xdg.comment"
- xattr_author = "user.xdg.comment.author"
- xattr_date = "user.xdg.comment.date"
- DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
- mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"}
- modes = ("db","xattr")
- mode = "db"
- ### commands
- CMD_COPY = ord('c') # open dialog to copy-with-comment
- CMD_DETAIL = ord('d') # open dialog
- CMD_EDIT = ord('e') # open dialog for typing & <esc> or <enter>
- CMD_HELP = ord('h') # open dialog
- CMD_MODE = ord('M') # switch between xattr and database mode
- CMD_MOVE = ord('m') # open dialog to move-with-comment
- CMD_QUIT = ord('q')
- CMD_RELOAD = ord('r') # reload
- CMD_SORT = ord('s') # open dialog for N,S,D,C
- CMD_CMNT_CP= ord('C') # open dialog to copy comments accept 1 or a or <esc>
- CMD_ESC = 27
- CMD_CD = ord('\n')
- # file comments will ALWAYS be written to both xattrs & database
- # access failure is shown once per directory
- # other options will be stored in database at ~/.dirnotes.db or /etc/dirnotes.db
- # - option to use MacOSX xattr labels
- #
- # at first launch (neither database is found), give the user a choice of
- # ~/.dirnotes.db or /var/lib/dirnotes.db
- # at usage time, check for ~/.dirnotes.db first
- ### colors
- CP_TITLE = 1
- CP_BODY = 2
- CP_FOCUS = 3
- CP_ERROR = 4
- CP_HELP = 5
- CP_DIFFER = 6
- COLOR_DIFFER = COLOR_TITLE = COLOR_BODY = COLOR_FOCUS = COLOR_ERROR = COLOR_HELP = None
- COLOR_THEME = ''' { "heading": ("yellow","blue"),
- "body":("white","blue"),
- "focus":("black","cyan") }
- '''
- now = time.time()
- YEAR = 3600*24*365
- verbose = None
- def print_d(*a):
- if verbose:
- print(*a)
- class Pane:
- ''' holds the whole display: handles file list directly,
- fills a child pad with the file info,
- draws scroll bar
- defers the status line to a child
- draws a border
- line format: filename=30%, size=7, date=12, comment=rest
- line 1=current directory + border
- line 2...h-4 = filename
- line h-3 = border
- line h-2 = status
- line h-1 = border
- column 0, sep1, sep2, sep3 and w-1 are borders w.r.t. pad
- filename starts in column 1 (border in 0)
- most methods take y=0..h-1 where y is the line number WITHIN the borders
- '''
- def __init__(self, win, curdir, files, start_file = None):
- self.curdir = curdir
- self.cursor = None
- self.first_visible = 0
- self.nFiles = len(files)
- self.start_file = start_file
-
- self.h, self.w = win.getmaxyx()
-
- self.main_win = win # whole screen
- self.win = win.subwin(self.h-1,self.w,0,0) # upper window, for border
- self.statusbar = win.subwin(1,self.w,self.h-1,0) # status at the bottom
- self.pad_height = max(self.nFiles,self.h-4)
- self.file_pad = curses.newpad(self.pad_height,self.w)
- self.file_pad.keypad(True)
- self.win.bkgdset(' ',curses.color_pair(CP_BODY))
- self.statusbar.bkgdset(' ',curses.color_pair(CP_BODY))
- self.file_pad.bkgdset(' ',curses.color_pair(CP_BODY))
- self.resize()
- logging.info("made the pane")
- def resize(self): # and refill
- logging.info("got to resize")
- self.h, self.w = self.main_win.getmaxyx()
- self.sep1 = self.w // 3
- self.sep2 = self.sep1 + 8
- self.sep3 = self.sep2 + 13
- self.win.resize(self.h-1,self.w)
- self.statusbar.resize(1,self.w)
- self.statusbar.mvwin(self.h-1,0)
- self.pad_height = max(len(files),self.h-4)
- self.pad_visible = self.h-4
- self.file_pad.resize(self.pad_height+1,self.w-2)
- self.refill()
- self.refresh()
- def refresh(self):
- self.win.refresh()
-
- if self.some_comments_differ:
- self.setStatus("The xattr and database comments differ where shown in green")
- else:
- self.setStatus("")
- self.file_pad.refresh(self.first_visible,0, 2,1, self.h-3,self.w-2)
-
- def refill(self):
- self.win.bkgdset(' ',curses.color_pair(CP_BODY))
- self.win.erase()
- self.win.box()
- h,w = self.win.getmaxyx()
- self.win.addnstr(0,3,os.path.realpath(self.curdir),w-4)
- mc = files.getMasterComment()
- if mc:
- self.win.addnstr(0,w-len(mc)-1,files.getMasterComment(),w-len(mc)-1)
- self.win.attron(COLOR_TITLE | curses.A_BOLD)
- self.win.addstr(1,1,'Name'.center(self.sep1-1))
- self.win.addstr(1,self.sep1+2,'Size')
- self.win.addstr(1,self.sep2+4,'Date')
- self.win.addstr(1,self.sep3+2,'Comments')
- self.win.attroff(COLOR_BODY)
- self.some_comments_differ = False
- # now fill the file_pad
- for i,f in enumerate(files):
- self.fill_line(i) # fill the file_pad
- if self.nFiles < self.pad_height:
- for i in range(self.nFiles, self.pad_height):
- self.file_pad.addstr(i,0,' ' * (self.w-2))
- # and display the file_pan
- if self.cursor == None:
- self.cursor = 0
- if self.start_file: # if the command line had a file, find it and highlight it....once
- for i,f in enumerate(files):
- if f.getDisplayName() == self.start_file:
- self.cursor = i
- self.start_file = None
- self.focus_line()
- def fill_line(self,y):
- #logging.info(f"about to add {self.w-2} spaces at {y} to the file_pad size: {self.file_pad.getmaxyx()}")
- f = files[y]
- self.file_pad.addstr(y,0,' ' * (self.w-2))
- self.file_pad.addnstr(y,0,f.getDisplayName(),self.sep1-1)
- self.file_pad.addstr(y,self.sep1,UiHelper.getShortSize(f))
- self.file_pad.addstr(y,self.sep2,UiHelper.getShortDate(f.date))
- comment = f.getComment(mode) or ''
- other = f.getOtherComment(mode) or ''
- logging.info(f"file_line, comments are <{comment}> and <{other}> differ_flag:{self.some_comments_differ}")
- if comment == other:
- self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
- else:
- self.some_comments_differ = True
- self.file_pad.attron(COLOR_HELP)
- self.file_pad.addnstr(y,self.sep3,comment or ' ',self.w-self.sep3-2)
- self.file_pad.attroff(COLOR_HELP)
- self.file_pad.vline(y,self.sep1-1,curses.ACS_VLINE,1)
- self.file_pad.vline(y,self.sep2-1,curses.ACS_VLINE,1)
- self.file_pad.vline(y,self.sep3-1,curses.ACS_VLINE,1)
- def unfocus_line(self):
- self.fill_line(self.cursor)
- def focus_line(self):
- self.file_pad.attron(COLOR_FOCUS)
- self.fill_line(self.cursor)
- self.file_pad.attroff(COLOR_FOCUS)
- def line_move(self,direction):
- # try a move first
- new_cursor = self.cursor + direction
- if new_cursor < 0:
- new_cursor = 0
- if new_cursor >= self.nFiles:
- new_cursor = self.nFiles - 1
- if new_cursor == self.cursor:
- return
- # then adjust the window
- if new_cursor < self.first_visible:
- self.first_visible = new_cursor
- self.file_pad.redrawwin()
- if new_cursor >= self.first_visible + self.pad_visible - 1:
- self.first_visible = new_cursor - self.pad_visible + 1
- self.file_pad.redrawwin()
- self.unfocus_line()
- self.cursor = new_cursor
- self.focus_line()
- self.file_pad.move(self.cursor,0) # just move the flashing cursor
- self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
-
- def setStatus(self,data):
- h,w = self.statusbar.getmaxyx()
- self.statusbar.clear()
- self.statusbar.attron(curses.A_REVERSE)
- self.statusbar.addstr(0,0,mode_names[mode])
- self.statusbar.attroff(curses.A_REVERSE)
- y,x = self.statusbar.getyx()
- self.statusbar.addnstr(" " + data,w-x-1)
- self.statusbar.refresh()
- ## to hold the FileObj collection
- class Files():
- def __init__(self,directory,db):
- self.db = db
- if not os.path.isdir(directory):
- errorBox(f"the command line argument: {directory} is not a directory; starting in the current directory")
- directory = '.'
- self.directory = FileObj(directory,self.db)
- try:
- current, dirs, non_dirs = next(os.walk(directory))
- except:
- errorBox(f"{directory} is not a valid directory")
- raise
- if current != '/':
- dirs.insert(0,"..")
- self.files = []
- for f in dirs + non_dirs:
- self.files.append(FileObj(os.path.join(current,f),self.db))
- self.sort()
- def sortName(a):
- ''' when sorting by name put the .. and other <dir> entries first '''
- if a.getDisplayName() == '../':
- return "\x00"
- if a.isDir():
- return ' ' + a.getDisplayName()
- # else:
- return a.getDisplayName()
- def sortDate(a):
- if a.getDisplayName() == '../':
- return 0
- return a.getDate()
- def sortSize(a):
- if a.getDisplayName() == '../':
- return -2
- if a.isDir() or a.isLink() or a.isSock():
- return -1
- return a.getSize()
- def sortComment(a):
- return a.getComment(mode) or '~'
- sortFunc = sortName
- def sort(self):
- self.files.sort(key = Files.sortFunc)
- def getCurDir(self):
- return self.directory
- def getMasterComment(self):
- return self.directory.getComment(mode)
- ## accessors ##
- def __len__(self):
- return len(self.files)
- def __getitem__(self, i):
- return self.files[i]
- def __iter__(self):
- return self.files.__iter__()
-
- def errorBox(string):
- if curses_running:
- werr = curses.newwin(3,len(string)+8,5,5)
- werr.bkgd(' ',COLOR_ERROR)
- werr.clear()
- werr.box()
- werr.addstr(1,1,string)
- werr.timeout(3000)
- werr.getch() # any key
- del werr
- else:
- print(string)
- time.sleep(3)
-
- # >>> snip here <<<
- #============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
- import getpass, time, stat, shutil, sqlite3, json, os, math
- DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
- # config
- # we could store the config in the database, in a second table
- # or in a .json file
- DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
- "database":"~/.local/share/dirnotes/dirnotes.db",
- "start_mode":"xattr",
- "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
- "options for start_mode":("db","xattr")
- }
- class ConfigLoader: # singleton
- def __init__(self, configFile):
- configFile = os.path.expanduser(configFile)
- try:
- with open(configFile,"r") as f:
- config = json.load(f)
- except json.JSONDecodeError:
- errorBox(f"problem reading config file {configFile}; check the JSON syntax")
- config = DEFAULT_CONFIG
- except FileNotFoundError:
- errorBox(f"config file {configFile} not found; using the default settings")
- config = DEFAULT_CONFIG
- try:
- os.makedirs(os.path.dirname(configFile),exist_ok = True)
- with open(configFile,"w") as f:
- json.dump(config,f,indent=4)
- except:
- errorBox(f"problem creating the config file {configFile}")
- self.dbName = os.path.expanduser(config["database"])
- self.mode = config["start_mode"] # can get over-ruled by the command line options
- self.xattr_comment = config["xattr_tag"]
- class DnDataBase:
- ''' the database is flat
- fileName: fully qualified name
- st_mtime: a float
- size: a long
- comment: a string
- comment_time: a float, the time of the comment save
- author: the username that created the comment
- this object: 1) finds or creates the database
- 2) determine if it's readonly
- TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/)
- TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
- make it 0666 permissions (rw-rw-rw-)
- '''
- def __init__(self,dbFile):
- '''try to open the database; if not found, create it'''
- try:
- self.db = sqlite3.connect(dbFile)
- except sqlite3.OperationalError:
- print_d(f"Database {dbFile} not found")
- try:
- os.makedirs(os.path.dirname(dbFile), exist_ok = True)
- self.db = sqlite3.connect(dbFile)
- except (sqlite3.OperationalError, PermissionError):
- printd(f"Failed to create {dbFile}, aborting")
- raise
- # create new table if it doesn't exist
- try:
- self.db.execute("select * from dirnotes")
- except sqlite3.OperationalError:
- self.db.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
- self.db.execute("create index dirnotes_i on dirnotes(name)")
- print_d(f"Table dirnotes created")
- # at this point, if a shared database is required, somebody needs to set perms to 0o666
-
- self.writable = True
- try:
- self.db.execute("pragma user_verson=0")
- except sqlite3.OperationalError:
- self.writable = False
- DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
- class UiHelper:
- @staticmethod
- def epochToDb(epoch):
- return time.strftime(DATE_FORMAT,time.localtime(epoch))
- @staticmethod
- def DbToEpoch(dbTime):
- return time.mktime(time.strptime(dbTime,DATE_FORMAT))
- @staticmethod
- def getShortDate(longDate):
- now = time.time()
- diff = now - longDate
- if diff > YEAR:
- fmt = "%b %e %Y"
- else:
- fmt = "%b %e %H:%M"
- return time.strftime(fmt, time.localtime(longDate))
- @staticmethod
- def getShortSize(fo):
- if fo.isDir():
- return " <DIR> "
- elif fo.isLink():
- return " <LINK>"
- size = fo.getSize()
- log = int((math.log10(size+1)-2)/3)
- s = " KMGTE"[log]
- base = int(size/math.pow(10,log*3))
- return f"{base}{s}".strip().rjust(7)
- ## one for each file
- ## and a special one for ".." parent directory
- class FileObj:
- """ The FileObj knows about both kinds of comments. """
- def __init__(self, fileName, db):
- self.fileName = os.path.abspath(fileName) # full path; dirs end WITHOUT a terminal /
- self.stat = os.lstat(self.fileName)
- self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
- if self.isDir():
- if not self.displayName.endswith('/'):
- self.displayName += '/'
- self.date = self.stat.st_mtime
- self.size = self.stat.st_size
- self.db = db
- def getName(self):
- """ returns the absolute pathname """
- return self.fileName
- def getDisplayName(self):
- """ returns just the basename of the file; dirs end in / """
- return self.displayName
- def getDbData(self):
- """ returns (comment, author, comment_date) """
- if not hasattr(self,'dbCommentAuthorDate'):
- cad = self.db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(self.fileName,)).fetchone()
- self.dbCommentAuthorDate = cad if cad else (None, None, None)
- return self.dbCommentAuthorDate
- def getDbComment(self):
- return self.getDbData()[0]
- def getXattrData(self):
- """ returns (comment, author, comment_date) """
- if not hasattr(self,'xattrCommentAuthorDate'):
- c = a = d = None
- try:
- c = os.getxattr(self.fileName, xattr_comment, follow_symlinks=False).decode()
- a = os.getxattr(self.fileName, xattr_author, follow_symlinks=False).decode()
- d = os.getxattr(self.fileName, xattr_date, follow_symlinks=False).decode()
- except: # no xattr comment
- pass
- self.xattrCommentAuthorDate = c,a,d
- return self.xattrCommentAuthorDate
- def getXattrComment(self):
- return self.getXattrData()[0]
- def setDbComment(self,newComment):
- # how are we going to hook this?
- #if not self.db.writable:
- # errorBox("The database is readonly; you cannot add or edit comments")
- # return
- s = os.lstat(self.fileName)
- try:
- print_d(f"setDbComment db {self.db}, file: {self.fileName}")
- self.db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
- (self.fileName, s.st_mtime, s.st_size,
- str(newComment), time.time(), getpass.getuser()))
- self.db.commit()
- self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
- except sqlite3.OperationalError:
- print_d("database is locked or unwritable")
- errorBox("the database that stores comments is locked or unwritable")
- def setXattrComment(self,newComment):
- print_d(f"set comment {newComment} on file {self.fileName}")
- try:
- os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
- os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
- os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
- self.xattrCommentAuthorDate = newComment, getpass.getuser(), time.strftime(DATE_FORMAT)
- return True
- # we need to move these cases out to a handler
- except Exception as e:
- if self.isLink():
- errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
- elif self.isSock():
- errorBox("Linux does not allow comments on sockets; comment is stored in database")
- elif os.access(self.fileName, os.W_OK)!=True:
- errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
- # change the listbox background to yellow
- elif "Errno 95" in str(e):
- errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
- return False
- def getComment(self,mode):
- """ returns the comment for the given mode """
- return self.getDbComment() if mode == "db" else self.getXattrComment()
- def getOtherComment(self,mode):
- return self.getDbComment() if mode == "xattr" else self.getXattrComment()
- def getData(self,mode):
- """ returns (comment, author, comment_date) for the given mode """
- return self.getDbData() if mode == "db" else self.getXattrData()
- def getOtherData(self,mode):
- """ returns (comment, author, comment_date) for the 'other' mode """
- return self.getDbData() if mode == "xattr" else self.getXattrData()
- def getDate(self):
- return self.date
- def getSize(self):
- return self.size
- def isDir(self):
- return stat.S_ISDIR(self.stat.st_mode)
- def isLink(self):
- return stat.S_ISLNK(self.stat.st_mode)
- def isSock(self):
- return stat.S_ISSOCK(self.stat.st_mode)
- def copyFile(self, dest, doMove = False):
- """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
- # NOTE: this method copies the xattr (comment + old author + old date)
- # but creates new db (comment + this author + new date)
- if os.path.isdir(dest):
- dest = os.path.join(destDir,self.displayName)
- try:
- print_d("try copy from",self.fileName,"to",dest)
- # shutil methods preserve dates & chmod/chown & xattr
- if doMove:
- shutil.move(self.fileName, dest)
- else:
- shutil.copy2(self.fileName, dest)
- # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError
- except:
- errorBox(f"file copy/move to <{dest}> failed; check permissions")
- return
- # and copy the database record
- f = FileObj(dest, self.db)
- f.setDbComment(self.getDbComment())
- def moveFile(self, dest):
- """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
- self.copyFile(dest, doMove = True)
- # >>> snip here <<<
-
- ########## dest directory picker ###############
- # returns None if the user hits <esc>
- # the dir_pad contents are indexed from 0,0, matching self.fs
- class showDirectoryPicker:
- def __init__(self,starting_dir,title):
- self.selected = None
- self.title = title
- self.starting_dir = self.cwd = os.path.abspath(starting_dir)
- # draw the perimeter...it doesn't change
- self.W = curses.newwin(20,60,5,5)
- self.W.bkgd(' ',COLOR_HELP)
- self.h, self.w = self.W.getmaxyx()
- self.W.keypad(True)
- #self.W.clear()
- self.W.box()
- self.W.addnstr(0,1,self.title,self.w-2)
- self.W.addstr(self.h-1,1,"<Enter> to select or change dir, <esc> to exit")
- self.W.refresh()
- self.fill()
- inDialog = True
- selected = ''
- while inDialog:
- c = self.W.getch()
- y,x = self.dir_pad.getyx()
- if c == curses.KEY_UP:
- if y==0:
- continue
- y -= 1
- self.dir_pad.move(y,0)
- if y < self.first_visible:
- self.first_visible = y
- self.refresh()
- elif c == curses.KEY_DOWN:
- if y == len(self.fs)-1:
- continue
- y += 1
- self.dir_pad.move(y,0)
- if y-self.first_visible > self.h-3:
- self.first_visible += 1
- self.refresh()
- elif c == CMD_CD:
- # cd to new dir and refill
- if y==0 and self.fs[0].startswith('<use'): # current dir
- self.selected = self.cwd
- inDialog = False
- else:
- self.cwd = os.path.abspath(self.cwd + '/' + self.fs[y])
- #logging.info(f"change dir to {self.cwd}")
- self.fill() # throw away the old self.dir_pad
- elif c == CMD_ESC:
- inDialog = False
- del self.W
- def value(self):
- logging.info(f"dir picker returns {self.selected}")
- return self.selected
- def refresh(self):
- y,x = self.W.getbegyx()
- self.dir_pad.refresh(self.first_visible,0, x+1,y+1, x+self.h-2,y+self.w-2)
- def fill(self):
- # change to os.path.walk() and just use the directories
- # self.fs is the list of candidates, prefixed by "use this" and ".."
- d, self.fs, _ = next(os.walk(self.cwd))
- self.fs.sort()
- if self.cwd != '/':
- self.fs.insert(0,"..")
- if self.cwd != self.starting_dir:
- self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
-
- # create a pad big enough to hold all the entries
- self.pad_height = max(self.h-2,len(self.fs))
- self.dir_pad = curses.newpad(self.pad_height, self.w - 2)
- self.dir_pad.bkgdset(' ',curses.color_pair(CP_BODY))
- self.dir_pad.clear()
- self.first_visible = 0
- # and fill it with strings
- for i,f in enumerate(self.fs):
- self.dir_pad.addnstr(i,0,f,self.w-2)
- self.dir_pad.move(0,0)
- self.refresh()
- ########### comment management code #################
- # paint a dialog window with a border and contents
- # discard the 1st line, use the next line to set the width
- def paint_dialog(b_color,data):
- lines = data.split('\n')[1:]
- n = len(lines[0])
- w = curses.newwin(len(lines)+2,n+3,5,5)
- w.bkgd(' ',b_color)
- w.clear()
- w.box()
- for i,d in enumerate(lines):
- w.addnstr(i+1,1,d,n)
- #w.refresh I don't know why this isn't needed :(
- return w
- help_string = """
- Dirnotes add descriptions to files
- uses xattrs and a database
- version %s
- h help window (h1/h2 for more help)
- e edit file description
- d see file+comment details
- s sort
- q quit
- M switch between xattr & database
- C copy comment between modes
- p preferences/settings [not impl]
- c copy file
- m move file
- <enter> to enter directory""" % (VERSION,)
- def show_help():
- w = paint_dialog(COLOR_HELP,help_string)
- c = w.getch()
- del w
- if c==ord('1'):
- show_help1()
- if c==ord('2'):
- show_help2()
- help1_string = """
- Dirnotes stores its comments in the xattr property of files
- where it can, and in a database.
- XATTR
- =====
- The xattr comments are attached to the 'user.xdg.comment'
- property. If you copy/move/tar the file, there are often
- options to move the xattrs with the file.
- The xattr comments don't always work. For example, you may
- not have write permission on a file. Or you may be using
- an exFat/fuse filesystem that doesn't support xattr. You
- cannot add xattr comments to symlinks.
- DATABASE
- ========
- The database isvstored at ~/.dirnotes.db using sqlite3.
- The comments are indexed by the realpath(filename), which
- may change if you use external drives and use varying
- mountpoints.
- These comments will not move with a file unless you use the
- move/copy commands inside this program.
- The database allows you to add comments to files you don't
- own, or which are read-only.
- When the comments in the two systems differ, the comment is
- highlighted in green. The 'M' command lets you view either
- xattr or database comments. The 'C' command allows you to
- copy comments between xattr and database."""
- def show_help1():
- w = paint_dialog(COLOR_HELP,help1_string)
- c = w.getch()
- del w
- help2_string = """
- The comments are also stored with the date-of-the-comment and
- the username of the comment's author. The 'd' key will
- display that info.
- Optionally, the database can be stored at
- /var/lib/dirnotes/dirnotes.db
- which allows access to all users (not implimented)"""
- def show_help2():
- w = paint_dialog(COLOR_HELP,help2_string)
- c = w.getch()
- del w
- sort_string = """
- Select sort order:
-
- name
- date
- size
- comment"""
- def show_sort():
- h = paint_dialog(COLOR_HELP,sort_string)
- h.attron(COLOR_TITLE)
- h.addstr(3,3,"n") or h.addstr(4,3,"d") or h.addstr(5,3,"s") or h.addstr(6,3,"c")
- h.attroff(COLOR_TITLE)
- h.refresh()
- c = h.getch()
- del h
- return c
- detail_string = """
- Comments detail:
- Comment:
- Author:
- Date: """
- def show_detail(f):
- global mode
- h = paint_dialog(COLOR_HELP,detail_string)
- c,a,d = f.getData(mode) # get all three, depending on the current mode
- h.addstr(1,20,"from xattrs" if mode=="xattr" else "from database")
- h.addnstr(2,12,c or "<not set>",h.getmaxyx()[1]-13)
- h.addstr(3,12,a or "<not set>")
- h.addstr(4,12,d or "<not set>")
- h.refresh()
- c = h.getch()
- del h
- return c
- ## used by the comment editor to pick up <ENTER> and <ESC>
- edit_done = False
- def edit_fn(c):
- global edit_done
- if c==ord('\n'):
- edit_done = True
- return 7
- if c==27:
- return 7
- return c
- def main(w, cwd, database_file, start_file):
- global files, edit_done, mode
- global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
- curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
- curses.init_pair(CP_BODY, curses.COLOR_WHITE,curses.COLOR_BLUE)
- curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
- curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
- curses.init_pair(CP_HELP, curses.COLOR_WHITE,curses.COLOR_CYAN)
- curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
- COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
- COLOR_BODY = curses.color_pair(CP_BODY)
- COLOR_FOCUS = curses.color_pair(CP_FOCUS)
- COLOR_ERROR = curses.color_pair(CP_ERROR)
- COLOR_HELP = curses.color_pair(CP_HELP)
- COLOR_DIFFER = curses.color_pair(CP_DIFFER)
- logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
- db = DnDataBase(database_file).db
- files = Files(cwd,db)
- logging.info(f"got files, len={len(files)}")
- mywin = Pane(w,cwd,files,start_file = start_file)
-
- showing_edit = False
- while True:
- c = mywin.file_pad.getch(mywin.cursor,1)
-
- if c == CMD_QUIT or c == CMD_ESC:
- break
- elif c == CMD_HELP:
- show_help()
- mywin.refresh()
- elif c == CMD_SORT:
- c = show_sort()
- if c == ord('s') or c == ord('S'):
- Files.sortFunc = Files.sortSize
- elif c == ord('n') or c == ord('N'):
- Files.sortFunc = Files.sortName
- elif c == ord('d') or c == ord('D'):
- Files.sortFunc = Files.sortDate
- elif c == ord('c') or c == ord('C'):
- Files.sortFunc = Files.sortComment
- files.sort()
- mywin.refill()
- mywin.refresh()
- elif c == curses.KEY_UP:
- mywin.line_move(-1)
- elif c == curses.KEY_DOWN:
- mywin.line_move(1)
- elif c == curses.KEY_PPAGE:
- mywin.line_move(-mywin.pad_visible+1)
- elif c == curses.KEY_NPAGE:
- mywin.line_move(mywin.pad_visible-1)
- elif c == curses.KEY_HOME:
- mywin.line_move(-len(files)+1)
- elif c == curses.KEY_END:
- mywin.line_move(len(files)-1)
- elif c == CMD_DETAIL:
- show_detail(files[mywin.cursor])
- mywin.refresh()
- elif c == CMD_MODE:
- mode = "db" if mode=="xattr" else "xattr"
- mywin.refill()
- mywin.refresh()
- elif c == CMD_RELOAD:
- where = files.getCurDir().fileName
- files = Files(where,db)
- mywin = Pane(w,where,files)
- elif c == CMD_CD:
- f = files[mywin.cursor]
- if f.isDir():
- cwd = f.getName()
- print_d(f"CD change to {cwd}")
- files = Files(cwd,db)
- mywin = Pane(w,cwd,files)
- # TODO: should this simply re-fill() the existing Pane instead of destroy?
- elif c == CMD_EDIT:
- showing_edit = True
- edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
- edit_window.box()
- edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
- he,we = edit_window.getmaxyx()
- edit_sub = edit_window.derwin(3,we-2,1,1)
-
- f = files[mywin.cursor]
- mywin.setStatus(f"Edit file: {f.getName()}")
- existing_comment = f.getXattrComment()
- edit_sub.addstr(0,0,existing_comment or '')
- text = curses.textpad.Textbox(edit_sub)
- edit_window.refresh()
- comment = text.edit(edit_fn).strip()
- logging.info(f"comment: {comment} and flag-ok {edit_done}")
- if edit_done:
- comment = comment.replace('\n',' ')
- logging.info(f"got a new comment as '{comment}'")
- edit_done = False
- f.setXattrComment(comment)
- f.setDbComment(comment)
- logging.info(f"set file {f.fileName} with comment <{comment}>")
- mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
- del text, edit_sub, edit_window
- mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
- mywin.statusbar.redrawwin()
- mywin.focus_line()
- mywin.refresh()
- elif c == CMD_CMNT_CP:
- # copy comments to the other mode
- cp_cmnt_ask = curses.newwin(6,40,5,5)
- cp_cmnt_ask.box()
- cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
- cp_cmnt_ask.addstr(1,22,"database" if mode=="xattr" else "xattr")
- cp_cmnt_ask.addstr(2,1," 1 just this file")
- cp_cmnt_ask.addstr(3,1," a all files with comments")
- cp_cmnt_ask.addstr(4,1,"esc to cancel")
- cp_cmnt_ask.refresh()
-
- c = cp_cmnt_ask.getch()
- if c in (ord('1'), ord('a'), ord('A')):
- # copy comments for one file or all
- if c==ord('1'):
- collection = [files[mywin.cursor]]
- else:
- collection = files
- for f in collection:
- if mode=="xattr":
- if f.getXattrComment():
- f.setDbComment(f.getXattrComment())
- else:
- if f.getDbComment():
- f.setXattrComment(f.getDbComment())
- mywin.refill()
- mywin.refresh()
- elif c == CMD_COPY:
- if files[mywin.cursor].getDisplayName() == "../":
- continue
- if files[mywin.cursor].isDir():
- errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Copy not allowed")
- else:
- dest_dir = showDirectoryPicker(cwd,"Select folder for copy").value()
- if dest_dir:
- files[mywin.cursor].copyFile(dest_dir)
- mywin.refresh()
- elif c == CMD_MOVE:
- if files[mywin.cursor].getDisplayName() == "../":
- continue
- if files[mywin.cursor].isDir():
- errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Move not allowed")
- else:
- dest_dir = showDirectoryPicker(cwd,"Select folder for move").value()
- if dest_dir:
- files[mywin.cursor].moveFile(dest_dir)
- files = Files(cwd,db)
- mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
- mywin.refresh() # to clean up the errorBox or FolderPicker
-
- elif c == curses.KEY_RESIZE:
- mywin.resize()
- #mywin.refresh()
- def pre_main():
- # done before we switch to curses mode
- logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
- logging.info("starting curses dirnotes")
- parser = argparse.ArgumentParser(description="Add comments to files")
- parser.add_argument('-c','--config', dest='config_file', help="config file (json format)")
- parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
- parser.add_argument('-d','--db', action='store_true',help="start up in database mode")
- parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
- parser.add_argument('directory', type=str, default='.', nargs='?', help="directory or file to start")
- args = parser.parse_args()
- logging.info(args)
- config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
- if args.db:
- config.mode = "db"
- if args.xattr:
- config.mode = "xattr"
- # print(repr(config))
- # print("start_mode",config["start_mode"])
-
- return args, config
- curses_running = False
- args, config = pre_main()
- mode = config.mode
- xattr_comment = config.xattr_comment
- xattr_author = config.xattr_comment + ".author"
- xattr_date = config.xattr_comment + ".date"
- database_name = config.dbName
- if os.path.isdir(args.directory):
- cwd, start_file = args.directory, None
- else:
- cwd, start_file = os.path.split(args.directory)
- curses_running = True
- curses.wrapper(main, cwd or '.', database_name, start_file)
|