dirnotes 25 KB

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