dirnotes 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. #!/usr/bin/python3
  2. # TODO: pick up comment for cwd and display at the top somewhere, or maybe status line
  3. """ a simple gui or command line app
  4. to view and create/edit file comments
  5. comments are stored in an SQLite3 database
  6. default ~/.dirnotes.db
  7. where possible, comments are duplicated in
  8. xattr user.xdg.comment
  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
  14. """
  15. VERSION = "0.4"
  16. helpMsg = f"""<table width=100%><tr><td><h1>Dirnotes</h1><td>
  17. <td align=right>Version: {VERSION}</td></tr></table>
  18. <h3>Overview</h3>
  19. This app allows you to add comments to files. The comments are stored in
  20. a <i>database</i>, and where possible, saved in the <i>xattr</i> (hidden attributes)
  21. field of the file system.
  22. <p> Double click on a comment to create or edit.
  23. <p> You can sort the directory listing by clicking on the column heading.
  24. <p> Double click on directory names to navigate the file system. Hover over
  25. fields for more information.
  26. <h3>xattr extended attributes</h3>
  27. The xattr comment suffers from a few problems:
  28. <ul>
  29. <li>is not implemented on FAT/VFAT/EXFAT file systems (some USB sticks)
  30. <li>xattrs are not (by default) copied with the file when it's duplicated
  31. or backedup (<i>mv, rsync</i> and <i>tar</i> work, <i>ssh</i> and <i>scp</i> don't)
  32. <li>xattrs are not available for symlinks
  33. <li>some programs which edit files do not preserve the xattrs during file-save (<i>vim</i>)
  34. </ul>
  35. On the other hand, <i>xattr</i> comments can be bound directly to files on removable
  36. media (as long as the disk format allows it).
  37. <p>When the <i>database</i> version of a comment differs from the <i>xattr</i> version,
  38. the comment box gets a light yellow background.
  39. """
  40. import sys,os,argparse,stat,getpass
  41. #~ from dirWidget import DirWidget
  42. from PyQt5.QtGui import *
  43. from PyQt5.QtWidgets import *
  44. from PyQt5.QtCore import Qt, pyqtSignal
  45. import sqlite3, json, time
  46. VERSION = "0.4"
  47. xattr_comment = "user.xdg.comment"
  48. xattr_author = "user.xdg.comment.author"
  49. xattr_date = "user.xdg.comment.date"
  50. DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
  51. YEAR = 3600*25*365
  52. ## globals
  53. mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"}
  54. modes = ("db","xattr")
  55. mode = "db"
  56. global mainWindow, dbName
  57. DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
  58. # config
  59. # we could store the config in the database, in a second table
  60. # or in a .json file
  61. DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
  62. "database":"~/.dirnotes.db",
  63. "start_mode":"xattr",
  64. "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
  65. "options for start_mode":("db","xattr")
  66. }
  67. verbose = None
  68. def print_v(*a):
  69. if verbose:
  70. print(*a)
  71. class dnDataBase:
  72. ''' the database is flat
  73. fileName: fully qualified name
  74. st_mtime: a float
  75. size: a long
  76. comment: a string
  77. comment_time: a float, the time of the comment save
  78. author: the username that created the comment
  79. the database is associated with a user, in the $HOME dir
  80. '''
  81. def __init__(self,dbFile):
  82. '''try to open the database; if not found, create it'''
  83. try:
  84. self.db = sqlite3.connect(dbFile)
  85. except sqlite3.OperationalError:
  86. print(f"Database {dbFile} not found")
  87. raise
  88. self.db_cursor = self.db.cursor()
  89. try:
  90. self.db_cursor.execute("select * from dirnotes")
  91. except sqlite3.OperationalError:
  92. print_v("Table %s created" % ("dirnotes"))
  93. self.db_cursor.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
  94. def getData(self, fileName):
  95. self.db_cursor.execute("select * from dirnotes where name=? and comment<>'' order by comment_date desc",(os.path.abspath(fileName),))
  96. return self.db_cursor.fetchone()
  97. def setData(self, fileName, comment):
  98. s = os.lstat(fileName)
  99. print_v ("params: %s %s %d %s %s" % ((os.path.abspath(fileName),
  100. dnDataBase.epochToDb(s.st_mtime), s.st_size, comment,
  101. dnDataBase.epochToDb(time.time()))))
  102. try:
  103. self.db_cursor.execute("insert into dirnotes values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  104. (os.path.abspath(fileName), s.st_mtime, s.st_size, str(comment), time.time(), getpass.getuser()))
  105. self.db.commit()
  106. except sqlite3.OperationalError:
  107. print("database is locked or unwriteable")
  108. errorBox("database is locked or unwriteable")
  109. #TODO: put up a message box for locked database
  110. @staticmethod
  111. def epochToDb(epoch):
  112. return time.strftime(DATE_FORMAT,time.localtime(epoch))
  113. @staticmethod
  114. def DbToEpoch(dbTime):
  115. return time.mktime(time.strptime(dbTime,DATE_FORMAT))
  116. @staticmethod
  117. def getShortDate(longDate):
  118. now = time.time()
  119. diff = now - longDate
  120. if diff > YEAR:
  121. fmt = "%b %e %Y"
  122. else:
  123. fmt = "%b %d %H:%M"
  124. return time.strftime(fmt, time.localtime(longDate))
  125. ## one for each file
  126. ## and a special one for ".." parent directory
  127. class FileObj():
  128. FILE_IS_DIR = -1
  129. FILE_IS_LINK = -2
  130. FILE_IS_SOCKET = -3
  131. def __init__(self, fileName):
  132. self.fileName = os.path.abspath(fileName)
  133. self.displayName = os.path.split(fileName)[1]
  134. s = os.lstat(self.fileName)
  135. self.date = s.st_mtime
  136. if stat.S_ISDIR(s.st_mode):
  137. self.size = FileObj.FILE_IS_DIR
  138. self.displayName += '/'
  139. elif stat.S_ISLNK(s.st_mode):
  140. self.size = FileObj.FILE_IS_LINK
  141. elif stat.S_ISSOCK(s.st_mode):
  142. self.size = FileObj.FILE_IS_SOCKET
  143. else:
  144. self.size = s.st_size
  145. self.xattrComment = ''
  146. self.xattrAuthor = None
  147. self.xattrDate = None
  148. self.dbComment = ''
  149. self.dbAuthor = None
  150. self.dbDate = None
  151. self.commentsDiffer = False
  152. try:
  153. self.xattrComment = os.getxattr(fileName, xattr_comment, follow_symlinks=False).decode()
  154. self.xattrAuthor = os.getxattr(fileName, xattr_author, follow_symlinks=False).decode()
  155. self.xattrDate = os.getxattr(fileName, xattr_date, follow_symlinks=False).decode()
  156. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  157. except: # no xattr comment
  158. pass
  159. def getName(self):
  160. return self.fileName
  161. def getFileName(self):
  162. return self.displayName
  163. # with an already open database cursor
  164. def loadDbComment(self,cursor):
  165. cursor.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
  166. a = cursor.fetchone()
  167. if a:
  168. self.dbComment, self.dbAuthor, self.dbDate = a
  169. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  170. def getDbComment(self):
  171. return self.dbComment
  172. def getDbAuthor(self):
  173. return self.dbAuthor
  174. def getDbDate(self):
  175. return self.dbDate
  176. def setDbComment(self,newComment):
  177. try:
  178. self.db = sqlite3.connect(dbName)
  179. except sqlite3.OperationalError:
  180. print_v(f"database {dbName} not found")
  181. raise
  182. c = self.db.cursor()
  183. s = os.lstat(self.fileName)
  184. try:
  185. c.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  186. (os.path.abspath(self.fileName), s.st_mtime, s.st_size,
  187. str(newComment), time.time(), getpass.getuser()))
  188. self.db.commit()
  189. print_v(f"database write for {self.fileName}")
  190. self.dbComment = newComment
  191. except sqlite3.OperationalError:
  192. print_v("database is locked or unwritable")
  193. errorBox("the database that stores comments is locked or unwritable")
  194. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  195. def getXattrComment(self):
  196. return self.xattrComment
  197. def getXattrAuthor(self):
  198. return self.xattrAuthor
  199. def getXattrDate(self):
  200. print_v(f"someone accessed date on {self.fileName} {self.xattrDate}")
  201. return self.xattrDate
  202. def setXattrComment(self,newComment):
  203. print_v(f"set comment {newComment} on file {self.fileName}")
  204. try:
  205. os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
  206. os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
  207. os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
  208. self.xattrAuthor = getpass.getuser()
  209. self.xattrDate = time.strftime(DATE_FORMAT) # alternatively, re-instantiate this FileObj
  210. self.xattrComment = newComment
  211. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  212. return True
  213. # we need to move these cases out to a handler
  214. except Exception as e:
  215. if self.size == FileObj.FILE_IS_LINK:
  216. errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
  217. elif self.size == FileObj.FILE_IS_SOCKET:
  218. errorBox("Linux does not allow comments on sockets; comment is stored in database")
  219. elif os.access(self.fileName, os.W_OK)!=True:
  220. errorBox("you don't appear to have write permissions on this file")
  221. # change the listbox background to yellow
  222. self.displayBox.notifyUnchanged()
  223. elif "Errno 95" in str(e):
  224. errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
  225. self.commentsDiffer = True
  226. return False
  227. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  228. def getComment(self):
  229. return self.getDbComment() if mode == "db" else self.getXattrComment()
  230. def getOtherComment(self):
  231. return self.getDbComment() if mode == "xattr" else self.getXattrComment()
  232. def getDate(self):
  233. return self.date
  234. def getSize(self):
  235. return self.size
  236. def isDir(self):
  237. return self.size == self.FILE_IS_DIR
  238. def isLink(self):
  239. return self.size == self.FILE_IS_LINK
  240. class HelpWidget(QDialog):
  241. def __init__(self, parent):
  242. super(QDialog, self).__init__(parent)
  243. self.layout = QVBoxLayout(self)
  244. self.tb = QLabel(self)
  245. self.tb.setWordWrap(True)
  246. self.tb.setText(helpMsg)
  247. self.tb.setFixedWidth(500)
  248. self.pb = QPushButton('OK',self)
  249. self.pb.setFixedWidth(200)
  250. self.layout.addWidget(self.tb)
  251. self.layout.addWidget(self.pb)
  252. self.pb.clicked.connect(self.close)
  253. self.show()
  254. class errorBox(QDialog):
  255. def __init__(self, text):
  256. print_v(f"errorBox: {text}")
  257. super(QDialog, self).__init__(mainWindow)
  258. self.layout = QVBoxLayout(self)
  259. self.tb = QLabel(self)
  260. self.tb.setWordWrap(True)
  261. self.tb.setFixedWidth(500)
  262. self.tb.setText(text)
  263. self.pb = QPushButton("OK",self)
  264. self.layout.addWidget(self.tb)
  265. self.layout.addWidget(self.pb)
  266. self.pb.clicked.connect(self.close)
  267. self.show()
  268. icon = ["32 32 6 1", # the QPixmap constructor allows for str[]
  269. " c None",
  270. ". c #666666",
  271. "+ c #FFFFFF",
  272. "@ c #848484",
  273. "# c #000000",
  274. "$ c #FCE883",
  275. " ",
  276. " ........ ",
  277. " .++++++++. ",
  278. " .+++++++++.................. ",
  279. " .+++++++++++++++++++++++++++. ",
  280. " .+++++++++++++++++++++++++++. ",
  281. " .++..+......++@@@@@@@@@@@@@@@@@",
  282. " .++..++++++++#################@",
  283. " .+++++++++++#$$$$$$$$$$$$$$$$$#",
  284. " .++..+.....+#$$$$$$$$$$$$$$$$$#",
  285. " .++..+++++++#$$$$$$$$$$$$$$$$$#",
  286. " .+++++++++++#$$#############$$#",
  287. " .++..+.....+#$$$$$$$$$$$$$$$$$#",
  288. " .++..+++++++#$$########$$$$$$$#",
  289. " .+++++++++++#$$$$$$$$$$$$$$$$$#",
  290. " .++..+.....+#$$$$$$$$$$$$$$$$$#",
  291. " .++..++++++++#######$$$####### ",
  292. " .++++++++++++++++++#$$#++++++ ",
  293. " .++..+............+#$#++++++. ",
  294. " .++..++++++++++++++##+++++++. ",
  295. " .++++++++++++++++++#++++++++. ",
  296. " .++..+............++++++++++. ",
  297. " .++..+++++++++++++++++++++++. ",
  298. " .+++++++++++++++++++++++++++. ",
  299. " .++..+................++++++. ",
  300. " .++..+++++++++++++++++++++++. ",
  301. " .+++++++++++++++++++++++++++. ",
  302. " .++..+................++++++. ",
  303. " .++..+++++++++++++++++++++++. ",
  304. " .+++++++++++++++++++++++++++. ",
  305. " ........................... ",
  306. " "]
  307. ''' a widget that shows only a dir listing
  308. '''
  309. class DirWidget(QListWidget):
  310. ''' a simple widget that shows a list of directories, staring
  311. at the directory passed into the constructor
  312. a mouse click or 'enter' key will send a 'selected' signal to
  313. anyone who connects to this.
  314. the .. parent directory is shown for all dirs except /
  315. the most interesting parts of the interface are:
  316. constructor - send in the directory to view
  317. method - currentPath() returns text of current path
  318. signal - selected calls a slot with a single arg: new path
  319. '''
  320. selected = pyqtSignal(str)
  321. def __init__(self, directory='.', parent=None):
  322. super(DirWidget,self).__init__(parent)
  323. self.directory = directory
  324. self.refill()
  325. self.itemActivated.connect(self.selectionByLWI)
  326. # it would be nice to pick up single-mouse-click for selection as well
  327. # but that seems to be a system-preferences global
  328. def selectionByLWI(self, li):
  329. self.directory = os.path.abspath(self.directory + '/' + str(li.text()))
  330. self.refill()
  331. self.selected.emit(self.directory)
  332. def refill(self):
  333. current,dirs,files = next(os.walk(self.directory,followlinks=True))
  334. dirs.sort()
  335. if '/' not in dirs:
  336. dirs = ['..'] + dirs
  337. self.clear()
  338. for d in dirs:
  339. li = QListWidgetItem(d,self)
  340. def currentPath(self):
  341. return self.directory
  342. # sortable TableWidgetItem, based on idea by Aledsandar
  343. # http://stackoverflow.com/questions/12673598/python-numerical-sorting-in-qtablewidget
  344. # NOTE: the QTableWidgetItem has setData() and data() which may allow data bonding
  345. # in Qt5, data() binding is more awkward, so do it here
  346. class SortableTableWidgetItem(QTableWidgetItem):
  347. def __init__(self, text, sortValue, file_object):
  348. QTableWidgetItem.__init__(self, text, QTableWidgetItem.UserType)
  349. self.sortValue = sortValue
  350. self.file_object = file_object
  351. def __lt__(self, other):
  352. return self.sortValue < other.sortValue
  353. class DirNotes(QMainWindow):
  354. ''' the main window of the app
  355. '''
  356. def __init__(self, argFilename, db, start_mode, parent=None):
  357. super(DirNotes,self).__init__(parent)
  358. self.db = db
  359. self.refilling = False
  360. self.parent = parent
  361. win = QWidget()
  362. self.setCentralWidget(win)
  363. lb = QTableWidget()
  364. self.lb = lb
  365. lb.setColumnCount(4)
  366. lb.horizontalHeader().setSectionResizeMode( 3, QHeaderView.Stretch );
  367. lb.verticalHeader().setDefaultSectionSize(20); # thinner rows
  368. lb.verticalHeader().setVisible(False)
  369. longPathName = os.path.abspath(argFilename)
  370. print_v("longpathname is {}".format(longPathName))
  371. if os.path.isdir(longPathName):
  372. self.curPath = longPathName
  373. filename = ''
  374. else:
  375. self.curPath, filename = os.path.split(longPathName)
  376. print_v("working on <"+self.curPath+"> and <"+filename+">")
  377. layout = QVBoxLayout()
  378. copyIcon = QIcon.fromTheme('drive-harddisk-symbolic')
  379. changeIcon = QIcon.fromTheme('emblem-synchronizing-symbolic')
  380. topLayout = QHBoxLayout()
  381. self.modeShow = QLabel(win)
  382. topLayout.addWidget(self.modeShow)
  383. bmode = QPushButton(changeIcon, "change mode",win)
  384. topLayout.addWidget(bmode)
  385. cf = QPushButton(copyIcon, "copy file",win)
  386. topLayout.addWidget(cf)
  387. layout.addLayout(topLayout)
  388. layout.addWidget(lb)
  389. win.setLayout(layout)
  390. lb.itemChanged.connect(self.change)
  391. lb.cellDoubleClicked.connect(self.double)
  392. bmode.clicked.connect(self.switchMode)
  393. cf.clicked.connect(self.copyFile)
  394. lb.setHorizontalHeaderItem(0,QTableWidgetItem("File"))
  395. lb.setHorizontalHeaderItem(1,QTableWidgetItem("Date/Time"))
  396. lb.setHorizontalHeaderItem(2,QTableWidgetItem("Size"))
  397. lb.setHorizontalHeaderItem(3,QTableWidgetItem("Comment"))
  398. lb.setSortingEnabled(True)
  399. self.refill()
  400. lb.resizeColumnsToContents()
  401. if len(filename)>0:
  402. for i in range(lb.rowCount()):
  403. if filename == lb.item(i,0).data(32).getFileName():
  404. lb.setCurrentCell(i,3)
  405. break
  406. mb = self.menuBar()
  407. mf = mb.addMenu('&File')
  408. mf.addAction("Sort by name", self.sbn, "Ctrl+N")
  409. mf.addAction("Sort by date", self.sbd, "Ctrl+D")
  410. mf.addAction("Sort by size", self.sbs, "Ctrl+Z")
  411. mf.addAction("Sort by comment", self.sbc, "Ctrl+.")
  412. mf.addAction("Restore comment from database", self.restore_from_database, "Ctrl+R")
  413. mf.addSeparator()
  414. mf.addAction("Quit", self.close, QKeySequence.Quit)
  415. mf.addAction("About", self.about, QKeySequence.HelpContents)
  416. self.setWindowTitle("DirNotes Alt-F for menu Dir: "+self.curPath)
  417. self.setMinimumSize(600,700)
  418. self.setWindowIcon(QIcon(QPixmap(icon)))
  419. lb.setFocus()
  420. def closeEvent(self,e):
  421. print("closing")
  422. def sbd(self):
  423. print("sort by date")
  424. self.lb.sortItems(1,Qt.DescendingOrder)
  425. def sbs(self):
  426. print("sort by size")
  427. self.lb.sortItems(2)
  428. def sbn(self):
  429. print("sort by name")
  430. self.lb.sortItems(0)
  431. def about(self):
  432. HelpWidget(self)
  433. def sbc(self):
  434. print("sort by comment")
  435. self.lb.sortItems(3,Qt.DescendingOrder)
  436. def newDir(self):
  437. print("change dir to "+self.dirLeft.currentPath())
  438. def double(self,row,col):
  439. print_v("double click {} {}".format(row, col))
  440. fo = self.lb.item(row,0).file_object
  441. if col==0 and fo.isDir():
  442. print_v("double click on {}".format(fo.getName()))
  443. self.curPath = fo.getName()
  444. self.refill()
  445. def copyFile(self):
  446. # get current selection
  447. r, c = self.lb.currentRow(), self.lb.currentColumn()
  448. fo = self.lb.item(r,c).file_object
  449. if not fo.isDir() and not fo.isLink(): # TODO: add check for socket
  450. print_v(f"copy file {fo.getName()}")
  451. # open the dir.picker
  452. # TODO: move all this out to a function
  453. qd = QDialog(self.parent)
  454. qd.setWindowTitle("Select destination for FileCopy")
  455. qd.setWindowModality(Qt.ApplicationModal)
  456. dw = DirWidget('.',qd)
  457. d_ok = QPushButton('select')
  458. d_ok.setDefault(True)
  459. d_ok.clicked.connect(QDialog.accept)
  460. d_nope = QPushButton('cancel')
  461. d_nope.clicked.connect(QDialog.reject)
  462. # if returns from <enter>, copy the file and comments
  463. r = qd.exec()
  464. print_v(f"copy to {r}")
  465. pass
  466. def refill(self):
  467. self.refilling = True
  468. self.lb.sortingEnabled = False
  469. (self.modeShow.setText("View and edit file comments stored in extended attributes\n(xattr: user.xdg.comment)")
  470. if mode=="xattr" else
  471. self.modeShow.setText("View and edit file comments stored in the database \n(~/.dirnotes.db)"))
  472. self.lb.clearContents()
  473. small_font = QFont("",8)
  474. dirIcon = QIcon.fromTheme('folder')
  475. fileIcon = QIcon.fromTheme('text-x-generic')
  476. linkIcon = QIcon.fromTheme('emblem-symbolic-link')
  477. current, dirs, files = next(os.walk(self.curPath,followlinks=True))
  478. dirs.sort()
  479. files.sort()
  480. if current != '/':
  481. dirs.insert(0,"..")
  482. d = dirs + files
  483. self.lb.setRowCount(len(d))
  484. #~ self.files = {}
  485. self.files = []
  486. # this is a list of all the file
  487. #~ print("insert {} items into cleared table {}".format(len(d),current))
  488. for i,name in enumerate(d):
  489. this_file = FileObj(current+'/'+name)
  490. this_file.loadDbComment(self.db.db_cursor)
  491. print_v("FileObj created as {} and the db-comment is <{}>".format(this_file.displayName, this_file.dbComment))
  492. #~ print("insert order check: {} {} {} {}".format(d[i],i,this_file.getName(),this_file.getDate()))
  493. #~ self.files.update({this_file.getName(),this_file})
  494. self.files = self.files + [this_file]
  495. display_name = this_file.getFileName()
  496. if this_file.getSize() == FileObj.FILE_IS_DIR:
  497. item = SortableTableWidgetItem(display_name,' '+display_name, this_file) # directories sort first
  498. else:
  499. item = SortableTableWidgetItem(display_name,display_name, this_file)
  500. item.setData(32,this_file) # keep a hidden copy of the file object
  501. item.setToolTip(this_file.getName())
  502. self.lb.setItem(i,0,item)
  503. #lb.itemAt(i,0).setFlags(Qt.ItemIsEnabled) #NoItemFlags)
  504. # get the comment from database & xattrs, either can fail
  505. comment = this_file.getComment()
  506. other_comment = this_file.getOtherComment()
  507. ci = QTableWidgetItem(comment)
  508. ci.setToolTip(f"comment: {comment}\ncomment date: {this_file.getDbDate()}\nauthor: {this_file.getDbAuthor()}")
  509. if other_comment != comment:
  510. ci.setBackground(QBrush(QColor(255,255,160)))
  511. print_v("got differing comments <{}> and <{}>".format(comment, other_comment))
  512. self.lb.setItem(i,3,ci)
  513. dt = this_file.getDate()
  514. da = SortableTableWidgetItem(dnDataBase.getShortDate(dt),dt,this_file)
  515. da.setToolTip(time.strftime(DATE_FORMAT,time.localtime(dt)))
  516. self.lb.setItem(i,1,da)
  517. si = this_file.getSize()
  518. if this_file.isDir():
  519. sa = SortableTableWidgetItem('',0,this_file)
  520. item.setIcon(dirIcon)
  521. elif this_file.isLink():
  522. sa = SortableTableWidgetItem('symlink',-1,this_file)
  523. item.setIcon(linkIcon)
  524. dst = os.path.realpath(this_file.getName())
  525. sa.setToolTip(f"symlink: {dst}")
  526. else:
  527. sa = SortableTableWidgetItem(str(si),si,this_file)
  528. item.setIcon(fileIcon)
  529. sa.setTextAlignment(Qt.AlignRight)
  530. self.lb.setItem(i,2,sa)
  531. self.refilling = False
  532. self.lb.sortingEnabled = True
  533. self.lb.resizeColumnToContents(1)
  534. def change(self,x):
  535. if self.refilling:
  536. return
  537. print_v("debugging " + x.text() + " r:" + str(x.row()) + " c:" + str(x.column()))
  538. print_v(" selected file: "+self.lb.item(x.row(),0).file_object.getName())
  539. the_file = self.lb.item(x.row(),0).file_object
  540. print_v(" and the row file is "+the_file.getName())
  541. the_file.setDbComment(str(x.text()))
  542. r = the_file.setXattrComment(str(x.text()))
  543. # TODO: change this to FileObj.setDbComment()
  544. if r:
  545. self.db.setData(the_file.getName(),x.text())
  546. def switchMode(self):
  547. global mode
  548. mode = "xattr" if mode == "db" else "db"
  549. self.refill()
  550. # TODO: this may not be needed
  551. def restore_from_database(self):
  552. print("restore from database")
  553. # retrieve the full path name
  554. fileName = str(self.lb.item(self.lb.currentRow(),0).file_object.getName())
  555. print("using filename: "+fileName)
  556. existing_comment = str(self.lb.item(self.lb.currentRow(),3).text())
  557. print("restore....existing="+existing_comment+"=")
  558. if len(existing_comment) > 0:
  559. m = QMessageBox()
  560. m.setText("This file already has a comment. Overwrite?")
  561. m.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel);
  562. if m.exec_() != QMessageBox.Ok:
  563. return
  564. fo_row = self.db.getData(fileName)
  565. if fo_row and len(fo_row)>1:
  566. comment = fo_row[3]
  567. print(fileName,fo_row[0],comment)
  568. the_file = dn.files[self.lb.currentRow()]
  569. the_file.setComment(comment)
  570. self.lb.setItem(self.lb.currentRow(),3,QTableWidgetItem(comment))
  571. def parse():
  572. parser = argparse.ArgumentParser(description='dirnotes application')
  573. parser.add_argument('dirname',metavar='dirname',type=str,
  574. help='directory or file [default=current dir]',default=".",nargs='?')
  575. #parser.add_argument('dirname2',help='comparison directory, shows two-dirs side-by-side',nargs='?')
  576. parser.add_argument('-V','--version',action='version',version='%(prog)s '+VERSION)
  577. parser.add_argument('-v','--verbose',action='count',help="verbose, almost debugging")
  578. group = parser.add_mutually_exclusive_group()
  579. group.add_argument( '-s','--sort-by-size',action='store_true')
  580. group.add_argument( '-m','--sort-by-date',action='store_true')
  581. parser.add_argument('-c','--config', dest='config_file',help="config file (json format; default ~/.dirnotes.json)")
  582. parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
  583. parser.add_argument('-d','--db', action='store_true',help="start up in database mode (default)")
  584. return parser.parse_args()
  585. if __name__=="__main__":
  586. # TODO: delete this after debugging
  587. #from PyQt5.QtCore import pyqtRemoveInputHook
  588. #pyqtRemoveInputHook()
  589. p = parse()
  590. if len(p.dirname)>1 and p.dirname[-1]=='/':
  591. p.dirname = p.dirname[:-1]
  592. if os.path.isdir(p.dirname):
  593. p.dirname = p.dirname + '/'
  594. print_v(f"using {p.dirname}")
  595. verbose = p.verbose
  596. config_file = p.config_file if p.config_file else DEFAULT_CONFIG_FILE
  597. config_file = os.path.expanduser(config_file)
  598. config = DEFAULT_CONFIG
  599. try:
  600. with open(config_file,"r") as f:
  601. config = json.load(f)
  602. except json.JSONDecodeError:
  603. print(f"problem reading config file {config_file}; check the .json syntax")
  604. except FileNotFoundError:
  605. print(f"config file {config_file} not found, using the default settings and writing a default")
  606. try:
  607. with open(config_file,"w") as f:
  608. json.dump(config,f,indent=4)
  609. except:
  610. print(f"problem creating the file {config_file}")
  611. print_v(f"here is the .json {repr(config)}")
  612. dbName = os.path.expanduser(config["database"])
  613. db = dnDataBase(dbName)
  614. xattr_comment = config["xattr_tag"]
  615. xattr_author = xattr_comment + ".author"
  616. xattr_date = xattr_comment + ".date"
  617. mode = "xattr" if p.xattr else "db"
  618. a = QApplication([])
  619. mainWindow = DirNotes(p.dirname,db,config["start_mode"])
  620. mainWindow.show()
  621. if p.sort_by_date:
  622. mainWindow.sbd()
  623. if p.sort_by_size:
  624. mainWindow.sbs()
  625. a.exec_()
  626. #xattr.setxattr(filename,COMMENT_KEY,commentText)
  627. ''' files from directories
  628. use os.isfile()
  629. os.isdir()
  630. current, dirs, files = os.walk("path").next()
  631. possible set folllowLinks=True'''
  632. ''' notes from the wdrm project
  633. table showed
  634. filename, size, date size, date, desc
  635. at start, fills the list of all the files
  636. skip the . entry
  637. '''
  638. ''' should we also do user.xdg.tags="TagA,TagB" ?
  639. user.charset
  640. user.creator=application_name or user.xdg.creator
  641. user.xdg.origin.url
  642. user.xdg.language=[RFC3066/ISO639]
  643. user.xdg.publisher
  644. '''
  645. ''' TODO: add cut-copy-paste for comments '''
  646. ''' TODO: also need a way to display-&-restore comments from the database '''
  647. ''' TODO: implement startup -s and -m for size and date '''
  648. ''' TODO: add an icon for the app '''
  649. ''' TODO: create 'show comment history' popup '''
  650. ''' TODO: add dual-pane for file-move, file-copy '''
  651. ''' commandline xattr
  652. getfattr -h (don't follow symlink) -d (dump all properties)
  653. '''
  654. ''' if the args line contains a file, jump to it '''