dirnotes-tui 33 KB

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