dirnotes-cli 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. #!/usr/bin/python3
  2. # TODO: option to print out full path name; most useful in the .json output format
  3. VERSION = "0.4"
  4. import sys, argparse
  5. # global mutables
  6. answer_json = []
  7. verbose = debug = 0
  8. db = None
  9. xattr_comment = "user.xdg.comment"
  10. xattr_author = "user.xdg.comment.author"
  11. xattr_date = "user.xdg.comment.date"
  12. mode = "db"
  13. #======= debugging/verbose ===========
  14. def print_d(*a):
  15. if debug:
  16. print('>>', *a)
  17. def errorBox(*a):
  18. print(*a)
  19. # >>> snip here <<<
  20. #============ the DnDataBase, UiHelper and FileObj code is shared with other dirnotes programs
  21. import getpass, time, stat, shutil, sqlite3, json, os, math
  22. DEFAULT_CONFIG_FILE = "~/.config/dirnotes/dirnotes.conf" # or /etc/dirnotes.conf
  23. # config
  24. # we could store the config in the database, in a second table
  25. # or in a .json file
  26. DEFAULT_CONFIG = {"xattr_tag":"user.xdg.comment",
  27. "database":"~/.local/share/dirnotes/dirnotes.db",
  28. "start_mode":"xattr",
  29. "options for database":("~/.local/share/dirnotes/dirnotes.db","~/.dirnotes.db","/etc/dirnotes.db"),
  30. "options for start_mode":("db","xattr")
  31. }
  32. class ConfigLoader: # singleton
  33. def __init__(self, configFile):
  34. configFile = os.path.expanduser(configFile)
  35. try:
  36. with open(configFile,"r") as f:
  37. config = json.load(f)
  38. except json.JSONDecodeError:
  39. errorBox(f"problem reading config file {configFile}; check the JSON syntax")
  40. config = DEFAULT_CONFIG
  41. except FileNotFoundError:
  42. errorBox(f"config file {configFile} not found; using the default settings")
  43. config = DEFAULT_CONFIG
  44. try:
  45. os.makedirs(os.path.dirname(configFile),exist_ok = True)
  46. with open(configFile,"w") as f:
  47. json.dump(config,f,indent=4)
  48. except:
  49. errorBox(f"problem creating the config file {configFile}")
  50. self.dbName = os.path.expanduser(config["database"])
  51. self.mode = config["start_mode"] # can get over-ruled by the command line options
  52. self.xattr_comment = config["xattr_tag"]
  53. class DnDataBase:
  54. ''' the database is flat
  55. fileName: fully qualified name
  56. st_mtime: a float
  57. size: a long
  58. comment: a string
  59. comment_time: a float, the time of the comment save
  60. author: the username that created the comment
  61. this object: 1) finds or creates the database
  62. 2) determine if it's readonly
  63. TODO: the database is usually associated with a user, in $XDG_DATA_HOME (~/.local/share/)
  64. TODO: if the database is not found, create it in $XDG_DATA_DIRS (/usr/local/share/)
  65. make it 0666 permissions (rw-rw-rw-)
  66. '''
  67. def __init__(self,dbFile):
  68. '''try to open the database; if not found, create it'''
  69. try:
  70. self.db = sqlite3.connect(dbFile)
  71. except sqlite3.OperationalError:
  72. print_d(f"Database {dbFile} not found")
  73. try:
  74. os.makedirs(os.path.dirname(dbFile), exist_ok = True)
  75. self.db = sqlite3.connect(dbFile)
  76. except (sqlite3.OperationalError, PermissionError):
  77. printd(f"Failed to create {dbFile}, aborting")
  78. raise
  79. # create new table if it doesn't exist
  80. try:
  81. self.db.execute("select * from dirnotes")
  82. except sqlite3.OperationalError:
  83. self.db.execute("create table dirnotes (name TEXT, date DATETIME, size INTEGER, comment TEXT, comment_date DATETIME, author TEXT)")
  84. self.db.execute("create index dirnotes_i on dirnotes(name)")
  85. print_d(f"Table dirnotes created")
  86. # at this point, if a shared database is required, somebody needs to set perms to 0o666
  87. self.writable = True
  88. try:
  89. self.db.execute("pragma user_verson=0")
  90. except sqlite3.OperationalError:
  91. self.writable = False
  92. DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
  93. class UiHelper:
  94. @staticmethod
  95. def epochToDb(epoch):
  96. return time.strftime(DATE_FORMAT,time.localtime(epoch))
  97. @staticmethod
  98. def DbToEpoch(dbTime):
  99. return time.mktime(time.strptime(dbTime,DATE_FORMAT))
  100. @staticmethod
  101. def getShortDate(longDate):
  102. now = time.time()
  103. diff = now - longDate
  104. if diff > YEAR:
  105. fmt = "%b %e %Y"
  106. else:
  107. fmt = "%b %e %H:%M"
  108. return time.strftime(fmt, time.localtime(longDate))
  109. @staticmethod
  110. def getShortSize(fo):
  111. if fo.isDir():
  112. return " <DIR> "
  113. elif fo.isLink():
  114. return " <LINK>"
  115. size = fo.getSize()
  116. log = int((math.log10(size+1)-2)/3)
  117. s = " KMGTE"[log]
  118. base = int(size/math.pow(10,log*3))
  119. return f"{base}{s}".strip().rjust(7)
  120. ## one for each file
  121. ## and a special one for ".." parent directory
  122. class FileObj:
  123. """ The FileObj knows about both kinds of comments. """
  124. def __init__(self, fileName, db):
  125. self.fileName = os.path.abspath(fileName) # full path; dirs end WITHOUT a terminal /
  126. self.stat = os.lstat(self.fileName)
  127. self.displayName = os.path.split(fileName)[1] # base name; dirs end with a /
  128. if self.isDir():
  129. if not self.displayName.endswith('/'):
  130. self.displayName += '/'
  131. self.date = self.stat.st_mtime
  132. self.size = self.stat.st_size
  133. self.db = db
  134. def getName(self):
  135. """ returns the absolute pathname """
  136. return self.fileName
  137. def getDisplayName(self):
  138. """ returns just the basename of the file; dirs end in / """
  139. return self.displayName
  140. def getDbData(self):
  141. """ returns (comment, author, comment_date) """
  142. if not hasattr(self,'dbCommentAuthorDate'):
  143. cad = self.db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(self.fileName,)).fetchone()
  144. self.dbCommentAuthorDate = cad if cad else (None, None, None)
  145. return self.dbCommentAuthorDate
  146. def getDbComment(self):
  147. return self.getDbData()[0]
  148. def getXattrData(self):
  149. """ returns (comment, author, comment_date) """
  150. if not hasattr(self,'xattrCommentAuthorDate'):
  151. c = a = d = None
  152. try:
  153. c = os.getxattr(self.fileName, xattr_comment, follow_symlinks=False).decode()
  154. a = os.getxattr(self.fileName, xattr_author, follow_symlinks=False).decode()
  155. d = os.getxattr(self.fileName, xattr_date, follow_symlinks=False).decode()
  156. except: # no xattr comment
  157. pass
  158. self.xattrCommentAuthorDate = c,a,d
  159. return self.xattrCommentAuthorDate
  160. def getXattrComment(self):
  161. return self.getXattrData()[0]
  162. def setDbComment(self,newComment):
  163. # how are we going to hook this?
  164. #if not self.db.writable:
  165. # errorBox("The database is readonly; you cannot add or edit comments")
  166. # return
  167. s = os.lstat(self.fileName)
  168. try:
  169. print_d(f"setDbComment db {self.db}, file: {self.fileName}")
  170. self.db.execute("insert into dirnotes (name,date,size,comment,comment_date,author) values (?,datetime(?,'unixepoch','localtime'),?,?,datetime(?,'unixepoch','localtime'),?)",
  171. (self.fileName, s.st_mtime, s.st_size,
  172. str(newComment), time.time(), getpass.getuser()))
  173. self.db.commit()
  174. self.dbCommentAuthorDate = newComment, getpass.getuser(), UiHelper.epochToDb(time.time())
  175. except sqlite3.OperationalError:
  176. print_d("database is locked or unwritable")
  177. errorBox("the database that stores comments is locked or unwritable")
  178. def setXattrComment(self,newComment):
  179. print_d(f"set comment {newComment} on file {self.fileName}")
  180. try:
  181. os.setxattr(self.fileName,xattr_comment,bytes(newComment,'utf8'),follow_symlinks=False)
  182. os.setxattr(self.fileName,xattr_author,bytes(getpass.getuser(),'utf8'),follow_symlinks=False)
  183. os.setxattr(self.fileName,xattr_date,bytes(time.strftime(DATE_FORMAT),'utf8'),follow_symlinks=False)
  184. self.xattrCommentAuthorDate = newComment, getpass.getuser(), time.strftime(DATE_FORMAT)
  185. return True
  186. # we need to move these cases out to a handler
  187. except Exception as e:
  188. if self.isLink():
  189. errorBox("Linux does not allow xattr comments on symlinks; comment is stored in database")
  190. elif self.isSock():
  191. errorBox("Linux does not allow comments on sockets; comment is stored in database")
  192. elif os.access(self.fileName, os.W_OK)!=True:
  193. errorBox(f"you don't appear to have write permissions on this file: {self.fileName}")
  194. # change the listbox background to yellow
  195. elif "Errno 95" in str(e):
  196. errorBox("is this a VFAT or EXFAT volume? these don't allow comments")
  197. return False
  198. def getComment(self,mode):
  199. """ returns the comment for the given mode """
  200. return self.getDbComment() if mode == "db" else self.getXattrComment()
  201. def getOtherComment(self,mode):
  202. return self.getDbComment() if mode == "xattr" else self.getXattrComment()
  203. def getData(self,mode):
  204. """ returns (comment, author, comment_date) for the given mode """
  205. return self.getDbData() if mode == "db" else self.getXattrData()
  206. def getOtherData(self,mode):
  207. """ returns (comment, author, comment_date) for the 'other' mode """
  208. return self.getDbData() if mode == "xattr" else self.getXattrData()
  209. def getDate(self):
  210. return self.date
  211. def getSize(self):
  212. return self.size
  213. def isDir(self):
  214. return stat.S_ISDIR(self.stat.st_mode)
  215. def isLink(self):
  216. return stat.S_ISLNK(self.stat.st_mode)
  217. def isSock(self):
  218. return stat.S_ISSOCK(self.stat.st_mode)
  219. def copyFile(self, dest, doMove = False):
  220. """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
  221. # NOTE: this method copies the xattr (comment + old author + old date)
  222. # but creates new db (comment + this author + new date)
  223. if os.path.isdir(dest):
  224. dest = os.path.join(destDir,self.displayName)
  225. try:
  226. print_d("try copy from",self.fileName,"to",dest)
  227. # shutil methods preserve dates & chmod/chown & xattr
  228. if doMove:
  229. shutil.move(self.fileName, dest)
  230. else:
  231. shutil.copy2(self.fileName, dest)
  232. # can raise FileNotFoundError, Permission Error, shutil.SameFileError, IsADirectoryError
  233. except:
  234. errorBox(f"file copy/move to <{dest}> failed; check permissions")
  235. return
  236. # and copy the database record
  237. f = FileObj(dest, self.db)
  238. f.setDbComment(self.getDbComment())
  239. def moveFile(self, dest):
  240. """ dest is either a FQ filename or a FQ directory, to be expanded with same basename """
  241. self.copyFile(dest, doMove = True)
  242. # >>> snip here <<<
  243. #============= the functions that are called from the main.loop ===============
  244. def file_copy(f,target,target_is_dir,is_copy,force):
  245. print_d(f"call file_copy/move with args={target},{target_is_dir} and {force}")
  246. dest = target if not target_is_dir else os.path.join(target,f.getDisplayName())
  247. if os.path.exists(dest) and not force:
  248. go = input("The copy/move target <<" + dest + ">> exists. Overwrite? (y or n) ")
  249. if go != 'y' and go != 'Y':
  250. return
  251. print_d(f"copy/move from {f} to {dest}")
  252. if is_copy:
  253. f.copyFile(dest)
  254. else:
  255. f.moveFile(dest)
  256. def file_zap(f,all_flag):
  257. db = f.db
  258. print_d(f"zapping the comment history of {f.getName()}")
  259. if all_flag:
  260. confirm = input("You requested a complete flush of the comment database history. Please hit 'Y' to confirm")
  261. if confirm == 'Y':
  262. print_d("zapping the entire database")
  263. db.execute("delete from dirnotes where comment_date < (select max(comment_date) from dirnotes d2 where d2.name = dirnotes.name)")
  264. else:
  265. db.execute("delete from dirnotes where name=? and comment_date < (select max(comment_date) from dirnotes where name=?)",(f.getName(),f.getName()))
  266. db.commit()
  267. def file_modify_comment(f, create, append, erase):
  268. print_d(f"modify the comment on file {f} with extra={(create,append,erase)}")
  269. if not os.path.exists(f.getName()):
  270. print(f"the target file does not exist; please check the spelling of the file: {f}")
  271. sys.exit(10)
  272. if create:
  273. f.setXattrComment(create)
  274. f.setDbComment(create)
  275. elif append:
  276. c = f.getComment(mode)
  277. f.setXattrComment(f"{c}; {append}")
  278. f.setDbComment(f"{c}; {append}")
  279. elif erase:
  280. f.setXattrComment('')
  281. f.setDbComment('')
  282. def file_display(f, listall, json, minimal):
  283. fn = f.getDisplayName()
  284. print_d(f"list file details {fn}")
  285. c,a,d = f.getData(mode)
  286. c1,a1,d1 = f.getOtherData(mode)
  287. diffFlag = '*' if c and (c != c1) else ''
  288. if c or listall:
  289. if not json:
  290. if minimal:
  291. print(f"{c}{diffFlag}")
  292. elif verbose:
  293. print(f"{f.getName()}: {repr(c)}{diffFlag}, {repr(a)}, {repr(d)}")
  294. else:
  295. print(f"{fn}: {repr(c)}{diffFlag}")
  296. else:
  297. entry = {"file":fn, "comment":c}
  298. if verbose:
  299. entry.update({"file":f.getName(),"author":a, "date": d})
  300. if diffFlag:
  301. entry["diffFlag"] = True
  302. answer_json.append(entry)
  303. def file_history(f,json):
  304. db = f.db
  305. c = db.execute("select comment, author, comment_date from dirnotes where name=? order by comment_date desc",(f.getName(),))
  306. if not json:
  307. print(f"file: \t\t{f.getName()}\n")
  308. else:
  309. answer_json.append ( {"file":f.getName()} )
  310. for a in c.fetchall():
  311. if not json:
  312. print(f"comment: \t{a[0]}\nauthor: \t{a[1]}\t\tdate: \t\t{a[2]}\n")
  313. else:
  314. answer_json.append( {"comment":a[0],"author":a[1],"date":a[2]} )
  315. def main(args):
  316. parser = argparse.ArgumentParser(description="Display or add comments to files",
  317. epilog="Some options conflict. Use only one of: -l -c -a -H -e -z -Z and one of -d -x")
  318. parser.add_argument('-V',"--version", action="version", version=f"dncli ver:{VERSION}")
  319. parser.add_argument('-v',"--verbose", action='count', help="verbose output (include comment author & date)",default=0)
  320. parser.add_argument('-D',"--debug", action='store_true',help="include debugging output; do not use in scripts",default=0)
  321. parser.add_argument('-j',"--json", action="store_true",help="output in JSON format")
  322. pars_m = parser.add_mutually_exclusive_group()
  323. pars_m.add_argument('-l',"--listall", action="store_true",help="list all files, including those without comments")
  324. parser.add_argument('-d',"--db", action="store_true",help="list comments from database")
  325. parser.add_argument('-x',"--xattr", action="store_true",help="list comments from xattr")
  326. parser.add_argument('-n',"--minimal", action="store_true",help="output only comments; useful in scripting")
  327. parser.add_argument('-H',"--history", action="store_true",help="output the history of database comments for a file")
  328. pars_m.add_argument('-c',"--create", metavar="comment", help="add a comment to a file")
  329. pars_m.add_argument('-a',"--append", metavar="comment", help="append to a comment on a file, separator=';'")
  330. pars_m.add_argument('-C',"--copy", action="store_true",help="copy a file with its comments")
  331. pars_m.add_argument('-M',"--move", action="store_true",help="move a file with its comments")
  332. parser.add_argument('-y',"--cp_force",action="store_true",help="copy over existing files")
  333. pars_m.add_argument('-e',"--erase", action="store_true",help="erase the comment on a file")
  334. pars_m.add_argument('-z',"--zap", action="store_true",help="clear the database comment history on a file")
  335. pars_m.add_argument('-Z',"--zapall", action="store_true",help="clear the comment history in the entire database")
  336. parser.add_argument( "--config", dest="config_file", help="use config file (default ~/.config/dirnotes/dirnotes.conf)")
  337. parser.add_argument('file_list',nargs='*',help="file(s); list commands may omit this")
  338. args = parser.parse_args()
  339. # default is to display all files that have comments
  340. # major modes are: display (<none> -l -H), add-comment (-a -c -e), clear-history(-z -Z), copy (-C)
  341. # determine the major mode, then apply an appropriate function over the file_list
  342. args.display = not (args.create or args.append or args.copy or args.erase or args.zap or args.zapall)
  343. if args.cp_force and not args.copy:
  344. print("the -y/--cp_force options can only be used with the -C/--copy command")
  345. sys.exit(3)
  346. if args.json and not args.display:
  347. print("the -j/--json option can only be used with the display modes")
  348. sys.exit(4)
  349. if args.minimal and not args.display:
  350. print("the -n/--minimal option only applies to the display modes")
  351. sys.exit(5)
  352. if args.history and not args.display:
  353. print("the -H/--history option only applies to the display modes")
  354. sys.exit(5)
  355. if args.xattr and (args.zap or args.zapall):
  356. print("the -x/--xattr option doesn't apply to the -z/--zap and -Z/--zapall commands")
  357. sys.exit(7)
  358. global verbose, debug
  359. verbose = args.verbose
  360. debug = args.debug
  361. config = ConfigLoader(args.config_file or DEFAULT_CONFIG_FILE)
  362. global mode
  363. mode = config.mode
  364. mode = "xattr" if args.xattr else ("db" if args.db else mode)
  365. db = DnDataBase(config.dbName).db
  366. #====== 1) build the file list =============
  367. files = args.file_list
  368. # for the list commands, auto-fill the file list with the current directory
  369. if not files and args.display:
  370. files = os.listdir(".")
  371. files.sort()
  372. # other command require explicity file lists
  373. if not files:
  374. print("please specify a file or files to use")
  375. sys.exit(10)
  376. print_d("got the files:", files)
  377. #======= 2) build the function
  378. if args.create or args.append or args.erase:
  379. print_d(f"create/append/erase: {args.create} . {args.append} . {args.erase}")
  380. func = file_modify_comment
  381. loop_args = (args.create, args.append, args.erase)
  382. elif args.zap or args.zapall:
  383. print_d(f"got a zap command {args.zap} . {args.zapall}")
  384. func = file_zap
  385. loop_args = (args.zapall,)
  386. if args.zapall:
  387. files = ('.',)
  388. elif args.copy or args.move:
  389. print_d(f"got a copy/move command to copy={args.copy}, move={args.move}")
  390. # the last item on the file list is the target
  391. n_files = len(files)
  392. if n_files < 2:
  393. print("the copy/move command requires at least two arguments, the last one is the destination")
  394. sys.exit(1)
  395. files, target = files[:-1], files[-1]
  396. target_is_directory = os.path.isdir(target)
  397. print_d(f"copy/move from {files} to {target}")
  398. if n_files > 2 and not target_is_directory:
  399. print("multiple copy/move files must go to a target directory")
  400. sys.exit(3)
  401. func = file_copy
  402. loop_args = (target, target_is_directory, args.copy, args.cp_force)
  403. elif args.history:
  404. func = file_history
  405. loop_args = (args.json,)
  406. else:
  407. assert args.display
  408. print_d(f"list files using {mode} priority")
  409. print_d(f"display command with option: {args.listall} and {args.history} and {args.json} and {args.minimal}")
  410. loop_args = (args.listall, args.json, args.minimal)
  411. func = file_display
  412. #====== 3) loop on the list, execute the function =============
  413. for f in files:
  414. func(FileObj(f,db),*loop_args)
  415. if answer_json:
  416. print(json.dumps(answer_json))
  417. if __name__ == "__main__":
  418. main(sys.argv)