dirnotes 28 KB

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