diff --git a/pom.xml b/pom.xml index 9cf429c..5748a4f 100755 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,11 @@ org.postgresql postgresql - 42.7.8 + + + + org.xerial + sqlite-jdbc diff --git a/src/main/java/org/zaine/app/controller/CalibreController.java b/src/main/java/org/zaine/app/controller/CalibreController.java new file mode 100644 index 0000000..32571bf --- /dev/null +++ b/src/main/java/org/zaine/app/controller/CalibreController.java @@ -0,0 +1,32 @@ +package org.zaine.app.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zaine.app.dto.CalibreBookDTO; +import org.zaine.app.service.CalibreService; + +import java.util.List; + +/** + * Exposes read-only Calibre library data to the frontend. + * Only used to populate the book picker in the Mutāla'ah modal. + */ +@RestController +@RequestMapping("/api/calibre") +public class CalibreController { + + @Autowired + private CalibreService calibreService; + + /** + * GET /api/calibre/books + * Returns all books (id, title, authors) sorted alphabetically. + * Used to populate the Mutāla'ah session book picker. + */ + @GetMapping("/books") + public List getBooks() { + return calibreService.getAllBooks(); + } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/controller/MotalahController.java b/src/main/java/org/zaine/app/controller/MotalahController.java new file mode 100644 index 0000000..0192eb8 --- /dev/null +++ b/src/main/java/org/zaine/app/controller/MotalahController.java @@ -0,0 +1,75 @@ +package org.zaine.app.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.zaine.app.dto.MotalahSessionDTO; +import org.zaine.app.model.MotalahSession; +import org.zaine.app.service.MotalahService; + +import java.lang.System.Logger; +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/wird/motalah") +public class MotalahController { + + private static final Logger logger = System.getLogger(MotalahController.class.getName()); + + @Autowired + private MotalahService motalahService; + + /** + * GET /api/wird/motalah + * All study sessions, newest first. + */ + @GetMapping + public List getAll() { + logger.log(Logger.Level.INFO, "Fetching all motalah sessions"); + return motalahService.getAll(); + } + + /** + * GET /api/wird/motalah/today + * Convenience endpoint for today's sessions only. + */ + @GetMapping("/today") + public List getToday() { + return motalahService.getToday(); + } + + /** + * GET /api/wird/motalah/range?from=2025-01-01&to=2025-01-31 + * Used by the trend chart and month-total stat. + */ + @GetMapping("/range") + public List getInRange( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return motalahService.getInRange(from, to); + } + + /** + * POST /api/wird/motalah + * Body: { date, durationMinutes, bookIds, notes } + */ + @PostMapping + public ResponseEntity create(@RequestBody MotalahSessionDTO dto) { + logger.log(Logger.Level.INFO, "Creating motalah session: {0} min on {1}", + dto.getDurationMinutes(), dto.getDate()); + MotalahSession saved = motalahService.create(dto); + return ResponseEntity.ok(saved); + } + + /** + * DELETE /api/wird/motalah/{id} + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + logger.log(Logger.Level.INFO, "Deleting motalah session id: {0}", id); + motalahService.delete(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/controller/WirdController.java b/src/main/java/org/zaine/app/controller/WirdController.java index 913d3ec..0dc3cdb 100644 --- a/src/main/java/org/zaine/app/controller/WirdController.java +++ b/src/main/java/org/zaine/app/controller/WirdController.java @@ -85,5 +85,23 @@ public class WirdController { wirdService.deleteEntry(id); return ResponseEntity.noContent().build(); } + + /** + * GET /api/wird/entries/nafl/today + * Returns today's nafl prayer entries (convenience endpoint). + */ + @GetMapping("/entries/nafl/today") + public List getNaflToday() { + return wirdService.getNaflForDate(LocalDate.now()); + } + + /** + * GET /api/wird/entries/khatm + * All khatm completions, newest first. + */ + @GetMapping("/entries/khatm") + public List getKhatmEntries() { + return wirdService.getKhatmEntries(); + } } diff --git a/src/main/java/org/zaine/app/dto/CalibreBookDTO.java b/src/main/java/org/zaine/app/dto/CalibreBookDTO.java new file mode 100644 index 0000000..fdc4180 --- /dev/null +++ b/src/main/java/org/zaine/app/dto/CalibreBookDTO.java @@ -0,0 +1,22 @@ +package org.zaine.app.dto; + +/** + * Lightweight projection of a Calibre book — just what the + * Mutāla'ah book-picker needs. + */ +public class CalibreBookDTO { + + private final long id; + private final String title; + private final String authors; + + public CalibreBookDTO(long id, String title, String authors) { + this.id = id; + this.title = title; + this.authors = authors != null ? authors : ""; + } + + public long getId() { return id; } + public String getTitle() { return title; } + public String getAuthors() { return authors; } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java b/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java new file mode 100644 index 0000000..201d67a --- /dev/null +++ b/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java @@ -0,0 +1,26 @@ +package org.zaine.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public class MotalahSessionDTO { + + private LocalDate date; + private Integer durationMinutes; + private List bookIds; + private String notes; + + // ── Getters & Setters ───────────────────────────────── + + public LocalDate getDate() { return date; } + public void setDate(LocalDate date) { this.date = date; } + + public Integer getDurationMinutes() { return durationMinutes; } + public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; } + + public List getBookIds() { return bookIds; } + public void setBookIds(List bookIds) { this.bookIds = bookIds; } + + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/model/IntegerListConverter.java b/src/main/java/org/zaine/app/model/IntegerListConverter.java new file mode 100644 index 0000000..085f094 --- /dev/null +++ b/src/main/java/org/zaine/app/model/IntegerListConverter.java @@ -0,0 +1,42 @@ +package org.zaine.app.model; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Converts between a Java List and a PostgreSQL-compatible + * comma-separated string that Hibernate stores in the integer[] column. + * + * Because standard JPA/Hibernate doesn't natively map PostgreSQL arrays, + * we serialise as "{1,2,3}" which is exactly what Postgres expects for + * an array literal when sent as a string parameter. + */ +@Converter +public class IntegerListConverter implements AttributeConverter, String> { + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) return "{}"; + return "{" + attribute.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")) + "}"; + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank() || dbData.equals("{}")) { + return Collections.emptyList(); + } + // Strip surrounding braces: "{1,2,3}" → "1,2,3" + String inner = dbData.replaceAll("[{}]", "").trim(); + if (inner.isEmpty()) return Collections.emptyList(); + return Arrays.stream(inner.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/model/MotalahSession.java b/src/main/java/org/zaine/app/model/MotalahSession.java new file mode 100644 index 0000000..938706a --- /dev/null +++ b/src/main/java/org/zaine/app/model/MotalahSession.java @@ -0,0 +1,62 @@ +package org.zaine.app.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +@Entity +@Table(name = "motalah_sessions") +public class MotalahSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate date; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + /** + * Maps directly to PostgreSQL integer[]. + * @JdbcTypeCode(SqlTypes.ARRAY) tells Hibernate to use the + * native JDBC Array binding — no custom converter needed. + */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "book_ids", columnDefinition = "integer[]") + private List bookIds; + + @Column + private String notes; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @PrePersist + void onInsert() { + this.createdAt = OffsetDateTime.now(); + } + + // ── Getters & Setters ───────────────────────────────── + + public Long getId() { return id; } + + public LocalDate getDate() { return date; } + public void setDate(LocalDate date) { this.date = date; } + + public Integer getDurationMinutes() { return durationMinutes; } + public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; } + + public List getBookIds() { return bookIds; } + public void setBookIds(List bookIds) { this.bookIds = bookIds; } + + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + + public OffsetDateTime getCreatedAt() { return createdAt; } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java b/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java new file mode 100644 index 0000000..78f72e5 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java @@ -0,0 +1,25 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.zaine.app.model.MotalahSession; + +import java.time.LocalDate; +import java.util.List; + +public interface MotalahSessionRepository extends JpaRepository { + + /** All sessions newest first — used by the main list and heatmap. */ + List findAllByOrderByDateDesc(); + + /** Sessions within a date range — used for month totals / trend chart. */ + @Query("SELECT s FROM MotalahSession s WHERE s.date >= :from AND s.date <= :to ORDER BY s.date DESC") + List findInRange( + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); + + /** Today's sessions. */ + List findByDate(LocalDate date); +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java b/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java index 795b602..7306f9b 100644 --- a/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java +++ b/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java @@ -32,4 +32,6 @@ public interface WirdEntryRepository extends JpaRepository { @Param("from") LocalDate from, @Param("to") LocalDate to ); + + List findByWirdTypeOrderByDateDesc(String wirdType); } diff --git a/src/main/java/org/zaine/app/service/CalibreService.java b/src/main/java/org/zaine/app/service/CalibreService.java new file mode 100644 index 0000000..c569352 --- /dev/null +++ b/src/main/java/org/zaine/app/service/CalibreService.java @@ -0,0 +1,68 @@ +package org.zaine.app.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.zaine.app.dto.CalibreBookDTO; + +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +/** + * Reads book metadata directly from the Calibre SQLite database. + * + * The path is injected from application.properties: + * calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db + * + * We open a fresh connection per call — the Calibre DB is local, small, + * and this service is called infrequently (page load only), so connection + * pooling is not worth the complexity here. + */ +@Service +public class CalibreService { + + private static final Logger log = LoggerFactory.getLogger(CalibreService.class); + + @Value("${calibre.db.path}") + private String calibreDbPath; + + private static final String QUERY = """ + SELECT + b.id AS book_id, + b.title AS title, + GROUP_CONCAT(a.name, ', ') AS authors + FROM books b + LEFT JOIN books_authors_link bal ON b.id = bal.book + LEFT JOIN authors a ON bal.author = a.id + GROUP BY b.id + ORDER BY b.title COLLATE NOCASE + """; + + public List getAllBooks() { + List books = new ArrayList<>(); + String jdbcUrl = "jdbc:sqlite:" + calibreDbPath; + + try (var conn = DriverManager.getConnection(jdbcUrl); + var stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(QUERY)) { + + while (rs.next()) { + books.add(new CalibreBookDTO( + rs.getLong("book_id"), + rs.getString("title"), + rs.getString("authors") + )); + } + + } catch (Exception e) { + log.error("Failed to read Calibre database at {}: {}", calibreDbPath, e.getMessage(), e); + // Return empty list rather than blowing up the page — + // the book picker will simply show nothing. + } + + return books; + } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/service/MotalahService.java b/src/main/java/org/zaine/app/service/MotalahService.java new file mode 100644 index 0000000..2cb397a --- /dev/null +++ b/src/main/java/org/zaine/app/service/MotalahService.java @@ -0,0 +1,48 @@ +package org.zaine.app.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.zaine.app.dto.MotalahSessionDTO; +import org.zaine.app.model.MotalahSession; +import org.zaine.app.repositories.MotalahSessionRepository; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +@Service +public class MotalahService { + + @Autowired + private MotalahSessionRepository repo; + + /** All sessions, newest first. */ + public List getAll() { + return repo.findAllByOrderByDateDesc(); + } + + /** Today's sessions. */ + public List getToday() { + return repo.findByDate(LocalDate.now()); + } + + /** Sessions in a date range. */ + public List getInRange(LocalDate from, LocalDate to) { + return repo.findInRange(from, to); + } + + /** Persist a new session from the DTO. */ + public MotalahSession create(MotalahSessionDTO dto) { + MotalahSession session = new MotalahSession(); + session.setDate(dto.getDate() != null ? dto.getDate() : LocalDate.now()); + session.setDurationMinutes(dto.getDurationMinutes()); + session.setBookIds(dto.getBookIds() != null ? dto.getBookIds() : Collections.emptyList()); + session.setNotes(dto.getNotes()); + return repo.save(session); + } + + /** Delete a session by id. Silently no-ops if not found. */ + public void delete(Long id) { + repo.deleteById(id); + } +} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/service/WirdService.java b/src/main/java/org/zaine/app/service/WirdService.java index 619792a..28d75b4 100644 --- a/src/main/java/org/zaine/app/service/WirdService.java +++ b/src/main/java/org/zaine/app/service/WirdService.java @@ -9,6 +9,7 @@ import org.zaine.app.repositories.WirdEntryRepository; import java.time.LocalDate; import java.util.List; +import java.util.Set; @Service public class WirdService { @@ -50,4 +51,16 @@ public class WirdService { } repo.deleteById(id); } + + public List getNaflForDate(LocalDate date) { + Set naflTypes = Set.of("salatul_tawbah", "salatul_hajaat", "tahajjud"); + return repo.findAll() + .stream() + .filter(e -> naflTypes.contains(e.getWirdType()) && e.getDate().equals(date)) + .collect(java.util.stream.Collectors.toList()); + } + + public List getKhatmEntries() { + return repo.findByWirdTypeOrderByDateDesc("khatm"); + } }