|
@@ -1,7 +1,5 @@
|
|
|
#!/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
|
|
@@ -24,7 +22,7 @@ import curses, sqlite3, curses.textpad
|
|
|
import logging, getpass, argparse
|
|
|
import json
|
|
|
|
|
|
-VERSION = "1.7"
|
|
|
+VERSION = "1.8"
|
|
|
# these may be different on MacOS
|
|
|
xattr_comment = "user.xdg.comment"
|
|
|
xattr_author = "user.xdg.comment.author"
|
|
@@ -63,18 +61,6 @@ CMD_CD = ord('\n')
|
|
|
# ~/.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
|
|
@@ -93,38 +79,10 @@ COLOR_THEME = ''' { "heading": ("yellow","blue"),
|
|
|
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
|
|
|
+verbose = None
|
|
|
+def print_v(*a):
|
|
|
+ if verbose:
|
|
|
+ print(*a)
|
|
|
|
|
|
class Pane:
|
|
|
''' holds the whole display: handles file list directly,
|
|
@@ -143,11 +101,12 @@ class Pane:
|
|
|
|
|
|
most methods take y=0..h-1 where y is the line number WITHIN the borders
|
|
|
'''
|
|
|
- def __init__(self, win, curdir, files):
|
|
|
+ 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()
|
|
|
|
|
@@ -185,18 +144,20 @@ class Pane:
|
|
|
|
|
|
if self.some_comments_differ:
|
|
|
self.setStatus("The xattr and database comments differ where shown in green")
|
|
|
- # time.sleep(2)
|
|
|
+ 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.border() # TODO: or .box() ?
|
|
|
+ self.win.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
|
|
|
+ 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')
|
|
@@ -215,27 +176,31 @@ class Pane:
|
|
|
# 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()}")
|
|
|
- # 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.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() or ''
|
|
|
+ other = f.getOtherComment() 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,self.w-self.sep3-2)
|
|
|
+ self.file_pad.addnstr(y,self.sep3,comment or ' ',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)
|
|
@@ -280,76 +245,47 @@ class Pane:
|
|
|
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)
|
|
|
+ def __init__(self,directory,db):
|
|
|
+ self.db = db
|
|
|
|
|
|
- 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()
|
|
|
+ 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)
|
|
|
|
|
|
- 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)")
|
|
|
+ current, dirs, non_dirs = next(os.walk(directory))
|
|
|
+ except:
|
|
|
+ errorBox(f"{directory} is not a valid directory")
|
|
|
+ raise
|
|
|
+ if current != '/':
|
|
|
+ dirs.insert(0,"..")
|
|
|
|
|
|
- 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")
|
|
|
+ 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.getFileName() == '..':
|
|
|
+ if a.getDisplayName() == '../':
|
|
|
return "\x00"
|
|
|
if a.isDir():
|
|
|
- return ' ' + a.getFileName()
|
|
|
+ return ' ' + a.getDisplayName()
|
|
|
# else:
|
|
|
- return a.getFileName()
|
|
|
+ return a.getDisplayName()
|
|
|
|
|
|
def sortDate(a):
|
|
|
- if a.getFileName() == '..':
|
|
|
+ if a.getDisplayName() == '../':
|
|
|
return 0
|
|
|
return a.getDate()
|
|
|
|
|
|
def sortSize(a):
|
|
|
- if a.getFileName() == '..':
|
|
|
+ if a.getDisplayName() == '../':
|
|
|
return 0
|
|
|
return a.getSize()
|
|
|
|
|
@@ -374,129 +310,236 @@ class Files():
|
|
|
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
|
|
|
+ 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)
|
|
|
|
|
|
+############# the dnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
|
|
|
|
|
|
-## 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
|
|
|
+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")
|
|
|
+}
|
|
|
+
|
|
|
+class ConfigLoader: # singleton
|
|
|
+ def __init__(self, configFile):
|
|
|
+ configFile = os.path.expanduser(configFile)
|
|
|
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
|
|
|
+ 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:
|
|
|
+ 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:
|
|
|
+ logging.error(f"Database {dbFile} not found")
|
|
|
+ raise
|
|
|
+
|
|
|
+ # create new database if it doesn't exist
|
|
|
+ try:
|
|
|
+ self.db.execute("select * from dirnotes")
|
|
|
+ except sqlite3.OperationalError:
|
|
|
+ print_v(f"Table dirnotes created")
|
|
|
+ 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)")
|
|
|
+ # 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
|
|
|
+
|
|
|
+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):
|
|
|
return self.fileName
|
|
|
- def getFileName(self):
|
|
|
+ def getDisplayName(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
|
|
|
-
|
|
|
+ # new methods; no caching
|
|
|
+ def getDbData(self):
|
|
|
+ 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.dbComment
|
|
|
- def getDbAuthor(self):
|
|
|
- return self.dbAuthor
|
|
|
- def getDbDate(self):
|
|
|
- return self.dbDate
|
|
|
+ return self.getDbData()[0]
|
|
|
+
|
|
|
+ def getXattrData(self):
|
|
|
+ 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):
|
|
|
- try:
|
|
|
- self.db = sqlite3.connect(database_name)
|
|
|
- except sqlite3.OperationalError:
|
|
|
- logging.info(f"database {database_name} not found")
|
|
|
- raise OperationalError
|
|
|
- c = self.db.cursor()
|
|
|
+ # 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:
|
|
|
- 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,
|
|
|
+ print_v(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()
|
|
|
- logging.info(f"database write for {self.fileName}")
|
|
|
- self.dbComment = newComment
|
|
|
+ self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
|
|
|
except sqlite3.OperationalError:
|
|
|
- logging.info("database is locked or unwritable")
|
|
|
+ print_v("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):
|
|
|
- # TODO: make sure it's a string, not an int
|
|
|
- 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}")
|
|
|
+ print_v(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
|
|
|
+ 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:
|
|
|
- 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")
|
|
|
+ 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("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()
|
|
|
+ return self.getDbComment() if mode == "db" else self.getXattrComment()
|
|
|
def getOtherComment(self):
|
|
|
return self.getDbComment() if mode == "xattr" else self.getXattrComment()
|
|
|
+ def getData(self):
|
|
|
+ return self.getDbData() if mode == "db" else self.getXattrData()
|
|
|
+ def getOtherData(self):
|
|
|
+ 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 self.size == self.FILE_IS_DIR
|
|
|
+ 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, destDir):
|
|
|
+ # NOTE: this method copies the xattr (comment + old author + old date)
|
|
|
+ # but creates new db (comment + this author + new date)
|
|
|
+ if stat.S_ISREG(self.stat.st_mode):
|
|
|
+ dest = os.path.join(destDir,self.displayName)
|
|
|
+ try:
|
|
|
+ print("try copy from",self.fileName,"to",dest)
|
|
|
+ shutil.copy2(self.fileName, dest)
|
|
|
+ except:
|
|
|
+ errorBox(f"file copy to <{dest}> failed; check permissions")
|
|
|
+ f = FileObj(dest, self.db)
|
|
|
+ print("dest object created",repr(f))
|
|
|
+ f.setDbComment(self.getDbComment())
|
|
|
|
|
|
########## dest folder picker ###############
|
|
|
# returns None if the user hits <esc>
|
|
@@ -513,7 +556,7 @@ class showFolderPicker:
|
|
|
self.h, self.w = self.W.getmaxyx()
|
|
|
self.W.keypad(True)
|
|
|
#self.W.clear()
|
|
|
- self.W.border()
|
|
|
+ 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()
|
|
@@ -595,7 +638,7 @@ def paint_dialog(b_color,data):
|
|
|
w = curses.newwin(len(lines)+2,n+3,5,5)
|
|
|
w.bkgd(' ',b_color)
|
|
|
w.clear()
|
|
|
- w.border()
|
|
|
+ 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 :(
|
|
@@ -675,8 +718,6 @@ def show_help2():
|
|
|
c = w.getch()
|
|
|
del w
|
|
|
|
|
|
-#TODO: fix this to paint_dialog
|
|
|
-#TODO: fix to allow upper/lower case responses
|
|
|
sort_string = """
|
|
|
Select sort order:
|
|
|
|
|
@@ -703,16 +744,8 @@ Comments detail:
|
|
|
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()
|
|
|
+ c,a,d = f.getData() # 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,h.getmaxyx()[1]-13)
|
|
|
h.addstr(3,12,a if a else "<not set>")
|
|
|
h.addstr(4,12,d if d else "<not set>")
|
|
@@ -733,7 +766,7 @@ def edit_fn(c):
|
|
|
return 7
|
|
|
return c
|
|
|
|
|
|
-def main(w, cwd):
|
|
|
+def main(w, cwd, database_file, start_file):
|
|
|
global files, edit_done, mode
|
|
|
global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
|
|
|
|
|
@@ -752,10 +785,12 @@ def main(w, cwd):
|
|
|
COLOR_DIFFER = curses.color_pair(CP_DIFFER)
|
|
|
logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
|
|
|
|
|
|
- files = Files(cwd)
|
|
|
+ db = DnDataBase(database_file).db
|
|
|
+
|
|
|
+ files = Files(cwd,db)
|
|
|
logging.info(f"got files, len={len(files)}")
|
|
|
|
|
|
- mywin = Pane(w,cwd,files)
|
|
|
+ mywin = Pane(w,cwd,files,start_file = start_file)
|
|
|
|
|
|
showing_edit = False
|
|
|
|
|
@@ -807,7 +842,7 @@ def main(w, cwd):
|
|
|
|
|
|
elif c == CMD_RELOAD:
|
|
|
where = files.getCurDir().fileName
|
|
|
- files = Files(where)
|
|
|
+ files = Files(where,db)
|
|
|
mywin = Pane(w,where,files)
|
|
|
|
|
|
elif c == CMD_CD:
|
|
@@ -815,22 +850,22 @@ def main(w, cwd):
|
|
|
if f.isDir():
|
|
|
cwd = f.getName()
|
|
|
logging.info(f"CD change to {cwd}")
|
|
|
- files = Files(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.border()
|
|
|
+ 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.getFileName()}")
|
|
|
+ mywin.setStatus(f"Edit file: {f.getName()}")
|
|
|
existing_comment = f.getXattrComment()
|
|
|
- edit_sub.addstr(0,0,existing_comment)
|
|
|
+ edit_sub.addstr(0,0,existing_comment or '')
|
|
|
text = curses.textpad.Textbox(edit_sub)
|
|
|
edit_window.refresh()
|
|
|
|
|
@@ -854,30 +889,28 @@ def main(w, cwd):
|
|
|
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.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(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())
|
|
|
+ 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:
|
|
|
- if f.getDbComment():
|
|
|
- f.setXattrComment(f.getDbComment())
|
|
|
+ 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()
|
|
|
|
|
@@ -896,7 +929,7 @@ def main(w, cwd):
|
|
|
logging.info(f"copy from {src} to {dest_dir}")
|
|
|
shutil.copy2(src, dest_dir)
|
|
|
# and copy the database record
|
|
|
- f = FileObj(dest)
|
|
|
+ f = FileObj(dest,files.db)
|
|
|
f.setDbComment(files[mywin.cursor].getDbComment())
|
|
|
mywin.refresh()
|
|
|
|
|
@@ -915,9 +948,9 @@ def main(w, cwd):
|
|
|
logging.info(f"move from {src} to {dest_dir}")
|
|
|
shutil.move(src, dest_dir)
|
|
|
# and copy the database record
|
|
|
- f = FileObj(dest)
|
|
|
+ f = FileObj(dest,files.db)
|
|
|
f.setDbComment(files[mywin.cursor].getDbComment())
|
|
|
- files = Files(cwd)
|
|
|
+ files = Files(cwd,db)
|
|
|
mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
|
|
|
|
|
|
elif c == curses.KEY_RESIZE:
|
|
@@ -932,49 +965,32 @@ def pre_main():
|
|
|
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")
|
|
|
+ parser.add_argument('directory', type=str, default='.', nargs='?', help="directory or file 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)
|
|
|
+ config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
|
|
|
|
|
|
# print(repr(config))
|
|
|
# print("start_mode",config["start_mode"])
|
|
|
|
|
|
return args, config
|
|
|
|
|
|
+curses_running = False
|
|
|
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
|
|
|
+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.wrapper(main, cwd)
|
|
|
+curses_running = True
|
|
|
+curses.wrapper(main, cwd or '.', database_name, start_file)
|
|
|
|
|
|
# dirnotes database is name, date, size, comment, comment_date, author
|
|
|
|