diff --git a/README.md b/README.md
index 53ff0d8..268179c 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,7 @@ $ otag write id3 song.mp3
| `id3` | `.mp3` `.wav` `.flac` `.ogg` | The comment tag |
| `pdf` | `.pdf` | The keywords field |
| `office` | `.docx` `.xlsx` `.pptx` `.odt` `.ods` `.odp`, reading also `.doc` `.xls` `.ppt` | The description field |
+| `ebook` | `.epub` `.fb2` `.cbz` | One otag line where other apps don't look: a `` in the EPUB package file, a `` in FB2, the archive comment of CBZ (never overwriting another tool's comment); the book's own metadata is left untouched |
| `frontmatter` | `.md` `.markdown` `.txt` | `projects`, `contexts`, `tags` and `note` keys of the YAML front matter; other keys, comments and formatting are left untouched |
| `comment` | Source code such as `.py` `.sh` `.toml` `.js` `.c` `.go` `.rs` `.php` `.html` | An `otag: +project @context #tag note` comment line within the first ten lines, written in the file's comment style below any shebang, encoding, `` or ` ArgumentParser:
no_recursive(sub)
sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write)
- sub.add_argument('plugin', help='comment, frontmatter, id3, office or pdf')
+ sub.add_argument('plugin', help='comment, ebook, frontmatter, id3, office or pdf')
sub.add_argument('file')
sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export)
diff --git a/orvanitag/orvani.py b/orvanitag/orvani.py
index abfa896..e21674a 100644
--- a/orvanitag/orvani.py
+++ b/orvanitag/orvani.py
@@ -18,6 +18,7 @@ SEPARATOR = ' --- ' # between file name and contexts, in context files and ex
DEFAULT_PLUGINS = [
'frontmatter',
'comment',
+ 'ebook',
'id3',
'office',
'pdf'
diff --git a/orvanitag/plugins/_zip.py b/orvanitag/plugins/_zip.py
new file mode 100644
index 0000000..2df51d0
--- /dev/null
+++ b/orvanitag/plugins/_zip.py
@@ -0,0 +1,33 @@
+"""
+Shared by the plugins for zip based formats; not a plugin itself.
+"""
+import os
+import shutil
+from pathlib import Path
+from zipfile import ZipFile
+
+
+def rewrite(path, changes: dict = None, comment: bytes = None):
+ """
+ Rewrites a zip file atomically, with some text members changed and optionally a new archive comment
+
+ changes (dict): member name -> function from old to new text
+ comment (bytes): new archive comment, None keeps the old one
+ """
+ changes = changes or {}
+ path = Path(path)
+ tmp = path.with_name(f'.{path.name}.tmp')
+ try:
+ # Copying every ZipInfo keeps entry order and compression, which ODF and EPUB
+ # require for their leading, uncompressed 'mimetype' entry
+ with ZipFile(path) as source, ZipFile(tmp, 'w') as target:
+ for info in source.infolist():
+ data = source.read(info)
+ if info.filename in changes:
+ data = changes[info.filename](data.decode('utf-8')).encode('utf-8')
+ target.writestr(info, data)
+ target.comment = source.comment if comment is None else comment
+ shutil.copymode(path, tmp)
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
diff --git a/orvanitag/plugins/ebook.py b/orvanitag/plugins/ebook.py
new file mode 100644
index 0000000..e1f9d80
--- /dev/null
+++ b/orvanitag/plugins/ebook.py
@@ -0,0 +1,115 @@
+"""
+Tags in ebooks as one otag line in a place other apps ignore, so the book's own subjects and description stay untouched:
+an OPF `` in EPUB, a `` in FB2 and the archive comment of CBZ.
+"""
+import codecs
+import os
+import re
+import shutil
+from xml.sax.saxutils import escape, unescape
+from zipfile import ZipFile
+from orvanitag.plugins._zip import rewrite
+
+NAME = 'ebook'
+TYPES = ['.epub', '.fb2', '.cbz']
+MARKER = 'otag:' # starts the CBZ archive comment
+QUOTES = {'"': '"'}
+
+# Element prefixes like `opf:` are matched, so the new element can use the same namespace prefix
+ROOTFILE = re.compile(r']*\bfull-path=["\']([^"\']+)["\']')
+META = re.compile(r'<([\w-]+:)?meta\b[^>]*\bname=["\']otag["\'][^>]*>(?:\s*(?:[\w-]+:)?meta>)?')
+CONTENT = re.compile(r'\bcontent=(["\'])(.*?)\1', re.S)
+METADATA_END = re.compile(r'([\w-]+:)?metadata>')
+CUSTOM = re.compile(r'<(?:[\w-]+:)?custom-info\b[^>]*\binfo-type=["\']otag["\'][^>]*>(.*?)(?:[\w-]+:)?custom-info>', re.S)
+# FB2 allows only `output` elements after custom-info
+DESCRIPTION_END = re.compile(r'<([\w-]+:)?output\b|([\w-]+:)?description>')
+ENCODING = re.compile(rb'^<\?xml[^>]*\bencoding=["\']([\w.-]+)["\']')
+
+
+def package(archive: ZipFile) -> str:
+ """The name of the EPUB's OPF package file"""
+ found = ROOTFILE.search(archive.read('META-INF/container.xml').decode('utf-8'))
+ if not found:
+ raise ValueError('EPUB without package file')
+ return unescape(found.group(1))
+
+
+def read_fb2(path):
+ """Returns the text, its encoding and whether it starts with a byte order mark"""
+ data = path.read_bytes()
+ bom = data.startswith(codecs.BOM_UTF8)
+ data = data.removeprefix(codecs.BOM_UTF8)
+ found = ENCODING.match(data)
+ encoding = found.group(1).decode() if found else 'utf-8'
+ return data.decode(encoding), encoding, bom
+
+
+def reader(o):
+ suffix = o.file.suffix.lower()
+ text = ''
+ if suffix == '.epub':
+ with ZipFile(o.file) as archive:
+ opf = archive.read(package(archive)).decode('utf-8')
+ meta = META.search(opf)
+ content = CONTENT.search(meta.group(0)) if meta else None
+ text = unescape(content.group(2), {'"': '"'}) if content else ''
+ elif suffix == '.fb2':
+ found = CUSTOM.search(read_fb2(o.file)[0])
+ text = unescape(found.group(1)) if found else ''
+ elif suffix == '.cbz':
+ with ZipFile(o.file) as archive:
+ comment = archive.comment.decode('utf-8', 'replace')
+ text = comment.removeprefix(MARKER) if comment.startswith(MARKER) else ''
+ if text.strip():
+ return o.context(o.string(text))
+
+
+def epub_meta(opf: str, line: str) -> str:
+ """Replaces the otag meta element, or adds one at the end of the metadata"""
+ found = META.search(opf)
+ start, end = (found.start(), found.end()) if found else (None, None)
+ if not found:
+ found = METADATA_END.search(opf)
+ if not found:
+ raise ValueError('EPUB package file without metadata')
+ start = end = found.start()
+ return opf[:start] + f'<{found.group(1) or ""}meta name="otag" content="{escape(line, QUOTES)}"/>' + opf[end:]
+
+
+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"""
+ found = CUSTOM.search(text)
+ if found:
+ return text[:found.start(1)] + escape(line) + text[found.end(1):]
+ found = DESCRIPTION_END.search(text)
+ if not found:
+ raise ValueError('FB2 file without description')
+ prefix = found.group(1) or found.group(2) or ''
+ return text[:found.start()] + f'<{prefix}custom-info info-type="otag">{escape(line)}{prefix}custom-info>' + text[found.start():]
+
+
+def writer(o):
+ suffix = o.file.suffix.lower()
+ line = ' '.join(o.collect(o.context))
+ if suffix == '.epub':
+ with ZipFile(o.file) as archive:
+ name = package(archive)
+ rewrite(o.file, {name: lambda opf: epub_meta(opf, line)})
+
+ elif suffix == '.fb2':
+ text, encoding, bom = read_fb2(o.file)
+ data = fb2_custom_info(text, line).encode(encoding, 'xmlcharrefreplace')
+ tmp = o.file.with_name(f'.{o.file.name}.tmp')
+ try:
+ tmp.write_bytes((codecs.BOM_UTF8 if bom else b'') + data)
+ shutil.copymode(o.file, tmp)
+ os.replace(tmp, o.file)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+ elif suffix == '.cbz':
+ with ZipFile(o.file) as archive:
+ comment = archive.comment
+ if comment and not comment.startswith(MARKER.encode()):
+ raise ValueError(f'the archive comment of {o.file} is used by another tool')
+ rewrite(o.file, comment=f'{MARKER} {line}'.encode('utf-8'))
diff --git a/orvanitag/plugins/office.py b/orvanitag/plugins/office.py
index d335f37..c06603e 100644
--- a/orvanitag/plugins/office.py
+++ b/orvanitag/plugins/office.py
@@ -1,7 +1,7 @@
-import os
import re
from xml.sax.saxutils import escape, unescape
from zipfile import ZipFile
+from orvanitag.plugins._zip import rewrite
NAME = 'office'
TYPES = ['.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp']
@@ -46,19 +46,5 @@ def writer(o):
if suffix not in META_FILES:
raise NotImplementedError(f'writing metadata to {suffix} files is not supported')
- meta_file = META_FILES[suffix]
string = escape(' '.join(o.collect(o.context)))
- tmp = o.file.with_name(f'.{o.file.name}.tmp')
- try:
- # Copying every ZipInfo keeps entry order and compression, which ODF
- # requires for its leading, uncompressed 'mimetype' entry
- with ZipFile(o.file) as source, ZipFile(tmp, 'w') as target:
- for info in source.infolist():
- data = source.read(info)
- if info.filename == meta_file:
- meta = XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], data.decode('utf-8'))
- data = meta.encode('utf-8')
- target.writestr(info, data)
- os.replace(tmp, o.file)
- finally:
- tmp.unlink(missing_ok=True)
+ rewrite(o.file, {META_FILES[suffix]: lambda meta: XML_REGEX.sub(lambda m: m['pre'] + string + m['post'], meta)})
diff --git a/tests/test_ebook.py b/tests/test_ebook.py
new file mode 100644
index 0000000..f3d8c59
--- /dev/null
+++ b/tests/test_ebook.py
@@ -0,0 +1,119 @@
+import zipfile
+
+import pytest
+
+import orvanitag.orvani as orvani
+from orvanitag.orvani import OrvaniTag
+
+CONTAINER = (
+ ''
+ ''
+)
+OPF = (
+ ''
+ ''
+ 'x'
+ 'BookFiction'
+)
+PREFIXED_OPF = (
+ ''
+ ''
+ 'Book'
+)
+FB2 = (
+ ''
+ ''
+ 'космосАннотация
'
+ 'Текст
'
+)
+
+
+@pytest.fixture
+def cwd(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ return tmp_path
+
+
+def make_epub(path, opf=OPF):
+ with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
+ z.writestr(zipfile.ZipInfo('mimetype'), 'application/epub+zip')
+ z.writestr('META-INF/container.xml', CONTAINER)
+ z.writestr('OEBPS/content.opf', opf)
+
+
+def make_cbz(path, comment=b''):
+ with zipfile.ZipFile(path, 'w') as z:
+ z.writestr('001.png', b'png')
+ z.comment = comment
+
+
+def roundtrip(cwd, name, *tags):
+ OrvaniTag().add(name, *tags)
+ OrvaniTag().write_metadata(name, 'ebook')
+ expected = (cwd / orvani.CONTEXT_FILE).read_text()
+ (cwd / orvani.CONTEXT_FILE).unlink()
+ OrvaniTag().read_metadata(name)
+ assert (cwd / orvani.CONTEXT_FILE).read_text() == expected
+ return expected
+
+
+def opf_of(path):
+ with zipfile.ZipFile(path) as z:
+ return z.read('OEBPS/content.opf').decode()
+
+
+def test_epub_roundtrip_keeps_book_metadata(cwd):
+ make_epub(cwd / 'book.epub')
+ line = roundtrip(cwd, 'book.epub', '+reading', '#scifi', 'R&B', '"quoted"', 'C:\\1')
+ assert line == 'book.epub --- +reading #scifi R&B "quoted" C:\\1\n'
+ opf = opf_of(cwd / 'book.epub')
+ assert 'BookFiction' in opf
+ assert '' in opf
+ with zipfile.ZipFile(cwd / 'book.epub') as z:
+ first = z.infolist()[0]
+ assert first.filename == 'mimetype' and first.compress_type == zipfile.ZIP_STORED
+
+
+def test_epub_second_write_replaces_the_meta(cwd):
+ make_epub(cwd / 'book.epub')
+ o = OrvaniTag()
+ o.add('book.epub', '#one')
+ o.write_metadata('book.epub', 'ebook')
+ o.add('book.epub', '#two')
+ o.write_metadata('book.epub', 'ebook')
+ opf = opf_of(cwd / 'book.epub')
+ assert opf.count('name="otag"') == 1
+ assert 'content="#one #two"' in opf
+
+
+def test_epub_with_prefixed_metadata(cwd):
+ make_epub(cwd / 'book.epub', PREFIXED_OPF)
+ roundtrip(cwd, 'book.epub', '#scifi')
+ assert '' in opf_of(cwd / 'book.epub')
+
+
+@pytest.mark.parametrize('encoding', ['utf-8', 'windows-1251'])
+def test_fb2_roundtrip_keeps_encoding(cwd, encoding):
+ (cwd / 'book.fb2').write_bytes(FB2.format(encoding=encoding).encode(encoding))
+ line = roundtrip(cwd, 'book.fb2', '#фантастика', 'заметка')
+ assert line == 'book.fb2 --- #фантастика заметка\n'
+ text = (cwd / 'book.fb2').read_bytes().decode(encoding)
+ assert 'космосАннотация
' in text
+ assert '#фантастика заметка' in text
+
+
+def test_cbz_roundtrip_and_replacing_own_comment(cwd):
+ make_cbz(cwd / 'comic.cbz', b'otag: #old')
+ roundtrip(cwd, 'comic.cbz', '+series', '#manga')
+ with zipfile.ZipFile(cwd / 'comic.cbz') as z:
+ assert z.comment == b'otag: +series #manga'
+ assert z.read('001.png') == b'png'
+
+
+def test_cbz_foreign_comment_is_kept(cwd):
+ make_cbz(cwd / 'comic.cbz', b'{"ComicBookInfo/1.0": {}}')
+ before = (cwd / 'comic.cbz').read_bytes()
+ OrvaniTag().add('comic.cbz', '#manga')
+ with pytest.raises(ValueError):
+ OrvaniTag().write_metadata('comic.cbz', 'ebook')
+ assert (cwd / 'comic.cbz').read_bytes() == before