dirnotes-tui 33 KB

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