c_dirnotes.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921
  1. #!/usr/bin/env python3
  2. # TOTO: when changing directories, keep the sort order?
  3. # TODO: fix the 'reload' function....need 'this_dir' in Files class
  4. # TODO: write color scheme
  5. # TODO: add file copy/move/del
  6. # TODO: add database access & preference
  7. # TODO: re-read date/author to xattr after an edit
  8. # scroll
  9. # up/down - change focus, at limit: move 1 line,
  10. # pgup/down - move by (visible_range - 1), leave focus on the remaining element
  11. # home/end - top/bottom, focus on first/last
  12. # three main classes:
  13. # Pane: smart curses window cluster: main, status & scrolling pad
  14. # FileObj: a file with its xattr-comment and db-comment data
  15. # Files: a collection of FileObjs, sortable
  16. import os, time, stat, sys, shutil
  17. import time, math
  18. import curses, sqlite3, curses.textpad
  19. import logging, getpass, argparse
  20. import json
  21. VERSION = "1.5"
  22. # these may be different on MacOS
  23. xattr_comment = "user.xdg.comment"
  24. xattr_author = "user.xdg.comment.author"
  25. xattr_date = "user.xdg.comment.date"
  26. DEFAULT_CONFIG_FILE = "~/.dirnotes.conf"
  27. # convert the ~/ form to a fully qualified path
  28. # database_name = "~/.dirnotes.db"
  29. mode_names = {"db":"<Database mode> ","xattr":"<Xattr mode>"}
  30. modes = ("db","xattr")
  31. mode = "db"
  32. ### commands
  33. CMD_COPY = ord('c') # open dialog to copy-with-comment
  34. CMD_DETAIL = ord('d') # open dialog
  35. CMD_EDIT = ord('e') # open dialog for typing & <esc> or <enter>
  36. CMD_HELP = ord('h') # open dialog
  37. CMD_MOVE = ord('m') # open dialog to move-with-comment
  38. CMD_QUIT = ord('q')
  39. CMD_RELOAD = ord('r') # reload
  40. CMD_SORT = ord('s') # open dialog for N,S,D,C
  41. CMD_CMNT_CP= ord('C') # open dialog to copy comments accept 1 or a or <esc>
  42. CMD_MODE = ord('M') # switch between xattr and database mode
  43. CMD_ESC = 27
  44. CMD_CD = ord('\n')
  45. # other options will be stored in database at ~/.dirnotes.db or /etc/dirnotes.db
  46. # - option to use MacOSX xattr labels
  47. #
  48. # at first launch (neither database is found), give the user a choice of
  49. # ~/.dirnotes.db or /var/lib/dirnotes.db
  50. # at usage time, check for ~/.dirnotes.db first
  51. # there should be a copy db -> xattr (one file or all-in-dir)
  52. # and copy xattr -> db (one file or all-in-dir)
  53. # file copy/move will copy the comments IN BOTH DB AND XATTR
  54. # file comments will ALWAYS be written to both xattrs & database
  55. # access failure is shown once per directory
  56. # config
  57. # we could store the config in the database, in a second table
  58. # or in a .json file
  59. DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
  60. "database":"~/.dirnotes.db",
  61. "start_mode":"xattr",
  62. "options for database":("~/.dirnotes.db","/etc/dirnotes.db"),
  63. "options for start_mode":("db","xattr")
  64. }
  65. ### colors
  66. CP_TITLE = 1
  67. CP_BODY = 2
  68. CP_FOCUS = 3
  69. CP_ERROR = 4
  70. CP_HELP = 5
  71. CP_DIFFER = 6
  72. COLOR_DIFFER = COLOR_TITLE = COLOR_BODY = COLOR_FOCUS = COLOR_ERROR = COLOR_HELP = None
  73. COLOR_THEME = ''' { "heading": ("yellow","blue"),
  74. "body":("white","blue"),
  75. "focus":("black","cyan") }
  76. '''
  77. now = time.time()
  78. YEAR = 3600*24*365
  79. class Pane:
  80. ''' holds the whole display: handles file list directly,
  81. fills a child pad with the file info,
  82. draws scroll bar
  83. defers the status line to a child
  84. draws a border
  85. line format: filename=30%, size=7, date=12, comment=rest
  86. line 1=current directory + border
  87. line 2...h-4 = filename
  88. line h-3 = border
  89. line h-2 = status
  90. line h-1 = border
  91. column 0, sep1, sep2, sep3 and w-1 are borders w.r.t. pad
  92. filename starts in column 1 (border in 0)
  93. most methods take y=0..h-1 where y is the line number WITHIN the borders
  94. '''
  95. def __init__(self, win, curdir, files):
  96. self.curdir = curdir
  97. self.cursor = None
  98. self.first_visible = 0
  99. self.nFiles = len(files)
  100. self.h, self.w = win.getmaxyx()
  101. self.main_win = win # whole screen
  102. self.win = win.subwin(self.h-1,self.w,0,0) # upper window, for border
  103. self.statusbar = win.subwin(1,self.w,self.h-1,0) # status at the bottom
  104. self.pad_height = max(self.nFiles,self.h-4)
  105. self.file_pad = curses.newpad(self.pad_height,self.w)
  106. self.file_pad.keypad(True)
  107. self.win.bkgdset(' ',curses.color_pair(CP_BODY))
  108. self.statusbar.bkgdset(' ',curses.color_pair(CP_BODY))
  109. self.file_pad.bkgdset(' ',curses.color_pair(CP_BODY))
  110. self.resize()
  111. logging.info("made the pane")
  112. def resize(self): # and refill
  113. logging.info("got to resize")
  114. self.h, self.w = self.main_win.getmaxyx()
  115. self.sep1 = self.w // 3
  116. self.sep2 = self.sep1 + 8
  117. self.sep3 = self.sep2 + 13
  118. self.win.resize(self.h-1,self.w)
  119. self.statusbar.resize(1,self.w)
  120. self.statusbar.mvwin(self.h-1,0)
  121. self.pad_height = max(len(files),self.h-4)
  122. self.pad_visible = self.h-4
  123. self.file_pad.resize(self.pad_height+1,self.w-2)
  124. self.refill()
  125. self.refresh()
  126. def refresh(self):
  127. self.win.refresh()
  128. if self.some_comments_differ:
  129. self.setStatus("The xattr and database comments differ where shown in green")
  130. # time.sleep(2)
  131. self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
  132. def refill(self):
  133. self.win.bkgdset(' ',curses.color_pair(CP_BODY))
  134. self.win.erase()
  135. self.win.border() # TODO: or .box() ?
  136. h,w = self.win.getmaxyx()
  137. self.win.addnstr(0,3,os.path.realpath(self.curdir),w-4)
  138. n = len(files.getMasterComment())
  139. self.win.addnstr(0,w-n-1,files.getMasterComment(),w-n-1) # TODO: fix
  140. self.win.attron(COLOR_TITLE | curses.A_BOLD)
  141. self.win.addstr(1,1,'Name'.center(self.sep1-1))
  142. self.win.addstr(1,self.sep1+2,'Size')
  143. self.win.addstr(1,self.sep2+4,'Date')
  144. self.win.addstr(1,self.sep3+2,'Comments')
  145. self.win.attroff(COLOR_BODY)
  146. self.some_comments_differ = False
  147. # now fill the file_pad
  148. for i,f in enumerate(files):
  149. self.fill_line(i) # fill the file_pad
  150. if self.nFiles < self.pad_height:
  151. for i in range(self.nFiles, self.pad_height):
  152. self.file_pad.addstr(i,0,' ' * (self.w-2))
  153. # and display the file_pan
  154. if self.cursor == None:
  155. self.cursor = 0
  156. self.focus_line()
  157. def fill_line(self,y):
  158. #logging.info(f"about to add {self.w-2} spaces at {y} to the file_pad size: {self.file_pad.getmaxyx()}")
  159. # TODO: why do we have to have one extra line in the pad?
  160. f = files[y]
  161. self.file_pad.addstr(y,0,' ' * (self.w-2))
  162. self.file_pad.addnstr(y,0,f.getFileName(),self.sep1-1)
  163. self.file_pad.addstr(y,self.sep1,makeSize(f.size))
  164. self.file_pad.addstr(y,self.sep2,makeDate(f.date))
  165. dbComment = f.getDbComment()
  166. xattrComment = f.getXattrComment()
  167. comment = xattrComment if mode=="xattr" else dbComment
  168. if dbComment != xattrComment:
  169. self.some_comments_differ = True
  170. self.file_pad.attron(COLOR_HELP)
  171. self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
  172. self.file_pad.attroff(COLOR_HELP)
  173. else:
  174. self.file_pad.addnstr(y,self.sep3,comment,self.w-self.sep3-2)
  175. self.file_pad.vline(y,self.sep1-1,curses.ACS_VLINE,1)
  176. self.file_pad.vline(y,self.sep2-1,curses.ACS_VLINE,1)
  177. self.file_pad.vline(y,self.sep3-1,curses.ACS_VLINE,1)
  178. def unfocus_line(self):
  179. self.fill_line(self.cursor)
  180. def focus_line(self):
  181. self.file_pad.attron(COLOR_FOCUS)
  182. self.fill_line(self.cursor)
  183. self.file_pad.attroff(COLOR_FOCUS)
  184. def line_move(self,direction):
  185. # try a move first
  186. new_cursor = self.cursor + direction
  187. if new_cursor < 0:
  188. new_cursor = 0
  189. if new_cursor >= self.nFiles:
  190. new_cursor = self.nFiles - 1
  191. if new_cursor == self.cursor:
  192. return
  193. # then adjust the window
  194. if new_cursor < self.first_visible:
  195. self.first_visible = new_cursor
  196. self.file_pad.redrawwin()
  197. if new_cursor >= self.first_visible + self.pad_visible - 1:
  198. self.first_visible = new_cursor - self.pad_visible + 1
  199. self.file_pad.redrawwin()
  200. self.unfocus_line()
  201. self.cursor = new_cursor
  202. self.focus_line()
  203. self.file_pad.move(self.cursor,0) # just move the flashing cursor
  204. self.file_pad.refresh(self.first_visible,0,2,1,self.h-3,self.w-2)
  205. def setStatus(self,data):
  206. h,w = self.statusbar.getmaxyx()
  207. self.statusbar.clear()
  208. self.statusbar.attron(curses.A_REVERSE)
  209. self.statusbar.addstr(0,0,mode_names[mode])
  210. self.statusbar.attroff(curses.A_REVERSE)
  211. y,x = self.statusbar.getyx()
  212. self.statusbar.addnstr(" " + data,w-x-1)
  213. self.statusbar.refresh()
  214. # two helpers to format the date & size visuals
  215. def makeDate(when):
  216. ''' arg when is epoch seconds in localtime '''
  217. diff = now - when
  218. if diff > YEAR:
  219. fmt = "%b %e %Y"
  220. else:
  221. fmt = "%b %d %H:%M"
  222. return time.strftime(fmt, time.localtime(when))
  223. def makeSize(size):
  224. if size == FileObj.FILE_IS_DIR:
  225. return " <DIR> "
  226. elif size == FileObj.FILE_IS_LINK:
  227. return " <LINK>"
  228. log = int((math.log10(size+1)-2)/3)
  229. s = " KMGTE"[log]
  230. base = int(size/math.pow(10,log*3))
  231. return f"{base}{s}".strip().rjust(7)
  232. ## to hold the FileObj collection
  233. class Files():
  234. def __init__(self,directory):
  235. self.directory = FileObj(directory)
  236. self.files = []
  237. if directory != '/':
  238. self.files.append(FileObj(directory + "/.."))
  239. # TODO: switch to os.scandir()
  240. f = os.listdir(directory)
  241. for i in f:
  242. self.files.append(FileObj(directory + '/' + i))
  243. self.sort()
  244. self.db = None
  245. try:
  246. self.db = sqlite3.connect(database_name)
  247. c = self.db.cursor()
  248. c.execute("select * from dirnotes")
  249. except sqlite3.OperationalError:
  250. # TODO: problem with database....create one?
  251. c.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
  252. try:
  253. c = self.db.cursor()
  254. self.directory.loadDbComment(c)
  255. for f in self.files:
  256. f.loadDbComment(c)
  257. except sqlite3.OperationalError:
  258. errorBox("serial problem with the database")
  259. def sortName(a):
  260. ''' when sorting by name put the .. and other <dir> entries first '''
  261. if a.getFileName() == '..':
  262. return "\x00"
  263. if a.isDir():
  264. return ' ' + a.getFileName()
  265. # else:
  266. return a.getFileName()
  267. def sortDate(a):
  268. if a.getFileName() == '..':
  269. return 0
  270. return a.getDate()
  271. def sortSize(a):
  272. if a.getFileName() == '..':
  273. return 0
  274. return a.getSize()
  275. def sortComment(a):
  276. return a.getComment()
  277. sort_mode = sortName
  278. def sort(self):
  279. self.files.sort(key = Files.sort_mode)
  280. def getCurDir(self):
  281. return self.directory
  282. def getMasterComment(self):
  283. return self.directory.getComment()
  284. ## accessors ##
  285. def __len__(self):
  286. return len(self.files)
  287. def __getitem__(self, i):
  288. return self.files[i]
  289. def __iter__(self):
  290. return self.files.__iter__()
  291. def errorBox(string):
  292. werr = curses.newwin(3,len(string)+8,5,5)
  293. werr.bkgd(' ',COLOR_ERROR)
  294. werr.clear()
  295. werr.border()
  296. werr.addstr(1,1,string)
  297. werr.getch() # any key
  298. del werr
  299. ## one for each file
  300. ## a special one called .. exists for the parent
  301. class FileObj():
  302. FILE_IS_DIR = -1
  303. FILE_IS_LINK = -2
  304. def __init__(self, fileName):
  305. self.fileName = os.path.realpath(fileName)
  306. self.displayName = '..' if fileName.endswith('/..') else os.path.split(fileName)[1]
  307. s = os.lstat(fileName)
  308. self.date = s.st_mtime
  309. if stat.S_ISDIR(s.st_mode):
  310. self.size = FileObj.FILE_IS_DIR
  311. elif stat.S_ISLNK(s.st_mode):
  312. self.size = FileObj.FILE_IS_LINK
  313. else:
  314. self.size = s.st_size
  315. self.xattrComment = ''
  316. self.xattrAuthor = None
  317. self.xattrDate = None
  318. self.dbComment = ''
  319. self.dbAuthor = None
  320. self.dbDate = None
  321. self.commentsDiffer = False
  322. try:
  323. self.xattrComment = os.getxattr(fileName, xattr_comment, follow_symlinks=False).decode()
  324. self.xattrAuthor = os.getxattr(fileName, xattr_author, follow_symlinks=False).decode()
  325. self.xattrDate = float(os.getxattr(fileName, xattr_date, follow_symlinks=False).decode())
  326. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  327. except: # no xattr comment
  328. pass
  329. def getName(self):
  330. return self.fileName
  331. def getFileName(self):
  332. return self.displayName
  333. # with an already open database cursor
  334. def loadDbComment(self,c):
  335. c.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
  336. a = c.fetchone()
  337. if a:
  338. self.dbComment, self.dbAuthor, self.dbDate = a
  339. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  340. def getDbComment(self):
  341. return self.dbComment
  342. def getDbAuthor(self):
  343. return self.dbAuthor
  344. def getDbDate(self):
  345. return self.dbDate
  346. def setDbComment(self,newComment):
  347. try:
  348. self.db = sqlite3.connect(database_name)
  349. except sqlite3.OperationalError:
  350. logging.info(f"database {database_name} not found")
  351. raise OperationalError
  352. c = self.db.cursor()
  353. s = os.lstat(self.fileName)
  354. try:
  355. c.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  356. (os.path.abspath(self.fileName), s.st_mtime, s.st_size,
  357. str(newComment), time.time(), getpass.getuser()))
  358. self.db.commit()
  359. logging.info(f"database write for {self.fileName}")
  360. self.dbComment = newComment
  361. except sqlite3.OperationalError:
  362. logging.info("database is locked or unwritable")
  363. errorBox("the database that stores comments is locked or unwritable")
  364. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  365. def getXattrComment(self):
  366. return self.xattrComment
  367. def getXattrAuthor(self):
  368. return self.xattrAuthor
  369. def getXattrDate(self):
  370. logging.info(f"someone accessed date on {self.fileName} {self.xattrDate}")
  371. return self.xattrDate
  372. def setXattrComment(self,newComment):
  373. logging.info(f"set comment {newComment} on file {self.fileName}")
  374. try:
  375. os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
  376. os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
  377. os.setxattr(self.fileName,xattr_date,bytes(str(time.time()),'utf8'),follow_symlinks=False)
  378. self.xattrAuthor = getpass.getuser()
  379. self.xattrDate = time.time() # alternatively, re-instantiate this FileObj
  380. self.xattrComment = newComment
  381. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  382. return True
  383. # we need to move these cases out to a handler
  384. except Exception as e:
  385. errorBox("problem setting the comment on file %s" % self.getName())
  386. errorBox("error "+repr(e))
  387. ## todo: elif file.is_sym() the kernel won't allow comments on symlinks....stored in database
  388. if self.size == FileObj.FILE_IS_LINK:
  389. errorBox("Linux does not allow comments on symlinks; comment is stored in database")
  390. elif os.access(self.fileName, os.W_OK)!=True:
  391. errorBox("you don't appear to have write permissions on this file")
  392. # change the listbox background to yellow
  393. self.displayBox.notifyUnchanged()
  394. elif "Errno 95" in str(e):
  395. errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
  396. return False
  397. def getComment(self):
  398. return self.getDbComment() if mode == "db" else self.getXattrComment()
  399. def getOtherComment(self):
  400. return self.getDbComment() if mode == "xattr" else self.getXattrComment()
  401. def getDate(self):
  402. return self.date
  403. def getSize(self):
  404. return self.size
  405. def isDir(self):
  406. return self.size == self.FILE_IS_DIR
  407. ########## dest folder picker ###############
  408. # returns None if the user hits <esc>
  409. class showFolderPicker:
  410. def __init__(self,starting_dir,title):
  411. self.W = curses.newwin(20,60,5,5)
  412. self.W.bkgd(' ',COLOR_HELP)
  413. self.W.keypad(True)
  414. self.title = title
  415. self.starting_dir = starting_dir
  416. self.cwd = starting_dir
  417. self.fill()
  418. self.selected = None
  419. indialog = True
  420. selected = ''
  421. while indialog:
  422. c = self.W.getch()
  423. y,x = self.W.getyx()
  424. if c == curses.KEY_UP:
  425. if y>1: self.W.move(y-1,1)
  426. elif c == curses.KEY_DOWN:
  427. if y<len(self.fs)+1: self.W.move(y+1,1)
  428. elif c == CMD_CD:
  429. # cd to new dir and refill
  430. if y==1 and self.fs[0].startswith('<'): # current dir
  431. self.selected = self.cwd
  432. indialog = False
  433. else:
  434. self.cwd = self.cwd + '/' + self.fs[y-1]
  435. self.cwd = os.path.realpath(self.cwd)
  436. #logging.info(f"change dir to {self.cwd}")
  437. self.fill()
  438. elif c == CMD_ESC:
  439. indialog = False
  440. del self.W
  441. def value(self):
  442. #logging.info(f"dir picker returns {self.selected}")
  443. return self.selected
  444. def fill(self):
  445. h, w = self.W.getmaxyx()
  446. self.W.clear()
  447. self.W.border()
  448. self.W.addnstr(0,1,self.title,w-2)
  449. self.W.addstr(h-1,1,"<Enter> to select or change dir, <esc> to exit")
  450. self.fs = os.listdir(self.cwd)
  451. self.fs = [a for a in self.fs if os.path.isdir(a)]
  452. self.fs.sort()
  453. if self.cwd != '/':
  454. self.fs.insert(0,"..")
  455. if self.cwd != self.starting_dir:
  456. self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
  457. for i,f in enumerate(self.fs):
  458. self.W.addnstr(i+1,1,f,w-2)
  459. self.W.move(1,1)
  460. ########### comment management code #################
  461. # paint a dialog window with a border and contents
  462. # discard the 1st line, use the next line to set the width
  463. def paint_dialog(b_color,data):
  464. lines = data.split('\n')[1:]
  465. n = len(lines[0])
  466. w = curses.newwin(len(lines)+2,n+3,5,5)
  467. w.bkgd(' ',b_color)
  468. w.clear()
  469. w.border()
  470. for i,d in enumerate(lines):
  471. w.addnstr(i+1,1,d,n)
  472. #w.refresh I don't know why this isn't needed :(
  473. return w
  474. help_string = """
  475. Dirnotes add descriptions to files
  476. uses xattrs and a database
  477. version %s
  478. h help window (h1/h2 for more help)
  479. e edit file description
  480. d see file+comment details
  481. s sort
  482. q quit
  483. M switch between xattr & database
  484. C copy comment between modes
  485. p preferences/settings [not impl]
  486. c copy file
  487. m move file
  488. <enter> to enter directory""" % (VERSION,)
  489. def show_help():
  490. w = paint_dialog(COLOR_HELP,help_string)
  491. c = w.getch()
  492. del w
  493. if c==ord('1'):
  494. show_help1()
  495. if c==ord('2'):
  496. show_help2()
  497. help1_string = """
  498. Dirnotes stores its comments in the xattr property of files
  499. where it can, and in a database.
  500. XATTR
  501. =====
  502. The xattr comments are attached to the 'user.xdg.comment'
  503. property. If you copy/move/tar the file, there are often
  504. options to move the xattrs with the file.
  505. The xattr comments don't always work. For example, you may
  506. not have write permission on a file. Or you may be using
  507. an exFat/fuse filesystem that doesn't support xattr. You
  508. cannot add xattr comments to symlinks.
  509. DATABASE
  510. ========
  511. The database isvstored at ~/.dirnotes.db using sqlite3.
  512. The comments are indexed by the realpath(filename), which
  513. may change if you use external drives and use varying
  514. mountpoints.
  515. These comments will not move with a file unless you use the
  516. move/copy commands inside this program.
  517. The database allows you to add comments to files you don't
  518. own, or which are read-only.
  519. When the comments in the two systems differ, the comment is
  520. highlighted in green. The 'M' command lets you view either
  521. xattr or database comments. The 'C' command allows you to
  522. copy comments between xattr and database."""
  523. def show_help1():
  524. w = paint_dialog(COLOR_HELP,help1_string)
  525. c = w.getch()
  526. del w
  527. help2_string = """
  528. The comments are also stored with the date-of-the-comment and
  529. the username of the comment's author. The 'd' key will
  530. display that info.
  531. Optionally, the database can be stored at
  532. /var/lib/dirnotes/dirnotes.db
  533. which allows access to all users (not implimented)"""
  534. def show_help2():
  535. w = paint_dialog(COLOR_HELP,help2_string)
  536. c = w.getch()
  537. del w
  538. #TODO: fix this to paint_dialog
  539. #TODO: fix to allow upper/lower case responses
  540. sort_string = """
  541. Select sort order:
  542. name
  543. date
  544. size
  545. comment"""
  546. def show_sort():
  547. h = paint_dialog(COLOR_HELP,sort_string)
  548. h.attron(COLOR_TITLE)
  549. h.addstr(3,3,"n") or h.addstr(4,3,"d") or h.addstr(5,3,"s") or h.addstr(6,3,"c")
  550. h.attroff(COLOR_TITLE)
  551. h.refresh()
  552. c = h.getch()
  553. del h
  554. return c
  555. detail_string = """
  556. Comments detail:
  557. Comment:
  558. Author:
  559. Date: """
  560. def show_detail(f):
  561. global mode
  562. h = paint_dialog(COLOR_HELP,detail_string)
  563. if mode=="xattr":
  564. h.addstr(1,20,"from xattrs")
  565. c = f.getXattrComment()
  566. a = f.getXattrAuthor()
  567. d = time.ctime(f.getXattrDate())
  568. else:
  569. h.addstr(1,20,"from database")
  570. c = f.getDbComment()
  571. a = f.getDbAuthor()
  572. d = f.getDbDate()
  573. h.addnstr(2,12,c,h.getmaxyx()[1]-13)
  574. h.addstr(3,12,a if a else "<not set>")
  575. h.addstr(4,12,d if d else "<not set>")
  576. h.refresh()
  577. c = h.getch()
  578. del h
  579. return c
  580. ## used by the comment editor to pick up <ENTER> and <ESC>
  581. edit_done = False
  582. def edit_fn(c):
  583. global edit_done
  584. if c==ord('\n'):
  585. edit_done = True
  586. return 7
  587. if c==27:
  588. return 7
  589. return c
  590. def main(w, cwd):
  591. global files, edit_done, mode
  592. global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
  593. curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
  594. curses.init_pair(CP_BODY, curses.COLOR_WHITE,curses.COLOR_BLUE)
  595. curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
  596. curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
  597. curses.init_pair(CP_HELP, curses.COLOR_WHITE,curses.COLOR_CYAN)
  598. curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
  599. COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
  600. COLOR_BODY = curses.color_pair(CP_BODY)
  601. COLOR_FOCUS = curses.color_pair(CP_FOCUS)
  602. COLOR_ERROR = curses.color_pair(CP_ERROR)
  603. COLOR_HELP = curses.color_pair(CP_HELP)
  604. COLOR_DIFFER = curses.color_pair(CP_DIFFER)
  605. logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
  606. files = Files(cwd)
  607. logging.info(f"got files, len={len(files)}")
  608. mywin = Pane(w,cwd,files)
  609. showing_edit = False
  610. while True:
  611. c = mywin.file_pad.getch(mywin.cursor,1)
  612. if c == CMD_QUIT or c == CMD_ESC:
  613. break
  614. elif c == CMD_HELP:
  615. show_help()
  616. mywin.refresh()
  617. elif c == CMD_SORT:
  618. c = show_sort()
  619. if c == ord('s') or c == ord('S'):
  620. Files.sort_mode = Files.sortSize
  621. elif c == ord('n') or c == ord('N'):
  622. Files.sort_mode = Files.sortName
  623. elif c == ord('d') or c == ord('D'):
  624. Files.sort_mode = Files.sortDate
  625. elif c == ord('c') or c == ord('C'):
  626. Files.sort_mode = Files.sortComment
  627. files.sort()
  628. mywin.refill()
  629. mywin.refresh()
  630. elif c == curses.KEY_UP:
  631. mywin.line_move(-1)
  632. elif c == curses.KEY_DOWN:
  633. mywin.line_move(1)
  634. elif c == curses.KEY_PPAGE:
  635. mywin.line_move(-mywin.pad_visible+1)
  636. elif c == curses.KEY_NPAGE:
  637. mywin.line_move(mywin.pad_visible-1)
  638. elif c == curses.KEY_HOME:
  639. mywin.line_move(-len(files)+1)
  640. elif c == curses.KEY_END:
  641. mywin.line_move(len(files)-1)
  642. elif c == CMD_DETAIL:
  643. show_detail(files[mywin.cursor])
  644. mywin.refresh()
  645. elif c == CMD_MODE:
  646. mode = "db" if mode=="xattr" else "xattr"
  647. mywin.refill()
  648. mywin.refresh()
  649. elif c == CMD_RELOAD:
  650. where = files.getCurDir().fileName
  651. files = Files(where)
  652. mywin = Pane(w,where,files)
  653. elif c == CMD_CD:
  654. f = files[mywin.cursor]
  655. if f.isDir():
  656. cwd = f.getName()
  657. logging.info(f"CD change to {cwd}")
  658. files = Files(cwd)
  659. mywin = Pane(w,cwd,files)
  660. # TODO: should this simply re-fill() the existing Pane instead of destroy?
  661. elif c == CMD_EDIT:
  662. showing_edit = True
  663. edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
  664. edit_window.border()
  665. edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
  666. he,we = edit_window.getmaxyx()
  667. edit_sub = edit_window.derwin(3,we-2,1,1)
  668. f = files[mywin.cursor]
  669. mywin.setStatus(f"Edit file: {f.getFileName()}")
  670. existing_comment = f.getXattrComment()
  671. edit_sub.addstr(0,0,existing_comment)
  672. text = curses.textpad.Textbox(edit_sub)
  673. edit_window.refresh()
  674. comment = text.edit(edit_fn).strip()
  675. logging.info(f"comment: {comment} and flag-ok {edit_done}")
  676. if edit_done:
  677. comment = comment.replace('\n',' ')
  678. logging.info(f"got a new comment as '{comment}'")
  679. edit_done = False
  680. f.setXattrComment(comment)
  681. f.setDbComment(comment)
  682. logging.info(f"set file {f.fileName} with comment <{comment}>")
  683. mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
  684. del text, edit_sub, edit_window
  685. mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
  686. mywin.statusbar.redrawwin()
  687. mywin.focus_line()
  688. mywin.refresh()
  689. elif c == CMD_CMNT_CP:
  690. # copy comments to the other mode
  691. cp_cmnt_ask = curses.newwin(6,40,5,5)
  692. cp_cmnt_ask.border()
  693. cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
  694. cp_cmnt_ask.addstr(1,22,"database" if mode=="xattr" else "xattr")
  695. cp_cmnt_ask.addstr(2,1,"1 just this file")
  696. cp_cmnt_ask.addstr(3,1,"a all files with comments")
  697. cp_cmnt_ask.addstr(4,1,"esc to cancel")
  698. cp_cmnt_ask.refresh()
  699. c = cp_cmnt_ask.getch()
  700. # esc
  701. if c!=ord('1') and c!=ord('a') and c!=ord('A'):
  702. continue
  703. # copy comments for one file or all
  704. if c==ord('1'):
  705. collection = [files[mywin.cursor]]
  706. else:
  707. collection = files
  708. for f in collection:
  709. if mode=="xattr":
  710. if f.getXattrComment():
  711. f.setDbComment(f.getXattrComment())
  712. else:
  713. if f.getDbComment():
  714. f.setXattrComment(f.getDbComment())
  715. mywin.refill()
  716. mywin.refresh()
  717. elif c == CMD_COPY:
  718. if files[mywin.cursor].displayName == "..":
  719. continue
  720. if os.path.isdir(files[mywin.cursor].fileName):
  721. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Copy not allowed")
  722. continue
  723. dest_dir = showFolderPicker(cwd,"Select folder for copy").value()
  724. if dest_dir:
  725. #errorBox(f"copy cmd to {dest_dir}")
  726. src = cwd + '/' + files[mywin.cursor].displayName
  727. dest = dest_dir + '/' + files[mywin.cursor].displayName
  728. # copy2 preserves dates & chmod/chown & xattr
  729. logging.info(f"copy from {src} to {dest_dir}")
  730. shutil.copy2(src, dest_dir)
  731. # and copy the database record
  732. f = FileObj(dest)
  733. f.setDbComment(files[mywin.cursor].getDbComment())
  734. mywin.refresh()
  735. elif c == CMD_MOVE:
  736. if files[mywin.cursor].displayName == "..":
  737. continue
  738. if os.path.isdir(files[mywin.cursor].fileName):
  739. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Move not allowed")
  740. continue
  741. dest_dir = showFolderPicker(cwd,"Select folder for move").value()
  742. if dest_dir:
  743. #errorBox(f"move cmd to {dest_dir}")
  744. src = cwd + '/' + files[mywin.cursor].displayName
  745. dest = dest_dir + '/' + files[mywin.cursor].displayName
  746. # move preserves dates & chmod/chown & xattr
  747. logging.info(f"move from {src} to {dest_dir}")
  748. shutil.move(src, dest_dir)
  749. # and copy the database record
  750. f = FileObj(dest)
  751. f.setDbComment(files[mywin.cursor].getDbComment())
  752. files = Files(cwd)
  753. mywin = Pane(w,cwd,files)
  754. elif c == curses.KEY_RESIZE:
  755. mywin.resize()
  756. #mywin.refresh()
  757. def pre_main():
  758. # done before we switch to curses mode
  759. logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
  760. logging.info("starting curses dirnotes")
  761. parser = argparse.ArgumentParser(description="Add comments to files")
  762. parser.add_argument('-c','--config', dest='config_file', help="config file (json format)")
  763. parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
  764. parser.add_argument('directory', type=str, default='.', nargs='?', help="directory to start")
  765. args = parser.parse_args()
  766. logging.info(args)
  767. if args.config_file:
  768. config_file = args.config_file
  769. else:
  770. config_file = DEFAULT_CONFIG_FILE
  771. config_file = os.path.expanduser(config_file)
  772. config = DEFAULT_CONFIG
  773. try:
  774. with open(config_file,"r") as f:
  775. config = json.load(f)
  776. except json.JSONDecodeError:
  777. print(f"problem reading the config file {config_file}")
  778. printf("please check the .json syntax")
  779. time.sleep(2)
  780. except FileNotFoundError:
  781. print(f"config file {config_file} not found, using default settings & creating a default")
  782. try:
  783. with open(config_file,"w") as f:
  784. json.dump(config,f,indent=4)
  785. except:
  786. print(f"problem creating file {config_file}")
  787. time.sleep(2)
  788. # print(repr(config))
  789. # print("start_mode",config["start_mode"])
  790. # change ~ tilde to username
  791. config["database"] = os.path.expanduser(config["database"])
  792. # load globals from the config
  793. return args, config
  794. args, config = pre_main()
  795. mode = config["start_mode"]
  796. xattr_comment = config["xattr_tag"]
  797. xattr_author = config["xattr_tag"] + ".author"
  798. xattr_date = config["xattr_tag"] + ".date"
  799. database_name = os.path.expanduser(config["database"])
  800. cwd = args.directory
  801. curses.wrapper(main, cwd)
  802. # dirnotes database is name, date, size, comment, comment_date, author