dralkh_spaceforge/services/review-schedule-service.ts
dralkh e80dfda20d release: v1.0.5
JS/TS:
- Replace builtin-modules with node:module (esbuild.config.mjs)
- Replace fs-extra with native fs (install.js), remove from deps
- Fix createEl deprecation: use createDiv/createSpan
- Remove unnecessary type assertions, fix unsafe casts
- Bind event handlers with arrow functions

CSS:
- Remove all !important declarations (~30 instances) — replace with
  chained selectors (.class.class) for equal specificity
- Merge all duplicate selectors across 7 CSS files (~50+ instances)
- Convert 3-digit hex to 6-digit format (_variables.css)
- Fix duplicate CSS properties (overflow-y in mcq.css)
- Delete leftover display:none suppression rule in calendar.css

Meta:
- Bump version to 1.0.5
- Update minAppVersion to 1.8.7
- Add versions.json entry for 1.0.5
2026-05-20 11:02:54 +03:00

996 lines
No EOL
45 KiB
TypeScript

import { Notice, TFile } from 'obsidian';
import { ReviewSchedule, ReviewResponse, toSM2Quality, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import SpaceforgePlugin from '../main';
import { DateUtils } from '../utils/dates';
import { FsrsScheduleService } from './fsrs-schedule-service'; // Added FsrsScheduleService
import { EstimationUtils } from '../utils/estimation';
import { ReviewHistoryItem } from '../models/review-schedule'; // Need this for history recording
/**
* Handles management of review schedules and SM-2 calculations
*/
export class ReviewScheduleService {
/**
* Reference to the main plugin
*/
private plugin: SpaceforgePlugin;
private fsrsService: FsrsScheduleService; // Added FsrsScheduleService instance
/**
* Note schedules indexed by path
*/
schedules: Record<string, ReviewSchedule> = {};
/**
* Custom order for notes (user-defined ordering)
*/
customNoteOrder: string[] = [];
/**
* Timestamp of the last time link analysis was performed for ordering
*/
lastLinkAnalysisTimestamp: number | null = null;
/**
* Review history (will be managed by ReviewHistoryService, but needed for recordReview)
* This will be a reference to the history array in DataStorage
*/
history: ReviewHistoryItem[];
/**
* Initialize Review Schedule Service
*
* @param plugin Reference to the main plugin
* @param schedules Initial schedules data
* @param customNoteOrder Initial custom note order data
* @param lastLinkAnalysisTimestamp Initial last link analysis timestamp
* @param history Reference to the history array in DataStorage
*/
constructor(
plugin: SpaceforgePlugin,
schedules: Record<string, ReviewSchedule>,
customNoteOrder: string[],
lastLinkAnalysisTimestamp: number | null,
history: ReviewHistoryItem[]
) {
this.plugin = plugin;
this.schedules = schedules;
this.customNoteOrder = customNoteOrder;
this.lastLinkAnalysisTimestamp = lastLinkAnalysisTimestamp;
this.history = history;
this.fsrsService = new FsrsScheduleService(this.plugin.settings);
}
public updateAlgorithmServicesForSettingsChange(): void {
// Call this if settings change, especially FSRS parameters
this.fsrsService.updateFSRSInstance(this.plugin.settings);
}
/**
* Schedule a note for review
*
* @param path Path to the note file
* @param daysFromNow Days until first review (default: 0, same day)
*/
scheduleNoteForReview(path: string, daysFromNow = 0): void {
// Check if file exists and is a markdown file
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile) || file.extension !== "md") {
new Notice("Only Markdown files can be added to review.");
return;
}
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm;
let newSchedule: ReviewSchedule;
if (defaultAlgorithm === 'fsrs') {
// FSRS card creation date is the exact moment. Its nextReviewDate is also an exact moment.
const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now));
newSchedule = {
path,
lastReviewDate: null, // Will be UTC midnight when set
nextReviewDate: now, // FSRS cards are due immediately (exact timestamp)
reviewCount: 0,
schedulingAlgorithm: 'fsrs',
fsrsData: fsrsData,
// SM-2 fields can be undefined or default
ease: this.plugin.settings.baseEase, // Keep a base for potential conversion
interval: 0,
consecutive: 0,
repetitionCount: 0,
scheduleCategory: undefined, // Not applicable to FSRS
};
// daysFromNow is ignored for FSRS initial scheduling, it follows its learning steps.
} else { // sm2
newSchedule = {
path,
lastReviewDate: null, // Will be UTC midnight when set
nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow),
ease: this.plugin.settings.baseEase,
interval: daysFromNow,
consecutive: 0,
reviewCount: 0,
repetitionCount: 0,
scheduleCategory: this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced',
schedulingAlgorithm: 'sm2',
fsrsData: undefined,
};
if (newSchedule.scheduleCategory === 'initial') {
const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals;
if (initialIntervals && initialIntervals.length > 0) {
newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0];
}
if (daysFromNow === 0) {
newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval);
}
}
}
this.schedules[path] = newSchedule;
// Add to custom order if not already present
if (!this.customNoteOrder.includes(path)) {
this.customNoteOrder.push(path);
}
// Data saving is now handled by main.ts after this method returns
// Notify any listeners (for UI updates)
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
new Notice(`Note added to review.`);
}
/**
* Check if a note is due for review on or before the specified date
*
* @param schedule The review schedule for the note
* @param effectiveReviewDate The date to check against
* @returns true if the note is due, false otherwise
*/
private isNoteDue(schedule: ReviewSchedule, effectiveReviewDate: number): boolean {
const reviewDateObj = new Date(effectiveReviewDate);
const effectiveUTCDayEnd = DateUtils.endOfUTCDay(reviewDateObj);
// Note is due if its nextReviewDate is on or before the effective date
// Allow notes due today or earlier to be reviewed
return schedule.nextReviewDate <= effectiveUTCDayEnd;
}
/**
* Record a review for a note
*
* @param path Path to the note file
* @param response User's response during review (can be SM-2 or FSRS rating)
* @param isSkipped Whether this review was explicitly skipped (default: false)
* @param currentReviewDate Optional timestamp for the current review date (simulated or actual)
* @returns true if the review was recorded, false if it was just a preview
*/
recordReview(path: string, response: ReviewResponse | FsrsRating, isSkipped = false, currentReviewDate?: number): boolean {
const schedule = this.schedules[path];
if (!schedule) return false;
const effectiveReviewDate = currentReviewDate || Date.now();
// Check if the note is actually due for review
const isDue = this.isNoteDue(schedule, effectiveReviewDate);
if (!isDue) {
// Note is not due, this is just a preview - don't record anything
return false;
}
const reviewDateObj = new Date(effectiveReviewDate);
const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj);
// Determine historyResponse for logging
let historyResponseValue: ReviewResponse;
if (Object.values(FsrsRating).includes(response as FsrsRating) && typeof response === "number") {
switch (response as FsrsRating) {
case FsrsRating.Again: historyResponseValue = ReviewResponse.IncorrectResponse; break;
case FsrsRating.Hard: historyResponseValue = ReviewResponse.IncorrectButFamiliar; break;
case FsrsRating.Good: historyResponseValue = ReviewResponse.CorrectWithDifficulty; break;
case FsrsRating.Easy: historyResponseValue = ReviewResponse.CorrectWithHesitation; break;
default: historyResponseValue = ReviewResponse.CorrectWithDifficulty;
}
} else {
historyResponseValue = toSM2Quality(response as ReviewResponse);
}
// Record the actual review
if (!isSkipped) {
schedule.reviewCount = (schedule.reviewCount || 0) + 1;
}
schedule.lastReviewDate = effectiveUTCDayStart;
// Log to history
this.history.push({
path,
timestamp: effectiveReviewDate,
response: historyResponseValue,
interval: schedule.interval ?? schedule.fsrsData?.scheduled_days ?? 0,
ease: schedule.ease ?? (schedule.fsrsData?.difficulty ? Math.round(schedule.fsrsData.difficulty * 10) : this.plugin.settings.baseEase),
isSkipped: isSkipped
});
if (this.history.length > 1000) this.history.splice(0, this.history.length - 1000);
if (schedule.schedulingAlgorithm === 'fsrs') {
if (!schedule.fsrsData) { // Should not happen if card is properly initialized
// FSRS service expects a Date object for the review time.
// reviewDateObj (created from effectiveReviewDate) is correct here.
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
// --- START FIX: Determine correct FsrsRating ---
let actualFsrsRating: FsrsRating;
// Check if the response is one of the legacy ReviewResponse values from MCQ modal
if (response === ReviewResponse.PerfectRecall) { // Value 5
actualFsrsRating = FsrsRating.Easy; // Map to 4
} else if (response === ReviewResponse.CorrectWithHesitation) { // Value 4
actualFsrsRating = FsrsRating.Good; // Map to 3
} else if (response === ReviewResponse.CorrectWithDifficulty) { // Value 3
actualFsrsRating = FsrsRating.Hard; // Map to 2
} else if (response === ReviewResponse.IncorrectResponse) { // Value 1 (legacy)
actualFsrsRating = FsrsRating.Again; // Map to 1
} else if (Object.values(FsrsRating).includes(response as FsrsRating)) {
// If it's already a valid FsrsRating (1, 2, 3, 4), use it directly
actualFsrsRating = response as FsrsRating;
} else {
// Fallback for unexpected values (e.g., SM-2 specific 0, 2, potentially)
// Map based on general difficulty
const quality = toSM2Quality(response as ReviewResponse);
if (quality >= 4) actualFsrsRating = FsrsRating.Easy; // 4, 5 -> Easy
else if (quality === 3) actualFsrsRating = FsrsRating.Good; // 3 -> Good
else if (quality === 2) actualFsrsRating = FsrsRating.Hard; // 2 -> Hard
else actualFsrsRating = FsrsRating.Again; // 0, 1 -> Again
}
// --- END FIX ---
const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.recordReview(
schedule.fsrsData,
actualFsrsRating, // Pass the correctly determined FsrsRating
reviewDateObj // Pass the exact moment of review
);
schedule.fsrsData = updatedData;
schedule.nextReviewDate = newNextReviewDateFsrs; // This is already a UTC timestamp from FSRS
// SM-2 specific fields are not updated for FSRS cards
schedule.interval = updatedData.scheduled_days; // For display consistency if needed
schedule.ease = Math.round(updatedData.difficulty * 10); // Approximate for display
} else { // SM-2
const qualityRating = toSM2Quality(response as ReviewResponse);
// Initialize SM-2 fields if they are somehow missing (should not happen for SM-2 cards)
schedule.ease = schedule.ease ?? this.plugin.settings.baseEase;
schedule.interval = schedule.interval ?? 0;
schedule.repetitionCount = schedule.repetitionCount ?? 0;
schedule.consecutive = schedule.consecutive ?? 0;
schedule.scheduleCategory = schedule.scheduleCategory ?? (this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced');
if (schedule.scheduleCategory === 'initial') {
const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals || [];
// reviewCount is 0-indexed for history, 1-indexed for human counting of reviews.
// If reviewCount is 0 (first review), initialIntervals[0] is the interval *after* this review.
// If reviewCount is 1 (second review), initialIntervals[1] is the interval *after* this review.
// The schedule.reviewCount was already incremented if not skipped.
// So, if reviewCount is now 1 (meaning 1st review just happened), use initialIntervals[0] for next interval.
// This seems to be what the original logic intended with `initialIntervals[schedule.reviewCount]`
// if reviewCount was considered 0-indexed for *which review this is*.
// Let's assume schedule.reviewCount (already incremented) is the number of reviews *completed*.
// If 1 review completed, next interval is initialIntervals[0] if length allows.
// If `schedule.reviewCount -1` is the index for the interval *just completed*, then `schedule.reviewCount`
// would be the index for the *next* interval.
// The original `schedule.reviewCount < initialIntervals.length` and `initialIntervals[schedule.reviewCount]`
// implies that if `reviewCount` is (e.g.) 0 after incrementing (meaning it was -1, which is impossible),
// it would take `initialIntervals[0]`. If `reviewCount` is 1, it takes `initialIntervals[1]`.
// This needs to be robust. Let's use `schedule.repetitionCount` for initial steps as it's clearer.
// For initial phase, repetitionCount tracks progression through initial steps.
if (schedule.repetitionCount < initialIntervals.length) {
schedule.interval = initialIntervals[schedule.repetitionCount];
} else { // Graduated from initial steps
schedule.scheduleCategory = 'graduated';
// For the first SM-2 calculation after graduation, treat as if n=0 for interval calc.
// daysLate should be 0 as we are just graduating.
const daysLateForGraduation = 0;
const { interval, ease, repetitionCount: newRepCount } = this.calculateSM2Schedule(
schedule.interval, // previous interval (last of initial steps)
schedule.ease,
qualityRating,
0, // Reset repetition count for SM-2 calculation after graduation
daysLateForGraduation,
isSkipped
);
schedule.interval = interval;
schedule.ease = ease;
schedule.repetitionCount = newRepCount; // This will be 1 if q >= 3
}
// Update ease factor regardless of graduation
const q = qualityRating;
let newEase = schedule.ease / 100;
newEase = newEase + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
newEase = Math.max(1.3, newEase);
schedule.ease = Math.round(newEase * 100);
if (qualityRating >= (ReviewResponse.CorrectWithDifficulty as number)) {
schedule.consecutive += 1;
if (qualityRating >= 3) {
// repetitionCount for initial phase should increment if successful, reset if not.
// This is distinct from the SM-2 n.
schedule.repetitionCount = (schedule.repetitionCount || 0) + 1;
} else { // q < 3
schedule.repetitionCount = 0; // Reset progress in initial steps
}
} else { // q < 3
schedule.consecutive = 0;
schedule.repetitionCount = 0; // Reset progress in initial steps
}
} else { // 'spaced' or 'graduated' (already graduated or started as spaced)
const daysLate = schedule.nextReviewDate < effectiveUTCDayStart ? // Compare with UTC day start
DateUtils.dayDifferenceUTC(schedule.nextReviewDate, effectiveUTCDayStart) : 0;
const { interval, ease, repetitionCount } = this.calculateSM2Schedule(
schedule.interval, schedule.ease, qualityRating, schedule.repetitionCount || 0, daysLate, isSkipped
);
schedule.interval = interval;
schedule.ease = ease;
schedule.repetitionCount = repetitionCount;
if (qualityRating >= (ReviewResponse.CorrectWithDifficulty as number)) {
schedule.consecutive += 1;
} else {
schedule.consecutive = 0;
}
}
// Base the next review date on the UTC start of the current review day
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, schedule.interval);
}
// Data saving is now handled by main.ts after this method returns
// Notify any listeners (for UI updates)
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
return true; // Review was successfully recorded
}
/**
* Calculate new schedule parameters based on review response using the SM-2 algorithm
* (This method is likely redundant now that recordReview uses calculateSM2Schedule directly,
* but keeping for potential external use or backward compatibility if needed)
*
* @param currentInterval Current interval in days
* @param currentEase Current ease factor
* @param response User's response during review
* @param repetitionCount Current repetition count (n)
* @param daysLate How many days late the review is (0 if on time or early)
* @param isSkipped Whether the item was explicitly skipped by the user
* @returns New interval, ease, and repetition count
*/
calculateNewSchedule(
currentInterval: number,
currentEase: number,
response: ReviewResponse,
repetitionCount = 0,
daysLate = 0,
isSkipped = false
): { interval: number, ease: number, repetitionCount: number } {
return this.calculateSM2Schedule(
currentInterval,
currentEase,
toSM2Quality(response),
repetitionCount,
daysLate,
isSkipped
);
}
/**
* Calculate new schedule parameters using the enhanced SM-2 algorithm with lateness penalty
* (This is the core calculation logic used internally by recordReview and skipNote)
*
* @param currentInterval Current interval in days
* @param currentEase Current ease factor (expressed as a number where 2.5 = 250)
* @param qualityRating User's response during review, as a numeric quality rating (0-5)
* @param repetitionCount Current repetition count (n)
* @param daysLate How many days late the review is (0 if on time or early)
* @param isSkipped Whether the item was explicitly skipped by the user
* @returns New interval, ease, and repetition count
*/
private calculateSM2Schedule(
currentInterval: number,
currentEase: number,
qualityRating: number, // Changed parameter type from ReviewResponse to number
repetitionCount = 0,
daysLate = 0,
isSkipped = false
): { interval: number, ease: number, repetitionCount: number } {
// qualityRating is now expected to be a number (0-5) directly.
// The call to toSM2Quality(response) is removed.
// Handle overdue or skipped items according to modified SM-2 algorithm
if (isSkipped || daysLate > 0) {
// Determine effective quality rating:
// - If explicitly skipped, reduce quality by 1 (but not below 0)
// - If overdue, set quality to 0
// - If both overdue and explicitly skipped, prioritize the skip logic (user choice)
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
// Calculate new ease factor using SM-2 formula with the effective quality
ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// Force next review to be tomorrow (interval = 1) regardless of computed interval
// and reset repetition count to 1
const result = {
interval: 1, // Force next review to be tomorrow
ease: Math.round(ease * 100), // Convert back to internal format
repetitionCount: 1 // Reset repetition count to 1
};
return result;
}
// For normal reviews, use the regular SM-2 implementation
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval: number;
// Calculate new ease factor using SM-2 formula
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// If response is less than 3, reset repetition count to 0 (per strict SM-2)
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1; // Next interval is 1 day for failed items
} else {
// Increment repetition count for correct responses
newRepetitionCount += 1;
// Calculate new interval based on SM-2 rules
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
// For n > 2, use the formula I_n = I_(n-1) * EF
interval = Math.round(currentInterval * ease);
}
}
// Apply load balancing if enabled (this is an extension to the algorithm)
if (this.plugin.settings.loadBalance) {
const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0;
interval = interval + Math.random() * fuzz * 2 - fuzz;
}
// Ensure interval is at least 1 day
interval = Math.max(1, interval);
// Enforce maximum interval (this is an extension to the algorithm)
interval = Math.min(interval, this.plugin.settings.maximumInterval);
// Convert ease back to internal format before returning
return {
interval: Math.round(interval), // SM-2 uses whole days
ease: Math.round(ease * 100),
repetitionCount: newRepetitionCount
};
}
/**
* Get notes due for review
*
* @param date Optional target date (default: now)
* @param matchExactDate If true, only return notes due exactly on this date (ignoring time). Otherwise, notes due on or before this date.
* @returns Array of due note schedules sorted by due date
*/
getDueNotes(date: number = Date.now(), matchExactDate = false): ReviewSchedule[] {
const targetDate = new Date(date);
const targetUTCDayStart = DateUtils.startOfUTCDay(targetDate);
const targetUTCDayEnd = DateUtils.endOfUTCDay(targetDate);
return Object.values(this.schedules)
.filter(schedule => {
if (matchExactDate) {
// For all algorithms, a note is due on an exact date if its next review
// timestamp falls anywhere within that UTC day.
return schedule.nextReviewDate >= targetUTCDayStart && schedule.nextReviewDate <= targetUTCDayEnd;
} else {
// For all algorithms, a note is due "today" (or on/before a date) if its
// next review timestamp is anytime up to the end of that UTC day.
return schedule.nextReviewDate <= targetUTCDayEnd;
}
})
.sort((a, b) => a.nextReviewDate - b.nextReviewDate);
}
/**
* Get upcoming reviews within a specified timeframe
*
* @param days Number of days to look ahead
* @returns Array of upcoming review schedules sorted by due date
*/
getUpcomingReviews(days = 7): ReviewSchedule[] {
const now = Date.now();
const futureDate = DateUtils.addDays(now, days);
return Object.values(this.schedules)
.filter(schedule =>
schedule.nextReviewDate > now &&
schedule.nextReviewDate <= futureDate
)
.sort((a, b) => a.nextReviewDate - b.nextReviewDate);
}
/**
* Skip a note's review and reschedule for tomorrow with penalized quality
*
* This implements the "Postpone to Tomorrow" functionality from the modified SM-2 algorithm.
* It applies a one-step quality penalty (reduce by 1 but not below 0) and forces the next
* review to be tomorrow, regardless of what the normal interval would be. This keeps items
* in rotation rather than letting them disappear into an ever-growing backlog.
*
* @param path Path to the note file
* @param response Optional user's response to use for penalty calculation
* @param currentReviewDate Optional timestamp for the current review date (simulated or actual)
*/
skipNote(path: string, response: ReviewResponse | FsrsRating = ReviewResponse.CorrectWithDifficulty, currentReviewDate?: number): void {
const schedule = this.schedules[path];
if (!schedule) return;
const effectiveReviewDate = currentReviewDate || Date.now();
const reviewDateObj = new Date(effectiveReviewDate);
// Record as a skipped review (isSkipped = true)
// The 'response' for a skip is less critical but can be used for penalty in SM-2.
// For FSRS, skipReview in FsrsService handles it as 'Again'.
if (schedule.schedulingAlgorithm === 'fsrs') {
if (!schedule.fsrsData) { // Should not happen
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.skipReview(
schedule.fsrsData,
reviewDateObj // Pass exact moment for FSRS skip
);
schedule.fsrsData = updatedData;
schedule.nextReviewDate = newNextReviewDateFsrs; // Already a UTC timestamp
schedule.lastReviewDate = DateUtils.startOfUTCDay(reviewDateObj); // FSRS skip is a review, set last review to UTC midnight
this.history.push({ // Log FSRS skip
path, timestamp: effectiveReviewDate, response: ReviewResponse.IncorrectResponse, // Approx. for log
interval: schedule.fsrsData.scheduled_days, ease: Math.round(schedule.fsrsData.difficulty * 10), isSkipped: true
});
} else { // SM-2
let qualityRating = toSM2Quality(response as ReviewResponse);
qualityRating = Math.max(0, qualityRating - 1); // Apply skip penalty for SM-2
this.history.push({
path, timestamp: effectiveReviewDate, response: qualityRating,
interval: schedule.interval || 0, ease: schedule.ease || 0, isSkipped: true
});
const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj);
schedule.lastReviewDate = effectiveUTCDayStart; // Set last review to UTC midnight
if (schedule.scheduleCategory === 'initial') {
schedule.interval = 1; // Skip in initial phase often means try again soon
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, 1);
// For initial phase skips, repetitionCount might not advance or could reset.
// The current calculateSM2Schedule with isSkipped=true will set repCount=1.
} else {
const { interval, ease, repetitionCount } = this.calculateSM2Schedule(
schedule.interval || 0, schedule.ease || this.plugin.settings.baseEase, qualityRating,
schedule.repetitionCount || 0, 0, true // daysLate = 0 for a skip, isSkipped = true
);
schedule.interval = interval; // Will be 1 due to isSkipped=true
schedule.ease = ease;
schedule.repetitionCount = repetitionCount; // Will be 1
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, interval);
}
schedule.consecutive = 0;
}
// Data saving is now handled by main.ts after this method returns
// Notify any listeners (for UI updates)
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
}
/**
* Postpone a note's review
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
postponeNote(path: string, days = 1): void {
const schedule = this.schedules[path];
if (!schedule) return;
// Update the review date, preserving the current phase
// regardless of which initial phase the note is in
schedule.nextReviewDate = DateUtils.addDays(schedule.nextReviewDate, days);
// Data saving is now handled by main.ts after this method returns
// Note: We're now handling the UI update through the controller's postponeNote method
// directly, so we don't need to call handleNotePostponed here to avoid duplicate calls
// Refresh the sidebar view if available with a slight delay to allow data to settle
if (this.plugin.events) {
window.setTimeout(() => {
this.plugin.events.emit('sidebar-update');
}, 50); // Small delay (e.g., 50ms)
}
new Notice(`Review postponed for ${days} day${days !== 1 ? 's' : ''}.`);
}
/**
* Advance a note's review by one day, if eligible.
*
* @param path Path to the note file
* @returns True if the note was advanced, false otherwise.
*/
advanceNote(path: string): boolean {
const schedule = this.schedules[path];
if (!schedule) {
return false;
}
const todayUTCMidnight = DateUtils.startOfUTCDay(new Date());
// For SM-2, nextReviewDate is already UTC midnight. For FSRS, it's an exact time.
// To compare consistently for "advancing a day", we should compare UTC day starts.
const noteReviewUTCDayStart = DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate));
// Only advance future notes, and not past today (based on UTC days)
if (noteReviewUTCDayStart <= todayUTCMidnight) {
return false;
}
// New potential next review date is one day earlier.
// If FSRS, it's one day earlier from its exact time. If SM-2, one day earlier from its UTC midnight.
const newPotentialNextReviewTimestamp = DateUtils.addDays(schedule.nextReviewDate, -1);
// Ensure the new date (if SM-2, its UTC day start) is not before todayUTCMidnight
if (schedule.schedulingAlgorithm === 'sm2') {
schedule.nextReviewDate = Math.max(todayUTCMidnight, DateUtils.startOfUTCDay(new Date(newPotentialNextReviewTimestamp)));
} else { // FSRS
// For FSRS, ensure the new exact time is not earlier than the start of today UTC.
// Or, more practically, not earlier than the current exact time if that's preferred.
// Let's keep it simple: advance by one day, but not before current UTC midnight.
schedule.nextReviewDate = Math.max(todayUTCMidnight, newPotentialNextReviewTimestamp);
}
// Data saving is handled by the calling controller (e.g., ReviewControllerCore)
if (this.plugin.events) {
// Use a timeout to allow other operations to complete before UI refresh
window.setTimeout(() => {
this.plugin.events.emit('sidebar-update');
}, 50);
}
// Notice is handled by the calling controller for better context.
return true;
}
/**
* Remove a note from the review schedule
*
* @param path Path to the note file
*/
removeFromReview(path: string): void {
if (this.schedules[path]) {
delete this.schedules[path];
// Remove from custom order if present
this.customNoteOrder = this.customNoteOrder.filter(p => p !== path);
// Data saving is now handled by main.ts after this method returns
new Notice("Note removed from review.");
// Note: The controller will be notified separately to update its state
// This prevents immediate reordering based on link analysis after removal.
// The sidebar or other components should trigger an update if needed.
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update'); // Notify UI to refresh
}
}
}
/**
* Clear all review schedules
*/
clearAllSchedules(): void {
this.schedules = {};
this.customNoteOrder = []; // Also clear custom order
// Data saving is now handled by main.ts after this method returns
new Notice("All review schedules cleared.");
// Notify any listeners (for UI updates)
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
// Explicitly update the review controller's state
// This dependency might need to be managed differently, e.g., via events
if (this.plugin.reviewController) {
void this.plugin.reviewController.updateTodayNotes();
}
}
/**
* Estimate review time for a note
*
* @param path Path to the note file
* @returns Estimated review time in seconds
*/
async estimateReviewTime(path: string): Promise<number> {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return 60; // Default 1 minute
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch {
return 60; // Default 1 minute
}
}
/**
* Schedule multiple notes for review in a specific order
*
* @param paths Array of note paths in the order they should be processed
* @param daysFromNow Days until first review (default: 0, same day)
* @returns Number of notes scheduled
*/
scheduleNotesInOrder(paths: string[], daysFromNow = 0): number {
let count = 0;
// Schedule each note in the provided order
for (const path of paths) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile) || file.extension !== "md" || this.schedules[path]) {
continue;
}
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm;
let newSchedule: ReviewSchedule;
if (defaultAlgorithm === 'fsrs') {
const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now)); // Exact moment
newSchedule = {
path, lastReviewDate: null, nextReviewDate: now, reviewCount: 0, // FSRS due now
schedulingAlgorithm: 'fsrs', fsrsData: fsrsData,
ease: this.plugin.settings.baseEase, interval: 0, consecutive: 0, repetitionCount: 0, scheduleCategory: undefined,
};
} else { // sm2
newSchedule = {
path, lastReviewDate: null,
nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), // UTC midnight
ease: this.plugin.settings.baseEase, interval: daysFromNow,
consecutive: 0, reviewCount: 0, repetitionCount: 0,
scheduleCategory: this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced',
schedulingAlgorithm: 'sm2', fsrsData: undefined,
};
if (newSchedule.scheduleCategory === 'initial') {
const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals;
if (initialIntervals && initialIntervals.length > 0) {
newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0];
}
if (daysFromNow === 0) {
newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval);
}
}
}
this.schedules[path] = newSchedule;
// Add to custom order if not already present
if (!this.customNoteOrder.includes(path)) {
this.customNoteOrder.push(path);
}
count++;
}
if (count > 0) {
// Data saving is now handled by main.ts after this method returns
// Notify any listeners (for UI updates)
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
}
return count;
}
/**
* Update the custom note order - used to maintain user-defined ordering
*
* @param newOrder Array of note paths in desired order
*/
updateCustomNoteOrder(newOrder: string[]): void {
// Filter out duplicate paths and ensure we only store paths that exist in our schedules
const uniqueValidPaths = Array.from(new Set(newOrder)).filter(path => this.schedules[path] !== undefined);
this.customNoteOrder = uniqueValidPaths;
// Data saving is now handled by main.ts after this method returns
// Notify sidebar to update
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
}
/**
* Get due notes ordered by custom order if available
*
* @param date Optional target date (default: now)
* @param useCustomOrder Whether to apply custom ordering (default: true)
* @param matchExactDate Passed to getDueNotes to filter by exact date if true.
* @returns Array of due note schedules sorted appropriately
*/
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder = true, matchExactDate = false): ReviewSchedule[] {
// First, get all due notes using the modified method
const dueNotes = this.getDueNotes(date, matchExactDate);
// If we have no custom order or are instructed not to use it, return regular order
if (!useCustomOrder || this.customNoteOrder.length === 0) {
return dueNotes;
}
// Create a map for faster lookups
const notesByPath: Record<string, ReviewSchedule> = {};
dueNotes.forEach(note => {
notesByPath[note.path] = note;
});
// Apply custom order for notes that have an order defined
const notesInOrder: ReviewSchedule[] = [];
const orderedPaths: Set<string> = new Set<string>();
// First, add notes that have a defined order
for (const path of this.customNoteOrder) {
if (notesByPath[path]) {
notesInOrder.push(notesByPath[path]);
orderedPaths.add(path);
}
}
// Then add any remaining notes that don't have a defined order
for (const note of dueNotes) {
if (!orderedPaths.has(note.path)) {
notesInOrder.push(note);
}
}
return notesInOrder;
}
/**
* Handles the renaming of a note file.
* Updates the schedule and custom order if the note was scheduled.
*
* @param oldPath The original path of the note.
* @param newPath The new path of the note.
*/
handleNoteRename(oldPath: string, newPath: string): void {
if (this.schedules[oldPath]) {
const schedule = this.schedules[oldPath];
delete this.schedules[oldPath];
schedule.path = newPath;
this.schedules[newPath] = schedule;
// Update customNoteOrder
const oldPathIndex = this.customNoteOrder.indexOf(oldPath);
if (oldPathIndex > -1) {
this.customNoteOrder[oldPathIndex] = newPath;
} else {
// If oldPath wasn't in custom order for some reason,
// ensure newPath is added if it's not there already.
// This typically shouldn't happen if data is consistent.
if (!this.customNoteOrder.includes(newPath)) {
this.customNoteOrder.push(newPath);
}
}
// Notify any listeners (for UI updates)
// Data saving will be handled by the caller in main.ts
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
}
}
// Helper method for backward compatibility (moved from DataStorage)
private getRepetitionCount(interval: number): number {
// For legacy data migration: estimate repetition count based on interval
if (interval <= 1) return 0;
if (interval <= 6) return 1;
return 2;
}
public convertAllSm2ToFsrs(): void {
for (const path in this.schedules) {
if (Object.prototype.hasOwnProperty.call(this.schedules, path)) {
const schedule = this.schedules[path];
if (schedule.schedulingAlgorithm === 'sm2') {
schedule.schedulingAlgorithm = 'fsrs';
// Use last review date or now for FSRS card creation to get a sensible start.
const baseDate = schedule.lastReviewDate ? new Date(schedule.lastReviewDate) : new Date();
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(baseDate);
// New FSRS cards are typically due 'now' relative to their creation/conversion.
schedule.nextReviewDate = baseDate.getTime();
// Clear or nullify SM-2 specific fields
schedule.ease = this.plugin.settings.baseEase; // Keep a base ease for potential future conversion back
schedule.interval = 0; // Reset SM-2 interval
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = undefined;
}
}
}
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update'); // Notify UI to refresh
}
}
public convertAllFsrsToSm2(): void {
for (const path in this.schedules) {
if (Object.prototype.hasOwnProperty.call(this.schedules, path)) {
const schedule = this.schedules[path];
if (schedule.schedulingAlgorithm === 'fsrs') {
schedule.schedulingAlgorithm = 'sm2';
schedule.ease = this.plugin.settings.baseEase;
schedule.interval = 0; // Start with a 0-day interval, due immediately for SM-2 re-evaluation
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced';
// Set next review date based on SM-2 initial logic, using UTC days
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
let nextReview = DateUtils.addDays(todayUTCStart, 0); // Due today (UTC midnight)
if (schedule.scheduleCategory === 'initial' && this.plugin.settings.initialScheduleCustomIntervals.length > 0) {
schedule.interval = this.plugin.settings.initialScheduleCustomIntervals[0];
nextReview = DateUtils.addDays(todayUTCStart, schedule.interval);
}
schedule.nextReviewDate = nextReview;
schedule.fsrsData = undefined; // Clear FSRS data
}
}
}
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update'); // Notify UI to refresh
}
}
}