dirnotes 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. #!/usr/bin/python3
  2. """ a simple gui or command line app
  3. to view and create/edit file comments
  4. comments are stored in an SQLite3 database
  5. default ~/.dirnotes.db
  6. where possible, comments are duplicated in
  7. xattr user.xdg.comment
  8. depends on python-pyxattr
  9. some file systems don't allow xattr, and even linux
  10. doesn't allow xattr on symlinks, so the database is
  11. considered primary
  12. these comments stick to the symlink, not the deref
  13. nav tools are enabled, so you can double-click to go into a dir, and
  14. there's an UP arrow to navigate back
  15. """
  16. helpMsg = """<h1>Dirnotes</h1><h3>Overview</h3>
  17. This app allows you to add comments to files. The comments are stored in
  18. a database, and where possible, saved in the xattr (hidden attributes)
  19. field of the file system.
  20. <p>
  21. You can sort the directory listing by clicking on the column heading.
  22. <p>
  23. Double click on directory names to navigate the file system. Hover over
  24. fields for more information.
  25. <p>
  26. <h3>xattr extended attributes</h3>
  27. The xattr property is handy, but suffers from a few problems:
  28. <ul>
  29. <li>is not implemented on FAT/VFAT/EXFAT file systems (like USB sticks)
  30. <li>xattrs are not (by default) copied with the file when it's duplicated
  31. or backedup
  32. <li>xattrs are not available for symlinks
  33. <li>many programs which edit files do not preserve the xattrs during file-save
  34. </ul>
  35. When the database version of a comment differs from the xattr version, the
  36. comment box gets a light yellow background.
  37. """
  38. import sys,os,argparse,stat
  39. #~ from dirWidget import DirWidget
  40. from PyQt5.QtGui import *
  41. from PyQt5.QtWidgets import *
  42. from PyQt5.QtCore import Qt
  43. import xattr, sqlite3, time
  44. VERSION = "0.2"
  45. COMMENT_KEY = "user.xdg.comment"
  46. DATABASE_NAME = "~/.dirnotes.db"
  47. # convert the ~/ form to a fully qualified path
  48. DATABASE_NAME = os.path.expanduser(DATABASE_NAME)
  49. class dnDataBase:
  50. ''' the database is flat
  51. fileName: fully qualified name
  52. st_mtime: a float
  53. size: a long
  54. comment: a string
  55. comment_time: a float, the time of the comment save
  56. this is effectively a log file, as well as a resource for a restore
  57. (in case a file-move is done without comment)
  58. the database is associated with a user, in the $HOME dir
  59. '''
  60. def __init__(self):
  61. '''try to open the database; if not found, create it'''
  62. try:
  63. self.db = sqlite3.connect(DATABASE_NAME)
  64. except sqlite3.OperationalError:
  65. print("Database %s not found" % DATABASE_NAME)
  66. raise OperationalError
  67. c = self.db.cursor()
  68. try:
  69. c.execute("select * from dirnotes")
  70. except sqlite3.OperationalError:
  71. print("Table %s created" % ("dirnotes"))
  72. c.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME)")
  73. def getData(self, fileName):
  74. c = self.db.cursor()
  75. c.execute("select * from dirnotes where name=? and comment<>'' order by comment_date desc",(os.path.abspath(fileName),))
  76. return c.fetchone()
  77. def setData(self, fileName, comment):
  78. ''' TODO: convert filename to canonical '''
  79. c = self.db.cursor()
  80. s = os.lstat(fileName)
  81. print ("params: %s %s %d %s %s" % ((os.path.abspath(fileName),
  82. dnDataBase.epochToDb(s.st_mtime), s.st_size, comment,
  83. dnDataBase.epochToDb(time.time()))))
  84. try:
  85. c.execute("insert into dirnotes values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'))",
  86. (os.path.abspath(fileName), s.st_mtime, s.st_size, str(comment), time.time()))
  87. self.db.commit()
  88. except sqlite3.OperationalError:
  89. print("database is locked or unwriteable")
  90. #TODO: put up a message box for locked database
  91. @staticmethod
  92. def epochToDb(epoch):
  93. return time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(epoch))
  94. @staticmethod
  95. def DbToEpoch(dbTime):
  96. return time.mktime(time.strptime(dbTime,"%Y-%m-%d %H:%M:%S"))
  97. @staticmethod
  98. def getShortDate(longDate):
  99. e = dnDataBase.DbToEpoch(longDate)
  100. sd = time.strptime(longDate,"%Y-%m-%d %H:%M:%S")
  101. # check for this year, or today
  102. ty = time.mktime((time.localtime()[0],1,1,0,0,0,0,1,-1))
  103. today = time.mktime((time.localtime()[0:3]+(0,0,0,0,1,-1)))
  104. if e < ty:
  105. return time.strftime("%b %d %Y",sd)
  106. elif e < today:
  107. return time.strftime("%b %d",sd)
  108. else:
  109. return time.strftime("%X",sd)
  110. #~ test code for this routine
  111. #~ for i in range(int(time.time() - 370*24*3600),
  112. #~ int(time.time() + 48*3600),
  113. #~ 3599):
  114. #~ ds = dnDataBase.epochToDb(i)
  115. #~ print("%d\t%s\t%s" % (i,ds,dnDataBase.getShortDate(ds)))
  116. def parse():
  117. parser = argparse.ArgumentParser(description='dirnotes application')
  118. parser.add_argument('dirname',metavar='dirname',type=str,
  119. help='directory [default=current dir]',default=".",nargs='?')
  120. parser.add_argument('dirname2',help='comparison directory, shows two-dirs side-by-side',nargs='?')
  121. parser.add_argument('-n','--nogui',action="store_const",const="1",
  122. help='use text base interface')
  123. parser.add_argument('-v','--version',action='version',version='%(prog)s '+VERSION)
  124. group = parser.add_mutually_exclusive_group()
  125. group.add_argument('-s','--sort-by-name',metavar='sort',action="store_const",const='n')
  126. group.add_argument('-m','--sort-by-date',metavar='sort',action='store_const',const='d')
  127. return parser.parse_args()
  128. #~ class FileObj(QtCore.QObject):
  129. class FileObj():
  130. FILE_IS_DIR = -1
  131. FILE_IS_LINK = -2
  132. def __init__(self, fileName):
  133. self.fileName = fileName
  134. s = os.lstat(fileName)
  135. self.date = dnDataBase.epochToDb(s.st_mtime)
  136. if stat.S_ISDIR(s.st_mode):
  137. self.size = FileObj.FILE_IS_DIR
  138. elif stat.S_ISLNK(s.st_mode):
  139. self.size = FileObj.FILE_IS_LINK
  140. else:
  141. self.size = s.st_size
  142. self.xattrComment = ''
  143. try:
  144. self.xattrComment = xattr.get(fileName,COMMENT_KEY,nofollow=True).decode()
  145. except Exception as e:
  146. #print("comment read on %s failed, execption %s" % (self.fileName,e))
  147. pass
  148. def getName(self):
  149. return self.fileName
  150. def getFileName(self):
  151. return os.path.split(self.fileName)[1]
  152. def getXattrComment(self):
  153. return self.xattrComment
  154. def setXattrComment(self,newComment):
  155. self.xattrComment = newComment
  156. try:
  157. xattr.set(self.fileName,COMMENT_KEY,self.xattrComment,nofollow=True)
  158. return True
  159. # we need to move these cases out to a handler
  160. except Exception as e:
  161. print("problem setting the comment on file %s" % (self.fileName,))
  162. print("error "+repr(e))
  163. ## todo: elif file.is_sym() the kernel won't allow comments on symlinks....stored in database
  164. if self.size == FileObj.FILE_IS_LINK:
  165. print("Linux does not allow comments on symlinks; comment is stored in database")
  166. elif os.access(self.fileName, os.W_OK)!=True:
  167. print("you don't appear to have write permissions on this file")
  168. # change the listbox background to yellow
  169. self.displayBox.notifyUnchanged()
  170. elif "Errno 95" in str(e):
  171. print("is this a VFAT or EXFAT volume? these don't allow comments")
  172. return False
  173. def getDbComment(self,db):
  174. self.dbComment = ""
  175. row = db.getData(self.fileName)
  176. if row and len(row)>1:
  177. self.dbComment = row[3]
  178. def setDbComment(self,db,comment):
  179. db.setData(self.fileName,comment)
  180. def getDate(self):
  181. return self.date
  182. def getSize(self):
  183. return self.size
  184. class HelpWidget(QDialog):
  185. def __init__(self, parent):
  186. super(QDialog, self).__init__(parent)
  187. self.layout = QVBoxLayout(self)
  188. self.tb = QLabel(self)
  189. self.tb.setWordWrap(True)
  190. self.tb.setText(helpMsg)
  191. self.tb.setFixedWidth(500)
  192. self.pb = QPushButton('OK',self)
  193. self.pb.setFixedWidth(200)
  194. self.layout.addWidget(self.tb)
  195. self.layout.addWidget(self.pb)
  196. self.pb.pressed.connect(self.close)
  197. self.show()
  198. # sortable TableWidgetItem, based on idea by Aledsandar
  199. # http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
  200. # NOTE: the QTableWidgetItem has setData() and data() which may allow data bonding
  201. # in Qt5, data() binding is more awkward, so do it here
  202. class SortableTableWidgetItem(QTableWidgetItem):
  203. def __init__(self, text, sortValue, file_object):
  204. QTableWidgetItem.__init__(self, text, QTableWidgetItem.UserType)
  205. self.sortValue = sortValue
  206. self.file_object = file_object
  207. def __lt__(self, other):
  208. return self.sortValue < other.sortValue
  209. class DirNotes(QMainWindow):
  210. ''' the main window of the app
  211. has 3 list boxes: dir_left, dir_right (may be invisible) and files
  212. '''
  213. def __init__(self, argFilename, db, parent=None):
  214. super(DirNotes,self).__init__(parent)
  215. self.db = db
  216. self.refilling = False
  217. win = QWidget()
  218. self.setCentralWidget(win)
  219. lb = QTableWidget()
  220. self.lb = lb
  221. lb.setColumnCount(4)
  222. lb.horizontalHeader().setSectionResizeMode( 3, QHeaderView.Stretch );
  223. lb.verticalHeader().setDefaultSectionSize(20); # thinner rows
  224. lb.verticalHeader().setVisible(False)
  225. # resize the comments column
  226. # and resize the parent window to match the directory size
  227. # allow multiple entries on the line at this point
  228. #d = os.listdir(p.filename[0])
  229. #d.sort()
  230. longPathName = os.path.abspath(argFilename)
  231. print("longpathname is {}".format(longPathName))
  232. if os.path.isdir(longPathName):
  233. self.curPath = longPathName
  234. filename = ''
  235. else:
  236. self.curPath, filename = os.path.split(longPathName)
  237. print("working on <"+self.curPath+"> and <"+filename+">")
  238. lb.setHorizontalHeaderItem(0,QTableWidgetItem("File"))
  239. lb.setHorizontalHeaderItem(1,QTableWidgetItem("Date/Time"))
  240. lb.setHorizontalHeaderItem(2,QTableWidgetItem("Size"))
  241. lb.setHorizontalHeaderItem(3,QTableWidgetItem("Comment"))
  242. lb.setSortingEnabled(True)
  243. self.refill()
  244. lb.resizeColumnsToContents()
  245. if len(filename)>0:
  246. for i in range(lb.rowCount()):
  247. if filename == lb.item(i,0).data(32).toPyObject().getFileName():
  248. lb.setCurrentCell(i,3)
  249. break
  250. layout = QVBoxLayout()
  251. e = QLabel("View and edit file comments stored in extended attributes user.xdg.comment",win)
  252. layout.addWidget(e)
  253. bup = QPushButton("up",win)
  254. bup.setIcon(QIcon.fromTheme('go-up')) # or go-previous
  255. bup.setFixedWidth(100)
  256. b1 = QPushButton("restore from database",win)
  257. upperLayout = QHBoxLayout()
  258. upperLayout.addWidget(bup)
  259. upperLayout.addWidget(b1)
  260. layout.addLayout(upperLayout)
  261. layout.addWidget(lb)
  262. win.setLayout(layout)
  263. lb.itemChanged.connect(self.change)
  264. lb.cellDoubleClicked.connect(self.double)
  265. b1.pressed.connect(self.restore_from_database)
  266. bup.pressed.connect(self.upOneLevel)
  267. mb = self.menuBar()
  268. mf = mb.addMenu('&File')
  269. mf.addAction("Sort by name", self.sbn, "Ctrl+N")
  270. mf.addAction("Sort by date", self.sbd, "Ctrl+D")
  271. mf.addAction("Sort by size", self.sbs, "Ctrl+Z")
  272. mf.addAction("Sort by comment", self.sbc, "Ctrl+.")
  273. mf.addAction("Restore comment from database", self.restore_from_database, "Ctrl+R")
  274. mf.addSeparator()
  275. mf.addAction("Quit", self.close, "Ctrl+Q")
  276. mf.addAction("About", self.about, "Ctrl+H")
  277. #~ QShortcut(QKeySequence("Ctrl+Q"), self, self.close)
  278. self.setWindowTitle("DirNotes Alt-F for menu Dir: "+self.curPath)
  279. self.setMinimumSize(600,700)
  280. lb.setFocus()
  281. def closeEvent(self,e):
  282. print("closing")
  283. def sbd(self):
  284. print("sort by date")
  285. self.lb.sortItems(1,Qt.DescendingOrder)
  286. def sbs(self):
  287. print("sort by size")
  288. self.lb.sortItems(2)
  289. def sbn(self):
  290. print("sort by name")
  291. self.lb.sortItems(0)
  292. def about(self):
  293. HelpWidget(self)
  294. def sbc(self):
  295. print("sort by comment")
  296. self.lb.sortItems(3,Qt.DescendingOrder)
  297. def newDir(self):
  298. print("change dir to "+self.dirLeft.currentPath())
  299. def double(self,row,col):
  300. print("double click {} {}".format(row, col))
  301. fo = self.lb.item(row,0).file_object
  302. if col==0 and fo.getSize() == FileObj.FILE_IS_DIR:
  303. print("double click on {}".format(fo.getName()))
  304. self.curPath = fo.getName()
  305. self.refill()
  306. def upOneLevel(self):
  307. self.curPath = os.path.abspath(self.curPath + "/..")
  308. self.refill()
  309. def refill(self):
  310. self.refilling = True
  311. self.lb.sortingEnabled = False
  312. self.lb.clearContents()
  313. small_font = QFont("",8)
  314. dirIcon = QIcon.fromTheme('folder')
  315. fileIcon = QIcon.fromTheme('text-x-generic')
  316. linkIcon = QIcon.fromTheme('emblem-symbolic-link')
  317. current, dirs, files = next(os.walk(self.curPath,followlinks=True))
  318. dirs = list(map(lambda x:x+'/', dirs))
  319. dirs.sort()
  320. files.sort()
  321. d = dirs + files
  322. self.lb.setRowCount(len(d))
  323. #~ self.files = {}
  324. self.files = []
  325. # this is a list of all the file
  326. #~ print("insert {} items into cleared table {}".format(len(d),current))
  327. for i in range(len(d)):
  328. this_file = FileObj(current+'/'+d[i])
  329. this_file.getDbComment(self.db)
  330. print("FileObj created as {} and the db-comment is <{}>".format(this_file.fileName, this_file.dbComment))
  331. #~ print("insert order check: {} {} {} {}".format(d[i],i,this_file.getName(),this_file.getDate()))
  332. #~ self.files.update({this_file.getName(),this_file})
  333. self.files = self.files + [this_file]
  334. if this_file.getSize() == FileObj.FILE_IS_DIR:
  335. item = SortableTableWidgetItem(d[i],' '+d[i], this_file) # directories sort first
  336. else:
  337. item = SortableTableWidgetItem(d[i],d[i], this_file)
  338. item.setFlags(Qt.ItemIsEnabled)
  339. #item.setData(32,this_file) # keep a hidden copy of the file object
  340. item.setToolTip(this_file.getName())
  341. self.lb.setItem(i,0,item)
  342. #lb.itemAt(i,0).setFlags(Qt.ItemIsEnabled) #NoItemFlags)
  343. # get the comment from database & xattrs, either can fail
  344. comment = ""
  345. row = self.db.getData(this_file.getName())
  346. if row and len(row)>0:
  347. comment = row[3]
  348. xattrComment = this_file.getXattrComment()
  349. self.lb.setItem(i,3,QTableWidgetItem(comment))
  350. if xattrComment != comment:
  351. self.lb.item(i,3).setBackground(QBrush(Qt.yellow))
  352. print("got two comments <{}> and <{}>".format(comment, xattrComment))
  353. dt = this_file.getDate()
  354. da = SortableTableWidgetItem(dnDataBase.getShortDate(dt),dt,this_file)
  355. #da.setFont(small_font)
  356. da.setToolTip(dt)
  357. da.setFlags(Qt.ItemIsEnabled)
  358. self.lb.setItem(i,1,da)
  359. si = this_file.getSize()
  360. if si==FileObj.FILE_IS_DIR:
  361. sa = SortableTableWidgetItem('',0,this_file)
  362. item.setIcon(dirIcon)
  363. elif si==FileObj.FILE_IS_LINK:
  364. sa = SortableTableWidgetItem('symlink',-1,this_file)
  365. item.setIcon(linkIcon)
  366. dst = os.path.realpath(this_file.getName())
  367. sa.setToolTip("symlink: " + dst)
  368. else:
  369. sa = SortableTableWidgetItem(str(si),si,this_file)
  370. item.setIcon(fileIcon)
  371. sa.setTextAlignment(Qt.AlignRight)
  372. sa.setFlags(Qt.ItemIsEnabled)
  373. self.lb.setItem(i,2,sa)
  374. self.refilling = False
  375. self.lb.sortingEnabled = True
  376. self.lb.resizeColumnToContents(1)
  377. def change(self,x):
  378. if self.refilling:
  379. return
  380. print("debugging " + x.text() + " r:" + str(x.row()) + " c:" + str(x.column()))
  381. print(" selected file: "+self.lb.item(x.row(),0).file_object.getName())
  382. the_file = self.lb.item(x.row(),0).file_object
  383. print(" and the row file is "+the_file.getName())
  384. r = the_file.setXattrComment(str(x.text())) # TODO: change priority
  385. if r:
  386. self.db.setData(the_file.getName(),x.text())
  387. def restore_from_database(self):
  388. print("restore from database")
  389. # retrieve the full path name
  390. fileName = str(self.lb.item(self.lb.currentRow(),0).file_object.getName())
  391. print("using filename: "+fileName)
  392. existing_comment = str(self.lb.item(self.lb.currentRow(),3).text())
  393. print("restore....existing="+existing_comment+"=")
  394. if len(existing_comment) > 0:
  395. m = QMessageBox()
  396. m.setText("This file already has a comment. Overwrite?")
  397. m.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel);
  398. if m.exec_() != QMessageBox.Ok:
  399. return
  400. fo_row = self.db.getData(fileName)
  401. if fo_row and len(fo_row)>1:
  402. comment = fo_row[3]
  403. print(fileName,fo_row[0],comment)
  404. the_file = dn.files[self.lb.currentRow()]
  405. the_file.setComment(comment)
  406. self.lb.setItem(self.lb.currentRow(),3,QTableWidgetItem(comment))
  407. if __name__=="__main__":
  408. p = parse()
  409. if len(p.dirname)>1 and p.dirname[-1]=='/':
  410. p.dirname = p.dirname[:-1]
  411. if os.path.isdir(p.dirname):
  412. p.dirname = p.dirname + '/'
  413. print(p.dirname)
  414. db = dnDataBase()
  415. # TODO: if there's a file specified, jump to it
  416. a = QApplication([])
  417. dn = DirNotes(p.dirname,db)
  418. dn.show()
  419. a.exec_()
  420. #xattr.setxattr(filename,COMMENT_KEY,commentText)
  421. ''' files from directories
  422. use os.isfile()
  423. os.isdir()
  424. current, dirs, files = os.walk("path").next()
  425. possible set folllowLinks=True'''
  426. ''' notes from the wdrm project
  427. table showed
  428. filename, size, date size, date, desc
  429. at start, fills the list of all the files
  430. skip the . entry
  431. '''
  432. ''' should we also do user.xdg.tags="TagA,TagB" ?
  433. user.charset
  434. user.creator=application_name or user.xdg.creator
  435. user.xdg.origin.url
  436. user.xdg.language=[RFC3066/ISO639]
  437. user.xdg.publisher
  438. '''
  439. ''' TODO: add cut-copy-paste for comments '''
  440. ''' TODO: also need a way to display-&-restore comments from the database '''
  441. ''' commandline xattr
  442. getfattr -h (don't follow symlink) -d (dump all properties)
  443. '''
  444. ''' if the args line contains a file, jump to it '''