diff --git a/README.md b/README.md index 1ef421d..0ef0fc5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links). -- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants). +- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original, and never taller than 4096 px: such a width of a tall original is skipped with a warning and the job goes on) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants). - **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links). - **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search). - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. @@ -565,7 +565,7 @@ Headings, links and media (Phase 33): `TestHeadingConversionTracer`, `TestHeadin `TestJournalMCPSmoke` (root package, Docker-free) pins the eleven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it. -Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. `TestImageVariants` (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's `variants`, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width. +Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. `TestImageVariants` (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's `variants`, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width; `TestImageVariantsTallOriginal` that a 1000×9000 original followed by a normal one fails neither the job nor `journal:image-variants --all`. `TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule. diff --git a/classes/image_variants.go b/classes/image_variants.go index 6705a4d..ff8040f 100644 --- a/classes/image_variants.go +++ b/classes/image_variants.go @@ -192,6 +192,14 @@ func GenerateImageVariants(ctx context.Context, db *gorm.DB, bucket *blob.Bucket continue } h := max(1, int((int64(sh)*int64(w)+int64(sw)/2)/int64(sw))) + if h > MaxImageWidth { + // A tall original (an infographic): the variant would leave + // the thumbnailer's edge limit, so this width is skipped like + // one wider than the original, and the job goes on. + variantsLog.Warn("journal: no image variant taller than the edge limit", "file", f.ID, "width", w, "height", h, "max", MaxImageWidth) + out.Skipped++ + continue + } key, err := f.ThumbKey(ctx, bucket, w, h, ImageVariantsMode) if err != nil { return out, fmt.Errorf("journal: thumbnail %d at %d: %w", f.ID, w, err) diff --git a/related_media_test.go b/related_media_test.go index 9f47636..9b4ce94 100644 --- a/related_media_test.go +++ b/related_media_test.go @@ -24,8 +24,10 @@ import ( journal "git.golem15.com/golem15/sm-journal-plugin" "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/sm-journal-plugin/console" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gocloud.dev/blob" @@ -456,3 +458,60 @@ func TestImageVariants(t *testing.T) { } } } + +// TestImageVariantsTallOriginal pins L1 CR-01 (D-85): a width whose +// variant would be taller than the thumbnailer's 4096 px edge limit is +// skipped and counted, and the job and journal:image-variants go on with +// the next widths and images instead of failing on the tall original. +func TestImageVariantsTallOriginal(t *testing.T) { + if testShort() { + t.Skip("Postgres") + } + env := newPostsEnv(t) + bucket, _ := env.app.Lookup[*blob.Bucket]() + post := models.Post{Title: "Infographic", Slug: "infographic", Content: "
x
", Published: true} + if err := env.gdb.Create(&post).Error; err != nil { + t.Fatal(err) + } + upload := func(disk string, w, h int) attach.File { + t.Helper() + if err := bucket.WriteAll(t.Context(), attach.BlobKey(disk), jpegOf(t, w, h), nil); err != nil { + t.Fatal(err) + } + f := attach.File{DiskName: disk, FileName: disk, ContentType: "image/jpeg", Field: classes.FeaturedImagesField, AttachmentType: post.MorphName(), AttachmentID: strconv.FormatUint(uint64(post.ID), 10)} + if err := env.gdb.Create(&f).Error; err != nil { + t.Fatal(err) + } + return f + } + tall := upload("tall23def456ghi789jkl0.jpg", 1000, 9000) + wide := upload("wide23def456ghi789jkl0.jpg", 900, 600) + + res, err := classes.GenerateImageVariants(t.Context(), env.gdb, bucket, classes.ImageWidths(env.app.Config), []uint{tall.ID, wide.ID}) + if err != nil { + t.Fatalf("the job failed on the tall original: %v", err) + } + if res.Skipped < 3 { + t.Fatalf("result = %+v, want the tall original's three widths skipped (and the normal one's 1200)", res) + } + var tallRows, wideRows []models.ImageVariant + env.gdb.Where("file_id = ?", tall.ID).Find(&tallRows) + env.gdb.Where("file_id = ?", wide.ID).Order("width ASC").Find(&wideRows) + for _, v := range tallRows { + if v.Height > classes.MaxImageWidth { + t.Fatalf("a variant above the edge limit: %+v", v) + } + } + if len(wideRows) != 2 || wideRows[0].Width != 480 || wideRows[1].Width != 800 { + t.Fatalf("variants of the image after the tall one = %+v", wideRows) + } + + var buf strings.Builder + root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.ImageVariants(env.app)}, strings.NewReader(""), &buf, &buf) + if err != nil { + t.Fatal(err) + } + if code := bonfire.Execute(t.Context(), root, []string{"journal:image-variants", "--all"}); code != 0 { + t.Fatalf("journal:image-variants --all: %d\n%s", code, buf.String()) + } +}