fix: make frontmatter plugin read string and numeric tags and edit only its own front matter keys
This commit is contained in:
parent
633012d1ef
commit
3277e5b323
5 changed files with 176 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
72
tests/test_frontmatter.py
Normal 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
18
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue