dirnotes-tui 31 KB

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