symlink originals get tagged as well, if basename matches

This commit is contained in:
Karl Voit 2017-08-27 23:54:59 +02:00
parent 31c8de788f
commit b6992f07d8
3 changed files with 244 additions and 74 deletions

View file

@ -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 <tools@Karl-Voit.at>
: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 [<options>] <list of files>
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 <tools@Karl-Voit.at>
:license: GPL v3 or any later version
:URL: https://github.com/novoid/filetag
:bugreports: via github or <tools@Karl-Voit.at>
: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:

View file

@ -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')

View file

@ -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()