Add blog and article narration import
Deploy production / deploy (push) Successful in 4s

This commit is contained in:
2026-08-13 15:12:33 -07:00
parent a2e405cb96
commit 2fe6b689b0
8 changed files with 2672 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from pathlib import Path
import shutil
import sys
import unittest
import uuid
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from article_editor import ArticleEditorError, ArticleRepository, suggest_slug # noqa: E402
class ArticleEditorTests(unittest.TestCase):
def setUp(self) -> None:
cache_root = Path(__file__).resolve().parents[1] / ".cache"
cache_root.mkdir(exist_ok=True)
self.site_root = cache_root / f"article-editor-test-{uuid.uuid4().hex}"
(self.site_root / "content" / "articles").mkdir(parents=True)
(self.site_root / "content" / "tags" / "writing").mkdir(parents=True)
(self.site_root / "models").mkdir()
(self.site_root / "Test.lektorproject").write_text(
"[project]\nname = Test\n", encoding="utf-8"
)
(self.site_root / "content" / "articles" / "contents.lr").write_text(
"_model: section\n---\ntitle: Articles\n", encoding="utf-8"
)
(self.site_root / "content" / "tags" / "writing" / "contents.lr").write_text(
"_model: tag\n---\ntitle: Writing\n---\nsummary: Writing.\n",
encoding="utf-8",
)
source_model = Path(__file__).resolve().parents[1] / "models" / "entry.ini"
shutil.copy2(source_model, self.site_root / "models" / "entry.ini")
self.repository = ArticleRepository(self.site_root)
def tearDown(self) -> None:
shutil.rmtree(self.site_root)
@staticmethod
def values(**overrides: str) -> dict[str, str]:
values = {
"title": "Test Entry",
"date": "2026-08-13",
"updated": "",
"author": "Test Author",
"tags": "writing",
"kicker": "Article",
"summary": "A test summary.",
"body": "A **Markdown** body.",
"external_url": "",
"published_urls": "https://example.test/post",
}
values.update(overrides)
return values
def test_current_site_article_records_and_controlled_tags_load(self) -> None:
repository = ArticleRepository(Path(__file__).resolve().parents[1])
entries = repository.list_entries()
self.assertGreaterEqual(len(entries), 7)
self.assertGreaterEqual(len(repository.tags), 45)
self.assertTrue(all(entry.document.get("kicker") == "Article" for entry in entries))
for entry in entries:
with self.subTest(slug=entry.slug):
self.assertEqual(
entry.document.render().encode("utf-8"), entry.original_bytes
)
def test_create_uses_existing_record_conventions_and_refuses_collision(self) -> None:
entry = self.repository.create_entry(
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
)
rendered = entry.path.read_text(encoding="utf-8")
self.assertIn("_model: entry\n---\ntitle: Test Entry\n", rendered)
self.assertIn("tags: writing\n", rendered)
self.assertIn(
"published_urls:\n\nhttps://example.test/post\n---\nbody:\n",
rendered,
)
with self.assertRaises(ArticleEditorError):
self.repository.create_entry("test-entry", self.values())
def test_edit_preserves_unmanaged_and_unchanged_blocks_exactly(self) -> None:
entry = self.repository.create_entry(
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
)
original = entry.path.read_text(encoding="utf-8")
marker = "---\neditor_note:\nKeep this unknown field exactly. \n"
original = original.replace("---\nbody:\n", marker + "---\nbody:\n")
entry.path.write_text(original, encoding="utf-8")
loaded = self.repository.load_entry("test-entry")
values = self.repository.values_for(loaded.document)
values["summary"] = "An edited summary."
saved = self.repository.save_entry(loaded, values)
rendered = saved.path.read_text(encoding="utf-8")
self.assertIn(marker, rendered)
self.assertIn("summary: An edited summary.\n", rendered)
self.assertIn("body:\n preserved indentation\n\nBody text.\n", rendered)
self.assertIn("published_urls:\n\nhttps://example.test/post\n", rendered)
def test_save_detects_an_external_change(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
with entry.path.open("a", encoding="utf-8") as output:
output.write("\n")
with self.assertRaisesRegex(ArticleEditorError, "changed on disk"):
self.repository.save_entry(entry, self.values(summary="Changed"))
def test_delete_removes_only_the_loaded_entry_directory(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
attachment = entry.path.parent / "narration.mp3"
attachment.write_bytes(b"disposable attachment")
neighbor = self.repository.create_entry("neighbor-entry", self.values())
self.repository.delete_entry(entry)
self.assertFalse(entry.path.parent.exists())
self.assertTrue(neighbor.path.is_file())
def test_delete_detects_an_external_change(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
with entry.path.open("a", encoding="utf-8") as output:
output.write("\n")
with self.assertRaisesRegex(ArticleEditorError, "changed on disk"):
self.repository.delete_entry(entry)
self.assertTrue(entry.path.is_file())
def test_unapproved_tag_is_rejected(self) -> None:
with self.assertRaisesRegex(ArticleEditorError, "unapproved tag"):
self.repository.create_entry("test-entry", self.values(tags="invented"))
def test_cover_image_is_copied_renamed_referenced_and_reopened(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
source = self.site_root / "source.png"
source.write_bytes(b"disposable png bytes")
saved, cover = self.repository.add_or_replace_cover(
entry, source, "Test cover", replace=False
)
self.assertEqual(cover.name, "cover-image.png")
self.assertEqual(cover.path.read_bytes(), source.read_bytes())
self.assertTrue(saved.document.get("body").startswith("![Test cover](cover-image.png)\n\n"))
reopened = self.repository.load_entry("test-entry")
self.assertEqual(self.repository.cover_image(reopened), cover)
def test_cover_replacement_requires_confirmation_and_removes_old_cover(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
first = self.site_root / "first.png"
second = self.site_root / "second.jpg"
first.write_bytes(b"first")
second.write_bytes(b"second")
saved, _cover = self.repository.add_or_replace_cover(
entry, first, "First", replace=False
)
with self.assertRaisesRegex(ArticleEditorError, "confirm replacement"):
self.repository.add_or_replace_cover(saved, second, "Second", replace=False)
replaced, cover = self.repository.add_or_replace_cover(
saved, second, "Second", replace=True
)
self.assertEqual(cover.name, "cover-image.jpg")
self.assertFalse((replaced.path.parent / "cover-image.png").exists())
self.assertTrue((replaced.path.parent / "cover-image.jpg").is_file())
self.assertTrue(replaced.document.get("body").startswith("![Second](cover-image.jpg)\n\n"))
def test_remove_cover_removes_reference_and_article_local_file(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
source = self.site_root / "source.png"
source.write_bytes(b"cover")
saved, cover = self.repository.add_or_replace_cover(
entry, source, "Test cover", replace=False
)
without_cover = self.repository.remove_cover(saved, remove_file=True)
self.assertIsNone(self.repository.cover_image(without_cover))
self.assertFalse(cover.path.exists())
self.assertEqual(without_cover.document.get("body"), self.values()["body"])
def test_slug_suggestion_matches_repository_style(self) -> None:
self.assertEqual(suggest_slug("Were Testing: A GUI!"), "were-testing-a-gui")
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from pathlib import Path
import shutil
import sys
import unittest
import uuid
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from blog_editor import BlogEditorError, BlogRepository, suggest_slug # noqa: E402
class BlogEditorTests(unittest.TestCase):
def setUp(self) -> None:
cache_root = Path(__file__).resolve().parents[1] / ".cache"
cache_root.mkdir(exist_ok=True)
self.site_root = cache_root / f"blog-editor-test-{uuid.uuid4().hex}"
(self.site_root / "content" / "blog").mkdir(parents=True)
(self.site_root / "content" / "tags" / "writing").mkdir(parents=True)
(self.site_root / "models").mkdir()
(self.site_root / "Test.lektorproject").write_text(
"[project]\nname = Test\n", encoding="utf-8"
)
(self.site_root / "content" / "blog" / "contents.lr").write_text(
"_model: section\n---\ntitle: Blog\n", encoding="utf-8"
)
(self.site_root / "content" / "tags" / "writing" / "contents.lr").write_text(
"_model: tag\n---\ntitle: Writing\n---\nsummary: Writing.\n",
encoding="utf-8",
)
source_model = Path(__file__).resolve().parents[1] / "models" / "entry.ini"
shutil.copy2(source_model, self.site_root / "models" / "entry.ini")
self.repository = BlogRepository(self.site_root)
def tearDown(self) -> None:
shutil.rmtree(self.site_root)
@staticmethod
def values(**overrides: str) -> dict[str, str]:
values = {
"title": "Test Entry",
"date": "2026-08-13",
"updated": "",
"author": "Test Author",
"tags": "writing",
"kicker": "Blog",
"summary": "A test summary.",
"body": "A **Markdown** body.",
"external_url": "",
"published_urls": "https://example.test/post",
}
values.update(overrides)
return values
def test_current_site_blog_records_and_controlled_tags_load(self) -> None:
repository = BlogRepository(Path(__file__).resolve().parents[1])
entries = repository.list_entries()
self.assertGreaterEqual(len(entries), 7)
self.assertGreaterEqual(len(repository.tags), 45)
self.assertTrue(all(entry.document.get("kicker") == "Blog" for entry in entries))
for entry in entries:
with self.subTest(slug=entry.slug):
self.assertEqual(
entry.document.render().encode("utf-8"), entry.original_bytes
)
def test_create_uses_existing_record_conventions_and_refuses_collision(self) -> None:
entry = self.repository.create_entry(
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
)
rendered = entry.path.read_text(encoding="utf-8")
self.assertIn("_model: entry\n---\ntitle: Test Entry\n", rendered)
self.assertIn("tags: writing\n", rendered)
self.assertIn(
"published_urls:\n\nhttps://example.test/post\n---\nbody:\n",
rendered,
)
with self.assertRaises(BlogEditorError):
self.repository.create_entry("test-entry", self.values())
def test_edit_preserves_unmanaged_and_unchanged_blocks_exactly(self) -> None:
entry = self.repository.create_entry(
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
)
original = entry.path.read_text(encoding="utf-8")
marker = "---\neditor_note:\nKeep this unknown field exactly. \n"
original = original.replace("---\nbody:\n", marker + "---\nbody:\n")
entry.path.write_text(original, encoding="utf-8")
loaded = self.repository.load_entry("test-entry")
values = self.repository.values_for(loaded.document)
values["summary"] = "An edited summary."
saved = self.repository.save_entry(loaded, values)
rendered = saved.path.read_text(encoding="utf-8")
self.assertIn(marker, rendered)
self.assertIn("summary: An edited summary.\n", rendered)
self.assertIn("body:\n preserved indentation\n\nBody text.\n", rendered)
self.assertIn("published_urls:\n\nhttps://example.test/post\n", rendered)
def test_save_detects_an_external_change(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
with entry.path.open("a", encoding="utf-8") as output:
output.write("\n")
with self.assertRaisesRegex(BlogEditorError, "changed on disk"):
self.repository.save_entry(entry, self.values(summary="Changed"))
def test_delete_removes_only_the_loaded_entry_directory(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
attachment = entry.path.parent / "narration.mp3"
attachment.write_bytes(b"disposable attachment")
neighbor = self.repository.create_entry("neighbor-entry", self.values())
self.repository.delete_entry(entry)
self.assertFalse(entry.path.parent.exists())
self.assertTrue(neighbor.path.is_file())
def test_delete_detects_an_external_change(self) -> None:
entry = self.repository.create_entry("test-entry", self.values())
with entry.path.open("a", encoding="utf-8") as output:
output.write("\n")
with self.assertRaisesRegex(BlogEditorError, "changed on disk"):
self.repository.delete_entry(entry)
self.assertTrue(entry.path.is_file())
def test_unapproved_tag_is_rejected(self) -> None:
with self.assertRaisesRegex(BlogEditorError, "unapproved tag"):
self.repository.create_entry("test-entry", self.values(tags="invented"))
def test_slug_suggestion_matches_repository_style(self) -> None:
self.assertEqual(suggest_slug("Were Testing: A GUI!"), "were-testing-a-gui")
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import sys
import unittest
import uuid
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from narration_import import NarrationImportError, import_wav, remove_narration # noqa: E402
class NarrationImportTests(unittest.TestCase):
def setUp(self) -> None:
cache_root = Path(__file__).resolve().parents[1] / ".cache"
cache_root.mkdir(exist_ok=True)
self.directory = cache_root / f"narration-import-test-{uuid.uuid4().hex}"
self.directory.mkdir()
self.source = self.directory / "source.wav"
self.source.write_bytes(b"disposable wav data")
def tearDown(self) -> None:
shutil.rmtree(self.directory)
def test_success_uses_fixed_preset_and_replaces_only_after_temp_output(self) -> None:
destination = self.directory / "narration.mp3"
destination.write_bytes(b"old narration")
def successful_ffmpeg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
Path(command[-1]).write_bytes(b"converted mp3")
return subprocess.CompletedProcess(command, 0, "", "")
with mock.patch("narration_import.subprocess.run", side_effect=successful_ffmpeg) as run:
result = import_wav(self.directory, self.source, ffmpeg="ffmpeg")
self.assertEqual(result, destination)
self.assertEqual(destination.read_bytes(), b"converted mp3")
command = run.call_args.args[0]
self.assertEqual(
command[1:-1],
["-y", "-i", str(self.source), "-vn", "-ar", "44100", "-ac", "1",
"-c:a", "libmp3lame", "-b:a", "96k"],
)
self.assertFalse(any(self.directory.glob(".narration-*.mp3")))
def test_failed_conversion_preserves_existing_narration(self) -> None:
destination = self.directory / "narration.mp3"
destination.write_bytes(b"old narration")
failed = subprocess.CompletedProcess(["ffmpeg"], 1, "", "bad WAV")
with mock.patch("narration_import.subprocess.run", return_value=failed):
with self.assertRaisesRegex(NarrationImportError, "bad WAV"):
import_wav(self.directory, self.source, ffmpeg="ffmpeg")
self.assertEqual(destination.read_bytes(), b"old narration")
self.assertFalse(any(self.directory.glob(".narration-*.mp3")))
def test_missing_ffmpeg_and_removal_are_safe(self) -> None:
with mock.patch("narration_import.shutil.which", return_value=None):
with self.assertRaisesRegex(NarrationImportError, "ffmpeg is required"):
import_wav(self.directory, self.source)
unrelated = self.directory / "other-attachment.txt"
unrelated.write_text("keep", encoding="utf-8")
narration = self.directory / "narration.mp3"
narration.write_bytes(b"narration")
self.assertTrue(remove_narration(self.directory))
self.assertFalse(narration.exists())
self.assertEqual(unrelated.read_text(encoding="utf-8"), "keep")
def test_repository_fallback_is_used_when_ffmpeg_is_not_on_path(self) -> None:
fallback = self.directory / "ffmpeg.exe"
fallback.write_bytes(b"placeholder executable")
def successful_ffmpeg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
Path(command[-1]).write_bytes(b"converted mp3")
return subprocess.CompletedProcess(command, 0, "", "")
with mock.patch("narration_import.shutil.which", return_value=None), mock.patch(
"narration_import.subprocess.run", side_effect=successful_ffmpeg
) as run:
import_wav(self.directory, self.source, fallback_ffmpeg=fallback)
self.assertEqual(run.call_args.args[0][0], str(fallback))
if __name__ == "__main__":
unittest.main()