dirnotes 28 KB

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