From 8a19f5f0af2d327638a29e1d16c64f04e3cc4a5f Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 24 Oct 2026 22:24:39 +0300 Subject: [PATCH] feat: add write --clear to remove otag's tags from files --- README.md | 3 +- orvanitag/cmd.py | 9 ++- orvanitag/orvani.py | 8 ++- orvanitag/plugins/__init__.py | 3 +- orvanitag/plugins/comment.py | 9 ++- orvanitag/plugins/ebook.py | 17 +++-- orvanitag/plugins/frontmatter.py | 43 ++++++++---- orvanitag/plugins/id3.py | 5 +- orvanitag/plugins/office.py | 4 +- orvanitag/plugins/pdf.py | 5 +- tests/test_clear.py | 116 +++++++++++++++++++++++++++++++ 11 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 tests/test_clear.py diff --git a/README.md b/README.md index 34e0e72..023704d 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,12 @@ $ otag purge old-project/ `scan` (`s`) reads tags that are embedded in files and adds them to their entries, in the given folder (default: current) and its subdirectories. -`write` (`w`) writes the entry of a file into the file itself, using the named plugin. +`write` (`w`) writes the entry of a file into the file itself, using the named plugin. It replaces what the plugin stored before, so tags removed with `otag remove` disappear from the file on the next `write`. `-c` (`--clear`) removes the plugin's tags from the file instead, e.g. after all tags were removed; it is never done implicitly, because a file without entry may also be one otag never tagged. ``` $ otag scan music/ $ otag write id3 song.mp3 +$ otag write --clear id3 song.mp3 ``` | Plugin | File types | Where the tags are stored | diff --git a/orvanitag/cmd.py b/orvanitag/cmd.py index 96f4eba..ec6bb4a 100644 --- a/orvanitag/cmd.py +++ b/orvanitag/cmd.py @@ -153,7 +153,13 @@ def run_purge(orvani, args, console): def run_write(orvani, args, console): - entry = orvani.write_metadata(args.file, args.plugin) + try: + entry = orvani.write_metadata(args.file, args.plugin, clear=args.clear) + except NoEntry as error: + raise NoEntry(f'{error} Use --clear to remove its embedded tags.') from error + if args.clear: + console.print(f':wastebasket: Embedded tags successfully removed from file [strong]{escape(args.file)}[/strong].') + return console.print(f':floppy_disk: Metadata {nice_context(entry)}successfully written to file [strong]{escape(args.file)}[/strong].') @@ -237,6 +243,7 @@ def parser() -> ArgumentParser: sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write) sub.add_argument('plugin', help='comment, ebook, frontmatter, id3, office or pdf') sub.add_argument('file') + sub.add_argument('-c', '--clear', action='store_true', help="remove the plugin's tags from the file instead") sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export) sub.add_argument('file', nargs='?', default='context.txt') diff --git a/orvanitag/orvani.py b/orvanitag/orvani.py index e160877..12904eb 100644 --- a/orvanitag/orvani.py +++ b/orvanitag/orvani.py @@ -528,12 +528,14 @@ class OrvaniTag: pass - def write_metadata(self, filename: str, method: str) -> Entry: + def write_metadata(self, filename: str, method: str, clear: bool=False) -> Entry: """ Writes a file's entry as metadata to the file using a writer plugin filename (str): A valid file path method (str): The name of a plugin able to process the file type + clear (bool): Remove the plugin's tags from the file instead. Explicit, because a file + without entry could also be one otag never tagged, whose metadata must stay. Returns the written entry """ @@ -547,8 +549,8 @@ class OrvaniTag: raise UnsupportedType(f"Plugin {plugin_name} can only write to file types: {', '.join(suffixes)}.{configured}") if not hasattr(plugin, 'write'): raise PluginError(f"Plugin {plugin_name} can't write.") - entry = self.__load(path.parent).entries.get(path.name) - if not entry: + entry = Entry() if clear else self.__load(path.parent).entries.get(path.name) + if not clear and not entry: raise NoEntry(f"No entry for {filename} found.") plugin.write(path, entry, config) return entry diff --git a/orvanitag/plugins/__init__.py b/orvanitag/plugins/__init__.py index 02bdeb4..35ede35 100644 --- a/orvanitag/plugins/__init__.py +++ b/orvanitag/plugins/__init__.py @@ -6,7 +6,8 @@ TYPES the lowercase file suffixes it handles, e.g. ['.pdf'] read(path: Path, config: dict) -> Entry | None returns the tags found in the file, or None write(path: Path, entry: Entry, config: dict) - stores the entry in the file, optional for read-only plugins + stores the entry in the file, optional for read-only plugins; + an empty entry removes the plugin's tags from the file `config` is the file's folder configuration from `.otag.toml`. Expected problems with a file raise orvanitag.errors.UnsupportedFile. diff --git a/orvanitag/plugins/comment.py b/orvanitag/plugins/comment.py index 13e1c7f..3a80150 100644 --- a/orvanitag/plugins/comment.py +++ b/orvanitag/plugins/comment.py @@ -87,7 +87,14 @@ def write(path, entry, config): line = f'{opening}{MARKER} {entry}{closing}{newline}' num, contexts = find(lines) - if num is not None: + if not entry: + if num is None: + return + del lines[num] + # The blank line a tag line at the very top got when it was added + if num == 0 and lines and not lines[0].strip(): + del lines[0] + elif num is not None: lines[num] = line else: num = header(lines, path.suffix.lower()) diff --git a/orvanitag/plugins/ebook.py b/orvanitag/plugins/ebook.py index 869e5ca..099f5fc 100644 --- a/orvanitag/plugins/ebook.py +++ b/orvanitag/plugins/ebook.py @@ -67,8 +67,10 @@ def read(path, config): def epub_meta(opf: str, line: str) -> str: - """Replaces the otag meta element, or adds one at the end of the metadata""" + """Replaces the otag meta element, or adds one at the end of the metadata; an empty line removes it""" found = META.search(opf) + if not line: + return opf[:found.start()] + opf[found.end():] if found else opf start, end = (found.start(), found.end()) if found else (None, None) if not found: found = METADATA_END.search(opf) @@ -79,8 +81,10 @@ def epub_meta(opf: str, line: str) -> str: def fb2_custom_info(text: str, line: str) -> str: - """Replaces the text of the otag custom-info element, or adds one at the end of the description""" + """Replaces the text of the otag custom-info element, or adds one at the end of the description; an empty line removes it""" found = CUSTOM.search(text) + if not line: + return text[:found.start()] + text[found.end():] if found else text if found: return text[:found.start(1)] + escape(line) + text[found.end(1):] found = DESCRIPTION_END.search(text) @@ -112,6 +116,11 @@ def write(path, entry, config): elif suffix == '.cbz': with ZipFile(path) as archive: comment = archive.comment - if comment and not comment.startswith(MARKER.encode()): + ours = comment.startswith(MARKER.encode()) + if not entry: + if ours: # another tool's comment is not otag's to clear + rewrite(path, comment=b'') + elif comment and not ours: raise UnsupportedFile(f'the archive comment of {path} is used by another tool') - rewrite(path, comment=f'{MARKER} {line}'.encode('utf-8')) + else: + rewrite(path, comment=f'{MARKER} {line}'.encode('utf-8')) diff --git a/orvanitag/plugins/frontmatter.py b/orvanitag/plugins/frontmatter.py index 6edef39..7a58b98 100644 --- a/orvanitag/plugins/frontmatter.py +++ b/orvanitag/plugins/frontmatter.py @@ -36,6 +36,18 @@ def words(value) -> list: return ['-'.join(str(item).split()) for item in items if str(item).strip()] +def span(lines: list, start: int, end: int, key: str): + """Returns the lines a top-level key takes up as (first, stop), or None""" + found = next((num for num in range(start, end) if re.match(rf'{re.escape(key)}\s*:', lines[num])), None) + if found is None: + return None + # The value continues on indented lines or top-level list items + stop = found + 1 + while stop < end and lines[stop].startswith((' ', '\t', '-')): + stop += 1 + return found, stop + + def scalar(value: str) -> str: if PLAIN.fullmatch(value) and value.lower() not in SPECIAL: return value @@ -66,30 +78,31 @@ def write(path, entry, config): lines = text.splitlines(keepends=True) newline = '\r\n' if '\r\n' in text else '\n' - # Empty categories leave existing keys alone + # Empty categories leave existing keys alone; an empty entry removes all of otag's keys entries = {name: f'{name}: [{", ".join(scalar(tag) for tag in getattr(entry, name))}]{newline}' for name in LISTS if getattr(entry, name)} if entry.note: entries['note'] = f'note: {scalar(entry.note)}{newline}' if not entries: - return + entries = {name: None for name in [*LISTS, 'note']} - span = block(lines) - if not span: + found = block(lines) + if not found: + if not entry: + return lines[0:0] = [DELIMITER + newline, *entries.values(), DELIMITER + newline] else: - start, end = span + start, end = found for key, line in entries.items(): - found = next((num for num in range(start, end) if re.match(rf'{re.escape(key)}\s*:', lines[num])), None) - if found is None: - lines.insert(end, line) - end += 1 + taken = span(lines, start, end, key) + if taken is None: + if line: + lines.insert(end, line) + end += 1 else: - # The value continues on indented lines or top-level list items - stop = found + 1 - while stop < end and lines[stop].startswith((' ', '\t', '-')): - stop += 1 - lines[found:stop] = [line] - end -= stop - found - 1 + lines[taken[0]:taken[1]] = [line] if line else [] + end += (1 if line else 0) - (taken[1] - taken[0]) + if end == start: # nothing left between the delimiters + del lines[:end + 1] tmp = path.with_name(f'.{path.name}.tmp') try: diff --git a/orvanitag/plugins/id3.py b/orvanitag/plugins/id3.py index fe2277b..811b3f6 100644 --- a/orvanitag/plugins/id3.py +++ b/orvanitag/plugins/id3.py @@ -24,4 +24,7 @@ def read(path, config): def write(path, entry, config): with taglib.File(path, save_on_exit=True) as song: - song.tags['COMMENT'] = [str(entry)] + if entry: + song.tags['COMMENT'] = [str(entry)] + else: + song.tags.pop('COMMENT', None) diff --git a/orvanitag/plugins/office.py b/orvanitag/plugins/office.py index 389dcad..b6a556a 100644 --- a/orvanitag/plugins/office.py +++ b/orvanitag/plugins/office.py @@ -55,8 +55,10 @@ def write(path, entry, config): def describe(meta: str, string: str) -> str: - """Sets the text of the description element, adding the element if there is none""" + """Sets the text of the description element, adding the element if there is none; an empty text removes it""" found = DESCRIPTION.search(meta) + if not string: + return meta[:found.start()] + meta[found.end():] if found else meta if found and found.group('body') is not None: return meta[:found.start('body')] + string + meta[found.end('body'):] diff --git a/orvanitag/plugins/pdf.py b/orvanitag/plugins/pdf.py index dd3415a..1443753 100644 --- a/orvanitag/plugins/pdf.py +++ b/orvanitag/plugins/pdf.py @@ -18,7 +18,10 @@ def write(path, entry, config): try: with open(path, 'rb') as source: pdf = PdfWriter(clone_from=PdfReader(source)) - pdf.add_metadata({'/Keywords': string}) + if entry: + pdf.add_metadata({'/Keywords': string}) + else: + pdf.metadata = {key: value for key, value in (pdf.metadata or {}).items() if key != '/Keywords'} pdf.write(tmp) os.replace(tmp, path) finally: diff --git a/tests/test_clear.py b/tests/test_clear.py new file mode 100644 index 0000000..31f96f4 --- /dev/null +++ b/tests/test_clear.py @@ -0,0 +1,116 @@ +import sys +import wave +import zipfile + +import pytest +from pypdf import PdfReader, PdfWriter + +import orvanitag.orvani as orvani +from orvanitag.cmd import main +from orvanitag.orvani import OrvaniTag, NoEntry + +from test_ebook import FB2, OPF, make_cbz, make_epub, opf_of +from test_plugins import DOCX_CORE, make_docx + + +@pytest.fixture +def cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def tag_and_clear(name, plugin, *tags): + OrvaniTag().add(name, *(tags or ['#lofi'])) + OrvaniTag().write_metadata(name, plugin) + OrvaniTag().write_metadata(name, plugin, clear=True) + + +def test_without_clear_an_untagged_file_is_refused(cwd): + (cwd / 'a.py').write_text('print(1)\n') + with pytest.raises(NoEntry): + OrvaniTag().write_metadata('a.py', 'comment') + + +def test_clear_pdf_keeps_other_metadata(cwd): + writer = PdfWriter() + writer.add_blank_page(72, 72) + writer.add_metadata({'/Title': 'T'}) + writer.write(cwd / 'a.pdf') + tag_and_clear('a.pdf', 'pdf') + meta = PdfReader(cwd / 'a.pdf').metadata + assert '/Keywords' not in meta and meta['/Title'] == 'T' + + +def test_clear_docx_removes_description(cwd): + make_docx(cwd / 'a.docx') + tag_and_clear('a.docx', 'office') + with zipfile.ZipFile(cwd / 'a.docx') as z: + assert z.read('docProps/core.xml').decode() == DOCX_CORE.replace('', '') + + +def test_clear_id3_comment(cwd): + taglib = pytest.importorskip('taglib') + with wave.open(str(cwd / 'a.wav'), 'wb') as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(8000) + audio.writeframes(b'\0\0' * 800) + tag_and_clear('a.wav', 'id3') + with taglib.File(cwd / 'a.wav') as song: + assert 'COMMENT' not in song.tags + + +@pytest.mark.parametrize('name, source', [ + ('m.go', 'package main\n'), + ('s.sh', '#!/bin/sh\n\necho\n'), +]) +def test_clear_comment_line_restores_the_file(cwd, name, source): + (cwd / name).write_text(source) + tag_and_clear(name, 'comment') + assert (cwd / name).read_text() == source + + +def test_clear_ebooks(cwd): + make_epub(cwd / 'a.epub') + tag_and_clear('a.epub', 'ebook') + assert opf_of(cwd / 'a.epub') == OPF + + fb2 = FB2.format(encoding='utf-8') + (cwd / 'a.fb2').write_text(fb2) + tag_and_clear('a.fb2', 'ebook') + assert (cwd / 'a.fb2').read_text() == fb2 + + make_cbz(cwd / 'a.cbz') + tag_and_clear('a.cbz', 'ebook') + with zipfile.ZipFile(cwd / 'a.cbz') as z: + assert z.comment == b'' + + +def test_clear_keeps_a_foreign_cbz_comment(cwd): + make_cbz(cwd / 'a.cbz', b'{"ComicBookInfo/1.0": {}}') + OrvaniTag().write_metadata('a.cbz', 'ebook', clear=True) + with zipfile.ZipFile(cwd / 'a.cbz') as z: + assert z.comment == b'{"ComicBookInfo/1.0": {}}' + + +@pytest.mark.parametrize('source, cleared', [ + ('---\ntitle: A\n---\ntext\n', '---\ntitle: A\n---\ntext\n'), + ('text\n', 'text\n'), +]) +def test_clear_frontmatter(cwd, source, cleared): + (cwd / 'a.md').write_text(source) + tag_and_clear('a.md', 'frontmatter', '+p', '#lofi', 'a', 'note') + assert (cwd / 'a.md').read_text() == cleared + + +def test_cmd_clear_and_hint(cwd, monkeypatch, capsys): + (cwd / 'a.py').write_text('# otag: #old\nprint(1)\n') + monkeypatch.setattr(sys, 'argv', ['otag', 'w', 'comment', 'a.py']) + with pytest.raises(SystemExit): + main() + assert '--clear' in capsys.readouterr().out + + monkeypatch.setattr(sys, 'argv', ['otag', 'w', '--clear', 'comment', 'a.py']) + main() + assert (cwd / 'a.py').read_text() == 'print(1)\n' + assert not (cwd / orvani.CONTEXT_FILE).exists()