diff --git a/molodetz/database/media.py b/molodetz/database/media.py index c32f7f8..c1ff722 100644 --- a/molodetz/database/media.py +++ b/molodetz/database/media.py @@ -37,6 +37,15 @@ def upsert_media(filename, kind, caption, position, width, height, phash, thumb_ return fields["uid"] +def retire_media(filename): + existing = get_media_by_file(filename) + if not existing or existing.get("deleted_at"): + return False + stamp = now_iso() + db["media_items"].update({"id": existing["id"], "deleted_at": stamp, "deleted_by": "system", "updated_at": stamp}, ["id"]) + return True + + def count_media(kind=None): if "media_items" not in db.tables: return 0 diff --git a/molodetz/docs_api/content.py b/molodetz/docs_api/content.py index d1f22ef..3428d6d 100644 --- a/molodetz/docs_api/content.py +++ b/molodetz/docs_api/content.py @@ -6,7 +6,7 @@ GROUP = { "title": "Content", "description": "Public read routes: the roll, the standard, posts, galleries and people.", "endpoints": [ - endpoint("GET", "/", "Home", "Latest posts and the featured flyer.", sample={"posts": [], "flyer_count": 4, "meme_count": 20}), + endpoint("GET", "/", "Home", "Latest posts and the featured flyer.", sample={"posts": [], "flyer_count": 4, "meme_count": 22}), endpoint( "GET", "/roll", diff --git a/molodetz/gallery.py b/molodetz/gallery.py index 9c2b05c..34408bc 100644 --- a/molodetz/gallery.py +++ b/molodetz/gallery.py @@ -8,7 +8,7 @@ from PIL import Image from molodetz import config from molodetz.attachments import blob_path, blob_url, write_blob from molodetz.database import generate_uid, media_by_kind, upsert_media -from molodetz.database.media import get_media_by_file +from molodetz.database.media import get_media_by_file, retire_media logger = logging.getLogger(__name__) @@ -16,32 +16,57 @@ THUMB_WIDTH = 720 DUPLICATE_DISTANCE = 2 GALLERY = ( - ("flyer-keuzes.jpg", "flyer", "Thirty-one choices. You have zero."), - ("flyer-chat.jpg", "flyer", "A chat is not a portfolio."), - ("flyer-bijna.jpg", "flyer", "Almost is not a state."), - ("flyer-assistent.jpg", "flyer", "The assistant does not judge."), - ("meme-vloer.jpg", "meme", "A pass is fine. The floor, you mean."), - ("meme-split.jpg", "meme", "Send them home."), - ("meme-cursus.jpg", "meme", "No ebook. Just finish it."), - ("take-chatlog.jpg", "meme", "Your portfolio is a chat log."), - ("take-copilot.jpg", "meme", "Copilot is not a senior."), - ("take-coping.jpg", "meme", "A pass is coping."), - ("poster-thuis.jpg", "meme", "Vibe coders, go home."), - ("poster-vloer.jpg", "meme", "A pass? That is the floor."), - ("poster-cursus.jpg", "meme", "No course. Just finish it."), - ("poster-luiheid.jpg", "meme", "AI is not taking your job. Laziness is."), - ("poster-spel.jpg", "meme", "The game has turned. You have not."), - ("story.jpg", "meme", "No ebook. No 10x developer. Just finishing."), - ("meme-zes.jpg", "meme", "A pass. Congratulations, you are average."), - ("meme-gesprek.jpg", "meme", "My portfolio is a conversation."), - ("meme-product.jpg", "meme", "The course is the product. The work is not."), - ("meme-bijna.jpg", "meme", "Everyone lines up at almost."), + ("flyer-elite-twice.jpg", "flyer", "Elite twice. Know the code. Use the machine. Read every line it writes."), + ("flyer-taste.jpg", "flyer", "Taste does not autocomplete."), + ("flyer-demo.jpg", "flyer", "A demo is not a product."), + ("flyer-hands.jpg", "flyer", "Your hands are not the craft."), + ("meme-elite-galaxy-brain.jpg", "meme", "Getting elite twice: know every line before the AI writes it, review every line after, ship by lunch."), + ("meme-classic-autocomplete.jpg", "meme", "I don't need autocomplete. Hand-typed. Artisanal. Not shipped."), + ("meme-vibe-prompt-and-pray.jpg", "meme", "Prompt and pray: the same TypeError pasted into the chat 31 times. Suggested fix: learn what a function is."), + ("meme-elite-choose-your-fighter.jpg", "meme", "Choose your fighter: the classic, the vibe coder, or the elite who writes code, uses AI and reads every diff."), + ("meme-classic-starter-pack.jpg", "meme", "The 'AI is just fancy autocomplete' starter pack: 36 tmux panes, a vimrc older than the intern, zero pull requests."), + ("meme-vibe-starter-pack.jpg", "meme", "The 'built it in a weekend' starter pack: tab, tab, accept all, API key committed to main, works on my machine."), + ("meme-elite-build-log.jpg", "meme", "Elite twice. The model types. You decide. 1,184 lines drafted, every line read, 212 rejected."), + ("meme-classic-chart.jpg", "meme", "Free-range, hand-typed code: keystrokes up, features shipped flat, opinions about AI off the chart."), + ("meme-vibe-galaxy-brain.jpg", "meme", "Vibe debugging: read the error, paste the error, tab tab accept all, delete the repo and prompt a new one."), + ("meme-elite-take.jpg", "meme", "AI replaced the part of me that typed. The part that thinks got a raise."), + ("meme-classic-same-guy.jpg", "meme", "1987: compilers make programmers lazy. 2026: AI makes programmers lazy. Same guy. Still not shipping."), + ("meme-vibe-git-blame.jpg", "meme", "Git blame says it was you. You say it was the vibes."), + ("meme-elite-curve.jpg", "meme", "Pick your curve: classic stays flat, the vibe coder crashes at the first prod incident, elite plus AI keeps climbing."), + ("meme-classic-certificate.jpg", "meme", "Certificate of manual labour. Time saved: 0 hours. Moral high ground: maximum."), + ("meme-vibe-weekend.jpg", "meme", "A vibe coder's weekend: a SaaS on Friday, a public database by Sunday, a course about it on Monday."), + ("meme-elite-achievement.jpg", "meme", "Achievement unlocked: this developer embraced AI and is now elite twice."), + ("meme-classic-family.jpg", "meme", "Three generations of refusing the tool. Three generations of getting outshipped."), + ("meme-vibe-priorities.jpg", "meme", "Vibe coder priorities: learning what a for loop does, nah. Typing 'make it work, no bugs, please' 30 times, yep."), ("meme-compiles.jpg", "meme", "It compiles. Ship it, king."), ("meme-senior.jpg", "meme", "My copilot is the senior."), ("meme-trophy.jpg", "meme", "Peak performance, 6 out of 10."), ("meme-todo.jpg", "meme", "Side projects, a love story."), ) +RETIRED_GALLERY = ( + "flyer-keuzes.jpg", + "flyer-chat.jpg", + "flyer-bijna.jpg", + "flyer-assistent.jpg", + "meme-vloer.jpg", + "meme-split.jpg", + "meme-cursus.jpg", + "meme-zes.jpg", + "meme-gesprek.jpg", + "meme-product.jpg", + "meme-bijna.jpg", + "poster-thuis.jpg", + "poster-vloer.jpg", + "poster-cursus.jpg", + "poster-luiheid.jpg", + "poster-spel.jpg", + "story.jpg", + "take-chatlog.jpg", + "take-copilot.jpg", + "take-coping.jpg", +) + def _thumbnail_bytes(image): copy = image.convert("RGB") @@ -51,7 +76,12 @@ def _thumbnail_bytes(image): return buffer.getvalue() +def retire_gallery(): + return sum(1 for filename in RETIRED_GALLERY if retire_media(filename)) + + def sync_gallery(): + retire_gallery() seen_hashes = [] synced = 0 for position, (filename, kind, caption) in enumerate(GALLERY): diff --git a/molodetz/seo.py b/molodetz/seo.py index 9340e9b..6038f6b 100644 --- a/molodetz/seo.py +++ b/molodetz/seo.py @@ -6,7 +6,7 @@ from molodetz import config from molodetz.assets import static_url from molodetz.seo_meta_text import DEFAULT_DESCRIPTION -DEFAULT_OG_IMAGE_PATH = "/static/media/flyer-keuzes.jpg" +DEFAULT_OG_IMAGE_PATH = "/static/media/flyer-elite-twice.jpg" def base_url(request): diff --git a/molodetz/static/media/flyer-demo.jpg b/molodetz/static/media/flyer-demo.jpg new file mode 100644 index 0000000..17a732f Binary files /dev/null and b/molodetz/static/media/flyer-demo.jpg differ diff --git a/molodetz/static/media/flyer-elite-twice.jpg b/molodetz/static/media/flyer-elite-twice.jpg new file mode 100644 index 0000000..c9d54b3 Binary files /dev/null and b/molodetz/static/media/flyer-elite-twice.jpg differ diff --git a/molodetz/static/media/flyer-hands.jpg b/molodetz/static/media/flyer-hands.jpg new file mode 100644 index 0000000..0123e27 Binary files /dev/null and b/molodetz/static/media/flyer-hands.jpg differ diff --git a/molodetz/static/media/flyer-taste.jpg b/molodetz/static/media/flyer-taste.jpg new file mode 100644 index 0000000..c2dae2b Binary files /dev/null and b/molodetz/static/media/flyer-taste.jpg differ diff --git a/molodetz/static/media/meme-classic-autocomplete.jpg b/molodetz/static/media/meme-classic-autocomplete.jpg new file mode 100644 index 0000000..1d1a8b4 Binary files /dev/null and b/molodetz/static/media/meme-classic-autocomplete.jpg differ diff --git a/molodetz/static/media/meme-classic-certificate.jpg b/molodetz/static/media/meme-classic-certificate.jpg new file mode 100644 index 0000000..93c92a8 Binary files /dev/null and b/molodetz/static/media/meme-classic-certificate.jpg differ diff --git a/molodetz/static/media/meme-classic-chart.jpg b/molodetz/static/media/meme-classic-chart.jpg new file mode 100644 index 0000000..f81ff7a Binary files /dev/null and b/molodetz/static/media/meme-classic-chart.jpg differ diff --git a/molodetz/static/media/meme-classic-family.jpg b/molodetz/static/media/meme-classic-family.jpg new file mode 100644 index 0000000..2b63408 Binary files /dev/null and b/molodetz/static/media/meme-classic-family.jpg differ diff --git a/molodetz/static/media/meme-classic-same-guy.jpg b/molodetz/static/media/meme-classic-same-guy.jpg new file mode 100644 index 0000000..66f7e3b Binary files /dev/null and b/molodetz/static/media/meme-classic-same-guy.jpg differ diff --git a/molodetz/static/media/meme-classic-starter-pack.jpg b/molodetz/static/media/meme-classic-starter-pack.jpg new file mode 100644 index 0000000..3761d0e Binary files /dev/null and b/molodetz/static/media/meme-classic-starter-pack.jpg differ diff --git a/molodetz/static/media/meme-elite-achievement.jpg b/molodetz/static/media/meme-elite-achievement.jpg new file mode 100644 index 0000000..6627851 Binary files /dev/null and b/molodetz/static/media/meme-elite-achievement.jpg differ diff --git a/molodetz/static/media/meme-elite-build-log.jpg b/molodetz/static/media/meme-elite-build-log.jpg new file mode 100644 index 0000000..a04efbb Binary files /dev/null and b/molodetz/static/media/meme-elite-build-log.jpg differ diff --git a/molodetz/static/media/meme-elite-choose-your-fighter.jpg b/molodetz/static/media/meme-elite-choose-your-fighter.jpg new file mode 100644 index 0000000..7ef790a Binary files /dev/null and b/molodetz/static/media/meme-elite-choose-your-fighter.jpg differ diff --git a/molodetz/static/media/meme-elite-curve.jpg b/molodetz/static/media/meme-elite-curve.jpg new file mode 100644 index 0000000..d06d6f9 Binary files /dev/null and b/molodetz/static/media/meme-elite-curve.jpg differ diff --git a/molodetz/static/media/meme-elite-galaxy-brain.jpg b/molodetz/static/media/meme-elite-galaxy-brain.jpg new file mode 100644 index 0000000..f7ec61f Binary files /dev/null and b/molodetz/static/media/meme-elite-galaxy-brain.jpg differ diff --git a/molodetz/static/media/meme-elite-take.jpg b/molodetz/static/media/meme-elite-take.jpg new file mode 100644 index 0000000..408f0c0 Binary files /dev/null and b/molodetz/static/media/meme-elite-take.jpg differ diff --git a/molodetz/static/media/meme-vibe-galaxy-brain.jpg b/molodetz/static/media/meme-vibe-galaxy-brain.jpg new file mode 100644 index 0000000..33d6f0e Binary files /dev/null and b/molodetz/static/media/meme-vibe-galaxy-brain.jpg differ diff --git a/molodetz/static/media/meme-vibe-git-blame.jpg b/molodetz/static/media/meme-vibe-git-blame.jpg new file mode 100644 index 0000000..7d9b2f3 Binary files /dev/null and b/molodetz/static/media/meme-vibe-git-blame.jpg differ diff --git a/molodetz/static/media/meme-vibe-priorities.jpg b/molodetz/static/media/meme-vibe-priorities.jpg new file mode 100644 index 0000000..1e1eff4 Binary files /dev/null and b/molodetz/static/media/meme-vibe-priorities.jpg differ diff --git a/molodetz/static/media/meme-vibe-prompt-and-pray.jpg b/molodetz/static/media/meme-vibe-prompt-and-pray.jpg new file mode 100644 index 0000000..beb57ab Binary files /dev/null and b/molodetz/static/media/meme-vibe-prompt-and-pray.jpg differ diff --git a/molodetz/static/media/meme-vibe-starter-pack.jpg b/molodetz/static/media/meme-vibe-starter-pack.jpg new file mode 100644 index 0000000..463f77a Binary files /dev/null and b/molodetz/static/media/meme-vibe-starter-pack.jpg differ diff --git a/molodetz/static/media/meme-vibe-weekend.jpg b/molodetz/static/media/meme-vibe-weekend.jpg new file mode 100644 index 0000000..edb4383 Binary files /dev/null and b/molodetz/static/media/meme-vibe-weekend.jpg differ diff --git a/pyproject.toml b/pyproject.toml index cc986af..025c860 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "molodetz" -version = "1.0.7" +version = "1.0.8" description = "Molodetz, a calm community blog roll." readme = "README.md" requires-python = ">=3.12" diff --git a/tests/api/flyers.py b/tests/api/flyers.py index fd12e69..39d2014 100644 --- a/tests/api/flyers.py +++ b/tests/api/flyers.py @@ -5,3 +5,22 @@ def test_galleries_have_images(anon): for path, minimum in (("/flyers", 4), ("/memes", 10)): html = anon.get(path).text assert html.count("data-lightbox") >= minimum, path + + +def test_retired_dutch_images_are_not_listed(anon): + from molodetz.gallery import RETIRED_GALLERY + + for path in ("/flyers", "/memes"): + items = anon.get(path, headers={"Accept": "application/json"}).json()["items"] + names = {item["filename"] for item in items} + assert not names & set(RETIRED_GALLERY), path + assert all(item["caption"] for item in items), path + + +def test_home_features_the_elite_twice_flyer(anon): + html = anon.get("/").text + assert "flyer-elite-twice.jpg" in html + assert "Elite twice." in html + counts = anon.get("/", headers={"Accept": "application/json"}).json() + assert counts["flyer_count"] == 4 + assert counts["meme_count"] >= 18 diff --git a/tests/api/language.py b/tests/api/language.py index b0cd190..d1882a5 100644 --- a/tests/api/language.py +++ b/tests/api/language.py @@ -43,7 +43,7 @@ def test_sitemap_lists_english_paths(anon): def test_gallery_captions_are_english(anon): flyers = anon.get("/flyers", headers={"Accept": "application/json"}).json()["items"] captions = {item["caption"] for item in flyers} - assert "Thirty-one choices. You have zero." in captions + assert "Taste does not autocomplete." in captions assert not any(" geen " in f" {caption.lower()} " or " een " in f" {caption.lower()} " for caption in captions) diff --git a/tests/unit/gallery.py b/tests/unit/gallery.py index 00409d5..40d2b91 100644 --- a/tests/unit/gallery.py +++ b/tests/unit/gallery.py @@ -1,6 +1,8 @@ # retoor from molodetz import config -from molodetz.gallery import GALLERY +from PIL import Image + +from molodetz.gallery import GALLERY, RETIRED_GALLERY BANNED = ("selfie", "mirror", "devplace", "nigel", "nldr", "waifu", "zwem") @@ -16,3 +18,38 @@ def test_gallery_files_exist_and_are_allowed(): def test_both_kinds_present(): kinds = {entry["kind"] if isinstance(entry, dict) else entry[1] for entry in GALLERY} assert {"flyer", "meme"} <= kinds + + +def test_gallery_image_sizes_match_kind(): + for name, kind, _ in GALLERY: + if not name.startswith(("flyer-elite", "flyer-taste", "flyer-demo", "flyer-hands", "meme-classic", "meme-vibe", "meme-elite")): + continue + with Image.open(config.MEDIA_SOURCE_DIR / name) as image: + assert image.size == ((1080, 1350) if kind == "flyer" else (1080, 1080)), name + + +def test_retired_files_stay_on_disk_but_leave_the_gallery(): + names = {entry[0] for entry in GALLERY} + assert len(RETIRED_GALLERY) == 20 + for name in RETIRED_GALLERY: + assert name not in names, name + assert (config.MEDIA_SOURCE_DIR / name).is_file(), name + + +def test_gallery_captions_are_clean_english(): + for name, _, caption in GALLERY: + assert caption and "\u2014" not in caption, name + assert not any(word in caption.lower() for word in BANNED), name + assert GALLERY[0][0] == "flyer-elite-twice.jpg" + + +def test_retire_media_soft_deletes_once(app_server): + from molodetz.database import db + from molodetz.database.media import media_by_kind, retire_media, upsert_media + + upsert_media("story.jpg", "meme", "Old Dutch story", 999, 1080, 1350, None, None, None) + assert retire_media("story.jpg") is True + assert retire_media("story.jpg") is False + row = db["media_items"].find_one(filename="story.jpg") + assert row["deleted_at"] and row["deleted_by"] == "system" + assert "story.jpg" not in {item["filename"] for item in media_by_kind("meme")}