dirnotes 27 KB

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