#!/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"""
Dirnotes | Version: {VERSION} |
Double click on a comment to create or edit.
You can sort the directory listing by clicking on the column heading.
Double click on directory names to navigate the file system. Hover over fields for more information.
Database comments can be applied to any file, even read-only files and executables.
When the database version of a comment differs from the xattr version, the comment box gets a light yellow background.
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":"
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'Keyboard Shortcuts
Arrows normal movement through the table Ctrl+N sort the listing by filename Ctrl+D sort the listing by date Ctrl+S sort the listing by size Ctrl+T sort the listing by comment Ctrl+M toggle between database and xattr views Alt+C copy the file and its comments Alt+M copy the file and its comments 1st column: any letter jump to file beginning with that letter 1st column: <Enter> change directory 4th column: any letter create a comment; replace any existing comment 4th column: <Enter> open an existing comment for edit Ctrl+Q quit the app
')
(self.modeShow.setText("Showing comments stored in extended attributes{self.directory.getDisplayName()} {self.directory.getComment(mode)}
(xattr: user.xdg.comment)")
if mode=="xattr" else
self.modeShow.setText("Showing comments from the database
(~/.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
'''