wird updates

This commit is contained in:
2026-03-21 17:37:22 +00:00
parent e04d6f98d1
commit 81375a1acb
13 changed files with 438 additions and 1 deletions

View File

@@ -42,7 +42,11 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<!-- Testing -->

View File

@@ -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<CalibreBookDTO> getBooks() {
return calibreService.getAllBooks();
}
}

View File

@@ -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<MotalahSession> 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<MotalahSession> 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<MotalahSession> 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<MotalahSession> 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<Void> delete(@PathVariable Long id) {
logger.log(Logger.Level.INFO, "Deleting motalah session id: {0}", id);
motalahService.delete(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -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<WirdEntry> getNaflToday() {
return wirdService.getNaflForDate(LocalDate.now());
}
/**
* GET /api/wird/entries/khatm
* All khatm completions, newest first.
*/
@GetMapping("/entries/khatm")
public List<WirdEntry> getKhatmEntries() {
return wirdService.getKhatmEntries();
}
}

View File

@@ -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; }
}

View File

@@ -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<Integer> 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<Integer> getBookIds() { return bookIds; }
public void setBookIds(List<Integer> bookIds) { this.bookIds = bookIds; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
}

View File

@@ -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<Integer> 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<List<Integer>, String> {
@Override
public String convertToDatabaseColumn(List<Integer> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
return "{" + attribute.stream()
.map(String::valueOf)
.collect(Collectors.joining(",")) + "}";
}
@Override
public List<Integer> 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());
}
}

View File

@@ -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<Integer> 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<Integer> getBookIds() { return bookIds; }
public void setBookIds(List<Integer> bookIds) { this.bookIds = bookIds; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public OffsetDateTime getCreatedAt() { return createdAt; }
}

View File

@@ -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<MotalahSession, Long> {
/** All sessions newest first — used by the main list and heatmap. */
List<MotalahSession> 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<MotalahSession> findInRange(
@Param("from") LocalDate from,
@Param("to") LocalDate to
);
/** Today's sessions. */
List<MotalahSession> findByDate(LocalDate date);
}

View File

@@ -32,4 +32,6 @@ public interface WirdEntryRepository extends JpaRepository<WirdEntry, Long> {
@Param("from") LocalDate from,
@Param("to") LocalDate to
);
List<WirdEntry> findByWirdTypeOrderByDateDesc(String wirdType);
}

View File

@@ -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<CalibreBookDTO> getAllBooks() {
List<CalibreBookDTO> 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;
}
}

View File

@@ -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<MotalahSession> getAll() {
return repo.findAllByOrderByDateDesc();
}
/** Today's sessions. */
public List<MotalahSession> getToday() {
return repo.findByDate(LocalDate.now());
}
/** Sessions in a date range. */
public List<MotalahSession> 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);
}
}

View File

@@ -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<WirdEntry> getNaflForDate(LocalDate date) {
Set<String> 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<WirdEntry> getKhatmEntries() {
return repo.findByWirdTypeOrderByDateDesc("khatm");
}
}