dirnotes-tui 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014
  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_d(*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. # >>> snip here <<<
  278. #============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
  279. import getpass, time, stat, shutil
  280. DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
  281. # config
  282. # we could store the config in the database, in a second table
  283. # or in a .json file
  284. DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
  285. "database":"~/.local/share/dirnotes/dirnotes.db",
  286. "start_mode":"xattr",
  287. "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
  288. "options for start_mode":("db","xattr")
  289. }
  290. class ConfigLoader: # singleton
  291. def __init__(self, configFile):
  292. configFile = os.path.expanduser(configFile)
  293. try:
  294. with open(configFile,"r") as f:
  295. config = json.load(f)
  296. except json.JSONDecodeError:
  297. errorBox(f"problem reading config file {configFile}; check the JSON syntax")
  298. config = DEFAULT_CONFIG
  299. except FileNotFoundError:
  300. errorBox(f"config file {configFile} not found; using the default settings")
  301. config = DEFAULT_CONFIG
  302. try:
  303. os.makedirs(os.path.dirname(configFile),exist_ok = True)
  304. with open(configFile,"w") as f:
  305. json.dump(config,f,indent=4)
  306. except:
  307. errorBox(f"problem creating the config file {configFile}")
  308. self.dbName = os.path.expanduser(config["database"])
  309. self.mode = config["start_mode"] # can get over-ruled by the command line options
  310. self.xattr_comment = config["xattr_tag"]
  311. class DnDataBase:
  312. ''' the database is flat
  313. fileName: fully qualified name
  314. st_mtime: a float
  315. size: a long
  316. comment: a string
  317. comment_time: a float, the time of the comment save
  318. author: the username that created the comment
  319. this object: 1) finds or creates the database
  320. 2) determine if it's readonly
  321. TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/)
  322. TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
  323. make it 0666 permissions (rw-rw-rw-)
  324. '''
  325. def __init__(self,dbFile):
  326. '''try to open the database; if not found, create it'''
  327. try:
  328. self.db = sqlite3.connect(dbFile)
  329. except sqlite3.OperationalError:
  330. print_d(f"Database {dbFile} not found")
  331. try:
  332. os.makedirs(os.path.dirname(dbFile), exist_ok = True)
  333. self.db = sqlite3.connect(dbFile)
  334. except (sqlite3.OperationalError, PermissionError):
  335. printd(f"Failed to create {dbFile}, aborting")
  336. raise
  337. # create new table if it doesn't exist
  338. try:
  339. self.db.execute("select * from dirnotes")
  340. except sqlite3.OperationalError:
  341. self.db.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
  342. self.db.execute("create index dirnotes_i on dirnotes(name)")
  343. print_d(f"Table dirnotes created")
  344. # at this point, if a shared database is required, somebody needs to set perms to 0o666
  345. self.writable = True
  346. try:
  347. self.db.execute("pragma user_verson=0")
  348. except sqlite3.OperationalError:
  349. self.writable = False
  350. DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
  351. class UiHelper:
  352. @staticmethod
  353. def epochToDb(epoch):
  354. return time.strftime(DATE_FORMAT,time.localtime(epoch))
  355. @staticmethod
  356. def DbToEpoch(dbTime):
  357. return time.mktime(time.strptime(dbTime,DATE_FORMAT))
  358. @staticmethod
  359. def getShortDate(longDate):
  360. now = time.time()
  361. diff = now - longDate
  362. if diff > YEAR:
  363. fmt = "%b %e %Y"
  364. else:
  365. fmt = "%b %e %H:%M"
  366. return time.strftime(fmt, time.localtime(longDate))
  367. @staticmethod
  368. def getShortSize(fo):
  369. if fo.isDir():
  370. return " <DIR> "
  371. elif fo.isLink():
  372. return " <LINK>"
  373. size = fo.getSize()
  374. log = int((math.log10(size+1)-2)/3)
  375. s = " KMGTE"[log]
  376. base = int(size/math.pow(10,log*3))
  377. return f"{base}{s}".strip().rjust(7)
  378. ## one for each file
  379. ## and a special one for ".." parent directory
  380. class FileObj:
  381. """ The FileObj knows about both kinds of comments. """
  382. def __init__(self, fileName, db):
  383. self.fileName = os.path.abspath(fileName) # full path; dirs end WITHOUT a terminal /
  384. self.stat = os.lstat(self.fileName)
  385. self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
  386. if self.isDir():
  387. if not self.displayName.endswith('/'):
  388. self.displayName += '/'
  389. self.date = self.stat.st_mtime
  390. self.size = self.stat.st_size
  391. self.db = db
  392. def getName(self):
  393. """ returns the absolute pathname """
  394. return self.fileName
  395. def getDisplayName(self):
  396. """ returns just the basename of the file; dirs end in / """
  397. return self.displayName
  398. def getDbData(self):
  399. """ returns (comment, author, comment_date) """
  400. if not hasattr(self,'dbCommentAuthorDate'):
  401. cad = self.db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(self.fileName,)).fetchone()
  402. self.dbCommentAuthorDate = cad if cad else (None, None, None)
  403. return self.dbCommentAuthorDate
  404. def getDbComment(self):
  405. return self.getDbData()[0]
  406. def getXattrData(self):
  407. """ returns (comment, author, comment_date) """
  408. if not hasattr(self,'xattrCommentAuthorDate'):
  409. c = a = d = None
  410. try:
  411. c = os.getxattr(self.fileName, xattr_comment, follow_symlinks=False).decode()
  412. a = os.getxattr(self.fileName, xattr_author, follow_symlinks=False).decode()
  413. d = os.getxattr(self.fileName, xattr_date, follow_symlinks=False).decode()
  414. except: # no xattr comment
  415. pass
  416. self.xattrCommentAuthorDate = c,a,d
  417. return self.xattrCommentAuthorDate
  418. def getXattrComment(self):
  419. return self.getXattrData()[0]
  420. def setDbComment(self,newComment):
  421. # how are we going to hook this?
  422. #if not self.db.writable:
  423. # errorBox("The database is readonly; you cannot add or edit comments")
  424. # return
  425. s = os.lstat(self.fileName)
  426. try:
  427. print_d(f"setDbComment db {self.db}, file: {self.fileName}")
  428. self.db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  429. (self.fileName, s.st_mtime, s.st_size,
  430. str(newComment), time.time(), getpass.getuser()))
  431. self.db.commit()
  432. self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
  433. except sqlite3.OperationalError:
  434. print_d("database is locked or unwritable")
  435. errorBox("the database that stores comments is locked or unwritable")
  436. def setXattrComment(self,newComment):
  437. print_d(f"set comment {newComment} on file {self.fileName}")
  438. try:
  439. os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
  440. os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
  441. os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
  442. self.xattrCommentAuthorDate = newComment, getpass.getuser(), time.strftime(DATE_FORMAT)
  443. return True
  444. # we need to move these cases out to a handler
  445. except Exception as e:
  446. if self.isLink():
  447. errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
  448. elif self.isSock():
  449. errorBox("Linux does not allow comments on sockets; comment is stored in database")
  450. elif os.access(self.fileName, os.W_OK)!=True:
  451. errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
  452. # change the listbox background to yellow
  453. elif "Errno 95" in str(e):
  454. errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
  455. return False
  456. def getComment(self,mode):
  457. """ returns the comment for the given mode """
  458. return self.getDbComment() if mode == "db" else self.getXattrComment()
  459. def getOtherComment(self,mode):
  460. return self.getDbComment() if mode == "xattr" else self.getXattrComment()
  461. def getData(self,mode):
  462. """ returns (comment, author, comment_date) for the given mode """
  463. return self.getDbData() if mode == "db" else self.getXattrData()
  464. def getOtherData(self,mode):
  465. """ returns (comment, author, comment_date) for the 'other' mode """
  466. return self.getDbData() if mode == "xattr" else self.getXattrData()
  467. def getDate(self):
  468. return self.date
  469. def getSize(self):
  470. return self.size
  471. def isDir(self):
  472. return stat.S_ISDIR(self.stat.st_mode)
  473. def isLink(self):
  474. return stat.S_ISLNK(self.stat.st_mode)
  475. def isSock(self):
  476. return stat.S_ISSOCK(self.stat.st_mode)
  477. def copyFile(self, dest, doMove = False):
  478. """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
  479. # NOTE: this method copies the xattr (comment + old author + old date)
  480. # but creates new db (comment + this author + new date)
  481. if os.path.isdir(dest):
  482. dest = os.path.join(destDir,self.displayName)
  483. try:
  484. print_d("try copy from",self.fileName,"to",dest)
  485. # shutil methods preserve dates & chmod/chown & xattr
  486. if doMove:
  487. shutil.move(self.fileName, dest)
  488. else:
  489. shutil.copy2(self.fileName, dest)
  490. # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError
  491. except:
  492. errorBox(f"file copy/move to <{dest}> failed; check permissions")
  493. return
  494. # and copy the database record
  495. f = FileObj(dest, self.db)
  496. f.setDbComment(self.getDbComment())
  497. def moveFile(self, dest):
  498. """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
  499. self.copyFile(dest, doMove = True)
  500. # >>> snip here <<<
  501. ########## dest directory picker ###############
  502. # returns None if the user hits <esc>
  503. # the dir_pad contents are indexed from 0,0, matching self.fs
  504. class showDirectoryPicker:
  505. def __init__(self,starting_dir,title):
  506. self.selected = None
  507. self.title = title
  508. self.starting_dir = self.cwd = os.path.abspath(starting_dir)
  509. # draw the perimeter...it doesn't change
  510. self.W = curses.newwin(20,60,5,5)
  511. self.W.bkgd(' ',COLOR_HELP)
  512. self.h, self.w = self.W.getmaxyx()
  513. self.W.keypad(True)
  514. #self.W.clear()
  515. self.W.box()
  516. self.W.addnstr(0,1,self.title,self.w-2)
  517. self.W.addstr(self.h-1,1,"<Enter> to select or change dir, <esc> to exit")
  518. self.W.refresh()
  519. self.fill()
  520. inDialog = True
  521. selected = ''
  522. while inDialog:
  523. c = self.W.getch()
  524. y,x = self.dir_pad.getyx()
  525. if c == curses.KEY_UP:
  526. if y==0:
  527. continue
  528. y -= 1
  529. self.dir_pad.move(y,0)
  530. if y < self.first_visible:
  531. self.first_visible = y
  532. self.refresh()
  533. elif c == curses.KEY_DOWN:
  534. if y == len(self.fs)-1:
  535. continue
  536. y += 1
  537. self.dir_pad.move(y,0)
  538. if y-self.first_visible > self.h-3:
  539. self.first_visible += 1
  540. self.refresh()
  541. elif c == CMD_CD:
  542. # cd to new dir and refill
  543. if y==0 and self.fs[0].startswith('<use'): # current dir
  544. self.selected = self.cwd
  545. inDialog = False
  546. else:
  547. self.cwd = os.path.abspath(self.cwd + '/' + self.fs[y])
  548. #logging.info(f"change dir to {self.cwd}")
  549. self.fill() # throw away the old self.dir_pad
  550. elif c == CMD_ESC:
  551. inDialog = False
  552. del self.W
  553. def value(self):
  554. logging.info(f"dir picker returns {self.selected}")
  555. return self.selected
  556. def refresh(self):
  557. y,x = self.W.getbegyx()
  558. self.dir_pad.refresh(self.first_visible,0, x+1,y+1, x+self.h-2,y+self.w-2)
  559. def fill(self):
  560. # change to os.path.walk() and just use the directories
  561. # self.fs is the list of candidates, prefixed by "use this" and ".."
  562. d, self.fs, _ = next(os.walk(self.cwd))
  563. self.fs.sort()
  564. if self.cwd != '/':
  565. self.fs.insert(0,"..")
  566. if self.cwd != self.starting_dir:
  567. self.fs.insert(0,f"<use this dir> {os.path.basename(self.cwd)}")
  568. # create a pad big enough to hold all the entries
  569. self.pad_height = max(self.h-2,len(self.fs))
  570. self.dir_pad = curses.newpad(self.pad_height, self.w - 2)
  571. self.dir_pad.bkgdset(' ',curses.color_pair(CP_BODY))
  572. self.dir_pad.clear()
  573. self.first_visible = 0
  574. # and fill it with strings
  575. for i,f in enumerate(self.fs):
  576. self.dir_pad.addnstr(i,0,f,self.w-2)
  577. self.dir_pad.move(0,0)
  578. self.refresh()
  579. ########### comment management code #################
  580. # paint a dialog window with a border and contents
  581. # discard the 1st line, use the next line to set the width
  582. def paint_dialog(b_color,data):
  583. lines = data.split('\n')[1:]
  584. n = len(lines[0])
  585. w = curses.newwin(len(lines)+2,n+3,5,5)
  586. w.bkgd(' ',b_color)
  587. w.clear()
  588. w.box()
  589. for i,d in enumerate(lines):
  590. w.addnstr(i+1,1,d,n)
  591. #w.refresh I don't know why this isn't needed :(
  592. return w
  593. help_string = """
  594. Dirnotes add descriptions to files
  595. uses xattrs and a database
  596. version %s
  597. h help window (h1/h2 for more help)
  598. e edit file description
  599. d see file+comment details
  600. s sort
  601. q quit
  602. M switch between xattr & database
  603. C copy comment between modes
  604. p preferences/settings [not impl]
  605. c copy file
  606. m move file
  607. <enter> to enter directory""" % (VERSION,)
  608. def show_help():
  609. w = paint_dialog(COLOR_HELP,help_string)
  610. c = w.getch()
  611. del w
  612. if c==ord('1'):
  613. show_help1()
  614. if c==ord('2'):
  615. show_help2()
  616. help1_string = """
  617. Dirnotes stores its comments in the xattr property of files
  618. where it can, and in a database.
  619. XATTR
  620. =====
  621. The xattr comments are attached to the 'user.xdg.comment'
  622. property. If you copy/move/tar the file, there are often
  623. options to move the xattrs with the file.
  624. The xattr comments don't always work. For example, you may
  625. not have write permission on a file. Or you may be using
  626. an exFat/fuse filesystem that doesn't support xattr. You
  627. cannot add xattr comments to symlinks.
  628. DATABASE
  629. ========
  630. The database isvstored at ~/.dirnotes.db using sqlite3.
  631. The comments are indexed by the realpath(filename), which
  632. may change if you use external drives and use varying
  633. mountpoints.
  634. These comments will not move with a file unless you use the
  635. move/copy commands inside this program.
  636. The database allows you to add comments to files you don't
  637. own, or which are read-only.
  638. When the comments in the two systems differ, the comment is
  639. highlighted in green. The 'M' command lets you view either
  640. xattr or database comments. The 'C' command allows you to
  641. copy comments between xattr and database."""
  642. def show_help1():
  643. w = paint_dialog(COLOR_HELP,help1_string)
  644. c = w.getch()
  645. del w
  646. help2_string = """
  647. The comments are also stored with the date-of-the-comment and
  648. the username of the comment's author. The 'd' key will
  649. display that info.
  650. Optionally, the database can be stored at
  651. /var/lib/dirnotes/dirnotes.db
  652. which allows access to all users (not implimented)"""
  653. def show_help2():
  654. w = paint_dialog(COLOR_HELP,help2_string)
  655. c = w.getch()
  656. del w
  657. sort_string = """
  658. Select sort order:
  659. name
  660. date
  661. size
  662. comment"""
  663. def show_sort():
  664. h = paint_dialog(COLOR_HELP,sort_string)
  665. h.attron(COLOR_TITLE)
  666. h.addstr(3,3,"n") or h.addstr(4,3,"d") or h.addstr(5,3,"s") or h.addstr(6,3,"c")
  667. h.attroff(COLOR_TITLE)
  668. h.refresh()
  669. c = h.getch()
  670. del h
  671. return c
  672. detail_string = """
  673. Comments detail:
  674. Comment:
  675. Author:
  676. Date: """
  677. def show_detail(f):
  678. global mode
  679. h = paint_dialog(COLOR_HELP,detail_string)
  680. c,a,d = f.getData(mode) # get all three, depending on the current mode
  681. h.addstr(1,20,"from xattrs" if mode=="xattr" else "from database")
  682. h.addnstr(2,12,c or "<not set>",h.getmaxyx()[1]-13)
  683. h.addstr(3,12,a or "<not set>")
  684. h.addstr(4,12,d or "<not set>")
  685. h.refresh()
  686. c = h.getch()
  687. del h
  688. return c
  689. ## used by the comment editor to pick up <ENTER> and <ESC>
  690. edit_done = False
  691. def edit_fn(c):
  692. global edit_done
  693. if c==ord('\n'):
  694. edit_done = True
  695. return 7
  696. if c==27:
  697. return 7
  698. return c
  699. def main(w, cwd, database_file, start_file):
  700. global files, edit_done, mode
  701. global COLOR_TITLE, COLOR_BODY, COLOR_FOCUS, COLOR_ERROR, COLOR_HELP
  702. curses.init_pair(CP_TITLE, curses.COLOR_YELLOW,curses.COLOR_BLUE)
  703. curses.init_pair(CP_BODY, curses.COLOR_WHITE,curses.COLOR_BLUE)
  704. curses.init_pair(CP_FOCUS, curses.COLOR_BLACK,curses.COLOR_CYAN)
  705. curses.init_pair(CP_ERROR, curses.COLOR_BLACK,curses.COLOR_RED)
  706. curses.init_pair(CP_HELP, curses.COLOR_WHITE,curses.COLOR_CYAN)
  707. curses.init_pair(CP_DIFFER,curses.COLOR_WHITE,curses.COLOR_GREEN)
  708. COLOR_TITLE = curses.color_pair(CP_TITLE) | curses.A_BOLD
  709. COLOR_BODY = curses.color_pair(CP_BODY)
  710. COLOR_FOCUS = curses.color_pair(CP_FOCUS)
  711. COLOR_ERROR = curses.color_pair(CP_ERROR)
  712. COLOR_HELP = curses.color_pair(CP_HELP)
  713. COLOR_DIFFER = curses.color_pair(CP_DIFFER)
  714. logging.info(f"COLOR_DIFFER is {COLOR_DIFFER}")
  715. db = DnDataBase(database_file).db
  716. files = Files(cwd,db)
  717. logging.info(f"got files, len={len(files)}")
  718. mywin = Pane(w,cwd,files,start_file = start_file)
  719. showing_edit = False
  720. while True:
  721. c = mywin.file_pad.getch(mywin.cursor,1)
  722. if c == CMD_QUIT or c == CMD_ESC:
  723. break
  724. elif c == CMD_HELP:
  725. show_help()
  726. mywin.refresh()
  727. elif c == CMD_SORT:
  728. c = show_sort()
  729. if c == ord('s') or c == ord('S'):
  730. Files.sortFunc = Files.sortSize
  731. elif c == ord('n') or c == ord('N'):
  732. Files.sortFunc = Files.sortName
  733. elif c == ord('d') or c == ord('D'):
  734. Files.sortFunc = Files.sortDate
  735. elif c == ord('c') or c == ord('C'):
  736. Files.sortFunc = Files.sortComment
  737. files.sort()
  738. mywin.refill()
  739. mywin.refresh()
  740. elif c == curses.KEY_UP:
  741. mywin.line_move(-1)
  742. elif c == curses.KEY_DOWN:
  743. mywin.line_move(1)
  744. elif c == curses.KEY_PPAGE:
  745. mywin.line_move(-mywin.pad_visible+1)
  746. elif c == curses.KEY_NPAGE:
  747. mywin.line_move(mywin.pad_visible-1)
  748. elif c == curses.KEY_HOME:
  749. mywin.line_move(-len(files)+1)
  750. elif c == curses.KEY_END:
  751. mywin.line_move(len(files)-1)
  752. elif c == CMD_DETAIL:
  753. show_detail(files[mywin.cursor])
  754. mywin.refresh()
  755. elif c == CMD_MODE:
  756. mode = "db" if mode=="xattr" else "xattr"
  757. mywin.refill()
  758. mywin.refresh()
  759. elif c == CMD_RELOAD:
  760. where = files.getCurDir().fileName
  761. files = Files(where,db)
  762. mywin = Pane(w,where,files)
  763. elif c == CMD_CD:
  764. f = files[mywin.cursor]
  765. if f.isDir():
  766. cwd = f.getName()
  767. print_d(f"CD change to {cwd}")
  768. files = Files(cwd,db)
  769. mywin = Pane(w,cwd,files)
  770. # TODO: should this simply re-fill() the existing Pane instead of destroy?
  771. elif c == CMD_EDIT:
  772. showing_edit = True
  773. edit_window = curses.newwin(5,(4*mywin.w) // 5,mywin.h // 2 - 3, mywin.w // 10)
  774. edit_window.box()
  775. edit_window.addstr(0,1,"<enter> when done, <esc> to cancel")
  776. he,we = edit_window.getmaxyx()
  777. edit_sub = edit_window.derwin(3,we-2,1,1)
  778. f = files[mywin.cursor]
  779. mywin.setStatus(f"Edit file: {f.getName()}")
  780. existing_comment = f.getXattrComment()
  781. edit_sub.addstr(0,0,existing_comment or '')
  782. text = curses.textpad.Textbox(edit_sub)
  783. edit_window.refresh()
  784. comment = text.edit(edit_fn).strip()
  785. logging.info(f"comment: {comment} and flag-ok {edit_done}")
  786. if edit_done:
  787. comment = comment.replace('\n',' ')
  788. logging.info(f"got a new comment as '{comment}'")
  789. edit_done = False
  790. f.setXattrComment(comment)
  791. f.setDbComment(comment)
  792. logging.info(f"set file {f.fileName} with comment <{comment}>")
  793. mywin.main_win.redrawln(mywin.cursor-mywin.first_visible+2,1)
  794. del text, edit_sub, edit_window
  795. mywin.main_win.redrawln(mywin.h // 2 - 3, 5)
  796. mywin.statusbar.redrawwin()
  797. mywin.focus_line()
  798. mywin.refresh()
  799. elif c == CMD_CMNT_CP:
  800. # copy comments to the other mode
  801. cp_cmnt_ask = curses.newwin(6,40,5,5)
  802. cp_cmnt_ask.box()
  803. cp_cmnt_ask.addstr(1,1,"Copy comments to ==> ")
  804. cp_cmnt_ask.addstr(1,22,"database" if mode=="xattr" else "xattr")
  805. cp_cmnt_ask.addstr(2,1," 1 just this file")
  806. cp_cmnt_ask.addstr(3,1," a all files with comments")
  807. cp_cmnt_ask.addstr(4,1,"esc to cancel")
  808. cp_cmnt_ask.refresh()
  809. c = cp_cmnt_ask.getch()
  810. if c in (ord('1'), ord('a'), ord('A')):
  811. # copy comments for one file or all
  812. if c==ord('1'):
  813. collection = [files[mywin.cursor]]
  814. else:
  815. collection = files
  816. for f in collection:
  817. if mode=="xattr":
  818. if f.getXattrComment():
  819. f.setDbComment(f.getXattrComment())
  820. else:
  821. if f.getDbComment():
  822. f.setXattrComment(f.getDbComment())
  823. mywin.refill()
  824. mywin.refresh()
  825. elif c == CMD_COPY:
  826. if files[mywin.cursor].getDisplayName() == "../":
  827. continue
  828. if files[mywin.cursor].isDir():
  829. errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Copy not allowed")
  830. else:
  831. dest_dir = showDirectoryPicker(cwd,"Select folder for copy").value()
  832. if dest_dir:
  833. files[mywin.cursor].copyFile(dest_dir)
  834. mywin.refresh()
  835. elif c == CMD_MOVE:
  836. if files[mywin.cursor].getDisplayName() == "../":
  837. continue
  838. if files[mywin.cursor].isDir():
  839. errorBox(f"<{files[mywin.cursor].getDisplayName()}> is a directory. Move not allowed")
  840. else:
  841. dest_dir = showDirectoryPicker(cwd,"Select folder for move").value()
  842. if dest_dir:
  843. files[mywin.cursor].moveFile(dest_dir)
  844. files = Files(cwd,db)
  845. mywin = Pane(w,cwd,files) # is this the way to refresh the main pane? TODO
  846. mywin.refresh() # to clean up the errorBox or FolderPicker
  847. elif c == curses.KEY_RESIZE:
  848. mywin.resize()
  849. #mywin.refresh()
  850. def pre_main():
  851. # done before we switch to curses mode
  852. logging.basicConfig(filename='/tmp/dirnotes.log', level=logging.DEBUG)
  853. logging.info("starting curses dirnotes")
  854. parser = argparse.ArgumentParser(description="Add comments to files")
  855. parser.add_argument('-c','--config', dest='config_file', help="config file (json format)")
  856. parser.add_argument('-v','--version', action='version', version=f"dirnotes ver:{VERSION}")
  857. parser.add_argument('-d','--db', action='store_true',help="start up in database mode")
  858. parser.add_argument('-x','--xattr', action='store_true',help="start up in xattr mode")
  859. parser.add_argument('directory', type=str, default='.', nargs='?', help="directory or file to start")
  860. args = parser.parse_args()
  861. logging.info(args)
  862. config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
  863. if args.db:
  864. config.mode = "db"
  865. if args.xattr:
  866. config.mode = "xattr"
  867. # print(repr(config))
  868. # print("start_mode",config["start_mode"])
  869. return args, config
  870. curses_running = False
  871. args, config = pre_main()
  872. mode = config.mode
  873. xattr_comment = config.xattr_comment
  874. xattr_author = config.xattr_comment + ".author"
  875. xattr_date = config.xattr_comment + ".date"
  876. database_name = config.dbName
  877. if os.path.isdir(args.directory):
  878. cwd, start_file = args.directory, None
  879. else:
  880. cwd, start_file = os.path.split(args.directory)
  881. curses_running = True
  882. curses.wrapper(main, cwd or '.', database_name, start_file)