New English meme and flyer set, retire Dutch-text gallery images
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 189 KiB |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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")}
|
||||
|
||||