From edbee885acd6892ee4bb80639103916ad846cf4d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 5 Feb 2026 15:41:50 +0100 Subject: [PATCH] fix(09): revise plans based on checker feedback - Plan 02: Added key_link clarifying CmsPageService.render populates PageRenderContext.components before template rendering. Updated Task 2/3 actions to emphasize component initialization flow. - Plan 03: Split into 03 (storage + library core) and 03b (image processing + routes) to reduce scope from 9 files to 7+4 files. Estimated context reduced from ~65% to ~45% each. - Plan 03b: New plan for ImageProcessor and MediaRoutes. Added specific curl command with -F flags and expected JSON response. - Plan 05: Added plugin integration test (MenuPluginIntegrationSpec) demonstrating custom menu item type registration, resolution, and template rendering per CONT-09 requirement. - Plan 06: Reframed must_haves truths from implementation details to user-observable outcomes (e.g., 'Developer edits template file, browser refresh shows changes without server restart') - Roadmap: Updated Phase 9 from 6 to 7 plans. --- .planning/ROADMAP.md | 9 +- .../09-content-management/09-02-PLAN.md | 31 +- .../09-content-management/09-03-PLAN.md | 197 +---------- .../09-content-management/09-03b-PLAN.md | 310 ++++++++++++++++++ .../09-content-management/09-05-PLAN.md | 234 ++++++++----- .../09-content-management/09-06-PLAN.md | 21 +- 6 files changed, 506 insertions(+), 296 deletions(-) create mode 100644 .planning/phases/09-content-management/09-03b-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index de118c2..dbf8a0f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -171,14 +171,15 @@ Plans: 8. Navigation menus can be created and managed 9. Menu items support different types (URL, page reference, plugin-generated) 10. Changes reflect immediately during development (hot reload) -**Plans**: 6 plans +**Plans**: 7 plans Plans: - [ ] 09-01-PLAN.md - CMS pages and layouts (CmsPage, CmsLayout, CmsRouter, CmsPageService) - [ ] 09-02-PLAN.md - Component embedding (ComponentTag, PlaceholderTag, PutTag, PartialTag, CmsPebbleExtension) -- [ ] 09-03-PLAN.md - Media library (StorageBackend, LocalStorage, S3Storage, MediaLibrary, ImageProcessor) +- [ ] 09-03-PLAN.md - Media library core (StorageBackend, LocalStorage, S3Storage, MediaLibrary) +- [ ] 09-03b-PLAN.md - Media routes and image processing (ImageProcessor, MediaRoutes) - [ ] 09-04-PLAN.md - Content states and revisions (ContentRevision, RevisionService, publish/unpublish) -- [ ] 09-05-PLAN.md - Navigation menus (Menu, MenuItem, MenuItemTypeRegistry, MenuService) +- [ ] 09-05-PLAN.md - Navigation menus with plugin integration (Menu, MenuItem, MenuItemTypeRegistry, MenuService, plugin tests) - [ ] 09-06-PLAN.md - Hot reload (FileWatcher, HotReloadService, TemplateInvalidator) ### Phase 10: Core Plugins @@ -217,7 +218,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 6. Backend Authentication | 0/3 | Planned | - | | 7. Admin Forms & Lists | 0/3 | Planned | - | | 8. Admin Dashboard | 0/4 | Planned | - | -| 9. Content Management | 0/6 | Planned | - | +| 9. Content Management | 0/7 | Planned | - | | 10. Core Plugins | 0/4 | Not started | - | --- diff --git a/.planning/phases/09-content-management/09-02-PLAN.md b/.planning/phases/09-content-management/09-02-PLAN.md index 03ab508..ae47ab5 100644 --- a/.planning/phases/09-content-management/09-02-PLAN.md +++ b/.planning/phases/09-content-management/09-02-PLAN.md @@ -40,6 +40,10 @@ must_haves: to: "PageRenderContext.placeholderContent" via: "checks for put content before rendering default" pattern: "placeholderContent\\.get" + - from: "CmsPageService.render" + to: "PageRenderContext.components" + via: "initializes components from page config before rendering" + pattern: "PageRenderContext.*components.*=" --- @@ -136,6 +140,10 @@ Implement `{% component 'alias' prop1='value' %}`: - Call component.render() using pageContext.runtime.unsafeRun - Write rendered HTML to output +**CRITICAL: ComponentTag depends on PageRenderContext.components being pre-populated.** +The components map is populated by CmsPageService.render (Task 3) BEFORE the template is rendered. +ComponentTag only looks up by alias - it does NOT initialize components. + Component lookup: Page's INI section defines components like: ``` [blogPosts posts] @@ -190,17 +198,24 @@ class CmsPebbleExtension extends Extension: ``` **Update CmsPageService.scala render method:** -Implement two-pass rendering: +Implement two-pass rendering with COMPONENT INITIALIZATION: -1. Initialize components from page config: - - Parse component definitions from page settings - - For each `[componentClass alias]` section, create ComponentInstance - - Run component lifecycle (init, onRun) +1. **Initialize components from page config (CRITICAL - populates PageRenderContext.components):** + - Parse component definitions from page settings (INI format) + - For each `[componentClass alias]` section: + a. Look up component class from ComponentRegistry (from Phase 3) + b. Create ComponentInstance with properties from section + c. Run component lifecycle (init, onRun) + d. Store in components Map keyed by alias + - This Map is passed to PageRenderContext BEFORE any template rendering 2. First pass - render page content: - - Create PageRenderContext with mutable.Map for placeholderContent + - Create PageRenderContext with: + - `components`: the Map populated in step 1 + - `placeholderContent`: empty mutable.Map - Render page.markup with Pebble - This collects {% put %} content into placeholderContent + - {% component 'alias' %} tags can now resolve from pre-populated components Map 3. Second pass - render layout: - Get layout from page.config.layout @@ -219,7 +234,7 @@ End-to-end test: Create sample page with component, partial, and placeholder Render should produce composed HTML with all elements -CmsPebbleExtension registers all CMS tags with Pebble. CmsPageService.render uses two-pass rendering: first to collect put content, then to render layout with placeholders resolved. +CmsPebbleExtension registers all CMS tags with Pebble. CmsPageService.render initializes components from page config into PageRenderContext.components Map, then uses two-pass rendering: first to collect put content, then to render layout with placeholders resolved. @@ -267,6 +282,8 @@ property = "value" - {% placeholder 'name' %}default{% endplaceholder %} in layouts with page-fillable content - {% put 'name' %}content{% endput %} in pages fills layout placeholders - {% partial 'name' %} includes partials with variables +- CmsPageService.render initializes components from page config BEFORE template rendering +- PageRenderContext.components is populated when ComponentTag executes - Two-pass rendering correctly resolves all template composition diff --git a/.planning/phases/09-content-management/09-03-PLAN.md b/.planning/phases/09-content-management/09-03-PLAN.md index 711f155..053d0c2 100644 --- a/.planning/phases/09-content-management/09-03-PLAN.md +++ b/.planning/phases/09-content-management/09-03-PLAN.md @@ -11,16 +11,13 @@ files_modified: - summercms/src/content/media/S3Storage.scala - summercms/src/content/media/MediaItem.scala - summercms/src/content/media/MediaLibrary.scala - - summercms/src/content/media/ImageProcessor.scala - summercms/resources/db/migration/V010__media_library.sql - - summercms/src/api/admin/MediaRoutes.scala autonomous: true must_haves: truths: - "Admin can upload files to media library" - "Files stored via configurable storage backend (local or S3)" - - "Images can be resized and cropped via ImageProcessor" - "Media items organized in folder hierarchy" - "File metadata tracked in database for listing and search" artifacts: @@ -30,9 +27,6 @@ must_haves: - path: "summercms/src/content/media/MediaLibrary.scala" provides: "Media library service" contains: "trait MediaLibrary" - - path: "summercms/src/content/media/ImageProcessor.scala" - provides: "Image resize/crop operations" - contains: "trait ImageProcessor" - path: "summercms/resources/db/migration/V010__media_library.sql" provides: "Media metadata tables" contains: "CREATE TABLE media_files" @@ -41,18 +35,14 @@ must_haves: to: "StorageBackend.put" via: "stream to storage" pattern: "storage\\.put" - - from: "MediaRoutes" - to: "MediaLibrary.upload" - via: "HTTP multipart upload" - pattern: "MediaLibrary.*upload" --- -Implement media library with storage abstraction and image processing +Implement media library core with storage abstraction -Purpose: Enable admin to upload, organize, and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). Images can be resized/cropped per CONTEXT.md decisions. +Purpose: Enable admin to upload and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). This plan focuses on the core storage and library service. -Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, ImageProcessor for resize/crop, database migration for metadata, admin upload routes. +Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, database migration for metadata. @@ -65,7 +55,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service @.planning/ROADMAP.md @.planning/phases/09-content-management/09-RESEARCH.md @summercms/src/db/QuillContext.scala -@summercms/src/api/Routes.scala @build.mill @@ -83,10 +72,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service **build.mill - Add dependencies:** ```scala -// Image processing -mvn"com.sksamuel.scrimage::scrimage-core:4.1.0", -mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0", - // S3 storage backend mvn"dev.zio::zio-s3:0.4.2.1" ``` @@ -171,54 +156,12 @@ StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem - Task 2: Implement ImageProcessor and MediaLibrary service + Task 2: Implement MediaLibrary service and database migration - summercms/src/content/media/ImageProcessor.scala summercms/src/content/media/MediaLibrary.scala summercms/resources/db/migration/V010__media_library.sql -**ImageProcessor.scala:** -Using Scrimage for image operations (per CONTEXT.md: resize and crop only): -```scala -trait ImageProcessor: - def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] - def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] - def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] - def dimensions(input: Array[Byte]): IO[ImageError, (Int, Int)] - -object ImageProcessor: - val live: ULayer[ImageProcessor] = ZLayer.succeed { - new ImageProcessor: - def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] = - ZIO.attemptBlocking { - ImmutableImage.loader() - .detectOrientation(true) // Handle EXIF rotation - .fromBytes(input) - .fit(width, height) - .bytes(JpegWriter.Default) - }.mapError(ImageError.ProcessingFailed(_)) - - def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] = - ZIO.attemptBlocking { - ImmutableImage.loader() - .detectOrientation(true) - .fromBytes(input) - .subimage(x, y, width, height) - .bytes(JpegWriter.Default) - }.mapError(ImageError.ProcessingFailed(_)) - - def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] = - ZIO.attemptBlocking { - ImmutableImage.loader() - .detectOrientation(true) - .fromBytes(input) - .bound(maxWidth, maxHeight) - .bytes(JpegWriter.Default) - }.mapError(ImageError.ProcessingFailed(_)) - } -``` - **V010__media_library.sql:** ```sql CREATE TABLE media_files ( @@ -266,132 +209,15 @@ case class MediaFolder(id: Long, path: String, name: String, parentPath: Option[ Live implementation: - Generate unique path: folder/YYYY/MM/uuid-filename - Upload to storage backend -- If image: extract dimensions, generate thumbnail (store at path_thumb) - Insert metadata into database - Return MediaItem with public URL `./mill summercms.compile` succeeds -Migration file exists -ImageProcessor can resize a test image (manual test with sample JPEG) +Migration file exists and is syntactically valid -ImageProcessor provides resize/crop/thumbnail using Scrimage. MediaLibrary service handles uploads, metadata tracking, folder management. - - - - - Task 3: Create admin media upload routes - - summercms/src/api/admin/MediaRoutes.scala - summercms/src/api/Routes.scala - - -**MediaRoutes.scala:** -Create admin API routes for media operations: - -```scala -object MediaRoutes: - val routes: Routes[MediaLibrary & ImageProcessor, Response] = - Routes( - // List files in folder - Method.GET / "admin" / "api" / "media" / "files" -> handler { (req: Request) => - for - folder <- ZIO.succeed(req.url.queryParams.getOrElse("folder", "/")) - pageNum <- ZIO.succeed(req.url.queryParams.getOrElse("page", "1").toIntOption.getOrElse(1)) - library <- ZIO.service[MediaLibrary] - page <- library.list(folder, None, pageNum, 50) - yield Response.json(page.toJson) - }, - - // Upload file (multipart) - Method.POST / "admin" / "api" / "media" / "upload" -> handler { (req: Request) => - for - form <- req.body.asMultipartFormStream - folder <- extractField(form, "folder").orElseSucceed("/") - file <- extractFile(form, "file") - library <- ZIO.service[MediaLibrary] - item <- library.upload(folder, file.fileName, file.content, file.contentType) - yield Response.json(item.toJson).status(Status.Created) - }, - - // Resize image - Method.POST / "admin" / "api" / "media" / "resize" / long("id") -> handler { (id: Long, req: Request) => - for - body <- req.body.asJson[ResizeRequest] - library <- ZIO.service[MediaLibrary] - processor <- ZIO.service[ImageProcessor] - item <- library.get(id).someOrFail(MediaError.NotFound(id)) - // Fetch, resize, re-upload - content <- library.storage.get(item.path).flatMap(_.runCollect) - resized <- processor.resize(content.toArray, body.width, body.height) - newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(resized)), item.mimeType) - yield Response.json(newItem.toJson) - }, - - // Crop image - Method.POST / "admin" / "api" / "media" / "crop" / long("id") -> handler { (id: Long, req: Request) => - for - body <- req.body.asJson[CropRequest] - library <- ZIO.service[MediaLibrary] - processor <- ZIO.service[ImageProcessor] - item <- library.get(id).someOrFail(MediaError.NotFound(id)) - content <- library.storage.get(item.path).flatMap(_.runCollect) - cropped <- processor.crop(content.toArray, body.x, body.y, body.width, body.height) - newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(cropped)), item.mimeType) - yield Response.json(newItem.toJson) - }, - - // Delete files - Method.DELETE / "admin" / "api" / "media" / "files" -> handler { (req: Request) => - for - body <- req.body.asJson[DeleteRequest] - library <- ZIO.service[MediaLibrary] - _ <- library.delete(body.ids) - yield Response.ok - }, - - // Create folder - Method.POST / "admin" / "api" / "media" / "folders" -> handler { (req: Request) => - for - body <- req.body.asJson[CreateFolderRequest] - library <- ZIO.service[MediaLibrary] - folder <- library.createFolder(body.path) - yield Response.json(folder.toJson).status(Status.Created) - }, - - // List folders - Method.GET / "admin" / "api" / "media" / "folders" -> handler { (req: Request) => - for - parent <- ZIO.succeed(req.url.queryParams.get("parent")) - library <- ZIO.service[MediaLibrary] - folders <- library.listFolders(parent) - yield Response.json(folders.toJson) - } - ) - -case class ResizeRequest(width: Int, height: Int) -case class CropRequest(x: Int, y: Int, width: Int, height: Int) -case class DeleteRequest(ids: List[Long]) -case class CreateFolderRequest(path: String) -``` - -**Update Routes.scala:** -Add MediaRoutes to admin route composition. Ensure admin routes require authentication (from Phase 6). - -Security considerations: -- Validate file size limits (configurable, default 10MB) -- Validate mime types (whitelist images, documents) -- Sanitize file names (remove special chars, limit length) -- Path traversal protection in folder paths - - -`./mill summercms.compile` succeeds -Routes registered in main Routes composition -Test upload endpoint with curl multipart form - - -Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application. +MediaLibrary service handles uploads and metadata tracking. Database migration creates tables for files and folders. @@ -402,18 +228,15 @@ After all tasks complete: 1. `./mill summercms.compile` - all code compiles 2. Migration V010 ready for Flyway 3. Test local storage: upload file, verify on disk, retrieve via URL -4. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed -5. Test folder operations: create folder, list contents -6. curl -X POST -F "file=@test.jpg" -F "folder=/uploads" http://localhost:8080/admin/api/media/upload +4. Test folder operations: create folder, list contents -- Files upload via multipart POST to configurable storage backend -- LocalStorage writes to local filesystem +- Files upload via MediaLibrary to configurable storage backend +- LocalStorage writes to local filesystem with path traversal protection - S3Storage integrates with AWS S3 (compile-time only, runtime optional) -- ImageProcessor resizes/crops images using Scrimage with EXIF handling - Media metadata tracked in database -- Admin API provides full media management capabilities +- Folder hierarchy supported diff --git a/.planning/phases/09-content-management/09-03b-PLAN.md b/.planning/phases/09-content-management/09-03b-PLAN.md new file mode 100644 index 0000000..0ca54d7 --- /dev/null +++ b/.planning/phases/09-content-management/09-03b-PLAN.md @@ -0,0 +1,310 @@ +--- +phase: 09-content-management +plan: 03b +type: execute +wave: 2 +depends_on: ["09-03"] +files_modified: + - build.mill + - summercms/src/content/media/ImageProcessor.scala + - summercms/src/api/admin/MediaRoutes.scala + - summercms/src/api/Routes.scala +autonomous: true + +must_haves: + truths: + - "Images can be resized and cropped via ImageProcessor" + - "Admin can upload files through HTTP multipart endpoint" + - "Admin can resize and crop images through API" + - "Admin can delete media files through API" + artifacts: + - path: "summercms/src/content/media/ImageProcessor.scala" + provides: "Image resize/crop operations" + contains: "trait ImageProcessor" + - path: "summercms/src/api/admin/MediaRoutes.scala" + provides: "Admin media API routes" + contains: "object MediaRoutes" + key_links: + - from: "MediaRoutes" + to: "MediaLibrary.upload" + via: "HTTP multipart upload" + pattern: "MediaLibrary.*upload" + - from: "MediaRoutes.resize" + to: "ImageProcessor.resize" + via: "image processing call" + pattern: "processor\\.resize" +--- + + +Implement image processing and admin media upload routes + +Purpose: Enable image resize/crop operations and expose media library functionality through admin HTTP API. This plan builds on the core MediaLibrary from Plan 03. + +Output: ImageProcessor for resize/crop using Scrimage, admin upload routes with multipart handling. + + + +@/home/jin/.claude/get-shit-done/workflows/execute-plan.md +@/home/jin/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/09-content-management/09-RESEARCH.md +@.planning/phases/09-content-management/09-03-SUMMARY.md +@summercms/src/content/media/MediaLibrary.scala +@summercms/src/api/Routes.scala +@build.mill + + + + + + Task 1: Implement ImageProcessor using Scrimage + + build.mill + summercms/src/content/media/ImageProcessor.scala + + +**build.mill - Add Scrimage dependencies:** +```scala +// Image processing +mvn"com.sksamuel.scrimage::scrimage-core:4.1.0", +mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0", +``` + +**ImageProcessor.scala:** +Using Scrimage for image operations (per CONTEXT.md: resize and crop only): +```scala +trait ImageProcessor: + def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] + def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] + def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] + def dimensions(input: Array[Byte]): IO[ImageError, (Int, Int)] + +object ImageProcessor: + val live: ULayer[ImageProcessor] = ZLayer.succeed { + new ImageProcessor: + def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] = + ZIO.attemptBlocking { + ImmutableImage.loader() + .detectOrientation(true) // Handle EXIF rotation + .fromBytes(input) + .fit(width, height) + .bytes(JpegWriter.Default) + }.mapError(ImageError.ProcessingFailed(_)) + + def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] = + ZIO.attemptBlocking { + ImmutableImage.loader() + .detectOrientation(true) + .fromBytes(input) + .subimage(x, y, width, height) + .bytes(JpegWriter.Default) + }.mapError(ImageError.ProcessingFailed(_)) + + def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] = + ZIO.attemptBlocking { + ImmutableImage.loader() + .detectOrientation(true) + .fromBytes(input) + .bound(maxWidth, maxHeight) + .bytes(JpegWriter.Default) + }.mapError(ImageError.ProcessingFailed(_)) + } +``` + + +`./mill summercms.compile` succeeds with Scrimage dependencies +ImageProcessor can resize a test image (manual test with sample JPEG) + + +ImageProcessor provides resize/crop/thumbnail using Scrimage with EXIF orientation handling. + + + + + Task 2: Create admin media upload routes + + summercms/src/api/admin/MediaRoutes.scala + summercms/src/api/Routes.scala + + +**MediaRoutes.scala:** +Create admin API routes for media operations: + +```scala +object MediaRoutes: + val routes: Routes[MediaLibrary & ImageProcessor, Response] = + Routes( + // List files in folder + Method.GET / "admin" / "api" / "media" / "files" -> handler { (req: Request) => + for + folder <- ZIO.succeed(req.url.queryParams.getOrElse("folder", "/")) + pageNum <- ZIO.succeed(req.url.queryParams.getOrElse("page", "1").toIntOption.getOrElse(1)) + library <- ZIO.service[MediaLibrary] + page <- library.list(folder, None, pageNum, 50) + yield Response.json(page.toJson) + }, + + // Upload file (multipart) + Method.POST / "admin" / "api" / "media" / "upload" -> handler { (req: Request) => + for + form <- req.body.asMultipartFormStream + folder <- extractField(form, "folder").orElseSucceed("/") + file <- extractFile(form, "file") + library <- ZIO.service[MediaLibrary] + item <- library.upload(folder, file.fileName, file.content, file.contentType) + yield Response.json(item.toJson).status(Status.Created) + }, + + // Resize image + Method.POST / "admin" / "api" / "media" / "resize" / long("id") -> handler { (id: Long, req: Request) => + for + body <- req.body.asJson[ResizeRequest] + library <- ZIO.service[MediaLibrary] + processor <- ZIO.service[ImageProcessor] + item <- library.get(id).someOrFail(MediaError.NotFound(id)) + // Fetch, resize, re-upload + content <- library.storage.get(item.path).flatMap(_.runCollect) + resized <- processor.resize(content.toArray, body.width, body.height) + newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(resized)), item.mimeType) + yield Response.json(newItem.toJson) + }, + + // Crop image + Method.POST / "admin" / "api" / "media" / "crop" / long("id") -> handler { (id: Long, req: Request) => + for + body <- req.body.asJson[CropRequest] + library <- ZIO.service[MediaLibrary] + processor <- ZIO.service[ImageProcessor] + item <- library.get(id).someOrFail(MediaError.NotFound(id)) + content <- library.storage.get(item.path).flatMap(_.runCollect) + cropped <- processor.crop(content.toArray, body.x, body.y, body.width, body.height) + newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(cropped)), item.mimeType) + yield Response.json(newItem.toJson) + }, + + // Delete files + Method.DELETE / "admin" / "api" / "media" / "files" -> handler { (req: Request) => + for + body <- req.body.asJson[DeleteRequest] + library <- ZIO.service[MediaLibrary] + _ <- library.delete(body.ids) + yield Response.ok + }, + + // Create folder + Method.POST / "admin" / "api" / "media" / "folders" -> handler { (req: Request) => + for + body <- req.body.asJson[CreateFolderRequest] + library <- ZIO.service[MediaLibrary] + folder <- library.createFolder(body.path) + yield Response.json(folder.toJson).status(Status.Created) + }, + + // List folders + Method.GET / "admin" / "api" / "media" / "folders" -> handler { (req: Request) => + for + parent <- ZIO.succeed(req.url.queryParams.get("parent")) + library <- ZIO.service[MediaLibrary] + folders <- library.listFolders(parent) + yield Response.json(folders.toJson) + } + ) + +case class ResizeRequest(width: Int, height: Int) +case class CropRequest(x: Int, y: Int, width: Int, height: Int) +case class DeleteRequest(ids: List[Long]) +case class CreateFolderRequest(path: String) +``` + +**Update Routes.scala:** +Add MediaRoutes to admin route composition. Ensure admin routes require authentication (from Phase 6). + +Security considerations: +- Validate file size limits (configurable, default 10MB) +- Validate mime types (whitelist images, documents) +- Sanitize file names (remove special chars, limit length) +- Path traversal protection in folder paths + + +`./mill summercms.compile` succeeds +Routes registered in main Routes composition + +Test upload endpoint: +```bash +curl -X POST http://localhost:8080/admin/api/media/upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@test.jpg" \ + -F "folder=/uploads" +``` + +Expected response (HTTP 201): +```json +{ + "id": 1, + "path": "uploads/2024/01/abc123-test.jpg", + "publicUrl": "/media/uploads/2024/01/abc123-test.jpg", + "fileName": "test.jpg", + "size": 12345, + "mimeType": "image/jpeg", + "folder": "/uploads", + "itemType": "Image", + "width": 800, + "height": 600, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +Test list endpoint: +```bash +curl http://localhost:8080/admin/api/media/files?folder=/uploads \ + -H "Authorization: Bearer $TOKEN" +``` + +Expected response (HTTP 200): +```json +{ + "items": [...], + "total": 1, + "page": 1, + "perPage": 50 +} +``` + + +Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application and protected by admin authentication. + + + + + + +After all tasks complete: +1. `./mill summercms.compile` - all code compiles +2. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed +3. Test upload with curl: + ```bash + curl -X POST http://localhost:8080/admin/api/media/upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@test.jpg" \ + -F "folder=/uploads" + ``` + Expect: HTTP 201 with JSON containing id, path, publicUrl, mimeType, dimensions +4. Test folder creation and listing + + + +- ImageProcessor resizes/crops images using Scrimage with EXIF handling +- Upload endpoint accepts multipart form with file and folder fields +- Upload returns JSON with complete MediaItem including id, path, publicUrl +- Resize endpoint creates new resized version of image +- All routes protected by admin authentication + + + +After completion, create `.planning/phases/09-content-management/09-03b-SUMMARY.md` + diff --git a/.planning/phases/09-content-management/09-05-PLAN.md b/.planning/phases/09-content-management/09-05-PLAN.md index bf9fd1d..4003861 100644 --- a/.planning/phases/09-content-management/09-05-PLAN.md +++ b/.planning/phases/09-content-management/09-05-PLAN.md @@ -13,6 +13,7 @@ files_modified: - summercms/resources/db/migration/V012__navigation_menus.sql - summercms/src/api/admin/MenuRoutes.scala - summercms/src/content/cms/pebble/MenuFunction.scala + - summercms/test/content/menu/MenuPluginIntegrationSpec.scala autonomous: true must_haves: @@ -20,7 +21,7 @@ must_haves: - "Admin can create navigation menus with unique codes" - "Menu items support URL, CMS page reference, and plugin-generated types" - "Menu items can be nested (parent-child hierarchy)" - - "Plugins can register custom menu item types" + - "Plugins can register custom menu item types that render correctly" - "Templates can access menus via Pebble function" artifacts: - path: "summercms/src/content/menu/Menu.scala" @@ -35,6 +36,9 @@ must_haves: - path: "summercms/resources/db/migration/V012__navigation_menus.sql" provides: "Menu and item tables" contains: "CREATE TABLE menus" + - path: "summercms/test/content/menu/MenuPluginIntegrationSpec.scala" + provides: "Plugin integration test" + contains: "MenuPluginIntegrationSpec" key_links: - from: "MenuService.resolve" to: "MenuItemTypeRegistry" @@ -44,6 +48,10 @@ must_haves: to: "MenuService" via: "Pebble function fetches resolved menu" pattern: "menuService\\.getResolved" + - from: "Plugin registration" + to: "MenuItemTypeRegistry.register" + via: "plugin boot registers custom type" + pattern: "registry\\.register" --- @@ -51,7 +59,7 @@ Implement navigation menus with extensible item types Purpose: Enable admin to create and manage navigation menus. Menu items support static URLs, CMS page references (dynamic), and plugin-generated items (extensible). Menus accessible in templates via Pebble function. -Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function. +Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function, plugin integration test. @@ -327,11 +335,12 @@ MenuItemTypeRegistry allows plugins to register custom item types. MenuService r - Task 3: Create admin routes and Pebble menu function + Task 3: Create admin routes, Pebble function, and plugin integration test summercms/src/api/admin/MenuRoutes.scala summercms/src/content/cms/pebble/MenuFunction.scala summercms/src/content/cms/pebble/CmsPebbleExtension.scala + summercms/test/content/menu/MenuPluginIntegrationSpec.scala **MenuRoutes.scala:** @@ -341,88 +350,33 @@ object MenuRoutes: val routes: Routes[MenuService, Response] = Routes( // List all menus - Method.GET / "admin" / "api" / "menus" -> handler { (req: Request) => - for - service <- ZIO.service[MenuService] - menus <- service.listMenus - yield Response.json(menus.toJson) - }, + Method.GET / "admin" / "api" / "menus" -> handler { ... }, // Get menu with items - Method.GET / "admin" / "api" / "menus" / string("code") -> handler { (code: String, req: Request) => - for - service <- ZIO.service[MenuService] - menu <- service.getMenu(code) - items <- menu.fold(ZIO.succeed(List.empty[MenuItem]))(m => - ZIO.serviceWithZIO[MenuRepository](_.listItems(m.id)) - ) - yield menu match - case Some(m) => Response.json(MenuWithItems(m, items).toJson) - case None => Response.status(Status.NotFound) - }, + Method.GET / "admin" / "api" / "menus" / string("code") -> handler { ... }, // Create menu - Method.POST / "admin" / "api" / "menus" -> handler { (req: Request) => - for - body <- req.body.asJson[CreateMenuRequest] - service <- ZIO.service[MenuService] - menu <- service.createMenu(body.code, body.name) - yield Response.json(menu.toJson).status(Status.Created) - }, + Method.POST / "admin" / "api" / "menus" -> handler { ... }, // Update menu - Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) => - for - body <- req.body.asJson[UpdateMenuRequest] - service <- ZIO.service[MenuService] - menu <- service.updateMenu(id, body.name) - yield Response.json(menu.toJson) - }, + Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { ... }, // Delete menu - Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) => - for - service <- ZIO.service[MenuService] - _ <- service.deleteMenu(id) - yield Response.ok - }, + Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { ... }, // Add menu item - Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { (menuId: Long, req: Request) => - for - body <- req.body.asJson[CreateItemRequest] - service <- ZIO.service[MenuService] - item <- service.addItem(menuId, body.toMenuItem(menuId)) - yield Response.json(item.toJson).status(Status.Created) - }, + Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { ... }, // Update menu item - Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) => - for - body <- req.body.asJson[UpdateItemRequest] - service <- ZIO.service[MenuService] - item <- service.updateItem(body.toMenuItem(id)) - yield Response.json(item.toJson) - }, + Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... }, // Delete menu item - Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) => - for - service <- ZIO.service[MenuService] - _ <- service.removeItem(id) - yield Response.ok - }, + Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... }, // Reorder items - Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { (menuId: Long, req: Request) => - for - body <- req.body.asJson[ReorderRequest] - service <- ZIO.service[MenuService] - _ <- service.reorderItems(menuId, body.itemOrders) - yield Response.ok - }, + Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { ... }, - // List available item types + // List available item types (includes plugin-registered types) Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) => for registry <- ZIO.service[MenuItemTypeRegistry] @@ -465,25 +419,124 @@ class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Func ``` **Update CmsPebbleExtension.scala:** -Register MenuFunction: -```scala -class CmsPebbleExtension(menuService: MenuService, runtime: Runtime[Any]) extends Extension: - override def getFunctions: java.util.Map[String, Function] = - java.util.Map.of( - "menu", new MenuFunction(menuService, runtime) - ) - // ... existing token parsers -``` +Register MenuFunction (constructor now needs MenuService). -Note: Extension now needs MenuService injected at construction time. +**MenuPluginIntegrationSpec.scala:** +Test demonstrating plugin integration (CONT-09 requirement): +```scala +class MenuPluginIntegrationSpec extends ZIOSpecDefault: + // Example plugin-provided menu item type: "blog-category" + // Resolves blog category IDs to category pages + + class BlogCategoryResolver(blogService: BlogService) extends MenuItemResolver: + def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem] = + for + categoryId <- ZIO.fromOption(item.reference.flatMap(_.toLongOption)) + .orElseFail(MenuError.InvalidReference(item.id, "Expected category ID")) + category <- blogService.getCategory(categoryId) + .someOrFail(MenuError.ReferenceNotFound(item.id, s"Category $categoryId")) + url = s"/blog/category/${category.slug}" + yield ResolvedMenuItem( + title = item.title.getOrElse(category.name), + url = url, + isActive = currentUrl == url || currentUrl.startsWith(s"$url/"), + cssClass = item.cssClass, + items = List.empty + ) + + def spec = suite("MenuPluginIntegration")( + test("plugin can register custom menu item type") { + for + registry <- ZIO.service[MenuItemTypeRegistry] + blogService = TestBlogService() + resolver = new BlogCategoryResolver(blogService) + + // Plugin registers its type at boot + _ <- registry.register("blog-category", resolver) + + // Verify type is registered + types <- registry.listTypes + _ <- assertTrue(types.contains("blog-category")) + + // Verify type appears in admin API + // GET /admin/api/menus/item-types should include "blog-category" + yield assertTrue(types.contains("url"), types.contains("cms-page"), types.contains("blog-category")) + }, + + test("plugin-registered type resolves correctly in menu") { + for + registry <- ZIO.service[MenuItemTypeRegistry] + menuService <- ZIO.service[MenuService] + blogService = TestBlogService.withCategory(1L, "Tech", "tech") + resolver = new BlogCategoryResolver(blogService) + + _ <- registry.register("blog-category", resolver) + + // Create menu with plugin item type + menu <- menuService.createMenu("test-menu", "Test Menu") + _ <- menuService.addItem(menu.id, MenuItem( + id = 0, + menuId = menu.id, + parentId = None, + itemType = "blog-category", // Plugin-provided type + title = None, // Will inherit from category + reference = Some("1"), // Category ID + cssClass = None, + nestDepth = 0, + sortOrder = 0, + isHidden = false + )) + + // Resolve menu - plugin type should work + resolved <- menuService.getResolved("test-menu", "/other") + yield assertTrue( + resolved.isDefined, + resolved.get.items.head.title == "Tech", + resolved.get.items.head.url == "/blog/category/tech", + resolved.get.items.head.isActive == false + ) + }, + + test("plugin type renders correctly in Pebble template") { + for + registry <- ZIO.service[MenuItemTypeRegistry] + menuService <- ZIO.service[MenuService] + blogService = TestBlogService.withCategory(1L, "Tech", "tech") + + _ <- registry.register("blog-category", new BlogCategoryResolver(blogService)) + + // Setup menu with plugin item + menu <- menuService.createMenu("nav", "Navigation") + _ <- menuService.addItem(menu.id, MenuItem(/* blog-category type */)) + + // Render template using menu + template = """ + {% set nav = menu('nav') %} + {% for item in nav.items %} + {{ item.title }} + {% endfor %} + """ + rendered <- renderPebbleTemplate(template, Map("currentUrl" -> "/")) + yield assertTrue( + rendered.contains("""Tech""") + ) + } + ).provide( + MenuItemTypeRegistry.live, + MenuService.live, + MenuRepository.live, + // ... test dependencies + ) +``` `./mill summercms.compile` succeeds -MenuRoutes provide full CRUD +`./mill summercms.test` - MenuPluginIntegrationSpec passes menu('main') function works in templates +Plugin-registered type appears in item-types API endpoint -Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus with active state detection. Item types extensible via registry. +Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus. Plugin integration test demonstrates custom menu item type registration, resolution, and template rendering. @@ -492,21 +545,24 @@ Admin menu routes provide full CRUD for menus and items. Pebble menu() function After all tasks complete: 1. `./mill summercms.compile` - all code compiles -2. Migration V012 ready for Flyway -3. Test menu CRUD: +2. `./mill summercms.test` - plugin integration tests pass +3. Migration V012 ready for Flyway +4. Test menu CRUD: - Create menu with code "test" - Add URL item, CMS page item - Nest items under parent - Reorder items -4. Test menu resolution: +5. Test menu resolution: - GET /admin/api/menus/main returns menu with items - Items have correct URLs and active state -5. Test Pebble function: +6. Test Pebble function: - Template with `{% set nav = menu('main') %}` resolves menu - Loop over items renders navigation -6. Test extensibility: - - Plugin can register custom item type - - Custom type appears in item-types list +7. Test plugin extensibility (CONT-09): + - Plugin can register custom item type via registry + - Custom type appears in GET /admin/api/menus/item-types + - Menu items with custom type resolve correctly + - Custom type renders correctly in Pebble template @@ -515,8 +571,12 @@ After all tasks complete: - Nested items maintain parent-child hierarchy - Resolved menu includes active state for current URL - Plugins can register custom menu item types via registry +- Plugin-registered types appear in item-types API +- Plugin types resolve to valid ResolvedMenuItem with correct URLs +- Plugin types render correctly in Pebble templates - Templates access menus via menu('code') Pebble function - Admin API provides full menu management +- Integration test proves end-to-end plugin type functionality diff --git a/.planning/phases/09-content-management/09-06-PLAN.md b/.planning/phases/09-content-management/09-06-PLAN.md index 512a153..db06ff9 100644 --- a/.planning/phases/09-content-management/09-06-PLAN.md +++ b/.planning/phases/09-content-management/09-06-PLAN.md @@ -14,14 +14,14 @@ autonomous: true must_haves: truths: - - "Template file changes trigger Pebble cache invalidation" - - "Page/layout/partial modifications reflect immediately in browser" - - "Hot reload only active in development mode" - - "File watcher uses OS-native WatchService (not polling)" - - "Cache invalidation is targeted (not full cache clear)" + - "Developer edits template file, browser refresh shows changes without server restart" + - "Page/layout/partial modifications reflect immediately after save" + - "Hot reload only active when DEV_MODE environment variable is true" + - "File system changes detected efficiently without CPU overhead" + - "Only changed template is reloaded, not the entire cache" artifacts: - path: "summercms/src/hot/FileWatcher.scala" - provides: "File change detection using WatchService" + provides: "File change detection" contains: "class FileWatcher" - path: "summercms/src/hot/HotReloadService.scala" provides: "Reload orchestration service" @@ -500,12 +500,11 @@ After all tasks complete: - Hot reload only active when DEV_MODE=true and HOT_RELOAD=true -- FileWatcher uses OS-native WatchService (not polling) -- Template changes invalidate specific template cache entry -- Page changes also trigger router rebuild -- Changes reflect immediately without JVM restart +- Template file saved -> changes visible on next browser refresh (no restart) +- Targeted invalidation: only changed template reloaded, not all templates +- Page changes also trigger router rebuild for URL changes - Production mode uses full caching for performance -- Event debouncing prevents rapid reload spam +- Event debouncing prevents rapid reload spam during fast typing