c_dirnotes.py 27 KB

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