313 feature request add memories (#351)

This commit is contained in:
Daniel Graf
2025-10-24 12:52:53 +02:00
committed by GitHub
parent edcaf8bea1
commit 4e7b2988f2
90 changed files with 8673 additions and 510 deletions

2
.gitignore vendored
View File

@@ -26,7 +26,7 @@ replay_pid*
.aider*
/target/
/.idea/
/data
*secrets.properties
*oidc.properties

View File

@@ -450,18 +450,19 @@ To enable PKCE for the OIDC Client, you need to set `OIDC_AUTHENTICATION_METHOD`
## Backup & Data Persistence
Reitti is designed to be mostly stateless, with all important data stored in the PostgreSQL database (with PostGIS extensions). To ensure you do not lose any critical data:
- **Backup Requirement:** Only the PostGIS database needs to be backed up regularly. This database contains all user location data, analysis results, and other persistent information.
- **Stateless Services:** All other components (RabbitMQ, Redis, Reitti application, etc.) are stateless and do not store any important data. These can be redeployed or restarted without risk of data loss.
- **Backup Requirements:**
- The PostGIS database needs to be backed up regularly. This database contains all user location data, analysis results, and other persistent information.
- The storage path used by Reitti needs to be backed up regularly. This contains uploaded files.
- **Stateless Services:** All other components (RabbitMQ, Redis, Photon, etc.) are stateless and do not store any important data. These can be redeployed or restarted without risk of data loss.
**Recommended Backup Strategy:**
- Use standard PostgreSQL backup tools (such as `pg_dump` or physical volume snapshots) to back up your database.
- Back up the entire storage directory/volume used by Reitti for file storage.
- Ensure backups are performed regularly and stored securely.
- No backup is needed for RabbitMQ, Redis, or the Reitti application itself.
- No backup is needed for RabbitMQ, Redis, Photon.
**Restore:**
- In case of disaster recovery, restoring the PostGIS database is sufficient to recover all user data and history.
- In case of disaster recovery, restore both the PostGIS database and the storage path to recover all user data and history.
For more details, see the [Reitti backup documentation](https://www.dedicatedcode.com/projects/reitti/backup/).

View File

@@ -45,7 +45,7 @@ services:
timeout: 5s
retries: 5
photon:
image: rtuszik/photon-docker:1.0.0
image: rtuszik/photon-docker:1.2.1
environment:
- UPDATE_STRATEGY=PARALLEL
- REGION=de

View File

@@ -11,8 +11,13 @@ services:
restart: true
redis:
condition: service_healthy
volumes:
- reitti-data:/data/
environment:
PHOTON_BASE_URL: http://photon:2322
POSTGRES_USER: reitti
POSTGRES_PASSWORD: reitti
POSTGRES_DB: reittidb
postgis:
image: postgis/postgis:17-3.5-alpine
environment:
@@ -48,7 +53,7 @@ services:
timeout: 5s
retries: 5
photon:
image: rtuszik/photon-docker:1.0.0
image: rtuszik/photon-docker:1.2.1
environment:
- UPDATE_STRATEGY=PARALLEL
- REGION=de #set your main country code here to save space or drop this line to fetch the whole index.
@@ -59,3 +64,4 @@ volumes:
rabbitmq-data:
redis-data:
photon-data:
reitti-data:

65
generate-memory-blocks.md Normal file
View File

@@ -0,0 +1,65 @@
## Step 1: Data Pre-processing & Filtering 🧹
The goal here is to remove data points that are not actual, intentional visits.
Remove Accommodation Stays: The first step is to filter out all visits to the known accommodation. This location serves as your base reference point, not a tourist activity.
Filter by Duration: Remove very short visits. Stops under 10-15 minutes are often just traffic lights, brief errands (like an ATM), or GPS drift. Set a minimum duration threshold to focus on meaningful stays.
Consolidate Micro-Visits: If your app generates multiple separate "visits" for wandering around a single large area (e.g., a park or a market), you may need to merge these into one continuous visit before proceeding.
## Step 2: Data Enrichment with Context 🗺️
Raw coordinates are not useful for a travel log. You need to understand what these places are.
Reverse Geocoding: Convert each visit's latitude and longitude into a human-readable address.
Point of Interest (POI) Matching: This is the most crucial step. Use a service like the Google Places API, Foursquare API, or OpenStreetMap to match the coordinates to a named place. This will give you a name (e.g., "Louvre Museum"), a category (e.g., "museum"), and other details.
Your data will transform from this:
{lat: 48.8606, lon: 2.3376, start: '14:30', end: '17:00'}
To this:
{name: 'Louvre Museum', category: 'museum', address: 'Rue de Rivoli, 75001 Paris', ...}
## Step 3: Scoring & Identifying "Interesting" Visits ✨
Now you can define what makes a visit "interesting" by calculating an interest score. This helps prioritize the highlights of the day.
Combine several factors into a weighted score:
Duration: Longer stays are generally more significant. A 3-hour museum visit is more important than a 20-minute coffee stop.
Distance from Accommodation: Visits far from where you're staying are likely planned day trips or major excursions and should be scored higher. This is a very strong signal of intent.
Place Category: This is key. Use the POI data from Step 2 to assign a weight to each category.
High Interest: museum, landmark, park, tourist_attraction, historic_site.
Medium Interest: restaurant, cafe, shopping_mall.
Low Interest: grocery_store, pharmacy, gas_station.
Novelty: A place visited only once on the trip is typically more notable for a travel log than a coffee shop visited every morning.
You can create a simple scoring formula, for instance:
Score=(wd⋅Duration)+(wx⋅Distance)+(wc⋅CategoryWeight)
Where wd, wx, and wc are the weights you assign to duration, distance, and category, respectively.
## Step 4: Clustering & Creating a Narrative ✍️
A simple list of interesting places is good, but a great travel log groups them into a story.
Spatio-Temporal Clustering: Group visits that are close in both location and time. For example, a visit to a museum, followed by a visit to a café next door 15 minutes later, should be part of the same event.
Algorithm Choice: An algorithm like DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is excellent for this. You can define a "neighborhood" in terms of time (e.g., within 2 hours of each other) and space (e.g., within 500 meters of each other) to automatically find these groups.
Summarize the Cluster: Once you have a cluster of visits, create a single travel log entry for it.
Title: Name the event after the highest-scoring visit within the cluster (e.g., "Visit to the Eiffel Tower and Champ de Mars").
Timeframe: Use the start time of the first visit and the end time of the last visit in the cluster.
Content: List the significant places visited within that cluster.

View File

@@ -1,6 +1,8 @@
package com.dedicatedcode.reitti.config;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
import com.dedicatedcode.reitti.model.security.TokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
import com.dedicatedcode.reitti.repository.UserJdbcService;
@@ -17,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@Component
@@ -34,7 +37,12 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!"/access".equals(request.getRequestURI()) || request.getParameter("mt") == null) {
if (request.getParameter("mt") == null) {
filterChain.doFilter(request, response);
return;
}
boolean isMemoryRequest = request.getRequestURI().startsWith("/memories/");
if (!("/access".equals(request.getRequestURI()) || isMemoryRequest)) {
filterChain.doFilter(request, response);
return;
}
@@ -55,6 +63,18 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
response.sendRedirect("/error/magic-link?error=expired");
return;
}
Long resourceId = null;
if (isMemoryRequest) {
try {
resourceId = Long.parseLong(request.getRequestURI().substring("/memories/".length()));
} catch (NumberFormatException e) {
//ignored
}
if (linkToken.getResourceType() != MagicLinkResourceType.MEMORY || resourceId == null || resourceId.longValue() != linkToken.getResourceId()) {
response.sendRedirect("/error/magic-link?error=invalid");
return;
}
}
Optional<User> user = magicLinkJdbcService.findUserIdByToken(linkToken.getId()).flatMap(userJdbcService::findById);
@@ -67,7 +87,7 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
String specialRole = "ROLE_MAGIC_LINK_" + linkToken.getAccessLevel().name();
MagicLinkAuthenticationToken authentication = new MagicLinkAuthenticationToken(
user.get(),
new TokenUser(user.get(), linkToken.getResourceType(), resourceId, List.of(specialRole)),
null,
Collections.singletonList(new SimpleGrantedAuthority(specialRole)),
linkToken.getId()
@@ -77,7 +97,11 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
response.sendRedirect("/");
if (linkToken.getResourceType() != MagicLinkResourceType.MEMORY) {
response.sendRedirect("/");
} else {
response.sendRedirect("/memories/" + linkToken.getResourceId());
}
return;
} catch (Exception e) {
response.sendRedirect("/error/magic-link?error=processing");

View File

@@ -43,7 +43,9 @@ public class SecurityConfig {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/access", "/error").permitAll()
.requestMatchers("/settings/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name())
.requestMatchers("/api/v1/photos/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_FULL_ACCESS", "MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")
.requestMatchers("/api/v1/photos/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_FULL_ACCESS", "MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS", "MAGIC_LINK_MEMORY_VIEW_ONLY", "MAGIC_LINK_MEMORY_EDIT_ACCESS")
.requestMatchers("/memories/*/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_MEMORY_VIEW_ONLY", "MAGIC_LINK_MEMORY_EDIT_ACCESS")
.requestMatchers("/memories").hasAnyRole(Role.ADMIN.name(), Role.USER.name())
.requestMatchers("/css/**", "/js/**", "/images/**", "/img/**", "/error/magic-link/**", "/setup/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/reitti-integration/notify/**").permitAll()

View File

@@ -1,5 +1,6 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
@@ -9,6 +10,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.HashMap;
@@ -143,4 +145,13 @@ public class CustomErrorController implements ErrorController {
return sb.toString();
}
@ExceptionHandler(PageNotFoundException.class)
public String handlePageNotFound(HttpServletRequest request, Model model) {
String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
model.addAttribute("status", 404);
model.addAttribute("error", "Not Found");
model.addAttribute("message", "The page you are looking for could not be found.");
return "error";
}
}

View File

@@ -0,0 +1,427 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.dto.PhotoResponse;
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.TokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.service.MemoryService;
import com.dedicatedcode.reitti.service.StorageService;
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static com.dedicatedcode.reitti.model.Role.ADMIN;
import static com.dedicatedcode.reitti.model.Role.USER;
@Controller
@RequestMapping("/memories/{memoryId}/blocks")
public class MemoryBlockController {
private final MemoryService memoryService;
private final ImmichIntegrationService immichIntegrationService;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final StorageService storageService;
public MemoryBlockController(MemoryService memoryService, ImmichIntegrationService immichIntegrationService, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, StorageService storageService) {
this.memoryService = memoryService;
this.immichIntegrationService = immichIntegrationService;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.storageService = storageService;
}
@DeleteMapping("/{blockId}")
public String deleteBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestHeader(value = "HX-Request", required = false) String hxRequest) {
memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
memoryService.deleteBlock(blockId);
if (hxRequest != null) {
return "memories/fragments :: empty";
}
return "redirect:/memories/" + memoryId;
}
@GetMapping("/{blockId}/edit")
public String editBlockForm(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlock block = memoryService.getBlockById(user, blockId)
.orElseThrow(() -> new IllegalArgumentException("Block not found"));
model.addAttribute("memoryId", memoryId);
model.addAttribute("block", block);
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
switch (block.getBlockType()) {
case IMAGE_GALLERY:
boolean immichEnabled = immichIntegrationService.getIntegrationForUser(user)
.map(ImmichIntegration::isEnabled)
.orElse(false);
model.addAttribute("immichEnabled", immichEnabled);
model.addAttribute("imageBlock", memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found")) );
return "memories/blocks/edit :: edit-image-gallery-block";
case CLUSTER_VISIT:
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(b ->
model.addAttribute("clusterVisitBlock", b));
model.addAttribute("availableVisits", this.processedVisitJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
return "memories/blocks/edit :: edit-cluster-visit-block";
case TEXT:
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(text ->
model.addAttribute("textBlock", text));
return "memories/blocks/edit :: edit-text-block";
case CLUSTER_TRIP:
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(b ->
model.addAttribute("clusterTripBlock", b));
model.addAttribute("availableTrips", this.tripJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
return "memories/blocks/edit :: edit-cluster-trip-block";
}
return "memories/blocks/edit";
}
@GetMapping("/{blockId}/view")
public String viewBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/{blockId}/cluster")
public String updateClusterBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestParam(name = "selectedParts") List<Long> selectedParts,
@RequestParam(required = false) String title,
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryClusterBlock block = memoryService.getClusterBlock(user, blockId)
.orElseThrow(() -> new IllegalArgumentException("Cluster block not found"));
MemoryClusterBlock updated = block.withPartIds(selectedParts).withTitle(title);
memoryService.updateClusterBlock(user, updated);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/cluster")
public String createClusterBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam(required = false, defaultValue = "-1") int position,
@RequestParam(name = "selectedParts") List<Long> selectedParts,
@RequestParam BlockType type,
@RequestParam(required = false) String title,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlock block = memoryService.addBlock(user, memoryId, position, type);
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(block.getId(), selectedParts, title, null, type);
memoryService.createClusterBlock(user, clusterBlock);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/reorder")
public String reorderBlocks(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam List<Long> blockIds) {
memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
memoryService.reorderBlocks(user, memoryId, blockIds);
return "redirect:/memories/" + memoryId;
}
@PostMapping("/text")
public String createTextBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam(required = false, defaultValue = "-1") int position,
@RequestParam(required = false) String headline,
@RequestParam(required = false) String content,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlock block = memoryService.addBlock(user, memoryId, position, BlockType.TEXT);
memoryService.addTextBlock(block.getId(), headline, content);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/{blockId}/text")
public String updateTextBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestParam String headline,
@RequestParam String content,
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlockText textBlock = memoryService.getTextBlock(blockId)
.orElseThrow(() -> new IllegalArgumentException("Text block not found"));
MemoryBlockText updated = textBlock.withHeadline(headline).withContent(content);
memoryService.updateTextBlock(user, updated);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(updated));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/image-gallery")
public String createImageGalleryBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam(required = false, defaultValue = "-1") int position,
@RequestParam(required = false) List<String> uploadedUrls,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlock block = memoryService.addBlock(user, memoryId, position, BlockType.IMAGE_GALLERY);
List<MemoryBlockImageGallery.GalleryImage> imageBlocks = new ArrayList<>();
if (uploadedUrls != null) {
for (String url : uploadedUrls) {
imageBlocks.add(new MemoryBlockImageGallery.GalleryImage(url, null, "upload", null));
}
}
if (imageBlocks.isEmpty()) {
throw new IllegalArgumentException("No images selected");
}
memoryService.addImageGalleryBlock(block.getId(), imageBlocks);
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/{blockId}/image-gallery")
public String updateImageGalleryBlock(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@PathVariable Long blockId,
@RequestParam(required = false) List<String> uploadedUrls,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
MemoryBlockImageGallery imageBlock = memoryService.getImagesForBlock(blockId);
List<MemoryBlockImageGallery.GalleryImage> imageBlocks = new ArrayList<>();
if (uploadedUrls != null) {
for (String url : uploadedUrls) {
imageBlocks.add(new MemoryBlockImageGallery.GalleryImage(url, null, "upload", null));
}
}
if (imageBlocks.isEmpty()) {
throw new IllegalArgumentException("No images selected");
}
this.memoryService.updateImageBlock(user, imageBlock.withImages(imageBlocks));
model.addAttribute("memory", memory);
model.addAttribute("blocks", List.of(memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
return "memories/view :: view-block";
}
@PostMapping("/upload-image")
public String uploadImage(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam("files") List<MultipartFile> files,
Model model) {
memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
List<String> urls = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
try {
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String filename = UUID.randomUUID() + extension;
storageService.store("memories/" + memoryId + "/" + filename, file.getInputStream(), file.getSize(), file.getContentType());
String fileUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + filename;
urls.add(fileUrl);
} catch (IOException e) {
throw new RuntimeException("Failed to upload file", e);
}
}
model.addAttribute("urls", urls);
return "memories/fragments :: uploaded-photos";
}
@PostMapping("/fetch-immich-photo")
public String fetchImmichPhoto(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam String assetId,
Model model) {
memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
String imageUrl;
if (storageService.exists("memories/" + memoryId + "/" + assetId)) {
imageUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + assetId;
} else {
String filename = this.immichIntegrationService.downloadImage(user, assetId, "memories/" + memoryId);
imageUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + filename;
}
model.addAttribute("urls", List.of(imageUrl));
return "memories/fragments :: uploaded-photos";
}
@GetMapping("/immich-photos")
public String getImmichPhotos(
@AuthenticationPrincipal User user,
@PathVariable Long memoryId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "UTC") String timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, memoryId)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
ZoneId zoneId = ZoneId.of(timezone);
LocalDate startDate = memory.getStartDate().atZone(zoneId).toLocalDate();
LocalDate endDate = memory.getEndDate().atZone(zoneId).toLocalDate();
List<PhotoResponse> allPhotos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
int pageSize = 12;
int totalPages = (int) Math.ceil((double) allPhotos.size() / pageSize);
int startIndex = page * pageSize;
int endIndex = Math.min(startIndex + pageSize, allPhotos.size());
List<PhotoResponse> pagePhotos = allPhotos.subList(startIndex, endIndex);
model.addAttribute("photos", pagePhotos);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", totalPages);
model.addAttribute("memoryId", memoryId);
return "memories/fragments :: immich-photos-grid";
}
private boolean isOwner(Memory memory, User user) {
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
return this.memoryService.getOwnerId(memory) == user.getId();
} else {
return false;
}
}
private boolean canEdit(Memory memory, User user) {
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
return this.memoryService.getOwnerId(memory) == user.getId();
} else {
//assume the user is of type TokenUser
TokenUser tokenUser = (TokenUser) user;
return user.getAuthorities().contains(MagicLinkAccessLevel.MEMORY_EDIT_ACCESS.asAuthority()) && tokenUser.grantsAccessTo(MagicLinkResourceType.MEMORY, memory.getId());
}
}
}

View File

@@ -0,0 +1,433 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.controller.error.ForbiddenException;
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
import com.dedicatedcode.reitti.model.memory.HeaderType;
import com.dedicatedcode.reitti.model.memory.Memory;
import com.dedicatedcode.reitti.model.memory.MemoryBlockPart;
import com.dedicatedcode.reitti.model.memory.MemoryOverviewDTO;
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.TokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.service.MagicLinkTokenService;
import com.dedicatedcode.reitti.service.MemoryService;
import com.dedicatedcode.reitti.service.RequestHelper;
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import static com.dedicatedcode.reitti.model.Role.ADMIN;
import static com.dedicatedcode.reitti.model.Role.USER;
@Controller
@RequestMapping("/memories")
public class MemoryController {
private final MemoryService memoryService;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final ImmichIntegrationService immichIntegrationService;
private final MagicLinkTokenService magicLinkTokenService;
public MemoryController(MemoryService memoryService,
TripJdbcService tripJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
ImmichIntegrationService immichIntegrationService,
MagicLinkTokenService magicLinkTokenService) {
this.memoryService = memoryService;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.immichIntegrationService = immichIntegrationService;
this.magicLinkTokenService = magicLinkTokenService;
}
@GetMapping
public String get() {
return "memories/list";
}
@GetMapping("/years-navigation")
public String listMemories(@AuthenticationPrincipal User user, Model model) {
model.addAttribute("years", memoryService.getAvailableYears(user));
return "memories/fragments :: years-navigation";
}
@GetMapping("/all")
public String getAll(@AuthenticationPrincipal User user, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Model model) {
model.addAttribute("memories", this.memoryService.getMemoriesForUser(user).stream().map(m -> {
String startDateLocal = m.getStartDate().atZone(timezone).toLocalDate().toString();
String endDateLocal = m.getEndDate().atZone(timezone).toLocalDate().toString();
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
return new MemoryOverviewDTO(m, rawLocationUrl);
}).toList());
model.addAttribute("year", "all");
return "memories/fragments :: memories-list";
}
@GetMapping("/year/{year}")
public String getYear(@AuthenticationPrincipal User user, @PathVariable int year, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Model model) {
model.addAttribute("memories", this.memoryService.getMemoriesForUserAndYear(user, year)
.stream().map(m -> {
String startDateLocal = m.getStartDate().atZone(timezone).toLocalDate().toString();
String endDateLocal = m.getEndDate().atZone(timezone).toLocalDate().toString();
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
return new MemoryOverviewDTO(m, rawLocationUrl);
}).toList());
model.addAttribute("year", year);
return "memories/fragments :: memories-list";
}
@GetMapping("/{id}")
public String viewMemory(
@AuthenticationPrincipal User user,
@PathVariable Long id,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new PageNotFoundException("Memory not found"));
model.addAttribute("memory", memory);
List<MemoryBlockPart> blocks = memoryService.getBlockPartsForMemory(user, id, timezone);
model.addAttribute("blocks", blocks);
String startDateLocal = memory.getStartDate().atZone(timezone).toLocalDate().toString();
String endDateLocal = memory.getEndDate().atZone(timezone).toLocalDate().toString();
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
model.addAttribute("rawLocationUrl", rawLocationUrl);
model.addAttribute("canEdit", canEdit(memory, user));
model.addAttribute("isOwner", isOwner(memory, user));
return "memories/view";
}
private boolean isOwner(Memory memory, User user) {
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
return this.memoryService.getOwnerId(memory) == user.getId();
} else {
return false;
}
}
private boolean canEdit(Memory memory, User user) {
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
return this.memoryService.getOwnerId(memory) == user.getId();
} else {
//assume the user is of type TokenUser
TokenUser tokenUser = (TokenUser) user;
return user.getAuthorities().contains(MagicLinkAccessLevel.MEMORY_EDIT_ACCESS.asAuthority()) && tokenUser.grantsAccessTo(MagicLinkResourceType.MEMORY, memory.getId());
}
}
@GetMapping("/new")
public String newMemoryForm(
@AuthenticationPrincipal User user,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String year,
Model model) {
model.addAttribute("startDate", startDate);
model.addAttribute("endDate", endDate);
model.addAttribute("year", year);
return "memories/new :: new-memory";
}
@PostMapping
public String createMemory(
@AuthenticationPrincipal User user,
@RequestParam String title,
@RequestParam(required = false) String description,
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate,
@RequestParam(required = false) String headerImageUrl,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
if (title == null || title.trim().isEmpty()) {
model.addAttribute("error", "memory.validation.title.required");
model.addAttribute("title", title);
model.addAttribute("description", description);
model.addAttribute("startDate", startDate);
model.addAttribute("endDate", endDate);
model.addAttribute("headerImageUrl", headerImageUrl);
return "memories/new :: new-memory";
}
try {
Instant start = ZonedDateTime.of(startDate.atStartOfDay(), timezone).toInstant();
Instant end = ZonedDateTime.of(endDate.plusDays(1).atStartOfDay().minusNanos(1), timezone).toInstant();
Instant today = Instant.now();
// Validate dates are not in the future
if (start.isAfter(today) || end.isAfter(today)) {
model.addAttribute("error", "memory.validation.date.future");
model.addAttribute("title", title);
model.addAttribute("description", description);
model.addAttribute("startDate", startDate);
model.addAttribute("endDate", endDate);
model.addAttribute("headerImageUrl", headerImageUrl);
return "memories/new :: new-memory";
}
// Validate end date is not before start date
if (end.isBefore(start)) {
model.addAttribute("error", "memory.validation.end.date.before.start");
model.addAttribute("title", title);
model.addAttribute("description", description);
model.addAttribute("startDate", startDate);
model.addAttribute("endDate", endDate);
model.addAttribute("headerImageUrl", headerImageUrl);
return "memories/new :: new-memory";
}
Memory memory = new Memory(
title.trim(),
description != null ? description.trim() : null,
start,
end,
HeaderType.MAP,
headerImageUrl
);
Memory created = memoryService.createMemory(user, memory);
this.memoryService.recalculateMemory(user, created.getId(), timezone);
return "redirect:/memories/" + created.getId();
} catch (Exception e) {
model.addAttribute("error", "memory.validation.start.date.required");
model.addAttribute("title", title);
model.addAttribute("description", description);
model.addAttribute("startDate", startDate);
model.addAttribute("endDate", endDate);
model.addAttribute("headerImageUrl", headerImageUrl);
return "memories/new :: new-memory";
}
}
@GetMapping("/{id}/edit")
public String editMemoryForm(@AuthenticationPrincipal User user, @PathVariable Long id, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
if (!canEdit(memory, user)) {
throw new ForbiddenException("You are not allowed to edit this memory");
}
model.addAttribute("memory", memory);
model.addAttribute("startDate", memory.getStartDate().atZone(timezone).toLocalDate());
model.addAttribute("endDate", memory.getEndDate().atZone(timezone).toLocalDate());
return "memories/edit :: edit-memory";
}
@PostMapping("/{id}")
public String updateMemory(
@AuthenticationPrincipal User user,
@PathVariable Long id,
@RequestParam String title,
@RequestParam(required = false) String description,
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate,
@RequestParam HeaderType headerType,
@RequestParam(required = false) String headerImageUrl,
@RequestParam Long version,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
if (!canEdit(memory, user)) {
throw new ForbiddenException("You are not allowed to edit this memory");
}
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
// Add validation similar to create method
if (title == null || title.trim().isEmpty()) {
model.addAttribute("error", "memory.validation.title.required");
model.addAttribute("memory", memory);
model.addAttribute("cancelEndpoint", "/memories/" + id);
model.addAttribute("cancelTarget", ".memory-header");
model.addAttribute("formTarget", ".memory-header");
return "memories/edit :: edit-memory";
}
try {
Instant start = ZonedDateTime.of(startDate.atStartOfDay(), timezone).toInstant();
Instant end = ZonedDateTime.of(endDate.plusDays(1).atStartOfDay().minusSeconds(1), timezone).toInstant();
Instant today = Instant.now();
if (start.isAfter(today) || end.isAfter(today)) {
model.addAttribute("error", "memory.validation.date.future");
model.addAttribute("memory", memory);
model.addAttribute("cancelEndpoint", "/memories/" + id);
model.addAttribute("cancelTarget", ".memory-header");
model.addAttribute("formTarget", ".memory-header");
return "memories/edit :: edit-memory";
}
if (end.isBefore(start)) {
model.addAttribute("error", "memory.validation.end.date.before.start");
model.addAttribute("memory", memory);
model.addAttribute("cancelEndpoint", "/memories/" + id);
model.addAttribute("cancelTarget", ".memory-header");
model.addAttribute("formTarget", ".memory-header");
return "memories/edit :: edit-memory";
}
Memory updated = memory
.withTitle(title.trim())
.withDescription(description != null ? description.trim() : null)
.withStartDate(start)
.withEndDate(end)
.withHeaderType(headerType)
.withHeaderImageUrl(headerImageUrl)
.withVersion(version);
Memory savedMemory = memoryService.updateMemory(user, updated);
model.addAttribute("memory", savedMemory);
return "memories/view :: memory-header";
} catch (Exception e) {
model.addAttribute("error", "memory.validation.start.date.required");
model.addAttribute("memory", memory);
model.addAttribute("cancelEndpoint", "/memories/" + id);
model.addAttribute("cancelTarget", ".memory-header");
model.addAttribute("formTarget", ".memory-header");
return "memories/edit :: edit-memory";
}
}
@DeleteMapping("/{id}")
public String deleteMemory(@AuthenticationPrincipal User user, @PathVariable Long id) {
Memory memory = this.memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
if (!isOwner(memory, user)) {
throw new ForbiddenException("You are not allowed to delete this memory");
}
memoryService.deleteMemory(user, id);
return "redirect:/memories";
}
@GetMapping("/{id}/blocks/select-type")
public String selectBlockType(@AuthenticationPrincipal User user, @PathVariable Long id, @RequestParam(defaultValue = "-1") int position, Model model) {
model.addAttribute("memoryId", id);
model.addAttribute("position", position);
return "memories/fragments :: block-type-selection";
}
@GetMapping("/fragments/empty")
public String emptyFragment() {
return "memories/fragments :: empty";
}
@PostMapping("/{id}/recalculate")
@ResponseBody
public String recalculateMemory(@AuthenticationPrincipal User user, @PathVariable Long id,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
HttpServletResponse httpResponse) {
Memory memory = memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
if (!isOwner(memory, user)) {
throw new ForbiddenException("You are not allowed execute this action. Only the owner of the memory can do this.");
}
memoryService.recalculateMemory(user, id, timezone);
httpResponse.setHeader("HX-Redirect", "/memories/" + id + "?timezone=" + timezone.getId());
return "Ok";
}
@GetMapping("/{id}/blocks/new")
public String newBlockForm(@AuthenticationPrincipal User user,
@PathVariable Long id,
@RequestParam String type,
@RequestParam(defaultValue = "-1") int position, Model model) {
Memory memory = memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
model.addAttribute("memoryId", id);
model.addAttribute("position", position);
model.addAttribute("blockType", type);
model.addAttribute("isOwner", isOwner(memory, user));
model.addAttribute("canEdit", canEdit(memory, user));
switch (type) {
case "TEXT":
return "memories/fragments :: text-block-form";
case "TRIP_CLUSTER":
model.addAttribute("availableTrips", this.tripJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
return "memories/fragments :: trip-block-form";
case "VISIT_CLUSTER":
model.addAttribute("availableVisits", this.processedVisitJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
return "memories/fragments :: visit-block-form";
case "IMAGE_GALLERY":
boolean immichEnabled = immichIntegrationService.getIntegrationForUser(user)
.map(ImmichIntegration::isEnabled)
.orElse(false);
model.addAttribute("immichEnabled", immichEnabled);
return "memories/fragments :: image-gallery-block-form";
default:
throw new IllegalArgumentException("Unknown block type: " + type);
}
}
@GetMapping("/{id}/share")
public String shareMemoryOverlay(@AuthenticationPrincipal User user, @PathVariable Long id, Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
model.addAttribute("memory", memory);
return "memories/fragments :: share-overlay";
}
@GetMapping("/{id}/share/form")
public String shareMemoryForm(@AuthenticationPrincipal User user, @PathVariable Long id,
@RequestParam MagicLinkAccessLevel accessLevel, Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
model.addAttribute("memory", memory);
model.addAttribute("accessLevel", accessLevel);
return "memories/fragments :: share-form";
}
@PostMapping("/{id}/share")
public String createShareLink(@AuthenticationPrincipal User user,
@PathVariable Long id,
@RequestParam MagicLinkAccessLevel accessLevel,
@RequestParam(defaultValue = "30") int validDays,
HttpServletRequest request,
Model model) {
Memory memory = memoryService.getMemoryById(user, id)
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
String token = magicLinkTokenService.createMemoryShareToken(user, id, accessLevel, validDays);
String baseUrl = RequestHelper.getBaseUrl(request);
String shareUrl = baseUrl + "/memories/" + id + "?mt=" + token;
model.addAttribute("shareUrl", shareUrl);
model.addAttribute("memory", memory);
model.addAttribute("accessLevel", accessLevel);
return "memories/fragments :: share-result";
}
}

View File

@@ -49,6 +49,7 @@ public class UserSettingsControllerAdvice {
DEFAULT_HOME_LONGITUDE,
tilesCustomizationProvider.getTilesConfiguration(),
UserSettingsDTO.UIMode.FULL,
UserSettingsDTO.PhotoMode.DISABLED,
TimeDisplayMode.DEFAULT,
null,
null);
@@ -57,6 +58,7 @@ public class UserSettingsControllerAdvice {
String username = authentication.getName();
Optional<User> userOptional = userJdbcService.findByUsername(username);
UserSettingsDTO.UIMode uiMode = mapUserToUiMode(authentication);
UserSettingsDTO.PhotoMode photoMode = mapUserToPhotoMode(authentication);
if (userOptional.isPresent()) {
User user = userOptional.get();
UserSettings dbSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
@@ -68,6 +70,7 @@ public class UserSettingsControllerAdvice {
dbSettings.getHomeLongitude(),
tilesCustomizationProvider.getTilesConfiguration(),
uiMode,
photoMode,
dbSettings.getTimeDisplayMode(),
dbSettings.getTimeZoneOverride(),
dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null);
@@ -81,6 +84,7 @@ public class UserSettingsControllerAdvice {
DEFAULT_HOME_LONGITUDE,
tilesCustomizationProvider.getTilesConfiguration(),
uiMode,
photoMode,
TimeDisplayMode.DEFAULT,
null,
null);
@@ -94,9 +98,23 @@ public class UserSettingsControllerAdvice {
return UserSettingsDTO.UIMode.SHARED_FULL;
} else if (grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE") || grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")) {
return UserSettingsDTO.UIMode.SHARED_LIVE_MODE_ONLY;
} else if (grantedRoles.contains("ROLE_MAGIC_LINK_MEMORY_VIEW_ONLY") || grantedRoles.contains("ROLE_MAGIC_LINK_MEMORY_EDIT_ACCESS")) {
return UserSettingsDTO.UIMode.VIEW_MEMORIES;
} else {
throw new IllegalStateException("Invalid user authentication mode detected [" + grantedRoles + "]");
}
}
private UserSettingsDTO.PhotoMode mapUserToPhotoMode(Authentication authentication) {
List<String> grantedRoles = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
if (grantedRoles.contains("ROLE_ADMIN") ||
grantedRoles.contains("ROLE_USER") ||
grantedRoles.contains("MAGIC_LINK_MEMORY_VIEW_ONLY") ||
grantedRoles.contains("MAGIC_LINK_MEMORY_EDIT_ACCESS") ||
grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")) {
return UserSettingsDTO.PhotoMode.ENABLED;
} else {
return UserSettingsDTO.PhotoMode.DISABLED;
}
}
}

View File

@@ -0,0 +1,51 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.dto.PhotoResponse;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/v1/photos/immich")
public class ImmichPhotoApiController {
private final ImmichIntegrationService immichIntegrationService;
public ImmichPhotoApiController(ImmichIntegrationService immichIntegrationService) {
this.immichIntegrationService = immichIntegrationService;
}
@GetMapping("/range")
public ResponseEntity<List<PhotoResponse>> getPhotosForRange(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false, defaultValue = "UTC") String timezone,
@AuthenticationPrincipal User user) {
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
return ResponseEntity.ok(photos);
}
@GetMapping("/proxy/{assetId}/thumbnail")
public ResponseEntity<byte[]> getPhotoThumbnail(
@PathVariable String assetId,
@AuthenticationPrincipal User user) {
return immichIntegrationService.proxyImageRequest(user, assetId, "thumbnail");
}
@GetMapping("/proxy/{assetId}/original")
public ResponseEntity<byte[]> getPhotoOriginal(
@PathVariable String assetId,
@AuthenticationPrincipal User user) {
return immichIntegrationService.proxyImageRequest(user, assetId, "fullsize");
}
}

View File

@@ -3,8 +3,10 @@ package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.dto.LocationPoint;
import com.dedicatedcode.reitti.dto.RawLocationDataResponse;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.geo.Trip;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.repository.UserJdbcService;
import com.dedicatedcode.reitti.service.LocationPointsSimplificationService;
import org.slf4j.Logger;
@@ -17,9 +19,13 @@ import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1")
@@ -28,14 +34,16 @@ public class LocationDataApiController {
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final TripJdbcService tripJdbcService;
private final LocationPointsSimplificationService simplificationService;
private final UserJdbcService userJdbcService;
@Autowired
public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService,
public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService, TripJdbcService tripJdbcService,
LocationPointsSimplificationService simplificationService,
UserJdbcService userJdbcService) {
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.tripJdbcService = tripJdbcService;
this.simplificationService = simplificationService;
this.userJdbcService = userJdbcService;
}
@@ -49,6 +57,30 @@ public class LocationDataApiController {
return p;
}
@GetMapping("/raw-location-points/trips")
public ResponseEntity<?> getRawLocationPointsTrips(@AuthenticationPrincipal User user,
@RequestParam List<Long> trips,
@RequestParam(required = false) Integer zoom,
@RequestParam(required = false) Double minLat,
@RequestParam(required = false) Double maxLat,
@RequestParam(required = false) Double minLng,
@RequestParam(required = false) Double maxLng) {
List<List<LocationPoint>> tmp = new ArrayList<>();
List<Trip> loadedTrips = this.tripJdbcService.findByIds(user, trips);
for (Trip trip : loadedTrips) {
Instant startOfRange = trip.getStartTime();
Instant endOfRange = trip.getEndTime();
List<List<LocationPoint>> segments = loadSegmentsInBoundingBoxAndTime(user, minLat, maxLat, minLng, maxLng, startOfRange, endOfRange);
tmp.addAll(segments);
}
Optional<RawLocationPoint> latest = this.rawLocationPointJdbcService.findLatest(user);
RawLocationDataResponse result = new RawLocationDataResponse(tmp.stream().map(s -> {
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
return new RawLocationDataResponse.Segment(simplifiedPoints);
}).toList(), latest.map(LocationDataApiController::toLocationPoint).orElse(null));
return ResponseEntity.ok(result);
}
@GetMapping("/raw-location-points")
public ResponseEntity<?> getRawLocationPointsForCurrentUser(@AuthenticationPrincipal User user,
@RequestParam(required = false) String date,
@@ -76,21 +108,30 @@ public class LocationDataApiController {
@RequestParam(required = false) Double maxLng) {
try {
ZoneId userTimezone = ZoneId.of(timezone);
Instant startOfRange;
Instant endOfRange;
Instant startOfRange = null;
Instant endOfRange = null;
// Support both single date and date range
if (startDate != null && endDate != null) {
// Date range mode
LocalDate selectedStartDate = LocalDate.parse(startDate);
LocalDate selectedEndDate = LocalDate.parse(endDate);
//first try to parse them as date time
startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
try {
LocalDateTime startTimestamp = LocalDateTime.parse(startDate);
LocalDateTime endTimestamp = LocalDateTime.parse(endDate);
startOfRange = startTimestamp.atZone(userTimezone).toInstant();
endOfRange = endTimestamp.atZone(userTimezone).toInstant();
} catch (DateTimeParseException ignored) {
}
if (startOfRange == null && endOfRange == null) {
LocalDate selectedStartDate = LocalDate.parse(startDate);
LocalDate selectedEndDate = LocalDate.parse(endDate);
startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
}
} else if (date != null) {
// Single date mode (backward compatibility)
LocalDate selectedDate = LocalDate.parse(date);
startOfRange = selectedDate.atStartOfDay(userTimezone).toInstant();
endOfRange = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
} else {
@@ -103,13 +144,7 @@ public class LocationDataApiController {
User user = userJdbcService.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
List<RawLocationPoint> pointsInBoxWithNeighbors;
if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange,endOfRange);
} else {
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng);
}
List<List<LocationPoint>> segments = extractPathSegments(pointsInBoxWithNeighbors, minLat, maxLat, minLng, maxLng);
List<List<LocationPoint>> segments = loadSegmentsInBoundingBoxAndTime(user, minLat, maxLat, minLng, maxLng, startOfRange, endOfRange);
List<RawLocationDataResponse.Segment> result = segments.stream().map(s -> {
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
return new RawLocationDataResponse.Segment(simplifiedPoints);
@@ -164,6 +199,15 @@ public class LocationDataApiController {
}
}
private List<List<LocationPoint>> loadSegmentsInBoundingBoxAndTime(User user, Double minLat, Double maxLat, Double minLng, Double maxLng, Instant startOfRange, Instant endOfRange) {
List<RawLocationPoint> pointsInBoxWithNeighbors;
if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange);
} else {
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng);
}
return extractPathSegments(pointsInBoxWithNeighbors, minLat, maxLat, minLng, maxLng);
}
private List<List<LocationPoint>> extractPathSegments(List<RawLocationPoint> points, Double minLat, Double maxLat, Double minLng, Double maxLng) {
List<List<LocationPoint>> segments = new ArrayList<>();
@@ -209,4 +253,5 @@ public class LocationDataApiController {
point.getLongitude() <= maxLng;
}
}

View File

@@ -1,106 +0,0 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.dto.PhotoResponse;
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/photos")
public class PhotoApiController {
private final ImmichIntegrationService immichIntegrationService;
private final RestTemplate restTemplate;
public PhotoApiController(ImmichIntegrationService immichIntegrationService, RestTemplate restTemplate) {
this.immichIntegrationService = immichIntegrationService;
this.restTemplate = restTemplate;
}
@GetMapping("/range")
public ResponseEntity<List<PhotoResponse>> getPhotosForRange(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false, defaultValue = "UTC") String timezone,
@AuthenticationPrincipal User user) {
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
return ResponseEntity.ok(photos);
}
@GetMapping("/proxy/{assetId}/thumbnail")
public ResponseEntity<byte[]> getPhotoThumbnail(
@PathVariable String assetId,
@AuthenticationPrincipal User user) {
return proxyImageRequest(user, assetId, "thumbnail");
}
@GetMapping("/proxy/{assetId}/original")
public ResponseEntity<byte[]> getPhotoOriginal(
@PathVariable String assetId,
@AuthenticationPrincipal User user) {
return proxyImageRequest(user, assetId, "fullsize");
}
private ResponseEntity<byte[]> proxyImageRequest(User user, String assetId, String size) {
Optional<ImmichIntegration> integrationOpt = immichIntegrationService.getIntegrationForUser(user);
if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) {
return ResponseEntity.notFound().build();
}
ImmichIntegration integration = integrationOpt.get();
try {
String baseUrl = integration.getServerUrl().endsWith("/") ?
integration.getServerUrl() : integration.getServerUrl() + "/";
String imageUrl = baseUrl + "api/assets/" + assetId + "/thumbnail?size=" + size;
HttpHeaders headers = new HttpHeaders();
headers.add("x-api-key", integration.getApiToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<byte[]> response = restTemplate.exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
HttpHeaders responseHeaders = new HttpHeaders();
// Copy content type from Immich response if available
if (response.getHeaders().getContentType() != null) {
responseHeaders.setContentType(response.getHeaders().getContentType());
} else {
// Default to JPEG for images
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
}
// Set cache headers for better performance
responseHeaders.setCacheControl("public, max-age=3600");
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
}
} catch (Exception e) {
// Log error but don't expose details
return ResponseEntity.notFound().build();
}
return ResponseEntity.notFound().build();
}
}

View File

@@ -0,0 +1,58 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.StorageService;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/photos/reitti")
public class ReittiPhotoApiController {
private final StorageService storageService;
public ReittiPhotoApiController(StorageService storageService) {
this.storageService = storageService;
}
@GetMapping("/{filename}")
public ResponseEntity<InputStreamResource> getPhoto(@PathVariable String filename, @AuthenticationPrincipal User user) {
try {
StorageService.StorageContent result = this.storageService.read("images/" + filename);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.valueOf(result.getContentType()));
responseHeaders.setContentLength(result.getContentLength());
responseHeaders.setCacheControl("public, max-age=3600");
return ResponseEntity.ok()
.headers(responseHeaders)
.body(new InputStreamResource(result.getInputStream()));
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/memories/{memoryId}/{filename}")
public ResponseEntity<InputStreamResource> getPhotoForMemory(@PathVariable String memoryId,
@PathVariable String filename,
@AuthenticationPrincipal User user) {
try {
StorageService.StorageContent result = this.storageService.read("memories/" + memoryId + "/" + filename);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.valueOf(result.getContentType()));
responseHeaders.setContentLength(result.getContentLength());
responseHeaders.setCacheControl("public, max-age=3600");
return ResponseEntity.ok()
.headers(responseHeaders)
.body(new InputStreamResource(result.getInputStream()));
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -0,0 +1,11 @@
package com.dedicatedcode.reitti.controller.error;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.FORBIDDEN)
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
package com.dedicatedcode.reitti.controller.error;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class PageNotFoundException extends RuntimeException {
public PageNotFoundException(String message) {
super(message);
}
}

View File

@@ -6,16 +6,16 @@ import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSharing;
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
import com.dedicatedcode.reitti.repository.UserJdbcService;
import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
import com.dedicatedcode.reitti.service.AvatarService;
import com.dedicatedcode.reitti.service.I18nService;
import com.dedicatedcode.reitti.service.MagicLinkTokenService;
import com.dedicatedcode.reitti.service.RequestHelper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@@ -23,43 +23,41 @@ import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Controller
@RequestMapping("/settings/share-access")
public class ShareAccessController {
private final MagicLinkJdbcService magicLinkJdbcService;
private final MagicLinkTokenService magicLinkTokenService;
private final UserJdbcService userJdbcService;
private final UserSharingJdbcService userSharingJdbcService;
private final AvatarService avatarService;
private final MessageSource messageSource;
private final PasswordEncoder passwordEncoder;
private final boolean dataManagementEnabled;
private final I18nService i18n;
public ShareAccessController(MagicLinkJdbcService magicLinkJdbcService,
public ShareAccessController(MagicLinkTokenService magicLinkTokenService,
UserJdbcService userJdbcService,
UserSharingJdbcService userSharingJdbcService, AvatarService avatarService,
MessageSource messageSource,
PasswordEncoder passwordEncoder,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
this.magicLinkJdbcService = magicLinkJdbcService;
UserSharingJdbcService userSharingJdbcService,
AvatarService avatarService,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
I18nService i18nService) {
this.magicLinkTokenService = magicLinkTokenService;
this.userJdbcService = userJdbcService;
this.userSharingJdbcService = userSharingJdbcService;
this.avatarService = avatarService;
this.messageSource = messageSource;
this.passwordEncoder = passwordEncoder;
this.dataManagementEnabled = dataManagementEnabled;
this.i18n = i18nService;
}
@GetMapping
public String magicLinksContent(@AuthenticationPrincipal User user, Model model) {
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
List<MagicLinkToken> tokens = magicLinkTokenService.getTokensForUser(user);
model.addAttribute("tokens", tokens);
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
model.addAttribute("accessLevels", List.of(MagicLinkAccessLevel.FULL_ACCESS, MagicLinkAccessLevel.ONLY_LIVE, MagicLinkAccessLevel.ONLY_LIVE_WITH_PHOTOS));
model.addAttribute("activeSection", "sharing");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
@@ -84,9 +82,6 @@ public class ShareAccessController {
HttpServletRequest request,
Model model) {
try {
String rawToken = UUID.randomUUID().toString();
String tokenHash = passwordEncoder.encode(rawToken);
// Calculate expiry date
Instant expiryInstant = null;
if (expiryDate != null && !expiryDate.trim().isEmpty()) {
@@ -94,8 +89,8 @@ public class ShareAccessController {
LocalDate localDate = LocalDate.parse(expiryDate);
expiryInstant = localDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("magic.links.invalid.date"));
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
model.addAttribute("errorMessage", i18n.translate("magic.links.invalid.date"));
List<MagicLinkToken> tokens = magicLinkTokenService.getTokensForUser(user);
model.addAttribute("tokens", tokens);
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
return "settings/share-access :: magic-links-content";
@@ -103,27 +98,24 @@ public class ShareAccessController {
}
// Create token object
MagicLinkToken token = new MagicLinkToken(null, name, tokenHash, accessLevel, expiryInstant, null, null, false);
magicLinkJdbcService.create(user, token);
String rawToken = magicLinkTokenService.createMapShareToken(user, name, accessLevel, expiryInstant);
// Build the full magic link URL
String baseUrl = getBaseUrl(request);
String baseUrl = RequestHelper.getBaseUrl(request);
String magicLinkUrl = baseUrl + "/access?mt=" + rawToken;
model.addAttribute("newTokenName", name);
model.addAttribute("magicLinkUrl", magicLinkUrl);
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
model.addAttribute("tokens", tokens);
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
return "settings/share-access :: magic-links-content";
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("magic.links.create.error", e.getMessage()));
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
model.addAttribute("tokens", tokens);
model.addAttribute("errorMessage", i18n.translate("magic.links.create.error", e.getMessage()));
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
return "settings/share-access :: magic-links-content";
@@ -135,36 +127,18 @@ public class ShareAccessController {
@AuthenticationPrincipal User user,
Model model) {
try {
magicLinkJdbcService.delete(id);
model.addAttribute("successMessage", getMessage("magic.links.deleted.success"));
magicLinkTokenService.deleteToken(id);
model.addAttribute("successMessage", i18n.translate("magic.links.deleted.success"));
} catch (Exception e) {
model.addAttribute("errorMessage", getMessage("magic.links.delete.error", e.getMessage()));
model.addAttribute("errorMessage", i18n.translate("magic.links.delete.error", e.getMessage()));
}
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
model.addAttribute("tokens", tokens);
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
return "settings/share-access :: magic-links-content";
}
private String getBaseUrl(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
String contextPath = request.getContextPath();
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(serverName);
if ((scheme.equals("http") && serverPort != 80) || (scheme.equals("https") && serverPort != 443)) {
url.append(":").append(serverPort);
}
url.append(contextPath);
return url.toString();
}
@PostMapping("/users")
public String updateUserSharing(@AuthenticationPrincipal User user,
@RequestParam(value = "sharedUserIds", required = false) List<Long> sharedUserIds,
@@ -184,9 +158,9 @@ public class ShareAccessController {
.collect(Collectors.toSet());
this.userSharingJdbcService.delete(toDelete);
this.userSharingJdbcService.create(user, toCreate);
model.addAttribute("shareSuccessMessage", getMessage("share-with.updated.success"));
model.addAttribute("shareSuccessMessage", i18n.translate("share-with.updated.success"));
} catch (Exception e) {
model.addAttribute("shareErrorMessage", getMessage("share-with.update.error", e.getMessage()));
model.addAttribute("shareErrorMessage", i18n.translate("share-with.update.error", e.getMessage()));
}
List<UserDto> availableUsers = loadAvailableUsers(user);
@@ -200,10 +174,6 @@ public class ShareAccessController {
return "settings/share-access :: share-with-content";
}
private String getMessage(String key, Object... args) {
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
}
private List<UserDto> loadAvailableUsers(User user) {
return userJdbcService.getAllUsers().stream()
.filter(u -> !u.getId().equals(user.getId()))
@@ -220,9 +190,9 @@ public class ShareAccessController {
Model model) {
try {
userSharingJdbcService.dismissSharedAccess(id, user.getId());
model.addAttribute("shareSuccessMessage", getMessage("shared-with-me.dismissed.success"));
model.addAttribute("shareSuccessMessage", i18n.translate("shared-with-me.dismissed.success"));
} catch (Exception e) {
model.addAttribute("shareErrorMessage", getMessage("shared-with-me.dismiss.error", e.getMessage()));
model.addAttribute("shareErrorMessage", i18n.translate("shared-with-me.dismiss.error", e.getMessage()));
}
// Reload data for the fragment

View File

@@ -15,6 +15,7 @@ public record UserSettingsDTO(
Double homeLongitude,
TilesCustomizationDTO tiles,
UIMode uiMode,
PhotoMode photoMode,
TimeDisplayMode displayMode,
ZoneId timezoneOverride,
String customCssUrl
@@ -23,8 +24,13 @@ public record UserSettingsDTO(
public enum UIMode {
FULL,
SHARED_FULL,
VIEW_MEMORIES,
SHARED_LIVE_MODE_ONLY
}
public enum PhotoMode {
ENABLED,
DISABLED
}
public record TilesCustomizationDTO(String service, String attribution){}

View File

@@ -1,7 +1,14 @@
package com.dedicatedcode.reitti.model;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
public enum Role {
ADMIN,
USER,
API_ACCESS
API_ACCESS;
public GrantedAuthority asAuthority() {
return new SimpleGrantedAuthority( "ROLE_" + name());
}
}

View File

@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
import java.time.Instant;
import java.util.Objects;
public class ProcessedVisit {
@@ -67,4 +68,16 @@ public class ProcessedVisit {
", version=" + version +
'}';
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ProcessedVisit that = (ProcessedVisit) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}

View File

@@ -9,6 +9,7 @@ public class SignificantPlace implements Serializable {
private final Long id;
private final String name;
private final String address;
private final String city;
private final String countryCode;
private final Double latitudeCentroid;
private final Double longitudeCentroid;
@@ -18,12 +19,13 @@ public class SignificantPlace implements Serializable {
private final Long version;
public static SignificantPlace create(Double latitude, Double longitude) {
return new SignificantPlace(null, null, null, null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
return new SignificantPlace(null, null, null, null,null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
}
public SignificantPlace(Long id,
String name,
String address,
String city,
String countryCode,
Double latitudeCentroid,
Double longitudeCentroid,
@@ -33,6 +35,7 @@ public class SignificantPlace implements Serializable {
this.id = id;
this.name = name;
this.address = address;
this.city = city;
this.countryCode = countryCode;
this.latitudeCentroid = latitudeCentroid;
this.longitudeCentroid = longitudeCentroid;
@@ -84,31 +87,35 @@ public class SignificantPlace implements Serializable {
// Wither methods
public SignificantPlace withGeocoded(boolean geocoded) {
return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version);
}
public SignificantPlace withName(String name) {
return new SignificantPlace(this.id, name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withAddress(String address) {
return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withCountryCode(String countryCode) {
return new SignificantPlace(this.id, this.name, this.address, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withType(PlaceType type) {
return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version);
}
public SignificantPlace withId(Long id) {
return new SignificantPlace(id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withTimezone(ZoneId timezone) {
return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
public SignificantPlace withCity(String city) {
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
}
@Override
@@ -131,6 +138,9 @@ public class SignificantPlace implements Serializable {
'}';
}
public String getCity() {
return this.city;
}
public enum PlaceType {
RESTAURANT("lni-restaurant", "place.type.restaurant"),
@@ -151,6 +161,17 @@ public class SignificantPlace implements Serializable {
CHURCH("lni-church", "place.type.church"),
CINEMA("lni-camera", "place.type.cinema"),
CAFE("lni-coffee-cup", "place.type.cafe"),
MUSEUM("lni-museum", "place.type.museum"),
LANDMARK("lni-landmark", "place.type.landmark"),
TOURIST_ATTRACTION("lni-map", "place.type.tourist_attraction"),
HISTORIC_SITE("lni-history", "place.type.historic_site"),
MONUMENT("lni-monument", "place.type.monument"),
SHOPPING_MALL("lni-shopping-basket", "place.type.shopping_mall"),
MARKET("lni-store", "place.type.market"),
GALLERY("lni-gallery", "place.type.gallery"),
THEATER("lni-theater", "place.type.theater"),
GROCERY_STORE("lni-cart", "place.type.grocery_store"),
ATM("lni-money-location", "place.type.atm"),
OTHER("lni-map-marker", "place.type.other");
private final String iconClass;

View File

@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
import java.time.Instant;
import java.util.Objects;
public class Trip {
@@ -75,4 +76,16 @@ public class Trip {
public Trip withId(Long id) {
return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, this.version);
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Trip trip = (Trip) o;
return Objects.equals(id, trip.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}

View File

@@ -0,0 +1,8 @@
package com.dedicatedcode.reitti.model.memory;
public enum BlockType {
TEXT,
IMAGE_GALLERY,
CLUSTER_TRIP,
CLUSTER_VISIT,
}

View File

@@ -0,0 +1,6 @@
package com.dedicatedcode.reitti.model.memory;
public enum HeaderType {
IMAGE,
MAP
}

View File

@@ -0,0 +1,139 @@
package com.dedicatedcode.reitti.model.memory;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
public class Memory implements Serializable {
private final Long id;
private final String title;
private final String description;
private final Instant startDate;
private final Instant endDate;
private final HeaderType headerType;
private final String headerImageUrl;
private final Instant createdAt;
private final Instant updatedAt;
private final Long version;
public Memory(String title, String description, Instant startDate, Instant endDate, HeaderType headerType, String headerImageUrl) {
this(null, title, description, startDate, endDate, headerType, headerImageUrl, Instant.now(), Instant.now(), 1L);
}
public Memory(Long id, String title, String description, Instant startDate, Instant endDate, HeaderType headerType, String headerImageUrl, Instant createdAt, Instant updatedAt, Long version) {
this.id = id;
this.title = title;
this.description = description;
this.startDate = startDate;
this.endDate = endDate;
this.headerType = headerType;
this.headerImageUrl = headerImageUrl;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.version = version;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public Instant getStartDate() {
return startDate;
}
public Instant getEndDate() {
return endDate;
}
public HeaderType getHeaderType() {
return headerType;
}
public String getHeaderImageUrl() {
return headerImageUrl;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Long getVersion() {
return version;
}
public Memory withId(Long id) {
return new Memory(id, this.title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, this.updatedAt, this.version);
}
public Memory withTitle(String title) {
return new Memory(this.id, title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withDescription(String description) {
return new Memory(this.id, this.title, description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withStartDate(Instant startDate) {
return new Memory(this.id, this.title, this.description, startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withEndDate(Instant endDate) {
return new Memory(this.id, this.title, this.description, this.startDate, endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withHeaderType(HeaderType headerType) {
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withHeaderImageUrl(String headerImageUrl) {
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, this.headerType, headerImageUrl, this.createdAt, Instant.now(), this.version);
}
public Memory withVersion(Long version) {
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, this.updatedAt, version);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Memory memory = (Memory) o;
return id != null ? id.equals(memory.id) : memory.id == null;
}
@Override
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
@Override
public String toString() {
return "Memory{" +
"id=" + id +
", title='" + title + '\'' +
", description='" + description + '\'' +
", startDate=" + startDate +
", endDate=" + endDate +
", headerType=" + headerType +
", headerImageUrl='" + headerImageUrl + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
", version=" + version +
'}';
}
}

View File

@@ -0,0 +1,82 @@
package com.dedicatedcode.reitti.model.memory;
import java.io.Serializable;
public class MemoryBlock implements Serializable {
private final Long id;
private final Long memoryId;
private final BlockType blockType;
private final Integer position;
private final Long version;
public MemoryBlock(Long memoryId, BlockType blockType, Integer position) {
this(null, memoryId, blockType, position, 1L);
}
public MemoryBlock(Long id, Long memoryId, BlockType blockType, Integer position, Long version) {
this.id = id;
this.memoryId = memoryId;
this.blockType = blockType;
this.position = position;
this.version = version;
}
public Long getId() {
return id;
}
public Long getMemoryId() {
return memoryId;
}
public BlockType getBlockType() {
return blockType;
}
public Integer getPosition() {
return position;
}
public Long getVersion() {
return version;
}
public MemoryBlock withId(Long id) {
return new MemoryBlock(id, this.memoryId, this.blockType, this.position, this.version);
}
public MemoryBlock withPosition(Integer position) {
return new MemoryBlock(this.id, this.memoryId, this.blockType, position, this.version);
}
public MemoryBlock withVersion(Long version) {
return new MemoryBlock(this.id, this.memoryId, this.blockType, this.position, version);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryBlock that = (MemoryBlock) o;
return id != null ? id.equals(that.id) : that.id == null;
}
@Override
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryBlock{" +
"id=" + id +
", memoryId=" + memoryId +
", blockType=" + blockType +
", position=" + position +
", version=" + version +
'}';
}
}

View File

@@ -0,0 +1,107 @@
package com.dedicatedcode.reitti.model.memory;
import java.io.Serializable;
import java.util.List;
import java.util.Objects;
public class MemoryBlockImageGallery implements MemoryBlockPart, Serializable {
private final Long blockId;
private final List<GalleryImage> images;
public MemoryBlockImageGallery(Long blockId, List<GalleryImage> images) {
this.blockId = blockId;
this.images = images != null ? List.copyOf(images) : List.of();
}
public Long getBlockId() {
return blockId;
}
public List<GalleryImage> getImages() {
return images;
}
@Override
public BlockType getType() {
return BlockType.IMAGE_GALLERY;
}
public MemoryBlockImageGallery withImages(List<GalleryImage> images) {
return new MemoryBlockImageGallery(this.blockId, images);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryBlockImageGallery that = (MemoryBlockImageGallery) o;
return Objects.equals(blockId, that.blockId);
}
@Override
public int hashCode() {
return blockId != null ? blockId.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryBlockImageGallery{" +
"blockId=" + blockId +
", images=" + images +
'}';
}
public static class GalleryImage implements Serializable {
private final String imageUrl;
private final String integration;
private final String integrationId;
private final String caption;
public GalleryImage(String imageUrl, String caption, String integration, String integrationId) {
this.imageUrl = imageUrl;
this.caption = caption;
this.integration = integration;
this.integrationId = integrationId;
}
public String getImageUrl() {
return imageUrl;
}
public String getCaption() {
return caption;
}
public String getIntegration() {
return integration;
}
public String getIntegrationId() {
return integrationId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
GalleryImage that = (GalleryImage) o;
return Objects.equals(imageUrl, that.imageUrl) && Objects.equals(caption, that.caption);
}
@Override
public int hashCode() {
return Objects.hash(imageUrl, caption);
}
@Override
public String toString() {
return "GalleryImage{" +
"imageUrl='" + imageUrl + '\'' +
", caption='" + caption + '\'' +
'}';
}
}
}

View File

@@ -0,0 +1,5 @@
package com.dedicatedcode.reitti.model.memory;
public interface MemoryBlockPart {
BlockType getType();
}

View File

@@ -0,0 +1,65 @@
package com.dedicatedcode.reitti.model.memory;
import java.io.Serializable;
public class MemoryBlockText implements MemoryBlockPart, Serializable {
private final Long blockId;
private final String headline;
private final String content;
public MemoryBlockText(Long blockId, String headline, String content) {
this.blockId = blockId;
this.headline = headline;
this.content = content;
}
public Long getBlockId() {
return blockId;
}
public String getHeadline() {
return headline;
}
public String getContent() {
return content;
}
@Override
public BlockType getType() {
return BlockType.TEXT;
}
public MemoryBlockText withHeadline(String headline) {
return new MemoryBlockText(this.blockId, headline, this.content);
}
public MemoryBlockText withContent(String content) {
return new MemoryBlockText(this.blockId, this.headline, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryBlockText that = (MemoryBlockText) o;
return blockId != null ? blockId.equals(that.blockId) : that.blockId == null;
}
@Override
public int hashCode() {
return blockId != null ? blockId.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryBlockText{" +
"blockId=" + blockId +
", headline='" + headline + '\'' +
", content='" + content + '\'' +
'}';
}
}

View File

@@ -0,0 +1,74 @@
package com.dedicatedcode.reitti.model.memory;
import java.io.Serializable;
import java.util.List;
import java.util.Objects;
public class MemoryClusterBlock implements MemoryBlockPart, Serializable {
private final Long blockId;
private final List<Long> partIds;
private final String title;
private final String description;
private final BlockType type;
public MemoryClusterBlock(Long blockId, List<Long> partIds, String title, String description, BlockType type) {
this.blockId = blockId;
this.partIds = partIds != null ? List.copyOf(partIds) : List.of();
this.title = title;
this.description = description;
this.type = type;
}
public Long getBlockId() {
return blockId;
}
public List<Long> getPartIds() {
return partIds;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
@Override
public BlockType getType() {
return this.type;
}
public MemoryClusterBlock withPartIds(List<Long> partIds) {
return new MemoryClusterBlock(this.blockId, partIds, this.title, this.description, this.type);
}
public MemoryClusterBlock withTitle(String title) {
return new MemoryClusterBlock(this.blockId, this.partIds, title, this.description, this.type);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryClusterBlock that = (MemoryClusterBlock) o;
return Objects.equals(blockId, that.blockId);
}
@Override
public int hashCode() {
return blockId != null ? blockId.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryClusterBlock{" +
"blockId=" + blockId +
", tripIds=" + partIds +
", title='" + title + '\'' +
", description='" + description + '\'' +
'}';
}
}

View File

@@ -0,0 +1,19 @@
package com.dedicatedcode.reitti.model.memory;
public class MemoryOverviewDTO {
private final Memory memory;
private final String rawLocationUrl;
public MemoryOverviewDTO(Memory memory, String rawLocationUrl) {
this.memory = memory;
this.rawLocationUrl = rawLocationUrl;
}
public Memory getMemory() {
return memory;
}
public String getRawLocationUrl() {
return rawLocationUrl;
}
}

View File

@@ -0,0 +1,135 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.Trip;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable {
private final MemoryClusterBlock clusterBlock;
private final List<Trip> trips;
private final String rawLocationPointsUrl;
private final LocalDateTime adjustedStartTime;
private final LocalDateTime adjustedEndTime;
private final Long completeDuration;
private final Long movingDuration;
public MemoryTripClusterBlockDTO(MemoryClusterBlock clusterBlock, List<Trip> trips, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration, Long movingDuration) {
this.clusterBlock = clusterBlock;
this.trips = trips != null ? List.copyOf(trips) : List.of();
this.rawLocationPointsUrl = rawLocationPointsUrl;
this.adjustedStartTime = adjustedStartTime;
this.adjustedEndTime = adjustedEndTime;
this.completeDuration = completeDuration;
this.movingDuration = movingDuration;
}
public MemoryClusterBlock getClusterBlock() {
return clusterBlock;
}
public List<Trip> getTrips() {
return trips;
}
// Delegate to clusterBlock for common methods
public Long getBlockId() {
return clusterBlock.getBlockId();
}
public String getTitle() {
return clusterBlock.getTitle();
}
public String getDescription() {
return clusterBlock.getDescription();
}
public Long getCompleteDuration() {
return completeDuration;
}
public Long getMovingDuration() {
return movingDuration;
}
// Combined info methods
public Instant getCombinedStartTime() {
if (trips == null || trips.isEmpty()) return null;
return trips.stream()
.map(Trip::getStartTime)
.min(Instant::compareTo)
.orElse(null);
}
public Instant getCombinedEndTime() {
if (trips == null || trips.isEmpty()) return null;
return trips.stream()
.map(Trip::getEndTime)
.max(Instant::compareTo)
.orElse(null);
}
public Long getCombinedDurationSeconds() {
if (trips == null || trips.isEmpty()) return 0L;
return trips.stream()
.mapToLong(Trip::getDurationSeconds)
.sum();
}
public List<String> getCombinedStartPlaces() {
if (trips == null || trips.isEmpty()) return List.of();
return trips.stream()
.map(trip -> trip.getStartVisit() != null && trip.getStartVisit().getPlace() != null ? trip.getStartVisit().getPlace().getName() : null)
.toList();
}
public List<String> getCombinedEndPlaces() {
if (trips == null || trips.isEmpty()) return List.of();
return trips.stream()
.map(trip -> trip.getEndVisit() != null && trip.getEndVisit().getPlace() != null ? trip.getEndVisit().getPlace().getName() : null)
.toList();
}
public String getRawLocationPointsUrl() {
return rawLocationPointsUrl;
}
public LocalDateTime getAdjustedEndTime() {
return adjustedEndTime;
}
public LocalDateTime getAdjustedStartTime() {
return adjustedStartTime;
}
@Override
public BlockType getType() {
return BlockType.CLUSTER_TRIP;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryTripClusterBlockDTO that = (MemoryTripClusterBlockDTO) o;
return Objects.equals(clusterBlock, that.clusterBlock);
}
@Override
public int hashCode() {
return clusterBlock != null ? clusterBlock.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryClusterBlockDTO{" +
"clusterBlock=" + clusterBlock +
", trips=" + trips +
'}';
}
}

View File

@@ -0,0 +1,116 @@
package com.dedicatedcode.reitti.model.memory;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.Visit;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable {
private final MemoryClusterBlock clusterBlock;
private final List<ProcessedVisit> visits;
private final String rawLocationPointsUrl;
private final LocalDateTime adjustedStartTime;
private final LocalDateTime adjustedEndTime;
private final Long completeDuration;
public MemoryVisitClusterBlockDTO(MemoryClusterBlock clusterBlock, List<ProcessedVisit> visits, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration) {
this.clusterBlock = clusterBlock;
this.visits = visits != null ? List.copyOf(visits) : List.of();
this.rawLocationPointsUrl = rawLocationPointsUrl;
this.adjustedStartTime = adjustedStartTime;
this.adjustedEndTime = adjustedEndTime;
this.completeDuration = completeDuration;
}
public MemoryClusterBlock getClusterBlock() {
return clusterBlock;
}
public List<ProcessedVisit> getVisits() {
return visits;
}
// Delegate to clusterBlock for common methods
public Long getBlockId() {
return clusterBlock.getBlockId();
}
public String getTitle() {
return clusterBlock.getTitle();
}
public String getDescription() {
return clusterBlock.getDescription();
}
public Long getCompleteDuration() {
return completeDuration;
}
// Combined info methods
public Instant getCombinedStartTime() {
if (visits == null || visits.isEmpty()) return null;
return visits.stream()
.map(ProcessedVisit::getStartTime)
.min(Instant::compareTo)
.orElse(null);
}
public Instant getCombinedEndTime() {
if (visits == null || visits.isEmpty()) return null;
return visits.stream()
.map(ProcessedVisit::getEndTime)
.max(Instant::compareTo)
.orElse(null);
}
public Long getCombinedDurationSeconds() {
if (visits == null || visits.isEmpty()) return 0L;
return visits.stream()
.mapToLong(ProcessedVisit::getDurationSeconds)
.sum();
}
public String getRawLocationPointsUrl() {
return rawLocationPointsUrl;
}
public LocalDateTime getAdjustedEndTime() {
return adjustedEndTime;
}
public LocalDateTime getAdjustedStartTime() {
return adjustedStartTime;
}
@Override
public BlockType getType() {
return BlockType.CLUSTER_VISIT;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MemoryVisitClusterBlockDTO that = (MemoryVisitClusterBlockDTO) o;
return Objects.equals(clusterBlock, that.clusterBlock);
}
@Override
public int hashCode() {
return clusterBlock != null ? clusterBlock.hashCode() : 0;
}
@Override
public String toString() {
return "MemoryClusterBlockDTO{" +
"clusterBlock=" + clusterBlock +
", Visits=" + visits +
'}';
}
}

View File

@@ -1,7 +1,17 @@
package com.dedicatedcode.reitti.model.security;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
public enum MagicLinkAccessLevel {
FULL_ACCESS,
ONLY_LIVE,
ONLY_LIVE_WITH_PHOTOS
ONLY_LIVE_WITH_PHOTOS,
MEMORY_VIEW_ONLY,
MEMORY_EDIT_ACCESS;
public GrantedAuthority asAuthority() {
return new SimpleGrantedAuthority( "ROLE_MAGIC_LINK_" + name());
}
}

View File

@@ -0,0 +1,6 @@
package com.dedicatedcode.reitti.model.security;
public enum MagicLinkResourceType {
MAP,
MEMORY
}

View File

@@ -11,14 +11,21 @@ public class MagicLinkToken implements Serializable {
private final Instant expiryDate;
private final Instant createdAt;
private final Instant lastUsed;
private final MagicLinkResourceType resourceType;
private final Long resourceId;
private final boolean isUsed; // To enforce one-time use if needed
public MagicLinkToken(Long id, String name, String tokenHash, MagicLinkAccessLevel accessLevel, Instant expiryDate, Instant createdAt, Instant lastUsed, boolean isUsed) {
this(id, name, tokenHash, accessLevel, expiryDate, MagicLinkResourceType.MAP, null, createdAt, lastUsed, isUsed);
}
public MagicLinkToken(Long id, String name, String tokenHash, MagicLinkAccessLevel accessLevel, Instant expiryDate, MagicLinkResourceType resourceType, Long resourceId, Instant createdAt, Instant lastUsed, boolean isUsed) {
this.id = id;
this.name = name;
this.tokenHash = tokenHash;
this.accessLevel = accessLevel;
this.expiryDate = expiryDate;
this.resourceType = resourceType;
this.resourceId = resourceId;
this.createdAt = createdAt;
this.lastUsed = lastUsed;
this.isUsed = isUsed;
@@ -55,4 +62,12 @@ public class MagicLinkToken implements Serializable {
public Instant getCreatedAt() {
return createdAt;
}
public MagicLinkResourceType getResourceType() {
return resourceType;
}
public Long getResourceId() {
return resourceId;
}
}

View File

@@ -0,0 +1,62 @@
package com.dedicatedcode.reitti.model.security;
import com.dedicatedcode.reitti.model.Role;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class TokenUser extends User {
private final List<GrantedAuthority> authorities = new ArrayList<>();
private final User user;
private final MagicLinkResourceType type;
private final Long resourceId;
public TokenUser(User user, MagicLinkResourceType type, Long resourceId, List<String> additionalAuthorities) {
this.user = user;
this.type = type;
this.resourceId = resourceId;
this.authorities.addAll(additionalAuthorities.stream().map(SimpleGrantedAuthority::new).toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Long getId() {
return user.getId();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getDisplayName() {
return user.getDisplayName();
}
@Override
public String getPassword() {
throw new UnsupportedOperationException("TokenUser is not a password user");
}
@Override
public Role getRole() {
return Role.API_ACCESS;
}
@Override
public String getProfileUrl() {
return user.getProfileUrl();
}
public boolean grantsAccessTo(MagicLinkResourceType type, Long resourceId){
return this.type.equals(type) && (this.resourceId == null || this.resourceId.equals(resourceId));
}
}

View File

@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.cache.annotation.CacheEvict;
@@ -36,8 +37,8 @@ public class MagicLinkJdbcService {
@CacheEvict(value = "magic-links", allEntries = true)
public MagicLinkToken create(User user, MagicLinkToken token) {
String sql = """
INSERT INTO magic_link_tokens (user_id, name, token_hash, access_level, expiry_date, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO magic_link_tokens (user_id, name, token_hash, access_level, expiry_date, created_at, resource_type, resource_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
KeyHolder keyHolder = new GeneratedKeyHolder();
@@ -51,11 +52,17 @@ public class MagicLinkJdbcService {
ps.setString(4, token.getAccessLevel().name());
ps.setTimestamp(5, token.getExpiryDate() != null ? Timestamp.from(token.getExpiryDate()) : null);
ps.setTimestamp(6, Timestamp.from(now));
ps.setString(7, token.getResourceType().name());
if (token.getResourceId() == null) {
ps.setNull(8, java.sql.Types.BIGINT);
} else {
ps.setLong(8, token.getResourceId());
}
return ps;
}, keyHolder);
long id = keyHolder.getKey().longValue();
return new MagicLinkToken(id, token.getName(), token.getTokenHash(), token.getAccessLevel(), token.getExpiryDate(), now, null, false);
return new MagicLinkToken(id, token.getName(), token.getTokenHash(), token.getAccessLevel(), token.getExpiryDate(), token.getResourceType(), token.getResourceId(), now, null, false);
}
@CacheEvict(value = "magic-links", allEntries = true)
@@ -83,7 +90,7 @@ public class MagicLinkJdbcService {
@Cacheable(value = "magic-links", key = "#id")
public Optional<MagicLinkToken> findById(long id) {
String sql = """
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
FROM magic_link_tokens
WHERE id = ?
""";
@@ -96,7 +103,7 @@ public class MagicLinkJdbcService {
@Cacheable(value = "magic-links", key = "'hash:' + #tokenHash")
public Optional<MagicLinkToken> findByTokenHash(String tokenHash) {
String sql = """
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
FROM magic_link_tokens
WHERE token_hash = ?
""";
@@ -108,7 +115,7 @@ public class MagicLinkJdbcService {
@Transactional(readOnly = true)
public Optional<MagicLinkToken> findByRawToken(String rawToken) {
String sql = """
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
FROM magic_link_tokens
WHERE expiry_date IS NULL OR expiry_date > ?
""";
@@ -147,7 +154,7 @@ public class MagicLinkJdbcService {
@Cacheable(value = "magic-links", key = "'user:' + #user.id")
public List<MagicLinkToken> findByUser(User user) {
String sql = """
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
FROM magic_link_tokens
WHERE user_id = ?
ORDER BY created_at DESC
@@ -178,7 +185,9 @@ public class MagicLinkJdbcService {
boolean isUsed = lastUsed != null;
return new MagicLinkToken(id, name, tokenHash, accessLevel, expiryDate, createdAt, lastUsed, isUsed);
MagicLinkResourceType resourceTyp = MagicLinkResourceType.valueOf(rs.getString("resource_type"));
Long resourceId = rs.getLong("resource_id") == 0 ? null : rs.getLong("resource_id");
return new MagicLinkToken(id, name, tokenHash, accessLevel, expiryDate, resourceTyp, resourceId, createdAt, lastUsed, isUsed);
}
}
}

View File

@@ -0,0 +1,94 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.MemoryBlockImageGallery;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
@Repository
public class MemoryBlockImageGalleryJdbcService {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public MemoryBlockImageGalleryJdbcService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
private final RowMapper<MemoryBlockImageGallery> MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER = new RowMapper<>() {
@Override
public MemoryBlockImageGallery mapRow(ResultSet rs, int rowNum) throws SQLException {
Long blockId = rs.getLong("block_id");
String imagesJson = rs.getString("images");
List<MemoryBlockImageGallery.GalleryImage> images = null;
try {
images = objectMapper.readValue(imagesJson, new TypeReference<List<MemoryBlockImageGallery.GalleryImage>>() {});
} catch (Exception e) {
throw new SQLException("Failed to parse images JSON", e);
}
return new MemoryBlockImageGallery(blockId, images);
}
};
public MemoryBlockImageGallery create(MemoryBlockImageGallery gallery) {
try {
String imagesJson = objectMapper.writeValueAsString(gallery.getImages());
jdbcTemplate.update(
"INSERT INTO memory_block_image_gallery (block_id, images) VALUES (?, ?::jsonb)",
gallery.getBlockId(),
imagesJson
);
return gallery;
} catch (Exception e) {
throw new RuntimeException("Failed to create MemoryBlockImageGallery", e);
}
}
public MemoryBlockImageGallery update(MemoryBlockImageGallery gallery) {
try {
String imagesJson = objectMapper.writeValueAsString(gallery.getImages());
jdbcTemplate.update(
"UPDATE memory_block_image_gallery SET images = ?::jsonb WHERE block_id = ?",
imagesJson,
gallery.getBlockId()
);
return gallery;
} catch (Exception e) {
throw new RuntimeException("Failed to update MemoryBlockImageGallery", e);
}
}
public void delete(Long blockId) {
jdbcTemplate.update("DELETE FROM memory_block_image_gallery WHERE block_id = ?", blockId);
}
public void deleteByBlockId(Long blockId) {
jdbcTemplate.update("DELETE FROM memory_block_image_gallery WHERE block_id = ?", blockId);
}
public Optional<MemoryBlockImageGallery> findById(Long blockId) {
List<MemoryBlockImageGallery> results = jdbcTemplate.query(
"SELECT * FROM memory_block_image_gallery WHERE block_id = ?",
MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER,
blockId
);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public Optional<MemoryBlockImageGallery> findByBlockId(Long blockId) {
List<MemoryBlockImageGallery> results = jdbcTemplate.query(
"SELECT * FROM memory_block_image_gallery WHERE block_id = ?",
MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER,
blockId
);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
}

View File

@@ -0,0 +1,106 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.BlockType;
import com.dedicatedcode.reitti.model.memory.MemoryBlock;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Optional;
@Repository
public class MemoryBlockJdbcService {
private final JdbcTemplate jdbcTemplate;
public MemoryBlockJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<MemoryBlock> MEMORY_BLOCK_ROW_MAPPER = (rs, rowNum) -> new MemoryBlock(
rs.getLong("id"),
rs.getLong("memory_id"),
BlockType.valueOf(rs.getString("block_type")),
rs.getInt("position"),
rs.getLong("version")
);
public MemoryBlock create(MemoryBlock block) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO memory_block (memory_id, block_type, position, version) " +
"VALUES (?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, block.getMemoryId());
ps.setString(2, block.getBlockType().name());
ps.setInt(3, block.getPosition());
ps.setLong(4, block.getVersion());
return ps;
}, keyHolder);
Long id = (Long) keyHolder.getKeys().get("id");
return block.withId(id);
}
public MemoryBlock update(MemoryBlock block) {
int updated = jdbcTemplate.update(
"UPDATE memory_block " +
"SET position = ?, version = version + 1 " +
"WHERE id = ? AND version = ?",
block.getPosition(),
block.getId(),
block.getVersion()
);
if (updated == 0) {
throw new IllegalStateException("Memory block not found or version mismatch");
}
return block.withVersion(block.getVersion() + 1);
}
public void delete(Long blockId) {
jdbcTemplate.update("DELETE FROM memory_block WHERE id = ?", blockId);
}
public Optional<MemoryBlock> findById(User user, Long id) {
List<MemoryBlock> results = jdbcTemplate.query(
"SELECT * FROM memory_block LEFT JOIN memory ON memory_block.memory_id = memory.id WHERE memory_block.id = ? AND memory.user_id = ?",
MEMORY_BLOCK_ROW_MAPPER,
id,
user.getId()
);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public List<MemoryBlock> findByMemoryId(Long memoryId) {
return jdbcTemplate.query(
"SELECT * FROM memory_block WHERE memory_id = ? ORDER BY position",
MEMORY_BLOCK_ROW_MAPPER,
memoryId
);
}
public int getMaxPosition(Long memoryId) {
Integer maxPosition = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(position), -1) FROM memory_block WHERE memory_id = ?",
Integer.class,
memoryId
);
return maxPosition != null ? maxPosition : -1;
}
public void deleteByMemoryId(Long memoryId) {
this.jdbcTemplate.update("DELETE FROM memory_block WHERE memory_id = ?", memoryId);
}
}

View File

@@ -0,0 +1,58 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.MemoryBlockText;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class MemoryBlockTextJdbcService {
private final JdbcTemplate jdbcTemplate;
public MemoryBlockTextJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<MemoryBlockText> MEMORY_BLOCK_TEXT_ROW_MAPPER = (rs, rowNum) -> new MemoryBlockText(
rs.getLong("block_id"),
rs.getString("headline"),
rs.getString("content")
);
public MemoryBlockText create(MemoryBlockText blockText) {
jdbcTemplate.update(
"INSERT INTO memory_block_text (block_id, headline, content) VALUES (?, ?, ?)",
blockText.getBlockId(),
blockText.getHeadline(),
blockText.getContent()
);
return blockText;
}
public MemoryBlockText update(MemoryBlockText blockText) {
jdbcTemplate.update(
"UPDATE memory_block_text SET headline = ?, content = ? WHERE block_id = ?",
blockText.getHeadline(),
blockText.getContent(),
blockText.getBlockId()
);
return blockText;
}
public Optional<MemoryBlockText> findByBlockId(Long blockId) {
List<MemoryBlockText> results = jdbcTemplate.query(
"SELECT * FROM memory_block_text WHERE block_id = ?",
MEMORY_BLOCK_TEXT_ROW_MAPPER,
blockId
);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public void delete(Long blockId) {
jdbcTemplate.update("DELETE FROM memory_block_text WHERE block_id = ?", blockId);
}
}

View File

@@ -0,0 +1,80 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.BlockType;
import com.dedicatedcode.reitti.model.memory.MemoryBlockText;
import com.dedicatedcode.reitti.model.memory.MemoryClusterBlock;
import com.dedicatedcode.reitti.model.security.User;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
@Repository
public class MemoryClusterBlockRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public MemoryClusterBlockRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
public void save(User user, MemoryClusterBlock cluster) {
String sql = "INSERT INTO memory_block_cluster (block_id, part_ids, user_id, title, description, type) VALUES (?, ?::jsonb, ?, ?, ?, ?) " +
"ON CONFLICT (block_id) DO UPDATE SET part_ids = EXCLUDED.part_ids, title = EXCLUDED.title, description = EXCLUDED.description, type = EXCLUDED.type";
try {
String tripIdsJson = objectMapper.writeValueAsString(cluster.getPartIds());
jdbcTemplate.update(sql, cluster.getBlockId(), tripIdsJson, user.getId(), cluster.getTitle(), cluster.getDescription(), cluster.getType().name());
} catch (Exception e) {
throw new RuntimeException("Failed to save MemoryClusterBlock", e);
}
}
public Optional<MemoryClusterBlock> findByBlockId(User user, Long blockId) {
String sql = "SELECT block_id, part_ids, title, description, type FROM memory_block_cluster WHERE block_id = ? AND user_id = ?";
List<MemoryClusterBlock> results = jdbcTemplate.query(sql, new MemoryClusterBlockRowMapper(), blockId, user.getId());
return results.stream().findFirst();
}
public void deleteByBlockId(User user, Long blockId) {
String sql = "DELETE FROM memory_block_cluster WHERE block_id = ? AND user_id = ?";
jdbcTemplate.update(sql, blockId, user.getId());
}
public MemoryClusterBlock update(User user, MemoryClusterBlock cluster) {
String sql = "UPDATE memory_block_cluster SET part_ids = ?::jsonb, title = ?, description = ?, type = ? WHERE block_id = ? AND user_id = ?";
try {
String tripIdsJson = objectMapper.writeValueAsString(cluster.getPartIds());
this.jdbcTemplate.update(sql, tripIdsJson, cluster.getTitle(), cluster.getDescription(), cluster.getType().name(), cluster.getBlockId(), user.getId());
return cluster;
} catch (Exception e) {
throw new RuntimeException("Failed to save MemoryClusterBlock", e);
}
}
private class MemoryClusterBlockRowMapper implements RowMapper<MemoryClusterBlock> {
@Override
public MemoryClusterBlock mapRow(ResultSet rs, int rowNum) throws SQLException {
Long blockId = rs.getLong("block_id");
String tripIdsJson = rs.getString("part_ids");
List<Long> tripIds;
try {
tripIds = objectMapper.readValue(tripIdsJson, new TypeReference<>() {
});
} catch (Exception e) {
throw new SQLException("Failed to parse trip_ids JSON", e);
}
String title = rs.getString("title");
String description = rs.getString("description");
BlockType type = BlockType.valueOf(rs.getString("type"));
return new MemoryClusterBlock(blockId, tripIds, title, description, type);
}
}
}

View File

@@ -0,0 +1,150 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.memory.HeaderType;
import com.dedicatedcode.reitti.model.memory.Memory;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MemoryJdbcService {
private final JdbcTemplate jdbcTemplate;
public MemoryJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private static final RowMapper<Memory> MEMORY_ROW_MAPPER = (rs, rowNum) -> new Memory(
rs.getLong("id"),
rs.getString("title"),
rs.getString("description"),
rs.getTimestamp("start_date").toInstant(),
rs.getTimestamp("end_date").toInstant(),
HeaderType.valueOf(rs.getString("header_type")),
rs.getString("header_image_url"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant(),
rs.getLong("version")
);
public Memory create(User user, Memory memory) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO memory (user_id, title, description, start_date, end_date, header_type, header_image_url, created_at, updated_at, version) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, user.getId());
ps.setString(2, memory.getTitle());
ps.setString(3, memory.getDescription());
ps.setObject(4, Timestamp.from(memory.getStartDate()));
ps.setObject(5, Timestamp.from(memory.getEndDate()));
ps.setString(6, memory.getHeaderType().name());
ps.setString(7, memory.getHeaderImageUrl());
ps.setTimestamp(8, Timestamp.from(memory.getCreatedAt()));
ps.setTimestamp(9, Timestamp.from(memory.getUpdatedAt()));
ps.setLong(10, memory.getVersion());
return ps;
}, keyHolder);
Long id = (Long) keyHolder.getKeys().get("id");
return memory.withId(id);
}
public Memory update(User user, Memory memory) {
int updated = jdbcTemplate.update(
"UPDATE memory " +
"SET title = ?, description = ?, start_date = ?, end_date = ?, header_type = ?, header_image_url = ?, updated_at = ?, version = version + 1 " +
"WHERE id = ? AND user_id = ? AND version = ?",
memory.getTitle(),
memory.getDescription(),
Timestamp.from(memory.getStartDate()),
Timestamp.from(memory.getEndDate()),
memory.getHeaderType().name(),
memory.getHeaderImageUrl(),
Timestamp.from(memory.getUpdatedAt()),
memory.getId(),
user.getId(),
memory.getVersion()
);
if (updated == 0) {
throw new IllegalStateException("Memory not found or version mismatch");
}
return memory.withVersion(memory.getVersion() + 1);
}
public void delete(User user, Long memoryId) {
jdbcTemplate.update(
"DELETE FROM memory WHERE id = ? AND user_id = ?",
memoryId,
user.getId()
);
}
public Optional<Memory> findById(User user, Long id) {
List<Memory> results = jdbcTemplate.query(
"SELECT * FROM memory WHERE id = ? AND user_id = ?",
MEMORY_ROW_MAPPER,
id,
user.getId()
);
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
}
public List<Memory> findAllByUser(User user) {
return jdbcTemplate.query(
"SELECT * FROM memory WHERE user_id = ? ORDER BY created_at DESC",
MEMORY_ROW_MAPPER,
user.getId()
);
}
public List<Memory> findAllByUserAndYear(User user, int year) {
return jdbcTemplate.query(
"SELECT * FROM memory WHERE user_id = ? AND (extract(YEAR FROM start_date) = ? OR extract(YEAR FROM end_date) = ?) ORDER BY created_at DESC",
MEMORY_ROW_MAPPER,
user.getId(), year, year
);
}
public List<Memory> findByDateRange(User user, Instant startDate, Instant endDate) {
return jdbcTemplate.query(
"SELECT * FROM memory " +
"WHERE user_id = ? " +
"AND (end_date <= ? AND start_date >= ?) " +
"ORDER BY start_date DESC",
MEMORY_ROW_MAPPER,
user.getId(),
endDate,
startDate
);
}
public List<Integer> findDistinctYears(User user) {
String sql = "SELECT DISTINCT EXTRACT(YEAR FROM start_date) " +
"FROM memory " +
"WHERE user_id = ? " +
"ORDER BY EXTRACT(YEAR FROM start_date) DESC";
return jdbcTemplate.queryForList(sql, Integer.class, user.getId());
}
public Optional<Long> getOwnerId(Memory memory) {
return Optional.ofNullable(this.jdbcTemplate.queryForObject("SELECT user_id FROM memory WHERE id = ?", Long.class, memory.getId()));
}
}

View File

@@ -157,6 +157,20 @@ public class ProcessedVisitJdbcService {
jdbcTemplate.update(sql, ids.toArray());
}
public List<ProcessedVisit> findByIds(User user, List<Long> ids) {
String placeholders = String.join(",", ids.stream().map(id -> "?").toList());
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
"WHERE pv.user_id = ? AND pv.id IN (" + placeholders + ")";
Object[] params = new Object[ids.size() + 1];
params[0] = user.getId();
for (int i = 0; i < ids.size(); i++) {
params[i + 1] = ids.get(i);
}
return jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, params);
}
public Optional<ProcessedVisit> findByUserAndStartTimeAndEndTimeAndPlace(User user, Instant startTime, Instant endTime, SignificantPlace place) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
@@ -217,4 +231,5 @@ public class ProcessedVisitJdbcService {
public void deleteAllForUserAfter(User user, Instant start) {
jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ? AND end_time >= ?", user.getId(), Timestamp.from(start));
}
}

View File

@@ -32,6 +32,7 @@ public class SignificantPlaceJdbcService {
rs.getLong("id"),
rs.getString("name"),
rs.getString("address"),
rs.getString("city"),
rs.getString("country_code"),
rs.getDouble("latitude_centroid"),
rs.getDouble("longitude_centroid"),
@@ -44,7 +45,7 @@ public class SignificantPlaceJdbcService {
String countSql = "SELECT COUNT(*) FROM significant_places WHERE user_id = ?";
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, user.getId());
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
" FROM significant_places sp " +
"WHERE sp.user_id = ? ORDER BY sp.id " +
"LIMIT ? OFFSET ? ";
@@ -55,7 +56,7 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> findNearbyPlaces(Long userId, Point point, double distanceInMeters) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? " +
"AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)";
@@ -79,10 +80,11 @@ public class SignificantPlaceJdbcService {
@CacheEvict(cacheNames = "significant-places", key = "#place.id")
public SignificantPlace update(SignificantPlace place) {
String sql = "UPDATE significant_places SET name = ?, address = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
jdbcTemplate.update(sql,
place.getName(),
place.getAddress(),
place.getCity(),
place.getCountryCode(),
place.getType().name(),
place.getLatitudeCentroid(),
@@ -97,7 +99,7 @@ public class SignificantPlaceJdbcService {
@Cacheable("significant-places")
public Optional<SignificantPlace> findById(Long id) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.id = ?";
List<SignificantPlace> results = jdbcTemplate.query(sql, significantPlaceRowMapper, id);
@@ -109,7 +111,7 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> findNonGeocodedByUser(User user) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? AND sp.geocoded = false " +
"ORDER BY sp.id";
@@ -117,7 +119,7 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> findAllByUser(User user) {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.user_id = ? " +
"ORDER BY sp.id";
@@ -125,7 +127,7 @@ public class SignificantPlaceJdbcService {
}
public List<SignificantPlace> findWithMissingTimezone() {
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
"FROM significant_places sp " +
"WHERE sp.timezone IS NULL " +
"ORDER BY sp.id";

View File

@@ -12,6 +12,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -47,6 +48,20 @@ public class TripJdbcService {
}
};
public List<Trip> findByIds(User user, List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = "SELECT t.* FROM trips t WHERE t.user_id = ? AND t.id IN (" + placeholders + ")";
Object[] params = new Object[ids.size() + 1];
params[0] = user.getId();
for (int i = 0; i < ids.size(); i++) {
params[i + 1] = ids.get(i);
}
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, params);
}
public List<Trip> findByUser(User user) {
String sql = "SELECT t.*" +
"FROM trips t " +
@@ -54,6 +69,13 @@ public class TripJdbcService {
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, user.getId());
}
public Optional<Trip> findByUserAndId(User user, Long id) {
String sql = "SELECT t.*" +
"FROM trips t " +
"WHERE t.user_id = ? AND id = ?";
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, user.getId(), id).stream().findFirst();
}
public List<Trip> findByUserAndTimeOverlap(User user, Instant startTime, Instant endTime) {
String sql = "SELECT t.* " +
"FROM trips t " +

View File

@@ -0,0 +1,65 @@
package com.dedicatedcode.reitti.service;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Service;
import java.text.MessageFormat;
import java.time.Duration;
import java.util.Locale;
import java.util.ResourceBundle;
@Service
public class I18nService {
private final MessageSource messageSource;
public I18nService(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String humanizeDuration(long seconds) {
Duration duration = Duration.ofSeconds(seconds);
return humanizeDuration(duration);
}
public String humanizeDuration(Duration duration) {
long hours = duration.toHours();
long minutesPart = duration.toMinutesPart();
Locale locale = LocaleContextHolder.getLocale();
ResourceBundle bundle = ResourceBundle.getBundle("messages", locale);
if (hours > 0) {
// Format for hours and minutes (e.g., "2 hours and 5 minutes")
String pattern = bundle.getString("format.hours_minutes");
// Note: The MessageFormat pattern is designed to handle the pluralization
// and the exclusion of the minutes if minutesPart is 0.
return new MessageFormat(pattern, locale).format(new Object[]{hours, minutesPart});
} else {
// Format for minutes only (e.g., "65 minutes")
long totalMinutes = duration.toMinutes();
if (totalMinutes == 0) {
return "Less than a minute"; // Handle very short durations
}
String pattern = bundle.getString("format.minutes_only");
return new MessageFormat(pattern, locale).format(new Object[]{totalMinutes});
}
}
public String translate(String messageKey) {
return messageSource.getMessage(messageKey, null, LocaleContextHolder.getLocale());
}
public String translateWithDefault(String messageKey, String defaultMessage) {
return messageSource.getMessage(messageKey, null, defaultMessage, LocaleContextHolder.getLocale());
}
public String translate(String messageKey, Object... args) {
return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale());
}
public String translateWithDefault(String messageKey, String defaultMessage, Object... args) {
return messageSource.getMessage(messageKey, args, defaultMessage, LocaleContextHolder.getLocale());
}
}

View File

@@ -0,0 +1,85 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
@Service
public class MagicLinkTokenService {
private final MagicLinkJdbcService magicLinkJdbcService;
private final PasswordEncoder passwordEncoder;
private final SecureRandom secureRandom = new SecureRandom();
public MagicLinkTokenService(MagicLinkJdbcService magicLinkJdbcService, PasswordEncoder passwordEncoder) {
this.magicLinkJdbcService = magicLinkJdbcService;
this.passwordEncoder = passwordEncoder;
}
public String createMapShareToken(User user, String name, MagicLinkAccessLevel accessLevel, Instant expiryInstant) {
byte[] tokenBytes = new byte[32];
secureRandom.nextBytes(tokenBytes);
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
String tokenHash = passwordEncoder.encode(rawToken);
MagicLinkToken token = new MagicLinkToken(null, name, tokenHash, accessLevel, expiryInstant, null, null, false);
magicLinkJdbcService.create(user, token);
return rawToken;
}
public String createMemoryShareToken(User user, Long memoryId, MagicLinkAccessLevel accessLevel, int validDays) {
// Generate a secure random token
byte[] tokenBytes = new byte[32];
secureRandom.nextBytes(tokenBytes);
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
// Hash the token for storage
String tokenHash = passwordEncoder.encode(rawToken);
String tokenName = "Memory " + memoryId + " - " + (accessLevel == MagicLinkAccessLevel.MEMORY_VIEW_ONLY ? "View Only" : "Edit Access");
Instant expiryDate = validDays > 0 ? Instant.now().plus(validDays, ChronoUnit.DAYS) : null;
MagicLinkToken token = new MagicLinkToken(
null,
tokenName,
tokenHash,
accessLevel,
expiryDate,
MagicLinkResourceType.MEMORY,
memoryId,
Instant.now(),
null,
false
);
magicLinkJdbcService.create(user, token);
return rawToken;
}
public List<MagicLinkToken> getTokensForUser(User user) {
return magicLinkJdbcService.findByUser(user);
}
public Optional<MagicLinkToken> validateToken(String rawToken) {
return magicLinkJdbcService.findByRawToken(rawToken);
}
public void markTokenAsUsed(long tokenId) {
magicLinkJdbcService.updateLastUsed(tokenId);
}
public void deleteToken(long tokenId) {
magicLinkJdbcService.delete(tokenId);
}
}

View File

@@ -0,0 +1,568 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.dto.PhotoResponse;
import com.dedicatedcode.reitti.model.geo.GeoUtils;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.geo.Trip;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.TripJdbcService;
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Service;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class MemoryBlockGenerationService {
private static final Logger log = LoggerFactory.getLogger(MemoryBlockGenerationService.class);
private static final DateTimeFormatter FULL_DATE_FORMATTER = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMM");
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("dd MMM, HH:mm");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private static final long MIN_VISIT_DURATION_SECONDS = 600;
private static final double WEIGHT_DURATION = 1.0;
private static final double WEIGHT_DISTANCE = 2.0;
private static final double WEIGHT_CATEGORY = 3.0;
private static final double WEIGHT_NOVELTY = 1.5;
private static final long CLUSTER_TIME_THRESHOLD_SECONDS = 7200;
private static final double CLUSTER_DISTANCE_THRESHOLD_METERS = 1000;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final TripJdbcService tripJdbcService;
private final I18nService i18n;
private final ImmichIntegrationService immichIntegrationService;
private final StorageService storageService;
public MemoryBlockGenerationService(ProcessedVisitJdbcService processedVisitJdbcService,
TripJdbcService tripJdbcService,
I18nService i18nService,
ImmichIntegrationService immichIntegrationService,
StorageService storageService) {
this.processedVisitJdbcService = processedVisitJdbcService;
this.tripJdbcService = tripJdbcService;
this.i18n = i18nService;
this.immichIntegrationService = immichIntegrationService;
this.storageService = storageService;
}
public List<MemoryBlockPart> generate(User user, Memory memory, ZoneId timeZone) {
Instant startDate = memory.getStartDate();
Instant endDate = memory.getEndDate();
List<ProcessedVisit> allVisitsInRange = this.processedVisitJdbcService.findByUserAndTimeOverlap(user, startDate, endDate);
List<Trip> allTripsInRange = this.tripJdbcService.findByUserAndTimeOverlap(user, startDate, endDate);
// Step 1: Data Pre-processing & Filtering
Optional<ProcessedVisit> accommodation = findAccommodation(allVisitsInRange);
Optional<ProcessedVisit> home = findHome(allVisitsInRange);
// Find first and last accommodation visits
Instant firstAccommodationArrival = accommodation.flatMap(p -> allVisitsInRange.stream()
.filter(visit -> visit.getPlace().getId().equals(p.getPlace().getId()))
.min(Comparator.comparing(ProcessedVisit::getStartTime)))
.map(ProcessedVisit::getStartTime).orElse(null);
Instant lastAccommodationDeparture = accommodation.flatMap(p -> allVisitsInRange.stream()
.filter(visit -> visit.getPlace().getId().equals(p.getPlace().getId()))
.max(Comparator.comparing(ProcessedVisit::getStartTime)))
.map(ProcessedVisit::getStartTime).orElse(null);
List<ProcessedVisit> filteredVisits = accommodation.map(a -> filterVisits(allVisitsInRange, a)).orElse(allVisitsInRange);
log.info("Found {} visits after filtering (accommodation: {})", filteredVisits.size(), accommodation.map(a -> a.getPlace().getName()).orElse("none"));
// Step 3: Scoring & Identifying "Interesting" Visits
List<ScoredVisit> scoredVisits = scoreVisits(filteredVisits, accommodation.orElse(null));
// Sort by score descending
scoredVisits.sort(Comparator.comparingDouble(ScoredVisit::score).reversed());
log.info("Scored {} visits, top score: {}", scoredVisits.size(),
scoredVisits.isEmpty() ? 0 : scoredVisits.getFirst().score());
// Step 4: Clustering & Creating a Narrative
List<VisitCluster> clusters = clusterVisits(scoredVisits);
log.info("Created {} clusters from visits", clusters.size());
// Generate memory block parts from clusters
List<MemoryBlockPart> blockParts = new ArrayList<>();
// Add introduction text block
if (!clusters.isEmpty() && accommodation.isPresent() && home.isPresent() && startDate != null && endDate != null) {
String introText = generateIntroductionText(memory, clusters, accommodation.orElse(null), home.orElse(null), startDate, endDate, timeZone);
MemoryBlockText introBlock = new MemoryBlockText(null, i18n.translate("memory.generator.headline.text"), introText);
blockParts.add(introBlock);
}
// Add travel to the accommodation section
if (firstAccommodationArrival != null) {
List<Trip> tripsToAccommodation = allTripsInRange.stream()
.filter(trip -> trip.getEndTime() != null && !trip.getEndTime().isAfter(firstAccommodationArrival))
.filter(trip -> trip.getStartTime() != null && !trip.getStartTime().isBefore(startDate))
.sorted(Comparator.comparing(Trip::getStartTime))
.toList();
String formattedStartDate = formatTime(tripsToAccommodation.getFirst().getStartTime(), timeZone, false);
String formattedEndDate = formatTime(tripsToAccommodation.getLast().getEndTime(), timeZone, false);
if (!tripsToAccommodation.isEmpty()) {
String text = i18n.translate("memory.generator.travel_to_accommodation.text",
home.map(h ->h.getPlace().getCity()).orElse(""),
formattedStartDate,
accommodation.map(a -> a.getPlace().getCity()).orElse(""),
formattedEndDate,
i18n.humanizeDuration(Duration.between(tripsToAccommodation.getFirst().getStartTime(), tripsToAccommodation.getLast().getEndTime())),
i18n.humanizeDuration(tripsToAccommodation.stream().map(Trip::getDurationSeconds).reduce(0L, Long::sum))
);
MemoryBlockText accommodationPreRoll = new MemoryBlockText(null, null, text);
blockParts.add(accommodationPreRoll);
}
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsToAccommodation, "Journey to " + accommodation.get().getPlace().getCity());
blockParts.add(clusterBlock);
}
Map<LocalDate, List<PhotoResponse>> imagesByDay = loadImagesFromIntegrations(user, startDate, endDate);
accommodation.ifPresent(a -> {
MemoryBlockText intro = new MemoryBlockText(null,
i18n.translate("memory.generator.intro_accommodation.headline",a.getPlace().getName()),
i18n.translate("memory.generator.intro_accommodation.text"));
blockParts.add(intro);
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(null, List.of(a.getId()), null, null, BlockType.CLUSTER_VISIT);
blockParts.add(clusterBlock);
LocalDate dayOfAccommodation = a.getStartTime().atZone(ZoneId.of("UTC")).toLocalDate();
List<PhotoResponse> images = imagesByDay.get(dayOfAccommodation);
if (images != null && !images.isEmpty()) {
MemoryBlockImageGallery imageGallery = new MemoryBlockImageGallery(null, fetchImagesFromImmich(user, memory, images));
blockParts.add(imageGallery);
imagesByDay.remove(dayOfAccommodation);
}
});
Set<LocalDate> handledDays = new HashSet<>();
// Process each cluster
ProcessedVisit previousVisit = accommodation.orElse(null);
boolean firstOfDay;
for (int i = 0; i < clusters.size(); i++) {
VisitCluster cluster = clusters.get(i);
LocalDate today = cluster.getStartTime().atZone(ZoneId.systemDefault()).toLocalDate();
//filter out visits before the first stay at accommodation
if (firstAccommodationArrival != null && cluster.getEndTime() != null && cluster.getEndTime().isBefore(firstAccommodationArrival)) {
continue;
}
//filter out visits after the last stay at accommodation
if (lastAccommodationDeparture != null && cluster.getStartTime() != null && cluster.getStartTime().isAfter(lastAccommodationDeparture)) {
continue;
}
if (!handledDays.contains(today)) {
blockParts.add(new MemoryBlockText(null, i18n.translate("memory.generator.day.text",
Duration.between(startDate.truncatedTo(ChronoUnit.DAYS), cluster.getStartTime().truncatedTo(ChronoUnit.DAYS)).toDays(), cluster.getHighestScoredVisit().visit.getPlace().getCity()), null));
handledDays.add(today);
firstOfDay = true;
} else {
firstOfDay = false;
}
if (previousVisit != null) {
ProcessedVisit finalPreviousVisit = previousVisit;
List<Trip> tripsBetweenVisits = allTripsInRange.stream()
.filter(trip -> trip.getStartTime() != null && (trip.getStartTime().equals(finalPreviousVisit.getEndTime()) || trip.getStartTime().isAfter(finalPreviousVisit.getEndTime())))
.filter(trip -> trip.getEndTime() != null && (trip.getEndTime().equals(cluster.getStartTime())))
.sorted(Comparator.comparing(Trip::getEndTime))
.toList();
if (Duration.between(tripsBetweenVisits.getFirst().getStartTime(), tripsBetweenVisits.getLast().getEndTime()).toMinutes() > 30) {
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsBetweenVisits, "Journey to " + cluster.getHighestScoredVisit().visit().getPlace().getCity());
blockParts.add(clusterBlock);
}
previousVisit = cluster.getVisits().stream().map(ScoredVisit::visit).max(Comparator.comparing(ProcessedVisit::getEndTime)).orElse(null);
}
// Add a text block describing the cluster
String clusterHeadline = firstOfDay ? null : generateClusterHeadline(cluster, i + 1);
MemoryBlockText clusterTextBlock = new MemoryBlockText(null, clusterHeadline, null);
blockParts.add(clusterTextBlock);
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(null, cluster.getVisits().stream().map(ScoredVisit::visit)
.map(ProcessedVisit::getId).toList(), null, null, BlockType.CLUSTER_VISIT);
blockParts.add(clusterBlock);
List<PhotoResponse> todaysImages = imagesByDay.getOrDefault(today, Collections.emptyList());
if (!todaysImages.isEmpty()) {
MemoryBlockImageGallery imageGallery = new MemoryBlockImageGallery(null, fetchImagesFromImmich(user, memory, todaysImages));
blockParts.add(imageGallery);
}
imagesByDay.remove(today);
}
if (lastAccommodationDeparture != null) {
List<Trip> tripsFromAccommodation = allTripsInRange.stream()
.filter(trip -> trip.getStartTime() != null && !trip.getStartTime().isBefore(lastAccommodationDeparture))
.filter(trip -> trip.getEndTime() != null && !trip.getEndTime().isAfter(endDate))
.sorted(Comparator.comparing(Trip::getStartTime))
.toList();
if (!tripsFromAccommodation.isEmpty()) {
String formattedStartDate = formatTime(tripsFromAccommodation.getFirst().getStartTime(), timeZone, false);
String formattedEndDate = formatTime(tripsFromAccommodation.getLast().getEndTime(), timeZone, false);
String text = i18n.translate("memory.generator.travel_from_accommodation.text",
accommodation.map(a -> a.getPlace().getCity()).orElse(""),
formattedStartDate,
home.map(h -> h.getPlace().getCity()).orElse(""),
formattedEndDate,
i18n.humanizeDuration(Duration.between(tripsFromAccommodation.getFirst().getStartTime(), tripsFromAccommodation.getLast().getEndTime())),
i18n.humanizeDuration(tripsFromAccommodation.stream().map(Trip::getDurationSeconds).reduce(0L, Long::sum))
);
MemoryBlockText accommodationPreRoll = new MemoryBlockText(null, null, text);
blockParts.add(accommodationPreRoll);
}
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsFromAccommodation, "Journey to " + home.get().getPlace().getCity());
blockParts.add(clusterBlock);
}
log.info("Generated {} memory block parts", blockParts.size());
return blockParts;
}
private List<MemoryBlockImageGallery.GalleryImage> fetchImagesFromImmich(User user, Memory memory, List<PhotoResponse> todaysImages) {
return todaysImages.stream()
.map(s -> {
if (storageService.exists("memories/" + memory.getId() + "/" + s + "**")) {
return new MemoryBlockImageGallery.GalleryImage("/api/v1/photos/reitti/memories/" + memory.getId() + "/" + s.getFileName(), null, "immich", s.getId());
} else {
String filename = this.immichIntegrationService.downloadImage(user, s.getId(), "memories/" + memory.getId());
String imageUrl = "/api/v1/photos/reitti/memories/" + memory.getId() + "/" + filename;
return new MemoryBlockImageGallery.GalleryImage(imageUrl, null, "immich", s.getId());
}
}).toList();
}
private Map<LocalDate, List<PhotoResponse>> loadImagesFromIntegrations(User user, Instant startDate, Instant endDate) {
Map<LocalDate, List<PhotoResponse>> map = new HashMap<>();
LocalDate currentStart = startDate.atZone(ZoneId.of("UTC")).toLocalDate();
LocalDate currentEnd = startDate.plus(1, ChronoUnit.DAYS).atZone(ZoneId.of("UTC")).toLocalDate();
LocalDate end = endDate.atZone(ZoneId.of("UTC")).toLocalDate();
while (!currentEnd.isAfter(end)) {
map.put(currentStart, this.immichIntegrationService.searchPhotosForRange(user, currentStart, currentStart, "UTC")
.stream().sorted(Comparator.comparing(PhotoResponse::getDateTime)).toList());
currentStart = currentEnd;
currentEnd = currentEnd.plusDays(1);
}
return map;
}
private Optional<ProcessedVisit> findHome(List<ProcessedVisit> allVisitsInRange) {
if (allVisitsInRange.stream().findFirst().isPresent()) {
boolean homeDetected = allVisitsInRange.stream().findFirst().get().getPlace().equals(allVisitsInRange.getLast().getPlace());
if (homeDetected) {
return allVisitsInRange.stream().findFirst();
}
}
return Optional.empty();
}
private MemoryClusterBlock convertToTripCluster(List<Trip> trips, String title) {
return new MemoryClusterBlock(null, trips.stream().map(Trip::getId).toList(),
title, null, BlockType.CLUSTER_TRIP);
}
private String generateIntroductionText(Memory memory, List<VisitCluster> clusters, ProcessedVisit accommodation, ProcessedVisit homePlace,
Instant startDate, Instant endDate, ZoneId timeZone) {
long totalDays = Duration.between(memory.getStartDate(), memory.getEndDate()).toDays() + 1;
int totalVisits = clusters.stream().mapToInt(c -> c.getVisits().size()).sum();
SignificantPlace accommodationPlace = accommodation.getPlace();
String country;
if (accommodationPlace.getCountryCode() != null) {
country = i18n.translateWithDefault("country." + accommodationPlace.getCountryCode() + ".label", accommodation.getPlace().getCountryCode().toLowerCase());
} else {
country = i18n.translate("country.unknown.label");
}
String formattedStartDate = formatDate(startDate, timeZone, true);
String formattedEndDate = formatDate(endDate, timeZone, false);
return i18n.translate("memory.generator.introductory.text",
formattedStartDate,
homePlace.getPlace().getCity(),
totalDays,
accommodationPlace.getCity(),
country,
totalVisits,
clusters.size(),
formattedEndDate);
}
private static String formatDate(Instant date, ZoneId timeZone, boolean withYear) {
LocalDate localEnd = date.atZone(timeZone).toLocalDate();
return withYear ? localEnd.format(FULL_DATE_FORMATTER.withLocale(LocaleContextHolder.getLocale())) : localEnd.format(DATE_FORMATTER.withLocale(LocaleContextHolder.getLocale()));
}
private static String formatTime(Instant date, ZoneId timeZone, boolean withDay) {
LocalDateTime localEnd = date.atZone(timeZone).toLocalDateTime();
return withDay ? localEnd.format(DATETIME_FORMATTER.withLocale(LocaleContextHolder.getLocale())) : localEnd.format(TIME_FORMATTER.withLocale(LocaleContextHolder.getLocale()));
}
/**
* Generate a headline for a visit cluster
*/
private String generateClusterHeadline(VisitCluster cluster, int clusterNumber) {
ScoredVisit topVisit = cluster.getHighestScoredVisit();
if (topVisit != null && topVisit.visit().getPlace().getName() != null) {
return topVisit.visit().getPlace().getName();
}
return "Location " + clusterNumber;
}
/**
* Step 1: Find accommodation by analyzing visits during sleeping hours (22:00 - 06:00)
*/
private Optional<ProcessedVisit> findAccommodation(List<ProcessedVisit> visits) {
Map<Long, Long> sleepingHoursDuration = new HashMap<>();
for (ProcessedVisit visit : visits) {
long durationInSleepingHours = calculateSleepingHoursDuration(visit);
if (durationInSleepingHours > 0) {
sleepingHoursDuration.merge(visit.getPlace().getId(), durationInSleepingHours, Long::sum);
}
}
return sleepingHoursDuration.entrySet().stream()
.max(Map.Entry.comparingByValue())
.flatMap(entry -> visits.stream()
.filter(visit -> visit.getPlace().getId().equals(entry.getKey()))
.findFirst());
}
private long calculateSleepingHoursDuration(ProcessedVisit visit) {
ZoneId timeZone = visit.getPlace().getTimezone();
if (timeZone == null) {
timeZone = ZoneId.systemDefault();
}
var startLocal = visit.getStartTime().atZone(timeZone);
var endLocal = visit.getEndTime().atZone(timeZone);
long totalSleepingDuration = 0;
var currentDay = startLocal.toLocalDate();
var lastDay = endLocal.toLocalDate();
while (!currentDay.isAfter(lastDay)) {
var sleepStart = currentDay.atTime(22, 0).atZone(timeZone);
var sleepEnd = currentDay.plusDays(1).atTime(6, 0).atZone(timeZone);
var overlapStart = sleepStart.isAfter(startLocal) ? sleepStart : startLocal;
var overlapEnd = sleepEnd.isBefore(endLocal) ? sleepEnd : endLocal;
if (overlapStart.isBefore(overlapEnd)) {
totalSleepingDuration += Duration.between(overlapStart, overlapEnd).getSeconds();
}
currentDay = currentDay.plusDays(1);
}
return totalSleepingDuration;
}
/**
* Step 1: Filter visits - remove accommodation and short visits
*/
private List<ProcessedVisit> filterVisits(List<ProcessedVisit> visits, ProcessedVisit accommodation) {
Long accommodationPlaceId = accommodation != null ? accommodation.getPlace().getId() : null;
return visits.stream()
.filter(visit -> {
// Remove accommodation stays
if (visit.getPlace().getId().equals(accommodationPlaceId)) {
return false;
}
return visit.getDurationSeconds() >= MIN_VISIT_DURATION_SECONDS;
})
.collect(Collectors.toList());
}
/**
* Step 3: Score visits based on duration, distance from accommodation, category, and novelty
*/
private List<ScoredVisit> scoreVisits(List<ProcessedVisit> visits, ProcessedVisit accommodation) {
// Count visit frequency for novelty calculation
Map<Long, Long> visitCounts = visits.stream()
.collect(Collectors.groupingBy(v -> v.getPlace().getId(), Collectors.counting()));
// Calculate max duration for normalization
long maxDuration = visits.stream()
.mapToLong(ProcessedVisit::getDurationSeconds)
.max()
.orElse(1);
return visits.stream()
.map(visit -> {
double score = 0.0;
// Duration score (normalized 0-1)
double durationScore = (double) visit.getDurationSeconds() / maxDuration;
score += WEIGHT_DURATION * durationScore;
// Distance from accommodation score
if (accommodation != null) {
double distance = GeoUtils.distanceInMeters(
visit.getPlace().getLatitudeCentroid(),
visit.getPlace().getLongitudeCentroid(),
accommodation.getPlace().getLatitudeCentroid(),
accommodation.getPlace().getLongitudeCentroid()
);
// Normalize distance (assume max interesting distance is 50km)
double distanceScore = Math.min(distance / 50000.0, 1.0);
score += WEIGHT_DISTANCE * distanceScore;
}
// Category score
double categoryScore = getCategoryWeight(visit.getPlace().getType());
score += WEIGHT_CATEGORY * categoryScore;
// Novelty score (inverse of visit count, normalized)
long visitCount = visitCounts.get(visit.getPlace().getId());
double noveltyScore = 1.0 / visitCount;
score += WEIGHT_NOVELTY * noveltyScore;
return new ScoredVisit(visit, score);
})
.collect(Collectors.toList());
}
/**
* Get category weight based on place type
*/
private double getCategoryWeight(SignificantPlace.PlaceType placeType) {
if (placeType == null) {
return 0.3;
}
// High interest categories
return switch (placeType) {
case MUSEUM, LANDMARK, PARK, TOURIST_ATTRACTION, HISTORIC_SITE, MONUMENT -> 1.0;
// Medium interest categories
case RESTAURANT, CAFE, SHOPPING_MALL, MARKET, GALLERY, THEATER, CINEMA -> 0.6;
// Low interest categories
case GROCERY_STORE, PHARMACY, GAS_STATION, ATM, BANK -> 0.2;
// Default medium-low for all other types
default -> 0.4;
};
}
/**
* Step 4: Cluster visits using spatio-temporal proximity (simplified DBSCAN-like approach)
*/
private List<VisitCluster> clusterVisits(List<ScoredVisit> scoredVisits) {
if (scoredVisits.isEmpty()) {
return Collections.emptyList();
}
// Sort by time
List<ScoredVisit> sortedVisits = new ArrayList<>(scoredVisits);
sortedVisits.sort(Comparator.comparing(sv -> sv.visit().getStartTime()));
List<VisitCluster> clusters = new ArrayList<>();
VisitCluster currentCluster = new VisitCluster();
currentCluster.addVisit(sortedVisits.getFirst());
for (int i = 1; i < sortedVisits.size(); i++) {
ScoredVisit current = sortedVisits.get(i);
ScoredVisit previous = sortedVisits.get(i - 1);
long timeDiff = Duration.between(
previous.visit().getEndTime(),
current.visit().getStartTime()
).getSeconds();
double distance = GeoUtils.distanceInMeters(
previous.visit().getPlace().getLatitudeCentroid(),
previous.visit().getPlace().getLongitudeCentroid(),
current.visit().getPlace().getLatitudeCentroid(),
current.visit().getPlace().getLongitudeCentroid()
);
// Check if current visit should be added to current cluster
if (timeDiff <= CLUSTER_TIME_THRESHOLD_SECONDS && distance <= CLUSTER_DISTANCE_THRESHOLD_METERS) {
currentCluster.addVisit(current);
} else {
// Start new cluster
clusters.add(currentCluster);
currentCluster = new VisitCluster();
currentCluster.addVisit(current);
}
}
// Add the last cluster
if (!currentCluster.getVisits().isEmpty()) {
clusters.add(currentCluster);
}
return clusters;
}
private record ScoredVisit(ProcessedVisit visit, double score) {
}
/**
* Helper class to represent a cluster of visits
*/
private static class VisitCluster {
private final List<ScoredVisit> visits = new ArrayList<>();
public void addVisit(ScoredVisit visit) {
visits.add(visit);
}
public List<ScoredVisit> getVisits() {
return visits;
}
public ScoredVisit getHighestScoredVisit() {
return visits.stream()
.max(Comparator.comparingDouble(ScoredVisit::score))
.orElse(null);
}
public Instant getStartTime() {
return visits.stream()
.map(sv -> sv.visit().getStartTime())
.min(Instant::compareTo)
.orElse(null);
}
public Instant getEndTime() {
return visits.stream()
.map(sv -> sv.visit().getEndTime())
.max(Instant::compareTo)
.orElse(null);
}
}
}

View File

@@ -0,0 +1,310 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
import com.dedicatedcode.reitti.model.TimeDisplayMode;
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.geo.Trip;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSettings;
import com.dedicatedcode.reitti.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
@Service
public class MemoryService {
private static final Logger log = LoggerFactory.getLogger(MemoryService.class);
private final MemoryJdbcService memoryJdbcService;
private final MemoryBlockJdbcService memoryBlockJdbcService;
private final MemoryBlockTextJdbcService memoryBlockTextJdbcService;
private final MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService;
private final MemoryClusterBlockRepository memoryClusterBlockRepository;
private final MemoryBlockGenerationService blockGenerationService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
private final TripJdbcService tripJdbcService;
private final UserSettingsJdbcService userSettingsJdbcService;
public MemoryService(
MemoryJdbcService memoryJdbcService,
MemoryBlockJdbcService memoryBlockJdbcService,
MemoryBlockTextJdbcService memoryBlockTextJdbcService,
MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService,
MemoryClusterBlockRepository memoryClusterBlockRepository,
MemoryBlockGenerationService blockGenerationService,
ProcessedVisitJdbcService processedVisitJdbcService,
TripJdbcService tripJdbcService,
UserSettingsJdbcService userSettingsJdbcService) {
this.memoryJdbcService = memoryJdbcService;
this.memoryBlockJdbcService = memoryBlockJdbcService;
this.memoryBlockTextJdbcService = memoryBlockTextJdbcService;
this.memoryBlockImageGalleryJdbcService = memoryBlockImageGalleryJdbcService;
this.memoryClusterBlockRepository = memoryClusterBlockRepository;
this.blockGenerationService = blockGenerationService;
this.processedVisitJdbcService = processedVisitJdbcService;
this.tripJdbcService = tripJdbcService;
this.userSettingsJdbcService = userSettingsJdbcService;
}
@Transactional
public Memory createMemory(User user, Memory memory) {
return memoryJdbcService.create(user, memory);
}
@Transactional
public Memory updateMemory(User user, Memory memory) {
return memoryJdbcService.update(user, memory);
}
@Transactional
public void deleteMemory(User user, Long memoryId) {
memoryJdbcService.delete(user, memoryId);
}
public Optional<Memory> getMemoryById(User user, Long id) {
return memoryJdbcService.findById(user, id);
}
public List<Memory> getMemoriesForUser(User user) {
return memoryJdbcService.findAllByUser(user);
}
public List<Memory> getMemoriesForUserAndYear(User user, int year) {
return memoryJdbcService.findAllByUserAndYear(user, year);
}
@Transactional
public MemoryBlock addBlock(User user, Long memoryId, int position, BlockType blockType) {
this.memoryJdbcService.findById(user, memoryId).orElseThrow(() -> new PageNotFoundException("Unable to find memory with id [" + memoryId + "]"));
int maxPosition = memoryBlockJdbcService.getMaxPosition(memoryId);
MemoryBlock block = new MemoryBlock(memoryId, blockType, maxPosition + 1);
block = memoryBlockJdbcService.create(block);
if (position > -1) {
List<MemoryBlock> list = memoryBlockJdbcService.findByMemoryId(memoryId);
MemoryBlock lastBlock = list.removeLast();
list.add(position, lastBlock);
reorderBlocks(user, memoryId, list.stream().map(MemoryBlock::getId).toList());
}
return block;
}
@Transactional
public void deleteBlock(Long blockId) {
memoryBlockJdbcService.delete(blockId);
}
public List<MemoryBlockPart> getBlockPartsForMemory(User user, Long memoryId, ZoneId timezone) {
UserSettings settings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
List<MemoryBlock> blocks = memoryBlockJdbcService.findByMemoryId(memoryId);
List<MemoryBlockPart> blockParts = new ArrayList<>();
for (MemoryBlock block : blocks) {
Optional<? extends MemoryBlockPart> part = loadAndConvertBlockInstance(user, timezone, block, settings);
part.ifPresent(blockParts::add);
}
return blockParts;
}
public Optional<? extends MemoryBlockPart> getBlock(User user, ZoneId timezone, long memoryId, long blockId) {
UserSettings settings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
Optional<MemoryBlock> blockOpt = memoryBlockJdbcService.findById(user, blockId);
if (blockOpt.isPresent()) {
MemoryBlock block = blockOpt.get();
if (!block.getMemoryId().equals(memoryId)) {
throw new IllegalArgumentException("Block does not belong to this memory");
}
return loadAndConvertBlockInstance(user, timezone, block, settings);
} else {
return Optional.empty();
}
}
private Optional<? extends MemoryBlockPart> loadAndConvertBlockInstance(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
return switch (block.getBlockType()) {
case TEXT -> memoryBlockTextJdbcService.findByBlockId(block.getId());
case IMAGE_GALLERY -> memoryBlockImageGalleryJdbcService.findByBlockId(block.getId());
case CLUSTER_TRIP -> getClusterTripBlock(user, timezone, block, settings);
case CLUSTER_VISIT -> getClusterVisitBlock(user, timezone, block, settings);
};
}
public Optional<MemoryBlock> getBlockById(User user, Long blockId) {
return memoryBlockJdbcService.findById(user, blockId);
}
public Optional<MemoryClusterBlock> getClusterBlock(User user, Long blockId) {
return memoryClusterBlockRepository.findByBlockId(user, blockId);
}
@Transactional
public MemoryBlockText addTextBlock(Long blockId, String headline, String content) {
MemoryBlockText blockText = new MemoryBlockText(blockId, headline, content);
return memoryBlockTextJdbcService.create(blockText);
}
@Transactional
public MemoryBlockText updateTextBlock(User user, MemoryBlockText blockText) {
return memoryBlockTextJdbcService.update(blockText);
}
@Transactional
public MemoryBlockImageGallery updateImageBlock(User user, MemoryBlockImageGallery blockText) {
return memoryBlockImageGalleryJdbcService.update(blockText);
}
@Transactional
public void createClusterBlock(User user, MemoryClusterBlock clusterBlock) {
memoryClusterBlockRepository.save(user, clusterBlock);
}
@Transactional
public MemoryClusterBlock updateClusterBlock(User user, MemoryClusterBlock clusterBlock) {
return memoryClusterBlockRepository.update(user, clusterBlock);
}
public Optional<MemoryBlockText> getTextBlock(Long blockId) {
return memoryBlockTextJdbcService.findByBlockId(blockId);
}
@Transactional
public MemoryBlockImageGallery addImageGalleryBlock(Long blockId, List<MemoryBlockImageGallery.GalleryImage> images) {
return this.memoryBlockImageGalleryJdbcService.create(new MemoryBlockImageGallery(blockId, images));
}
@Transactional
public void deleteImageFromGallery(Long imageId) {
memoryBlockImageGalleryJdbcService.delete(imageId);
}
public MemoryBlockImageGallery getImagesForBlock(Long blockId) {
return memoryBlockImageGalleryJdbcService.findByBlockId(blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"));
}
@Transactional
public void reorderBlocks(User user, Long memoryId, List<Long> blockIds) {
// First, temporarily shift all positions to avoid unique constraint violations
List<MemoryBlock> allBlocks = memoryBlockJdbcService.findByMemoryId(memoryId);
int offset = blockIds.size() + 2; // Use an offset larger than the number of blocks
for (MemoryBlock block : allBlocks) {
memoryBlockJdbcService.update(block.withPosition(block.getPosition() + offset));
}
// Now, set the correct positions
for (int i = 0; i < blockIds.size(); i++) {
Long blockId = blockIds.get(i);
Optional<MemoryBlock> blockOpt = memoryBlockJdbcService.findById(user, blockId);
if (blockOpt.isPresent()) {
MemoryBlock block = blockOpt.get();
if (!block.getMemoryId().equals(memoryId)) {
throw new IllegalArgumentException("Block does not belong to this memory");
}
if (!block.getPosition().equals(i)) {
memoryBlockJdbcService.update(block.withPosition(i));
}
}
}
}
@Transactional
public void recalculateMemory(User user, Long memoryId, ZoneId timezone) {
Memory memory = memoryJdbcService.findById(user, memoryId).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
// Delete all existing blocks
memoryBlockJdbcService.deleteByMemoryId(memoryId);
// Generate new blocks
List<MemoryBlockPart> autoGeneratedBlocks = blockGenerationService.generate(user, memory, timezone);
// Save the generated blocks
for (MemoryBlockPart autoGeneratedBlock : autoGeneratedBlocks) {
if (autoGeneratedBlock instanceof MemoryBlockText textBlock) {
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, BlockType.TEXT);
memoryBlockTextJdbcService.create(new MemoryBlockText(memoryBlock.getId(), textBlock.getHeadline(), textBlock.getContent()));
} else if (autoGeneratedBlock instanceof MemoryBlockImageGallery imageGalleryBlock) {
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, BlockType.IMAGE_GALLERY);
memoryBlockImageGalleryJdbcService.create(new MemoryBlockImageGallery(memoryBlock.getId(), imageGalleryBlock.getImages()));
} else if (autoGeneratedBlock instanceof MemoryClusterBlock clusterBlock) {
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, clusterBlock.getType());
memoryClusterBlockRepository.save(user, new MemoryClusterBlock(memoryBlock.getId(),
clusterBlock.getPartIds(),
clusterBlock.getTitle(),
clusterBlock.getDescription(), clusterBlock.getType()));
}
}
log.info("Recalculated memory {} with {} blocks", memoryId, autoGeneratedBlocks.size());
}
private Optional<? extends MemoryBlockPart> getClusterTripBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
Optional<? extends MemoryBlockPart> part;
Optional<MemoryClusterBlock> clusterBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
part = clusterBlockOpt.map(memoryClusterBlock -> {
List<Trip> trips = tripJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
Optional<Trip> first = trips.stream().findFirst();
Optional<Trip> lastTrip = trips.stream().max(Comparator.comparing(Trip::getEndTime));
long movingTime = trips.stream().mapToLong(Trip::getDurationSeconds).sum();
long completeTime = first.map(trip -> Duration.between(trip.getStartTime(), lastTrip.get().getEndTime()).toSeconds()).orElse(0L);
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getStartVisit().getPlace(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = lastTrip.map(t -> adjustTime(settings, t.getEndTime(), t.getEndVisit().getPlace(), timezone)).orElse(null);
return new MemoryTripClusterBlockDTO(
memoryClusterBlock,
trips,
"/api/v1/raw-location-points/trips?trips=" + String.join(",", memoryClusterBlock.getPartIds().stream().map(Objects::toString).toList()),
adjustedStartTime,
adjustedEndTime,
completeTime,
movingTime);
});
return part;
}
private Optional<? extends MemoryBlockPart> getClusterVisitBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
Optional<? extends MemoryBlockPart> part;
Optional<MemoryClusterBlock> clusterVisitBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
part = clusterVisitBlockOpt.map(memoryClusterBlock -> {
List<ProcessedVisit> visits = processedVisitJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
Optional<ProcessedVisit> first = visits.stream().findFirst();
Optional<ProcessedVisit> last = visits.stream().max(Comparator.comparing(ProcessedVisit::getEndTime));
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getPlace(), timezone)).orElse(null);
LocalDateTime adjustedEndTime = last.map(t -> adjustTime(settings, t.getEndTime(), t.getPlace(), timezone)).orElse(null);
Long completeDuration = 0L;
return new MemoryVisitClusterBlockDTO(
memoryClusterBlock,
visits,
"/api/v1/raw-location-points?startDate=" + first.get().getStartTime().atZone(timezone).toLocalDateTime() + "&endDate=" + last.get().getEndTime().atZone(timezone).toLocalDateTime() + "&timezone=" + timezone,
adjustedStartTime,
adjustedEndTime,
completeDuration);
});
return part;
}
private LocalDateTime adjustTime(UserSettings settings, Instant startTime, SignificantPlace place, ZoneId timezone) {
if (settings.getTimeDisplayMode() == TimeDisplayMode.DEFAULT) {
return startTime.atZone(timezone).toLocalDateTime();
} else {
return startTime.atZone(place.getTimezone()).toLocalDateTime();
}
}
public List<Integer> getAvailableYears(User user) {
return this.memoryJdbcService.findDistinctYears(user);
}
public long getOwnerId(Memory memory) {
return this.memoryJdbcService.getOwnerId(memory).orElseThrow(() -> new PageNotFoundException("Memory not found"));
}
}

View File

@@ -0,0 +1,25 @@
package com.dedicatedcode.reitti.service;
import jakarta.servlet.http.HttpServletRequest;
public final class RequestHelper {
private RequestHelper() {
}
public static String getBaseUrl(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
String contextPath = request.getContextPath();
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(serverName);
if ((scheme.equals("http") && serverPort != 80) || (scheme.equals("https") && serverPort != 443)) {
url.append(":").append(serverPort);
}
url.append(contextPath);
return url.toString();
}
}

View File

@@ -0,0 +1,80 @@
package com.dedicatedcode.reitti.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.stream.Stream;
@Service
public class StorageService {
private final String storagePath;
public StorageService(@Value("${reitti.storage.path}") String storagePath) {
this.storagePath = storagePath;
Path path = Paths.get(storagePath);
if (!Files.isWritable(path)) {
throw new RuntimeException("Storage path '" + storagePath + "' is not writable. Please ensure the directory exists and the application has write permissions.");
}
}
public void store(String itemName, InputStream content, long contentLength, String contentType) {
Path filePath = Paths.get(storagePath, itemName);
try {
Files.createDirectories(filePath.getParent());
Files.copy(content, filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException("Failed to store item '" + itemName + "': " + e.getMessage(), e);
}
}
public StorageContent read(String itemName) {
Path filePath = Paths.get(storagePath, itemName);
try {
InputStream inputStream = Files.newInputStream(filePath);
String contentType = Files.probeContentType(filePath);
long contentLength = Files.size(filePath);
return new StorageContent(inputStream, contentType, contentLength);
} catch (IOException e) {
throw new RuntimeException("Failed to read item '" + itemName + "': " + e.getMessage(), e);
}
}
public boolean exists(String itemName) {
Path basePath = Paths.get(storagePath);
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + itemName);
try (Stream<Path> paths = Files.walk(basePath)) {
return paths
.map(basePath::relativize)
.anyMatch(matcher::matches);
} catch (IOException e) {
return false;
}
}
public static class StorageContent {
private final InputStream inputStream;
private final String contentType;
private final Long contentLength;
public StorageContent(InputStream inputStream, String contentType, Long contentLength) {
this.inputStream = inputStream;
this.contentType = contentType;
this.contentLength = contentLength;
}
public InputStream getInputStream() {
return inputStream;
}
public String getContentType() {
return contentType;
}
public Long getContentLength() {
return contentLength;
}
}
}

View File

@@ -54,7 +54,10 @@ public class ReverseGeocodingListener {
} else {
place = place.withName(street).withAddress(address);
}
place = place.withType(placeType).withCountryCode(countryCode);
place = place
.withType(placeType)
.withCity(city)
.withCountryCode(countryCode);
significantPlaceJdbcService.update(place.withGeocoded(true));
logger.info("Updated place ID: {} with geocoding data: {}", place.getId(), label);

View File

@@ -10,6 +10,7 @@ import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.ImmichIntegrationJdbcService;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
import com.dedicatedcode.reitti.service.StorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
@@ -34,13 +35,16 @@ public class ImmichIntegrationService {
private final ImmichIntegrationJdbcService immichIntegrationJdbcService;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final RestTemplate restTemplate;
private final StorageService storageService;
public ImmichIntegrationService(ImmichIntegrationJdbcService immichIntegrationJdbcService,
RawLocationPointJdbcService rawLocationPointJdbcService,
RestTemplate restTemplate) {
RestTemplate restTemplate,
StorageService storageService) {
this.immichIntegrationJdbcService = immichIntegrationJdbcService;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.restTemplate = restTemplate;
this.storageService = storageService;
}
public Optional<ImmichIntegration> getIntegrationForUser(User user) {
@@ -148,9 +152,8 @@ public class ImmichIntegrationService {
if (searchResponse.getAssets() != null && searchResponse.getAssets().getItems() != null) {
for (ImmichAsset asset : searchResponse.getAssets().getItems()) {
// Use proxy URLs instead of direct Immich URLs
String thumbnailUrl = "/api/v1/photos/proxy/" + asset.getId() + "/thumbnail";
String fullImageUrl = "/api/v1/photos/proxy/" + asset.getId() + "/original";
String thumbnailUrl = "/api/v1/photos/immich/proxy/" + asset.getId() + "/thumbnail";
String fullImageUrl = "/api/v1/photos/immich/proxy/" + asset.getId() + "/original";
Double latitude = null;
Double longitude = null;
@@ -193,4 +196,82 @@ public class ImmichIntegrationService {
return photos;
}
public ResponseEntity<byte[]> proxyImageRequest(User user, String assetId, String size) {
Optional<ImmichIntegration> integrationOpt = getIntegrationForUser(user);
if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) {
return ResponseEntity.notFound().build();
}
ImmichIntegration integration = integrationOpt.get();
try {
String baseUrl = integration.getServerUrl().endsWith("/") ?
integration.getServerUrl() : integration.getServerUrl() + "/";
String imageUrl = baseUrl + "api/assets/" + assetId + "/thumbnail?size=" + size;
HttpHeaders headers = new HttpHeaders();
headers.add("x-api-key", integration.getApiToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<byte[]> response = restTemplate.exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
HttpHeaders responseHeaders = new HttpHeaders();
// Copy content type from Immich response if available
if (response.getHeaders().getContentType() != null) {
responseHeaders.setContentType(response.getHeaders().getContentType());
} else {
// Default to JPEG for images
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
}
// Set cache headers for better performance
responseHeaders.setCacheControl("public, max-age=3600");
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
}
} catch (Exception e) {
// Log error but don't expose details
return ResponseEntity.notFound().build();
}
return ResponseEntity.notFound().build();
}
public String downloadImage(User user, String assetId, String targetPath) {
ResponseEntity<byte[]> response = proxyImageRequest(user, assetId, "fullsize");
if (response.getStatusCode().is2xxSuccessful()) {
byte[] imageData = response.getBody();
if (imageData != null) {
String contentType = response.getHeaders().getContentType() != null ? response.getHeaders().getContentType().toString() : "image/jpeg";
long contentLength = imageData.length;
String filename = assetId + getExtensionFromContentType(contentType);
storageService.store(targetPath + "/" + filename, new java.io.ByteArrayInputStream(imageData), contentLength, contentType);
return filename;
}
}
throw new IllegalStateException("Unable to download image from Immich");
}
private String getExtensionFromContentType(String contentType) {
return switch (contentType) {
case "image/jpeg" -> ".jpg";
case "image/png" -> ".png";
case "image/gif" -> ".gif";
case "image/webp" -> ".webp";
case null, default -> ".jpg"; // default
};
}
}

View File

@@ -13,3 +13,5 @@ spring.thymeleaf.cache=false
reitti.server.advertise-uri=http://localhost:8080
reitti.security.local-login.disable=false
reitti.storage.path=data/

View File

@@ -44,4 +44,6 @@ reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:}
reitti.import.batch-size=${PROCESSING_BATCH_SIZE:1000}
reitti.events.concurrency=${PROCESSING_WORKERS_PER_QUEUE:4-16}
reitti.storage.path=/data/
logging.level.root = INFO

View File

@@ -96,6 +96,9 @@ reitti.ui.tiles.default.attribution=&copy; <a href="https://www.openstreetmap.or
reitti.data-management.enabled=false
reitti.data-management.preview-cleanup.cron=0 0 4 * * *
reitti.storage.path=data/
reitti.storage.cleanup.cron=0 0 4 * * *
# For OIDC security configuration, create a separate oidc.properties file instead of configuring OIDC settings directly in this file. See the oidc.properties.example for the needed properties.
spring.config.import=optional:oidc.properties

View File

@@ -0,0 +1,70 @@
-- Create memory table
CREATE TABLE memory (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
header_type VARCHAR(50) NOT NULL,
header_image_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
version BIGINT NOT NULL DEFAULT 1,
CONSTRAINT memory_date_range_check CHECK (end_date >= start_date)
);
CREATE INDEX idx_memory_user_id ON memory(user_id);
CREATE INDEX idx_memory_date_range ON memory(start_date, end_date);
CREATE INDEX idx_memory_created_at ON memory(created_at DESC);
-- Create memory_block table
CREATE TABLE memory_block (
id BIGSERIAL PRIMARY KEY,
memory_id BIGINT NOT NULL REFERENCES memory(id) ON DELETE CASCADE,
block_type VARCHAR(50) NOT NULL,
position INTEGER NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
CONSTRAINT memory_block_position_check CHECK (position >= 0),
CONSTRAINT memory_block_unique_position UNIQUE (memory_id, position)
);
CREATE INDEX idx_memory_block_memory_id ON memory_block(memory_id);
CREATE INDEX idx_memory_block_position ON memory_block(memory_id, position);
-- Create memory_block_visit table
CREATE TABLE memory_block_visit (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
visit_id BIGINT NOT NULL REFERENCES processed_visits(id) ON DELETE CASCADE
);
CREATE INDEX idx_memory_block_visit_visit_id ON memory_block_visit(visit_id);
-- Create memory_block_trip table
CREATE TABLE memory_block_trip (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE
);
CREATE INDEX idx_memory_block_trip_trip_id ON memory_block_trip(trip_id);
-- Create memory_block_text table
CREATE TABLE memory_block_text (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
headline VARCHAR(255),
content TEXT
);
-- Create memory_block_image_gallery table
CREATE TABLE memory_block_image_gallery (
id BIGSERIAL PRIMARY KEY,
block_id BIGINT NOT NULL REFERENCES memory_block(id) ON DELETE CASCADE,
image_url TEXT NOT NULL,
caption TEXT,
position INTEGER NOT NULL,
CONSTRAINT memory_block_image_gallery_position_check CHECK (position >= 0),
CONSTRAINT memory_block_image_gallery_unique_position UNIQUE (block_id, position)
);
CREATE INDEX idx_memory_block_image_gallery_block_id ON memory_block_image_gallery(block_id);
CREATE INDEX idx_memory_block_image_gallery_position ON memory_block_image_gallery(block_id, position);

View File

@@ -0,0 +1,40 @@
-- Drop existing foreign key constraints and recreate tables with embedded data
-- Drop old tables
DROP TABLE IF EXISTS memory_block_visit;
DROP TABLE IF EXISTS memory_block_trip;
-- Recreate memory_block_visit with embedded data
CREATE TABLE memory_block_visit (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
original_processed_visit_id BIGINT REFERENCES processed_visits(id) ON DELETE SET NULL,
place_name VARCHAR(255),
place_address TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
duration_seconds BIGINT NOT NULL
);
CREATE INDEX idx_memory_block_visit_original_id ON memory_block_visit(original_processed_visit_id);
CREATE INDEX idx_memory_block_visit_start_time ON memory_block_visit(start_time);
-- Recreate memory_block_trip with embedded data
CREATE TABLE memory_block_trip (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
duration_seconds BIGINT NOT NULL,
estimated_distance_meters DOUBLE PRECISION,
travelled_distance_meters DOUBLE PRECISION,
transport_mode_inferred VARCHAR(50),
start_place_name VARCHAR(255),
start_latitude DOUBLE PRECISION,
start_longitude DOUBLE PRECISION,
end_place_name VARCHAR(255),
end_latitude DOUBLE PRECISION,
end_longitude DOUBLE PRECISION
);
CREATE INDEX idx_memory_block_trip_start_time ON memory_block_trip(start_time);

View File

@@ -0,0 +1,9 @@
CREATE TABLE memory_block_cluster (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trip_ids JSONB NOT NULL, -- JSON array of trip block IDs (e.g., [1, 2, 3])
title VARCHAR(255),
description TEXT
);
CREATE INDEX idx_memory_block_cluster_block_id ON memory_block_cluster(block_id);

View File

@@ -0,0 +1 @@
ALTER TABLE significant_places ADD COLUMN city VARCHAR(255);

View File

@@ -0,0 +1,14 @@
-- Refactor memory_block_image_gallery to store multiple images per block
-- Drop the old table and create a new one with JSONB for images
DROP TABLE IF EXISTS memory_block_image_gallery;
CREATE TABLE memory_block_image_gallery (
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
images JSONB NOT NULL DEFAULT '[]'::jsonb
);
CREATE INDEX idx_memory_block_image_gallery_block_id ON memory_block_image_gallery(block_id);
COMMENT ON TABLE memory_block_image_gallery IS 'Stores image galleries for memory blocks with multiple images per gallery';
COMMENT ON COLUMN memory_block_image_gallery.images IS 'JSON array of image objects with imageUrl and caption fields';

View File

@@ -0,0 +1,3 @@
ALTER TABLE memory_block_cluster RENAME COLUMN trip_ids TO part_ids;
ALTER TABLE memory_block_cluster ADD COLUMN type VARCHAR(255) DEFAULT 'CLUSTER_TRIP';
ALTER TABLE memory_block_cluster ALTER COLUMN type DROP DEFAULT;

View File

@@ -0,0 +1,2 @@
DROP TABLE memory_block_trip;
DROP TABLE memory_block_Visit;

View File

@@ -0,0 +1,4 @@
ALTER TABLE magic_link_tokens ADD COLUMN resource_type VARCHAR(255) DEFAULT 'MAP';
ALTER TABLE magic_link_tokens ADD COLUMN resource_id BIGINT;
ALTER TABLE magic_link_tokens ALTER COLUMN resource_type DROP DEFAULT;

View File

@@ -5,6 +5,7 @@ statistics.page.title=Statistics - Reitti
# Navigation
nav.timeline=Timeline
nav.statistics=Statistics
nav.memories=Memories
nav.settings=Settings
nav.logout=Logout
nav.settings.tooltip=Open settings\u2026
@@ -55,6 +56,261 @@ settings.job.status=Job Status
settings.import.data=Import Data
settings.share.access=Share Access
# Countries
country.af.label=Afghanistan
country.ax.label=\u00C5land Islands
country.al.label=Albania
country.dz.label=Algeria
country.as.label=American Samoa
country.ad.label=Andorra
country.ao.label=Angola
country.ai.label=Anguilla
country.aq.label=Antarctica
country.ag.label=Antigua and Barbuda
country.ar.label=Argentina
country.am.label=Armenia
country.aw.label=Aruba
country.au.label=Australia
country.at.label=Austria
country.az.label=Azerbaijan
country.bs.label=Bahamas
country.bh.label=Bahrain
country.bd.label=Bangladesh
country.bb.label=Barbados
country.by.label=Belarus
country.be.label=Belgium
country.bz.label=Belize
country.bj.label=Benin
country.bm.label=Bermuda
country.bt.label=Bhutan
country.bo.label=Bolivia
country.bq.label=Bonaire, Sint Eustatius and Saba
country.ba.label=Bosnia and Herzegovina
country.bw.label=Botswana
country.br.label=Brazil
country.io.label=British Indian Ocean Territory
country.bn.label=Brunei Darussalam
country.bg.label=Bulgaria
country.bf.label=Burkina Faso
country.bi.label=Burundi
country.cv.label=Cabo Verde
country.kh.label=Cambodia
country.cm.label=Cameroon
country.ca.label=Canada
country.ky.label=Cayman Islands
country.cf.label=Central African Republic
country.td.label=Chad
country.cl.label=Chile
country.cn.label=China
country.cx.label=Christmas Island
country.cc.label=Cocos (Keeling) Islands
country.co.label=Colombia
country.km.label=Comoros
country.cg.label=Congo
country.cd.label=Congo (Democratic Republic of the)
country.ck.label=Cook Islands
country.cr.label=Costa Rica
country.ci.label=C\u00F4te d'Ivoire
country.hr.label=Croatia
country.cu.label=Cuba
country.cw.label=Cura\u00E7ao
country.cy.label=Cyprus
country.cz.label=Czechia
country.dk.label=Denmark
country.dj.label=Djibouti
country.dm.label=Dominica
country.do.label=Dominican Republic
country.ec.label=Ecuador
country.eg.label=Egypt
country.sv.label=El Salvador
country.gq.label=Equatorial Guinea
country.er.label=Eritrea
country.ee.label=Estonia
country.sz.label=Eswatini
country.et.label=Ethiopia
country.fk.label=Falkland Islands (Malvinas)
country.fo.label=Faroe Islands
country.fj.label=Fiji
country.fi.label=Finland
country.fr.label=France
country.gf.label=French Guiana
country.pf.label=French Polynesia
country.tf.label=French Southern Territories
country.ga.label=Gabon
country.gm.label=Gambia
country.ge.label=Georgia
country.de.label=Germany
country.gh.label=Ghana
country.gi.label=Gibraltar
country.gr.label=Greece
country.gl.label=Greenland
country.gd.label=Grenada
country.gp.label=Guadeloupe
country.gu.label=Guam
country.gt.label=Guatemala
country.gg.label=Guernsey
country.gn.label=Guinea
country.gw.label=Guinea-Bissau
country.gy.label=Guyana
country.ht.label=Haiti
country.va.label=Holy See
country.hn.label=Honduras
country.hk.label=Hong Kong
country.hu.label=Hungary
country.is.label=Iceland
country.in.label=India
country.id.label=Indonesia
country.ir.label=Iran
country.iq.label=Iraq
country.ie.label=Ireland
country.im.label=Isle of Man
country.il.label=Israel
country.it.label=Italy
country.jm.label=Jamaica
country.jp.label=Japan
country.je.label=Jersey
country.jo.label=Jordan
country.kz.label=Kazakhstan
country.ke.label=Kenya
country.ki.label=Kiribati
country.kp.label=Korea (North)
country.kr.label=Korea (South)
country.kw.label=Kuwait
country.kg.label=Kyrgyzstan
country.la.label=Laos
country.lv.label=Latvia
country.lb.label=Lebanon
country.ls.label=Lesotho
country.lr.label=Liberia
country.ly.label=Libya
country.li.label=Liechtenstein
country.lt.label=Lithuania
country.lu.label=Luxembourg
country.mo.label=Macao
country.mg.label=Madagascar
country.mw.label=Malawi
country.my.label=Malaysia
country.mv.label=Maldives
country.ml.label=Mali
country.mt.label=Malta
country.mh.label=Marshall Islands
country.mq.label=Martinique
country.mr.label=Mauritania
country.mu.label=Mauritius
country.yt.label=Mayotte
country.mx.label=Mexico
country.fm.label=Micronesia
country.md.label=Moldova
country.mc.label=Monaco
country.mn.label=Mongolia
country.me.label=Montenegro
country.ms.label=Montserrat
country.ma.label=Morocco
country.mz.label=Mozambique
country.mm.label=Myanmar
country.na.label=Namibia
country.nr.label=Nauru
country.np.label=Nepal
country.nl.label=Netherlands
country.nc.label=New Caledonia
country.nz.label=New Zealand
country.ni.label=Nicaragua
country.ne.label=Niger
country.ng.label=Nigeria
country.nu.label=Niue
country.nf.label=Norfolk Island
country.mp.label=Northern Mariana Islands
country.mk.label=North Macedonia
country.no.label=Norway
country.om.label=Oman
country.pk.label=Pakistan
country.pw.label=Palau
country.ps.label=Palestine, State of
country.pa.label=Panama
country.pg.label=Papua New Guinea
country.py.label=Paraguay
country.pe.label=Peru
country.ph.label=Philippines
country.pn.label=Pitcairn
country.pl.label=Poland
country.pt.label=Portugal
country.pr.label=Puerto Rico
country.qa.label=Qatar
country.re.label=R\u00E9union
country.ro.label=Romania
country.ru.label=Russian Federation
country.rw.label=Rwanda
country.bl.label=Saint Barth\u00E9lemy
country.sh.label=Saint Helena, Ascension and Tristan da Cunha
country.kn.label=Saint Kitts and Nevis
country.lc.label=Saint Lucia
country.mf.label=Saint Martin (French part)
country.pm.label=Saint Pierre and Miquelon
country.vc.label=Saint Vincent and the Grenadines
country.ws.label=Samoa
country.sm.label=San Marino
country.st.label=Sao Tome and Principe
country.sa.label=Saudi Arabia
country.sn.label=Senegal
country.rs.label=Serbia
country.sc.label=Seychelles
country.sl.label=Sierra Leone
country.sg.label=Singapore
country.sx.label=Sint Maarten (Dutch part)
country.sk.label=Slovakia
country.si.label=Slovenia
country.sb.label=Solomon Islands
country.so.label=Somalia
country.za.label=South Africa
country.gs.label=South Georgia and the South Sandwich Islands
country.ss.label=South Sudan
country.es.label=Spain
country.lk.label=Sri Lanka
country.sd.label=Sudan
country.sr.label=Suriname
country.sj.label=Svalbard and Jan Mayen
country.se.label=Sweden
country.ch.label=Switzerland
country.sy.label=Syrian Arab Republic
country.tw.label=Taiwan
country.tj.label=Tajikistan
country.tz.label=Tanzania
country.th.label=Thailand
country.tl.label=Timor-Leste
country.tg.label=Togo
country.tk.label=Tokelau
country.to.label=Tonga
country.tt.label=Trinidad and Tobago
country.tn.label=Tunisia
country.tr.label=Turkey
country.tm.label=Turkmenistan
country.tc.label=Turks and Caicos Islands
country.tv.label=Tuvalu
country.ug.label=Uganda
country.ua.label=Ukraine
country.ae.label=United Arab Emirates
country.gb.label=United Kingdom
country.us.label=United States
country.um.label=United States Minor Outlying Islands
country.uy.label=Uruguay
country.uz.label=Uzbekistan
country.vu.label=Vanuatu
country.ve.label=Venezuela
country.vn.label=Viet Nam
country.vg.label=Virgin Islands (British)
country.vi.label=Virgin Islands (U.S.)
country.wf.label=Wallis and Futuna
country.eh.label=Western Sahara
country.ye.label=Yemen
country.zm.label=Zambia
country.zw.label=Zimbabwe
country.unknown.label=Unknown
#Formats
format.hours_minutes={0,choice,0#|1#{0} hour|1<{0} hours} {1,choice,0#|1#and {1} minute|1<and {1} minutes}
format.minutes_only={0,choice,1#{0} minute|1<{0} minutes}
# Navigation
nav.back.to.timeline=Back to Timeline
@@ -171,6 +427,8 @@ places.edit.visit.stats.title=Visit Statistics
places.edit.visit.summary=You visited {0} {1} times.
places.edit.visit.complete=You visited {0} {1} times. Your first visit was on {2} and your most recent visit was on {3}.
places.edit.no.visits=No visits recorded for this place yet.
place.type.train_station=Train Station
place.type.gas_station=Gas Station
place.type.restaurant=Restaurant
place.type.park=Park
place.type.shop=Shop
@@ -179,8 +437,6 @@ place.type.work=Work
place.type.hospital=Hospital
place.type.school=School
place.type.airport=Airport
place.type.train_station=Train Station
place.type.gas_station=Gas Station
place.type.hotel=Hotel
place.type.bank=Bank
place.type.pharmacy=Pharmacy
@@ -189,12 +445,25 @@ place.type.library=Library
place.type.church=Church
place.type.cinema=Cinema
place.type.cafe=Cafe
place.type.museum=Museum
place.type.landmark=Landmark
place.type.tourist_attraction=Tourist Attraction
place.type.historic_site=Historic Site
place.type.monument=Monument
place.type.shopping_mall=Shopping Mall
place.type.market=Market
place.type.gallery=Gallery
place.type.theater=Theater
place.type.grocery_store=Grocery Store
place.type.atm=ATM
place.type.other=Other
# Forms
form.create=Create
form.update=Update
form.delete=Delete
form.cancel=Cancel
form.save.changes=Save Changes
form.save=Save
form.previous=Previous
form.next=Next
form.refresh=Refresh
@@ -296,7 +565,7 @@ integrations.owntracks.recorder.auth.optional=Leave empty if no authentication i
integrations.owntracks.recorder.enabled=Enable Integration
integrations.owntracks.recorder.save=Save Configuration
integrations.owntracks.recorder.test.connection=Test Connection
integrations.owntracks.recorder.connection.success=Connection successful
integrations.owntracks.recorder.connection.success=The connection was successful.
integrations.owntracks.recorder.connection.failed=Connection failed: {0}
integrations.owntracks.recorder.config.saved=OwnTracks Recorder configuration saved successfully
integrations.owntracks.recorder.config.error=Error saving configuration: {0}
@@ -540,6 +809,13 @@ error.action.home=Go Home
error.action.back=Go Back
error.action.retry=Try Again
# Memory validation
memory.validation.start.date.required=Start date is required
memory.validation.end.date.required=End date is required
memory.validation.end.date.before.start=End date cannot be before start date
memory.validation.title.required=Title is required
memory.validation.date.future=Dates cannot be in the future
share-access.title=Share Access
magic.links.title=Magic Links
@@ -562,6 +838,8 @@ magic.links.access.level.label=Access Level
magic.links.access.level.full_access=Full Access
magic.links.access.level.only_live=Live Data Only
magic.links.access.level.only_live_with_photos=Live Data Only + Photos
magic.links.access.level.memory_view_only=View Memory
magic.links.access.level.memory_edit_access=View and Edit Memory
magic.links.expiry.days.label=Expiry (Days)
magic.links.expiry.days.placeholder=e.g., 30
magic.links.expiry.days.help=Leave empty for no expiration
@@ -626,6 +904,7 @@ visit.sensitivity.recalculation.error=Error starting recalculation: {0}
# Visit Sensitivity Validation Messages
visit.sensitivity.validation.date.duplicate=A configuration already exists for this date. Please choose a different date.
visit.sensitivity.validation.save.error=Error saving configuration: {0}
magic.links.info.title=About Magic Links
magic.links.info.description=Magic links allow you to share your location data with others without requiring them to create an account. Anyone with the link can access your data according to the permissions you set.
magic.links.info.security.title=Security Considerations
@@ -766,3 +1045,149 @@ settings.geocoding.description=Configure geocoding services to convert coordinat
settings.manage.data.description=Manually trigger data processing and manage your location data
settings.integrations.description=Connect external services and mobile apps to automatically import location data
settings.about.description=View application version and build information
memory.new.page.title=New Memory - Reitti
memory.new.title=New Memory
memory.new.back.to.memories=Back to Memories
memory.form.title.label=Title *
memory.form.title.placeholder=Give your memory a title
memory.form.description.label=Description
memory.form.description.placeholder=Add a description (optional)
memory.form.start.date.label=Start Date *
memory.form.end.date.label=End Date *
memory.form.header.type.label=Header Type *
memory.form.header.type.map=Map
memory.form.header.type.image=Image
memory.form.header.image.url.label=Header Image URL
memory.form.header.image.url.placeholder=https://example.com/image.jpg
memory.form.cancel=Cancel
memory.form.create=Create Memory
memory.form.creating=Creating...
memory.view.edit=Edit
memory.view.back=Back
memory.view.recalculate=Recalculate
memory.view.add.block=Add Block after
memory.view.add.first.block=Add your first Block
memory.view.no.blocks=No blocks yet. Add your first block to start building your memory.
memory.view.block.text.title=Text Block
memory.view.block.text.content=Content will be loaded here
memory.view.block.visit.content=Visit block
memory.view.block.trip.content=Trip block
memory.view.block.gallery.content=Image gallery
memory.view.block.cluster.duration=Took {0} hours {1} minutes. {2} hours and {3} moving.
memory.view.block.cluster_visit.duration=Spent {0} hours {1} minutes.
memory.generator.day.text=Day {0}: {1}
memory.generator.headline.text=Our Journey
memory.generator.introductory.text=What an unforgettable adventure we had! Our journey began on {0} as we set out from {1}, and for the \
next {2} days, we made {3}, {4} our wonderful home base. \
From there, we explored the heart of the region, filling our days with {5} memorable visits across {6} beautiful locations. \
This is the story of our time together, the places we saw, and the memories we created before returning home on {7}.
memory.generator.travel_to_accommodation.text=We set off from {0} at {1} and arrived in {2} at {3}. \
The total time for this part of our trip was {4}, with {5} of that spent actively traveling. Now it's time for us to relax, unpack, and prepare for what's next.
memory.generator.travel_from_accommodation.text=We set off from {0} at {1} and arrived back home in {2} at {3}. \
The total time for this final part of our trip was {4}, with {5} of that spent actively traveling. \
Our journey has concluded, and now we can look back on all the memories we've made.
memory.generator.intro_accommodation.headline=Welcome to {0}
memory.generator.intro_accommodation.text=We are officially checked in! We took a moment to appreciate the atmosphere before dealing with our luggage. \
It feels good to be here, and we're looking forward to exploring the immediate surroundings. This place is going to be a great home base for our trip.
memory.list.all=All
memory.block.select.type=Select Block Type
memory.block.type.text=Text
memory.block.type.text.description=Add text content with headlines and paragraphs
memory.block.type.visit=Visit
memory.block.type.visit.description=Add a location you visited during this memory
memory.block.type.trip=Trip
memory.block.type.trip.description=Add a journey or route from this memory
memory.block.type.gallery=Image Gallery
memory.block.type.gallery.description=Add a collection of photos from this memory
memory.block.cancel=Cancel
memory.block.text.new=New Text Block
memory.block.text.headline=Headline
memory.block.text.headline.placeholder=Enter headline
memory.block.text.content=Content
memory.block.text.content.placeholder=Enter your text content
memory.block.create=Create Block
memory.block.visit.new=New Visit Block
memory.block.visit.select=Select Visit
memory.block.visit.select.placeholder=Choose a visit...
memory.block.trip.new=New Trip Block
memory.block.trip.select=Select Trip
memory.block.trip.select.placeholder=Choose a trip...
memory.block.gallery.new=New Image Gallery Block
memory.block.gallery.edit=Edit Image Gallery Block
memory.block.gallery.immich.title=Select from Immich
memory.block.gallery.loading=Loading photos...
memory.block.gallery.selected.title=Selected Photos
memory.block.gallery.upload.title=Upload Images
memory.block.gallery.upload.choose=Choose a file or drag here
memory.block.gallery.immich.no.photos=No photos found for this date range
memory.block.gallery.pagination.previous=Previous
memory.block.gallery.pagination.next=Next
memory.block.gallery.error.no.images=Please select or upload at least one image
memory.block.gallery.error.create=Failed to create a gallery block
memory.block.gallery.remove=Remove image
memory.edit.block.title=Title
memory.edit.block.title.placeholder=Enter title
memory.edit.block.cluster.trip.title = Edit Trips Block
memory.edit.block.cluster.trip.select.trips = Select Trips
memory.edit.block.cluster.trip.selected = Selected
memory.edit.block.cluster.trip.trip = Trip
memory.edit.block.cluster.visit.title = Edit Visit Block
memory.edit.block.cluster.visit.select.visits = Select Visits
memory.edit.block.cluster.visit.selected = Selected
memory.edit.block.cluster.visit.visit = Visit
memory.form.date.error.end.before.start=The end date must be equal or after the start date.
# Memory sharing functionality
memory.share.title=Share Memory
memory.share.what.title=What will be shared?
memory.share.what.content=The complete memory with all its content blocks
memory.share.what.location=Location data and maps for the memory period
memory.share.what.photos=Photos and text content within the memory
memory.share.what.trips=Trip and visit information during this time period
memory.share.permissions.title=Choose sharing permissions:
memory.share.view.title=View Only
memory.share.view.description=Recipients can view the memory but cannot make any changes
memory.share.edit.title=Edit Access
memory.share.edit.description=Recipients can view and edit the memory, add blocks, and modify content
# Share configuration
memory.share.configure.title=Configure Share Link
memory.share.configure.sharing=Sharing
memory.share.access.view=View Only Access
memory.share.access.edit=Edit Access
memory.share.expires.label=Link expires after:
memory.share.expires.7days=7 days
memory.share.expires.30days=30 days
memory.share.expires.90days=90 days
memory.share.expires.never=Never expires
memory.share.expires.help=Choose how long the share link should remain valid
memory.share.create.button=Create Share Link
memory.share.back.button=Back
# Share result
memory.share.result.title=Share Link Created
memory.share.result.success=Share Link Created Successfully!
memory.share.result.memory=Memory:
memory.share.result.access=Access Level:
memory.share.result.link.label=Share this link:
memory.share.result.copy=Copy
memory.share.result.copied=Copied!
memory.share.result.instructions.title=How to share:
memory.share.result.instructions.copy=Copy the link above and send it to anyone you want to share with
memory.share.result.instructions.account=Recipients don't need an account to access the memory
memory.share.result.instructions.permissions=The link will work according to the permissions you've set
memory.share.result.instructions.view=Recipients can view but not edit the memory
memory.share.result.instructions.edit=Recipients can view and edit the memory
memory.share.result.done=Done
memory.share.result.another=Create Another Link

View File

@@ -0,0 +1,321 @@
/* Gallery Block Form Styles */
.gallery-block-form {
max-width: 1200px;
margin: 0 auto;
}
.gallery-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.gallery-panels {
grid-template-columns: 1fr;
}
}
/* Immich Photos Container */
.immich-photos-container {
margin-bottom: 1.5rem;
}
.immich-photos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
min-height: 200px;
}
.immich-photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
border: 1px solid var(--color-highlight);
transition: all 0.2s ease;
}
.immich-photo-item:hover {
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3); transform: scale(1.02);
}
.immich-photo-item.selected {
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
}
.immich-photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.immich-photo-item:hover .photo-overlay,
.immich-photo-item.selected .photo-overlay {
opacity: 1;
}
.photo-overlay i {
font-size: 2rem;
color: #6c6c6c;
}
.immich-photo-item.selected .photo-overlay i {
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
background: #6c6c6c;
border-radius: 50%;
padding: 0.25rem;
}
/* Loading State */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-secondary, #757575);
font-style: italic;
}
/* No Photos State */
.no-photos {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-secondary, #757575);
text-align: center;
padding: 2rem;
}
/* Pagination Controls */
.immich-pagination {
margin-top: 1rem;
}
.pagination-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
grid-column: 1 / span 3;
}
.pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gallery-images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-gap: 8px;
}
/* Selected Photos List */
.selected-photos-combined {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(256px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.gallery-image-item,
.selected-photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
transition: all 0.3s ease;
}
.gallery-image-item:hover {
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
transform: scale(1.02);
transition: all 0.3s ease;
z-index: 1000;
}
.gallery-image-item img,
.selected-photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.selected-photo-item .btn-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease;
}
.selected-photo-item .btn-remove:hover {
background: rgba(211, 47, 47, 0.9);
transform: scale(1.1);
}
.selected-photo-item .btn-remove i {
font-size: 0.875rem;
}
/* Upload Panel */
.upload-area {
margin-bottom: 1.5rem;
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
border: 1px solid var(--color-highlight);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.upload-label:hover {
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3); transform: scale(1.02);
}
.upload-label i {
font-size: 3rem;
color: var(--color-highlight);
margin-bottom: 1rem;
}
.upload-label span {
color: var(--color-highlight);
font-size: 0.875rem;
}
#fileInput {
display: none;
}
/* Uploaded Files List */
.uploaded-files-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.uploaded-file-item {
position: relative;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
overflow: hidden;
background: var(--surface-color, #ffffff);
}
.uploaded-file-item img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.uploaded-file-item span {
display: block;
padding: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary, #757575);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--surface-variant, #f5f5f5);
}
.uploaded-file-item .btn-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: none;
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease;
}
.uploaded-file-item .btn-remove:hover {
background: rgba(211, 47, 47, 0.9);
transform: scale(1.1);
}
.uploaded-file-item .btn-remove i {
font-size: 0.875rem;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.immich-photos-grid {
grid-template-columns: repeat(2, 1fr);
}
.selected-photos-combined {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
}
.uploaded-files-list {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.form-actions {
flex-direction: column-reverse;
}
.form-actions button {
width: 100%;
}
}

View File

@@ -31,6 +31,7 @@
--color-background-dark-light: #5c5c5c;
--color-text-white: #e3e3e3;
--serif-font: "Fraunces";
--memories-header-height: 550px;
}
body {
@@ -57,6 +58,7 @@ body {
}
.navbar .nav-link {
background: transparent;
color: white;
text-decoration: none;
padding: 5px 10px;
@@ -64,6 +66,10 @@ body {
transition: background-color 0.2s;
}
.navbar .nav-link.active {
color: var(--color-highlight)
}
.navbar .nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
@@ -121,9 +127,15 @@ header {
/* Elements */
.btn {
background-color: var(--color-background-dark);
font-family: var(--sans-font);
}
.btn.btn-block {
width: 100%;
}
display: inline-block;
box-sizing: border-box;}
.btn.btn-danger {
background: #995353;
@@ -190,7 +202,7 @@ label {
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
}
input[type="text"], input[type="password"], input[type="url"], input[type="number"], input[type="date"], select {
input[type="text"], input[type="password"], input[type="url"], input[type="number"], input[type="date"], select, textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
@@ -201,6 +213,20 @@ input[type="text"], input[type="password"], input[type="url"], input[type="numbe
}
select:active,
textarea:active,
input:active,
select:focus,
textarea:focus,
input:focus {
border-color: var(--color-highlight);
}
textarea:focus-visible {
outline: none;
}
select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e3e3e3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
@@ -488,15 +514,18 @@ tr:hover {
height: initial;
}
a.btn,
button {
background-color: transparent;
font-size: 14px;
color: var(--color-highlight);
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
a.btn:hover,
button:hover {
background-color: #959595;
}
@@ -712,7 +741,7 @@ input[type=file]::file-selector-button:hover {
.statistics-content {
margin-left: 30rem;
color: var(--color-text-white);
font-family: "Fraunces", serif;
font-family: var(--serif-font);
background: transparent;
z-index: 1000;
position: relative;
@@ -987,7 +1016,7 @@ input[type=file]::file-selector-button:hover {
background-color: var(--color-background-dark);
color: var(--color-text-white);
min-height: 100vh;
font-family: "Fraunces", serif;
font-family: var(--serif-font);
font-weight: normal;
}
@@ -1099,7 +1128,7 @@ input[type=file]::file-selector-button:hover {
.settings-container {
flex-direction: column;
}
.settings-page .settings-nav {
width: 100%;
display: flex;
@@ -1108,32 +1137,33 @@ input[type=file]::file-selector-button:hover {
border-right: none;
border-bottom: 1px solid var(--color-highlight);
}
.settings-page .settings-nav-item {
white-space: nowrap;
border-left: none;
border-bottom: 3px solid transparent;
padding: 18px 8px;
}
.settings-page .settings-nav-item.active {
border-left: none;
border-bottom-color: var(--color-highlight);
}
.settings-content-area {
padding: 20px;
}
.settings-header {
padding: 15px;
}
.settings-header h1 {
font-size: 1.5rem;
}
}
a.btn:disabled,
button:disabled {
background: #9b9b9b;
cursor: not-allowed;
@@ -1345,7 +1375,7 @@ button:disabled {
.user-header {
pointer-events: all;
color: white;
font-family: "Fraunces", serif;
font-family: var(--serif-font);
font-weight: normal;
display: inline-block;
text-align: center;
@@ -1421,7 +1451,7 @@ button:disabled {
.avatar::after {
content: attr(alt);
font-size: 36px;
font-family: "Fraunces", serif;
font-family: var(--serif-font);
font-weight: lighter;
color: white;
position: absolute;
@@ -1596,7 +1626,7 @@ button:disabled {
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
font-family: "Fraunces", serif;
font-family: var(--serif-font);
text-align: center;
}
@@ -1661,8 +1691,50 @@ button:disabled {
pointer-events: none;
}
/* Create Memory FAB */
.create-memory-fab {
position: fixed;
bottom: 320px;
right: 2rem;
z-index: 999;
animation: slideInUp 0.3s ease-out;
}
.fab-button {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background-color: #ff6c00;
color: white;
border-radius: 50px;
text-decoration: none;
box-shadow: 0 4px 12px rgba(255, 108, 0, 0.4);
transition: all 0.3s ease;
font-weight: 600;
font-size: 1rem;
}
.fab-button:hover {
background-color: #e66100;
box-shadow: 0 6px 16px rgba(255, 108, 0, 0.5);
transform: translateY(-2px);
}
.fab-button i {
font-size: 1.2rem;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.home-location-inputs {
@@ -1674,3 +1746,396 @@ button:disabled {
min-width: unset;
}
}
.memories-overview {
margin-left: 30rem;
color: var(--color-text-white);
font-family: var(--serif-font);
background: transparent;
z-index: 1000;
position: relative;
max-height: 100vh;
padding: 2rem;
}
/* Memories */
.memories-page {
background-color: var(--color-background-dark);
color: var(--color-text-white);
min-height: 100vh;
font-family: var(--serif-font);
font-weight: normal;
}
.memories-page .btn {
border: 1px solid var(--color-highlight);
}
.memories-page .btn i {
vertical-align: middle;
}
.memories-page .settings-nav-item {
display: block;
text-decoration: none;
padding: 15px 25px;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: none;
transition: all 0.2s ease;
color: var(--color-text-white);
}
.memories-page .settings-nav-item:hover {
background-color: var(--color-background-dark);
border-left-color: var(--color-highlight);
}
.memories-page .settings-nav-item.active {
border-bottom: none;
font-weight: bold;
color: var(--color-highlight);
}
.memory-header #edit-fragment {
margin: var(--memories-header-height) auto;
margin-top: 0;
padding: 30px;
width: 100%;
max-width: 1280px;
}
.memories-page .memories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
gap: 20px;
margin-top: 20px;
}
.memories-page .memory-card {
background: var(--color-background-dark);
border: 1px solid var(--color-highlight);
border-radius: 12px;
text-align: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
overflow: hidden;
}
.memories-page .memory-card .memory-card-details {
display: flex;
flex-direction: column;
height: 180px;
padding: 15px;
}
.memories-page .memory-card .memory-card-info {
margin: 15px 0;
font-size: 0.9em;
flex-grow: 2;
}
.memories-page .memory-card .memory-card-info div {
margin-bottom: 5px;
}
.memories-page .memory-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
}
.memories-page .memory-card-map-container {
height: 400px;
width: 100%;
}
.memories-page .settings-content-area {
flex: 1;
overflow-y: auto;
font-family: initial;
}
.memories-page .memory-header {
position: absolute;
left: 0;
right: 0;
top: 0;
height: var(--memories-header-height);
}
.memories-page .memory-header .action-bar {
z-index: 500;
position: relative;
padding: 24px;
display: flex;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.memories-page .memory-header:hover .action-bar {
opacity: 1;
pointer-events: all;
}
.memories-page .memory-header .action-bar div:first-child {
flex-grow: 2;
}
.memories-page .memory-details {
position: absolute;
text-align: center;
font-family: var(--serif-font);
color: #fbe7c3;
font-size: 2em;
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
width: 100%;
}
.memories-page .memory-map {
position: absolute;
height: var(--memories-header-height);
width: 100%;
top: 0;
z-index: 0;
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
}
.memories-page .memory-blocks-container {
padding: 30px;
margin: var(--memories-header-height) auto 0;
width: 100%;
max-width: 1280px;
}
.memories-page .memory-block {
position: relative;
margin-bottom: 16px;
}
.memories-page .memory-block .block-actions {
position: absolute;
right: 8px;
top: 8px;
z-index: 5000;
opacity: 0;
transition: opacity 0.3s ease;
}
.memories-page .memory-block:hover .block-actions {
opacity: 1;
}
.memories-page .memory-block .time::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 30%;
height: 1px;
background-color: #ccc;
margin-top: 36px;
}
.memories-page .text-block {
font-size: 1.2em;
}
.memories-page .visit-address,
.memories-page .cluster-block .cluster-duration {
text-align: center;
}
.memories-page .cluster-block .cluster-duration {
}
.memories-page .cluster-block .block-header,
.memories-page .visit-block .block-header {
position: relative;
margin-bottom: 16px;
}
.memories-page .cluster-block .cluster-map {
height: 550px;
border-radius: 16px;
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
}
.memories-page .visit-block .visit-map {
height: 200px;
border-radius: 16px;
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
}
.memories-page .block-header .time {
text-align: center;
font-size: 1.2em;
font-weight: 300;
font-family: var(--serif-font);
color: lightgray;
}
.memories-page .block-form {
margin-bottom: 16px;
}
.memories-page .add-block-spacer {
opacity: 0;
transition: all 0.3s ease-in-out;
text-align: center;
margin: 10px 0;
pointer-events: none;
height: 1px;
}
.memories-page .empty-blocks .add-block-spacer,
.memories-page .memory-block:hover .add-block-spacer {
opacity: 1;
pointer-events: auto;
height: 36px;
}
.memories-page .block-header .title {
margin: 0;
font-size: 1.5em;
text-align: center;
color: var(--color-highlight);
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
}
.memories-page .block-header .visit-title {
position: absolute;
bottom: 0;
text-align: center;
font-family: var(--serif-font);
color: #fbe7c3;
font-size: 1.8em;
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
z-index: 500;
right: 10px;
font-weight: inherit;
}
.block-type-selection {
background-color: var(--color-background-dark);
border-radius: 8px;
padding: 15px;
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
flex: 1;
min-width: 200px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
margin-top: 15px;
margin-bottom: 15px;
}
.block-type-selection:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
}
.block-type-selection h3 {
margin: 0 0 15px 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.block-type-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.block-type-option {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 15px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-primary);
}
.block-type-option:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.block-type-option span {
display: block;
font-weight: 600;
font-size: 1rem;
margin-bottom: 5px;
}
.block-type-option p {
margin: 0;
font-size: 0.85rem;
opacity: 0.8;
line-height: 1.3;
}
.empty-blocks {
text-align: center;
font-size: 1.4rem;
color: var(--color-highlight);
margin-top: 20px;
margin-bottom: 20px;
}
/* Lazy loading styles */
.lazy-image {
transition: opacity 0.3s ease;
background-color: #f0f0f0;
}
.lazy-image.loaded {
opacity: 1;
}
/* Placeholder styling for lazy images */
.gallery-image-item {
position: relative;
min-height: 200px;
background-color: #f8f9fa;
border-radius: 4px;
overflow: hidden;
}
.gallery-image-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Loading spinner for images */
.photo-loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
z-index: 1;
}
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
/* Hide spinner when image is loaded */
.gallery-image-item img.loaded + .photo-loading-spinner {
display: none;
}

View File

@@ -0,0 +1,282 @@
.share-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.share-overlay-content {
background: white;
border-radius: 8px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.share-overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
color: var(--color-text-white);
background: var(--color-highlight);
}
.share-overlay-header h2 {
margin: 0;
color: var(--color-background-dark);
font-size: 1.5rem;
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 4px;
border-radius: 4px;
}
.btn-close:hover {
background-color: #f5f5f5;
color: #333;
}
.share-overlay-body {
padding: 24px;
color: var(--color-text-white);
background: var(--color-background-dark);
}
.memory-info {
margin-bottom: 24px;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--color-highlight);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
}
.memory-info h3 {
margin: 0 0 8px 0;
}
.memory-date-range {
margin: 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.memory-description {
margin: 8px 0 0 0;
}
.sharing-explanation {
margin-bottom: 24px;
}
.sharing-explanation h4 {
margin: 0 0 12px 0;
}
.sharing-explanation ul {
margin: 0;
padding-left: 20px;
}
.sharing-explanation li {
margin-bottom: 4px;
}
.sharing-options h4 {
margin: 0 0 16px 0;
}
.sharing-option {
margin-bottom: 12px;
}
.sharing-option-content {
display: flex;
align-items: center;
padding: 16px;
text-align: left;
}
.sharing-option-icon {
font-size: 2rem;
margin-right: 16px;
min-width: 48px;
}
.sharing-option-details h5 {
margin: 0 0 4px 0;
font-size: 1.1rem;
}
.sharing-option-details p {
margin: 0;
font-size: 0.9rem;
}
.share-config-summary {
margin-bottom: 24px;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--color-highlight);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
}
.share-config-summary h3 {
margin: 0 0 12px 0;
}
.access-level-display {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.share-config-form .form-group {
margin-bottom: 20px;
}
.share-config-form label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.share-config-form .form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-highlight);
border-radius: 4px;
font-size: 1rem;
}
.share-config-form .form-text {
font-size: 0.85rem;
margin-top: 4px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.share-success {
text-align: center;
}
.success-icon {
font-size: 3rem;
color: #28a745;
margin-bottom: 16px;
}
.share-success h3 {
margin: 0 0 20px 0;
}
.share-details {
margin-bottom: 24px;
text-align: left;
}
.share-detail-item {
margin-bottom: 8px;
}
.share-link-section {
margin-bottom: 24px;
text-align: left;
}
.share-link-section label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.share-link-container {
display: flex;
gap: 8px;
}
.share-url-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--color-highlight);
color: var(--color-text-white);
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
background-color: #f8f9fa;
}
.share-instructions {
text-align: left;
}
.share-instructions h4 {
margin: 0 0 12px 0;
}
.share-instructions ul {
margin: 0;
padding-left: 20px;
}
.share-instructions li {
margin-bottom: 6px;
}
@media (max-width: 768px) {
.share-overlay {
padding: 10px;
}
.share-overlay-content {
max-height: 95vh;
}
.share-overlay-header,
.share-overlay-body {
padding: 16px;
}
.sharing-option-content {
padding: 12px;
}
.sharing-option-icon {
font-size: 1.5rem;
margin-right: 12px;
min-width: 36px;
}
.form-actions {
flex-direction: column;
}
.share-link-container {
flex-direction: column;
}
}

View File

@@ -1,14 +1,17 @@
class PhotoClient {
constructor(map) {
constructor(map, enabled) {
this.map = map;
this.photoMarkers = [];
this.photos = [];
this.enabled = enabled;
}
async updatePhotosForRange(start, end, timezone) {
if (!this.enabled) {
return;
}
try {
const response = await fetch(`/api/v1/photos/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
const response = await fetch(`/api/v1/photos/immich/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
if (!response.ok) {
console.warn('Could not fetch photos for date:', date);
this.photos = [];

View File

@@ -0,0 +1,319 @@
/**
* RawLocationLoader - Handles loading and displaying raw location data on the map
*/
class RawLocationLoader {
constructor(map, userSettings, fitToBoundsConfig = null) {
this.map = map;
this.userSettings = userSettings;
this.rawPointPaths = [];
this.pulsatingMarkers = [];
this.currentZoomLevel = null;
this.userConfigs = [];
this.isFittingBounds = false;
// Configuration for map bounds fitting
this.fitToBoundsConfig = fitToBoundsConfig || {};
// Listen for map events
this.setupMapEventListeners();
}
/**
* Initialize with user configurations
* @param {Array} userConfigs - Array of user configuration objects
* Each config should contain: { url, color, avatarUrl, avatarFallback, displayName }
*/
init(userConfigs) {
this.userConfigs = userConfigs || [];
}
setupMapEventListeners() {
// Listen for zoom end events to reload raw location points
this.map.on('zoomend', () => {
if (this.isFittingBounds) return;
const newZoomLevel = Math.round(this.map.getZoom());
// Only reload if zoom level actually changed
if (this.currentZoomLevel !== null && this.currentZoomLevel !== newZoomLevel) {
console.log('Zoom level changed from', this.currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points');
this.currentZoomLevel = newZoomLevel;
this.reloadForCurrentView();
} else if (this.currentZoomLevel === null) {
this.currentZoomLevel = newZoomLevel;
}
});
// Listen for move end events to reload raw location points
this.map.on('moveend', () => {
if (this.isFittingBounds) return;
this.reloadForCurrentView();
});
}
/**
* Get bounding box parameters for the current map view with buffer
*/
getBoundingBoxParams() {
const bounds = this.map.getBounds();
const southWest = bounds.getSouthWest();
const northEast = bounds.getNorthEast();
// Calculate buffer as 20% of the current viewport size
const latBuffer = (northEast.lat - southWest.lat) * 0.2;
const lngBuffer = (northEast.lng - southWest.lng) * 0.2;
return {
minLat: southWest.lat - latBuffer,
minLng: southWest.lng - lngBuffer,
maxLat: northEast.lat + latBuffer,
maxLng: northEast.lng + lngBuffer
};
}
/**
* Load raw location data for a specific date range
*/
loadForDateRange(autoUpdateMode = false, withBounds = true) {
// Remove pulsating markers when loading new data
this.removePulsatingMarkers();
// Clear existing paths
this.clearPaths();
let bounds = L.latLngBounds();
const fetchPromises = [];
for (let i = 0; i < this.userConfigs.length; i++) {
const config = this.userConfigs[i];
if (config.url) {
// Get current zoom level
const currentZoom = Math.round(this.map.getZoom());
// Get bounding box parameters
const bbox = this.getBoundingBoxParams();
// Build URL with zoom and bounding box parameters
const separator = config.url.includes('?') ? '&' : '?';
let urlWithParams = config.url + separator +
'zoom=' + currentZoom;
if (config.respectBounds && withBounds) {
urlWithParams +=
'&minLat=' + bbox.minLat +
'&minLng=' + bbox.minLng +
'&maxLat=' + bbox.maxLat +
'&maxLng=' + bbox.maxLng;
}
// Create fetch promise for raw location points with index to maintain order
const fetchPromise = fetch(urlWithParams).then(response => {
if (!response.ok) {
console.warn('Could not fetch raw location points');
return { points: [], index: i, config: config };
}
return response.json();
}).then(rawPointsData => {
return { ...rawPointsData, index: i, config: config };
}).catch(error => {
console.warn('Error fetching raw location points:', error);
return { points: [], index: i, config: config };
});
fetchPromises.push(fetchPromise);
}
}
// Wait for all fetch operations to complete, then update map in correct order
Promise.all(fetchPromises).then(results => {
// Sort results by original index to maintain order
results.sort((a, b) => a.index - b.index);
// Process results in order
results.forEach(result => {
const fetchBounds = this.updateMapWithRawPoints(result, result.config.color, autoUpdateMode);
if (fetchBounds.isValid()) {
bounds.extend(fetchBounds);
}
});
// Update map bounds after all fetch operations are complete
if (bounds.isValid()) {
window.originalBounds = bounds;
this.isFittingBounds = true;
this.map.fitBounds(bounds, this.fitToBoundsConfig);
this.isFittingBounds = false;
}
});
}
/**
* Reload raw location points for the current map view
*/
reloadForCurrentView(withBounds = true) {
let bounds = L.latLngBounds();
const fetchPromises = [];
for (let i = 0; i < this.userConfigs.length; i++) {
const config = this.userConfigs[i];
if (config.url) {
// Get current zoom level
const currentZoom = Math.round(this.map.getZoom());
// Get bounding box parameters
const bbox = this.getBoundingBoxParams();
// Build URL with zoom and bounding box parameters
const separator = config.url.includes('?') ? '&' : '?';
const urlWithParams = config.url + separator +
'zoom=' + currentZoom + (config.respectBounds && withBounds ? ('&minLat=' + bbox.minLat +'&minLng=' + bbox.minLng +'&maxLat=' + bbox.maxLat +'&maxLng=' + bbox.maxLng) : '');
// Create fetch promise for raw location points with index to maintain order
const fetchPromise = fetch(urlWithParams).then(response => {
if (!response.ok) {
console.warn('Could not fetch raw location points');
return { points: [], index: i, config: config };
}
return response.json();
}).then(rawPointsData => {
return { ...rawPointsData, index: i, config: config };
}).catch(error => {
console.warn('Error fetching raw location points:', error);
return { points: [], index: i, config: config };
});
fetchPromises.push(fetchPromise);
}
}
// Wait for all fetch operations to complete, then update map in correct order
Promise.all(fetchPromises).then(results => {
this.clearPaths();
results.sort((a, b) => a.index - b.index);
// Process results in order
results.forEach(result => {
const fetchBounds = this.updateMapWithRawPoints(result, result.config.color);
if (fetchBounds.isValid()) {
bounds.extend(fetchBounds);
}
});
});
}
/**
* Update map with raw location points data
*/
updateMapWithRawPoints(rawPointsData, color, autoUpdateMode = false) {
const bounds = L.latLngBounds();
if (rawPointsData && rawPointsData.segments && rawPointsData.segments.length > 0) {
for (const segment of rawPointsData.segments) {
const rawPointsPath = L.geodesic([], {
color: color == null ? '#f1ba63' : color,
weight: 6,
opacity: 0.9,
lineJoin: 'round',
lineCap: 'round',
steps: 2
});
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
bounds.extend(rawPointsCoords)
rawPointsPath.setLatLngs(rawPointsCoords);
rawPointsPath.addTo(this.map);
this.rawPointPaths.push(rawPointsPath)
}
}
// Add avatar marker for the latest point if in auto-update mode and today is selected
if (autoUpdateMode && this.isSelectedDateToday() && rawPointsData.latest) {
const latestPoint = rawPointsData.latest;
const config = rawPointsData.config;
if (config) {
const userData = {
avatarUrl: config.avatarUrl,
avatarFallback: config.avatarFallback,
displayName: config.displayName
};
this.addAvatarMarker(latestPoint.latitude, latestPoint.longitude, userData);
}
}
return bounds;
}
/**
* Clear all raw location paths from the map
*/
clearPaths() {
for (const path of this.rawPointPaths) {
path.remove();
}
this.rawPointPaths.length = 0;
}
/**
* Add an avatar marker at the specified coordinates
*/
addAvatarMarker(lat, lng, userData) {
// Create avatar marker with user's avatar
const avatarHtml = `
<div class="avatar-marker">
<img src="${userData.avatarUrl}"
alt="${userData.avatarFallback}"
class="avatar-marker-img">
</div>
`;
const avatarIcon = L.divIcon({
className: 'avatar-marker-icon',
html: avatarHtml,
iconSize: [40, 40],
iconAnchor: [20, 20]
});
const avatarMarker = L.marker([lat, lng], {icon: avatarIcon}).addTo(this.map);
// Add tooltip
avatarMarker.bindTooltip(`${window.locale.autoupdate.latestLocation} - ${userData.displayName}`, {
permanent: false,
direction: 'top'
});
// Store the marker for cleanup later
this.pulsatingMarkers.push(avatarMarker);
}
/**
* Remove all pulsating markers from the map
*/
removePulsatingMarkers() {
this.pulsatingMarkers.forEach(marker => {
if (marker) {
this.map.removeLayer(marker);
}
});
this.pulsatingMarkers = [];
}
/**
* Check if the selected date is today
*/
isSelectedDateToday() {
const today = new Date().toISOString().split('T')[0];
const selectedDate = this.getSelectedDate();
return selectedDate === today;
}
/**
* Get the currently selected date
*/
getSelectedDate() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('date')) {
return urlParams.get('date');
} else if (window.userSettings.newestData) {
return window.userSettings.newestData.split('T')[0];
} else {
return new Date().toISOString().split('T')[0];
}
}
}

View File

@@ -1,14 +1,15 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<body>
<div th:fragment="settings-nav(activeSection, dataManagementEnabled, isAdmin)" class="settings-nav">
<div class="navbar">
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link active" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<form th:action="@{/logout}" method="post" >
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
class="lni lni-exit"></i>
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
</button>
</form>
</div>

View File

@@ -19,6 +19,7 @@
<script src="/js/horizontal-date-picker.js"></script>
<script src="/js/timeline-scroll-indicator.js"></script>
<script src="/js/photo-client.js"></script>
<script src="/js/raw-location-loader.js"></script>
<script src="/js/htmx.min.js"></script>
<script src="/js/leaflet.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
@@ -45,6 +46,8 @@
<div class="timeline">
<div class="navbar">
<span><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></span>
<a href="/" class="nav-link active" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<a type="button" class="nav-link" id="auto-update-btn" onclick="toggleAutoUpdate()" title="Auto Update"><i class="lni lni-play"></i></a>
@@ -116,13 +119,13 @@
// Store current date range selection
let currentDateRange = null;
// Store current zoom level
let currentZoomLevel = null;
// Initialize the map
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
// Initialize raw location loader
let rawLocationLoader;
function getSelectedDate() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('date')) {
@@ -162,22 +165,6 @@
}
}
function getBoundingBoxParams() {
const bounds = map.getBounds();
const southWest = bounds.getSouthWest();
const northEast = bounds.getNorthEast();
// Calculate buffer as 20% of the current viewport size
const latBuffer = (northEast.lat - southWest.lat) * 0.2;
const lngBuffer = (northEast.lng - southWest.lng) * 0.2;
return {
minLat: southWest.lat - latBuffer,
minLng: southWest.lng - lngBuffer,
maxLat: northEast.lat + latBuffer,
maxLng: northEast.lng + lngBuffer
};
}
function selectUser(userHeader) {
// Remove active class from all user headers
@@ -203,14 +190,9 @@
}
}
const fitToBoundsConfig = {
paddingTopLeft: [100,0],
paddingBottomRight: [100, 300],
zoomSnap: 0.1
};
let pulsatingMarkers = [];
document.addEventListener('DOMContentLoaded', function () {
// Check if date is in URL parameters
const urlParams = new URLSearchParams(window.location.search);
@@ -251,96 +233,22 @@
.addTo(map)
// Initialize photo client
const photoClient = new PhotoClient(map);
const photoClient = new PhotoClient(map, window.userSettings.photoMode === 'ENABLED');
// Initialize raw location loader
rawLocationLoader = new RawLocationLoader(map, window.userSettings, {
paddingTopLeft: [100,0],
paddingBottomRight: [100, 300],
zoomSnap: 0.1
});
// Initialize raw location loader with user configurations
initializeRawLocationLoader();
// Listen for map move/zoom events to update photo markers
map.on('moveend zoomend', () => {
photoClient.onMapMoveEnd();
});
// Listen for zoom end events to reload raw location points
map.on('zoomend', () => {
const newZoomLevel = Math.round(map.getZoom());
// Only reload if zoom level actually changed
if (currentZoomLevel !== null && currentZoomLevel !== newZoomLevel) {
console.log('Zoom level changed from', currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points');
currentZoomLevel = newZoomLevel;
reloadRawLocationPoints();
} else if (currentZoomLevel === null) {
currentZoomLevel = newZoomLevel;
}
});
// Listen for zoom end events to reload raw location points
map.on('moveend', () => {
reloadRawLocationPoints();
});
function reloadRawLocationPoints() {
removePulsatingMarkers();
// Reload raw location points with new zoom level
const timelineContainer = document.querySelectorAll('.user-timeline-section');
let bounds = L.latLngBounds();
const fetchPromises = [];
for (let i = 0; i < timelineContainer.length; i++) {
const element = timelineContainer[i];
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
const color = element?.dataset.baseColor;
if (rawLocationPointsUrl) {
// Get current zoom level
const currentZoom = Math.round(map.getZoom());
// Get bounding box parameters
const bbox = getBoundingBoxParams();
// Build URL with zoom and bounding box parameters
const separator = rawLocationPointsUrl.includes('?') ? '&' : '?';
const urlWithParams = rawLocationPointsUrl + separator +
'zoom=' + currentZoom +
'&minLat=' + bbox.minLat +
'&minLng=' + bbox.minLng +
'&maxLat=' + bbox.maxLat +
'&maxLng=' + bbox.maxLng;
// Create fetch promise for raw location points with index to maintain order
const fetchPromise = fetch(urlWithParams).then(response => {
if (!response.ok) {
console.warn('Could not fetch raw location points');
return { points: [], index: i, color: color };
}
return response.json();
}).then(rawPointsData => {
return { ...rawPointsData, index: i, color: color };
}).catch(error => {
console.warn('Error fetching raw location points:', error);
return { points: [], index: i, color: color };
});
fetchPromises.push(fetchPromise);
}
}
// Wait for all fetch operations to complete, then update map in correct order
Promise.all(fetchPromises).then(results => {
for (const path of rawPointPaths) {
path.remove();
}
rawPointPaths.length = 0;
results.sort((a, b) => a.index - b.index);
// Process results in order
results.forEach(result => {
const fetchBounds = updateMapWithRawPoints(result, result.color);
if (fetchBounds.isValid()) {
bounds.extend(fetchBounds);
}
});
});
}
function loadTimelineData(startDate, endDate) {
if (startDate && endDate && startDate !== endDate) {
@@ -351,101 +259,8 @@
photoClient.updatePhotosForRange(date, date, getUserTimezone());
}
removePulsatingMarkers();
const timelineContainer = document.querySelectorAll('.user-timeline-section');
for (const path of rawPointPaths) {
path.remove();
}
let bounds = L.latLngBounds();
const fetchPromises = [];
for (let i = 0; i < timelineContainer.length; i++) {
const element = timelineContainer[i];
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
const color = element?.dataset.baseColor;
if (rawLocationPointsUrl) {
const currentZoom = Math.round(map.getZoom());
const separator = rawLocationPointsUrl.includes('?') ? '&' : '?';
const urlWithParams = rawLocationPointsUrl + separator +
'zoom=' + currentZoom;
const fetchPromise = fetch(urlWithParams).then(response => {
if (!response.ok) {
console.warn('Could not fetch raw location points');
return { points: [], index: i, color: color };
}
return response.json();
}).then(rawPointsData => {
return { ...rawPointsData, index: i, color: color };
}).catch(error => {
console.warn('Error fetching raw location points:', error);
return { points: [], index: i, color: color };
});
fetchPromises.push(fetchPromise);
}
}
Promise.all(fetchPromises).then(results => {
results.sort((a, b) => a.index - b.index);
results.forEach(result => {
const fetchBounds = updateMapWithRawPoints(result, result.color);
if (fetchBounds.isValid()) {
bounds.extend(fetchBounds);
}
});
if (bounds.isValid()) {
window.originalBounds = bounds;
map.fitBounds(bounds, fitToBoundsConfig);
}
});
}
// Function to update map with raw location points
function updateMapWithRawPoints(rawPointsData, color) {
const bounds = L.latLngBounds();
if (rawPointsData && rawPointsData.segments && rawPointsData.segments.length > 0) {
for (const segment of rawPointsData.segments) {
const rawPointsPath = L.geodesic([], {
color: color == null ? '#f1ba63' : color,
weight: 6,
opacity: 0.9,
lineJoin: 'round',
lineCap: 'round',
steps: 2
});
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
bounds.extend(rawPointsCoords)
rawPointsPath.setLatLngs(rawPointsCoords);
rawPointsPath.addTo(map);
rawPointPaths.push(rawPointsPath)
}
}
// Add avatar marker for the latest point if in auto-update mode and today is selected
if (autoUpdateMode && isSelectedDateToday() && rawPointsData.latest) {
const latestPoint = rawPointsData.latest;
// Find the corresponding timeline section to get user data
const timelineContainers = document.querySelectorAll('.user-timeline-section');
const timelineSection = timelineContainers[rawPointsData.index];
if (timelineSection) {
const userData = {
avatarUrl: timelineSection.dataset.userAvatarUrl,
avatarFallback: timelineSection.dataset.avatarFallback,
displayName: timelineSection.dataset.displayName
};
addAvatarMarker(latestPoint.latitude, latestPoint.longitude, userData);
}
}
return bounds;
// Load raw location data using the loader
rawLocationLoader.loadForDateRange(autoUpdateMode, false);
}
const selectedPath = L.geodesic([], {
@@ -458,11 +273,33 @@
});
const rawPointPaths = [];
// Function to initialize raw location loader with user configurations
function initializeRawLocationLoader() {
const userConfigs = [];
const timelineContainers = document.querySelectorAll('.user-timeline-section');
timelineContainers.forEach(container => {
const config = {
respectBounds: true,
url: container.dataset.rawLocationPointsUrl,
color: container.dataset.baseColor,
avatarUrl: container.dataset.userAvatarUrl,
avatarFallback: container.dataset.avatarFallback,
displayName: container.dataset.displayName
};
userConfigs.push(config);
});
rawLocationLoader.init(userConfigs);
}
// Add HTMX event handlers for timeline updates
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.classList.contains('timeline-container')) {
// Re-initialize raw location loader with updated user configurations
initializeRawLocationLoader();
// Timeline content has been updated, update map markers
const params = getTimelineParams();
loadTimelineData(params.startDate, params.endDate);
@@ -618,7 +455,7 @@
if (isCurrentlyActive) {
// Deselection: zoom back to original bounds showing all data
if (window.originalBounds && window.originalBounds.isValid()) {
map.flyToBounds(window.originalBounds, fitToBoundsConfig);
map.flyToBounds(window.originalBounds, rawLocationLoader.fitToBoundsConfig);
}
} else {
// Selection: zoom to specific entry
@@ -643,13 +480,11 @@
}
if (newBounds.isValid()) {
map.flyToBounds(newBounds, fitToBoundsConfig);
map.flyToBounds(newBounds, rawLocationLoader.fitToBoundsConfig);
}
}
});
// Load initial timeline data via HTMX (will be triggered by the hx-trigger="load")
// Also load photos and raw points for the initial date
loadTimelineData(formattedDate, formattedDate);
// Parse the initial date properly to ensure correct date picker initialization
@@ -777,8 +612,7 @@
eventSource = null;
}
// Remove pulsating markers when auto-update is disabled
removePulsatingMarkers();
rawLocationLoader.removePulsatingMarkers();
// Hide auto-update overlay
hideAutoUpdateOverlay();
@@ -823,41 +657,6 @@
return selectedDate === today;
}
function addAvatarMarker(lat, lng, userData) {
// Create avatar marker with user's avatar
const avatarHtml = `
<div class="avatar-marker">
<img src="${userData.avatarUrl}"
alt="${userData.avatarFallback}"
class="avatar-marker-img">
</div>
`;
const avatarIcon = L.divIcon({
className: 'avatar-marker-icon',
html: avatarHtml,
iconSize: [40, 40],
iconAnchor: [20, 20]
});
const avatarMarker = L.marker([lat, lng], {icon: avatarIcon}).addTo(map);
// Add tooltip
avatarMarker.bindTooltip(`${window.locale.autoupdate.latestLocation} - ${userData.displayName}`, {
permanent: false,
direction: 'top'
});
// Store the marker for cleanup later
pulsatingMarkers.push(avatarMarker);
}
function removePulsatingMarkers() {
pulsatingMarkers.forEach(marker => {
map.removeLayer(marker);
});
pulsatingMarkers = [];
}
function scheduleTimelineReload(eventData) {
// Add event to pending events
@@ -986,7 +785,7 @@
function hideUIElements() {
const timeline = document.querySelector('.timeline');
const datePicker = document.getElementById('horizontal-date-picker-container');
timeline.classList.add('hidden');
datePicker.classList.add('hidden');
}

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Block - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/memories.css">
<link rel="stylesheet" href="/css/lineicons.css">
</head>
<body class="memories-page">
<div class="settings-container">
<nav class="settings-nav">
<div class="settings-header">
<a th:href="@{/memories/{id}(id=${memoryId})}" class="back-link">
<i class="lni lni-arrow-left"></i>
<span>Back to Memory</span>
</a>
<h1>Edit Block</h1>
</div>
</nav>
<div class="settings-content-area">
<div th:fragment="edit-text-block" th:if="${block.blockType.name() == 'TEXT'}" class="memory-form">
<h2>Edit Text Block</h2>
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/text(memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label for="headline">Headline</label>
<input type="text" id="headline" name="headline" class="form-control"
th:value="${textBlock?.headline}" placeholder="Enter headline">
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea id="content" name="content" class="form-control" rows="10"
placeholder="Enter content" th:text="${textBlock?.content}"></textarea>
</div>
<div class="form-actions">
<button type="button"
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
hx-target="closest .memory-block"
hx-swap="innerHTML"
class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<div th:fragment="edit-cluster-trip-block" th:if="${block.blockType.name() == 'CLUSTER_TRIP'}" class="memory-form">
<h2 th:text="#{memory.edit.block.cluster.trip.title}">Edit Trips Block</h2>
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/cluster(type='CLUSTER_TRIP', memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
<input type="text" id="title" name="title" class="form-control"
th:value="${clusterTripBlock?.title}" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
</div>
<div class="form-group">
<label th:text="#{memory.edit.block.cluster.trip.select.trips}">Select Trips</label>
<table class="table">
<thead>
<tr>
<th th:text="#{memory.edit.block.cluster.trip.selected}">Selected</th>
<th th:text="#{memory.edit.block.cluster.trip.trip}">Trip</th>
</tr>
</thead>
<tbody>
<tr th:each="trip : ${availableTrips}">
<td>
<input type="checkbox"
name="selectedParts"
th:value="${trip.id}"
th:checked="${clusterTripBlock.trips != null and clusterTripBlock.trips.contains(trip)}">
</td>
<td th:text="${#temporals.format(trip.startTime, 'MMM d, yyyy HH:mm')} + ' (' + ${trip.startVisit != null and trip.startVisit.place != null ? trip.startVisit.place.name : 'Unknown'} + ') -> ' + ${#temporals.format(trip.endTime, 'HH:mm')} + ' (' + ${trip.endVisit != null and trip.endVisit.place != null ? trip.endVisit.place.name : 'Unknown'} + ')'">Trip details</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button"
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
hx-target="closest .memory-block"
hx-swap="innerHTML"
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
</div>
</form>
</div>
<div th:fragment="edit-cluster-visit-block" th:if="${block.blockType.name() == 'CLUSTER_VISIT'}" class="memory-form">
<h2 th:text="#{memory.edit.block.cluster.visit.title}">Edit Visits Block</h2>
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/cluster(type='CLUSTER_VISIT', memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label th:text="#{memory.edit.block.title}" for="visit_title">Title</label>
<input type="text" id="visit_title" name="title" class="form-control"
th:value="${clusterVisitBlock?.title}" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
</div>
<div class="form-group">
<label th:text="#{memory.edit.block.cluster.visit.select.visits}">Select Trips</label>
<table class="table">
<thead>
<tr>
<th th:text="#{memory.edit.block.cluster.visit.selected}">Selected</th>
<th th:text="#{memory.edit.block.cluster.visit.visit}">Visit</th>
</tr>
</thead>
<tbody>
<tr th:each="visit : ${availableVisits}">
<td>
<input type="checkbox"
name="selectedParts"
th:value="${visit.id}"
th:checked="${clusterVisitBlock.visits != null and clusterVisitBlock.visits.contains(visit)}">
</td>
<td th:text="${visit.place.name} + ' (' + ${#temporals.format(visit.startTime, 'MMM d, yyyy HH:mm')} + ' -> ' + ${#temporals.format(visit.endTime, 'HH:mm')} + ')'">Visit details</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button"
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
hx-target="closest .memory-block"
hx-swap="innerHTML"
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
</div>
</form>
</div>
<div th:fragment="edit-image-gallery-block" th:if="${block.blockType.name() == 'IMAGE_GALLERY'}" class="memory-form">
<h2 th:text="#{memory.block.gallery.edit}">New Image Gallery Block</h2>
<form th:attr="hx-post=@{/memories/{id}/blocks/{blockId}/image-gallery(id=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="gallery-panels">
<!-- Immich Panel -->
<div class="gallery-panel immich-panel" th:if="${immichEnabled && isOwner}">
<h4 th:text="#{memory.block.gallery.immich.title}">Select from Immich</h4>
<div class="immich-photos-container">
<div class="immich-photos-grid"
id="immichPhotosGrid"
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=0, id=${memoryId})}"
hx-target=".immich-photos-container"
hx-trigger="load"
hx-swap="innerHTML">
<div class="loading" th:text="#{memory.block.gallery.loading}">Loading photos...</div>
</div>
<div class="immich-pagination" id="immichPagination"></div>
</div>
</div>
<!-- File Upload Panel -->
<div class="gallery-panel upload-panel">
<h4 th:text="#{memory.block.gallery.upload.title}">Upload Images</h4>
<div class="upload-area">
<input type="file"
id="fileInput"
name="files"
multiple
accept="image/*"
class="form-control"
th:attr="hx-post=@{/memories/{id}/blocks/upload-image(id=${memoryId})}"
hx-encoding="multipart/form-data"
hx-target="#selectedPhotos"
hx-swap="beforeend">
<label for="fileInput" class="upload-label">
<i class="lni lni-cloud-upload"></i>
<span th:text="#{memory.block.gallery.upload.choose}">Choose files or drag here</span>
</label>
</div>
</div>
</div>
<!-- Combined Selected Photos Section -->
<div class="selected-photos-section">
<h4 th:text="#{memory.block.gallery.selected.title}">Selected Photos</h4>
<div class="selected-photos-combined" id="selectedPhotos">
<div th:each="image : ${imageBlock.images}" class="selected-photo-item">
<img th:src="${image.imageUrl}" alt="Uploaded image">
<input type="hidden" name="uploadedUrls" th:value="${image.imageUrl}">
<button type="button" class="btn-remove"
hx-get="/memories/fragments/empty"
hx-target="closest .selected-photo-item"
hx-swap="delete"
th:title="#{memory.block.gallery.remove}">
<i class="lni lni-trash-3"></i>
</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button"
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
hx-target="closest .memory-block"
hx-swap="innerHTML"
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,121 @@
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memories - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
<script src="/js/htmx.min.js"></script>
</head>
<body class="memories-page">
<div class="settings-container">
<div class="settings-nav">
<div class="navbar">
<a th:href="@{/}" href="../index.html"><img class="logo" th:src="@{/img/logo.svg}"
src="../../static/img/logo.svg" alt="reitti logo"
title="reitti"></a>
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<form th:action="@{/logout}" method="post">
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
class="lni lni-exit"></i>
</button>
</form>
</div>
</div>
<div class="settings-content-area">
<div id="edit-fragment" class="memory-header" th:fragment="edit-memory">
<div th:if="${error}" class="alert alert-error">
<i class="lni lni-warning"></i>
<span th:text="#{${error}}">Error message</span>
</div>
<form th:attr="hx-post=@{/memories/{id}(id=${memory.id})}" hx-vals="js:{timezone: getUserTimezone()}" method="post" class="memory-form" hx-swap="outerHTML" hx-target=".memory-header">
<input type="hidden" name="version" th:value="${memory.version}">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title" required class="form-control"
th:value="${memory.title}" placeholder="Give your memory a title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-control" rows="4"
placeholder="Add a description (optional)" th:text="${memory.description}"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="startDate">Start Date *</label>
<input type="date" id="startDate" name="startDate" required class="form-control"
th:value="${startDate}">
</div>
<div class="form-group">
<label for="endDate">End Date *</label>
<input type="date" id="endDate" name="endDate" required class="form-control"
th:value="${endDate}">
</div>
</div>
<div class="form-group">
<label>Header Type *</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="headerType" value="MAP"
th:checked="${memory.headerType.name() == 'MAP'}">
<span><i class="lni lni-map"></i> Map</span>
</label>
<label class="radio-label">
<input type="radio" name="headerType" value="IMAGE"
th:checked="${memory.headerType.name() == 'IMAGE'}">
<span><i class="lni lni-image"></i> Image</span>
</label>
</div>
</div>
<div class="form-group" id="imageUrlGroup"
th:style="${memory.headerType.name() == 'IMAGE'} ? 'display: block;' : 'display: none;'">
<label for="headerImageUrl">Header Image URL</label>
<input type="url" id="headerImageUrl" name="headerImageUrl" class="form-control"
th:value="${memory.headerImageUrl}" placeholder="https://example.com/image.jpg">
</div>
<div class="form-actions">
<a class="btn btn-secondary"
th:href="@{/memories/{id}(id=${memory.id})}">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<script>
// User settings for timezone handling
window.userSettings = /*[[${userSettings}]]*/ {};
function getUserTimezone() {
if (window.userSettings.timeZoneOverride) {
return window.userSettings.timeZoneOverride;
} else {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
// Show/hide image URL field based on header type selection
document.querySelectorAll('input[name="headerType"]').forEach(radio => {
radio.addEventListener('change', function() {
const imageUrlGroup = document.getElementById('imageUrlGroup');
if (this.value === 'IMAGE') {
imageUrlGroup.style.display = 'block';
} else {
imageUrlGroup.style.display = 'none';
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,614 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
</head>
<body>
<!-- Block type selection fragment -->
<div th:fragment="block-type-selection" class="block-type-selection">
<h3 th:text="#{memory.block.select.type}">Select Block Type</h3>
<div class="block-type-grid">
<button type="button"
class="block-type-option"
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='TEXT', position=${position})}"
hx-target=".block-type-selection"
hx-swap="outerHTML">
<span th:text="#{memory.block.type.text}">Text Block</span>
<p th:text="#{memory.block.type.text.description}">Add text content with headlines and paragraphs</p>
</button>
<button type="button"
class="block-type-option"
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='VISIT_CLUSTER', position=${position})}"
hx-target=".block-type-selection"
hx-swap="outerHTML">
<span th:text="#{memory.block.type.visit}">Visit Block</span>
<p th:text="#{memory.block.type.visit.description}">Add a location you visited during this memory</p>
</button>
<button type="button"
class="block-type-option"
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='TRIP_CLUSTER', position=${position})}"
hx-target=".block-type-selection"
hx-swap="outerHTML">
<span th:text="#{memory.block.type.trip}">Trip Block</span>
<p th:text="#{memory.block.type.trip.description}">Add a journey or route from this memory</p>
</button>
<button type="button"
class="block-type-option"
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='IMAGE_GALLERY', position=${position})}"
hx-target=".block-type-selection"
hx-swap="outerHTML">
<span th:text="#{memory.block.type.gallery}">Image Gallery</span>
<p th:text="#{memory.block.type.gallery.description}">Add a collection of photos from this memory</p>
</button>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
hx-get="/memories/fragments/empty"
hx-target=".block-type-selection"
hx-swap="delete"
th:text="#{memory.block.cancel}">Cancel</button>
</div>
</div>
<!-- Empty fragment for clearing content -->
<div th:fragment="empty"></div>
<!-- Text block form -->
<div th:fragment="text-block-form" class="block-form">
<h3 th:text="#{memory.block.text.new}">Add Text Block</h3>
<form th:attr="hx-post=@{/memories/{id}/blocks/text(id=${memoryId}, position=${position})}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label for="headline" th:text="#{memory.block.text.headline}">Headline</label>
<input type="text" id="headline" name="headline" class="form-control" th:placeholder="#{memory.block.text.headline.placeholder}">
</div>
<div class="form-group">
<label for="content" th:text="#{memory.block.text.content}">Content</label>
<textarea id="content" name="content" class="form-control" rows="6" th:placeholder="#{memory.block.text.content.placeholder}"></textarea>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
hx-get="/memories/fragments/empty"
hx-target=".block-form"
hx-swap="delete"
th:text="#{memory.block.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
</div>
</form>
</div>
<!-- Visit block form -->
<div th:fragment="visit-block-form" class="block-form">
<h2 th:text="#{memory.block.visit.new}">Add Visit Block</h2>
<form th:attr="hx-post=@{/memories/{id}/blocks/cluster(id=${memoryId}, position=${position}, type='CLUSTER_VISIT')}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
<input type="text" id="title" name="title" class="form-control" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
</div>
<div class="form-group">
<label th:text="#{memory.edit.block.cluster.visit.select.visits}">Select Visits</label>
<table class="table">
<thead>
<tr>
<th th:text="#{memory.edit.block.cluster.visit.selected}">Selected</th>
<th th:text="#{memory.edit.block.cluster.visit.visit}">Trip</th>
</tr>
</thead>
<tbody>
<tr th:each="visit : ${availableVisits}">
<td>
<input type="checkbox"
name="selectedParts"
th:value="${visit.id}">
</td>
<td th:text="${visit.place.name} + ' (' + ${#temporals.format(visit.startTime, 'MMM d, yyyy HH:mm')} + ' -> ' + ${#temporals.format(visit.endTime, 'HH:mm')} + ')'">Visit details</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
hx-get="/memories/fragments/empty"
hx-target=".block-form"
hx-swap="delete"
th:text="#{memory.block.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
</div>
</form>
</div>
<!-- Trip block form -->
<div th:fragment="trip-block-form" class="block-form">
<h2 th:text="#{memory.block.trip.new}">Edit Trips Block</h2>
<form th:attr="hx-post=@{/memories/{id}/blocks/cluster(id=${memoryId}, position=${position}, type='CLUSTER_TRIP')}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="form-group">
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
<input type="text" id="title" name="title" class="form-control" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
</div>
<div class="form-group">
<label th:text="#{memory.edit.block.cluster.trip.select.trips}">Select Trips</label>
<table class="table">
<thead>
<tr>
<th th:text="#{memory.edit.block.cluster.trip.selected}">Selected</th>
<th th:text="#{memory.edit.block.cluster.trip.trip}">Trip</th>
</tr>
</thead>
<tbody>
<tr th:each="trip : ${availableTrips}">
<td>
<input type="checkbox"
name="selectedParts"
th:value="${trip.id}">
</td>
<td th:text="${#temporals.format(trip.startTime, 'MMM d, yyyy HH:mm')} + ' (' + ${trip.startVisit != null and trip.startVisit.place != null ? trip.startVisit.place.name : 'Unknown'} + ') -> ' + ${#temporals.format(trip.endTime, 'HH:mm')} + ' (' + ${trip.endVisit != null and trip.endVisit.place != null ? trip.endVisit.place.name : 'Unknown'} + ')'">Trip details</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
hx-get="/memories/fragments/empty"
hx-target=".block-form"
hx-swap="delete"
th:text="#{memory.block.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
</div>
</form>
</div>
<!-- Image gallery block form -->
<div th:fragment="image-gallery-block-form" class="block-form gallery-block-form">
<h3 th:text="#{memory.block.gallery.new}">New Image Gallery Block</h3>
<form th:attr="hx-post=@{/memories/{id}/blocks/image-gallery(id=${memoryId}, position=${position})}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
<div class="gallery-panels">
<!-- Immich Panel -->
<div class="gallery-panel immich-panel" th:if="${immichEnabled && isOwner}">
<h4 th:text="#{memory.block.gallery.immich.title}">Select from Immich</h4>
<div class="immich-photos-container">
<div class="immich-photos-grid"
id="immichPhotosGrid"
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=0, id=${memoryId})}"
hx-target=".immich-photos-container"
hx-trigger="load"
hx-swap="innerHTML">
<div class="loading" th:text="#{memory.block.gallery.loading}">Loading photos...</div>
</div>
<div class="immich-pagination" id="immichPagination"></div>
</div>
</div>
<!-- File Upload Panel -->
<div class="gallery-panel upload-panel">
<h4 th:text="#{memory.block.gallery.upload.title}">Upload Images</h4>
<div class="upload-area">
<input type="file"
id="fileInput"
name="files"
multiple
accept="image/*"
class="form-control"
th:attr="hx-post=@{/memories/{id}/blocks/upload-image(id=${memoryId})}"
hx-encoding="multipart/form-data"
hx-target="#selectedPhotos"
hx-swap="beforeend">
<label for="fileInput" class="upload-label">
<i class="lni lni-cloud-upload"></i>
<span th:text="#{memory.block.gallery.upload.choose}">Choose files or drag here</span>
</label>
</div>
</div>
</div>
<!-- Combined Selected Photos Section -->
<div class="selected-photos-section">
<h4 th:text="#{memory.block.gallery.selected.title}">Selected Photos</h4>
<div class="selected-photos-combined" id="selectedPhotos"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Create Gallery Block</button>
<button type="button"
class="btn btn-secondary"
th:attr="hx-get=@{/memories/fragments/empty}"
hx-target=".block-form"
hx-swap="delete"
th:text="#{memory.block.cancel}">Cancel</button>
</div>
</form>
</div>
<!-- Uploaded photos fragment -->
<th:block th:fragment="uploaded-photos">
<div th:each="url : ${urls}" class="selected-photo-item">
<img th:src="${url}" alt="Uploaded image">
<input type="hidden" name="uploadedUrls" th:value="${url}">
<button type="button" class="btn-remove"
hx-get="/memories/fragments/empty"
hx-target="closest .selected-photo-item"
hx-swap="delete"
th:title="#{memory.block.gallery.remove}">
<i class="lni lni-trash-3"></i>
</button>
</div>
</th:block>
<!-- Immich photos grid fragment -->
<div class="immich-photos-grid" th:fragment="immich-photos-grid">
<div th:if="${photos.isEmpty()}" class="no-photos" th:text="#{memory.block.gallery.immich.no.photos}">
No photos found for this date range
</div>
<div th:each="photo : ${photos}"
class="immich-photo-item"
th:attr="hx-post=@{/memories/{id}/blocks/fetch-immich-photo(id=${memoryId}, assetId=${photo.id})}"
hx-target="#selectedPhotos"
hx-swap="beforeend">
<img th:src="${photo.thumbnailUrl}" th:alt="${photo.fileName}">
<div class="photo-overlay">
<i class="lni lni-plus"></i>
</div>
</div>
<div th:if="${totalPages > 1}" class="pagination-controls">
<button th:if="${currentPage > 0}"
type="button"
class="btn btn-secondary"
th:text="#{memory.block.gallery.pagination.previous}"
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=${currentPage - 1}, id=${memoryId})}"
hx-target=".immich-photos-container"
hx-swap="innerHTML">Previous</button>
<span class="page-info" th:text="${currentPage + 1} + ' / ' + ${totalPages}"></span>
<button th:if="${currentPage < totalPages - 1}"
type="button"
class="btn btn-secondary"
th:text="#{memory.block.gallery.pagination.next}"
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=${currentPage + 1}, id=${memoryId})}"
hx-target=".immich-photos-container"
hx-swap="innerHTML">Next</button>
</div>
</div>
<div class="years-navigation" th:fragment="years-navigation">
<div class="timeline-entry trip active" id="overall-entry" data-year="overall"
hx-get="/memories/all"
hx-target=".memories-overview"
hx-trigger="load, click[shouldTrigger(this)]"
hx-swap="outerHTML">
<div class="entry-description" th:text="#{memory.list.all}">All</div>
</div>
<div class="timeline-entry trip" th:each="year : ${years}"
th:attr="hx-get='/memories/year/'+ ${year}, data-year=${year}"
hx-target=".memories-overview"
hx-trigger="click[shouldTrigger(this)]"
hx-swap="outerHTML">
<div class="entry-description" th:text="${year}">2024</div>
</div>
<script>
new function () {
document.querySelector('.years-navigation').addEventListener('click', function (event) {
const entry = event.target.closest('.timeline-entry');
if (!entry) return;
const isCurrentlyActive = entry.classList.contains('active');
if (isCurrentlyActive) {
return;
}
document.querySelectorAll('.timeline-container .timeline-entry')
.forEach(e => e.classList.remove('active'));
entry.classList.add('active');
});
}()
</script>
</div>
<div class="memories-overview settings-content-area" th:fragment="memories-list">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Memories</h2>
<button th:attr="hx-get=@{/memories/new(year=${year})}" hx-target=".settings-content-area" class="btn btn-primary">Create Memory</button>
</div>
<div th:if="${memories.isEmpty()}" class="empty-state">
<i class="lni lni-image"></i>
<h2>No memories yet</h2>
<p>Create your first memory by selecting a date range on the timeline</p>
</div>
<div class="memories-grid" th:unless="${memories.isEmpty()}">
<div th:each="memory : ${memories}" class="memory-card">
<div class="memory-card-map-container" th:attr="data-raw-location-url=${memory.rawLocationUrl}">
</div>
<div class="memory-card-details">
<div class="memory-card-info">
<h3 th:text="${memory.memory.title}">Memory Title</h3>
<div>
<span th:text="${#temporals.format(memory.memory.startDate, 'MMM d, yyyy')}">Start Date</span>
<span th:if="${memory.memory.startDate != memory.memory.endDate}">
- <span th:text="${#temporals.format(memory.memory.endDate, 'MMM d, yyyy')}">End Date</span>
</span>
</div>
<div th:if="${memory.memory.description != null && !memory.memory.description.isEmpty()}"
th:text="${memory.memory.description}">Description</div>
</div>
<div>
<a th:href="@{/memories/{id}(id=${memory.memory.id})}"
class="btn btn-block memory-link"
th:attr="data-memory-id=${memory.memory.id}"
onclick="navigateToMemoryWithTimezone(event, this.dataset.memoryId)">View Memory</a>
</div>
</div>
</div>
</div>
<script>
function navigateToMemoryWithTimezone(event, memoryId) {
event.preventDefault();
const timezone = getUserTimezone();
window.location.href = `/memories/${memoryId}?timezone=${timezone}`;
}
(function () {
const mapContainers = document.querySelectorAll('.memory-card-map-container');
for (const mapContainer of mapContainers) {
const memoryMap = L.map(mapContainer, {
zoomControl: false,
attributionControl: false,
boxZoom: false,
doubleClickZoom: false,
dragging: false,
keyboard: false,
scrollWheelZoom: false,
tap: false,
touchZoom: false
}).setView([51.505, -0.09], 13);
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(memoryMap);
const rawLocationLoader = new RawLocationLoader(memoryMap, window.userSettings);
const userConfigs = [{
url: mapContainer.attributes['data-raw-location-url'].value,
color: '#f1ba63',
avatarUrl: null,
avatarFallback: null,
displayName: 'Memory Location Data'
}];
rawLocationLoader.init(userConfigs);
// Load location data for the memory date range without bounds filtering
rawLocationLoader.loadForDateRange(
null,
null,
false, // autoUpdateMode
false // withBounds - don't filter by current map bounds
);
}
})()
</script>
</div>
<div th:fragment="share-overlay" class="share-overlay">
<div class="share-overlay-content">
<div class="share-overlay-header">
<h2 th:text="#{memory.share.title}">Share Memory</h2>
<button type="button"
class="btn-close"
hx-get="/memories/fragments/empty"
hx-target="#share-overlay-container"
hx-swap="innerHTML">×</button>
</div>
<div class="share-overlay-body">
<div class="memory-info">
<h3 th:text="${memory.title}">Memory Title</h3>
<p class="memory-date-range">
<i class="lni lni-calendar"></i>
<span th:text="${#temporals.format(memory.startDate, 'MMMM d, yyyy')}">Start Date</span>
<span th:if="${memory.startDate != memory.endDate}">
- <span th:text="${#temporals.format(memory.endDate, 'MMMM d, yyyy')}">End Date</span>
</span>
</p>
<p th:if="${memory.description != null && !memory.description.isEmpty()}"
class="memory-description"
th:text="${memory.description}">Description</p>
</div>
<div class="sharing-explanation">
<h4 th:text="#{memory.share.what.title}">What will be shared?</h4>
<ul>
<li th:text="#{memory.share.what.content}">The complete memory with all its content blocks</li>
<li th:text="#{memory.share.what.location}">Location data and maps for the memory period</li>
<li th:text="#{memory.share.what.photos}">Photos and text content within the memory</li>
<li th:text="#{memory.share.what.trips}">Trip and visit information during this time period</li>
</ul>
</div>
<div class="sharing-options">
<h4 th:text="#{memory.share.permissions.title}">Choose sharing permissions:</h4>
<div class="sharing-option">
<button type="button"
class="btn btn-block sharing-option-btn"
th:attr="hx-get=@{/memories/{id}/share/form(id=${memory.id}, accessLevel='MEMORY_VIEW_ONLY')}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<div class="sharing-option-content">
<div class="sharing-option-icon">
<i class="lni lni-eye"></i>
</div>
<div class="sharing-option-details">
<h5 th:text="#{memory.share.view.title}">View Only</h5>
<p th:text="#{memory.share.view.description}">Recipients can view the memory but cannot make any changes</p>
</div>
</div>
</button>
</div>
<div class="sharing-option">
<button type="button"
class="btn btn-block sharing-option-btn"
th:attr="hx-get=@{/memories/{id}/share/form(id=${memory.id}, accessLevel='MEMORY_EDIT_ACCESS')}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<div class="sharing-option-content">
<div class="sharing-option-icon">
<i class="lni lni-pencil-1"></i>
</div>
<div class="sharing-option-details">
<h5 th:text="#{memory.share.edit.title}">Edit Access</h5>
<p th:text="#{memory.share.edit.description}">Recipients can view and edit the memory, add blocks, and modify content</p>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
<div th:fragment="share-form" class="share-overlay">
<div class="share-overlay-content">
<div class="share-overlay-header">
<h2 th:text="#{memory.share.configure.title}">Configure Share Link</h2>
<button type="button"
class="btn-close"
hx-get="/memories/fragments/empty"
hx-target="#share-overlay-container"
hx-swap="innerHTML">×</button>
</div>
<div class="share-overlay-body">
<div class="share-config-summary">
<h3 th:text="#{memory.share.configure.sharing} + ': ' + ${memory.title}">Sharing: Memory Title</h3>
<div class="access-level-display">
<i th:class="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? 'lni lni-eye' : 'lni lni-pencil'}"></i>
<span th:text="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? #{memory.share.access.view} : #{memory.share.access.edit}}">Access Level</span>
</div>
</div>
<form th:attr="hx-post=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML"
class="share-config-form">
<input type="hidden" name="accessLevel" th:value="${accessLevel}">
<div class="form-group">
<label for="validDays" th:text="#{memory.share.expires.label}">Link expires after:</label>
<select name="validDays" id="validDays" class="form-control">
<option value="7" th:text="#{memory.share.expires.7days}">7 days</option>
<option value="30" selected th:text="#{memory.share.expires.30days}">30 days</option>
<option value="90" th:text="#{memory.share.expires.90days}">90 days</option>
<option value="0" th:text="#{memory.share.expires.never}">Never expires</option>
</select>
<small class="form-text" th:text="#{memory.share.expires.help}">Choose how long the share link should remain valid</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="lni lni-link"></i> <span th:text="#{memory.share.create.button}">Create Share Link</span>
</button>
<button type="button"
class="btn"
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-arrow-left"></i> <span th:text="#{memory.share.back.button}">Back</span>
</button>
</div>
</form>
</div>
</div>
</div>
<div th:fragment="share-result" class="share-overlay">
<div class="share-overlay-content">
<div class="share-overlay-header">
<h2 th:text="#{memory.share.result.title}">Share Link Created</h2>
<button type="button"
class="btn-close"
hx-get="/memories/fragments/empty"
hx-target="#share-overlay-container"
hx-swap="innerHTML">×</button>
</div>
<div class="share-overlay-body">
<div class="share-success">
<div class="success-icon">
<i class="lni lni-check-circle-1"></i>
</div>
<h3 th:text="#{memory.share.result.success}">Share Link Created Successfully!</h3>
<div class="share-details">
<div class="share-detail-item">
<strong th:text="#{memory.share.result.memory}">Memory:</strong> <span th:text="${memory.title}">Memory Title</span>
</div>
<div class="share-detail-item">
<strong th:text="#{memory.share.result.access}">Access Level:</strong>
<span th:text="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? #{memory.share.access.view} : #{memory.share.access.edit}}">Access Level</span>
</div>
</div>
<div class="share-link-section">
<label for="shareUrl" th:text="#{memory.share.result.link.label}">Share this link:</label>
<div class="share-link-container">
<input type="text"
id="shareUrl"
class="form-control share-url-input"
th:value="${shareUrl}"
readonly
onclick="this.select()">
<button type="button"
class="btn btn-copy"
th:attr="data-copied-text=#{memory.share.result.copied}, data-copy-text=#{memory.share.result.copy}"
onclick="navigator.clipboard.writeText(document.getElementById('shareUrl').value).then(() => { this.innerHTML = '<i class=\'lni lni-checkmark\'></i> ' + this.dataset.copiedText; setTimeout(() => { this.innerHTML = '<i class=\'lni lni-copy\'></i> ' + this.dataset.copyText; }, 2000); })">
<i class="lni lni-copy"></i> <span th:text="#{memory.share.result.copy}">Copy</span>
</button>
</div>
</div>
<div class="share-instructions">
<h4 th:text="#{memory.share.result.instructions.title}">How to share:</h4>
<ul>
<li th:text="#{memory.share.result.instructions.copy}">Copy the link above and send it to anyone you want to share with</li>
<li th:text="#{memory.share.result.instructions.account}">Recipients don't need an account to access the memory</li>
<li th:text="#{memory.share.result.instructions.permissions}">The link will work according to the permissions you've set</li>
<li th:if="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY}" th:text="#{memory.share.result.instructions.view}">Recipients can view but not edit the memory</li>
<li th:if="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_EDIT_ACCESS}" th:text="#{memory.share.result.instructions.edit}">Recipients can view and edit the memory</li>
</ul>
</div>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-primary"
hx-get="/memories/fragments/empty"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-checkmark"></i> <span th:text="#{memory.share.result.done}">Done</span>
</button>
<button type="button"
class="btn"
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-share"></i> <span th:text="#{memory.share.result.another}">Create Another Link</span>
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memories - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
<link rel="stylesheet" th:href="@{/css/leaflet.css}" href="../../static/css/leaflet.css">
<script th:src="@{/js/htmx.min.js}"></script>
<script th:src="@{/js/leaflet.js}"></script>
<script th:src="@{/js/leaflet.geodesic.2.7.2.js}"></script>
<script th:src="@{/js/TileLayer.Grayscale.js}"></script>
<script th:src="@{/js/raw-location-loader.js}"></script>
</head>
<body class="memories-page">
<div id="memories-panel" class="memories-overview"></div>
<div class="timeline">
<div class="navbar">
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link active" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<form th:action="@{/logout}" method="post" >
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
</button>
</form>
</div>
<div class="timeline-container">
<div id="years-navigation"
hx-get="/memories/years-navigation"
hx-trigger="load">
<div class="photo-modal-loading-spinner htmx-indicator" th:text="#{timeline.loading}">Loading...</div>
</div>
</div>
</div>
<script th:inline="javascript">
window.userSettings = /*[[${userSettings}]]*/ {};
function getUserTimezone() {
if (window.userSettings.timeZoneOverride) {
return window.userSettings.timeZoneOverride;
} else {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
function shouldTrigger(element) {
return !element.classList.contains('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="#{memory.new.page.title}">New Memory - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
<link rel="stylesheet" th:href="@{/css/gallery-block.css}" href="../../static/css/gallery-block.css">
<link rel="stylesheet" th:href="@{/css/photo-client.css}" href="../../static/css/photo-client.css">
<link rel="stylesheet" href="/css/leaflet.css">
<script src="/js/htmx.min.js"></script>
<script src="/js/leaflet.js"></script>
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
<script src="/js/raw-location-loader.js"></script>
<script src="/js/HumanizeDuration.js"></script>
<script src="/js/photo-client.js"></script>
</head>
<body class="memories-page">
<div class="settings-container">
<nav class="settings-nav">
<div class="settings-header">
<a href="/memories" class="back-link">
<i class="lni lni-arrow-left"></i>
<span th:text="#{memory.new.back.to.memories}">Back to Memories</span>
</a>
<h1 th:text="#{memory.new.title}">New Memory</h1>
</div>
</nav>
<div th:fragment="new-memory" class="settings-content-area">
<div th:if="${error}" class="alert alert-error">
<i class="lni lni-warning"></i>
<span th:text="#{${error}}">Error message</span>
</div>
<form th:href="@{/memories}" class="memory-form" onsubmit="return validateDates()">
<div class="form-group">
<label for="title" th:text="#{memory.form.title.label}">Title *</label>
<input type="text" id="title" name="title" required class="form-control" th:placeholder="#{memory.form.title.placeholder}" th:value="${title}">
</div>
<div class="form-group">
<label for="description" th:text="#{memory.form.description.label}">Description</label>
<textarea id="description" name="description" class="form-control" rows="4" th:placeholder="#{memory.form.description.placeholder}" th:text="${description}"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="startDate" th:text="#{memory.form.start.date.label}">Start Date *</label>
<input type="date" id="startDate" name="startDate" required class="form-control"
th:value="${startDate}">
</div>
<div class="form-group">
<label for="endDate" th:text="#{memory.form.end.date.label}">End Date *</label>
<input type="date" id="endDate" name="endDate" required class="form-control"
th:value="${endDate}">
</div>
</div>
<div id="dateError" class="alert alert-error" style="display: none;">
<i class="lni lni-warning"></i>
<span id="dateErrorText"></span>
</div>
<div class="form-actions">
<button th:if="${year != 'all'}" th:attr="hx-get=@{/memories/year/{year}(year=${year})}"
hx-target=".memories-overview" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}" class="btn btn-primary" th:text="#{memory.form.cancel}">Cancel</button>
<button th:if="${year == 'all'}" th:attr="hx-get=@{/memories/all}"
hx-target=".memories-overview" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}" class="btn btn-primary" th:text="#{memory.form.cancel}">Cancel</button>
<button type="submit" class="btn btn-primary"
hx-indicator="#create-spinner"
hx-disabled-elt="this">
<span th:text="#{memory.form.create}">Create Memory</span>
<span id="create-spinner" class="htmx-indicator">
<i class="lni lni-spinner-2-sacle lni-is-spinning"></i>
<span th:text="#{memory.form.creating}">Creating...</span>
</span>
</button>
</div>
</form>
<script th:inline="javascript">
// User settings from server
window.userSettings = /*[[${userSettings}]]*/ {};
function getUserTimezone() {
if (window.userSettings.timezoneOverride) {
return window.userSettings.timezoneOverride;
} else {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
function validateDates() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const errorDiv = document.getElementById('dateError');
const errorText = document.getElementById('dateErrorText');
if (!startDate || !endDate) {
errorText.textContent = /*[[#{memory.form.date.error.empty}]]*/ 'Dates cannot be empty';
errorDiv.style.display = 'block';
return false;
}
if (new Date(startDate) > new Date(endDate)) {
errorText.textContent = /*[[#{memory.form.date.error.end.before.start}]]*/ 'End date must be on or after start date';
errorDiv.style.display = 'block';
return false;
}
errorDiv.style.display = 'none';
return true;
}
</script>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${memory.title} + ' - Reitti'">Memory - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
<link rel="stylesheet" th:href="@{/css/gallery-block.css}" href="../../static/css/gallery-block.css">
<link rel="stylesheet" th:href="@{/css/photo-client.css}" href="../../static/css/photo-client.css">
<link rel="stylesheet" th:href="@{/css/leaflet.css}" href="../../static/css/leaflet.css">
<link rel="stylesheet" th:href="@{/css/share-overlay.css}" href="../../static/css/share-overlay.css">
<script src="/js/htmx.min.js"></script>
<script src="/js/leaflet.js"></script>
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
<script src="/js/raw-location-loader.js"></script>
<script src="/js/HumanizeDuration.js"></script>
<script src="/js/photo-client.js"></script>
</head>
<body class="memories-page">
<div class="settings-container">
<div class="settings-content-area">
<div class="memory-header" th:fragment="memory-header">
<div class="action-bar">
<div sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')"><a class="btn"
th:href="@{/memories}"><i class="lni lni-arrow-left"></i>
<span th:text="#{memory.view.back}"></span></a></a>
</div>
<button type="button"
class="btn"
th:if="${canEdit}"
th:attr="hx-get=@{/memories/{id}/edit(id=${memory.id})}"
hx-target=".memory-header"
hx-swap="innerHTML"
hx-vals="js:{timezone: getUserTimezone()}"
th:text="#{memory.view.edit}">Edit
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-post=@{/memories/{id}/recalculate(id=${memory.id})}"
hx-target="body"
hx-swap="innerHTML"
hx-confirm="Recalculate this memory? This will update all location data."
hx-vals="js:{timezone: getUserTimezone()}"
th:text="#{memory.view.recalculate}">Recalculate
</button>
<button type="button"
class="btn"
th:if="${canEdit && isOwner}"
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
hx-target="#share-overlay-container"
hx-swap="innerHTML">
<i class="lni lni-share"></i> Share
</button>
</div>
<div class="memory-header-section map-header-section">
<div id="memory-map" class="memory-map"></div>
</div>
<div class="memory-details">
<h1 class="memory-date-range-large">
<span th:text="${memory.title}">Title</span>
</h1>
<p class="memory-description-large" th:if="${memory.description != null && !memory.description.isEmpty()}"
th:text="${memory.description}">Description</p>
<p class="memory-date-range-large">
<i class="lni lni-calendar"></i>
<span th:text="${#temporals.format(memory.startDate, 'MMMM d, yyyy')}">Start Date</span>
<span th:if="${memory.startDate != memory.endDate}">
- <span th:text="${#temporals.format(memory.endDate, 'MMMM d, yyyy')}">End Date</span>
</span>
</p>
</div>
</div>
<div class="memory-blocks-container">
<div th:if="${blocks.isEmpty()}" class="memory-block empty-blocks">
<i class="lni lni-layers"></i>
<p th:text="#{memory.view.no.blocks}">No blocks yet. Add your first block to start building your memory.</p>
<div th:if="${canEdit}" class="add-block-spacer">
<button type="button"
class="btn btn-primary"
th:attr="hx-get=@{/memories/{id}/blocks/select-type(id=${memory.id}, position=0)}"
hx-target="closest .empty-blocks"
hx-swap="outerHTML">
<i class="lni lni-plus"></i> <span th:text="#{memory.view.add.first.block}">Add Block after</span>
</button>
</div>
</div>
<div class="blocks-list" id="blocksList">
<div th:fragment="view-block" th:each="block, iterStat : ${blocks}" class="memory-block" th:attr="data-block-id=${block.blockId}, data-block-type=${block.type.name()}">
<div class="block-content">
<div th:if="${block.type.name() == 'TEXT'}" class="text-block">
<div class="block-header" th:if="${block.headline != null && !block.headline.isEmpty()}">
<h3 class="title" th:text="${block.headline}">Text Block</h3>
</div>
<p th:text="${block.content}">Content will be loaded here</p>
</div>
<div th:if="${block.type.name() == 'IMAGE_GALLERY'}" class="gallery-block">
<div class="gallery-images-grid" th:id="${'gallery_' + block.blockId}">
<div th:each="image : ${block.images}"
class="gallery-image-item"
th:attr="data-image-url=${image.imageUrl},data-caption=${image.caption}">
<div class="photo-loading-spinner"></div>
<img th:data-src="${image.imageUrl}"
th:alt="${image.caption != null ? image.caption : 'Gallery image'}"
class="lazy-image"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELoading...%3C/text%3E%3C/svg%3E"
onload="this.classList.add('loaded'); this.previousElementSibling.style.display='none';"
onerror="this.style.display='none'; this.previousElementSibling.style.display='none'; this.parentElement.innerHTML='📷';">
</div>
</div>
<script th:inline="javascript">
(function() {
const blockId = /*[[${block.blockId}]]*/ 0;
const images = /*[[${block.images}]]*/ [];
const galleryContainer = document.getElementById('gallery_' + blockId);
const imageElements = galleryContainer.querySelectorAll('.gallery-image-item');
imageElements.forEach((element, index) => {
element.addEventListener('click', () => {
showGalleryModal(images, index);
});
});
function showGalleryModal(allImages, currentIndex) {
const modal = document.createElement('div');
modal.className = 'photo-modal';
const imageContainer = document.createElement('div');
imageContainer.className = 'photo-modal-container';
const modalSpinner = document.createElement('div');
modalSpinner.className = 'photo-modal-loading-spinner';
imageContainer.appendChild(modalSpinner);
const img = document.createElement('img');
img.src = allImages[currentIndex].imageUrl;
img.alt = allImages[currentIndex].caption || 'Gallery image';
img.addEventListener('load', () => {
img.classList.add('loaded');
if (imageContainer.contains(modalSpinner)) {
imageContainer.removeChild(modalSpinner);
}
});
img.addEventListener('error', () => {
if (imageContainer.contains(modalSpinner)) {
imageContainer.removeChild(modalSpinner);
}
img.style.display = 'none';
const errorMsg = document.createElement('div');
errorMsg.textContent = 'Failed to load image';
errorMsg.style.color = '#ccc';
errorMsg.style.fontSize = '18px';
imageContainer.appendChild(errorMsg);
});
const closeButton = document.createElement('button');
closeButton.innerHTML = '<i class="lni lni-xmark-circle"></i>';
closeButton.className = 'photo-modal-close-button';
let prevButton, nextButton, counter;
if (allImages.length > 1) {
prevButton = document.createElement('button');
prevButton.innerHTML = '<i class="lni lni-chevron-left"></i>';
prevButton.className = 'photo-nav-button photo-nav-prev';
prevButton.disabled = currentIndex === 0;
nextButton = document.createElement('button');
nextButton.innerHTML = '<i class="lni lni-chevron-left lni-rotate-180"></i>';
nextButton.className = 'photo-nav-button photo-nav-next';
nextButton.disabled = currentIndex === allImages.length - 1;
counter = document.createElement('div');
counter.className = 'photo-counter';
counter.textContent = `${currentIndex + 1} / ${allImages.length}`;
}
const showPrevImage = () => {
if (currentIndex > 0) {
closeModal();
showGalleryModal(allImages, currentIndex - 1);
}
};
const showNextImage = () => {
if (currentIndex < allImages.length - 1) {
closeModal();
showGalleryModal(allImages, currentIndex + 1);
}
};
const handleKeydown = (e) => {
switch (e.key) {
case 'Escape':
closeModal();
break;
case 'ArrowLeft':
e.preventDefault();
showPrevImage();
break;
case 'ArrowRight':
e.preventDefault();
showNextImage();
break;
}
};
const closeModal = () => {
document.removeEventListener('keydown', handleKeydown);
if (document.body.contains(modal)) {
document.body.removeChild(modal);
}
};
closeButton.addEventListener('click', (e) => {
e.preventDefault();
closeModal();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
if (prevButton) {
prevButton.addEventListener('click', (e) => {
e.stopPropagation();
showPrevImage();
});
}
if (nextButton) {
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
showNextImage();
});
}
document.addEventListener('keydown', handleKeydown);
imageContainer.appendChild(img);
imageContainer.appendChild(closeButton);
if (prevButton) imageContainer.appendChild(prevButton);
if (nextButton) imageContainer.appendChild(nextButton);
if (counter) imageContainer.appendChild(counter);
modal.appendChild(imageContainer);
document.body.appendChild(modal);
}
})();
</script>
</div>
<div th:if="${block.type.name() == 'CLUSTER_TRIP'}" class="cluster-block">
<div class="block-header">
<div class="time">
<i class="lni lni-timer"></i>
<span th:text="${#temporals.format(block.adjustedStartTime, 'MMM d, yyyy HH:mm')}">Start Time</span>
<span> - </span>
<span th:text="${#temporals.format(block.adjustedEndTime, 'HH:mm')}">End Time</span>
</div>
<i class="lni lni-layers"></i>
<div class="title" th:text="${block.title}">Cluster Title</div>
</div>
<p th:if="${block.description != null}" class="cluster-description" th:text="${block.description}">Description</p>
<div class="cluster-map" th:id="${'map_cluster_' + block.blockId}"></div>
<script th:inline="javascript">
(function() {
const userSettings = /*[[${userSettings}]]*/ {};
const blockId = /*[[${'map_cluster_' + block.blockId}]]*/ 'map_cluster_99';
const trips = /*[[${block.trips}]]*/ [];
const rawLocationUrl = /*[[${block.rawLocationPointsUrl}]]*/ '';
const combinedStartTime = /*[[${block.combinedStartTime}]]*/ null;
// Initialize map
const clusterMap = L.map(blockId, {
attributionControl: false,
}).setView([50,50], 19);
let tileLayer;
if (userSettings.preferColoredMap) {
tileLayer = L.tileLayer(userSettings.tiles.service, {
maxZoom: 19,
attribution: userSettings.tiles.attribution
});
} else {
tileLayer = L.tileLayer.grayscale(userSettings.tiles.service, {
maxZoom: 19,
attribution: userSettings.tiles.attribution
});
}
tileLayer.addTo(clusterMap);
let clusterRawLocationLoader = new RawLocationLoader(clusterMap, userSettings);
// Load raw location data for the cluster's time range
if (rawLocationUrl) {
const userConfigs = [{
respectBounds: false,
url: rawLocationUrl,
color: '#f1ba63',
avatarUrl: null,
avatarFallback: null,
displayName: 'Cluster Path'
}];
clusterRawLocationLoader.init(userConfigs);
clusterRawLocationLoader.loadForDateRange(false, false);
}
// Add markers for start, stops, and end using default filled circle markers
let bounds = [];
if (trips.length > 0) {
// Start marker
const startTrip = trips[0];
if (startTrip.startVisit && startTrip.startVisit.place) {
const startLat = startTrip.startVisit.place.latitudeCentroid;
const startLon = startTrip.startVisit.place.longitudeCentroid;
const startName = startTrip.startVisit.place.name;
const startTimeFormatted = combinedStartTime ? new Date(combinedStartTime).toLocaleTimeString() : 'unknown';
L.circleMarker([startLat, startLon], {
color: '#4a9fdc',
fillColor: '#59bcff',
fillOpacity: 0.2
}).addTo(clusterMap).bindPopup('Start: ' + startName + '<br>Departed at: ' + startTimeFormatted);
bounds.push([startLat, startLon]);
}
// Intermediate stops (end of each trip except last)
for (let i = 0; i < trips.length - 1; i++) {
const trip = trips[i];
if (trip.endVisit && trip.endVisit.place) {
const lat = trip.endVisit.place.latitudeCentroid;
const lon = trip.endVisit.place.longitudeCentroid;
const name = trip.endVisit.place.name;
const arrivedAfter = combinedStartTime && trip.endTime ? humanizeDuration((new Date(trip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
L.circleMarker([lat, lon], {
color: '#6a6a6a',
fillColor: '#ff984f',
fillOpacity: 0.1
}).addTo(clusterMap).bindPopup('Stop: ' + name + '<br>Arrived after: ' + arrivedAfter);
bounds.push([lat, lon]);
}
}
// End marker
const endTrip = trips[trips.length - 1];
if (endTrip.endVisit && endTrip.endVisit.place) {
const endLat = endTrip.endVisit.place.latitudeCentroid;
const endLon = endTrip.endVisit.place.longitudeCentroid;
const endName = endTrip.endVisit.place.name;
const arrivedAfter = combinedStartTime && endTrip.endTime ? humanizeDuration((new Date(endTrip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
L.circleMarker([endLat, endLon], {
color: '#37bd57',
fillColor: '#55ea7a',
fillOpacity: 0.2
}).addTo(clusterMap).bindPopup('End: ' + endName + '<br>Arrived after: ' + arrivedAfter);
bounds.push([endLat, endLon]);
}
}
// Fit map to bounds if markers exist
if (bounds.length > 0) {
clusterMap.fitBounds(bounds, {padding: [10, 10]});
} else {
clusterMap.setView([51.505, -0.09], 13);
}
})();
</script>
<div class="cluster-summary" th:if="${block.combinedStartTime != null && block.combinedEndTime != null}">
<div class="cluster-duration" th:if="${block.completeDuration != null}">
<span th:text="
#{memory.view.block.cluster.duration(${#numbers.formatDecimal(block.completeDuration / 3600.0, 0, 0)},${#numbers.formatDecimal((block.completeDuration % 3600) / 60, 0, 0)},${#numbers.formatDecimal(block.movingDuration / 3600.0, 0, 0)},${#numbers.formatDecimal((block.movingDuration % 3600) / 60, 0, 0)})}">Total Duration</span>
</div>
</div>
<div class="cluster-trips" th:if="${!block.trips.isEmpty()}">
<h4>Trips in this Journey:</h4>
<ul>
<li th:each="trip : ${block.trips}" class="trip-item">
<span th:text="${trip.startVisit != null && trip.startVisit.place != null ? trip.startVisit.place.name : 'Start'}">Start</span>
<span></span>
<span th:text="${trip.endVisit != null && trip.endVisit.place != null ? trip.endVisit.place.name : 'End'}">End</span>
</li>
</ul>
</div>
</div>
<div th:if="${block.type.name() == 'CLUSTER_VISIT'}" class="cluster-block">
<div class="block-header">
<div class="time">
<i class="lni lni-timer"></i>
<span th:text="${#temporals.format(block.adjustedStartTime, 'MMM d, yyyy HH:mm')}">Start Time</span>
<span> - </span>
<span th:text="${#temporals.format(block.adjustedEndTime, 'HH:mm')}">End Time</span>
</div>
<i class="lni lni-layers"></i>
<div class="title" th:text="${block.title}">Cluster Title</div>
</div>
<p th:if="${block.description != null}" class="cluster-description" th:text="${block.description}">Description</p>
<div class="cluster-map" th:id="${'map_cluster_' + block.blockId}"></div>
<script th:inline="javascript">
(function() {
const userSettings = /*[[${userSettings}]]*/ {};
const blockId = /*[[${'map_cluster_' + block.blockId}]]*/ 'map_cluster_99';
const visits = /*[[${block.visits}]]*/ [];
const rawLocationUrl = /*[[${block.rawLocationPointsUrl}]]*/ '';
// Initialize map
const clusterMap = L.map(blockId, {
attributionControl: false,
}).setView([50,50], 19);
let tileLayer;
if (userSettings.preferColoredMap) {
tileLayer = L.tileLayer(userSettings.tiles.service, {
maxZoom: 19,
attribution: userSettings.tiles.attribution
});
} else {
tileLayer = L.tileLayer.grayscale(userSettings.tiles.service, {
maxZoom: 19,
attribution: userSettings.tiles.attribution
});
}
tileLayer.addTo(clusterMap);
let clusterRawLocationLoader = new RawLocationLoader(clusterMap, userSettings);
// Load raw location data for the cluster's time range
if (rawLocationUrl) {
const userConfigs = [{
respectBounds: false,
url: rawLocationUrl,
color: '#f1ba63',
avatarUrl: null,
avatarFallback: null,
displayName: 'Cluster Path'
}];
clusterRawLocationLoader.init(userConfigs);
clusterRawLocationLoader.loadForDateRange(false, false);
}
let bounds = [];
if (visits.length > 0) {
for (let i = 0; i < visits.length; i++) {
const visit = visits[i];
if (visit.place) {
const lat = visit.place.latitudeCentroid;
const lon = visit.place.longitudeCentroid;
const name = visit.place.name;
L.circleMarker([lat, lon], {
color: '#6a6a6a',
fillColor: '#ff984f',
fillOpacity: 0.1
}).addTo(clusterMap).bindPopup('Name: ' + name);
bounds.push([lat, lon]);
}
}
}
// Fit map to bounds if markers exist
if (bounds.length > 0) {
clusterMap.fitBounds(bounds, {padding: [10, 10]});
} else {
clusterMap.setView([51.505, -0.09], 13);
}
})();
</script>
<div class="cluster-trips" th:if="${!block.visits.isEmpty()}">
<h4>Visits in this Journey:</h4>
<ul>
<li th:each="visit : ${block.visits}" class="trip-item">
<span th:text="${visit.place != null ? visit.place.name : 'Start'}">Start</span>
</li>
</ul>
</div>
</div>
</div>
<div th:if="${canEdit}" class="block-actions">
<button type="button"
class="btn"
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/edit(memoryId=${memory.id}, blockId=${block.blockId})}"
hx-target="closest .memory-block"
hx-swap="innerHTML">
<i class="lni lni-pencil-1"></i>
</button>
<button type="button"
class="btn btn-danger"
th:attr="hx-delete=@{/memories/{memoryId}/blocks/{blockId}(memoryId=${memory.id}, blockId=${block.blockId})}"
hx-target="closest .memory-block"
hx-swap="outerHTML"
hx-confirm="Delete this block?">
<i class="lni lni-trash-3"></i>
</button>
</div>
<div th:if="${canEdit}" class="add-block-spacer">
<button type="button"
class="btn btn-primary"
th:attr="hx-get=@{/memories/{id}/blocks/select-type(id=${memory.id})},data-block-id=${block.blockId}"
hx-target="closest .add-block-spacer"
hx-swap="afterend"
hx-vals="js:{position: getBlockPosition()}">
<i class="lni lni-plus"></i> <span th:text="#{memory.view.add.block}">Add Block after</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script th:inline="javascript">
// User settings for timezone handling
window.userSettings = /*[[${userSettings}]]*/ {};
function getBlockPosition() {
let allBlocks = document.querySelectorAll('.memory-block');
for (let i = 0; i < allBlocks.length; i++) {
const block = allBlocks[i];
if (block.dataset.blockId === event.currentTarget.dataset.blockId) {
return i + 1;
}
}
}
function getUserTimezone() {
if (window.userSettings.timeZoneOverride) {
return window.userSettings.timeZoneOverride;
} else {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
function humanizeDuration(seconds) {
if (!seconds || seconds <= 0) return 'unknown';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return hours + 'h ' + minutes + 'm';
} else {
return minutes + 'm';
}
}
const memory = /*[[${memory}]]*/ {};
const rawLocationUrl = /*[[${rawLocationUrl}]]*/ '';
let rawLocationLoader = null;
let memoryMap = null;
function initializeMemoryMap() {
// Only initialize if header type is MAP and map container exists
if (memory.headerType === 'MAP' && document.getElementById('memory-map')) {
if (memoryMap && typeof memoryMap.getContainer === 'function') {
memoryMap.remove();
}
memoryMap = L.map('memory-map', {
zoomControl: false,
attributionControl: false,
boxZoom: false,
doubleClickZoom: false,
dragging: false,
keyboard: false,
scrollWheelZoom: false,
tap: false,
touchZoom: false
}).setView([51.505, -0.09], 13);
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(memoryMap);
if (rawLocationLoader === null) {
rawLocationLoader = new RawLocationLoader(memoryMap, window.userSettings);
}
// Initialize raw location loader for the memory
if (rawLocationUrl && rawLocationLoader !== null) {
const userConfigs = [{
url: rawLocationUrl,
color: '#f1ba63',
avatarUrl: null,
avatarFallback: null,
displayName: 'Memory Location Data'
}];
rawLocationLoader.init(userConfigs);
// Load location data for the memory date range without bounds filtering
rawLocationLoader.loadForDateRange(
memory.startDate,
memory.endDate,
false, // autoUpdateMode
false // withBounds - don't filter by current map bounds
);
}
}
}
// Lazy loading for images using Intersection Observer
function initializeLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const dataSrc = img.getAttribute('data-src');
if (dataSrc) {
img.src = dataSrc;
img.classList.remove('lazy-image');
observer.unobserve(img);
}
}
});
}, {
rootMargin: '50px 0px', // Start loading 50px before the image enters viewport
threshold: 0.01
});
// Observe all lazy images
document.querySelectorAll('.lazy-image').forEach(img => {
imageObserver.observe(img);
});
} else {
// Fallback for browsers without Intersection Observer support
document.querySelectorAll('.lazy-image').forEach(img => {
const dataSrc = img.getAttribute('data-src');
if (dataSrc) {
img.src = dataSrc;
img.classList.remove('lazy-image');
}
});
}
}
// Initialize map on page load
initializeMemoryMap();
// Initialize lazy loading on page load
initializeLazyLoading();
// Listen for htmx events to reinitialize map and lazy loading when content is updated
document.body.addEventListener('htmx:afterSwap', function (event) {
if (event.detail.target.classList.contains('memory-header')) {
initializeMemoryMap()
if (rawLocationUrl) {
if (rawLocationLoader === null) {
rawLocationLoader = new RawLocationLoader(memoryMap, {});
}
const userConfigs = [{
url: rawLocationUrl,
color: '#f1ba63',
avatarUrl: null,
avatarFallback: null,
displayName: 'Memory Location Data'
}];
rawLocationLoader.init(userConfigs);
rawLocationLoader.reloadForCurrentView(false);
}
}
// Reinitialize lazy loading for any new images added via HTMX
initializeLazyLoading();
});
</script>
<div id="share-overlay-container"></div>
</body>
</html>

View File

@@ -12,7 +12,7 @@
<script src="/js/TileLayer.Grayscale.js"></script>
<style>
:root {
--color-highlight: #F5DEB3FF;
--color-highlight: #fddca1;
--color-background-dark: #3b3b3b;
--color-background-dark-light: #5c5c5c;
--color-text-white: #e3e3e3;

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -22,7 +22,9 @@
<div class="navbar">
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
<a href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link active" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<form th:action="@{/logout}" method="post" >
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
</button>

View File

@@ -69,7 +69,7 @@ public class TestingService {
}
public User randomUser() {
return this.userJdbcService.createUser(new User(UUID.randomUUID().toString(), "Test User"));
return this.userJdbcService.createUser(new User(UUID.randomUUID().toString(), "Test User"));
}
public void triggerProcessingPipeline(int timeout) {

View File

@@ -126,16 +126,16 @@ class MagicLinkJdbcServiceTest {
// Given
MagicLinkToken originalToken = createTestToken("update-token");
Instant lastUsed = Instant.now();
MagicLinkToken updatedToken = new MagicLinkToken(
originalToken.getId(),
originalToken.getId(),
originalToken.getName(),
originalToken.getTokenHash(),
originalToken.getTokenHash(),
MagicLinkAccessLevel.ONLY_LIVE, // Changed access level
originalToken.getExpiryDate(),
originalToken.getCreatedAt(),
lastUsed, // Set last used
true
originalToken.getExpiryDate(),
originalToken.getCreatedAt(),
lastUsed, // Set last used
true
);
// When

View File

@@ -0,0 +1,194 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class MemoryBlockImageGalleryJdbcServiceTest {
@Autowired
private MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService;
@Autowired
private MemoryBlockJdbcService memoryBlockJdbcService;
@Autowired
private MemoryJdbcService memoryJdbcService;
@Autowired
private TestingService testingService;
@Autowired
private JdbcTemplate jdbcTemplate;
private User testUser;
private Memory testMemory;
private MemoryBlock testBlock;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM memory_block_image_gallery");
jdbcTemplate.update("DELETE FROM memory_block");
jdbcTemplate.update("DELETE FROM memory");
testUser = testingService.randomUser();
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
testMemory = memoryJdbcService.create(testUser, memory);
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.IMAGE_GALLERY, 0);
testBlock = memoryBlockJdbcService.create(block);
}
@Test
void testCreateGallery() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
assertEquals(testBlock.getId(), created.getBlockId());
assertEquals(2, created.getImages().size());
assertEquals("https://example.com/image1.jpg", created.getImages().get(0).getImageUrl());
assertEquals("Caption 1", created.getImages().get(0).getCaption());
assertEquals("https://example.com/image2.jpg", created.getImages().get(1).getImageUrl());
assertEquals("Caption 2", created.getImages().get(1).getCaption());
}
@Test
void testUpdateGallery() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Original Caption", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
List<MemoryBlockImageGallery.GalleryImage> updatedImages = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Updated Caption", null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image3.jpg", "New Image", null, null)
);
MemoryBlockImageGallery updated = created.withImages(updatedImages);
MemoryBlockImageGallery result = memoryBlockImageGalleryJdbcService.update(updated);
assertEquals(2, result.getImages().size());
assertEquals("Updated Caption", result.getImages().get(0).getCaption());
assertEquals("https://example.com/image3.jpg", result.getImages().get(1).getImageUrl());
}
@Test
void testDeleteGallery() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
memoryBlockImageGalleryJdbcService.create(gallery);
memoryBlockImageGalleryJdbcService.delete(testBlock.getId());
Optional<MemoryBlockImageGallery> found = memoryBlockImageGalleryJdbcService.findById(testBlock.getId());
assertFalse(found.isPresent());
}
@Test
void testDeleteByBlockId() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
memoryBlockImageGalleryJdbcService.create(gallery);
memoryBlockImageGalleryJdbcService.deleteByBlockId(testBlock.getId());
Optional<MemoryBlockImageGallery> galleries = memoryBlockImageGalleryJdbcService.findByBlockId(testBlock.getId());
assertTrue(galleries.isEmpty());
}
@Test
void testFindById() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Test Caption", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
memoryBlockImageGalleryJdbcService.create(gallery);
Optional<MemoryBlockImageGallery> found = memoryBlockImageGalleryJdbcService.findById(testBlock.getId());
assertTrue(found.isPresent());
assertEquals(testBlock.getId(), found.get().getBlockId());
assertEquals(1, found.get().getImages().size());
assertEquals("https://example.com/image1.jpg", found.get().getImages().get(0).getImageUrl());
}
@Test
void testFindByBlockId() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image3.jpg", "Caption 3", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
memoryBlockImageGalleryJdbcService.create(gallery);
Optional<MemoryBlockImageGallery> galleries = memoryBlockImageGalleryJdbcService.findByBlockId(testBlock.getId());
assertTrue(galleries.isPresent());
assertEquals(3, galleries.get().getImages().size());
assertEquals("https://example.com/image1.jpg", galleries.get().getImages().get(0).getImageUrl());
assertEquals("https://example.com/image2.jpg", galleries.get().getImages().get(1).getImageUrl());
assertEquals("https://example.com/image3.jpg", galleries.get().getImages().get(2).getImageUrl());
}
@Test
void testCreateGalleryWithNullCaptions() {
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", null, null, null),
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
);
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
assertNull(created.getImages().get(0).getCaption());
assertEquals("Caption 2", created.getImages().get(1).getCaption());
}
@Test
void testCreateEmptyGallery() {
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), List.of());
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
assertTrue(created.getImages().isEmpty());
}
}

View File

@@ -0,0 +1,169 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.memory.BlockType;
import com.dedicatedcode.reitti.model.memory.HeaderType;
import com.dedicatedcode.reitti.model.memory.Memory;
import com.dedicatedcode.reitti.model.memory.MemoryBlock;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class MemoryBlockJdbcServiceTest {
@Autowired
private MemoryBlockJdbcService memoryBlockJdbcService;
@Autowired
private MemoryJdbcService memoryJdbcService;
@Autowired
private TestingService testingService;
@Autowired
private JdbcTemplate jdbcTemplate;
private User testUser;
private Memory testMemory;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM memory_block");
jdbcTemplate.update("DELETE FROM memory");
testUser = testingService.randomUser();
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
testMemory = memoryJdbcService.create(testUser, memory);
}
@Test
void testCreateBlock() {
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock created = memoryBlockJdbcService.create(block);
assertNotNull(created.getId());
assertEquals(testMemory.getId(), created.getMemoryId());
assertEquals(BlockType.TEXT, created.getBlockType());
assertEquals(0, created.getPosition());
assertEquals(1L, created.getVersion());
}
@Test
void testUpdateBlock() {
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock created = memoryBlockJdbcService.create(block);
MemoryBlock updated = created.withPosition(5);
MemoryBlock result = memoryBlockJdbcService.update(updated);
assertEquals(5, result.getPosition());
assertEquals(2L, result.getVersion());
}
@Test
void testUpdateBlockWithWrongVersion() {
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock created = memoryBlockJdbcService.create(block);
MemoryBlock withWrongVersion = created.withVersion(999L).withPosition(5);
assertThrows(IllegalStateException.class, () -> {
memoryBlockJdbcService.update(withWrongVersion);
});
}
@Test
void testDeleteBlock() {
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock created = memoryBlockJdbcService.create(block);
memoryBlockJdbcService.delete(created.getId());
Optional<MemoryBlock> found = memoryBlockJdbcService.findById(testUser, created.getId());
assertFalse(found.isPresent());
}
@Test
void testFindById() {
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock created = memoryBlockJdbcService.create(block);
Optional<MemoryBlock> found = memoryBlockJdbcService.findById(testUser, created.getId());
assertTrue(found.isPresent());
assertEquals(created.getId(), found.get().getId());
assertEquals(BlockType.TEXT, found.get().getBlockType());
}
@Test
void testFindByMemoryId() {
MemoryBlock block1 = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock block2 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
MemoryBlock block3 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 2);
memoryBlockJdbcService.create(block1);
memoryBlockJdbcService.create(block2);
memoryBlockJdbcService.create(block3);
List<MemoryBlock> blocks = memoryBlockJdbcService.findByMemoryId(testMemory.getId());
assertEquals(3, blocks.size());
assertEquals(0, blocks.get(0).getPosition());
assertEquals(1, blocks.get(1).getPosition());
assertEquals(2, blocks.get(2).getPosition());
}
@Test
void testGetMaxPosition() {
assertEquals(-1, memoryBlockJdbcService.getMaxPosition(testMemory.getId()));
MemoryBlock block1 = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock block2 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
MemoryBlock block3 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 5);
memoryBlockJdbcService.create(block1);
memoryBlockJdbcService.create(block2);
memoryBlockJdbcService.create(block3);
assertEquals(5, memoryBlockJdbcService.getMaxPosition(testMemory.getId()));
}
@Test
void testCreateMultipleBlockTypes() {
MemoryBlock textBlock = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
MemoryBlock visitBlock = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
MemoryBlock tripBlock = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 2);
MemoryBlock galleryBlock = new MemoryBlock(testMemory.getId(), BlockType.IMAGE_GALLERY, 3);
MemoryBlock createdText = memoryBlockJdbcService.create(textBlock);
MemoryBlock createdVisit = memoryBlockJdbcService.create(visitBlock);
MemoryBlock createdTrip = memoryBlockJdbcService.create(tripBlock);
MemoryBlock createdGallery = memoryBlockJdbcService.create(galleryBlock);
assertEquals(BlockType.TEXT, createdText.getBlockType());
assertEquals(BlockType.CLUSTER_VISIT, createdVisit.getBlockType());
assertEquals(BlockType.CLUSTER_TRIP, createdTrip.getBlockType());
assertEquals(BlockType.IMAGE_GALLERY, createdGallery.getBlockType());
}
}

View File

@@ -0,0 +1,157 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class MemoryBlockTextJdbcServiceTest {
@Autowired
private MemoryBlockTextJdbcService memoryBlockTextJdbcService;
@Autowired
private MemoryBlockJdbcService memoryBlockJdbcService;
@Autowired
private MemoryJdbcService memoryJdbcService;
@Autowired
private TestingService testingService;
@Autowired
private JdbcTemplate jdbcTemplate;
private User testUser;
private Memory testMemory;
private MemoryBlock testBlock;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM memory_block_text");
jdbcTemplate.update("DELETE FROM memory_block");
jdbcTemplate.update("DELETE FROM memory");
testUser = testingService.randomUser();
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
testMemory = memoryJdbcService.create(testUser, memory);
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
testBlock = memoryBlockJdbcService.create(block);
}
@Test
void testCreateTextBlock() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
"Test Headline",
"Test content goes here"
);
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
assertEquals(testBlock.getId(), created.getBlockId());
assertEquals("Test Headline", created.getHeadline());
assertEquals("Test content goes here", created.getContent());
}
@Test
void testUpdateTextBlock() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
"Original Headline",
"Original content"
);
memoryBlockTextJdbcService.create(textBlock);
MemoryBlockText updated = textBlock
.withHeadline("Updated Headline")
.withContent("Updated content");
MemoryBlockText result = memoryBlockTextJdbcService.update(updated);
assertEquals("Updated Headline", result.getHeadline());
assertEquals("Updated content", result.getContent());
}
@Test
void testFindByBlockId() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
"Test Headline",
"Test content"
);
memoryBlockTextJdbcService.create(textBlock);
Optional<MemoryBlockText> found = memoryBlockTextJdbcService.findByBlockId(testBlock.getId());
assertTrue(found.isPresent());
assertEquals("Test Headline", found.get().getHeadline());
assertEquals("Test content", found.get().getContent());
}
@Test
void testDeleteTextBlock() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
"Test Headline",
"Test content"
);
memoryBlockTextJdbcService.create(textBlock);
memoryBlockTextJdbcService.delete(testBlock.getId());
Optional<MemoryBlockText> found = memoryBlockTextJdbcService.findByBlockId(testBlock.getId());
assertFalse(found.isPresent());
}
@Test
void testCreateTextBlockWithNullHeadline() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
null,
"Content without headline"
);
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
assertNull(created.getHeadline());
assertEquals("Content without headline", created.getContent());
}
@Test
void testCreateTextBlockWithNullContent() {
MemoryBlockText textBlock = new MemoryBlockText(
testBlock.getId(),
"Headline only",
null
);
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
assertEquals("Headline only", created.getHeadline());
assertNull(created.getContent());
}
}

View File

@@ -0,0 +1,106 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.memory.*;
import com.dedicatedcode.reitti.model.security.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@IntegrationTest
public class MemoryClusterBlockRepositoryTest {
@Autowired
private MemoryClusterBlockRepository repository;
@Autowired
private MemoryJdbcService memoryJdbcService;
@Autowired
private MemoryBlockJdbcService memoryBlockJdbcService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestingService testingService;
private User user;
@BeforeEach
void setUp() {
user = testingService.randomUser();
}
@Test
public void testSaveAndFindByBlockId() {
// Given
List<Long> tripIds = List.of(1L, 2L, 3L);
Memory memory = this.memoryJdbcService.create(user,
new Memory("Test",
"Test description",
Instant.parse("2007-12-03T10:15:30.00Z"),
Instant.parse("2007-12-03T11:15:30.00Z"),
HeaderType.MAP,
null));
MemoryBlock memoryBlock = memoryBlockJdbcService.create(new MemoryBlock(memory.getId(), BlockType.CLUSTER_TRIP, 0));
MemoryClusterBlock cluster = new MemoryClusterBlock(memoryBlock.getId(), tripIds, "Journey to Airport", "A trip from home to the airport", BlockType.CLUSTER_TRIP);
// When
repository.save(user, cluster);
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, memoryBlock.getId());
// Then
assertThat(found).isPresent();
assertThat(found.get().getBlockId()).isEqualTo(memoryBlock.getId());
assertThat(found.get().getPartIds()).isEqualTo(tripIds);
assertThat(found.get().getTitle()).isEqualTo("Journey to Airport");
assertThat(found.get().getDescription()).isEqualTo("A trip from home to the airport");
}
@Test
public void testDeleteByBlockId() {
// Given
Memory memory = this.memoryJdbcService.create(user,
new Memory("Test",
"Test description",
Instant.parse("2007-12-03T10:15:30.00Z"),
Instant.parse("2007-12-03T11:15:30.00Z"),
HeaderType.MAP,
null));
MemoryBlock memoryBlock = memoryBlockJdbcService.create(new MemoryBlock(memory.getId(), BlockType.CLUSTER_TRIP, 0));
List<Long> tripIds = List.of(4L, 5L);
MemoryClusterBlock cluster = new MemoryClusterBlock(memoryBlock.getId(), tripIds, "Another Journey", "Description", BlockType.CLUSTER_TRIP);
repository.save(user, cluster);
// When
repository.deleteByBlockId(user, 101L);
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, 101L);
// Then
assertThat(found).isEmpty();
}
@Test
public void testFindByBlockIdNotFound() {
// When
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, 999L);
// Then
assertThat(found).isEmpty();
}
}

View File

@@ -0,0 +1,280 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.memory.HeaderType;
import com.dedicatedcode.reitti.model.memory.Memory;
import com.dedicatedcode.reitti.model.security.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class MemoryJdbcServiceTest {
@Autowired
private MemoryJdbcService memoryJdbcService;
@Autowired
private TestingService testingService;
@Autowired
private JdbcTemplate jdbcTemplate;
private User testUser;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM memory");
testUser = testingService.randomUser();
}
@Test
void testCreateMemory() {
Memory memory = new Memory(
"Test Memory",
"Test Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
assertNotNull(created.getId());
assertEquals("Test Memory", created.getTitle());
assertEquals("Test Description", created.getDescription());
assertEquals(LocalDate.of(2024, 1, 1), created.getStartDate());
assertEquals(LocalDate.of(2024, 1, 7), created.getEndDate());
assertEquals(HeaderType.MAP, created.getHeaderType());
assertNull(created.getHeaderImageUrl());
assertNotNull(created.getCreatedAt());
assertNotNull(created.getUpdatedAt());
assertEquals(1L, created.getVersion());
}
@Test
void testCreateMemoryWithImage() {
Memory memory = new Memory(
"Image Memory",
"Description",
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 2, 5).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.IMAGE,
"https://example.com/image.jpg"
);
Memory created = memoryJdbcService.create(testUser, memory);
assertNotNull(created.getId());
assertEquals(HeaderType.IMAGE, created.getHeaderType());
assertEquals("https://example.com/image.jpg", created.getHeaderImageUrl());
}
@Test
void testUpdateMemory() {
Memory memory = new Memory(
"Original Title",
"Original Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
Memory updated = created
.withTitle("Updated Title")
.withDescription("Updated Description")
.withHeaderType(HeaderType.IMAGE)
.withHeaderImageUrl("https://example.com/new-image.jpg");
Memory result = memoryJdbcService.update(testUser, updated);
assertEquals("Updated Title", result.getTitle());
assertEquals("Updated Description", result.getDescription());
assertEquals(HeaderType.IMAGE, result.getHeaderType());
assertEquals("https://example.com/new-image.jpg", result.getHeaderImageUrl());
assertEquals(2L, result.getVersion());
}
@Test
void testUpdateMemoryWithWrongVersion() {
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
Memory withWrongVersion = created.withVersion(999L).withTitle("Updated");
assertThrows(IllegalStateException.class, () -> {
memoryJdbcService.update(testUser, withWrongVersion);
});
}
@Test
void testDeleteMemory() {
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
memoryJdbcService.delete(testUser, created.getId());
Optional<Memory> found = memoryJdbcService.findById(testUser, created.getId());
assertFalse(found.isPresent());
}
@Test
void testFindById() {
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
Optional<Memory> found = memoryJdbcService.findById(testUser, created.getId());
assertTrue(found.isPresent());
assertEquals(created.getId(), found.get().getId());
assertEquals("Test Memory", found.get().getTitle());
}
@Test
void testFindByIdDifferentUser() {
User otherUser = testingService.randomUser();
Memory memory = new Memory(
"Test Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory created = memoryJdbcService.create(testUser, memory);
Optional<Memory> found = memoryJdbcService.findById(otherUser, created.getId());
assertFalse(found.isPresent());
}
@Test
void testFindAllByUser() {
Memory memory1 = new Memory(
"Memory 1",
"Description 1",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory memory2 = new Memory(
"Memory 2",
"Description 2",
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 2, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.IMAGE,
"https://example.com/image.jpg"
);
memoryJdbcService.create(testUser, memory1);
memoryJdbcService.create(testUser, memory2);
List<Memory> memories = memoryJdbcService.findAllByUser(testUser);
assertEquals(2, memories.size());
}
@Test
void testFindByDateRange() {
Memory memory1 = new Memory(
"January Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory memory2 = new Memory(
"February Memory",
"Description",
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 2, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
Memory memory3 = new Memory(
"March Memory",
"Description",
LocalDate.of(2024, 3, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 3, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
memoryJdbcService.create(testUser, memory1);
memoryJdbcService.create(testUser, memory2);
memoryJdbcService.create(testUser, memory3);
List<Memory> memories = memoryJdbcService.findByDateRange(
testUser,
LocalDate.of(2024, 1, 15).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 2, 15).atStartOfDay().toInstant(ZoneOffset.UTC)
);
assertEquals(2, memories.size());
assertTrue(memories.stream().anyMatch(m -> m.getTitle().equals("January Memory")));
assertTrue(memories.stream().anyMatch(m -> m.getTitle().equals("February Memory")));
}
@Test
void testFindByDateRangeOverlapping() {
Memory memory = new Memory(
"Long Memory",
"Description",
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 3, 31).atStartOfDay().toInstant(ZoneOffset.UTC),
HeaderType.MAP,
null
);
memoryJdbcService.create(testUser, memory);
List<Memory> memories = memoryJdbcService.findByDateRange(
testUser,
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
LocalDate.of(2024, 2, 28).atStartOfDay().toInstant(ZoneOffset.UTC)
);
assertEquals(1, memories.size());
assertEquals("Long Memory", memories.get(0).getTitle());
}
}

View File

@@ -126,6 +126,7 @@ class SignificantPlaceJdbcServiceTest {
created.getId(),
"Updated Name",
"Updated Address",
"Berlin",
"DE",
53.863149,
10.700927,
@@ -210,6 +211,7 @@ class SignificantPlaceJdbcServiceTest {
created1.getId(),
created1.getName(),
"Some Address",
"Berlin",
"DE",
created1.getLatitudeCentroid(),
created1.getLongitudeCentroid(),
@@ -265,6 +267,7 @@ class SignificantPlaceJdbcServiceTest {
name,
null,
null,
null,
latitude,
longitude,
SignificantPlace.PlaceType.OTHER,
@@ -280,6 +283,7 @@ class SignificantPlaceJdbcServiceTest {
name,
null,
null,
null,
latitude,
longitude,
SignificantPlace.PlaceType.OTHER,