wird updates
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
src/main/java/org/zaine/app/dto/CalibreBookDTO.java
Normal file
22
src/main/java/org/zaine/app/dto/CalibreBookDTO.java
Normal 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; }
|
||||
}
|
||||
26
src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
Normal file
26
src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
Normal 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; }
|
||||
}
|
||||
42
src/main/java/org/zaine/app/model/IntegerListConverter.java
Normal file
42
src/main/java/org/zaine/app/model/IntegerListConverter.java
Normal 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());
|
||||
}
|
||||
}
|
||||
62
src/main/java/org/zaine/app/model/MotalahSession.java
Normal file
62
src/main/java/org/zaine/app/model/MotalahSession.java
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -32,4 +32,6 @@ public interface WirdEntryRepository extends JpaRepository<WirdEntry, Long> {
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to
|
||||
);
|
||||
|
||||
List<WirdEntry> findByWirdTypeOrderByDateDesc(String wirdType);
|
||||
}
|
||||
|
||||
68
src/main/java/org/zaine/app/service/CalibreService.java
Normal file
68
src/main/java/org/zaine/app/service/CalibreService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/main/java/org/zaine/app/service/MotalahService.java
Normal file
48
src/main/java/org/zaine/app/service/MotalahService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user