dirnotes 13 KB

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