diff --git a/docs/development/translations.md b/docs/development/translations.md index 47443e00..9af613fc 100644 --- a/docs/development/translations.md +++ b/docs/development/translations.md @@ -10,7 +10,7 @@ TaskNotes ships with a lightweight i18n system that keeps interface copy separat ## Adding a New Language -1. Duplicate `src/i18n/resources/en.ts` to a new file with the ISO language code, e.g. `de.ts`. +2. Duplicate `src/i18n/resources/en.ts` to a new file with the ISO language code, e.g. `de.ts`. 2. Translate only the string values. Keep keys identical so TypeScript’s type-checking continues to work. 3. Import the new resource in `src/i18n/index.ts` and add it to `translationResources`. 4. (Optional) Add a human-readable label under `common.languages.` so the language shows up nicely in the UI picker. diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index 490f9f61..246750a7 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -110,7 +110,13 @@ export class BasesDataAdapter { return result; } - // DateValue + // DateValue - check for date property (more reliable than constructor check) + if (value.date instanceof Date) { + // Return the date as ISO string for consistency + return value.date.toISOString(); + } + + // DateValue - legacy check with toISOString method if (value.constructor?.name === "DateValue" && value.toISOString) { return value.toISOString(); } diff --git a/src/bases/MiniCalendarView.ts b/src/bases/MiniCalendarView.ts new file mode 100644 index 00000000..38de4382 --- /dev/null +++ b/src/bases/MiniCalendarView.ts @@ -0,0 +1,565 @@ +import { TFile, FuzzySuggestModal, FuzzyMatch, setTooltip } from "obsidian"; +import TaskNotesPlugin from "../main"; +import { BasesViewBase } from "./BasesViewBase"; +import { TaskInfo } from "../types"; +import { format } from "date-fns"; +import { + formatDateForStorage, + getTodayLocal, + createUTCDateFromLocalCalendarDate, + convertUTCToLocalCalendarDate, + createSafeUTCDate, + parseDateToUTC, + getDatePart, +} from "../utils/dateUtils"; +import { isSameDay } from "../utils/helpers"; + +interface NoteEntry { + file: TFile; + title: string; + path: string; + dateValue: string; // The date string from the property + basesEntry?: any; // Reference to Bases entry for additional data +} + +export class MiniCalendarView extends BasesViewBase { + type = "tasknotesMiniCalendar"; + private calendarEl: HTMLElement | null = null; + private basesViewContext?: any; + + // View options + private dateProperty: string | null = null; // e.g., "note.dueDate", "file.ctime", "note.scheduled" + private displayedMonth: number; + private displayedYear: number; + private selectedDate: Date; // UTC-anchored + + // Data + private notesByDate: Map = new Map(); + private monthCalculationCache: Map = new Map(); + + constructor(controller: any, containerEl: HTMLElement, plugin: TaskNotesPlugin) { + super(controller, containerEl, plugin); + + // Initialize with today + const todayLocal = getTodayLocal(); + const todayUTC = createUTCDateFromLocalCalendarDate(todayLocal); + this.selectedDate = todayUTC; + this.displayedMonth = todayUTC.getUTCMonth(); + this.displayedYear = todayUTC.getUTCFullYear(); + } + + setBasesViewContext(context: any): void { + this.basesViewContext = context; + (this.dataAdapter as any).basesView = context; + + // Read view options + this.readViewOptions(); + } + + private readViewOptions(): void { + if (!this.basesViewContext?.config) return; + + const config = this.basesViewContext.config; + if (typeof config.get !== 'function') return; + + try { + this.dateProperty = config.get('dateProperty') || 'file.ctime'; + } catch (e) { + console.error("[TaskNotes][MiniCalendarView] Error reading view options:", e); + } + } + + async render(): Promise { + if (!this.calendarEl || !this.rootElement) return; + if (!this.basesViewContext?.data?.data) return; + + try { + // Clear calendar + this.calendarEl.empty(); + + // Use raw Bases data (has getValue() method) + const basesEntries = this.basesViewContext.data.data; + + // Index notes by date + this.indexNotesByDate(basesEntries); + + // Render calendar grid + this.renderCalendarControls(); + this.renderCalendarGrid(); + } catch (error: any) { + console.error("[TaskNotes][MiniCalendarView] Error rendering:", error); + this.renderError(error); + } + } + + private indexNotesByDate(dataItems: any[]): void { + this.notesByDate.clear(); + + if (!this.dateProperty) { + return; + } + + for (const item of dataItems) { + try { + const file = item.file; + if (!file) continue; + + // Get date value from the configured property + const dateValue = this.getDateValueFromProperty(item, this.dateProperty); + if (!dateValue) continue; + + // Extract date part (handles both date-only and datetime strings) + const dateKey = getDatePart(dateValue); + if (!dateKey) continue; + + // Create note entry + const noteEntry: NoteEntry = { + file: file, + title: file.basename || file.name, + path: file.path, + dateValue: dateValue, + basesEntry: item, + }; + + // Add to map + if (!this.notesByDate.has(dateKey)) { + this.notesByDate.set(dateKey, []); + } + this.notesByDate.get(dateKey)!.push(noteEntry); + } catch (error) { + console.warn("[TaskNotes][MiniCalendarView] Error indexing note:", error); + } + } + } + + private getDateValueFromProperty(item: any, propertyId: string): string | null { + try { + // Use BasesDataAdapter to get the property value (handles all Bases Value types) + const value = this.dataAdapter.getPropertyValue(item, propertyId); + + if (!value) { + return null; + } + + // Now we have a native JavaScript value + let dateString: string | null = null; + + // Handle different value types + if (typeof value === 'string') { + // Could be an ISO date string, date-only string, or filename + // First, try to parse as date + const testDate = new Date(value); + if (!isNaN(testDate.getTime())) { + dateString = getDatePart(value); + } else { + // Maybe it's a filename with embedded date + const dateMatch = value.match(/(\d{4}-\d{2}-\d{2})/); + if (dateMatch) { + dateString = dateMatch[1]; + } + } + } else if (typeof value === 'number') { + // Unix timestamp + const date = new Date(value); + if (!isNaN(date.getTime())) { + dateString = format(date, "yyyy-MM-dd"); + } + } else if (value instanceof Date) { + // Direct Date object + if (!isNaN(value.getTime())) { + dateString = format(value, "yyyy-MM-dd"); + } + } + + return dateString; + } catch (error) { + console.warn("[TaskNotes][MiniCalendarView] Error getting date value:", error); + return null; + } + } + + private renderCalendarControls(): void { + const controlsContainer = this.calendarEl!.createDiv({ cls: "mini-calendar-view__controls" }); + const headerContainer = controlsContainer.createDiv({ cls: "mini-calendar-view__header" }); + + // Navigation section + const navSection = headerContainer.createDiv({ cls: "mini-calendar-view__navigation" }); + + // Previous month button + const prevButton = navSection.createEl("button", { + text: "‹", + cls: "mini-calendar-view__nav-button mini-calendar-view__nav-button--prev tn-btn tn-btn--icon tn-btn--ghost", + attr: { + "aria-label": "Previous month", + title: "Previous month", + }, + }); + prevButton.addEventListener("click", () => this.navigateToPreviousMonth()); + + // Current month display + navSection.createDiv({ + cls: "mini-calendar-view__month-display", + text: format(convertUTCToLocalCalendarDate(this.selectedDate), "MMMM yyyy"), + }); + + // Next month button + const nextButton = navSection.createEl("button", { + text: "›", + cls: "mini-calendar-view__nav-button mini-calendar-view__nav-button--next tn-btn tn-btn--icon tn-btn--ghost", + attr: { + "aria-label": "Next month", + title: "Next month", + }, + }); + nextButton.addEventListener("click", () => this.navigateToNextMonth()); + + // Today button + const todayButton = headerContainer.createEl("button", { + text: "Today", + cls: "mini-calendar-view__today-button tn-btn tn-btn--ghost tn-btn--sm", + attr: { + "aria-label": "Go to today", + title: "Go to today", + }, + }); + todayButton.addEventListener("click", () => this.navigateToToday()); + } + + private renderCalendarGrid(): void { + const gridContainer = this.calendarEl!.createDiv({ cls: "mini-calendar-view__grid-container" }); + + // Get current month/year from displayed date + const currentMonth = this.displayedMonth; + const currentYear = this.displayedYear; + + // Get first and last day of month + const firstDayOfMonth = new Date(Date.UTC(currentYear, currentMonth, 1)); + const lastDayOfMonth = new Date(Date.UTC(currentYear, currentMonth + 1, 0)); + + const firstDaySetting = this.plugin.settings.calendarViewSettings.firstDay || 0; + const firstDayOfWeek = (firstDayOfMonth.getUTCDay() - firstDaySetting + 7) % 7; + + // Create calendar grid + const calendarGrid = gridContainer.createDiv({ + cls: "mini-calendar-view__grid", + attr: { + role: "grid", + "aria-label": `Calendar for ${format(convertUTCToLocalCalendarDate(new Date(Date.UTC(currentYear, currentMonth, 1))), "MMMM yyyy")}`, + }, + }); + + // Day names header + const calendarHeader = calendarGrid.createDiv({ + cls: "mini-calendar-view__grid-header", + attr: { role: "row" }, + }); + + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const reorderedDayNames = [ + ...dayNames.slice(firstDaySetting), + ...dayNames.slice(0, firstDaySetting), + ]; + + reorderedDayNames.forEach((dayName) => { + calendarHeader.createDiv({ + text: dayName, + cls: "mini-calendar-view__day-header", + attr: { role: "columnheader", "aria-label": dayName }, + }); + }); + + // Calculate grid layout + const daysFromPrevMonth = firstDayOfWeek; + const totalCells = 42; // 6 rows of 7 days + const daysThisMonth = lastDayOfMonth.getUTCDate(); + const daysFromNextMonth = totalCells - daysThisMonth - daysFromPrevMonth; + const lastDayOfPrevMonth = new Date(Date.UTC(currentYear, currentMonth, 0)).getUTCDate(); + + // Today for comparison + const todayLocal = getTodayLocal(); + const today = createUTCDateFromLocalCalendarDate(todayLocal); + + // Render days + let currentWeekRow = calendarGrid.createDiv({ + cls: "mini-calendar-view__week", + attr: { role: "row" }, + }); + + // Previous month days + for (let i = 0; i < daysFromPrevMonth; i++) { + const dayNum = lastDayOfPrevMonth - daysFromPrevMonth + i + 1; + const dayDate = new Date(Date.UTC(currentYear, currentMonth - 1, dayNum)); + this.renderDay(currentWeekRow, dayDate, dayNum, true); + } + + // Current month days + for (let i = 1; i <= daysThisMonth; i++) { + if ((i + daysFromPrevMonth - 1) % 7 === 0 && i > 1) { + currentWeekRow = calendarGrid.createDiv({ + cls: "mini-calendar-view__week", + attr: { role: "row" }, + }); + } + + const dayDate = new Date(Date.UTC(currentYear, currentMonth, i)); + this.renderDay(currentWeekRow, dayDate, i, false); + } + + // Next month days + for (let i = 1; i <= daysFromNextMonth; i++) { + if ((i + daysFromPrevMonth + daysThisMonth - 1) % 7 === 0 && i > 1) { + currentWeekRow = calendarGrid.createDiv({ + cls: "mini-calendar-view__week", + attr: { role: "row" }, + }); + } + + const dayDate = new Date(Date.UTC(currentYear, currentMonth + 1, i)); + this.renderDay(currentWeekRow, dayDate, i, true); + } + } + + private renderDay(weekRow: HTMLElement, dayDate: Date, dayNum: number, isOutsideMonth: boolean): void { + const todayLocal = getTodayLocal(); + const today = createUTCDateFromLocalCalendarDate(todayLocal); + + const isToday = isSameDay(dayDate, today); + const isSelected = isSameDay(dayDate, this.selectedDate); + + let classNames = "mini-calendar-view__day"; + if (isToday) classNames += " mini-calendar-view__day--today"; + if (isSelected) classNames += " mini-calendar-view__day--selected"; + if (isOutsideMonth) classNames += " mini-calendar-view__day--outside-month"; + + const dayEl = weekRow.createDiv({ + cls: classNames, + text: dayNum.toString(), + attr: { + role: "gridcell", + tabindex: isSelected ? "0" : "-1", + "aria-label": format(convertUTCToLocalCalendarDate(dayDate), "EEEE, MMMM d, yyyy") + (isToday ? " (Today)" : ""), + "aria-selected": isSelected ? "true" : "false", + "aria-current": isToday ? "date" : null, + }, + }); + + // Add dot indicator if notes exist for this date + const dateKey = formatDateForStorage(dayDate); + const notesForDay = this.notesByDate.get(dateKey); + + if (notesForDay && notesForDay.length > 0) { + const indicator = dayEl.createDiv({ cls: "note-indicator" }); + + // Style based on count + if (notesForDay.length >= 5) { + indicator.addClass("many-notes"); + dayEl.addClass("mini-calendar-view__day--has-notes-many"); + } else if (notesForDay.length >= 2) { + indicator.addClass("some-notes"); + dayEl.addClass("mini-calendar-view__day--has-notes-some"); + } else { + indicator.addClass("few-notes"); + dayEl.addClass("mini-calendar-view__day--has-notes-few"); + } + + setTooltip(indicator, `${notesForDay.length} note${notesForDay.length > 1 ? "s" : ""}`, { + placement: "top", + }); + } + + // Click handler - select date or show fuzzy selector + dayEl.addEventListener("click", () => { + this.handleDayClick(dayDate); + }); + + // Keyboard handler + dayEl.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this.handleDayClick(dayDate); + } + }); + } + + private handleDayClick(date: Date): void { + // Update selected date + this.selectedDate = date; + + // Check if date has notes + const dateKey = formatDateForStorage(date); + const notesForDay = this.notesByDate.get(dateKey); + + if (notesForDay && notesForDay.length > 0) { + // Show fuzzy selector with notes + const modal = new NoteSelectionModal( + this.plugin.app, + this.plugin, + notesForDay, + (selectedNote) => { + if (selectedNote) { + // Open the selected note + this.plugin.app.workspace.getLeaf(false).openFile(selectedNote.file); + } + } + ); + modal.open(); + } else { + // Just update selection visually + this.refresh(); + } + } + + private navigateToPreviousMonth(): void { + const newDate = new Date(this.selectedDate.getTime()); + newDate.setUTCMonth(this.selectedDate.getUTCMonth() - 1); + this.selectedDate = newDate; + this.displayedMonth = newDate.getUTCMonth(); + this.displayedYear = newDate.getUTCFullYear(); + this.monthCalculationCache.clear(); + this.refresh(); + } + + private navigateToNextMonth(): void { + const newDate = new Date(this.selectedDate.getTime()); + newDate.setUTCMonth(this.selectedDate.getUTCMonth() + 1); + this.selectedDate = newDate; + this.displayedMonth = newDate.getUTCMonth(); + this.displayedYear = newDate.getUTCFullYear(); + this.monthCalculationCache.clear(); + this.refresh(); + } + + private navigateToToday(): void { + const todayLocal = getTodayLocal(); + const todayUTC = createUTCDateFromLocalCalendarDate(todayLocal); + this.selectedDate = todayUTC; + this.displayedMonth = todayUTC.getUTCMonth(); + this.displayedYear = todayUTC.getUTCFullYear(); + this.monthCalculationCache.clear(); + this.refresh(); + } + + protected setupContainer(): void { + super.setupContainer(); + + const calendar = document.createElement("div"); + calendar.className = "mini-calendar-bases-view"; + this.rootElement?.appendChild(calendar); + this.calendarEl = calendar; + } + + protected async handleTaskUpdate(task: TaskInfo): Promise { + // For mini calendar, refresh on any data change + this.debouncedRefresh(); + } + + private renderError(error: Error): void { + if (!this.calendarEl) return; + + const errorEl = document.createElement("div"); + errorEl.className = "tn-bases-error"; + errorEl.style.cssText = + "padding: 20px; color: #d73a49; background: #ffeaea; border-radius: 4px; margin: 10px 0;"; + errorEl.textContent = `Error loading mini calendar: ${error.message || "Unknown error"}`; + this.calendarEl.appendChild(errorEl); + } + + protected cleanup(): void { + super.cleanup(); + this.calendarEl = null; + this.notesByDate.clear(); + this.monthCalculationCache.clear(); + } +} + +// Fuzzy selector modal for notes +class NoteSelectionModal extends FuzzySuggestModal { + private notes: NoteEntry[]; + private onChooseNote: (note: NoteEntry | null) => void; + private plugin: TaskNotesPlugin; + + constructor( + app: any, + plugin: TaskNotesPlugin, + notes: NoteEntry[], + onChooseNote: (note: NoteEntry | null) => void + ) { + super(app); + this.plugin = plugin; + this.notes = notes; + this.onChooseNote = onChooseNote; + + this.setPlaceholder("Select a note to open"); + this.setInstructions([ + { command: "↑↓", purpose: "Navigate" }, + { command: "↵", purpose: "Open note" }, + { command: "esc", purpose: "Dismiss" }, + ]); + } + + getItems(): NoteEntry[] { + // Sort by title + return this.notes.sort((a, b) => a.title.localeCompare(b.title)); + } + + getItemText(note: NoteEntry): string { + return note.title; + } + + renderSuggestion(item: FuzzyMatch, el: HTMLElement): void { + const note = item.item; + const container = el.createDiv({ cls: "note-selector-modal__suggestion" }); + + // Title + container.createDiv({ + cls: "note-selector-modal__title", + text: note.title, + }); + + // Path (if not same as title) + if (note.path !== note.title) { + container.createDiv({ + cls: "note-selector-modal__path", + text: note.path, + }); + } + } + + onChooseItem(item: NoteEntry, evt: MouseEvent | KeyboardEvent): void { + this.onChooseNote(item); + } +} + +// Factory function +export function buildMiniCalendarViewFactory(plugin: TaskNotesPlugin) { + return function (basesContainer: any, containerEl?: HTMLElement) { + const viewContainerEl = containerEl || basesContainer.viewContainerEl; + const controller = basesContainer; + + if (!viewContainerEl) { + console.error("[TaskNotes][MiniCalendarView] No viewContainerEl found"); + return { destroy: () => {} } as any; + } + + const view = new MiniCalendarView(controller, viewContainerEl, plugin); + + return { + load: () => view.load(), + unload: () => view.unload(), + refresh() { view.render(); }, + onDataUpdated: function(this: any) { + view.setBasesViewContext(this); + view.onDataUpdated(); + }, + onResize: () => { + // Handle resize if needed + }, + getEphemeralState: () => view.getEphemeralState(), + setEphemeralState: (state: any) => view.setEphemeralState(state), + focus: () => view.focus(), + destroy() { + view.unload(); + }, + }; + }; +} diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 87465e42..06f626b0 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -4,6 +4,7 @@ import { requireApiVersion } from "obsidian"; import { buildTaskListViewFactory } from "./TaskListView"; import { buildKanbanViewFactory } from "./KanbanView"; import { buildCalendarViewFactory } from "./CalendarView"; +import { buildMiniCalendarViewFactory } from "./MiniCalendarView"; import { registerBasesView, unregisterBasesView } from "./api"; /** @@ -385,8 +386,28 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise [ + { + type: "property", + key: "dateProperty", + displayName: "Date Property", + placeholder: "Select property to show on calendar", + default: "file.ctime", + filter: (prop: string) => { + // Show date-type properties from all sources + return prop.startsWith("note.") || prop.startsWith("file.") || prop.startsWith("task."); + }, + }, + ], + }); + // Consider it successful if any view registered successfully - if (!taskListSuccess && !kanbanSuccess && !calendarSuccess) { + if (!taskListSuccess && !kanbanSuccess && !calendarSuccess && !miniCalendarSuccess) { console.debug("[TaskNotes][Bases] Bases plugin not available for registration"); return false; } @@ -440,6 +461,7 @@ export function unregisterBasesViews(plugin: TaskNotesPlugin): void { unregisterBasesView(plugin, "tasknotesTaskList"); unregisterBasesView(plugin, "tasknotesKanban"); unregisterBasesView(plugin, "tasknotesCalendar"); + unregisterBasesView(plugin, "tasknotesMiniCalendar"); } catch (error) { console.error("[TaskNotes][Bases] Error during view unregistration:", error); }