c_dirnotes.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  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. try:
  236. db = sqlite3.connect(DATABASE_NAME)
  237. c = db.cursor()
  238. self.directory.loadDbComment(c)
  239. for f in self.files:
  240. f.loadDbComment(c)
  241. except sqlite3.OperationalError:
  242. # TODO: problem with database....create one?
  243. pass
  244. self.sort()
  245. def sortName(a):
  246. if a.getFileName() == '..':
  247. return "\x00"
  248. return a.getFileName()
  249. def sortDate(a):
  250. if a.getFileName() == '..':
  251. return 0
  252. return a.getDate()
  253. def sortSize(a):
  254. if a.getFileName() == '..':
  255. return 0
  256. return a.getSize()
  257. def getCurDir(self):
  258. return self.directory
  259. def getMasterComment(self):
  260. return self.directory.xattrComment if mode==MODE_XATTR else self.directory.dbComment
  261. sort_mode = sortName
  262. def sort(self):
  263. self.files.sort(key = Files.sort_mode)
  264. ## accessors ##
  265. def __len__(self):
  266. return len(self.files)
  267. def __getitem__(self, i):
  268. return self.files[i]
  269. def __iter__(self):
  270. return self.files.__iter__()
  271. def errorBox(string):
  272. werr = curses.newwin(3,len(string)+8,5,5)
  273. werr.bkgd(' ',COLOR_ERROR)
  274. werr.clear()
  275. werr.border()
  276. werr.addstr(1,1,string)
  277. werr.getch() # any key
  278. del werr
  279. ## one for each file
  280. ## a special one called .. exists for the parent
  281. class FileObj():
  282. FILE_IS_DIR = -1
  283. FILE_IS_LINK = -2
  284. def __init__(self, fileName):
  285. self.fileName = os.path.realpath(fileName)
  286. self.displayName = '..' if fileName.endswith('/..') else os.path.split(fileName)[1]
  287. s = os.lstat(fileName)
  288. self.date = s.st_mtime
  289. if stat.S_ISDIR(s.st_mode):
  290. self.size = FileObj.FILE_IS_DIR
  291. elif stat.S_ISLNK(s.st_mode):
  292. self.size = FileObj.FILE_IS_LINK
  293. else:
  294. self.size = s.st_size
  295. self.xattrComment = ''
  296. self.xattrAuthor = None
  297. self.xattrDate = None
  298. self.dbComment = ''
  299. self.dbAuthor = None
  300. self.dbDate = None
  301. self.commentsDiffer = False
  302. try:
  303. self.xattrComment = os.getxattr(fileName, XATTR_COMMENT, follow_symlinks=False).decode()
  304. self.xattrAuthor = os.getxattr(fileName, XATTR_AUTHOR, follow_symlinks=False).decode()
  305. self.xattrDate = float(os.getxattr(fileName, XATTR_DATE, follow_symlinks=False).decode())
  306. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  307. except: # no xattr comment
  308. pass
  309. def getName(self):
  310. return self.fileName
  311. def getFileName(self):
  312. return self.displayName
  313. # with an already open database cursor
  314. def loadDbComment(self,c):
  315. c.execute("select comment,author,comment_date from dirnotes where name=? and comment<>'' order by comment_date desc",(self.fileName,))
  316. a = c.fetchone()
  317. if a:
  318. self.dbComment, self.dbAuthor, self.dbDate = a
  319. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  320. def getDbComment(self):
  321. return self.dbComment
  322. def getDbAuthor(self):
  323. return self.dbAuthor
  324. def getDbDate(self):
  325. return self.dbDate
  326. def setDbComment(self,newComment):
  327. try:
  328. self.db = sqlite3.connect(DATABASE_NAME)
  329. except sqlite3.OperationalError:
  330. logging.info(f"database {DATABASE_NAME} not found")
  331. raise OperationalError
  332. c = self.db.cursor()
  333. s = os.lstat(self.fileName)
  334. try:
  335. c.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  336. (os.path.abspath(self.fileName), s.st_mtime, s.st_size,
  337. str(newComment), time.time(), getpass.getuser()))
  338. self.db.commit()
  339. logging.info(f"database write for {self.fileName}")
  340. self.dbComment = newComment
  341. except sqlite3.OperationalError:
  342. logging.info("database is locked or unwritable")
  343. errorBox("the database that stores comments is locked or unwritable")
  344. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  345. def getXattrComment(self):
  346. return self.xattrComment
  347. def getXattrAuthor(self):
  348. return self.xattrAuthor
  349. def getXattrDate(self):
  350. logging.info(f"someone accessed date on {self.fileName} {self.xattrDate}")
  351. return self.xattrDate
  352. def setXattrComment(self,newComment):
  353. logging.info(f"set comment {newComment} on file {self.fileName}")
  354. try:
  355. os.setxattr(self.fileName,XATTR_COMMENT,bytes(newComment,'utf8'),follow_symlinks=False)
  356. os.setxattr(self.fileName,XATTR_AUTHOR,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
  357. os.setxattr(self.fileName,XATTR_DATE,bytes(str(time.time()),'utf8'),follow_symlinks=False)
  358. self.xattrAuthor = getpass.getuser()
  359. self.xattrDate = time.time() # alternatively, re-instantiate this FileObj
  360. self.xattrComment = newComment
  361. self.commentsDiffer = True if self.xattrComment == self.dbComment else False
  362. return True
  363. # we need to move these cases out to a handler
  364. except Exception as e:
  365. errorBox("problem setting the comment on file %s" % self.getName())
  366. errorBox("error "+repr(e))
  367. ## todo: elif file.is_sym() the kernel won't allow comments on symlinks....stored in database
  368. if self.size == FileObj.FILE_IS_LINK:
  369. errorBox("Linux does not allow comments on symlinks; comment is stored in database")
  370. elif os.access(self.fileName, os.W_OK)!=True:
  371. errorBox("you don't appear to have write permissions on this file")
  372. # change the listbox background to yellow
  373. self.displayBox.notifyUnchanged()
  374. elif "Errno 95" in str(e):
  375. errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
  376. return False
  377. def getDate(self):
  378. return self.date
  379. def getSize(self):
  380. return self.size
  381. def isDir(self):
  382. return self.size == self.FILE_IS_DIR
  383. ########## dest folder picker ###############
  384. # returns None if the user hits <esc>
  385. class showFolderPicker:
  386. def __init__(self,starting_dir,title):
  387. self.W = curses.newwin(20,60,5,5)
  388. self.W.bkgd(' ',COLOR_HELP)
  389. self.W.keypad(True)
  390. self.title = title
  391. self.starting_dir = starting_dir
  392. self.cwd = starting_dir
  393. self.fill()
  394. self.selected = None
  395. indialog = True
  396. selected = ''
  397. while indialog:
  398. c = self.W.getch()
  399. y,x = self.W.getyx()
  400. if c == curses.KEY_UP:
  401. if y>1: self.W.move(y-1,1)
  402. elif c == curses.KEY_DOWN:
  403. if y<len(self.fs)+1: self.W.move(y+1,1)
  404. elif c == CMD_CD:
  405. # cd to new dir and refill
  406. if y==1 and self.fs[0].startswith('<'): # current dir
  407. self.selected = self.cwd
  408. indialog = False
  409. else:
  410. self.cwd = self.cwd + '/' + self.fs[y-1]
  411. self.cwd = os.path.realpath(self.cwd)
  412. #logging.info(f"change dir to {self.cwd}")
  413. self.fill()
  414. elif c == CMD_ESC:
  415. indialog = False
  416. del self.W
  417. def value(self):
  418. #logging.info(f"dir picker returns {self.selected}")
  419. return self.selected
  420. def fill(self):
  421. h, w = self.W.getmaxyx()
  422. self.W.clear()
  423. self.W.border()
  424. self.W.addnstr(0,1,self.title,w-2)
  425. self.W.addstr(h-1,1,"<Enter> to select or change dir, <esc> to exit")
  426. self.fs = os.listdir(self.cwd)
  427. self.fs = [a for a in self.fs if os.path.isdir(a)]
  428. self.fs.sort()
  429. if self.cwd != '/':
  430. self.fs.insert(0,"..")
  431. if self.cwd != self.starting_dir:
  432. self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
  433. for i,f in enumerate(self.fs):
  434. self.W.addnstr(i+1,1,f,w-2)
  435. self.W.move(1,1)
  436. ########### comment management code #################
  437. # paint a dialog window with a border and contents
  438. # discard the 1st line, use the next line to set the width
  439. def paint_dialog(b_color,data):
  440. lines = data.split('\n')[1:]
  441. n = len(lines[0])
  442. w = curses.newwin(len(lines)+2,n+3,5,5)
  443. w.bkgd(' ',b_color)
  444. w.clear()
  445. w.border()
  446. for i,d in enumerate(lines):
  447. w.addnstr(i+1,1,d,n)
  448. #w.refresh I don't know why this isn't needed :(
  449. return w
  450. help_string = """
  451. Dirnotes add descriptions to files
  452. uses xattrs and a database
  453. version %s
  454. h help window (h1/h2 for more help)
  455. e edit file description
  456. d see file+comment details
  457. s sort
  458. q quit
  459. M switch between xattr & database
  460. C copy comment between modes
  461. p preferences/settings [not impl]
  462. c copy file
  463. m move file
  464. <enter> to enter directory""" % (VERSION,)
  465. def show_help():
  466. w = paint_dialog(COLOR_HELP,help_string)
  467. c = w.getch()
  468. del w
  469. if c==ord('1'):
  470. show_help1()
  471. if c==ord('2'):
  472. show_help2()
  473. help1_string = """
  474. Dirnotes stores its comments in the xattr property of files
  475. where it can, and in a database.
  476. XATTR
  477. =====
  478. The xattr comments are attached to the 'user.xdg.comment'
  479. property. If you copy/move/tar the file, there are often
  480. options to move the xattrs with the file.
  481. The xattr comments don't always work. For example, you may
  482. not have write permission on a file. Or you may be using
  483. an exFat/fuse filesystem that doesn't support xattr. You
  484. cannot add xattr comments to symlinks.
  485. DATABASE
  486. ========
  487. The database isvstored at ~/.dirnotes.db using sqlite3.
  488. The comments are indexed by the realpath(filename), which
  489. may change if you use external drives and use varying
  490. mountpoints.
  491. These comments will not move with a file unless you use the
  492. move/copy commands inside this program.
  493. The database allows you to add comments to files you don't
  494. own, or which are read-only.
  495. When the comments in the two systems differ, the comment is
  496. highlighted in green. The 'M' command lets you view either
  497. xattr or database comments. The 'C' command allows you to
  498. copy comments between xattr and database."""
  499. def show_help1():
  500. w = paint_dialog(COLOR_HELP,help1_string)
  501. c = w.getch()
  502. del w
  503. help2_string = """
  504. The comments are also stored with the date-of-the-comment and
  505. the username of the comment's author. The 'd' key will
  506. display that info.
  507. Optionally, the database can be stored at
  508. /var/lib/dirnotes/dirnotes.db
  509. which allows access to all users (not implimented)"""
  510. def show_help2():
  511. w = paint_dialog(COLOR_HELP,help2_string)
  512. c = w.getch()
  513. del w
  514. #TODO: fix this to paint_dialog
  515. sort_string = """
  516. Select sort order:
  517. Name
  518. Date
  519. Size
  520. Comment"""
  521. def show_sort():
  522. h = paint_dialog(COLOR_HELP,sort_string)
  523. h.attron(COLOR_TITLE)
  524. h.addstr(3,3,"N") or h.addstr(4,3,"D") or h.addstr(5,3,"S") or h.addstr(6,3,"C")
  525. h.attroff(COLOR_TITLE)
  526. h.refresh()
  527. c = h.getch()
  528. del h
  529. return c
  530. detail_string = """
  531. Comments detail:
  532. Comment:
  533. Author:
  534. Date: """
  535. def show_detail(f):
  536. global mode
  537. h = paint_dialog(COLOR_HELP,detail_string)
  538. if mode==MODE_XATTR:
  539. h.addstr(1,20,"from xattrs")
  540. c = f.getXattrComment()
  541. a = f.getXattrAuthor()
  542. d = time.ctime(f.getXattrDate())
  543. else:
  544. h.addstr(1,20,"from database")
  545. c = f.getDbComment()
  546. a = f.getDbAuthor()
  547. d = f.getDbDate()
  548. h.addnstr(2,12,c,h.getmaxyx()[1]-13)
  549. h.addstr(3,12,a if a else "<not set>")
  550. h.addstr(4,12,d if d else "<not set>")
  551. h.refresh()
  552. c = h.getch()
  553. del h
  554. return c
  555. ## used by the comment editor to pick up <ENTER> and <ESC>
  556. edit_done = False
  557. def edit_fn(c):
  558. global edit_done
  559. if c==ord('\n'):
  560. edit_done = True
  561. return 7
  562. if c==27:
  563. return 7
  564. return c
  565. def main(w):
  566. global files, edit_done, mode
  567. global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
  568. logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
  569. logging.info("starting curses dirnotes")
  570. curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
  571. curses.init_pair(CP_BODY, curses.COLOR_WHITE,curses.COLOR_BLUE)
  572. curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
  573. curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
  574. curses.init_pair(CP_HELP, curses.COLOR_WHITE,curses.COLOR_CYAN)
  575. curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
  576. COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
  577. COLOR_BODY = curses.color_pair(CP_BODY)
  578. COLOR_FOCUS = curses.color_pair(CP_FOCUS)
  579. COLOR_ERROR = curses.color_pair(CP_ERROR)
  580. COLOR_HELP = curses.color_pair(CP_HELP)
  581. COLOR_DIFFER = curses.color_pair(CP_DIFFER)
  582. logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
  583. if len(sys.argv) > 1:
  584. cwd = sys.argv[1]
  585. else:
  586. cwd = os.getcwd()
  587. files = Files(cwd)
  588. logging.info(f"got files, len={len(files)}")
  589. mywin = Pane(w,cwd,files)
  590. showing_edit = False
  591. while True:
  592. c = mywin.file_pad.getch(mywin.cursor,1)
  593. if c == CMD_QUIT or c == CMD_ESC:
  594. break
  595. elif c == CMD_HELP:
  596. show_help()
  597. mywin.refresh()
  598. elif c == CMD_SORT:
  599. c = show_sort()
  600. if c == ord('s'):
  601. Files.sort_mode = Files.sortSize
  602. elif c == ord('n'):
  603. Files.sort_mode = Files.sortName
  604. elif c == ord('d'):
  605. Files.sort_mode = Files.sortDate
  606. files.sort()
  607. mywin.refill()
  608. mywin.refresh()
  609. elif c == curses.KEY_UP:
  610. mywin.line_move(-1)
  611. elif c == curses.KEY_DOWN:
  612. mywin.line_move(1)
  613. elif c == curses.KEY_PPAGE:
  614. mywin.line_move(-mywin.pad_visible+1)
  615. elif c == curses.KEY_NPAGE:
  616. mywin.line_move(mywin.pad_visible-1)
  617. elif c == curses.KEY_HOME:
  618. mywin.line_move(-len(files)+1)
  619. elif c == curses.KEY_END:
  620. mywin.line_move(len(files)-1)
  621. elif c == CMD_DETAIL:
  622. show_detail(files[mywin.cursor])
  623. mywin.refresh()
  624. elif c == CMD_MODE:
  625. mode = MODE_DATABASE if mode==MODE_XATTR else MODE_XATTR
  626. mywin.refill()
  627. mywin.refresh()
  628. elif c == CMD_RELOAD:
  629. where = files.getCurDir().fileName
  630. files = Files(where)
  631. mywin = Pane(w,where,files)
  632. elif c == CMD_CD:
  633. f = files[mywin.cursor]
  634. if f.isDir():
  635. cwd = f.getName()
  636. logging.info(f"CD change to {cwd}")
  637. files = Files(cwd)
  638. mywin = Pane(w,cwd,files)
  639. # TODO: should this simply re-fill() the existing Pane instead of destroy?
  640. elif c == CMD_EDIT:
  641. showing_edit = True
  642. edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
  643. edit_window.border()
  644. edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
  645. he,we = edit_window.getmaxyx()
  646. edit_sub = edit_window.derwin(3,we-2,1,1)
  647. f = files[mywin.cursor]
  648. mywin.setStatus(f"Edit file: {f.getFileName()}")
  649. existing_comment = f.getXattrComment()
  650. edit_sub.addstr(0,0,existing_comment)
  651. text = curses.textpad.Textbox(edit_sub)
  652. edit_window.refresh()
  653. comment = text.edit(edit_fn).strip()
  654. logging.info(f"comment: {comment} and flag-ok {edit_done}")
  655. if edit_done:
  656. comment = comment.replace('\n',' ')
  657. logging.info(f"got a new comment as '{comment}'")
  658. edit_done = False
  659. f.setXattrComment(comment)
  660. f.setDbComment(comment)
  661. logging.info(f"set file {f.fileName} with comment <{comment}>")
  662. mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
  663. del text, edit_sub, edit_window
  664. mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
  665. mywin.statusbar.redrawwin()
  666. mywin.focus_line()
  667. mywin.refresh()
  668. elif c == CMD_CMNT_CP:
  669. # copy comments to the other mode
  670. cp_cmnt_ask = curses.newwin(6,40,5,5)
  671. cp_cmnt_ask.border()
  672. cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
  673. cp_cmnt_ask.addstr(1,22,"database" if mode==MODE_XATTR else "xattr")
  674. cp_cmnt_ask.addstr(2,1,"1 just this file")
  675. cp_cmnt_ask.addstr(3,1,"a all files with comments")
  676. cp_cmnt_ask.addstr(4,1,"esc to cancel")
  677. cp_cmnt_ask.refresh()
  678. c = cp_cmnt_ask.getch()
  679. # esc
  680. if c!=ord('1') and c!=ord('a') and c!=ord('A'):
  681. continue
  682. # copy comments for one file or all
  683. if c==ord('1'):
  684. collection = [files[mywin.cursor]]
  685. else:
  686. collection = files
  687. for f in collection:
  688. if mode==MODE_XATTR:
  689. if f.getXattrComment():
  690. f.setDbComment(f.getXattrComment())
  691. else:
  692. if f.getDbComment():
  693. f.setXattrComment(f.getDbComment())
  694. mywin.refill()
  695. mywin.refresh()
  696. elif c == CMD_COPY:
  697. if files[mywin.cursor].displayName == "..":
  698. continue
  699. if os.path.isdir(files[mywin.cursor].fileName):
  700. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Copy not allowed")
  701. continue
  702. dest_dir = showFolderPicker(cwd,"Select folder for copy").value()
  703. if dest_dir:
  704. #errorBox(f"copy cmd to {dest_dir}")
  705. src = cwd + '/' + files[mywin.cursor].displayName
  706. dest = dest_dir + '/' + files[mywin.cursor].displayName
  707. # copy2 preserves dates & chmod/chown & xattr
  708. logging.info(f"copy from {src} to {dest_dir}")
  709. shutil.copy2(src, dest_dir)
  710. # and copy the database record
  711. f = FileObj(dest)
  712. f.setDbComment(files[mywin.cursor].getDbComment())
  713. mywin.refresh()
  714. elif c == CMD_MOVE:
  715. if files[mywin.cursor].displayName == "..":
  716. continue
  717. if os.path.isdir(files[mywin.cursor].fileName):
  718. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Move not allowed")
  719. continue
  720. dest_dir = showFolderPicker(cwd,"Select folder for move").value()
  721. if dest_dir:
  722. #errorBox(f"move cmd to {dest_dir}")
  723. src = cwd + '/' + files[mywin.cursor].displayName
  724. dest = dest_dir + '/' + files[mywin.cursor].displayName
  725. # move preserves dates & chmod/chown & xattr
  726. logging.info(f"move from {src} to {dest_dir}")
  727. shutil.move(src, dest_dir)
  728. # and copy the database record
  729. f = FileObj(dest)
  730. f.setDbComment(files[mywin.cursor].getDbComment())
  731. files = Files(cwd)
  732. mywin = Pane(w,cwd,files)
  733. elif c == curses.KEY_RESIZE:
  734. mywin.resize()
  735. #mywin.refresh()
  736. curses.wrapper(main)
  737. # dirnotes database is name, date, size, comment, comment_date, author