123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793 |
- #!/usr/bin/python3
- # TODO: XDG compatibility: config in ~/.config/dirnotes/dirnotes.conf and data in ~/.local/share/dirnotes/dirnotes.db
- # TODO: clearing a comment out to '' doesn't seem to work on both sides
- # TODO: change banner line to indicate the CWD
- """ a simple gui or command line app
- to view and create/edit file comments
- comments are stored in an SQLite3 database
- default ~/.local/share/dirnotes/dirnotes.db
- where possible, comments are duplicated in
- xattr user.xdg.comment
- some file systems don't allow xattr, and even linux
- doesn't allow xattr on symlinks, so the database is
- considered primary
- these comments stick to the symlink, not the deref
- you can double-click to go into a dir
- """
- VERSION = "0.9"
- helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
- <td align=right>Version: {VERSION}</td></tr></table>
- <h3>Overview</h3>
- This app allows you to add comments to files. The comments are stored in
- a <i>database</i>, and where possible, saved in the <i>xattr</i> (hidden attributes)
- field of the file system.
- <p> Double click on a comment to create or edit.
- <p> You can sort the directory listing by clicking on the column heading.
- <p> Double click on directory names to navigate the file system. Hover over
- fields for more information.
- <h4>xattr extended attributes</h4>
- The <i>xattr</i> comment suffers from a few problems:
- <ul>
- <li>is not implemented on FAT/VFAT/EXFAT file systems (some USB sticks)
- <li>xattrs are not (by default) copied with the file when it's duplicated
- or backedup (<i>mv, rsync</i> and <i>tar</i> work, <i>ssh</i> and <i>scp</i> don't)
- <li>xattrs are not available for symlinks
- <li>some programs which edit files do not preserve the xattrs during file-save (<i>vim</i>)
- </ul>
- On the other hand, <i>xattr</i> comments can be bound directly to files on removable
- media (as long as the disk format allows it).
- <h4>database comments</h4>
- <p><i>Database</i> comments can be applied to any file, even read-only files and executables.
- <p>When the <i>database</i> version of a comment differs from the <i>xattr</i> version,
- the comment box gets a light yellow background.
- <p>To edit a comment, first select it; to replace the comment, just type over it; to edit the comment,
- double-click the mouse, or hit <Enter>.
- """
- import sys,argparse,logging
- from PyQt5.QtGui import *
- from PyQt5.QtWidgets import *
- from PyQt5.QtCore import Qt, pyqtSignal
- 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"
- YEAR = 3600*25*365
- ## globals
- mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"}
- modes = ("db","xattr")
- mode = "db"
- global mainWindow, dbName
- verbose = None
- def print_d(*a):
- if verbose:
- print(*a)
- # >>> 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 <<<
- class HelpWidget(QDialog):
- def __init__(self, parent):
- super(QDialog, self).__init__(parent)
- tb = QLabel(self)
- tb.setWordWrap(True)
- tb.setText(helpMsg)
- tb.setFixedWidth(500)
- pb = QPushButton('OK',self)
- pb.setFixedWidth(200)
- kb = QPushButton('Keyboard Help',self)
- layout = QVBoxLayout(self)
- layout.addWidget(tb)
- lowerBox = QHBoxLayout()
- lowerBox.addWidget(pb)
- lowerBox.addWidget(kb)
- layout.addLayout(lowerBox)
- pb.clicked.connect(self.close)
- kb.clicked.connect(self.showKeyboardHelp)
- self.show()
- def showKeyboardHelp(self):
- KeyboardHelpWidget(self)
- class KeyboardHelpWidget(QDialog):
- def __init__(self, parent):
- super(QDialog, self).__init__(parent)
- tb = QLabel(self)
- tb.setWordWrap(True)
- tb.setText(keyboardHelpMsg)
- tb.setFixedWidth(500)
- pb = QPushButton('OK',self)
- layout = QVBoxLayout(self)
- layout.addWidget(tb)
- layout.addWidget(pb)
- pb.clicked.connect(self.close)
- self.show()
- class errorBox(QDialog):
- def __init__(self, text):
- print_d(f"errorBox: {text}")
- super(QDialog, self).__init__(mainWindow)
- self.layout = QVBoxLayout(self)
- self.tb = QLabel(self)
- self.tb.setWordWrap(True)
- self.tb.setFixedWidth(500)
- self.tb.setText(text)
- self.pb = QPushButton("OK",self)
- self.layout.addWidget(self.tb)
- self.layout.addWidget(self.pb)
- self.pb.clicked.connect(self.close)
- self.show()
- keyboardHelpMsg = """
- <h2>Keyboard Shortcuts</h2>
- <p>
- <table width=100%>
- <tr><td><i>Arrows</i></td><td>normal movement through the table</td></tr>
- <tr><td>Ctrl+N</td><td>sort the listing by filename</td></tr>
- <tr><td>Ctrl+D</td><td>sort the listing by date</td></tr>
- <tr><td>Ctrl+S</td><td>sort the listing by size</td></tr>
- <tr><td>Ctrl+T</td><td>sort the listing by comment</td></tr>
- <tr><td>Ctrl+M</td><td>toggle between <i>database</i> and <i>xattr</i> views</td></tr>
- <tr><td>Alt+C </td><td>copy the file <i>and its comments</i></td></tr>
- <tr><td>Alt+M </td><td>copy the file <i>and its comments</i></td></tr>
- <tr><td>1st column: <i>any letter</i></td><td>jump to file beginning with that letter</td></tr>
- <tr><td>1st column: <Enter> </td><td>change directory</td></tr>
- <tr><td>4th column: <i>any letter</i></td><td>create a comment; replace any existing comment</td></tr>
- <tr><td>4th column: <Enter> </td><td>open an existing comment for edit</td></tr>
- <tr><td>Ctrl+Q</td><td>quit the app</td></tr>
- </table>
- <p>
- NOTE: In edit mode, Ctrl+X, Ctrl+C and Ctrl+V work for cut, copy and paste.
- """
- icon = ["32 32 6 1", # the QPixmap constructor allows for str[]
- " c None",
- ". c #666666",
- "+ c #FFFFFF",
- "@ c #848484",
- "# c #000000",
- "$ c #FCE883",
- " ",
- " ........ ",
- " .++++++++. ",
- " .+++++++++.................. ",
- " .+++++++++++++++++++++++++++. ",
- " .+++++++++++++++++++++++++++. ",
- " .++..+......++@@@@@@@@@@@@@@@@@",
- " .++..++++++++#################@",
- " .+++++++++++#$$$$$$$$$$$$$$$$$#",
- " .++..+.....+#$$$$$$$$$$$$$$$$$#",
- " .++..+++++++#$$$$$$$$$$$$$$$$$#",
- " .+++++++++++#$$#############$$#",
- " .++..+.....+#$$$$$$$$$$$$$$$$$#",
- " .++..+++++++#$$########$$$$$$$#",
- " .+++++++++++#$$$$$$$$$$$$$$$$$#",
- " .++..+.....+#$$$$$$$$$$$$$$$$$#",
- " .++..++++++++#######$$$####### ",
- " .++++++++++++++++++#$$#++++++ ",
- " .++..+............+#$#++++++. ",
- " .++..++++++++++++++##+++++++. ",
- " .++++++++++++++++++#++++++++. ",
- " .++..+............++++++++++. ",
- " .++..+++++++++++++++++++++++. ",
- " .+++++++++++++++++++++++++++. ",
- " .++..+................++++++. ",
- " .++..+++++++++++++++++++++++. ",
- " .+++++++++++++++++++++++++++. ",
- " .++..+................++++++. ",
- " .++..+++++++++++++++++++++++. ",
- " .+++++++++++++++++++++++++++. ",
- " ........................... ",
- " "]
- # sortable TableWidgetItem, based on idea by Aledsandar
- # http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
- # NOTE: the QTableWidgetItem has setData() and data() which may allow data bonding
- # we could use the Qt.DisplayRole/Qt.EditRole for display, and Qt.UserRole for sorting
- # in Qt5, data() binding is more awkward, so do it here
- class SortableTableWidgetItem(QTableWidgetItem):
- def __init__(self, text, sortValue, file_object):
- QTableWidgetItem.__init__(self, text, QTableWidgetItem.UserType + 1)
- self.sortValue = sortValue
- self.file_object = file_object
- def __lt__(self, other):
- return self.sortValue < other.sortValue
-
- class DirNotes(QMainWindow):
- ''' the main window of the app
- '''
- def __init__(self, argFilename, db, parent=None):
- super(DirNotes,self).__init__(parent)
- self.db = db
- self.refilling = False
- self.parent = parent
- longPathName = os.path.abspath(argFilename)
- print_d("longpathname is {}".format(longPathName))
- if os.path.isdir(longPathName):
- self.curPath = longPathName
- filename = ''
- else:
- self.curPath, filename = os.path.split(longPathName)
- print_d("working on <"+self.curPath+"> and <"+filename+">")
- win = QWidget()
- self.setCentralWidget(win)
-
- mb = self.menuBar()
- mf = mb.addMenu('&File')
- mf.addAction("Sort by name", self.sbn, "Ctrl+N")
- mf.addAction("Sort by date", self.sbd, "Ctrl+D")
- mf.addAction("Sort by size", self.sbs, "Ctrl+S")
- mf.addAction("Sort by comment", self.sbc, "Ctrl+T")
- mf.addSeparator()
- mf.addAction("Change mode", self.switchMode, "Ctrl+M")
- mf.addAction("Copy file", self.copyFile, "Alt+C")
- mf.addAction("Move file", self.moveFile, "Alt+M")
- mf.addSeparator()
- mf.addAction("Quit", self.close, QKeySequence.Quit)
- mf.addAction("About", self.about, QKeySequence.HelpContents)
- self.setWindowTitle("==DirNotes== Dir: "+self.curPath)
- self.setMinimumSize(600,700)
- self.setWindowIcon(QIcon(QPixmap(icon)))
- lb = QTableWidget()
- self.lb = lb
- lb.setColumnCount(4)
- lb.horizontalHeader().setSectionResizeMode( 3, QHeaderView.Stretch );
- lb.verticalHeader().setDefaultSectionSize(20); # thinner rows
- lb.verticalHeader().setVisible(False)
-
- self.modeShow = QLabel(win)
- copyIcon = QIcon.fromTheme('edit-copy')
- changeIcon = QIcon.fromTheme('emblem-synchronizing')
- bmode = QPushButton(changeIcon, "change mode (ctrl+m)",win)
- cf = QPushButton(copyIcon, "copy file",win)
- self.thisDirLabel = QLabel(win)
- layout = QVBoxLayout()
- topLayout = QHBoxLayout()
- topLayout.addWidget(self.modeShow)
- topLayout.addWidget(bmode)
- topLayout.addWidget(cf)
- layout.addLayout(topLayout)
- layout.addWidget(self.thisDirLabel)
- layout.addWidget(lb)
- win.setLayout(layout)
-
- lb.itemChanged.connect(self.change)
- lb.cellDoubleClicked.connect(self.double)
- bmode.clicked.connect(self.switchMode)
- cf.clicked.connect(self.copyFile)
- lb.setHorizontalHeaderItem(0,QTableWidgetItem("File"))
- lb.setHorizontalHeaderItem(1,QTableWidgetItem("Date/Time"))
- lb.setHorizontalHeaderItem(2,QTableWidgetItem("Size"))
- lb.setHorizontalHeaderItem(3,QTableWidgetItem("Comment"))
- lb.setSortingEnabled(True)
- self.sameBrush = QBrush(QColor(255,255,255))
- self.differBrush = QBrush(QColor(255,255,160))
- self.refill()
- lb.resizeColumnsToContents()
-
- if filename:
- for i in range(lb.rowCount()):
- if filename == lb.item(i,0).file_object.getDisplayName():
- lb.setCurrentCell(i,0)
- break
- lb.setFocus()
- def sbd(self):
- print_d("sort by date")
- self.lb.sortItems(1,Qt.DescendingOrder)
- def sbs(self):
- print_d("sort by size")
- self.lb.sortItems(2)
- def sbn(self):
- print_d("sort by name")
- self.lb.sortItems(0)
- def sbc(self):
- print_d("sort by comment")
- self.lb.sortItems(3)
- def about(self):
- HelpWidget(self)
- def double(self,row,col):
- print_d("double click {} {}".format(row, col))
- fo = self.lb.item(row,0).file_object
- if col==0 and fo.isDir():
- print_d("double click on {}".format(fo.getName()))
- self.curPath = fo.getName()
- self.refill()
- def keyPressEvent(self,e):
- if e.key() in (Qt.Key_Return, Qt.Key_Enter):
- col = self.lb.currentColumn()
- fo = self.lb.item(self.lb.currentRow(),0).file_object
- if col==0 and fo and fo.isDir():
- self.curPath = fo.getName()
- self.refill()
- return
- if col==3:
- self.lb.editItem(self.lb.currentItem())
- return
- #self.lb.superKeyEvent(e)
- super().keyPressEvent(e)
- def copyMoveFile(self, doCopy, pickerTitle):
- # get current selection
- r, c = self.lb.currentRow(), self.lb.currentColumn()
- fo = self.lb.item(r,c).file_object
- if not fo.isDir() and not fo.isLink() and not fo.isSock():
- print_d(f"{'copy' if doCopy=='copy' else 'move'} file {fo.getName()}")
- # open the dir.picker
- d = QFileDialog.getExistingDirectory(self.parent, pickerTitle)
- if d:
- print_d(f"senf file to {d}")
- fo.copyFile(d) if doCopy=='copy' else fo.moveFile(d)
- def copyFile(self):
- self.copyMoveFile('copy',"Select destination for FileCopy")
- def moveFile(self):
- self.copyMoveFile('move',"Select destination for FileMove")
- self.refill()
- def refill(self):
- self.refilling = True
- self.lb.sortingEnabled = False
- self.directory = FileObj(self.curPath,self.db)
-
- self.thisDirLabel.setText(f'<table width=100%><tr><th><b>{self.directory.getDisplayName()}</b></th><th style"text-align:right;">{self.directory.getComment(mode)}</th></tr></table>')
- (self.modeShow.setText("<i>Showing comments stored in extended attributes</i><br>(xattr: user.xdg.comment)")
- if mode=="xattr" else
- self.modeShow.setText("<i>Showing comments from the database</i><br>(~/.dirnotes.db)"))
- self.lb.clearContents()
- dirIcon = QIcon.fromTheme('folder')
- fileIcon = QIcon.fromTheme('text-x-generic')
- linkIcon = QIcon.fromTheme('emblem-symbolic-link')
- sockIcon = QIcon.fromTheme('emblem-shared')
- try:
- current, dirs, files = next(os.walk(self.curPath,followlinks=True))
- except:
- print(f"{self.curPath} is not a valid directory")
- sys.exit(1)
- dirs.sort()
- files.sort()
-
- if current != '/':
- dirs.insert(0,"..")
- d = dirs + files
- self.lb.setRowCount(len(d))
- #~ print("insert {} items into cleared table {}".format(len(d),current))
- for i,name in enumerate(d):
- this_file = FileObj(os.path.join(current,name),self.db)
- print_d("FileObj created as {} and the db-comment is <{}>".format(this_file.displayName, this_file.getDbComment))
- #~ print("insert order check: {} {} {} {}".format(d[i],i,this_file.getName(),this_file.getDate()))
- display_name = this_file.getDisplayName()
- if this_file.isDir():
- item = SortableTableWidgetItem(display_name,' '+display_name, this_file) # directories sort first
- else:
- item = SortableTableWidgetItem(display_name,display_name, this_file)
- item.setToolTip(this_file.getName())
- item.setFlags(Qt.ItemIsEnabled)
- self.lb.setItem(i,0,item)
- # get the comment from database & xattrs, either can fail
- comment, auth, cdate = this_file.getData(mode)
- other_comment = this_file.getOtherComment(mode)
- ci = SortableTableWidgetItem(comment,comment or '~',this_file)
- ci.setToolTip(f"comment: {comment}\ncomment date: {cdate}\nauthor: {auth}")
- if other_comment != comment:
- ci.setBackground(self.differBrush)
- print_d("got differing comments <{}> and <{}>".format(comment, other_comment))
- ci.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled)
- self.lb.setItem(i,3,ci)
- dt = this_file.getDate()
- da = SortableTableWidgetItem(UiHelper.getShortDate(dt),dt,this_file)
- da.setToolTip(time.strftime(DATE_FORMAT,time.localtime(dt)))
- da.setFlags(Qt.ItemIsEnabled)
- self.lb.setItem(i,1,da)
- si = this_file.getSize()
- if this_file.isDir():
- sa = SortableTableWidgetItem(UiHelper.getShortSize(this_file),0,this_file)
- sa.setIcon(dirIcon)
- elif this_file.isLink():
- sa = SortableTableWidgetItem(UiHelper.getShortSize(this_file),-1,this_file)
- sa.setIcon(linkIcon)
- dst = os.path.realpath(this_file.getName())
- sa.setToolTip(f"symlink: {dst}")
- elif this_file.isSock():
- sa = SortableTableWidgetItem(UiHelper.getShortSize(this_file),-1,this_file)
- sa.setIcon(sockIcon)
- else:
- sa = SortableTableWidgetItem(UiHelper.getShortSize(this_file),si,this_file)
- sa.setIcon(fileIcon)
- sa.setTextAlignment(Qt.AlignRight)
- sa.setFlags(Qt.ItemIsEnabled)
- self.lb.setItem(i,2,sa)
- self.lb.setCurrentCell(0,0)
- self.refilling = False
- self.lb.sortingEnabled = True
- self.lb.resizeColumnToContents(1)
-
- def change(self,x):
- if self.refilling:
- return
- the_file = self.lb.item(x.row(),0).file_object
- print_d(f"debugging {x.text()} r:{str(x.row())} c:{str(x.column())}")
- print_d(f" selected file: {the_file.getName()} new text: >{x.text()}<")
- the_file.setDbComment(str(x.text()))
- the_file.setXattrComment(str(x.text()))
- # set the background (wrap it, because of reentry to .change())
- self.refilling = True
- if the_file.getComment(mode) == the_file.getOtherComment(mode):
- x.setBackground(self.sameBrush)
- else:
- x.setBackground(self.differBrush)
- self.refilling = False
- def switchMode(self):
- global mode
- mode = "xattr" if mode == "db" else "db"
- row,column = self.lb.currentRow(), self.lb.currentColumn()
- self.refill()
- self.lb.setCurrentCell(row,column)
- self.lb.setFocus(True)
-
- def parse():
- parser = argparse.ArgumentParser(description='dirnotes application')
- parser.add_argument('dirname',metavar='dirname',type=str,
- help='directory or file [default=current dir]',default=".",nargs='?')
- #parser.add_argument('dirname2',help='comparison directory, shows two-dirs side-by-side',nargs='?')
- parser.add_argument('-V','--version',action='version',version='%(prog)s '+VERSION)
- parser.add_argument('-v','--verbose',action='count',help="verbose, almost debugging")
- group = parser.add_mutually_exclusive_group()
- group.add_argument( '-s','--sort-by-size',action='store_true')
- group.add_argument( '-m','--sort-by-date',action='store_true')
- parser.add_argument('-c','--config', dest='config_file',help="config file (json format; default ~/.dirnotes.json)")
- parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
- parser.add_argument('-d','--db', action='store_true',help="start up in database mode (default)")
- return parser.parse_args()
- if __name__=="__main__":
- # TODO: delete this after debugging
- #from PyQt5.QtCore import pyqtRemoveInputHook
- #pyqtRemoveInputHook()
- p = parse()
- if len(p.dirname)>1 and p.dirname[-1]=='/':
- p.dirname = p.dirname[:-1]
- if os.path.isdir(p.dirname):
- p.dirname = p.dirname + '/'
- print_d(f"using {p.dirname}")
- verbose = p.verbose
-
- config = ConfigLoader(p.config_file or DEFAULT_CONFIG_FILE)
-
- print_d(f"here is the .json {repr(config)}")
- dbName = config.dbName
- db = DnDataBase(dbName).db
- xattr_comment = config.xattr_comment
- xattr_author = xattr_comment + ".author"
- xattr_date = xattr_comment + ".date"
- mode = config.mode
- if p.xattr:
- mode = "xattr"
- if p.db:
- mode = "db"
- a = QApplication([])
- # TODO: add 'mode' as an argument to contructor; add setMode() as a method
- mainWindow = DirNotes(p.dirname,db)
- if p.sort_by_size:
- mainWindow.sbs()
- if p.sort_by_date:
- mainWindow.sbd()
- mainWindow.show()
- a.exec_()
-
- ''' should we also do user.xdg.tags="TagA,TagB" ?
- user.charset
- user.creator=application_name or user.xdg.creator
- user.xdg.origin.url
- user.xdg.language=[RFC3066/ISO639]
- user.xdg.publisher
- '''
- ''' TODO: also need a way to display-&-restore comments from the database '''
- ''' TODO: implement startup -s and -m for size and date '''
- ''' TODO: create 'show comment history' popup '''
-
- ''' commandline xattr
- getfattr -h (don't follow symlink) -d (dump all properties)
- '''
- ''' CODING NOTES:
- in FileObj, the long absolute name always ends without a /
- the short display name ends with a / if it's a directory
- dates are always in YYYY-MM-DD HH:MM:SS format
- these can be sorted
- '''
|