fix: make frontmatter plugin read string and numeric tags and edit only its own front matter keys

This commit is contained in:
randogoth 2026-10-24 21:37:30 +03:00
parent 633012d1ef
commit 3277e5b323
5 changed files with 176 additions and 35 deletions

View file

@ -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, `<!DOCTYPE>` or `<?php` line |
### Import and Export

View file

@ -1,22 +1,103 @@
import frontmatter
from pathlib import Path
"""
Tags as `projects`, `contexts`, `tags` and `note` keys of a Markdown file's YAML front matter.
Writing edits only those lines, so other keys, comments and formatting stay as they are.
"""
import json
import os
import re
import shutil
import yaml
NAME = 'frontmatter'
TYPES = ['.md', '.txt', '.markdown', '.rst']
TYPES = ['.md', '.markdown', '.txt']
DELIMITER = '---'
PLAIN = re.compile(r'[A-Za-z][\w-]*')
# Plain words YAML 1.1 would read as booleans or null
SPECIAL = {'y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false', 'null'}
def block(lines: list):
"""Returns the line range between the front matter delimiters, or None"""
if lines and lines[0].strip() == DELIMITER:
for num, line in enumerate(lines[1:], start=1):
if line.strip() in (DELIMITER, '...'):
return 1, num
return None
def words(value) -> 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))
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)

View file

@ -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]

72
tests/test_frontmatter.py Normal file
View file

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

18
uv.lock generated
View file

@ -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"