dirnotes-tui 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951
  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. class showFolderPicker:
  437. def __init__(self,starting_dir,title):
  438. self.W = curses.newwin(20,60,5,5)
  439. self.W.bkgd(' ',COLOR_HELP)
  440. self.W.keypad(True)
  441. self.title = title
  442. self.starting_dir = starting_dir
  443. self.cwd = starting_dir
  444. self.fill()
  445. self.selected = None
  446. indialog = True
  447. selected = ''
  448. while indialog:
  449. c = self.W.getch()
  450. y,x = self.W.getyx()
  451. if c == curses.KEY_UP:
  452. if y>1: self.W.move(y-1,1)
  453. elif c == curses.KEY_DOWN:
  454. if y<len(self.fs)+1: self.W.move(y+1,1)
  455. elif c == CMD_CD:
  456. # cd to new dir and refill
  457. if y==1 and self.fs[0].startswith('<'): # current dir
  458. self.selected = self.cwd
  459. indialog = False
  460. else:
  461. self.cwd = self.cwd + '/' + self.fs[y-1]
  462. self.cwd = os.path.realpath(self.cwd)
  463. #logging.info(f"change dir to {self.cwd}")
  464. self.fill()
  465. elif c == CMD_ESC:
  466. indialog = False
  467. del self.W
  468. def value(self):
  469. #logging.info(f"dir picker returns {self.selected}")
  470. return self.selected
  471. def fill(self):
  472. h, w = self.W.getmaxyx()
  473. self.W.clear()
  474. self.W.border()
  475. self.W.addnstr(0,1,self.title,w-2)
  476. self.W.addstr(h-1,1,"<Enter> to select or change dir, <esc> to exit")
  477. self.fs = os.listdir(self.cwd)
  478. self.fs = [a for a in self.fs if os.path.isdir(a)]
  479. self.fs.sort()
  480. if self.cwd != '/':
  481. self.fs.insert(0,"..")
  482. if self.cwd != self.starting_dir:
  483. self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
  484. for i,f in enumerate(self.fs):
  485. self.W.addnstr(i+1,1,f,w-2)
  486. self.W.move(1,1)
  487. ########### comment management code #################
  488. # paint a dialog window with a border and contents
  489. # discard the 1st line, use the next line to set the width
  490. def paint_dialog(b_color,data):
  491. lines = data.split('\n')[1:]
  492. n = len(lines[0])
  493. w = curses.newwin(len(lines)+2,n+3,5,5)
  494. w.bkgd(' ',b_color)
  495. w.clear()
  496. w.border()
  497. for i,d in enumerate(lines):
  498. w.addnstr(i+1,1,d,n)
  499. #w.refresh I don't know why this isn't needed :(
  500. return w
  501. help_string = """
  502. Dirnotes add descriptions to files
  503. uses xattrs and a database
  504. version %s
  505. h help window (h1/h2 for more help)
  506. e edit file description
  507. d see file+comment details
  508. s sort
  509. q quit
  510. M switch between xattr & database
  511. C copy comment between modes
  512. p preferences/settings [not impl]
  513. c copy file
  514. m move file
  515. <enter> to enter directory""" % (VERSION,)
  516. def show_help():
  517. w = paint_dialog(COLOR_HELP,help_string)
  518. c = w.getch()
  519. del w
  520. if c==ord('1'):
  521. show_help1()
  522. if c==ord('2'):
  523. show_help2()
  524. help1_string = """
  525. Dirnotes stores its comments in the xattr property of files
  526. where it can, and in a database.
  527. XATTR
  528. =====
  529. The xattr comments are attached to the 'user.xdg.comment'
  530. property. If you copy/move/tar the file, there are often
  531. options to move the xattrs with the file.
  532. The xattr comments don't always work. For example, you may
  533. not have write permission on a file. Or you may be using
  534. an exFat/fuse filesystem that doesn't support xattr. You
  535. cannot add xattr comments to symlinks.
  536. DATABASE
  537. ========
  538. The database isvstored at ~/.dirnotes.db using sqlite3.
  539. The comments are indexed by the realpath(filename), which
  540. may change if you use external drives and use varying
  541. mountpoints.
  542. These comments will not move with a file unless you use the
  543. move/copy commands inside this program.
  544. The database allows you to add comments to files you don't
  545. own, or which are read-only.
  546. When the comments in the two systems differ, the comment is
  547. highlighted in green. The 'M' command lets you view either
  548. xattr or database comments. The 'C' command allows you to
  549. copy comments between xattr and database."""
  550. def show_help1():
  551. w = paint_dialog(COLOR_HELP,help1_string)
  552. c = w.getch()
  553. del w
  554. help2_string = """
  555. The comments are also stored with the date-of-the-comment and
  556. the username of the comment's author. The 'd' key will
  557. display that info.
  558. Optionally, the database can be stored at
  559. /var/lib/dirnotes/dirnotes.db
  560. which allows access to all users (not implimented)"""
  561. def show_help2():
  562. w = paint_dialog(COLOR_HELP,help2_string)
  563. c = w.getch()
  564. del w
  565. #TODO: fix this to paint_dialog
  566. #TODO: fix to allow upper/lower case responses
  567. sort_string = """
  568. Select sort order:
  569. name
  570. date
  571. size
  572. comment"""
  573. def show_sort():
  574. h = paint_dialog(COLOR_HELP,sort_string)
  575. h.attron(COLOR_TITLE)
  576. h.addstr(3,3,"n") or h.addstr(4,3,"d") or h.addstr(5,3,"s") or h.addstr(6,3,"c")
  577. h.attroff(COLOR_TITLE)
  578. h.refresh()
  579. c = h.getch()
  580. del h
  581. return c
  582. detail_string = """
  583. Comments detail:
  584. Comment:
  585. Author:
  586. Date: """
  587. def show_detail(f):
  588. global mode
  589. h = paint_dialog(COLOR_HELP,detail_string)
  590. if mode=="xattr":
  591. h.addstr(1,20,"from xattrs")
  592. c = f.getXattrComment()
  593. a = f.getXattrAuthor()
  594. d = time.ctime(f.getXattrDate())
  595. else:
  596. h.addstr(1,20,"from database")
  597. c = f.getDbComment()
  598. a = f.getDbAuthor()
  599. d = f.getDbDate()
  600. h.addnstr(2,12,c,h.getmaxyx()[1]-13)
  601. h.addstr(3,12,a if a else "<not set>")
  602. h.addstr(4,12,d if d else "<not set>")
  603. h.refresh()
  604. c = h.getch()
  605. del h
  606. return c
  607. ## used by the comment editor to pick up <ENTER> and <ESC>
  608. edit_done = False
  609. def edit_fn(c):
  610. global edit_done
  611. if c==ord('\n'):
  612. edit_done = True
  613. return 7
  614. if c==27:
  615. return 7
  616. return c
  617. def main(w, cwd):
  618. global files, edit_done, mode
  619. global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
  620. curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
  621. curses.init_pair(CP_BODY, curses.COLOR_WHITE,curses.COLOR_BLUE)
  622. curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
  623. curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
  624. curses.init_pair(CP_HELP, curses.COLOR_WHITE,curses.COLOR_CYAN)
  625. curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
  626. COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
  627. COLOR_BODY = curses.color_pair(CP_BODY)
  628. COLOR_FOCUS = curses.color_pair(CP_FOCUS)
  629. COLOR_ERROR = curses.color_pair(CP_ERROR)
  630. COLOR_HELP = curses.color_pair(CP_HELP)
  631. COLOR_DIFFER = curses.color_pair(CP_DIFFER)
  632. logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
  633. files = Files(cwd)
  634. logging.info(f"got files, len={len(files)}")
  635. mywin = Pane(w,cwd,files)
  636. showing_edit = False
  637. while True:
  638. c = mywin.file_pad.getch(mywin.cursor,1)
  639. if c == CMD_QUIT or c == CMD_ESC:
  640. break
  641. elif c == CMD_HELP:
  642. show_help()
  643. mywin.refresh()
  644. elif c == CMD_SORT:
  645. c = show_sort()
  646. if c == ord('s') or c == ord('S'):
  647. Files.sort_mode = Files.sortSize
  648. elif c == ord('n') or c == ord('N'):
  649. Files.sort_mode = Files.sortName
  650. elif c == ord('d') or c == ord('D'):
  651. Files.sort_mode = Files.sortDate
  652. elif c == ord('c') or c == ord('C'):
  653. Files.sort_mode = Files.sortComment
  654. files.sort()
  655. mywin.refill()
  656. mywin.refresh()
  657. elif c == curses.KEY_UP:
  658. mywin.line_move(-1)
  659. elif c == curses.KEY_DOWN:
  660. mywin.line_move(1)
  661. elif c == curses.KEY_PPAGE:
  662. mywin.line_move(-mywin.pad_visible+1)
  663. elif c == curses.KEY_NPAGE:
  664. mywin.line_move(mywin.pad_visible-1)
  665. elif c == curses.KEY_HOME:
  666. mywin.line_move(-len(files)+1)
  667. elif c == curses.KEY_END:
  668. mywin.line_move(len(files)-1)
  669. elif c == CMD_DETAIL:
  670. show_detail(files[mywin.cursor])
  671. mywin.refresh()
  672. elif c == CMD_MODE:
  673. mode = "db" if mode=="xattr" else "xattr"
  674. mywin.refill()
  675. mywin.refresh()
  676. elif c == CMD_RELOAD:
  677. where = files.getCurDir().fileName
  678. files = Files(where)
  679. mywin = Pane(w,where,files)
  680. elif c == CMD_CD:
  681. f = files[mywin.cursor]
  682. if f.isDir():
  683. cwd = f.getName()
  684. logging.info(f"CD change to {cwd}")
  685. files = Files(cwd)
  686. mywin = Pane(w,cwd,files)
  687. # TODO: should this simply re-fill() the existing Pane instead of destroy?
  688. elif c == CMD_EDIT:
  689. showing_edit = True
  690. edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
  691. edit_window.border()
  692. edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
  693. he,we = edit_window.getmaxyx()
  694. edit_sub = edit_window.derwin(3,we-2,1,1)
  695. f = files[mywin.cursor]
  696. mywin.setStatus(f"Edit file: {f.getFileName()}")
  697. existing_comment = f.getXattrComment()
  698. edit_sub.addstr(0,0,existing_comment)
  699. text = curses.textpad.Textbox(edit_sub)
  700. edit_window.refresh()
  701. comment = text.edit(edit_fn).strip()
  702. logging.info(f"comment: {comment} and flag-ok {edit_done}")
  703. if edit_done:
  704. comment = comment.replace('\n',' ')
  705. logging.info(f"got a new comment as '{comment}'")
  706. edit_done = False
  707. f.setXattrComment(comment)
  708. f.setDbComment(comment)
  709. logging.info(f"set file {f.fileName} with comment <{comment}>")
  710. mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
  711. del text, edit_sub, edit_window
  712. mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
  713. mywin.statusbar.redrawwin()
  714. mywin.focus_line()
  715. mywin.refresh()
  716. elif c == CMD_CMNT_CP:
  717. # copy comments to the other mode
  718. cp_cmnt_ask = curses.newwin(6,40,5,5)
  719. cp_cmnt_ask.border()
  720. cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
  721. cp_cmnt_ask.addstr(1,22,"database" if mode=="xattr" else "xattr")
  722. cp_cmnt_ask.addstr(2,1,"1 just this file")
  723. cp_cmnt_ask.addstr(3,1,"a all files with comments")
  724. cp_cmnt_ask.addstr(4,1,"esc to cancel")
  725. cp_cmnt_ask.refresh()
  726. c = cp_cmnt_ask.getch()
  727. # esc
  728. if c!=ord('1') and c!=ord('a') and c!=ord('A'):
  729. continue
  730. # copy comments for one file or all
  731. if c==ord('1'):
  732. collection = [files[mywin.cursor]]
  733. else:
  734. collection = files
  735. for f in collection:
  736. if mode=="xattr":
  737. if f.getXattrComment():
  738. f.setDbComment(f.getXattrComment())
  739. else:
  740. if f.getDbComment():
  741. f.setXattrComment(f.getDbComment())
  742. mywin.refill()
  743. mywin.refresh()
  744. elif c == CMD_COPY:
  745. if files[mywin.cursor].displayName == "..":
  746. continue
  747. if os.path.isdir(files[mywin.cursor].fileName):
  748. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Copy not allowed")
  749. continue
  750. dest_dir = showFolderPicker(cwd,"Select folder for copy").value()
  751. if dest_dir:
  752. #errorBox(f"copy cmd to {dest_dir}")
  753. src = cwd + '/' + files[mywin.cursor].displayName
  754. dest = dest_dir + '/' + files[mywin.cursor].displayName
  755. # copy2 preserves dates & chmod/chown & xattr
  756. logging.info(f"copy from {src} to {dest_dir}")
  757. shutil.copy2(src, dest_dir)
  758. # and copy the database record
  759. f = FileObj(dest)
  760. f.setDbComment(files[mywin.cursor].getDbComment())
  761. mywin.refresh()
  762. elif c == CMD_MOVE:
  763. if files[mywin.cursor].displayName == "..":
  764. continue
  765. if os.path.isdir(files[mywin.cursor].fileName):
  766. errorBox(f"<{files[mywin.cursor].displayName}> is a directory. Move not allowed")
  767. continue
  768. dest_dir = showFolderPicker(cwd,"Select folder for move").value()
  769. if dest_dir:
  770. #errorBox(f"move cmd to {dest_dir}")
  771. src = cwd + '/' + files[mywin.cursor].displayName
  772. dest = dest_dir + '/' + files[mywin.cursor].displayName
  773. # move preserves dates & chmod/chown & xattr
  774. logging.info(f"move from {src} to {dest_dir}")
  775. shutil.move(src, dest_dir)
  776. # and copy the database record
  777. f = FileObj(dest)
  778. f.setDbComment(files[mywin.cursor].getDbComment())
  779. files = Files(cwd)
  780. mywin = Pane(w,cwd,files)
  781. elif c == curses.KEY_RESIZE:
  782. mywin.resize()
  783. #mywin.refresh()
  784. def pre_main():
  785. # done before we switch to curses mode
  786. logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
  787. logging.info("starting curses dirnotes")
  788. parser = argparse.ArgumentParser(description="Add comments to files")
  789. parser.add_argument('-c','--config', dest='config_file', help="config file (json format)")
  790. parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
  791. parser.add_argument('directory', type=str, default='.', nargs='?', help="directory to start")
  792. args = parser.parse_args()
  793. logging.info(args)
  794. if args.config_file:
  795. config_file = args.config_file
  796. else:
  797. config_file = DEFAULT_CONFIG_FILE
  798. config_file = os.path.expanduser(config_file)
  799. config = DEFAULT_CONFIG
  800. try:
  801. with open(config_file,"r") as f:
  802. config = json.load(f)
  803. except json.JSONDecodeError:
  804. print(f"problem reading the config file {config_file}")
  805. printf("please check the .json syntax")
  806. time.sleep(2)
  807. except FileNotFoundError:
  808. print(f"config file {config_file} not found, using default settings & creating a default")
  809. try:
  810. with open(config_file,"w") as f:
  811. json.dump(config,f,indent=4)
  812. except:
  813. print(f"problem creating file {config_file}")
  814. time.sleep(2)
  815. # print(repr(config))
  816. # print("start_mode",config["start_mode"])
  817. return args, config
  818. args, config = pre_main()
  819. mode = config["start_mode"]
  820. xattr_comment = config["xattr_tag"]
  821. xattr_author = config["xattr_tag"] + ".author"
  822. xattr_date = config["xattr_tag"] + ".date"
  823. # change ~ tilde to username
  824. database_name = os.path.expanduser(config["database"])
  825. cwd = args.directory
  826. curses.wrapper(main, cwd)
  827. # dirnotes database is name, date, size, comment, comment_date, author
  828. # symlinks: follow_symlinks should always be True, because symlinks in Linux
  829. # can't have xattr....it appears to be the same in darwin