dirnotes 14 KB

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