feat: add recurring notes and drag-and-drop reordering

- Implement recurring notes feature with interval selection
- Add drag-and-drop reordering with position persistence
- Update data model to support recurring notes
- Add UI controls for recurrence settings
- Implement position tracking for drag-and-drop
- Align recurring note colors with other button backgrounds

Recurring notes can be toggled via button or context menu, show visual
indicators, and automatically schedule next occurrences. Drag-and-drop
reordering persists the new order across sessions using existing
customNoteOrder system.
This commit is contained in:
dralkh 2026-02-15 10:05:34 +03:00
parent 6c615f9914
commit 7ac79ae05f
8 changed files with 533 additions and 69 deletions

View file

@ -25,6 +25,7 @@ import { ReviewSessionService } from './services/review-session-service';
import { MCQService } from './services/mcq-service';
import { PomodoroService } from './services/pomodoro-service';
import { CalendarEventService } from './services/calendar-event-service';
import { RecurringNotesService } from './services/recurring-notes-service';
/**
* Spaceforge: Spaced Repetition Plugin for Obsidian
@ -44,6 +45,7 @@ export default class SpaceforgePlugin extends Plugin {
mcqService: MCQService;
pomodoroService: PomodoroService;
calendarEventService: CalendarEventService;
recurringNotesService: RecurringNotesService;
private readonly stylesheetPath: string = "styles.css";
@ -99,6 +101,9 @@ export default class SpaceforgePlugin extends Plugin {
const eventsArray = Object.values(this.pluginState.calendarEvents);
this.calendarEventService.initialize(eventsArray);
// Initialize RecurringNotesService after settings are loaded
this.recurringNotesService = new RecurringNotesService(this);

View file

@ -65,6 +65,12 @@ export interface ReviewSchedule {
};
schedulingAlgorithm: 'sm2' | 'fsrs'; // Determines which algo rules apply
// Recurring notes settings
isRecurring?: boolean;
recurrenceInterval?: number; // Interval in days for recurring notes
recurrenceEndDate?: number | null; // Optional end date for recurrence (timestamp)
originalSchedule?: ReviewSchedule | null; // Store original schedule for reference
}
// Removed INITIAL_INTERVALS, isInitialPhase, and getInitialInterval.

View file

@ -335,6 +335,18 @@ export interface SpaceforgeSettings {
* Default: 'personal'
*/
defaultEventCategory: string;
/**
* Enable recurring notes feature
* Default: false
*/
enableRecurringNotes: boolean;
/**
* Default recurrence interval for new recurring notes (in days)
* Default: 7
*/
defaultRecurrenceInterval: number;
}
/**
@ -434,4 +446,8 @@ export const DEFAULT_SETTINGS: SpaceforgeSettings = {
showUpcomingEvents: true,
upcomingEventsDays: 7,
defaultEventCategory: 'personal',
// Recurring Notes Defaults
enableRecurringNotes: false,
defaultRecurrenceInterval: 7,
};

View file

@ -0,0 +1,195 @@
import SpaceforgePlugin from '../main';
import { ReviewSchedule } from '../models/review-schedule';
import { DateUtils } from '../utils/dates';
/**
* Service for managing recurring notes functionality
*/
export class RecurringNotesService {
plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Convert a regular note to a recurring note
* @param path Path to the note file
* @param interval Recurrence interval in days
* @param endDate Optional end date for recurrence (timestamp)
*/
async convertToRecurringNote(path: string, interval: number, endDate: number | null = null): Promise<void> {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule) {
console.warn(`No schedule found for note: ${path}`);
return;
}
// Store the original schedule before making it recurring
const originalSchedule: ReviewSchedule = {
...schedule,
isRecurring: false,
recurrenceInterval: undefined,
recurrenceEndDate: undefined,
originalSchedule: null
};
// Update the schedule to be recurring
schedule.isRecurring = true;
schedule.recurrenceInterval = interval;
schedule.recurrenceEndDate = endDate;
schedule.originalSchedule = originalSchedule;
// Schedule the next occurrence
await this.scheduleNextRecurrence(path);
await this.plugin.savePluginData();
}
/**
* Schedule the next occurrence of a recurring note
* @param path Path to the note file
*/
async scheduleNextRecurrence(path: string): Promise<void> {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule || !schedule.isRecurring || !schedule.recurrenceInterval) {
return;
}
const now = Date.now();
const nextReviewDate = schedule.nextReviewDate || now;
// Calculate the next occurrence based on the recurrence interval
const nextOccurrenceDate = nextReviewDate + (schedule.recurrenceInterval * 24 * 60 * 60 * 1000);
// If there's an end date and we've passed it, don't schedule further occurrences
if (schedule.recurrenceEndDate && nextOccurrenceDate > schedule.recurrenceEndDate) {
schedule.isRecurring = false; // Stop recurrence
return;
}
// Update the schedule with the next occurrence date
schedule.nextReviewDate = nextOccurrenceDate;
schedule.lastReviewDate = nextReviewDate; // Mark as reviewed for this occurrence
await this.plugin.savePluginData();
}
/**
* Convert a recurring note back to a regular note
* @param path Path to the note file
*/
async convertToRegularNote(path: string): Promise<void> {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule || !schedule.isRecurring) {
return;
}
// Restore original schedule if available
if (schedule.originalSchedule) {
const original = schedule.originalSchedule;
schedule.path = original.path;
schedule.lastReviewDate = original.lastReviewDate;
schedule.nextReviewDate = original.nextReviewDate;
schedule.ease = original.ease;
schedule.interval = original.interval;
schedule.consecutive = original.consecutive;
schedule.reviewCount = original.reviewCount;
schedule.repetitionCount = original.repetitionCount;
schedule.scheduleCategory = original.scheduleCategory;
schedule.fsrsData = original.fsrsData;
schedule.schedulingAlgorithm = original.schedulingAlgorithm;
}
// Remove recurring properties
schedule.isRecurring = false;
schedule.recurrenceInterval = undefined;
schedule.recurrenceEndDate = undefined;
schedule.originalSchedule = null;
await this.plugin.savePluginData();
}
/**
* Check if a note is recurring
* @param path Path to the note file
* @returns True if the note is recurring
*/
isNoteRecurring(path: string): boolean {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return !!schedule?.isRecurring;
}
/**
* Get recurrence information for a note
* @param path Path to the note file
* @returns Recurrence information or null if not recurring
*/
getRecurrenceInfo(path: string): {
interval: number;
endDate: number | null;
nextOccurrence: number;
} | null {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule?.isRecurring || !schedule.recurrenceInterval) {
return null;
}
return {
interval: schedule.recurrenceInterval,
endDate: schedule.recurrenceEndDate || null,
nextOccurrence: schedule.nextReviewDate
};
}
/**
* Process all recurring notes and schedule their next occurrences
* This should be called periodically (e.g., daily)
*/
async processAllRecurringNotes(): Promise<void> {
const schedules = this.plugin.reviewScheduleService.schedules;
const now = Date.now();
for (const path in schedules) {
const schedule = schedules[path];
if (schedule?.isRecurring && schedule.recurrenceInterval) {
// Check if the note is due for its next occurrence
if (schedule.nextReviewDate <= now) {
await this.scheduleNextRecurrence(path);
}
}
}
await this.plugin.savePluginData();
}
/**
* Update the recurrence interval for a note
* @param path Path to the note file
* @param newInterval New recurrence interval in days
*/
async updateRecurrenceInterval(path: string, newInterval: number): Promise<void> {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule?.isRecurring) {
return;
}
schedule.recurrenceInterval = newInterval;
await this.plugin.savePluginData();
}
/**
* Update the recurrence end date for a note
* @param path Path to the note file
* @param newEndDate New end date timestamp or null for no end date
*/
async updateRecurrenceEndDate(path: string, newEndDate: number | null): Promise<void> {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (!schedule?.isRecurring) {
return;
}
schedule.recurrenceEndDate = newEndDate;
await this.plugin.savePluginData();
}
}

View file

@ -21,6 +21,11 @@
--sf-overdue-accent: #ff8c42; /* Warm orange/amber */
--sf-overdue-bg: rgba(255, 140, 66, 0.08); /* Very light orange tint */
/* Recurring colors - aligned with other button backgrounds */
--sf-recurring-accent: var(--interactive-accent, #5e81ac); /* Use same accent color as other buttons */
--sf-recurring-accent-hover: color-mix(in srgb, var(--interactive-accent, #5e81ac), white 85%); /* Lighter version for hover */
--sf-recurring-bg: rgba(94, 129, 172, 0.05); /* Very light blue tint to match accent */
/* Text colors */
--sf-text: var(--text-normal, #333);
--sf-text-muted: var(--text-muted, #888);

View file

@ -367,6 +367,7 @@
flex-wrap: wrap;
transition: var(--sf-transition);
border: 1px solid transparent;
position: relative;
}
.review-note-item:last-child {
@ -537,6 +538,26 @@
border: 1px dashed var(--sf-primary);
}
.review-note-item.drag-over-above::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--sf-primary);
}
.review-note-item.drag-over-below::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--sf-primary);
}
.review-all-caught-up {
padding: var(--sf-space-md);
text-align: center;
@ -910,3 +931,38 @@
.pomodoro-container.is-idle .pomodoro-timer-display {
opacity: 0.5; /* Dim timer more when idle */
}
/* Recurring Notes Styles */
.review-note-item.recurring-note {
border-left: 3px solid var(--sf-recurring-accent);
background-color: var(--sf-recurring-bg);
}
.recurrence-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 100px;
background-color: var(--sf-recurring-accent);
color: white;
font-weight: 500;
margin-left: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.recurrence-badge::before {
content: "↻";
font-size: 10px;
}
.review-note-button.review-note-recurrence {
background-color: var(--sf-recurring-accent);
color: white;
border-color: var(--sf-recurring-accent);
}
.review-note-button.review-note-recurrence:hover {
background-color: var(--sf-recurring-accent-hover);
border-color: var(--sf-recurring-accent-hover);
}

View file

@ -54,16 +54,19 @@ export class ListViewRenderer {
this.refreshSidebarView = stateAccessors.refreshSidebarView;
}
/**
* Render the list view content into the provided container.
* @param container Container element for list view content
*/
async render(container: HTMLElement): Promise<void> {
// container.empty(); // Clear only the list view content area -- REMOVED
/**
* Render the list view content into the provided container.
* @param container Container element for list view content
*/
async render(container: HTMLElement): Promise<void> {
// container.empty(); // Clear only the list view content area -- REMOVED
const activeListBaseDate = this.getActiveListBaseDate();
const selectedNotes = this.getSelectedNotes();
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
const activeListBaseDate = this.getActiveListBaseDate();
const selectedNotes = this.getSelectedNotes();
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
// Setup drag-and-drop event handlers for the container
this.setupDragAndDrop(container);
// --- Stats Section (REMOVED as per user request) ---
// await this._ensureAndUpdateStatsSection(container, dueNotesForStats);
@ -727,31 +730,125 @@ export class ListViewRenderer {
* Updates the visibility of the bulk action buttons based on selection count.
* (Called by handleSelectionChange and render)
*/
private updateBulkActionButtonsVisibility(container: HTMLElement): void {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector<HTMLElement>('.review-bulk-actions');
private updateBulkActionButtonsVisibility(container: HTMLElement): void {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector<HTMLElement>('.review-bulk-actions');
if (bulkActionsContainer) {
bulkActionsContainer.toggleClass('sf-hidden', selectedNotesPaths.length <= 1);
if (bulkActionsContainer) {
bulkActionsContainer.toggleClass('sf-hidden', selectedNotesPaths.length <= 1);
// Handle visibility/disabled state of "Advance Selected" button
const advanceSelectedBtn = bulkActionsContainer.querySelector<HTMLButtonElement>('.review-bulk-advance');
if (advanceSelectedBtn) {
if (selectedNotesPaths.length > 1) {
const todayStart = DateUtils.startOfUTCDay(new Date()); // Returns timestamp
const hasEligibleFutureNote = selectedNotesPaths.some(path => {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return schedule && DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.toggleClass('sf-hidden', false); // Always show if bulk actions are visible, rely on disabled state
} else {
advanceSelectedBtn.disabled = true;
// advanceSelectedBtn.style.display = 'none'; // Or hide if no selection
}
}
}
}
// Handle visibility/disabled state of "Advance Selected" button
const advanceSelectedBtn = bulkActionsContainer.querySelector<HTMLButtonElement>('.review-bulk-advance');
if (advanceSelectedBtn) {
if (selectedNotesPaths.length > 1) {
const todayStart = DateUtils.startOfUTCDay(new Date()); // Returns timestamp
const hasEligibleFutureNote = selectedNotesPaths.some(path => {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return schedule && DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.toggleClass('sf-hidden', false); // Always show if bulk actions are visible, rely on disabled state
} else {
advanceSelectedBtn.disabled = true;
// advanceSelectedBtn.style.display = 'none'; // Or hide if no selection
}
}
}
}
/**
* Setup drag-and-drop event handlers for the container
*/
private setupDragAndDrop(container: HTMLElement): void {
// Add drag-over event handler
container.addEventListener('dragover', (e) => {
e.preventDefault();
const target = e.target as HTMLElement;
const noteItem = target.closest('.review-note-item') as HTMLElement | null;
if (noteItem) {
// Find the drop target container
const notesContainer = noteItem.closest('.review-notes-container');
if (notesContainer) {
// Find all note items in this container
const noteItems = Array.from(notesContainer.querySelectorAll('.review-note-item')) as HTMLElement[];
// Find the position where we should insert
const targetRect = noteItem.getBoundingClientRect();
const midPoint = targetRect.top + targetRect.height / 2;
// Remove any existing drag-over indicators
noteItems.forEach(item => item.classList.remove('drag-over', 'drag-over-above', 'drag-over-below'));
if (e.clientY < midPoint) {
noteItem.classList.add('drag-over', 'drag-over-above');
} else {
noteItem.classList.add('drag-over', 'drag-over-below');
}
}
}
});
// Add drop event handler
container.addEventListener('drop', async (e) => {
e.preventDefault();
const target = e.target as HTMLElement;
const noteItem = target.closest('.review-note-item') as HTMLElement | null;
const draggedPath = e.dataTransfer?.getData('text/plain');
if (noteItem && draggedPath) {
const notesContainer = noteItem.closest('.review-notes-container');
if (notesContainer) {
// Remove drag-over indicators
const noteItems = Array.from(notesContainer.querySelectorAll('.review-note-item')) as HTMLElement[];
noteItems.forEach(item => item.classList.remove('drag-over', 'drag-over-above', 'drag-over-below'));
// Find the target note path
const targetPath = noteItem.dataset.notePath;
if (targetPath && draggedPath !== targetPath) {
// Get the current custom order
const currentOrder = [...this.plugin.reviewScheduleService.customNoteOrder];
// Find the positions of the dragged and target notes
const draggedIndex = currentOrder.indexOf(draggedPath);
const targetIndex = currentOrder.indexOf(targetPath);
if (draggedIndex !== -1 && targetIndex !== -1) {
// Remove the dragged note from its current position
currentOrder.splice(draggedIndex, 1);
// Check if we're dropping above or below the target
const targetRect = noteItem.getBoundingClientRect();
const midPoint = targetRect.top + targetRect.height / 2;
const isAbove = e.clientY < midPoint;
// Insert the dragged note at the new position
const insertIndex = isAbove ? targetIndex : targetIndex + 1;
currentOrder.splice(insertIndex, 0, draggedPath);
// Update the custom order
await this.plugin.reviewScheduleService.updateCustomNoteOrder(currentOrder);
await this.plugin.savePluginData();
// Refresh the view to show the new order
await this.refreshSidebarView();
}
}
}
}
});
// Add drag-leave event handler to clean up indicators
container.addEventListener('dragleave', (e) => {
const target = e.target as HTMLElement;
const noteItem = target.closest('.review-note-item') as HTMLElement | null;
if (noteItem) {
noteItem.classList.remove('drag-over', 'drag-over-above', 'drag-over-below');
}
});
}
/**
* Updates the main header statistics display.

View file

@ -37,6 +37,22 @@ export class NoteItemRenderer {
noteEl.removeClass("selected");
}
// Recurrence status
const isRecurring = this.plugin.recurringNotesService.isNoteRecurring(note.path);
if (isRecurring) {
noteEl.addClass("recurring-note");
const recurrenceInfo = this.plugin.recurringNotesService.getRecurrenceInfo(note.path);
if (recurrenceInfo) {
const recurrenceBadge = noteEl.querySelector('.recurrence-badge') || noteEl.createSpan('recurrence-badge');
recurrenceBadge.setText(`${recurrenceInfo.interval}d`);
(recurrenceBadge as HTMLElement).title = `This note recurs every ${recurrenceInfo.interval} days`;
}
} else {
noteEl.removeClass("recurring-note");
const existingBadge = noteEl.querySelector('.recurrence-badge');
if (existingBadge) existingBadge.remove();
}
// Title
const titleEl = noteEl.querySelector<HTMLElement>(".review-note-title");
if (titleEl) {
@ -156,6 +172,11 @@ export class NoteItemRenderer {
setIcon(removeBtn, "trash-2");
removeBtn.title = "Remove";
// Add recurrence button
const recurrenceBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-recurrence" });
setIcon(recurrenceBtn, "repeat");
recurrenceBtn.title = "Toggle recurrence";
const dragHandleEl = buttonsEl.createDiv("review-note-drag-handle"); // Create drag handle structure
dragHandleEl.setAttribute('aria-label', 'Drag to reorder');
for (let i = 0; i < 3; i++) {
@ -216,29 +237,53 @@ export class NoteItemRenderer {
}
});
removeBtn.addEventListener("click", (e) => {
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
try {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice(`Note removed from review schedule`);
await onNoteAction();
} catch (_error) {
new Notice("Failed to remove note from schedule.");
await onNoteAction();
}
})();
}
).open();
}
});
// Add event handler for recurrence button
recurrenceBtn.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
try {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice(`Note removed from review schedule`);
await onNoteAction();
} catch (_error) {
new Notice("Failed to remove note from schedule.");
await onNoteAction();
}
})();
void (async () => {
const isCurrentlyRecurring = this.plugin.recurringNotesService.isNoteRecurring(path);
if (isCurrentlyRecurring) {
// Convert back to regular note
await this.plugin.recurringNotesService.convertToRegularNote(path);
new Notice(`Note is no longer recurring`);
} else {
// Convert to recurring note with default interval
const defaultInterval = this.plugin.settings.defaultRecurrenceInterval;
await this.plugin.recurringNotesService.convertToRecurringNote(path, defaultInterval);
new Notice(`Note is now recurring every ${defaultInterval} days`);
}
).open();
await onNoteAction();
})();
}
});
@ -349,25 +394,64 @@ export class NoteItemRenderer {
}
}
menu.addItem((item) => item
.setTitle("Remove from review")
.setIcon("trash")
.onClick(() => {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice("Note removed from review schedule.");
await onNoteAction();
})();
// Add recurrence context menu items
const isCurrentlyRecurring = this.plugin.recurringNotesService.isNoteRecurring(path);
if (isCurrentlyRecurring) {
menu.addItem((item) => item
.setTitle("Stop recurrence")
.setIcon("x-circle")
.onClick(async () => {
await this.plugin.recurringNotesService.convertToRegularNote(path);
new Notice("Note is no longer recurring");
await onNoteAction();
}));
// Add option to change recurrence interval
menu.addItem((item) => item
.setTitle("Change recurrence interval")
.setIcon("calendar-range")
.onClick(async () => {
const recurrenceInfo = this.plugin.recurringNotesService.getRecurrenceInfo(path);
if (recurrenceInfo) {
// For now, just cycle through common intervals
const currentInterval = recurrenceInfo.interval;
const newInterval = currentInterval === 7 ? 14 : currentInterval === 14 ? 30 : 7;
await this.plugin.recurringNotesService.updateRecurrenceInterval(path, newInterval);
new Notice(`Recurrence interval changed to ${newInterval} days`);
await onNoteAction();
}
).open();
}));
}));
} else {
menu.addItem((item) => item
.setTitle("Make recurring")
.setIcon("repeat")
.onClick(async () => {
const defaultInterval = this.plugin.settings.defaultRecurrenceInterval;
await this.plugin.recurringNotesService.convertToRecurringNote(path, defaultInterval);
new Notice(`Note is now recurring every ${defaultInterval} days`);
await onNoteAction();
}));
}
menu.addItem((item) => item
.setTitle("Remove from review")
.setIcon("trash")
.onClick(() => {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice("Note removed from review schedule.");
await onNoteAction();
})();
}
).open();
}));
menu.showAtMouseEvent(e);
});