Repository: ervinhun/Belman
Pull request #61: report fix adding date, photo angle and page number
Author: login: ervinhun
Created: 2025-05-19T14:58:01Z | Merged: 2025-05-19T15:43:41Z



Diff:
diff --git a/src/main/java/dk/easv/belman/be/PhotoDataForReport.java b/src/main/java/dk/easv/belman/be/PhotoDataForReport.java
new file mode 100644
index 0000000..c812958
--- /dev/null
+++ b/src/main/java/dk/easv/belman/be/PhotoDataForReport.java
@@ -0,0 +1,22 @@
+package dk.easv.belman.be;
+
+import java.awt.image.BufferedImage;
+
+public class PhotoDataForReport {
+    BufferedImage image;
+    String angle;
+
+    public PhotoDataForReport(BufferedImage image, String angle) {
+        this.image = image;
+        this.angle = angle;
+    }
+
+    public BufferedImage getImage() {
+        return image;
+    }
+
+    public String getAngle() {
+        return angle;
+    }
+
+}
diff --git a/src/main/java/dk/easv/belman/dal/GenerateReport.java b/src/main/java/dk/easv/belman/dal/GenerateReport.java
index 20456b0..821584a 100644
--- a/src/main/java/dk/easv/belman/dal/GenerateReport.java
+++ b/src/main/java/dk/easv/belman/dal/GenerateReport.java
@@ -1,6 +1,7 @@
 package dk.easv.belman.dal;
 
 import dk.easv.belman.be.Photo;
+import dk.easv.belman.be.PhotoDataForReport;
 import dk.easv.belman.be.User;
 import dk.easv.belman.exceptions.BelmanException;
 import org.apache.pdfbox.pdmodel.PDDocument;
@@ -19,10 +20,10 @@
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
-import java.nio.file.*;
 import java.security.GeneralSecurityException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -34,15 +35,20 @@
 
 public class GenerateReport {
     private static final Logger logger = LoggerFactory.getLogger(GenerateReport.class);
+
     static {
         new DALManager();
     }
+
     private final String productNo;
     private static final String FILE_NAME = "report.pdf";
     private boolean isSendingEmail;
     private String email;
-
-
+    private static final float LINE_SPACING = 15f;
+    private static final float IMAGE_SPACING = 20f;
+    private static final float ANGLE_LABEL_X_OFFSET = 50f;
+    private static final float ANGLE_LABEL_Y_OFFSET = 10f;
+    private static final String DATE_FORMAT_PATTERN = "dd/MM/yyyy";
 
 
     public GenerateReport(String productNumber, User loggedInUser, boolean isSendingEmail, String email) {
@@ -66,12 +72,16 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             return;
         }
 
-        List<BufferedImage> bufferedImages = photos.stream()
+        List<PhotoDataForReport> bufferedImages = photos.stream()
                 .map(p -> {
                     try {
-                        return ImageIO.read(
-                                new ByteArrayInputStream(p.getPhotoFile())   // or p.getData()
-                        );
+                                BufferedImage img = ImageIO.read(new ByteArrayInputStream(p.getPhotoFile()));
+                                if (img != null)
+                                    return new PhotoDataForReport(img, p.getAngle());
+                                else {
+                                    logger.error("Image is null for angle: {}", p.getAngle());
+                                    return null;
+                                }
                     } catch (IOException e) {
                         logger.error("Failed to decode image blob for angle {}: {}", p.getAngle(), e.getMessage());
                         return null;
@@ -106,7 +116,7 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             float bodyTextFontSize = 12;
             float headerFontSize = 10;
             PDType1Font titleFont = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
-
+            PDType1Font imageAngle = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
             PDType1Font dynamicFont = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
             // Set header
             contentStream.beginText();
@@ -114,7 +124,7 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             contentStream.newLineAtOffset(480, 810);
             for (String headTextLine : headText) {
                 contentStream.showText(headTextLine);
-                contentStream.newLineAtOffset(0, -15);
+                contentStream.newLineAtOffset(0, -LINE_SPACING);
             }
             contentStream.endText();
             // Calculate width of the title
@@ -134,14 +144,14 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             contentStream.setFont(titleFont, titleFontSize);
             contentStream.newLineAtOffset(centerXTitle, 720);
             contentStream.showText(title);
-            currentYPosition = 720 - titleFontSize - 20;
+            currentYPosition = 720 - titleFontSize - IMAGE_SPACING;
 
             // Set font and add order number line
-            contentStream.newLineAtOffset(centerXDynamic - centerXTitle, -20);
+            contentStream.newLineAtOffset(centerXDynamic - centerXTitle, -IMAGE_SPACING);
             contentStream.setFont(dynamicFont, productNoFontSize);
             contentStream.showText(dynamicText);
             contentStream.endText();
-            currentYPosition -= productNoFontSize + 20;
+            currentYPosition -= productNoFontSize + IMAGE_SPACING;
 
 
             // Add body text
@@ -150,41 +160,56 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             contentStream.newLineAtOffset(100, currentYPosition);
             for (String bodyTextLine : bodyText) {
                 contentStream.showText(bodyTextLine);
-                contentStream.newLineAtOffset(0, -15);
-                currentYPosition -= bodyTextFontSize + 15;
+                contentStream.newLineAtOffset(0, -LINE_SPACING);
+                currentYPosition -= bodyTextFontSize + LINE_SPACING;
             }
             contentStream.endText();
 
             currentYPosition -= 10;
 
             // Add image
-            for (BufferedImage bImage : bufferedImages) {
-
+            for (PhotoDataForReport photoData : bufferedImages) {
                 // Create PDImageXObject from BufferedImage
-            PDImageXObject pdImage = LosslessFactory.createFromImage(document, bImage);
-            float originalWidth = pdImage.getWidth();
-            float originalHeight = pdImage.getHeight();
+                PDImageXObject pdImage = LosslessFactory.createFromImage(document, photoData.getImage());
+                float originalWidth = pdImage.getWidth();
+                float originalHeight = pdImage.getHeight();
+
+                // Scale to fit available width
+                float scale = availableWidth / originalWidth;
+                float scaledWidth = originalWidth * scale;
+                float scaledHeight = originalHeight * scale;
+
+                // Add new page if not enough space
+                if (currentYPosition - scaledHeight < minY) {
+                    contentStream.close();
+                    page = new PDPage(PDRectangle.A4);
+                    document.addPage(page);
+                    contentStream = new PDPageContentStream(document, page);
+                    currentYPosition = page.getMediaBox().getHeight() - margin;
+                }
 
-            // Scale to fit available width
-            float scale = availableWidth / originalWidth;
-            float scaledWidth = originalWidth * scale;
-            float scaledHeight = originalHeight * scale;
+                // Draw the scaled image
+                contentStream.drawImage(pdImage, margin, currentYPosition - scaledHeight, scaledWidth, scaledHeight);
+                // Add image angle
+                String angleText = "Angle: " + photoData.getAngle();
+                contentStream.beginText();
+                contentStream.setFont(imageAngle, 10);
+                contentStream.newLineAtOffset(margin + scaledWidth - ANGLE_LABEL_X_OFFSET,
+                        currentYPosition - scaledHeight - ANGLE_LABEL_Y_OFFSET);
+                contentStream.showText(angleText);
+                contentStream.endText();
+                // Update current Y position
 
-            // Add new page if not enough space
-            if (currentYPosition - scaledHeight < minY) {
-                contentStream.close();
-                page = new PDPage(PDRectangle.A4);
-                document.addPage(page);
-                contentStream = new PDPageContentStream(document, page);
-                currentYPosition = page.getMediaBox().getHeight() - margin;
-            }
 
-            // Draw the scaled image
-            contentStream.drawImage(pdImage, margin, currentYPosition - scaledHeight, scaledWidth, scaledHeight);
-            currentYPosition -= scaledHeight + 20; // spacing between images
-        }
+                currentYPosition -= scaledHeight + IMAGE_SPACING + 20; // spacing between images 10 for font size and 10 more for spacing between the image and the angle
+            }
             // Add footer
-            float footerHeight = 35; // font size 10 * 2 (because it's two lines) plus 15 for spacing
+            final float FONT_SIZE = 10;
+            final int NUM_LINES = 3;
+            float footerHeight = (FONT_SIZE * NUM_LINES) + (2 * LINE_SPACING);
+            LocalDateTime dateTime = LocalDateTime.now();
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT_PATTERN);
+            String formattedDate = dateTime.format(formatter);
             if (currentYPosition - footerHeight < margin) {
                 contentStream.close();
                 page = new PDPage(PDRectangle.A4);
@@ -198,8 +223,10 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             contentStream.setFont(dynamicFont, 10);
             contentStream.newLineAtOffset(400, footerY);
             contentStream.showText(signedBy);
-            contentStream.newLineAtOffset(50, -15);
+            contentStream.newLineAtOffset(50, -LINE_SPACING);
             contentStream.showText(loggedInUser.getFullName());
+            contentStream.newLineAtOffset(0, -LINE_SPACING);
+            contentStream.showText("Date: " + formattedDate);
             contentStream.endText();
 
             contentStream.close();
@@ -208,8 +235,8 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             if (!targetDir.exists()) {
                 targetDir.mkdirs();
             }
-            sleepForFileCreation(filePath + "/" + FILE_NAME);
-            document.save(filePath + "/" + FILE_NAME);
+            PDDocument documentToSave = addPageNumber(document);
+            documentToSave.save(filePath + "/" + FILE_NAME);
             openDocument(productNo);
 
         } catch (IOException e) {
@@ -218,34 +245,9 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
             logger.error("Illegal state encountered while generating PDF: {}", e.getMessage());
         } catch (SecurityException e) {
             logger.error("Security exception while accessing file system: {}", e.getMessage());
-            } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            logger.error("Thread was interrupted while creating the file: {}", e.getMessage());
         }
     }
 
-    private void sleepForFileCreation(String filepath) throws IOException, InterruptedException {
-        File file = new File(filepath);
-        if (file.exists()) {
-            return;
-        }
-        Path directory = file.getParentFile().toPath();
-        try (WatchService watchService = directory.getFileSystem().newWatchService()) {
-            directory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE);
-            while (true) {
-                WatchKey key = watchService.take();
-                for (WatchEvent<?> event : key.pollEvents()) {
-                    Path createdFile = directory.resolve((Path) event.context());
-                    if (createdFile.toFile().equals(file)) {
-                        return;
-                    }
-                }
-                if (!key.reset()) {
-                    break;
-                }
-            }
-        }
-    }
 
     public String getFilePath() {
         return FilePaths.REPORT_DIRECTORY + productNo + "/" + FILE_NAME;
@@ -253,7 +255,7 @@ public String getFilePath() {
 
     public void openDocument(String productNumber) {
         File pdfFile = new File(FilePaths.BASE_PATH + "report/" + productNumber + "/report.pdf");
-        if (Desktop.isDesktopSupported() && pdfFile.exists()) {
+        if (Desktop.isDesktopSupported() && pdfFile.exists() && !GraphicsEnvironment.isHeadless()) {
             try {
                 Desktop.getDesktop().open(pdfFile);
             } catch (IOException e) {
@@ -281,4 +283,32 @@ public void openDocument(String productNumber) {
             }
         }
     }
+
+
+    private PDDocument addPageNumber(PDDocument document) {
+        PDType1Font pageNumberFont = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+        int pageCount = document.getNumberOfPages();
+
+        for (int i = 0; i < pageCount; i++) {
+            PDPage currentPage = document.getPage(i);
+            String pageNumberText = "Page " + (i + 1) + " of " + pageCount;
+            float fontSize = 10;
+            try {
+                PDPageContentStream pageContentStream = new PDPageContentStream(document, currentPage, PDPageContentStream.AppendMode.APPEND, true, true);
+                float stringWidth = pageNumberFont.getStringWidth(pageNumberText) / 1000 * fontSize;
+                float pageWidth = currentPage.getMediaBox().getWidth();
+                float x = (pageWidth - stringWidth) / 2;
+                float y = 20; // 20 units from the bottom
+                pageContentStream.beginText();
+                pageContentStream.setFont(pageNumberFont, fontSize);
+                pageContentStream.newLineAtOffset(x, y);
+                pageContentStream.showText(pageNumberText);
+                pageContentStream.endText();
+                pageContentStream.close();
+            } catch (IOException e) {
+                logger.error("Error drawing the font for page number: {}", e.getMessage());
+            }
+        }
+        return document;
+    }
 }
\ No newline at end of file
diff --git a/src/main/java/dk/easv/belman/dal/GmailService.java b/src/main/java/dk/easv/belman/dal/GmailService.java
index c69a95a..1cee023 100644
--- a/src/main/java/dk/easv/belman/dal/GmailService.java
+++ b/src/main/java/dk/easv/belman/dal/GmailService.java
@@ -5,7 +5,7 @@
 import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
 import com.google.api.client.http.javanet.NetHttpTransport;
 import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.jackson2.JacksonFactory;
+import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.util.store.FileDataStoreFactory;
 import com.google.api.services.gmail.Gmail;
 import com.google.api.services.gmail.model.Message;
@@ -27,25 +27,25 @@
 
 public class GmailService {
     private static final String APPLICATION_NAME = "GmailFXSender";
-    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
+    private static final JsonFactory GSON_FACTORY = GsonFactory.getDefaultInstance();
     private static final List<String> SCOPES = Collections.singletonList("https://www.googleapis.com/auth/gmail.send");
     private static final String TOKENS_DIRECTORY_PATH = "tokens";
 
     private final Gmail service;
 
     public GmailService() throws BelmanException, GeneralSecurityException, IOException {
-        final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
+        NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
         InputStream in = getClass().getResourceAsStream("/credential.json");
         if (in == null) {
             throw new FileNotFoundException("credential.json not found");
         }
-        GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
+        GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(GSON_FACTORY, new InputStreamReader(in));
         GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
-                HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
+                httpTransport, GSON_FACTORY, clientSecrets, SCOPES)
                 .setDataStoreFactory(new FileDataStoreFactory(new File(TOKENS_DIRECTORY_PATH)))
                 .setAccessType("offline")
                 .build();
-        service = new Gmail.Builder(HTTP_TRANSPORT, JSON_FACTORY,
+        service = new Gmail.Builder(httpTransport, GSON_FACTORY,
                 new com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp(
                         flow, new com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver()).authorize("user"))
                 .setApplicationName(APPLICATION_NAME)
@@ -77,7 +77,6 @@ public void sendEmailWithAttachment(String userId, String to, String subject,
         ByteArrayOutputStream buffer = new ByteArrayOutputStream();
         email.writeTo(buffer);
         byte[] rawMessageBytes = buffer.toByteArray();
-        //String encodedEmail = com.google.api.client.util.Base64.encodeBase64URLSafeString(rawMessageBytes);
         String encodedEmail = Base64.getUrlEncoder().encodeToString(rawMessageBytes);
 
         Message message = new Message();
diff --git a/src/main/resources/dk/easv/belman/report/TEST-001/report.pdf b/src/main/resources/dk/easv/belman/report/TEST-001/report.pdf
new file mode 100644
index 0000000..5215458
Binary files /dev/null and b/src/main/resources/dk/easv/belman/report/TEST-001/report.pdf differ


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR replaces the JSON parser in `GmailService` and enriches the PDF report generation by adding photo angle annotations, a date footer, and page numbering, while cleaning up unused logic and improving headless checks.

- Swapped `JacksonFactory` for `GsonFactory` in `GmailService`
- Introduced spacing constants and removed file-watch logic in `GenerateReport`
- Added photo angle labels, report date, page numbers, and a headless check

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

| File                      | Description                                                                                   |
| ------------------------- | --------------------------------------------------------------------------------------------- |
| GmailService.java         | Replaced `JacksonFactory` with `GsonFactory`, renamed constants and variables accordingly      |
| GenerateReport.java       | Added `LINE_SPACING`/`IMAGE_SPACING`, photo angle labels, date footer, page numbers, removed `sleepForFileCreation`, updated headless check |


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/main/java/dk/easv/belman/dal/GenerateReport.java:113**
* [nitpick] Variable name `imageAngle` suggests an angle value but it represents a font. Consider renaming to `imageAngleFont` for clarity.
```
PDType1Font imageAngle = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
```
**src/main/java/dk/easv/belman/dal/GenerateReport.java:282**
* The new page numbering logic isn't covered by tests. Consider adding unit tests to verify correct numbering for single- and multi-page documents.
```
private PDDocument addPageNumber (PDDocument document) {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T15:00:42Z
user: login: tamazs
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-19T15:05:13Z
user: login: OleksandrDarchyk
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-19T15:12:24Z

Review comments:
diff_hunk: @@ -181,10 +185,25 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
 
             // Draw the scaled image
             contentStream.drawImage(pdImage, margin, currentYPosition - scaledHeight, scaledWidth, scaledHeight);
-            currentYPosition -= scaledHeight + 20; // spacing between images
+            // Add image angle
+            String angleText = "Angle: " + photos.get(bufferedImages.indexOf(bImage)).getAngle();
path: src/main/java/dk/easv/belman/dal/GenerateReport.java
user: login: Copilot
body: Using `bufferedImages.indexOf(bImage)` inside the loop causes O(n²) complexity and may mis-map duplicates. Iterate with an index or track the current index instead.
created_at: 2025-05-19T15:00:41Z
author_association: NONE
diff_hunk: @@ -181,10 +185,25 @@ public GenerateReport(String productNumber, User loggedInUser, boolean isSending
 
             // Draw the scaled image
             contentStream.drawImage(pdImage, margin, currentYPosition - scaledHeight, scaledWidth, scaledHeight);
-            currentYPosition -= scaledHeight + 20; // spacing between images
+            // Add image angle
+            String angleText = "Angle: " + photos.get(bufferedImages.indexOf(bImage)).getAngle();
+            contentStream.beginText();
+            contentStream.setFont(imageAngle, 10);
+            contentStream.newLineAtOffset(margin + scaledWidth - 50, currentYPosition - scaledHeight - 10);
path: src/main/java/dk/easv/belman/dal/GenerateReport.java
user: login: Copilot
body: [nitpick] Magic numbers `-50` and `-10` reduce readability. Define constants (e.g., `ANGLE_LABEL_X_OFFSET`, `ANGLE_LABEL_Y_OFFSET`) to document their meaning and simplify spacing tweaks.
```suggestion
            contentStream.newLineAtOffset(margin + scaledWidth + ANGLE_LABEL_X_OFFSET, currentYPosition - scaledHeight + ANGLE_LABEL_Y_OFFSET);
```
created_at: 2025-05-19T15:00:42Z
author_association: NONE
diff_hunk: @@ -281,4 +277,33 @@ public void openDocument(String productNumber) {
             }
         }
     }
+
+
+    private PDDocument addPageNumber (PDDocument document) {
path: src/main/java/dk/easv/belman/dal/GenerateReport.java
user: login: Copilot
body: [nitpick] Remove the extra space before the parenthesis in the method declaration (`addPageNumber(PDDocument document)`) to maintain formatting consistency.
```suggestion
    private PDDocument addPageNumber(PDDocument document) {
```
created_at: 2025-05-19T15:00:42Z
author_association: NONE

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T15:29:13Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=ervinhun_Belman&pullRequest=61) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ervinhun_Belman&pullRequest=61&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ervinhun_Belman&pullRequest=61)


author_association: NONE