forked from Github/filetags
symlink originals get tagged as well, if basename matches
This commit is contained in:
parent
31c8de788f
commit
b6992f07d8
3 changed files with 244 additions and 74 deletions
73
README.org
73
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 <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:
|
||||
|
|
|
|||
104
filetags.py
104
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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue