wird updates
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -42,7 +42,11 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<version>42.7.8</version>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Testing -->
|
<!-- 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);
|
wirdService.deleteEntry(id);
|
||||||
return ResponseEntity.noContent().build();
|
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("from") LocalDate from,
|
||||||
@Param("to") LocalDate to
|
@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.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class WirdService {
|
public class WirdService {
|
||||||
@@ -50,4 +51,16 @@ public class WirdService {
|
|||||||
}
|
}
|
||||||
repo.deleteById(id);
|
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