diff --git a/README.org b/README.org index 683ed14..47bb5cc 100644 --- a/README.org +++ b/README.org @@ -1,4 +1,4 @@ -## Time-stamp: <2017-04-09 11:13:03 vk> +## Time-stamp: <2017-08-27 23:59:18 vk> ## -*- mode: org; coding: utf-8 -*- ## This file is best viewed with GNU Emacs Org-mode: http://orgmode.org/ @@ -92,7 +92,7 @@ Verbose description: http://Karl-Voit.at/managing-digital-photographs/ :license: GPL v3 or any later version :URL: https://github.com/novoid/filetag :bugreports: via github or -:version: 2017-04-09 +:version: 2017-08-27 Options: @@ -128,71 +128,6 @@ Options: --version display version and exit #+END_src -#+BEGIN_src -Usage: - ./filetags.py [] - -This tool adds or removes simple tags to/from file names. - -Tags within file names are placed between the actual file name and -the file extension, separated with " -- ". Multiple tags are -separated with " ": - Update for the Boss -- projectA presentation.pptx - 2013-05-16T15.31.42 Error message -- screenshot projectB.png - -This easy to use tag system has a drawback: for tagging a larger -set of files with the same tag, you have to rename each file -separately. With this tool, this only requires one step. - -Example usages: - ./filetags.py --tags="presentation projectA" *.pptx - ... adds the tags "presentation" and "projectA" to all PPTX-files - ./filetags.py -i * - ... ask for tag(s) and add them to all files in current folder - ./filetags.py -r draft *report* - ... removes the tag "draft" from all files containing the word "report" - - -This tools is looking for (the first) text file named ".filetags" in -current and parent directories. Each line of it is interpreted as a tag -for tag completion. - -Verbose description: http://Karl-Voit.at/managing-digital-photographs/ - -:copyright: (c) by Karl Voit -:license: GPL v3 or any later version -:URL: https://github.com/novoid/filetag -:bugreports: via github or -:version: 2016-08-21 - - -Options: - -h, --help show this help message and exit - -t TAGS, --tag=TAGS, --tags=TAGS - one or more tags (in quotes, separated by spaces) to - add/remove - -r, -d, --remove, --delete - remove tags from (instead of adding to) file name(s) - -i, --interactive interactive mode: ask for (a)dding or (r)emoving and - name of tag(s) - -s, --dryrun enable dryrun mode: just simulate what would happen, - do not modify files - --ln, --list-tags-by-number - list all file-tags sorted by their number of use - --la, --list-tags-by-alphabet - list all file-tags sorted by their name - --lu, --list-tags-unknown-to-vocabulary - list all file-tags which are found in file names but - are not part of .filetags - --tag-gardening This is for getting an overview on tags that might - require to be renamed (typos, singular/plural, ...). - See also http://www.webology.org/2008/v5n3/a58.html - -v, --verbose enable verbose mode - -q, --quiet enable quiet mode - --version display version and exit -#+END_src - - *** Examples: : filetags.py --tag foo a_file_name.txt @@ -253,6 +188,10 @@ tags that are most likely typos or abandoned tags one by one by simply re-executing the previous command line: the file name changes in between because of the previous tag(s) being added. +- 2017-08-27: when tagging symbolic links whose source file has a + matching file name, the source file gets the same tags as the + symbolic link of it + - This is especially useful when using the =--filter= option ** Get the most out of filetags: controlled vocabulary ~.filetags~ :PROPERTIES: diff --git a/filetags.py b/filetags.py index 760dc51..aa051a6 100755 --- a/filetags.py +++ b/filetags.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -PROG_VERSION = "Time-stamp: <2017-08-22 12:54:39 vk>" +PROG_VERSION = "Time-stamp: <2017-08-27 23:51:14 vk>" # TODO: # - fix parts marked with «FIXXME» @@ -65,6 +65,7 @@ CONTROLLED_VOCABULARY_FILENAME = ".filetags" HINT_FOR_BEING_IN_VOCABULARY_TEMPLATE = ' *' TAGFILTER_DIRECTORY = os.path.join(os.path.expanduser("~"), ".filetags_tagfilter") DEFAULT_IMAGE_VIEWER_LINUX = 'geeqie' +TAG_SYMLINK_ORIGINALS_WHEN_TAGGING_SYMLINKS = True try: TTY_HEIGHT, TTY_WIDTH = [int(x) for x in os.popen('stty size', 'r').read().split()] @@ -498,6 +499,89 @@ def find_unique_alternative_to_file(filename): return False +def is_nonbroken_symlink_file(filename): + """ + Returns true if the filename is a non-broken symbolic link and not just an ordinary file. False, for any other case like no file at all. + + @param filename: an unicode string containing a file name + @param return: bookean + """ + + if os.path.isfile(filename): + if os.path.islink(filename): + return True + else: + return False + + +def get_link_source_file(filename): + """ + Return a string representing the path to which the symbolic link points. + + @param filename: an unicode string containing a file name + @param return: file path string + """ + + assert(os.path.islink(filename)) + return os.readlink(filename) + + +def is_broken_link(name): + """ + This function determines if the given name points to a file that is a broken link. + It returns False for any other cases such as non existing files and so forth. + + @param name: an unicode string containing a file name + @param return: boolean + """ + + if os.path.isfile(name): + return False + + try: + return not os.path.exists(os.readlink(name)) + except FileNotFoundError: + return False + + +def handle_file_and_symlink_source_if_found(filename, tags, do_remove, do_filter, dryrun): + """ + @param filename: string containing one file name + @param tags: list containing one or more tags + @param do_remove: boolean which defines if tags should be added (False) or removed (True) + @param dryrun: boolean which defines if files should be changed (False) or not (True) + @param return: error value or new filename + """ + + # if filename is a symbolic link and has same basename, tag the source file as well: + if TAG_SYMLINK_ORIGINALS_WHEN_TAGGING_SYMLINKS and is_nonbroken_symlink_file(filename): + old_sourcefilename = get_link_source_file(filename) + + if os.path.basename(old_sourcefilename) == os.path.basename(filename): + + new_sourcefilename = handle_file(old_sourcefilename, tags, do_remove, do_filter, dryrun) + if old_sourcefilename != new_sourcefilename: + logging.info('Tagging the symlink-destination file of "' + filename + '" ("' + + old_sourcefilename + '") as well …') + if options.dryrun: + logging.debug('I would re-link the old sourcefilename "' + old_sourcefilename + + '" to the new one "' + new_sourcefilename + '"') + else: + logging.debug('re-linking symlink "' + filename + '" from the old sourcefilename "' + + old_sourcefilename + '" to the new one "' + new_sourcefilename + '"') + os.remove(filename) + os.symlink(new_sourcefilename, filename) + else: + logging.debug('The old sourcefilename "' + old_sourcefilename + '" did not change. So therefore I don\'t re-link.') + else: + logging.debug('The file "' + os.path.basename(filename) + '" is a symlink to "' + old_sourcefilename + + '" but they two do have different basenames. Therefore I ignore the original file.') + + # after handling potential symlink originals, I now handle the file we were talking about in the first place: + return handle_file(filename, tags, do_remove, do_filter, dryrun) + + + def handle_file(filename, tags, do_remove, do_filter, dryrun): """ @param filename: string containing one file name @@ -541,18 +625,22 @@ def handle_file(filename, tags, do_remove, do_filter, dryrun): else: # add or remove tags: new_filename = filename + logging.debug('handle_file: set new_filename [' + new_filename + '] according to parameters (initialization)') for tagname in tags: if do_remove: new_filename = removing_tag_from_filename(new_filename, tagname) + logging.debug('handle_file: set new_filename [' + new_filename + '] when do_remove') elif tagname[0] == '-': new_filename = removing_tag_from_filename(new_filename, tagname[1:]) + logging.debug('handle_file: set new_filename [' + new_filename + '] when tag starts with a minus') else: # FIXXME: not performance optimized for large number of unique tags in many lists: tag_in_unique_tags, matching_unique_tag_list = item_contained_in_list_of_lists(tagname, unique_tags) if tagname != tag_in_unique_tags: new_filename = adding_tag_to_filename(new_filename, tagname) + logging.debug('handle_file: set new_filename [' + new_filename + '] when tagname != tag_in_unique_tags') else: # if tag within unique_tags found, and new unique tag is given, remove old tag: # e.g.: unique_tags = (u'yes', u'no') -> if 'no' should be added, remove existing tag 'yes' (and vice versa) @@ -564,7 +652,9 @@ def handle_file(filename, tags, do_remove, do_filter, dryrun): logging.debug("found unique tag %s which require old unique tag(s) to be removed: %s" % (tagname, repr(conflicting_tags))) for conflicting_tag in conflicting_tags: new_filename = removing_tag_from_filename(new_filename, conflicting_tag) + logging.debug('handle_file: set new_filename [' + new_filename + '] when conflicting_tag in conflicting_tags') new_filename = adding_tag_to_filename(new_filename, tagname) + logging.debug('handle_file: set new_filename [' + new_filename + '] after adding_tag_to_filename()') if do_remove: transition = 'delete' @@ -1222,7 +1312,8 @@ def main(): tags_from_userinput = [] vocabulary = sorted(locate_and_parse_controlled_vocabulary(False)) - if len(args) < 1 and not (options.tagfilter or options.list_tags_by_alphabet or options.list_tags_by_number or options.list_unknown_tags or options.tag_gardening): + if len(args) < 1 and not (options.tagfilter or options.list_tags_by_alphabet or + options.list_tags_by_number or options.list_unknown_tags or options.tag_gardening): error_exit(5, "Please add at least one file name as argument") if options.list_tags_by_alphabet or options.list_tags_by_number or options.list_unknown_tags: @@ -1346,7 +1437,14 @@ def main(): logging.debug('determined maximum file name length with %i' % max_file_length) for filename in files: - handle_file(filename, tags_from_userinput, options.remove, options.tagfilter, options.dryrun) + + if is_broken_link(filename): + # skip broken links completely and write error message: + logging.error('File "' + filename + '" is a broken symbolic link. Skipping this one …') + + else: + # if filename is a symbolic link, tag the source file as well: + handle_file_and_symlink_source_if_found(filename, tags_from_userinput, options.remove, options.tagfilter, options.dryrun) if options.tagfilter: save_import('subprocess') diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 1fdde46..c211f14 100755 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Time-stamp: <2017-08-27 17:12:59 vk> +# Time-stamp: <2017-08-27 23:54:13 vk> # invoke tests using following command line: # ~/src/vktag % PYTHONPATH="~/src/filetags:" tests/unit_tests.py --verbose @@ -10,8 +10,17 @@ import os import filetags import tempfile import os.path +import logging from shutil import rmtree +# TEMPLATE for debugging: +# try: +# except AssertionError: +# import pdb; pdb.set_trace() + +FORMAT = "%(levelname)-8s %(asctime)-15s %(message)s" +logging.basicConfig(level=logging.DEBUG, format=FORMAT) + class TestMethods(unittest.TestCase): @@ -158,9 +167,12 @@ class TestFileWithoutTags(unittest.TestCase): # double-check set-up: self.assertTrue(self.file_exists(self.testfilename)) + os.sync() + def create_tmp_file(self, name): - open(os.path.join(self.tempdir, name), 'w') + with open(os.path.join(self.tempdir, name), 'w') as outputhandle: + outputhandle.write('This is a test file for filetags unit testing') def file_exists(self, name): @@ -227,6 +239,7 @@ class TestFileWithoutTags(unittest.TestCase): filetags.handle_file(os.path.join(self.tempdir, filename), ['foo'], do_remove=False, do_filter=False, dryrun=False) self.assertEqual(self.file_exists(filename + ' -- foo'), True) + # there is no such function: filetags.list_tags_by_number() def NOtest_list_tags_by_number(self): # starting with no file with tags: @@ -249,6 +262,7 @@ class TestFileWithoutTags(unittest.TestCase): self.assertEqual(filetags.list_tags_by_number(max_tag_count=1), {'bar': 1}) self.assertEqual(filetags.list_tags_by_number(max_tag_count=0), {'bar': 1, 'foo': 2}) + # there is no such function: filetags.list_tags_by_aphabet() def NOtest_list_tags_by_alphabet(self): # starting with no file with tags: @@ -299,9 +313,12 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): self.create_tmp_file("foo2 -- bar baz.txt") self.create_tmp_file("foo3 -- bar baz teststring1.txt") + os.sync() + def create_tmp_file(self, name): - open(os.path.join(self.tempdir, name), 'w') + with open(os.path.join(self.tempdir, name), 'w') as outputhandle: + outputhandle.write('This is a test file for filetags unit testing') def file_exists(self, name): @@ -329,11 +346,127 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): print("FIXXME: test_locate_and_parse_controlled_vocabulary() not implemented yet") - def tearDown(self): rmtree(self.tempdir) +class TestReplacingSymlinkSourceAndTarget(unittest.TestCase): + + tempdir = None + + SOURCEFILE1 = 'source file 1 - same tags -- bar.txt' + LINKFILE1 = 'symlink file 1 - same tags -- bar.txt' + + SOURCEFILE2 = 'source file 2 - source has tag and symlink not -- baz.txt' + LINKFILE2 = 'symlink file 2 - source has tag and symlink not.txt' + + SOURCEFILE3 = 'source file 3 - source no tags and symlink has.txt' + LINKFILE3 = 'symlink file 3 - source no tags and symlink has -- baz.txt' + + TESTFILE1 = 'symlink and source same name.txt' + + def setUp(self): + + # create temporary directory for the source files: + self.sourcedir = tempfile.mkdtemp(prefix='sources') + self.symlinkdir = tempfile.mkdtemp(prefix='symlinks') + + os.chdir(self.sourcedir) + logging.info("\nTestReplacingSymlinkSourceAndTarget: sourcedir: " + self.sourcedir + " and symlinkdir: " + self.symlinkdir) + + # create set of test files: + self.create_source_file(self.SOURCEFILE1) + self.create_source_file(self.SOURCEFILE2) + self.create_source_file(self.SOURCEFILE3) + self.create_source_file(self.SOURCEFILE3) + self.create_source_file(self.TESTFILE1) + + # create symbolic links: + self.create_symlink_file(self.SOURCEFILE1, self.LINKFILE1) + self.create_symlink_file(self.SOURCEFILE2, self.LINKFILE2) + self.create_symlink_file(self.SOURCEFILE3, self.LINKFILE3) + self.create_symlink_file(self.TESTFILE1, self.TESTFILE1) + + os.sync() + + def create_source_file(self, name): + + with open(os.path.join(self.sourcedir, name), 'w') as outputhandle: + outputhandle.write('This is a test file for filetags unit testing') + + def create_symlink_file(self, source, destination): + + os.symlink(os.path.join(self.sourcedir, source), os.path.join(self.symlinkdir, destination)) + + def source_file_exists(self, name): + + return os.path.isfile(os.path.join(self.sourcedir, name)) + + def symlink_file_exists(self, name): + + return os.path.isfile(os.path.join(self.symlinkdir, name)) + + def is_broken_link(self, name): + + # This function determines if the given name points to a file + # that is a broken link. It returns False for any other cases + # such as non existing files and so forth. + + if self.symlink_file_exists(name): + return False + + try: + return not os.path.exists(os.readlink(os.path.join(self.symlinkdir, name))) + except FileNotFoundError: + return False + + def tearDown(self): + + rmtree(self.sourcedir) + rmtree(self.symlinkdir) + + def test_adding_tags_to_symlinks(self): + + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, self.LINKFILE1), ['foo'], do_remove=False, do_filter=False, dryrun=False) + logging.info('only the symlink gets this tag because basenames differ:') + self.assertEqual(self.symlink_file_exists('symlink file 1 - same tags -- bar foo.txt'), True) + self.assertEqual(self.source_file_exists(self.SOURCEFILE1), True) + + logging.info('basenames are same, so both files should get the tag:') + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, self.TESTFILE1), ['foo'], do_remove=False, do_filter=False, dryrun=False) + self.assertEqual(self.symlink_file_exists('symlink and source same name -- foo.txt'), True) + self.assertEqual(self.source_file_exists('symlink and source same name -- foo.txt'), True) + + def test_adding_tag_to_an_original_file_causing_broken_symlink(self): + + self.assertFalse(self.is_broken_link(self.TESTFILE1)) + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.sourcedir, self.TESTFILE1), ['foo'], do_remove=False, do_filter=False, dryrun=False) + self.assertEqual(self.source_file_exists('symlink and source same name -- foo.txt'), True) + self.assertTrue(self.is_broken_link(self.TESTFILE1)) + + def test_removing_tags(self): + + logging.info('removing a non existing tag should not change anything at all:') + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, self.TESTFILE1), ['foo'], do_remove=True, do_filter=False, dryrun=False) + self.assertEqual(self.source_file_exists(self.TESTFILE1), True) + self.assertEqual(self.symlink_file_exists(self.TESTFILE1), True) + + logging.info('adding tags just for the next tests:') + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, self.TESTFILE1), ['foo', 'bar'], do_remove=False, do_filter=False, dryrun=False) + + self.assertEqual(self.symlink_file_exists('symlink and source same name -- foo bar.txt'), True) + self.assertEqual(self.source_file_exists('symlink and source same name -- foo bar.txt'), True) + + logging.info('removing tags which only exists partially:') + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, 'symlink and source same name -- foo bar.txt'), ['baz', 'bar'], do_remove=True, do_filter=False, dryrun=False) + self.assertEqual(self.symlink_file_exists('symlink and source same name -- foo.txt'), True) + self.assertEqual(self.source_file_exists('symlink and source same name -- foo.txt'), True) + + logging.info('removing tags using minus-notation like "-foo"') + filetags.handle_file_and_symlink_source_if_found(os.path.join(self.symlinkdir, 'symlink and source same name -- foo.txt'), ['-foo', 'bar'], do_remove=False, do_filter=False, dryrun=False) + self.assertEqual(self.symlink_file_exists('symlink and source same name -- bar.txt'), True) + self.assertEqual(self.source_file_exists('symlink and source same name -- bar.txt'), True) + if __name__ == '__main__': unittest.main()