dirnotes-tui 31 KB

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