{{ note().title }}
@if (note().tags.length > 0) {
diff --git a/src/services/markdown-calendar.service.ts b/src/services/markdown-calendar.service.ts
index a50e687..857397c 100644
--- a/src/services/markdown-calendar.service.ts
+++ b/src/services/markdown-calendar.service.ts
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { Observable, of, shareReplay, finalize, map } from 'rxjs';
import { FileMetadata } from '../types';
@Injectable({
@@ -9,23 +9,48 @@ import { FileMetadata } from '../types';
export class MarkdownCalendarService {
private readonly http = inject(HttpClient);
- getFilesMetadata(): Observable {
- return this.http.get('/api/files/metadata');
+ private metadataCache: FileMetadata[] | null = null;
+ private metadataRequest$: Observable | null = null;
+ private readonly parsedDateCache = new Map();
+
+ getFilesMetadata(forceRefresh = false): Observable {
+ if (!forceRefresh && this.metadataCache) {
+ return of(this.metadataCache);
+ }
+
+ if (!forceRefresh && this.metadataRequest$) {
+ return this.metadataRequest$;
+ }
+
+ const request$ = this.http
+ .get('/api/files/metadata')
+ .pipe(
+ map((metadata) => {
+ this.metadataCache = metadata;
+ this.metadataRequest$ = null;
+ this.parsedDateCache.clear();
+ return metadata;
+ }),
+ finalize(() => {
+ this.metadataRequest$ = null;
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ this.metadataRequest$ = request$;
+ return request$;
}
searchFilesByDate(date: Date): Observable {
- return this.http.get('/api/files/by-date', {
- params: { date: date.toISOString() },
- });
+ const start = this.startOfDay(date);
+ const end = this.endOfDay(date);
+ return this.getFilesMetadata().pipe(map((metadata) => this.filterByRange(metadata, start, end)));
}
searchFilesByDateRange(startDate: Date, endDate: Date): Observable {
- return this.http.get('/api/files/by-date-range', {
- params: {
- start: startDate.toISOString(),
- end: endDate.toISOString(),
- },
- });
+ const rangeStart = this.startOfDay(startDate);
+ const rangeEnd = this.endOfDay(endDate);
+ return this.getFilesMetadata().pipe(map((metadata) => this.filterByRange(metadata, rangeStart, rangeEnd)));
}
groupMetadataByDay(metadata: FileMetadata[]): Map {
@@ -66,4 +91,48 @@ export class MarkdownCalendarService {
private appendUnique(list: FileMetadata[], file: FileMetadata): FileMetadata[] {
return list.some((item) => item.id === file.id) ? list : list.concat(file);
}
+
+ private filterByRange(metadata: FileMetadata[], start: Date, end: Date): FileMetadata[] {
+ const startTime = start.getTime();
+ const endTime = end.getTime();
+
+ return metadata.filter((file) => this.matchesRange(file, startTime, endTime));
+ }
+
+ private matchesRange(file: FileMetadata, startTime: number, endTime: number): boolean {
+ const created = this.getTimeValue(file.createdAt);
+ if (created !== null && created >= startTime && created <= endTime) {
+ return true;
+ }
+
+ const updated = this.getTimeValue(file.updatedAt);
+ return updated !== null && updated >= startTime && updated <= endTime;
+ }
+
+ private getTimeValue(value: string | undefined | null): number | null {
+ if (!value) {
+ return null;
+ }
+
+ if (this.parsedDateCache.has(value)) {
+ return this.parsedDateCache.get(value) ?? null;
+ }
+
+ const time = Date.parse(value);
+ const normalized = Number.isNaN(time) ? null : time;
+ this.parsedDateCache.set(value, normalized);
+ return normalized;
+ }
+
+ private startOfDay(date: Date): Date {
+ const start = new Date(date);
+ start.setHours(0, 0, 0, 0);
+ return start;
+ }
+
+ private endOfDay(date: Date): Date {
+ const end = new Date(date);
+ end.setHours(23, 59, 59, 999);
+ return end;
+ }
}
diff --git a/src/services/vault-events.service.ts b/src/services/vault-events.service.ts
new file mode 100644
index 0000000..3dbb842
--- /dev/null
+++ b/src/services/vault-events.service.ts
@@ -0,0 +1,102 @@
+import { Injectable, NgZone, OnDestroy } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+
+export interface VaultEventPayload {
+ event: string;
+ path?: string;
+ timestamp: number;
+ message?: string;
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class VaultEventsService implements OnDestroy {
+ private readonly eventsSubject = new Subject();
+ private eventSource: EventSource | null = null;
+ private reconnectTimer: number | null = null;
+ private readonly reconnectDelayMs = 5000;
+
+ constructor(private readonly zone: NgZone) {}
+
+ events$(): Observable {
+ this.ensureConnection();
+ return this.eventsSubject.asObservable();
+ }
+
+ private ensureConnection(): void {
+ if (this.eventSource || typeof window === 'undefined' || typeof EventSource === 'undefined') {
+ return;
+ }
+
+ this.zone.runOutsideAngular(() => {
+ const source = new EventSource('/api/vault/events');
+ this.eventSource = source;
+
+ source.onmessage = (event: MessageEvent) => {
+ this.handleMessage(event.data);
+ };
+
+ source.onerror = () => {
+ // Let the browser attempt to reconnect automatically, but make sure we clean up references.
+ this.scheduleReconnect();
+ };
+ });
+ }
+
+ private handleMessage(rawData: string): void {
+ if (!rawData) {
+ return;
+ }
+
+ let payload: VaultEventPayload | null = null;
+ try {
+ payload = JSON.parse(rawData) as VaultEventPayload;
+ } catch (error) {
+ console.error('Failed to parse vault event payload:', error);
+ return;
+ }
+
+ if (!payload || typeof payload.event !== 'string') {
+ return;
+ }
+
+ this.zone.run(() => {
+ this.eventsSubject.next(payload!);
+ });
+ }
+
+ private scheduleReconnect(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+
+ if (this.reconnectTimer !== null) {
+ return;
+ }
+
+ this.reconnectTimer = window.setTimeout(() => {
+ this.reconnectTimer = null;
+ this.ensureConnection();
+ }, this.reconnectDelayMs);
+ }
+
+ ngOnDestroy(): void {
+ if (this.reconnectTimer !== null && typeof window !== 'undefined') {
+ window.clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+
+ this.eventsSubject.complete();
+ }
+}
diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts
index b296cff..1350ac9 100644
--- a/src/services/vault.service.ts
+++ b/src/services/vault.service.ts
@@ -1,6 +1,8 @@
-import { Injectable, signal, computed } from '@angular/core';
+import { Injectable, signal, computed, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types';
+import { VaultEventsService, VaultEventPayload } from './vault-events.service';
+import { Subscription } from 'rxjs';
interface VaultApiNote {
id: string;
@@ -22,7 +24,7 @@ interface VaultApiResponse {
@Injectable({
providedIn: 'root'
})
-export class VaultService {
+export class VaultService implements OnDestroy {
private notesMap = signal