diff --git a/README.md b/README.md index 937bfba..4400a71 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ So a as an example an audio-file could be assigned to project `+album` and have $ uv tool install 'orvanitag[all]' ``` -Requires Python 3.11 or newer. The core only needs rich. Reading and writing embedded metadata uses optional extras: `audio` (pytaglib, needs the TagLib C library), `pdf` (pypdf), `office` (olefile, only for legacy `.doc`/`.xls`/`.ppt`) and `markdown` (python-frontmatter). Plugins whose extra is not installed are skipped. +Requires Python 3.11 or newer. The core only needs rich. Reading and writing embedded metadata uses optional extras: `audio` (pytaglib, needs the TagLib C library), `pdf` (pypdf), `office` (olefile, only for legacy `.doc`/`.xls`/`.ppt`) and `markdown` (PyYAML). Plugins whose extra is not installed are skipped. ## Command Line Tool @@ -114,7 +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 | -| `frontmatter` | `.md` `.txt` `.markdown` `.rst` | `projects`, `contexts`, `tags` and `note` keys of the YAML front matter | +| `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 ` list: + """Tags from a YAML list or a comma or space separated string; spaces inside a tag become dashes""" + if isinstance(value, list): + items = value + else: + items = str(value).split(',') if ',' in str(value) else str(value).split() + return ['-'.join(str(item).split()) for item in items if str(item).strip()] + + +def scalar(value: str) -> str: + if PLAIN.fullmatch(value) and value.lower() not in SPECIAL: + return value + return json.dumps(value, ensure_ascii=False) # a JSON string is a valid YAML double-quoted scalar + def reader(o): - with open(o.file, 'r') as data: - y = frontmatter.load(data) - if y.metadata: - context = y.metadata.copy() - for meta in y.metadata.keys(): - if not meta in o.schema: - del context[meta] - return context - + with o.file.open(encoding='utf-8') as data: + lines = data.readlines() + span = block(lines) + if not span: + return None + # BaseLoader keeps every value a string, so `2023` or `yes` stay as written + metadata = yaml.load(''.join(lines[span[0]:span[1]]), Loader=yaml.BaseLoader) + if not isinstance(metadata, dict): + return None + + context = {} + for key, prefix in o.schema.items(): + if metadata.get(key): + context[key] = words(metadata[key]) if prefix else ' '.join(str(metadata[key]).split()) + if any(context.values()): + return context + + def writer(o): - y = frontmatter.load(o.file) - for key in o.context: - y[key] = o.context[key] - with open(o.file, 'w') as data: - data.write(frontmatter.dumps(y)) \ No newline at end of file + with o.file.open(encoding='utf-8', newline='') as data: # keeps \r\n line endings + text = data.read() + lines = text.splitlines(keepends=True) + newline = '\r\n' if '\r\n' in text else '\n' + + entries = {} + for key, prefix in o.schema.items(): + value = o.context.get(key) + if value: # empty categories leave existing keys alone + rendered = f'[{", ".join(scalar(tag) for tag in value)}]' if prefix else scalar(value) + entries[key] = f'{key}: {rendered}{newline}' + if not entries: + return + + span = block(lines) + if not span: + lines[0:0] = [DELIMITER + newline, *entries.values(), DELIMITER + newline] + else: + start, end = span + for key, entry 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, entry) + 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] = [entry] + end -= stop - found - 1 + + tmp = o.file.with_name(f'.{o.file.name}.tmp') + try: + with open(tmp, 'w', encoding='utf-8', newline='') as data: + data.write(''.join(lines)) + shutil.copymode(o.file, tmp) + os.replace(tmp, o.file) + finally: + tmp.unlink(missing_ok=True) diff --git a/pyproject.toml b/pyproject.toml index 6829042..f2caf48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ audio = ["pytaglib>=2.0.0"] pdf = ["pypdf>=4.0"] office = ["olefile>=0.46"] -markdown = ["python-frontmatter>=1.0"] +markdown = ["PyYAML>=6.0"] all = ["orvanitag[audio,pdf,office,markdown]"] [project.urls] diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py new file mode 100644 index 0000000..c197972 --- /dev/null +++ b/tests/test_frontmatter.py @@ -0,0 +1,72 @@ +import pytest + +import orvanitag.orvani as orvani +from orvanitag.orvani import OrvaniTag + + +@pytest.fixture +def cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def scanned(cwd): + OrvaniTag().scan('.', False) + return (cwd / orvani.CONTEXT_FILE).read_text() + + +def test_reads_lists_strings_and_plain_scalars(cwd): + (cwd / 'a.md').write_text('---\ntags: lofi\nprojects: album\nnote: first take\n---\n') + (cwd / 'b.md').write_text('---\ntags: "blog, lo fi"\n---\n') + (cwd / 'c.md').write_text('---\ntags: [2023, yes]\ncontexts:\n - desk\n---\n') + assert scanned(cwd) == ( + 'a.md --- +album #lofi first take\n' + 'b.md --- #blog #lo-fi\n' + 'c.md --- @desk #2023 #yes\n' + ) + + +def test_write_changes_only_its_own_keys(cwd): + post = '---\ntitle: Post\n# my comment\ndate: 2024-01-05\ntags:\n - blog\n - lo-fi\nlayout: post\n---\nBody text.\n' + (cwd / 'post.md').write_text(post) + o = OrvaniTag() + o.add('post.md', '#blog', '#lo-fi', '#new') + assert o.write_metadata('post.md', 'frontmatter')[0] == 0 + assert (cwd / 'post.md').read_text() == ( + '---\ntitle: Post\n# my comment\ndate: 2024-01-05\ntags: [blog, lo-fi, new]\nlayout: post\n---\nBody text.\n' + ) + + +def test_write_adds_missing_keys_and_quotes_when_needed(cwd): + (cwd / 'a.md').write_text('---\ntitle: A\n---\ntext\n') + o = OrvaniTag() + o.add('a.md', '+c++', '#yes', 'a: note') + o.write_metadata('a.md', 'frontmatter') + assert (cwd / 'a.md').read_text() == '---\ntitle: A\nprojects: ["c++"]\ntags: ["yes"]\nnote: "a: note"\n---\ntext\n' + + (cwd / orvani.CONTEXT_FILE).unlink() + assert scanned(cwd) == 'a.md --- +c++ #yes a: note\n' + + +def test_write_creates_front_matter_and_keeps_crlf(cwd): + (cwd / 'a.md').write_bytes(b'# Heading\r\n') + o = OrvaniTag() + o.add('a.md', '#lofi') + o.write_metadata('a.md', 'frontmatter') + assert (cwd / 'a.md').read_bytes() == b'---\r\ntags: [lofi]\r\n---\r\n# Heading\r\n' + + +def test_empty_categories_leave_existing_keys_alone(cwd): + (cwd / 'a.md').write_text('---\nprojects: [old]\n---\n') + o = OrvaniTag() + o.add('a.md', '#lofi') + o.write_metadata('a.md', 'frontmatter') + assert (cwd / 'a.md').read_text() == '---\nprojects: [old]\ntags: [lofi]\n---\n' + + +def test_rst_is_not_handled(cwd): + (cwd / 'doc.rst').write_text('Title\n=====\n') + o = OrvaniTag() + o.add('doc.rst', '#x') + assert o.write_metadata('doc.rst', 'frontmatter')[0] == 4 + assert (cwd / 'doc.rst').read_text() == 'Title\n=====\n' diff --git a/uv.lock b/uv.lock index c48f9f2..bfc69fe 100644 --- a/uv.lock +++ b/uv.lock @@ -63,13 +63,13 @@ all = [ { name = "olefile" }, { name = "pypdf" }, { name = "pytaglib" }, - { name = "python-frontmatter" }, + { name = "pyyaml" }, ] audio = [ { name = "pytaglib" }, ] markdown = [ - { name = "python-frontmatter" }, + { name = "pyyaml" }, ] office = [ { name = "olefile" }, @@ -90,7 +90,7 @@ requires-dist = [ { name = "orvanitag", extras = ["audio", "pdf", "office", "markdown"], marker = "extra == 'all'" }, { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=4.0" }, { name = "pytaglib", marker = "extra == 'audio'", specifier = ">=2.0.0" }, - { name = "python-frontmatter", marker = "extra == 'markdown'", specifier = ">=1.0" }, + { name = "pyyaml", marker = "extra == 'markdown'", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.4.2" }, ] provides-extras = ["audio", "pdf", "office", "markdown", "all"] @@ -184,18 +184,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] -[[package]] -name = "python-frontmatter" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322, upload-time = "2026-05-20T19:21:44.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3"