c_dirnotes.py 30 KB

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