dirnotes-tui 32 KB

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