dralkh_spaceforge/main.js
Dralk dbd37e8537 refactor: replace fetch with Obsidian requestUrl for API calls
- Switches all LLM services (Claude, Gemini, Ollama, OpenAI, OpenRouter, Together) from native fetch to Obsidian’s requestUrl for better CORS handling and plugin compatibility
- Updates UI components to use new Obsidian API patterns (createDiv with options)
- Minor clean-ups in MCQ, Pomodoro, and review services
2025-08-02 01:06:54 +03:00

10417 lines
444 KiB
JavaScript

/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
// main.ts
var main_exports = {};
__export(main_exports, {
default: () => SpaceforgePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian26 = require("obsidian");
// utils/event-emitter.ts
var EventEmitter = class {
constructor() {
/**
* Event listeners by event name
*/
this.listeners = {};
}
/**
* Register a listener for an event
*
* @param event Event name
* @param callback Function to call when event is emitted
*/
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
/**
* Emit an event
*
* @param event Event name
* @param args Arguments to pass to listeners
*/
emit(event, ...args) {
if (!this.listeners[event]) {
return;
}
for (const callback of this.listeners[event]) {
callback(...args);
}
}
/**
* Remove a listener for an event
*
* @param event Event name
* @param callback Function to remove
*/
off(event, callback) {
if (!this.listeners[event]) {
return;
}
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
}
/**
* Remove all listeners for an event
*
* @param event Event name
*/
removeAllListeners(event) {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
};
// data-storage.ts
var import_obsidian = require("obsidian");
// models/review-schedule.ts
var FsrsRating = /* @__PURE__ */ ((FsrsRating2) => {
FsrsRating2[FsrsRating2["Again"] = 1] = "Again";
FsrsRating2[FsrsRating2["Hard"] = 2] = "Hard";
FsrsRating2[FsrsRating2["Good"] = 3] = "Good";
FsrsRating2[FsrsRating2["Easy"] = 4] = "Easy";
return FsrsRating2;
})(FsrsRating || {});
var ReviewResponse = /* @__PURE__ */ ((ReviewResponse2) => {
ReviewResponse2[ReviewResponse2["CompleteBlackout"] = 0] = "CompleteBlackout";
ReviewResponse2[ReviewResponse2["IncorrectResponse"] = 1] = "IncorrectResponse";
ReviewResponse2[ReviewResponse2["IncorrectButFamiliar"] = 2] = "IncorrectButFamiliar";
ReviewResponse2[ReviewResponse2["CorrectWithDifficulty"] = 3] = "CorrectWithDifficulty";
ReviewResponse2[ReviewResponse2["CorrectWithHesitation"] = 4] = "CorrectWithHesitation";
ReviewResponse2[ReviewResponse2["PerfectRecall"] = 5] = "PerfectRecall";
ReviewResponse2[ReviewResponse2["Hard"] = 1] = "Hard";
ReviewResponse2[ReviewResponse2["Fair"] = 3] = "Fair";
ReviewResponse2[ReviewResponse2["Good"] = 4] = "Good";
ReviewResponse2[ReviewResponse2["Perfect"] = 5] = "Perfect";
return ReviewResponse2;
})(ReviewResponse || {});
function toSM2Quality(response) {
if (response >= 0 && response <= 5) {
return response;
}
switch (response) {
case 1 /* Hard */:
return 1 /* IncorrectResponse */;
case 3 /* Fair */:
return 3 /* CorrectWithDifficulty */;
case 4 /* Good */:
return 4 /* CorrectWithHesitation */;
case 5 /* Perfect */:
return 5 /* PerfectRecall */;
default:
return 3 /* CorrectWithDifficulty */;
}
}
// utils/estimation.ts
var EstimationUtils = class {
/**
* Set the plugin reference
*
* @param plugin Reference to the main plugin
*/
static setPlugin(plugin) {
this.plugin = plugin;
}
/**
* Get the user's reading speed from settings
*
* @param contentType Optional content type for adjustment
* @returns Reading speed in words per minute
*/
static getReadingSpeed(contentType) {
var _a;
const baseSpeed = ((_a = this.plugin) == null ? void 0 : _a.settings.readingSpeed) || 200;
if (contentType) {
const baseContentSpeed = this.READING_SPEEDS.notes;
const contentSpeed = this.READING_SPEEDS[contentType];
return baseSpeed * (contentSpeed / baseContentSpeed);
}
return baseSpeed;
}
/**
* Estimate review time for a file based on its content
*
* @param file The file to estimate review time for
* @param fileContent Optional file content (to avoid reading file again)
* @param contentType Type of content for reading speed adjustment
* @returns Estimated review time in seconds
*/
static async estimateReviewTime(file, fileContent, contentType = "notes") {
if (!file) {
return this.MIN_REVIEW_TIME;
}
if (!fileContent) {
const sizeEstimate = Math.ceil(file.stat.size / (this.AVG_WORD_LENGTH * 7)) * 60;
return Math.max(this.MIN_REVIEW_TIME, sizeEstimate);
}
const wordCount = this.countWords(fileContent);
const readingSpeed = this.getReadingSpeed(contentType);
const readingTimeMinutes = wordCount / readingSpeed;
const reviewTimeSeconds = Math.ceil(readingTimeMinutes * 60);
return Math.max(this.MIN_REVIEW_TIME, reviewTimeSeconds);
}
/**
* Calculate aggregate review time for multiple notes
*
* @param paths Array of note paths
* @returns Total estimated review time in seconds
*/
static async calculateTotalReviewTime(paths) {
if (!this.plugin) {
return paths.length * this.MIN_REVIEW_TIME;
}
let totalTime = 0;
for (const path of paths) {
totalTime += await this.plugin.dataStorage.estimateReviewTime(path);
}
return totalTime;
}
/**
* Count words in text
*
* @param text Text to count words in
* @returns Number of words
*/
static countWords(text) {
const cleanText = text.replace(/```[\s\S]*?```/g, "").replace(/`.*?`/g, "").replace(/\[.*?\]\(.*?\)/g, "").replace(/\*\*.*?\*\*/g, "$1").replace(/\*.*?\*/g, "$1").replace(/~~.*?~~/g, "$1");
const words = cleanText.match(/\S+/g) || [];
return words.length;
}
/**
* Format seconds as a readable time string
*
* @param seconds Time in seconds
* @returns Formatted time string (e.g., "5 min" or "1 hr 30 min")
*/
static formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} min`;
} else {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hr`;
} else {
return `${hours} hr ${remainingMinutes} min`;
}
}
}
/**
* Format a time estimate with color coding based on duration
*
* @param seconds Time in seconds
* @param element HTML element to update
* @returns Formatted HTML time string with color coding
*/
static formatTimeWithColor(seconds, element) {
const formattedTime = this.formatTime(seconds);
element.setText(formattedTime);
if (seconds < 5 * 60) {
element.addClass("review-time-short");
element.removeClass("review-time-medium");
element.removeClass("review-time-long");
} else if (seconds < 15 * 60) {
element.addClass("review-time-medium");
element.removeClass("review-time-short");
element.removeClass("review-time-long");
} else {
element.addClass("review-time-long");
element.removeClass("review-time-short");
element.removeClass("review-time-medium");
}
}
};
/**
* Reading speeds for different content types (words per minute)
* Used as fallback and for content-specific adjustments
*/
EstimationUtils.READING_SPEEDS = {
notes: 200,
// General notes
technical: 100,
// Technical content
fiction: 250,
// Fiction/prose
simple: 300
// Simple content
};
/**
* Average English word length in characters (including spaces)
*/
EstimationUtils.AVG_WORD_LENGTH = 5.5;
/**
* Minimum review time in seconds
*/
EstimationUtils.MIN_REVIEW_TIME = 30;
// data-storage.ts
var DataStorage = class {
/**
* Initialize data storage
*
* @param plugin Reference to the main plugin
* @param reviewScheduleService Instance of ReviewScheduleService
* @param reviewHistoryService Instance of ReviewHistoryService
* @param reviewSessionService Instance of ReviewSessionService
* @param mcqService Instance of MCQService
*/
constructor(plugin, reviewScheduleService, reviewHistoryService, reviewSessionService, mcqService) {
this.plugin = plugin;
this.reviewScheduleService = reviewScheduleService;
this.reviewHistoryService = reviewHistoryService;
this.reviewSessionService = reviewSessionService;
this.mcqService = mcqService;
}
// Removed ensureDataLoaded() method
// Removed loadData() method (and all localStorage logic within it)
// Removed saveData() method (and all localStorage logic within it)
/**
* Initialize default data when no data is available
* This is now primarily for internal use during integrity checks,
* as main.ts handles initial default loading.
*/
initializeDefaultData() {
this.reviewScheduleService.schedules = {};
this.reviewHistoryService.history = [];
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
this.mcqService.mcqSets = {};
this.mcqService.mcqSessions = {};
this.reviewScheduleService.customNoteOrder = [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = null;
}
/**
* Verify data integrity and fix any issues
* @returns true if data is valid, false if it needed to be fixed
*/
verifyDataIntegrity() {
console.log("Verifying data integrity...");
let isValid = true;
if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== "object") {
console.warn("Invalid schedules data structure");
this.reviewScheduleService.schedules = {};
isValid = false;
} else {
let invalidCount = 0;
for (const path in this.reviewScheduleService.schedules) {
const schedule = this.reviewScheduleService.schedules[path];
if (!schedule || typeof schedule !== "object") {
delete this.reviewScheduleService.schedules[path];
invalidCount++;
isValid = false;
continue;
}
const s = schedule;
if (!("path" in s) || typeof s.path !== "string" || !("lastReviewDate" in s) || s.lastReviewDate !== null && typeof s.lastReviewDate !== "number" || !("nextReviewDate" in s) || typeof s.nextReviewDate !== "number" || !("ease" in s) || typeof s.ease !== "number") {
delete this.reviewScheduleService.schedules[path];
invalidCount++;
isValid = false;
}
}
if (invalidCount > 0) {
console.warn(`Removed ${invalidCount} invalid schedules`);
}
}
if (!Array.isArray(this.reviewHistoryService.history)) {
console.warn("Invalid history data structure");
this.reviewHistoryService.history = [];
isValid = false;
}
if (!this.reviewSessionService.reviewSessions || typeof this.reviewSessionService.reviewSessions !== "object" || !this.reviewSessionService.reviewSessions.sessions || typeof this.reviewSessionService.reviewSessions.sessions !== "object") {
console.warn("Invalid review sessions data structure");
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
isValid = false;
}
if (!this.mcqService.mcqSets || typeof this.mcqService.mcqSets !== "object") {
console.warn("Invalid MCQ sets data structure");
this.mcqService.mcqSets = {};
isValid = false;
}
if (!this.mcqService.mcqSessions || typeof this.mcqService.mcqSessions !== "object") {
console.warn("Invalid MCQ sessions data structure");
this.mcqService.mcqSessions = {};
isValid = false;
}
if (!Array.isArray(this.reviewScheduleService.customNoteOrder)) {
console.warn("Invalid custom note order data structure");
this.reviewScheduleService.customNoteOrder = [];
isValid = false;
}
if (this.reviewScheduleService.lastLinkAnalysisTimestamp !== null && typeof this.reviewScheduleService.lastLinkAnalysisTimestamp !== "number") {
console.warn("Invalid last link analysis timestamp data structure");
this.reviewScheduleService.lastLinkAnalysisTimestamp = null;
isValid = false;
}
const noSchedules = Object.keys(this.reviewScheduleService.schedules).length === 0;
const hasMCQs = Object.keys(this.mcqService.mcqSets).length > 0;
if (noSchedules && hasMCQs) {
console.warn("WARNING: No schedules found but MCQ data exists - possible data inconsistency");
}
console.log("Data integrity check complete:", {
isValid,
schedules: Object.keys(this.reviewScheduleService.schedules).length,
history: this.reviewHistoryService.history.length,
reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions).length,
mcqSets: Object.keys(this.mcqService.mcqSets).length,
mcqSessions: Object.keys(this.mcqService.mcqSessions).length
});
return isValid;
}
/**
* Verify data integrity and remove schedules for files that no longer exist
* @returns {Promise<boolean>} True if any cleanup was performed, false otherwise.
*/
async cleanupNonExistentFiles() {
console.log("Verifying data integrity and cleaning up non-existent files...");
let changesMade = false;
if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== "object") {
console.warn("Schedules is not a valid object, resetting it");
this.reviewScheduleService.schedules = {};
changesMade = true;
}
if (!Array.isArray(this.reviewHistoryService.history)) {
console.warn("History is not a valid array, resetting it");
this.reviewHistoryService.history = [];
changesMade = true;
}
if (!this.reviewSessionService.reviewSessions || typeof this.reviewSessionService.reviewSessions !== "object" || !this.reviewSessionService.reviewSessions.sessions) {
console.warn("Review sessions is not a valid object, resetting it");
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
changesMade = true;
}
let cleanupCount = 0;
const beforeCount = Object.keys(this.reviewScheduleService.schedules).length;
const safetyCheck = {
totalSchedules: beforeCount,
checkedSchedules: 0,
missingSchedules: 0,
preserved: false
};
try {
const allSchedules = { ...this.reviewScheduleService.schedules };
const allFiles = /* @__PURE__ */ new Set();
try {
const mdFiles = this.plugin.app.vault.getMarkdownFiles();
mdFiles.forEach((file) => allFiles.add(file.path));
console.log(`Preloaded ${allFiles.size} markdown files for checking`);
} catch (listError) {
console.error("Error listing markdown files:", listError);
return changesMade;
}
if (allFiles.size === 0 && beforeCount > 0) {
console.warn("No files found in vault but schedules exist - preserving schedules");
safetyCheck.preserved = true;
return changesMade;
}
for (const path in allSchedules) {
try {
safetyCheck.checkedSchedules++;
if (allFiles.has(path))
continue;
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file) {
safetyCheck.missingSchedules++;
delete this.reviewScheduleService.schedules[path];
cleanupCount++;
changesMade = true;
}
} catch (checkError) {
console.warn(`Error checking file at path ${path}:`, checkError);
}
if (safetyCheck.missingSchedules > 0 && safetyCheck.missingSchedules === safetyCheck.checkedSchedules && safetyCheck.checkedSchedules >= 5) {
console.warn(`SAFETY ALERT: Preventing removal of all schedules (${safetyCheck.missingSchedules}/${safetyCheck.totalSchedules} would be removed)`);
this.reviewScheduleService.schedules = allSchedules;
cleanupCount = 0;
changesMade = false;
safetyCheck.preserved = true;
break;
}
}
console.log(`Cleanup complete: ${beforeCount} schedules before, ${Object.keys(this.reviewScheduleService.schedules).length} after (${cleanupCount} removed)`);
console.log(`Safety check: ${safetyCheck.checkedSchedules} checked, ${safetyCheck.missingSchedules} missing, preserved: ${safetyCheck.preserved}`);
if (cleanupCount > 0 && !safetyCheck.preserved) {
console.log(`Spaceforge: Cleaned up ${cleanupCount} non-existent files from schedules. Data needs saving.`);
}
const dataState = {
schedules: Object.keys(this.reviewScheduleService.schedules).length,
history: this.reviewHistoryService.history.length,
reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions || {}).length,
mcqSets: Object.keys(this.mcqService.mcqSets || {}).length,
mcqSessions: Object.keys(this.mcqService.mcqSessions || {}).length
};
console.log("Final data state after cleanup check:", dataState);
if (dataState.schedules === 0 && (dataState.history > 0 || dataState.reviewSessions > 0 || dataState.mcqSets > 0)) {
console.warn("WARNING: No schedules found but other data exists - possible data inconsistency");
}
} catch (error) {
console.error("Error during cleanup:", error);
}
return changesMade;
}
// The following methods are now delegated to the respective service classes.
// They are kept here as public methods to maintain the public API of DataStorage,
// but they now simply call the corresponding method on the service instance.
// REMOVED await this.saveData() from all these methods.
async scheduleNoteForReview(path, daysFromNow = 0) {
await this.reviewScheduleService.scheduleNoteForReview(path, daysFromNow);
}
async recordReview(path, response, isSkipped = false) {
return await this.reviewScheduleService.recordReview(path, response, isSkipped);
}
calculateNewSchedule(currentInterval, currentEase, response) {
const result = this.reviewScheduleService.calculateNewSchedule(currentInterval, currentEase, response);
return { interval: result.interval, ease: result.ease };
}
async skipNote(path, response = 3 /* CorrectWithDifficulty */) {
await this.reviewScheduleService.skipNote(path, response);
}
async postponeNote(path, days = 1) {
await this.reviewScheduleService.postponeNote(path, days);
}
async removeFromReview(path) {
await this.reviewScheduleService.removeFromReview(path);
}
async clearAllSchedules() {
await this.reviewScheduleService.clearAllSchedules();
}
async estimateReviewTime(path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof import_obsidian.TFile))
return 60;
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch (error) {
console.error("Error estimating review time:", error);
return 60;
}
}
async createReviewSession(folderPath, name) {
const session = await this.reviewSessionService.createReviewSession(folderPath, name);
return session;
}
async setActiveSession(sessionId) {
const success = await this.reviewSessionService.setActiveSession(sessionId);
return success;
}
getActiveSession() {
return this.reviewSessionService.getActiveSession();
}
getNextSessionFile() {
return this.reviewSessionService.getNextSessionFile();
}
async advanceActiveSession() {
const moreFiles = await this.reviewSessionService.advanceActiveSession();
return moreFiles;
}
async scheduleNotesInOrder(paths, daysFromNow = 0) {
const count = await this.reviewScheduleService.scheduleNotesInOrder(paths, daysFromNow);
return count;
}
async scheduleSessionForReview(sessionId) {
return await this.reviewSessionService.scheduleSessionForReview(sessionId);
}
async saveMCQSet(mcqSet) {
const id = this.mcqService.saveMCQSet(mcqSet);
return id;
}
getMCQSetForNote(notePath) {
return this.mcqService.getMCQSetForNote(notePath);
}
async saveMCQSession(session) {
this.mcqService.saveMCQSession(session);
}
getMCQSessionsForNote(notePath) {
return this.mcqService.getMCQSessionsForNote(notePath);
}
getLatestMCQSessionForNote(notePath) {
return this.mcqService.getLatestMCQSessionForNote(notePath);
}
async updateCustomNoteOrder(order) {
await this.reviewScheduleService.updateCustomNoteOrder(order);
}
getDueNotesWithCustomOrder(date = Date.now(), useCustomOrder = true) {
return this.reviewScheduleService.getDueNotesWithCustomOrder(date, useCustomOrder);
}
// Removed internal helper methods that were moved to services
};
// controllers/review-controller-core.ts
var import_obsidian3 = require("obsidian");
// ui/review-modal.ts
var import_obsidian2 = require("obsidian");
// node_modules/ts-fsrs/dist/index.mjs
var c = ((s) => (s[s.New = 0] = "New", s[s.Learning = 1] = "Learning", s[s.Review = 2] = "Review", s[s.Relearning = 3] = "Relearning", s))(c || {});
var l = ((s) => (s[s.Manual = 0] = "Manual", s[s.Again = 1] = "Again", s[s.Hard = 2] = "Hard", s[s.Good = 3] = "Good", s[s.Easy = 4] = "Easy", s))(l || {});
var h = class {
static card(t) {
return { ...t, state: h.state(t.state), due: h.time(t.due), last_review: t.last_review ? h.time(t.last_review) : void 0 };
}
static rating(t) {
if (typeof t == "string") {
const e = t.charAt(0).toUpperCase(), i = t.slice(1).toLowerCase(), r = l[`${e}${i}`];
if (r === void 0)
throw new Error(`Invalid rating:[${t}]`);
return r;
} else if (typeof t == "number")
return t;
throw new Error(`Invalid rating:[${t}]`);
}
static state(t) {
if (typeof t == "string") {
const e = t.charAt(0).toUpperCase(), i = t.slice(1).toLowerCase(), r = c[`${e}${i}`];
if (r === void 0)
throw new Error(`Invalid state:[${t}]`);
return r;
} else if (typeof t == "number")
return t;
throw new Error(`Invalid state:[${t}]`);
}
static time(t) {
if (typeof t == "object" && t instanceof Date)
return t;
if (typeof t == "string") {
const e = Date.parse(t);
if (isNaN(e))
throw new Error(`Invalid date:[${t}]`);
return new Date(e);
} else if (typeof t == "number")
return new Date(t);
throw new Error(`Invalid date:[${t}]`);
}
static review_log(t) {
return { ...t, due: h.time(t.due), rating: h.rating(t.rating), state: h.state(t.state), review: h.time(t.review) };
}
};
var X = "4.7.1";
Date.prototype.scheduler = function(s, t) {
return F(this, s, t);
}, Date.prototype.diff = function(s, t) {
return L(this, s, t);
}, Date.prototype.format = function() {
return O(this);
}, Date.prototype.dueFormat = function(s, t, e) {
return j(this, s, t, e);
};
function F(s, t, e) {
return new Date(e ? h.time(s).getTime() + t * 24 * 60 * 60 * 1e3 : h.time(s).getTime() + t * 60 * 1e3);
}
function L(s, t, e) {
if (!s || !t)
throw new Error("Invalid date");
const i = h.time(s).getTime() - h.time(t).getTime();
let r = 0;
switch (e) {
case "days":
r = Math.floor(i / (24 * 60 * 60 * 1e3));
break;
case "minutes":
r = Math.floor(i / (60 * 1e3));
break;
}
return r;
}
function O(s) {
const t = h.time(s), e = t.getFullYear(), i = t.getMonth() + 1, r = t.getDate(), a = t.getHours(), n = t.getMinutes(), d = t.getSeconds();
return `${e}-${p(i)}-${p(r)} ${p(a)}:${p(n)}:${p(d)}`;
}
function p(s) {
return s < 10 ? `0${s}` : `${s}`;
}
var S = [60, 60, 24, 31, 12];
var E = ["second", "min", "hour", "day", "month", "year"];
function j(s, t, e, i = E) {
s = h.time(s), t = h.time(t), i.length !== E.length && (i = E);
let r = s.getTime() - t.getTime(), a;
for (r /= 1e3, a = 0; a < S.length && !(r < S[a]); a++)
r /= S[a];
return `${Math.floor(r)}${e ? i[a] : ""}`;
}
var I = Object.freeze([l.Again, l.Hard, l.Good, l.Easy]);
var Z = [{ start: 2.5, end: 7, factor: 0.15 }, { start: 7, end: 20, factor: 0.1 }, { start: 20, end: 1 / 0, factor: 0.05 }];
function G(s, t, e) {
let i = 1;
for (const n of Z)
i += n.factor * Math.max(Math.min(s, n.end) - n.start, 0);
s = Math.min(s, e);
let r = Math.max(2, Math.round(s - i));
const a = Math.min(Math.round(s + i), e);
return s > t && (r = Math.max(r, t + 1)), r = Math.min(r, a), { min_ivl: r, max_ivl: a };
}
function m(s, t, e) {
return Math.min(Math.max(s, t), e);
}
function N(s, t) {
const e = Date.UTC(s.getUTCFullYear(), s.getUTCMonth(), s.getUTCDate()), i = Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate());
return Math.floor((i - e) / 864e5);
}
var k = 0.9;
var C = 36500;
var T = Object.freeze([0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 46e-4, 1.54575, 0.1192, 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621]);
var U = false;
var q = true;
var tt = `v${X} using FSRS-5.0`;
var _ = 0.01;
var b = 100;
var R = Object.freeze([Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([1, 10]), Object.freeze([1e-3, 4]), Object.freeze([1e-3, 4]), Object.freeze([1e-3, 0.75]), Object.freeze([0, 4.5]), Object.freeze([0, 0.8]), Object.freeze([1e-3, 3.5]), Object.freeze([1e-3, 5]), Object.freeze([1e-3, 0.25]), Object.freeze([1e-3, 0.9]), Object.freeze([0, 4]), Object.freeze([0, 1]), Object.freeze([1, 6]), Object.freeze([0, 2]), Object.freeze([0, 2])]);
var z = (s) => {
var _a, _b;
let t = [...T];
return (s == null ? void 0 : s.w) && (s.w.length === 19 ? t = [...s.w] : s.w.length === 17 && (t = s == null ? void 0 : s.w.concat([0, 0]), t[4] = +(t[5] * 2 + t[4]).toFixed(8), t[5] = +(Math.log(t[5] * 3 + 1) / 3).toFixed(8), t[6] = +(t[6] + 0.5).toFixed(8), console.debug("[FSRS V5]auto fill w to 19 length"))), t = t.map((e, i) => m(e, R[i][0], R[i][1])), { request_retention: (s == null ? void 0 : s.request_retention) || k, maximum_interval: (s == null ? void 0 : s.maximum_interval) || C, w: t, enable_fuzz: (_a = s == null ? void 0 : s.enable_fuzz) != null ? _a : U, enable_short_term: (_b = s == null ? void 0 : s.enable_short_term) != null ? _b : q };
};
function v(s, t) {
const e = { due: s ? h.time(s) : /* @__PURE__ */ new Date(), stability: 0, difficulty: 0, elapsed_days: 0, scheduled_days: 0, reps: 0, lapses: 0, state: c.New, last_review: void 0 };
return t && typeof t == "function" ? t(e) : e;
}
var et = class {
constructor(t) {
__publicField(this, "c");
__publicField(this, "s0");
__publicField(this, "s1");
__publicField(this, "s2");
const e = it();
this.c = 1, this.s0 = e(" "), this.s1 = e(" "), this.s2 = e(" "), t == null && (t = +/* @__PURE__ */ new Date()), this.s0 -= e(t), this.s0 < 0 && (this.s0 += 1), this.s1 -= e(t), this.s1 < 0 && (this.s1 += 1), this.s2 -= e(t), this.s2 < 0 && (this.s2 += 1);
}
next() {
const t = 2091639 * this.s0 + this.c * 23283064365386963e-26;
return this.s0 = this.s1, this.s1 = this.s2, this.s2 = t - (this.c = t | 0), this.s2;
}
set state(t) {
this.c = t.c, this.s0 = t.s0, this.s1 = t.s1, this.s2 = t.s2;
}
get state() {
return { c: this.c, s0: this.s0, s1: this.s1, s2: this.s2 };
}
};
function it() {
let s = 4022871197;
return function(t) {
t = String(t);
for (let e = 0; e < t.length; e++) {
s += t.charCodeAt(e);
let i = 0.02519603282416938 * s;
s = i >>> 0, i -= s, i *= s, s = i >>> 0, i -= s, s += i * 4294967296;
}
return (s >>> 0) * 23283064365386963e-26;
};
}
function rt(s) {
const t = new et(s), e = () => t.next();
return e.int32 = () => t.next() * 4294967296 | 0, e.double = () => e() + (e() * 2097152 | 0) * 11102230246251565e-32, e.state = () => t.state, e.importState = (i) => (t.state = i, e), e;
}
var $ = -0.5;
var D = 19 / 81;
function P(s, t) {
return +Math.pow(1 + D * s / t, $).toFixed(8);
}
var Y = class {
constructor(t) {
__publicField(this, "param");
__publicField(this, "intervalModifier");
__publicField(this, "_seed");
__publicField(this, "forgetting_curve", P);
this.param = new Proxy(z(t), this.params_handler_proxy()), this.intervalModifier = this.calculate_interval_modifier(this.param.request_retention);
}
get interval_modifier() {
return this.intervalModifier;
}
set seed(t) {
this._seed = t;
}
calculate_interval_modifier(t) {
if (t <= 0 || t > 1)
throw new Error("Requested retention rate should be in the range (0,1]");
return +((Math.pow(t, 1 / $) - 1) / D).toFixed(8);
}
get parameters() {
return this.param;
}
set parameters(t) {
this.update_parameters(t);
}
params_handler_proxy() {
const t = this;
return { set: function(e, i, r) {
return i === "request_retention" && Number.isFinite(r) && (t.intervalModifier = t.calculate_interval_modifier(Number(r))), Reflect.set(e, i, r), true;
} };
}
update_parameters(t) {
const e = z(t);
for (const i in e)
if (i in this.param) {
const r = i;
this.param[r] = e[r];
}
}
init_stability(t) {
return Math.max(this.param.w[t - 1], 0.1);
}
init_difficulty(t) {
return this.constrain_difficulty(this.param.w[4] - Math.exp((t - 1) * this.param.w[5]) + 1);
}
apply_fuzz(t, e) {
if (!this.param.enable_fuzz || t < 2.5)
return Math.round(t);
const i = rt(this._seed)(), { min_ivl: r, max_ivl: a } = G(t, e, this.param.maximum_interval);
return Math.floor(i * (a - r + 1) + r);
}
next_interval(t, e) {
const i = Math.min(Math.max(1, Math.round(t * this.intervalModifier)), this.param.maximum_interval);
return this.apply_fuzz(i, e);
}
linear_damping(t, e) {
return +(t * (10 - e) / 9).toFixed(8);
}
next_difficulty(t, e) {
const i = -this.param.w[6] * (e - 3), r = t + this.linear_damping(i, t);
return this.constrain_difficulty(this.mean_reversion(this.init_difficulty(l.Easy), r));
}
constrain_difficulty(t) {
return Math.min(Math.max(+t.toFixed(8), 1), 10);
}
mean_reversion(t, e) {
return +(this.param.w[7] * t + (1 - this.param.w[7]) * e).toFixed(8);
}
next_recall_stability(t, e, i, r) {
const a = l.Hard === r ? this.param.w[15] : 1, n = l.Easy === r ? this.param.w[16] : 1;
return +m(e * (1 + Math.exp(this.param.w[8]) * (11 - t) * Math.pow(e, -this.param.w[9]) * (Math.exp((1 - i) * this.param.w[10]) - 1) * a * n), _, 36500).toFixed(8);
}
next_forget_stability(t, e, i) {
return +m(this.param.w[11] * Math.pow(t, -this.param.w[12]) * (Math.pow(e + 1, this.param.w[13]) - 1) * Math.exp((1 - i) * this.param.w[14]), _, 36500).toFixed(8);
}
next_short_term_stability(t, e) {
return +m(t * Math.exp(this.param.w[17] * (e - 3 + this.param.w[18])), _, 36500).toFixed(8);
}
next_state(t, e, i) {
const { difficulty: r, stability: a } = t != null ? t : { difficulty: 0, stability: 0 };
if (e < 0)
throw new Error(`Invalid delta_t "${e}"`);
if (i < 0 || i > 4)
throw new Error(`Invalid grade "${i}"`);
if (r === 0 && a === 0)
return { difficulty: this.init_difficulty(i), stability: this.init_stability(i) };
if (i === 0)
return { difficulty: r, stability: a };
if (r < 1 || a < _)
throw new Error(`Invalid memory state { difficulty: ${r}, stability: ${a} }`);
const n = this.forgetting_curve(e, a), d = this.next_recall_stability(r, a, n, i), u = this.next_forget_stability(r, a, n), o = this.next_short_term_stability(a, i);
let f = d;
if (i === 1) {
let [y, w] = [0, 0];
this.param.enable_short_term && (y = this.param.w[17], w = this.param.w[18]);
const g = a / Math.exp(y * w);
f = m(+g.toFixed(8), _, u);
}
return e === 0 && this.param.enable_short_term && (f = o), { difficulty: this.next_difficulty(r, i), stability: f };
}
};
function H() {
const s = this.review_time.getTime(), t = this.current.reps, e = this.current.difficulty * this.current.stability;
return `${s}_${t}_${e}`;
}
var x = ((s) => (s.SCHEDULER = "Scheduler", s.SEED = "Seed", s))(x || {});
var A = class {
constructor(t, e, i, r = { seed: H }) {
__publicField(this, "last");
__publicField(this, "current");
__publicField(this, "review_time");
__publicField(this, "next", /* @__PURE__ */ new Map());
__publicField(this, "algorithm");
__publicField(this, "initSeedStrategy");
this.algorithm = i, this.initSeedStrategy = r.seed.bind(this), this.last = h.card(t), this.current = h.card(t), this.review_time = h.time(e), this.init();
}
init() {
const { state: t, last_review: e } = this.current;
let i = 0;
t !== c.New && e && (i = N(e, this.review_time)), this.current.last_review = this.review_time, this.current.elapsed_days = i, this.current.reps += 1, this.algorithm.seed = this.initSeedStrategy();
}
preview() {
return { [l.Again]: this.review(l.Again), [l.Hard]: this.review(l.Hard), [l.Good]: this.review(l.Good), [l.Easy]: this.review(l.Easy), [Symbol.iterator]: this.previewIterator.bind(this) };
}
*previewIterator() {
for (const t of I)
yield this.review(t);
}
review(t) {
const { state: e } = this.last;
let i;
switch (e) {
case c.New:
i = this.newState(t);
break;
case c.Learning:
case c.Relearning:
i = this.learningState(t);
break;
case c.Review:
i = this.reviewState(t);
break;
}
if (i)
return i;
throw new Error("Invalid grade");
}
buildLog(t) {
const { last_review: e, due: i, elapsed_days: r } = this.last;
return { rating: t, state: this.current.state, due: e || i, stability: this.current.stability, difficulty: this.current.difficulty, elapsed_days: this.current.elapsed_days, last_elapsed_days: r, scheduled_days: this.current.scheduled_days, review: this.review_time };
}
};
var V = class extends A {
newState(t) {
const e = this.next.get(t);
if (e)
return e;
const i = h.card(this.current);
switch (i.difficulty = this.algorithm.init_difficulty(t), i.stability = this.algorithm.init_stability(t), t) {
case l.Again:
i.scheduled_days = 0, i.due = this.review_time.scheduler(1), i.state = c.Learning;
break;
case l.Hard:
i.scheduled_days = 0, i.due = this.review_time.scheduler(5), i.state = c.Learning;
break;
case l.Good:
i.scheduled_days = 0, i.due = this.review_time.scheduler(10), i.state = c.Learning;
break;
case l.Easy: {
const a = this.algorithm.next_interval(i.stability, this.current.elapsed_days);
i.scheduled_days = a, i.due = this.review_time.scheduler(a, true), i.state = c.Review;
break;
}
default:
throw new Error("Invalid grade");
}
const r = { card: i, log: this.buildLog(t) };
return this.next.set(t, r), r;
}
learningState(t) {
const e = this.next.get(t);
if (e)
return e;
const { state: i, difficulty: r, stability: a } = this.last, n = h.card(this.current), d = this.current.elapsed_days;
switch (n.difficulty = this.algorithm.next_difficulty(r, t), n.stability = this.algorithm.next_short_term_stability(a, t), t) {
case l.Again: {
n.scheduled_days = 0, n.due = this.review_time.scheduler(5, false), n.state = i;
break;
}
case l.Hard: {
n.scheduled_days = 0, n.due = this.review_time.scheduler(10), n.state = i;
break;
}
case l.Good: {
const o = this.algorithm.next_interval(n.stability, d);
n.scheduled_days = o, n.due = this.review_time.scheduler(o, true), n.state = c.Review;
break;
}
case l.Easy: {
const o = this.algorithm.next_short_term_stability(a, l.Good), f = this.algorithm.next_interval(o, d), y = Math.max(this.algorithm.next_interval(n.stability, d), f + 1);
n.scheduled_days = y, n.due = this.review_time.scheduler(y, true), n.state = c.Review;
break;
}
default:
throw new Error("Invalid grade");
}
const u = { card: n, log: this.buildLog(t) };
return this.next.set(t, u), u;
}
reviewState(t) {
const e = this.next.get(t);
if (e)
return e;
const i = this.current.elapsed_days, { difficulty: r, stability: a } = this.last, n = this.algorithm.forgetting_curve(i, a), d = h.card(this.current), u = h.card(this.current), o = h.card(this.current), f = h.card(this.current);
this.next_ds(d, u, o, f, r, a, n), this.next_interval(d, u, o, f, i), this.next_state(d, u, o, f), d.lapses += 1;
const y = { card: d, log: this.buildLog(l.Again) }, w = { card: u, log: super.buildLog(l.Hard) }, g = { card: o, log: super.buildLog(l.Good) }, M = { card: f, log: super.buildLog(l.Easy) };
return this.next.set(l.Again, y), this.next.set(l.Hard, w), this.next.set(l.Good, g), this.next.set(l.Easy, M), this.next.get(t);
}
next_ds(t, e, i, r, a, n, d) {
t.difficulty = this.algorithm.next_difficulty(a, l.Again);
const u = n / Math.exp(this.algorithm.parameters.w[17] * this.algorithm.parameters.w[18]), o = this.algorithm.next_forget_stability(a, n, d);
t.stability = m(+u.toFixed(8), _, o), e.difficulty = this.algorithm.next_difficulty(a, l.Hard), e.stability = this.algorithm.next_recall_stability(a, n, d, l.Hard), i.difficulty = this.algorithm.next_difficulty(a, l.Good), i.stability = this.algorithm.next_recall_stability(a, n, d, l.Good), r.difficulty = this.algorithm.next_difficulty(a, l.Easy), r.stability = this.algorithm.next_recall_stability(a, n, d, l.Easy);
}
next_interval(t, e, i, r, a) {
let n, d;
n = this.algorithm.next_interval(e.stability, a), d = this.algorithm.next_interval(i.stability, a), n = Math.min(n, d), d = Math.max(d, n + 1);
const u = Math.max(this.algorithm.next_interval(r.stability, a), d + 1);
t.scheduled_days = 0, t.due = this.review_time.scheduler(5), e.scheduled_days = n, e.due = this.review_time.scheduler(n, true), i.scheduled_days = d, i.due = this.review_time.scheduler(d, true), r.scheduled_days = u, r.due = this.review_time.scheduler(u, true);
}
next_state(t, e, i, r) {
t.state = c.Relearning, e.state = c.Review, i.state = c.Review, r.state = c.Review;
}
};
var B = class extends A {
newState(t) {
const e = this.next.get(t);
if (e)
return e;
this.current.scheduled_days = 0, this.current.elapsed_days = 0;
const i = h.card(this.current), r = h.card(this.current), a = h.card(this.current), n = h.card(this.current);
return this.init_ds(i, r, a, n), this.next_interval(i, r, a, n, 0), this.next_state(i, r, a, n), this.update_next(i, r, a, n), this.next.get(t);
}
init_ds(t, e, i, r) {
t.difficulty = this.algorithm.init_difficulty(l.Again), t.stability = this.algorithm.init_stability(l.Again), e.difficulty = this.algorithm.init_difficulty(l.Hard), e.stability = this.algorithm.init_stability(l.Hard), i.difficulty = this.algorithm.init_difficulty(l.Good), i.stability = this.algorithm.init_stability(l.Good), r.difficulty = this.algorithm.init_difficulty(l.Easy), r.stability = this.algorithm.init_stability(l.Easy);
}
learningState(t) {
return this.reviewState(t);
}
reviewState(t) {
const e = this.next.get(t);
if (e)
return e;
const i = this.current.elapsed_days, { difficulty: r, stability: a } = this.last, n = this.algorithm.forgetting_curve(i, a), d = h.card(this.current), u = h.card(this.current), o = h.card(this.current), f = h.card(this.current);
return this.next_ds(d, u, o, f, r, a, n), this.next_interval(d, u, o, f, i), this.next_state(d, u, o, f), d.lapses += 1, this.update_next(d, u, o, f), this.next.get(t);
}
next_ds(t, e, i, r, a, n, d) {
t.difficulty = this.algorithm.next_difficulty(a, l.Again);
const u = this.algorithm.next_forget_stability(a, n, d);
t.stability = m(n, _, u), e.difficulty = this.algorithm.next_difficulty(a, l.Hard), e.stability = this.algorithm.next_recall_stability(a, n, d, l.Hard), i.difficulty = this.algorithm.next_difficulty(a, l.Good), i.stability = this.algorithm.next_recall_stability(a, n, d, l.Good), r.difficulty = this.algorithm.next_difficulty(a, l.Easy), r.stability = this.algorithm.next_recall_stability(a, n, d, l.Easy);
}
next_interval(t, e, i, r, a) {
let n, d, u, o;
n = this.algorithm.next_interval(t.stability, a), d = this.algorithm.next_interval(e.stability, a), u = this.algorithm.next_interval(i.stability, a), o = this.algorithm.next_interval(r.stability, a), n = Math.min(n, d), d = Math.max(d, n + 1), u = Math.max(u, d + 1), o = Math.max(o, u + 1), t.scheduled_days = n, t.due = this.review_time.scheduler(n, true), e.scheduled_days = d, e.due = this.review_time.scheduler(d, true), i.scheduled_days = u, i.due = this.review_time.scheduler(u, true), r.scheduled_days = o, r.due = this.review_time.scheduler(o, true);
}
next_state(t, e, i, r) {
t.state = c.Review, e.state = c.Review, i.state = c.Review, r.state = c.Review;
}
update_next(t, e, i, r) {
const a = { card: t, log: this.buildLog(l.Again) }, n = { card: e, log: super.buildLog(l.Hard) }, d = { card: i, log: super.buildLog(l.Good) }, u = { card: r, log: super.buildLog(l.Easy) };
this.next.set(l.Again, a), this.next.set(l.Hard, n), this.next.set(l.Good, d), this.next.set(l.Easy, u);
}
};
var st = class {
constructor(t) {
__publicField(this, "fsrs");
this.fsrs = t;
}
replay(t, e, i) {
return this.fsrs.next(t, e, i);
}
handleManualRating(t, e, i, r, a, n, d) {
if (typeof e > "u")
throw new Error("reschedule: state is required for manual rating");
let u, o;
if (e === c.New)
u = { rating: l.Manual, state: e, due: d != null ? d : i, stability: t.stability, difficulty: t.difficulty, elapsed_days: r, last_elapsed_days: t.elapsed_days, scheduled_days: t.scheduled_days, review: i }, o = v(i), o.last_review = i;
else {
if (typeof d > "u")
throw new Error("reschedule: due is required for manual rating");
const f = d.diff(i, "days");
u = { rating: l.Manual, state: t.state, due: t.last_review || t.due, stability: t.stability, difficulty: t.difficulty, elapsed_days: r, last_elapsed_days: t.elapsed_days, scheduled_days: t.scheduled_days, review: i }, o = { ...t, state: e, due: d, last_review: i, stability: a || t.stability, difficulty: n || t.difficulty, elapsed_days: r, scheduled_days: f, reps: t.reps + 1 };
}
return { card: o, log: u };
}
reschedule(t, e) {
const i = [];
let r = v(t.due);
for (const a of e) {
let n;
if (a.review = h.time(a.review), a.rating === l.Manual) {
let d = 0;
r.state !== c.New && r.last_review && (d = a.review.diff(r.last_review, "days")), n = this.handleManualRating(r, a.state, a.review, d, a.stability, a.difficulty, a.due ? h.time(a.due) : void 0);
} else
n = this.replay(r, a.review, a.rating);
i.push(n), r = n.card;
}
return i;
}
calculateManualRecord(t, e, i, r) {
if (!i)
return null;
const { card: a, log: n } = i, d = h.card(t);
return d.due.getTime() === a.due.getTime() ? null : (d.scheduled_days = a.due.diff(d.due, "days"), this.handleManualRating(d, a.state, h.time(e), n.elapsed_days, r ? a.stability : void 0, r ? a.difficulty : void 0, a.due));
}
};
var W = class extends Y {
constructor(t) {
super(t);
__publicField(this, "strategyHandler", /* @__PURE__ */ new Map());
__publicField(this, "Scheduler");
const { enable_short_term: e } = this.parameters;
this.Scheduler = e ? V : B;
}
params_handler_proxy() {
const t = this;
return { set: function(e, i, r) {
return i === "request_retention" && Number.isFinite(r) ? t.intervalModifier = t.calculate_interval_modifier(Number(r)) : i === "enable_short_term" && (t.Scheduler = r === true ? V : B), Reflect.set(e, i, r), true;
} };
}
useStrategy(t, e) {
return this.strategyHandler.set(t, e), this;
}
clearStrategy(t) {
return t ? this.strategyHandler.delete(t) : this.strategyHandler.clear(), this;
}
getScheduler(t, e) {
const i = this.strategyHandler.get(x.SEED), r = this.strategyHandler.get(x.SCHEDULER) || this.Scheduler, a = i || H;
return new r(t, e, this, { seed: a });
}
repeat(t, e, i) {
const r = this.getScheduler(t, e).preview();
return i && typeof i == "function" ? i(r) : r;
}
next(t, e, i, r) {
const a = this.getScheduler(t, e), n = h.rating(i);
if (n === l.Manual)
throw new Error("Cannot review a manual rating");
const d = a.review(n);
return r && typeof r == "function" ? r(d) : d;
}
get_retrievability(t, e, i = true) {
const r = h.card(t);
e = e ? h.time(e) : /* @__PURE__ */ new Date();
const a = r.state !== c.New ? Math.max(e.diff(r.last_review, "days"), 0) : 0, n = r.state !== c.New ? this.forgetting_curve(a, +r.stability.toFixed(8)) : 0;
return i ? `${(n * 100).toFixed(2)}%` : n;
}
rollback(t, e, i) {
const r = h.card(t), a = h.review_log(e);
if (a.rating === l.Manual)
throw new Error("Cannot rollback a manual rating");
let n, d, u;
switch (a.state) {
case c.New:
n = a.due, d = void 0, u = 0;
break;
case c.Learning:
case c.Relearning:
case c.Review:
n = a.review, d = a.due, u = r.lapses - (a.rating === l.Again && a.state === c.Review ? 1 : 0);
break;
}
const o = { ...r, due: n, stability: a.stability, difficulty: a.difficulty, elapsed_days: a.last_elapsed_days, scheduled_days: a.scheduled_days, reps: Math.max(0, r.reps - 1), lapses: Math.max(0, u), state: a.state, last_review: d };
return i && typeof i == "function" ? i(o) : o;
}
forget(t, e, i = false, r) {
const a = h.card(t);
e = h.time(e);
const n = a.state === c.New ? 0 : e.diff(a.last_review, "days"), d = { rating: l.Manual, state: a.state, due: a.due, stability: a.stability, difficulty: a.difficulty, elapsed_days: 0, last_elapsed_days: a.elapsed_days, scheduled_days: n, review: e }, u = { card: { ...a, due: e, stability: 0, difficulty: 0, elapsed_days: 0, scheduled_days: 0, reps: i ? 0 : a.reps, lapses: i ? 0 : a.lapses, state: c.New, last_review: a.last_review }, log: d };
return r && typeof r == "function" ? r(u) : u;
}
reschedule(t, e = [], i = {}) {
const { recordLogHandler: r, reviewsOrderBy: a, skipManual: n = true, now: d = /* @__PURE__ */ new Date(), update_memory_state: u = false } = i;
a && typeof a == "function" && e.sort(a), n && (e = e.filter((M) => M.rating !== l.Manual));
const o = new st(this), f = o.reschedule(i.first_card || v(), e), y = f.length, w = h.card(t), g = o.calculateManualRecord(w, d, y ? f[y - 1] : void 0, u);
return r && typeof r == "function" ? { collections: f.map(r), reschedule_item: g ? r(g) : null } : { collections: f, reschedule_item: g };
}
};
// ui/review-modal.ts
var ReviewModal = class extends import_obsidian2.Modal {
constructor(app, plugin, path) {
super(app);
this.plugin = plugin;
this.path = path;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Review Note" });
const buttonsContainer = contentEl.createDiv("review-buttons-container");
const schedule = this.plugin.reviewScheduleService.schedules[this.path];
if (schedule && schedule.schedulingAlgorithm === "fsrs") {
const createFsrsButton = (text, clsSuffix, rating) => {
const button = buttonsContainer.createEl("button", { text, cls: `review-button review-button-fsrs-${clsSuffix}` });
button.addEventListener("click", () => {
this.plugin.reviewController.processReviewResponse(this.path, rating);
this.close();
});
};
createFsrsButton("1: Again", "again", 1 /* Again */);
createFsrsButton("2: Hard", "hard", 2 /* Hard */);
createFsrsButton("3: Good", "good", 3 /* Good */);
createFsrsButton("4: Easy", "easy", 4 /* Easy */);
} else {
const createSm2Button = (text, cls, response) => {
const button = buttonsContainer.createEl("button", { text, cls });
button.addEventListener("click", () => {
this.plugin.reviewController.processReviewResponse(this.path, response);
this.close();
});
};
createSm2Button("0: Complete Blackout", "review-button review-button-complete-blackout", 0 /* CompleteBlackout */);
createSm2Button("1: Incorrect Response", "review-button review-button-incorrect", 1 /* IncorrectResponse */);
createSm2Button("2: Incorrect but Familiar", "review-button review-button-incorrect-familiar", 2 /* IncorrectButFamiliar */);
createSm2Button("3: Correct with Difficulty", "review-button review-button-correct-difficulty", 3 /* CorrectWithDifficulty */);
createSm2Button("4: Correct with Hesitation", "review-button review-button-correct-hesitation", 4 /* CorrectWithHesitation */);
createSm2Button("5: Perfect Recall", "review-button review-button-perfect-recall", 5 /* PerfectRecall */);
}
buttonsContainer.createEl("div", { cls: "review-button-separator" });
const postponeButton = buttonsContainer.createEl("button", { text: "Postpone to Tomorrow", cls: "review-button review-button-postpone" });
postponeButton.addEventListener("click", () => {
this.plugin.reviewController.skipReview(this.path);
this.close();
});
const skipButton = buttonsContainer.createEl("button", { text: "Skip/Next", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", async () => {
this.close();
if (this.plugin.navigationController) {
await this.plugin.navigationController.navigateToNextNoteWithoutRating();
}
});
if (this.plugin.settings.enableMCQ) {
buttonsContainer.createEl("div", { cls: "review-button-separator" });
const mcqButton = buttonsContainer.createEl("button", { cls: "review-button review-button-mcq" });
const mcqIconSpan = mcqButton.createSpan("mcq-button-icon");
(0, import_obsidian2.setIcon)(mcqIconSpan, "mcq-quiz");
const textSpan = mcqButton.createSpan("mcq-button-text");
textSpan.setText("Test with MCQs");
mcqButton.addEventListener("click", () => {
const mcqController2 = this.plugin.mcqController;
if (mcqController2) {
mcqController2.startMCQReview(this.path);
this.close();
} else {
this.plugin.initializeMCQComponents();
const initializedMcqController = this.plugin.mcqController;
if (initializedMcqController) {
initializedMcqController.startMCQReview(this.path);
this.close();
} else {
new import_obsidian2.Notice("MCQ feature could not be initialized. Please check settings.");
}
}
});
const mcqController = this.plugin.mcqController;
if (mcqController && this.plugin.mcqService.getMCQSetForNote(this.path)) {
const refreshMcqButton = buttonsContainer.createEl("button", { cls: "review-button review-button-mcq-refresh" });
const refreshIconSpan = refreshMcqButton.createSpan("mcq-button-icon");
(0, import_obsidian2.setIcon)(refreshIconSpan, "refresh-cw");
const refreshTextSpan = refreshMcqButton.createSpan("mcq-button-text");
refreshTextSpan.setText("Generate New MCQs");
refreshMcqButton.addEventListener("click", async () => {
if (mcqController) {
new import_obsidian2.Notice("Generating new MCQs...");
const success = await mcqController.generateMCQs(this.path, true);
if (success) {
mcqController.startMCQReview(this.path);
this.close();
} else {
new import_obsidian2.Notice("Failed to generate new MCQs");
}
}
});
}
}
const infoText = contentEl.createDiv("review-info-text");
infoText.empty();
if (schedule) {
const file = this.app.vault.getAbstractFileByPath(this.path);
const fileName = file instanceof import_obsidian2.TFile ? file.basename : this.path;
infoText.createEl("p", { text: `Reviewing: ${fileName}` });
const activeSession = this.plugin.reviewSessionService.getActiveSession();
if (activeSession) {
const currentIndex = activeSession.currentIndex;
const totalFiles = activeSession.hierarchy.traversalOrder.length;
infoText.createEl("p", { text: `Session: ${activeSession.name} (${currentIndex + 1}/${totalFiles})`, cls: "review-session-info" });
}
if (schedule.lastReviewDate)
infoText.createEl("p", { text: `Last reviewed: ${new Date(schedule.lastReviewDate).toLocaleDateString()}` });
if (schedule.schedulingAlgorithm === "fsrs" && schedule.fsrsData) {
infoText.createEl("p", { text: `Algorithm: FSRS`, cls: "review-phase-fsrs" });
infoText.createEl("p", { text: `Stability: ${schedule.fsrsData.stability.toFixed(2)}` });
infoText.createEl("p", { text: `Difficulty: ${schedule.fsrsData.difficulty.toFixed(2)}` });
infoText.createEl("p", { text: `State: ${c[schedule.fsrsData.state]}` });
infoText.createEl("p", { text: `Interval: ${schedule.fsrsData.scheduled_days} days` });
} else {
infoText.createEl("p", { text: `Algorithm: SM-2`, cls: "review-phase-sm2" });
let phaseText;
let phaseClass;
if (schedule.scheduleCategory === "initial") {
const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length;
const currentStepDisplay = (schedule.reviewCount || 0) < totalInitialSteps ? (schedule.reviewCount || 0) + 1 : totalInitialSteps;
phaseText = `Initial phase (${currentStepDisplay}/${totalInitialSteps})`;
phaseClass = "review-phase-initial";
} else if (schedule.scheduleCategory === "graduated") {
phaseText = "Graduated (Spaced Repetition)";
phaseClass = "review-phase-graduated";
} else {
phaseText = "Spaced Repetition";
phaseClass = "review-phase-spaced";
}
infoText.createEl("p", { text: phaseText, cls: phaseClass });
infoText.createEl("p", { text: `Current ease: ${schedule.ease}` });
infoText.createEl("p", { text: `Current interval: ${schedule.interval} days` });
}
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
};
// controllers/review-controller-core.ts
var ReviewControllerCore = class {
/**
* Initialize review controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
/**
* Currently loaded notes due for review
*/
this.todayNotes = [];
/**
* Current index in today's notes
*/
this.currentNoteIndex = 0;
/**
* Cache of linked notes to improve performance
*/
this.linkedNoteCache = /* @__PURE__ */ new Map();
/**
* Traversal order of notes for hierarchical navigation
*/
this.traversalOrder = [];
/**
* Map of paths to their position in the traversal order
* Used for fast lookups during navigation
*/
this.traversalPositions = /* @__PURE__ */ new Map();
/**
* Optional override for the current date, for testing or reviewing past/future notes.
* If null, Date.now() is used.
*/
this.currentReviewDateOverride = null;
this.plugin = plugin;
this.updateTodayNotes();
}
/**
* Sets an override for the current review date.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
async setReviewDateOverride(date) {
this.currentReviewDateOverride = date;
console.log(`Review date override set to: ${date ? new Date(date).toISOString() : "null (use current time)"}`);
await this.updateTodayNotes();
}
/**
* Gets the effective review date (override or actual Date.now()).
* @returns Timestamp for the effective review date.
*/
getEffectiveReviewDate() {
var _a;
return (_a = this.currentReviewDateOverride) != null ? _a : Date.now();
}
/**
* Gets the current review date override.
* @returns Timestamp of the override, or null if no override is set.
*/
getCurrentReviewDateOverride() {
return this.currentReviewDateOverride;
}
/**
* Get the currently loaded notes due for review
*/
getTodayNotes() {
return this.todayNotes;
}
/**
* Get the current index in today's notes
*/
getCurrentNoteIndex() {
return this.currentNoteIndex;
}
/**
* Set the current index in today's notes
*
* @param index The new index
*/
setCurrentNoteIndex(index) {
if (index >= 0 && index < this.todayNotes.length) {
this.currentNoteIndex = index;
console.log(`Set currentNoteIndex to: ${index}`);
} else {
console.warn(`Attempted to set invalid currentNoteIndex: ${index}. Max index is ${this.todayNotes.length - 1}`);
this.currentNoteIndex = Math.max(0, Math.min(index, this.todayNotes.length - 1));
}
}
/**
* Update the list of today's due notes
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex = false) {
let currentNotePath = null;
if (preserveCurrentIndex && this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) {
currentNotePath = this.todayNotes[this.currentNoteIndex].path;
}
this.linkedNoteCache.clear();
this.traversalOrder = [];
this.traversalPositions.clear();
const originalTraversalOrder = [...this.traversalOrder];
const originalPositions = new Map(this.traversalPositions);
const effectiveDate = this.getEffectiveReviewDate();
const matchExactDate = this.currentReviewDateOverride !== null;
const newDueNotes = this.plugin.dataStorage.reviewScheduleService.getDueNotesWithCustomOrder(effectiveDate, true, matchExactDate);
this.todayNotes = newDueNotes;
this.traversalOrder = this.todayNotes.map((note) => note.path);
this.traversalPositions = /* @__PURE__ */ new Map();
this.traversalOrder.forEach((path, index) => {
this.traversalPositions.set(path, index);
});
if (!preserveCurrentIndex) {
this.currentNoteIndex = 0;
}
this.linkedNoteCache.clear();
if (this.todayNotes.length === 0) {
return;
}
if (preserveCurrentIndex && currentNotePath) {
const newIndex = this.todayNotes.findIndex((note) => note.path === currentNotePath);
if (newIndex !== -1) {
this.currentNoteIndex = newIndex;
} else {
this.currentNoteIndex = 0;
}
this.currentNoteIndex = Math.min(Math.max(0, newIndex), this.todayNotes.length - 1);
}
console.log(`Updated today's notes list with ${this.todayNotes.length} notes.`);
}
/**
* Review the current note
*/
async reviewCurrentNote() {
if (this.todayNotes.length === 0) {
await this.updateTodayNotes();
if (this.todayNotes.length === 0) {
new import_obsidian3.Notice("No notes due for review today!");
return;
}
}
const note = this.todayNotes[this.currentNoteIndex];
await this.reviewNote(note.path);
}
/**
* Start a review for a note
*
* @param path Path to the note file
*/
async reviewNote(path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof import_obsidian3.TFile)) {
new import_obsidian3.Notice("Cannot review: file not found");
return;
}
await this.plugin.app.workspace.getLeaf().openFile(file);
this.showReviewModal(path);
}
/**
* Postpone a note's review
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path, days = 1) {
await this.plugin.dataStorage.reviewScheduleService.postponeNote(path, days);
await this.plugin.savePluginData();
await this.handleNotePostponed(path);
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
}
/**
* Advance a note's review by one day, if eligible.
*
* @param path Path to the note file
*/
async advanceNote(path) {
const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
if (advanced) {
await this.plugin.savePluginData();
await this.handleNoteAdvanced(path);
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
new import_obsidian3.Notice(`Note advanced.`);
} else {
new import_obsidian3.Notice(`Note is not eligible to be advanced.`);
}
}
/**
* Handle a note being advanced, updating navigation state.
* This primarily involves re-evaluating the todayNotes list.
*
* @param path Path to the advanced note
*/
async handleNoteAdvanced(path) {
var _a;
console.log(`Handling advanced note: ${path}`);
await this.updateTodayNotes(true);
console.log(`After advance - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`);
console.log(`Current note after advance update: ${(_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path}, index: ${this.currentNoteIndex}`);
}
/**
* Handle a note being postponed, updating navigation state
*
* @param path Path to the postponed note
*/
async handleNotePostponed(path) {
var _a;
console.log(`Handling postponed note: ${path}`);
if (this.todayNotes.length === 0)
return;
const postponedIndex = this.todayNotes.findIndex((note) => note.path === path);
if (postponedIndex === -1) {
console.log(`Note ${path} not found in today's notes`);
return;
}
const wasCurrentNote = postponedIndex === this.currentNoteIndex;
const currentNotePath = this.currentNoteIndex < this.todayNotes.length ? this.todayNotes[this.currentNoteIndex].path : null;
console.log(`Current note before update: ${currentNotePath}, index: ${this.currentNoteIndex}`);
this.traversalOrder = this.traversalOrder.filter((p2) => p2 !== path);
this.traversalPositions.delete(path);
this.traversalOrder.forEach((p2, i) => this.traversalPositions.set(p2, i));
this.todayNotes = this.todayNotes.filter((n) => n.path !== path);
if (wasCurrentNote) {
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
} else if (currentNotePath) {
const newIndex = this.todayNotes.findIndex((n) => n.path === currentNotePath);
this.currentNoteIndex = newIndex !== -1 ? newIndex : Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
}
await this.plugin.dataStorage.reviewScheduleService.updateCustomNoteOrder(this.traversalOrder);
console.log(`After postpone - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`);
if (wasCurrentNote) {
console.log(`Postponed note was the current note, selecting new current note`);
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
} else if (currentNotePath) {
const newIndex = this.todayNotes.findIndex((note) => note.path === currentNotePath);
if (newIndex !== -1) {
console.log(`Keeping previously selected note: ${currentNotePath}`);
this.currentNoteIndex = newIndex;
} else {
console.log(`Previously selected note no longer in list, adjusting index`);
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
}
} else {
console.log(`Adjusting index to remain in bounds`);
if (this.currentNoteIndex >= this.todayNotes.length) {
this.currentNoteIndex = Math.max(0, this.todayNotes.length - 1);
}
}
console.log(`Current note after update: ${(_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path}, index: ${this.currentNoteIndex}`);
}
/**
* Show the review modal for a note
*
* @param path Path to the note file
*/
showReviewModal(path) {
const modal = new ReviewModal(this.plugin.app, this.plugin, path);
modal.open();
}
/**
* Skip the review of a note and reschedule for tomorrow with penalty
*
* @param path Path to the note file
*/
async skipReview(path) {
const effectiveDate = this.getEffectiveReviewDate();
await this.plugin.dataStorage.reviewScheduleService.skipNote(path, 3 /* CorrectWithDifficulty */, effectiveDate);
await this.plugin.savePluginData();
new import_obsidian3.Notice("Review postponed to tomorrow. Note will be easier to recover with a small penalty applied.");
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
await this.updateTodayNotes(true);
if (this.todayNotes.length > 0) {
this.currentNoteIndex = (this.currentNoteIndex + 1) % this.todayNotes.length;
const currentPath = this.todayNotes[this.currentNoteIndex].path;
if (currentPath === path) {
new import_obsidian3.Notice("All caught up! No more notes due for review.");
return;
}
if (this.plugin.navigationController) {
if (this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) {
const nextNotePath = this.todayNotes[this.currentNoteIndex].path;
await this.plugin.navigationController.openNoteWithoutReview(nextNotePath);
this.showReviewModal(nextNotePath);
} else {
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
} else {
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
} else {
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
}
/**
* Process a review response
*
* @param path Path to the note file
* @param response User's response during review (SM-2 or FSRS)
*/
async processReviewResponse(path, response) {
var _a, _b;
const effectiveDate = this.getEffectiveReviewDate();
const wasRecorded = await this.plugin.dataStorage.reviewScheduleService.recordReview(path, response, false, effectiveDate);
if (!wasRecorded) {
new import_obsidian3.Notice("Note previewed, not recorded");
return;
}
const schedule = this.plugin.dataStorage.reviewScheduleService.schedules[path];
let triggerRegeneration = false;
if (schedule && this.plugin.settings.enableQuestionRegenerationOnRating && this.plugin.mcqService && typeof response === "number") {
if (schedule.schedulingAlgorithm === "fsrs") {
if (response >= this.plugin.settings.minFsrsRatingForQuestionRegeneration) {
triggerRegeneration = true;
console.log(`FSRS Rating ${response} met/exceeded threshold (${this.plugin.settings.minFsrsRatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`);
}
} else {
if (response >= this.plugin.settings.minSm2RatingForQuestionRegeneration) {
triggerRegeneration = true;
console.log(`SM-2 Rating ${response} met/exceeded threshold (${this.plugin.settings.minSm2RatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`);
}
}
}
if (triggerRegeneration) {
this.plugin.mcqService.flagMCQSetForRegeneration(path);
}
let responseText;
if (schedule && schedule.schedulingAlgorithm === "fsrs") {
switch (response) {
case 1 /* Again */:
responseText = "Again (1)";
break;
case 2 /* Hard */:
responseText = "Hard (2)";
break;
case 3 /* Good */:
responseText = "Good (3)";
break;
case 4 /* Easy */:
responseText = "Easy (4)";
break;
default:
responseText = "Unknown FSRS Rating";
}
} else {
switch (response) {
case 0 /* CompleteBlackout */:
responseText = "Complete Blackout (0)";
break;
case 1 /* IncorrectResponse */:
responseText = "Incorrect Response (1)";
break;
case 2 /* IncorrectButFamiliar */:
responseText = "Incorrect but Familiar (2)";
break;
case 3 /* CorrectWithDifficulty */:
responseText = "Correct with Difficulty (3)";
break;
case 4 /* CorrectWithHesitation */:
responseText = "Correct with Hesitation (4)";
break;
case 5 /* PerfectRecall */:
responseText = "Perfect Recall (5)";
break;
default:
responseText = "Unknown SM-2 Rating";
}
}
new import_obsidian3.Notice(`Note review recorded: ${responseText}`);
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
await this.updateTodayNotes(true);
const activeSession = this.plugin.dataStorage.getActiveSession();
if (activeSession) {
await this.plugin.dataStorage.advanceActiveSession();
const nextFilePath = this.plugin.dataStorage.getNextSessionFile();
if (nextFilePath) {
await this.reviewNote(nextFilePath);
} else {
new import_obsidian3.Notice("Hierarchical review session complete!");
}
} else if (this.todayNotes.length > 0) {
console.log("Before navigation - Current Index:", this.currentNoteIndex);
console.log("Before navigation - Current Note:", (_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path);
console.log("Before navigation - Traversal Order:", this.traversalOrder);
this.currentNoteIndex = (this.currentNoteIndex + 1) % this.todayNotes.length;
console.log(`Moving to next note in sidebar order at index ${this.currentNoteIndex}`);
const currentPath = this.todayNotes[this.currentNoteIndex].path;
if (currentPath === path) {
console.log("All notes have been reviewed");
new import_obsidian3.Notice("All caught up! No more notes due for review.");
return;
}
console.log("After navigation - Current Index:", this.currentNoteIndex);
console.log("After navigation - Current Note:", (_b = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _b.path);
if (this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) {
const nextNotePath = this.todayNotes[this.currentNoteIndex].path;
console.log("Opening review modal for:", nextNotePath);
if (this.plugin.navigationController) {
await this.plugin.navigationController.openNoteWithoutReview(nextNotePath);
this.showReviewModal(nextNotePath);
} else {
console.log("No navigation controller available");
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
} else {
console.log("No more notes to review - reached the end");
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
} else {
new import_obsidian3.Notice("All caught up! No more notes due for review.");
}
await this.plugin.savePluginData();
}
};
// controllers/review-navigation-controller.ts
var import_obsidian4 = require("obsidian");
var ReviewNavigationController = class {
/**
* Initialize navigation controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
this.plugin = plugin;
}
/**
* Navigate to the current note without showing review modal
*/
async navigateToCurrentNoteWithoutModal() {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const todayNotes = reviewController.getTodayNotes();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
await reviewController.updateTodayNotes();
if (todayNotes.length === 0) {
new import_obsidian4.Notice("No notes due for review today!");
return;
}
}
const note = todayNotes[currentNoteIndex];
await this.openNoteWithoutReview(note.path);
}
/**
* Navigate to the next note following the current order
*/
async navigateToNextNote() {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
await reviewController.updateTodayNotes(false);
if (todayNotes.length === 0) {
new import_obsidian4.Notice("No notes due for review today!");
return;
}
}
if (todayNotes.length === 1) {
await this.navigateToCurrentNoteWithoutModal();
return;
}
const nextIndex = (currentNoteIndex + 1) % todayNotes.length;
const nextNote = todayNotes[nextIndex];
const nextPath = nextNote.path;
console.log(`Navigating to next note: ${nextIndex + 1}/${todayNotes.length} (${nextPath})`);
let messageType = "next note";
const currentFile = this.plugin.app.vault.getAbstractFileByPath(todayNotes[currentNoteIndex].path);
const nextFile = this.plugin.app.vault.getAbstractFileByPath(nextPath);
if (currentFile instanceof import_obsidian4.TFile && nextFile instanceof import_obsidian4.TFile) {
const currentFolder = currentFile.parent ? currentFile.parent.path : null;
const nextFolder = nextFile.parent ? nextFile.parent.path : null;
if (this.plugin.sessionController && this.plugin.sessionController.getDueLinkedNotes(todayNotes[currentNoteIndex].path).includes(nextPath)) {
messageType = "linked note";
} else if (currentFolder === nextFolder) {
messageType = "next note in folder";
} else {
messageType = "next note in different folder";
}
}
if (this.plugin.reviewController) {
this.plugin.reviewController.setCurrentNoteIndex(nextIndex);
}
await this.openNoteWithoutReview(nextPath);
if (this.plugin.settings.showNavigationNotifications) {
new import_obsidian4.Notice(`Navigated to ${messageType} (${nextIndex + 1}/${todayNotes.length})`);
}
}
/**
* Navigate to the next note without recording a review
* Uses the same traversal logic as navigateToNextNote
*/
async navigateToNextNoteWithoutRating() {
await this.navigateToNextNote();
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const todayNotes = reviewController.getTodayNotes();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length > 0) {
const nextNote = todayNotes[currentNoteIndex];
reviewController.showReviewModal(nextNote.path);
}
}
/**
* Navigate to the previous note in the current order
*/
async navigateToPreviousNote() {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
if (todayNotes.length === 0) {
new import_obsidian4.Notice("No notes due for review today!");
return;
}
}
if (todayNotes.length === 1) {
await this.navigateToCurrentNoteWithoutModal();
return;
}
const prevIndex = (currentNoteIndex - 1 + todayNotes.length) % todayNotes.length;
const prevNote = todayNotes[prevIndex];
const prevPath = prevNote.path;
console.log(`Navigating to previous note: ${prevIndex + 1}/${todayNotes.length} (${prevPath})`);
if (this.plugin.reviewController) {
this.plugin.reviewController.setCurrentNoteIndex(prevIndex);
}
await this.openNoteWithoutReview(prevPath);
if (this.plugin.settings.showNavigationNotifications) {
new import_obsidian4.Notice(`Navigated to previous note (${prevIndex + 1}/${todayNotes.length})`);
}
}
/**
* Open a note without showing the review modal
*
* @param path Path to the note file
*/
async openNoteWithoutReview(path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof import_obsidian4.TFile)) {
new import_obsidian4.Notice("Cannot navigate: file not found");
return;
}
await this.plugin.app.workspace.getLeaf().openFile(file);
}
/**
* Swap two notes in the traversal order
*
* @param path1 Path to the first note
* @param path2 Path to the second note
*/
async swapNotes(path1, path2) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
const index1 = todayNotes.findIndex((n) => n.path === path1);
const index2 = todayNotes.findIndex((n) => n.path === path2);
if (index1 < 0 || index2 < 0)
return;
const newTodayNotes = [...todayNotes];
const temp = newTodayNotes[index1];
newTodayNotes[index1] = newTodayNotes[index2];
newTodayNotes[index2] = temp;
const newOrder = newTodayNotes.map((note) => note.path);
await this.plugin.reviewScheduleService.updateCustomNoteOrder(newOrder);
await this.plugin.savePluginData();
await reviewController.updateTodayNotes(true);
console.log(`Swapped notes: ${path1} and ${path2}`);
}
};
// controllers/review-batch-controller.ts
var import_obsidian7 = require("obsidian");
// ui/batch-review-modal.ts
var import_obsidian6 = require("obsidian");
// ui/consolidated-mcq-modal.ts
var import_obsidian5 = require("obsidian");
var ConsolidatedMCQModal = class extends import_obsidian5.Modal {
/**
* Initialize consolidated MCQ modal
*
* @param plugin Reference to the main plugin
* @param mcqSets Collection of all MCQ sets
* @param onComplete Callback for when review is completed
*/
constructor(plugin, mcqSets, onComplete) {
super(plugin.app);
/**
* All questions from all MCQ sets, flattened
*/
this.allQuestions = [];
/**
* Current question index
*/
this.currentQuestionIndex = 0;
/**
* User's answers
*/
this.answers = [];
/**
* Start time for current question
*/
this.questionStartTime = 0;
this.plugin = plugin;
this.mcqSets = mcqSets;
this.onComplete = onComplete;
for (const set of mcqSets) {
set.mcqSet.questions.forEach((question, index) => {
this.allQuestions.push({
...question,
notePath: set.path,
fileName: set.fileName,
mcqSetId: `${set.path}_${set.mcqSet.generatedAt}`,
originalIndex: index
});
});
}
}
/**
* Called when the modal is opened
*/
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("spaceforge-mcq-modal");
const headerContainer = contentEl.createDiv("mcq-header-container");
headerContainer.createEl("h2", { text: "Multiple Choice Review" });
const progressEl = contentEl.createDiv("mcq-progress");
const progressPercent = Math.round((this.currentQuestionIndex + 1) / this.allQuestions.length * 100);
contentEl.setAttribute("data-progress", progressPercent.toString());
progressEl.setText(`Question ${this.currentQuestionIndex + 1} of ${this.allQuestions.length}`);
const progressCounter = contentEl.createDiv("mcq-progress-counter");
progressCounter.setText(`${this.allQuestions.length} questions from ${this.mcqSets.length} notes`);
const noteInfoEl = contentEl.createDiv("mcq-note-info");
noteInfoEl.setText(`Question from: ${this.allQuestions[this.currentQuestionIndex].fileName}`);
this.questionStartTime = Date.now();
this.displayCurrentQuestion(contentEl);
this.registerKeyboardShortcuts();
}
/**
* Register keyboard shortcuts for answering questions
*/
registerKeyboardShortcuts() {
const keyDownHandler = (event) => {
const questionIndex = this.currentQuestionIndex;
if (questionIndex >= this.allQuestions.length)
return;
const question = this.allQuestions[questionIndex];
if (!question || !question.choices)
return;
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= question.choices.length) {
this.handleAnswer(num - 1);
return;
}
if (event.key.length === 1) {
const letterCode = event.key.toUpperCase().charCodeAt(0);
const index = letterCode - 65;
if (index >= 0 && index < question.choices.length) {
this.handleAnswer(index);
return;
}
}
};
document.addEventListener("keydown", keyDownHandler);
this.onClose = () => {
document.removeEventListener("keydown", keyDownHandler);
const { contentEl } = this;
contentEl.empty();
};
}
/**
* Display the current question
*
* @param containerEl Container element
*/
displayCurrentQuestion(containerEl) {
const questionIndex = this.currentQuestionIndex;
if (questionIndex >= this.allQuestions.length) {
this.completeReview();
return;
}
const question = this.allQuestions[questionIndex];
if (!question || !question.choices || question.choices.length < 2) {
console.error("Invalid question data:", question);
new import_obsidian5.Notice("Error: Invalid question data. Moving to next question.");
this.currentQuestionIndex++;
if (this.currentQuestionIndex < this.allQuestions.length) {
this.displayCurrentQuestion(containerEl);
} else {
this.completeReview();
}
return;
}
const questionContainer = containerEl.querySelector(".mcq-question-container");
if (questionContainer) {
questionContainer.remove();
}
const noteInfoEl = containerEl.querySelector(".mcq-note-info");
if (noteInfoEl instanceof HTMLElement) {
noteInfoEl.setText(`Question from: ${question.fileName}`);
}
const newQuestionContainer = containerEl.createDiv("mcq-question-container");
const questionEl = newQuestionContainer.createDiv("mcq-question-text");
questionEl.setText(question.question);
const choicesContainer = newQuestionContainer.createDiv("mcq-choices-container");
question.choices.forEach((choice, index) => {
const choiceEl = choicesContainer.createDiv("mcq-choice");
const choiceBtn = choiceEl.createEl("button", {
cls: "mcq-choice-btn"
});
const letterLabel = choiceBtn.createSpan("mcq-choice-letter");
letterLabel.setText(String.fromCharCode(65 + index) + ") ");
const textSpan = choiceBtn.createSpan("mcq-choice-text");
textSpan.setText(choice || "(Empty choice)");
choiceBtn.addEventListener("click", () => {
this.handleAnswer(index);
});
});
}
/**
* Handle user's answer selection
*
* @param selectedIndex Index of the selected answer
*/
handleAnswer(selectedIndex) {
const questionIndex = this.currentQuestionIndex;
const question = this.allQuestions[questionIndex];
const isCorrect = selectedIndex === question.correctAnswerIndex;
const timeToAnswer = (Date.now() - this.questionStartTime) / 1e3;
const existingAnswerIndex = this.answers.findIndex(
(a) => a.questionIndex === questionIndex
);
let answer;
if (existingAnswerIndex >= 0) {
answer = this.answers[existingAnswerIndex];
answer.selectedAnswerIndex = selectedIndex;
answer.correct = isCorrect;
answer.timeToAnswer = timeToAnswer;
answer.attempts += 1;
} else {
answer = {
questionIndex,
selectedAnswerIndex: selectedIndex,
// Always record the selected index
correct: isCorrect,
timeToAnswer,
attempts: 1,
notePath: question.notePath,
fileName: question.fileName
};
this.answers.push(answer);
}
this.highlightAnswer(selectedIndex, isCorrect);
setTimeout(() => {
if (isCorrect) {
this.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
const progressEl = contentEl.querySelector(".mcq-progress");
if (progressEl instanceof HTMLElement) {
progressEl.textContent = `Question ${this.currentQuestionIndex + 1} of ${this.allQuestions.length} (${this.allQuestions.length} questions from ${this.mcqSets.length} notes)`;
}
const newProgressPercent = Math.round((this.currentQuestionIndex + 1) / this.allQuestions.length * 100);
contentEl.setAttribute("data-progress", newProgressPercent.toString());
if (this.currentQuestionIndex < this.allQuestions.length) {
this.displayCurrentQuestion(contentEl);
} else {
this.completeReview();
}
} else {
new import_obsidian5.Notice("Incorrect answer. Try again to proceed to the next question.");
setTimeout(() => {
const choiceButtons = document.querySelectorAll(".mcq-choice-btn");
if (choiceButtons.length <= selectedIndex)
return;
const selectedBtn = choiceButtons[selectedIndex];
selectedBtn.classList.remove("mcq-choice-incorrect");
}, 500);
}
}, 1e3);
}
/**
* Highlight the selected answer
*
* @param selectedIndex Index of the selected answer
* @param isCorrect Whether the answer is correct
*/
highlightAnswer(selectedIndex, isCorrect) {
const choiceButtons = document.querySelectorAll(".mcq-choice-btn");
if (choiceButtons.length <= selectedIndex)
return;
const selectedBtn = choiceButtons[selectedIndex];
if (isCorrect) {
selectedBtn.classList.add("mcq-choice-correct");
} else {
selectedBtn.classList.add("mcq-choice-incorrect");
}
}
/**
* Complete the review and show results
*/
completeReview() {
const noteScores = {};
for (const answer of this.answers) {
if (!noteScores[answer.notePath]) {
noteScores[answer.notePath] = {
totalQuestions: 0,
correctAnswers: 0,
score: 0,
notePath: answer.notePath,
fileName: answer.fileName
};
}
}
for (const question of this.allQuestions) {
if (!noteScores[question.notePath]) {
noteScores[question.notePath] = {
totalQuestions: 0,
correctAnswers: 0,
score: 0,
notePath: question.notePath,
fileName: question.fileName
// Assuming fileName is consistent for the notePath
};
}
noteScores[question.notePath].totalQuestions++;
}
const finalAnswersCorrectCount = {};
for (const question of this.allQuestions) {
finalAnswersCorrectCount[question.notePath] = 0;
}
for (const answer of this.answers) {
if (answer.correct && answer.attempts <= 1) {
if (noteScores[answer.notePath]) {
noteScores[answer.notePath].correctAnswers++;
}
}
}
for (const notePath in noteScores) {
const noteScore = noteScores[notePath];
if (noteScore.totalQuestions > 0) {
noteScore.score = noteScore.correctAnswers / noteScore.totalQuestions;
} else {
noteScore.score = 0;
}
}
const results = [];
for (const notePath in noteScores) {
const noteScore = noteScores[notePath];
const score = noteScore.score;
let success = false;
let response = 1 /* Hard */;
if (score >= 0.9) {
success = true;
response = 5 /* Perfect */;
} else if (score >= 0.7) {
success = true;
response = 4 /* Good */;
} else if (score >= 0.5) {
success = true;
response = 3 /* Fair */;
} else {
success = false;
response = 1 /* Hard */;
}
results.push({
path: notePath,
success,
response,
score
});
}
this.onComplete(results);
const { contentEl } = this;
contentEl.empty();
const headerEl = contentEl.createEl("h2", { text: "MCQ Review Complete", cls: "mcq-review-complete-header" });
const totalCorrectOverall = this.answers.filter((a) => a.correct && a.attempts <= 1).length;
const totalQuestionsOverall = this.allQuestions.length;
const overallScore = totalQuestionsOverall > 0 ? totalCorrectOverall / totalQuestionsOverall : 0;
const scorePercentOverall = Math.round(overallScore * 100);
const scoreEl = contentEl.createDiv("mcq-score");
const scoreTextEl = scoreEl.createDiv("mcq-score-text");
scoreTextEl.setText(`Overall Score: ${scorePercentOverall}%`);
const performanceIndicator = scoreEl.createDiv("mcq-performance-indicator");
if (scorePercentOverall >= 90) {
performanceIndicator.setText("\u{1F393} Excellent Performance!");
performanceIndicator.addClass("excellent");
} else if (scorePercentOverall >= 70) {
performanceIndicator.setText("\u{1F44D} Good Work!");
performanceIndicator.addClass("good");
} else if (scorePercentOverall >= 50) {
performanceIndicator.setText("\u{1F504} Keep Practicing");
performanceIndicator.addClass("needs-improvement");
} else {
performanceIndicator.setText("\u{1F4DA} More Review Recommended");
performanceIndicator.addClass("review-recommended");
}
const statsEl = scoreEl.createDiv("mcq-stats-summary");
statsEl.setText(`${totalCorrectOverall} correct out of ${totalQuestionsOverall} questions`);
const noteScoresEl = contentEl.createDiv("mcq-note-scores");
const scoreHeading = noteScoresEl.createEl("h3", { text: "Scores by Note", cls: "mcq-note-scores-heading" });
const sortedNotes = Object.keys(noteScores).sort((a, b2) => noteScores[b2].score - noteScores[a].score);
for (const notePath of sortedNotes) {
const noteScore = noteScores[notePath];
if (noteScore.totalQuestions === 0)
continue;
const noteScoreEl = noteScoresEl.createDiv("mcq-note-score");
noteScoreEl.createEl("div", {
text: noteScore.fileName,
cls: "mcq-note-score-title"
});
const scorePercent = Math.round(noteScore.score * 100);
const scoreTextValueEl = noteScoreEl.createEl("div", {
text: `Score: ${scorePercent}% (${noteScore.correctAnswers}/${noteScore.totalQuestions})`,
cls: "mcq-note-score-value"
});
if (noteScore.score >= 0.7) {
scoreTextValueEl.addClass("high-score");
} else if (noteScore.score >= 0.5) {
scoreTextValueEl.addClass("medium-score");
} else {
scoreTextValueEl.addClass("low-score");
}
const progressBar = noteScoreEl.createDiv("mcq-progress-bar");
const progressFill = progressBar.createDiv("mcq-progress-fill");
progressFill.style.width = `${scorePercent}%`;
if (noteScore.score >= 0.7) {
progressFill.addClass("high-score");
} else if (noteScore.score >= 0.5) {
progressFill.addClass("medium-score");
} else {
progressFill.addClass("low-score");
}
}
const breakdownContainer = contentEl.createDiv("mcq-detailed-breakdown");
breakdownContainer.createEl("h3", { text: "Detailed Question Breakdown" });
this.allQuestions.forEach((question, index) => {
const questionEl = breakdownContainer.createDiv("mcq-breakdown-item");
const questionHeader = questionEl.createDiv();
questionHeader.createSpan({ text: `Q${index + 1} (from ${question.fileName}): `, cls: "mcq-breakdown-q-header" });
questionHeader.createSpan({ text: question.question });
const userAnswer = this.answers.find((a) => a.questionIndex === index);
const userAnswerTextEl = questionEl.createDiv("mcq-user-answer-text");
let userAnswerDisplay = "Not answered";
if (userAnswer && userAnswer.selectedAnswerIndex !== -1 && userAnswer.selectedAnswerIndex < question.choices.length) {
userAnswerDisplay = question.choices[userAnswer.selectedAnswerIndex];
} else if (userAnswer && userAnswer.selectedAnswerIndex === -1) {
userAnswerDisplay = "Attempted, but no valid choice recorded";
}
if (userAnswer) {
const correctnessText = userAnswer.correct ? " (Correct)" : " (Incorrect)";
userAnswerTextEl.createSpan({ text: "Your answer: " });
const userAnswerSpan = userAnswerTextEl.createSpan({ text: userAnswerDisplay });
const correctnessSpan = userAnswerTextEl.createSpan({ text: correctnessText, cls: "mcq-correctness-indicator" });
if (userAnswer.correct) {
userAnswerSpan.addClass("correct");
correctnessSpan.addClass("correct");
} else {
userAnswerSpan.addClass("incorrect");
correctnessSpan.addClass("incorrect");
}
} else {
userAnswerTextEl.createSpan({ text: "Your answer: " + userAnswerDisplay });
userAnswerTextEl.style.fontStyle = "italic";
}
const correctAnswerEl = questionEl.createDiv("mcq-correct-answer");
correctAnswerEl.createSpan({ text: "Correct answer: " });
correctAnswerEl.createSpan({ text: question.choices[question.correctAnswerIndex], cls: "mcq-correct-answer-text" });
});
const closeBtn = contentEl.createEl("button", { cls: "mcq-close-btn", text: "Close" });
closeBtn.addEventListener("click", () => {
this.close();
});
}
/**
* Shuffle an array
*
* @param array Array to shuffle
* @returns Shuffled array
*/
shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j2 = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j2]] = [newArray[j2], newArray[i]];
}
return newArray;
}
};
// ui/batch-review-modal.ts
var BatchReviewModal = class extends import_obsidian6.Modal {
constructor(app, plugin, notes, useMCQ = false) {
super(app);
this.currentIndex = 0;
this.results = [];
this.started = false;
this.allMCQSets = [];
this.collectingMCQs = false;
this.plugin = plugin;
this.notes = notes;
this.useMCQ = useMCQ;
}
async onOpen() {
const { contentEl } = this;
this.renderStartScreen(contentEl);
}
renderStartScreen(contentEl) {
contentEl.empty();
contentEl.createEl("h2", { text: "Batch Review" });
const infoEl = contentEl.createDiv("batch-review-info");
infoEl.createEl("p", { text: `${this.notes.length} notes scheduled for review` });
this.estimateAndShowTime(infoEl);
const buttonsEl = contentEl.createDiv("batch-review-buttons");
const startButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Start MCQ Review" : "Start Manual Review",
cls: "batch-review-start-button"
});
startButton.addEventListener("click", () => this.startBatchReview());
const toggleMCQButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Switch to Manual Review" : "Switch to MCQ Review",
cls: "batch-review-toggle-button"
});
toggleMCQButton.addEventListener("click", () => {
this.useMCQ = !this.useMCQ;
this.renderStartScreen(contentEl);
});
if (this.useMCQ) {
const regenerateButton = buttonsEl.createEl("button", {
text: "Regenerate All MCQs",
cls: "batch-review-regenerate-button"
});
regenerateButton.addEventListener("click", () => {
this.close();
if (this.plugin.batchController) {
this.plugin.batchController.regenerateAllMCQs();
}
});
}
const cancelButton = buttonsEl.createEl("button", { text: "Cancel", cls: "batch-review-cancel-button" });
cancelButton.addEventListener("click", () => this.close());
}
async estimateAndShowTime(containerEl) {
let totalTime = 0;
for (const note of this.notes) {
totalTime += await this.plugin.dataStorage.estimateReviewTime(note.path);
}
if (this.useMCQ) {
totalTime += this.notes.length * 15;
}
containerEl.createEl("p", { text: `Estimated time: ${EstimationUtils.formatTime(totalTime)}`, cls: "batch-review-time" });
}
async startBatchReview() {
this.started = true;
if (this.useMCQ) {
this.collectingMCQs = true;
await this.collectAllMCQs();
if (this.allMCQSets.length > 0) {
this.showConsolidatedMCQUI();
} else {
new import_obsidian6.Notice("Could not generate any MCQs. Falling back to manual review.");
await this.processNextManual();
}
} else {
await this.processNextManual();
}
}
async collectAllMCQs() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Collecting MCQs" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Preparing MCQs for ${this.notes.length} notes...` });
const progressBar = contentEl.createDiv("mcq-collection-progress");
progressBar.style.width = "100%";
progressBar.style.height = "10px";
progressBar.style.marginTop = "20px";
progressBar.style.backgroundColor = "var(--background-modifier-border)";
progressBar.style.borderRadius = "5px";
progressBar.style.overflow = "hidden";
const progressFill = progressBar.createDiv();
progressFill.style.width = "0%";
progressFill.style.height = "100%";
progressFill.style.backgroundColor = "var(--interactive-accent)";
progressFill.style.transition = "width 0.3s ease";
const statusEl = contentEl.createDiv("batch-review-status");
statusEl.style.marginTop = "10px";
statusEl.style.color = "var(--text-muted)";
this.allMCQSets = [];
for (let i = 0; i < this.notes.length; i++) {
const note = this.notes[i];
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path;
progressFill.style.width = `${(i + 1) / this.notes.length * 100}%`;
statusEl.setText(`Processing ${i + 1}/${this.notes.length}: ${fileName}`);
await new Promise((resolve) => setTimeout(resolve, 10));
let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path);
if (!mcqSet && this.plugin.mcqGenerationService && this.plugin.mcqController) {
statusEl.setText(`Generating MCQs for ${i + 1}/${this.notes.length}: ${fileName}...`);
try {
mcqSet = await this.plugin.mcqController.generateMCQs(note.path);
} catch (error) {
console.error("Error generating MCQs:", error);
statusEl.setText(`Error generating MCQs for ${fileName}`);
await new Promise((resolve) => setTimeout(resolve, 1e3));
}
}
if (mcqSet) {
this.allMCQSets.push({ path: note.path, mcqSet, fileName });
}
}
statusEl.setText(`Collected MCQs for ${this.allMCQSets.length}/${this.notes.length} notes`);
await new Promise((resolve) => setTimeout(resolve, 1e3));
this.collectingMCQs = false;
}
showConsolidatedMCQUI() {
if (this.allMCQSets.length === 0) {
this.showSummary();
return;
}
if (this.plugin.mcqController) {
try {
this.close();
const consolidatedModal = new ConsolidatedMCQModal(
this.plugin,
this.allMCQSets,
(results) => {
this.results = results;
this.recordAllReviews(results).then(() => {
this.open();
this.showSummary();
});
}
);
consolidatedModal.open();
} catch (error) {
console.error("Error showing consolidated MCQ UI:", error);
new import_obsidian6.Notice("Error showing MCQ review. Falling back to manual review.");
this.open();
this.processNextManual();
}
} else {
new import_obsidian6.Notice("MCQ controller not available. Falling back to manual review.");
this.open();
this.processNextManual();
}
}
async recordAllReviews(results) {
for (const result of results) {
await this.plugin.dataStorage.recordReview(result.path, result.response);
}
}
// This method is likely unused now due to the consolidated modal approach, but kept for reference/potential fallback
async processNextMCQ() {
if (this.currentIndex >= this.notes.length) {
this.showSummary();
return;
}
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "MCQ Review in Progress" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Processing note ${this.currentIndex + 1}/${this.notes.length}` });
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path;
progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" });
this.close();
if (this.plugin.mcqController) {
let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path);
if (!mcqSet && this.plugin.mcqGenerationService) {
new import_obsidian6.Notice(`Generating MCQs for ${fileName}...`);
mcqSet = await this.plugin.mcqController.generateMCQs(note.path);
}
if (mcqSet) {
this.plugin.mcqController.startMCQReview(
note.path
/* Removed callback */
);
console.warn("processNextMCQ flow is currently inactive due to consolidated modal implementation.");
this.open();
this.showSummary();
} else {
new import_obsidian6.Notice(`Couldn't generate MCQs for ${fileName}, falling back to manual review`);
this.open();
this.processNextManual();
}
} else {
new import_obsidian6.Notice("MCQ controller not available, falling back to manual review");
this.open();
this.processNextManual();
}
}
getLatestMCQScore(path) {
const session = this.plugin.dataStorage.getLatestMCQSessionForNote(path);
return session == null ? void 0 : session.score;
}
async processNextManual() {
if (this.currentIndex >= this.notes.length) {
this.showSummary();
return;
}
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Manual Review" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Note ${this.currentIndex + 1}/${this.notes.length}` });
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path;
progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" });
if (this.plugin.navigationController) {
await this.plugin.navigationController.openNoteWithoutReview(note.path);
}
const buttonsContainer = contentEl.createDiv("batch-review-buttons");
const blackoutButton = buttonsContainer.createEl("button", { text: "0: Complete Blackout", cls: "review-button review-button-complete-blackout" });
blackoutButton.addEventListener("click", () => this.recordManualResult(note.path, 0 /* CompleteBlackout */));
const incorrectButton = buttonsContainer.createEl("button", { text: "1: Incorrect Response", cls: "review-button review-button-incorrect" });
incorrectButton.addEventListener("click", () => this.recordManualResult(note.path, 1 /* IncorrectResponse */));
const incorrectFamiliarButton = buttonsContainer.createEl("button", { text: "2: Incorrect but Familiar", cls: "review-button review-button-incorrect-familiar" });
incorrectFamiliarButton.addEventListener("click", () => this.recordManualResult(note.path, 2 /* IncorrectButFamiliar */));
const correctDifficultyButton = buttonsContainer.createEl("button", { text: "3: Correct with Difficulty", cls: "review-button review-button-correct-difficulty" });
correctDifficultyButton.addEventListener("click", () => this.recordManualResult(note.path, 3 /* CorrectWithDifficulty */));
const correctHesitationButton = buttonsContainer.createEl("button", { text: "4: Correct with Hesitation", cls: "review-button review-button-correct-hesitation" });
correctHesitationButton.addEventListener("click", () => this.recordManualResult(note.path, 4 /* CorrectWithHesitation */));
const perfectRecallButton = buttonsContainer.createEl("button", { text: "5: Perfect Recall", cls: "review-button review-button-perfect-recall" });
perfectRecallButton.addEventListener("click", () => this.recordManualResult(note.path, 5 /* PerfectRecall */));
const skipButton = buttonsContainer.createEl("button", { text: "Skip", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", () => {
this.currentIndex++;
this.processNextManual();
});
}
async recordManualResult(path, response) {
this.results.push({ path, success: response >= 3 /* CorrectWithDifficulty */, response });
const wasRecorded = await this.plugin.dataStorage.recordReview(path, response);
if (!wasRecorded) {
new import_obsidian6.Notice("Note previewed, not recorded");
}
this.currentIndex++;
this.processNextManual();
}
showSummary() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Batch Review Complete" });
const statsEl = contentEl.createDiv("batch-review-summary-stats");
const totalNotes = this.results.length;
const successfulNotes = this.results.filter((r) => r.success).length;
const successRate = totalNotes > 0 ? Math.round(successfulNotes / totalNotes * 100) : 0;
statsEl.createEl("p", { text: `Completed: ${totalNotes}/${this.notes.length} notes` });
statsEl.createEl("p", { text: `Success rate: ${successRate}% (${successfulNotes}/${totalNotes})`, cls: successRate >= 70 ? "batch-review-success" : "batch-review-needs-improvement" });
const resultsEl = contentEl.createDiv("batch-review-results");
resultsEl.createEl("h3", { text: "Individual Results" });
const resultsListEl = resultsEl.createDiv("batch-review-results-list");
for (const result of this.results) {
const resultItemEl = resultsListEl.createDiv("batch-review-result-item");
const file = this.plugin.app.vault.getAbstractFileByPath(result.path);
const fileName = file instanceof import_obsidian6.TFile ? file.basename : result.path;
resultItemEl.createEl("div", { text: fileName, cls: "batch-review-result-filename" });
let responseText;
let responseClass;
switch (result.response) {
case 0 /* CompleteBlackout */:
responseText = "Complete Blackout (0)";
responseClass = "batch-review-complete-blackout";
break;
case 1 /* IncorrectResponse */:
responseText = "Incorrect Response (1)";
responseClass = "batch-review-incorrect";
break;
case 2 /* IncorrectButFamiliar */:
responseText = "Incorrect but Familiar (2)";
responseClass = "batch-review-incorrect-familiar";
break;
case 3 /* CorrectWithDifficulty */:
responseText = "Correct with Difficulty (3)";
responseClass = "batch-review-correct-difficulty";
break;
case 4 /* CorrectWithHesitation */:
responseText = "Correct with Hesitation (4)";
responseClass = "batch-review-correct-hesitation";
break;
case 5 /* PerfectRecall */:
responseText = "Perfect Recall (5)";
responseClass = "batch-review-perfect-recall";
break;
default:
responseText = "Unknown";
responseClass = "";
}
resultItemEl.createEl("div", { text: responseText, cls: `batch-review-result-response ${responseClass}` });
if (result.score !== void 0) {
resultItemEl.createEl("div", { text: `MCQ Score: ${Math.round(result.score * 100)}%`, cls: "batch-review-result-mcq-score" });
}
}
const closeButton = contentEl.createEl("button", { text: "Close", cls: "batch-review-close-button" });
closeButton.addEventListener("click", () => {
this.close();
if (this.plugin.sidebarView)
this.plugin.sidebarView.refresh();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.started && this.currentIndex < this.notes.length && this.results.length > 0) {
new import_obsidian6.Notice(`Batch review interrupted after ${this.results.length} notes`);
}
}
};
// controllers/review-batch-controller.ts
var ReviewBatchController = class {
/**
* Initialize batch controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
this.plugin = plugin;
}
/**
* Start reviewing all of today's notes
*/
async reviewAllTodaysNotes() {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
await reviewController.updateTodayNotes(true);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new import_obsidian7.Notice("No notes due for review today!");
return;
}
console.log("Review All Notes - Today's Notes Order:", todayNotes.map((n) => n.path));
if (reviewController) {
await reviewController.updateTodayNotes(false);
const note = todayNotes[0];
console.log(`Review All: Starting with note at index 0: ${note.path}`);
await reviewController.reviewNote(note.path);
}
new import_obsidian7.Notice(`Starting review of all ${todayNotes.length} notes due today`);
}
/**
* Review a specific set of notes
*
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths, useMCQ = false) {
if (paths.length === 0) {
new import_obsidian7.Notice("No notes selected for review.");
return;
}
const notesToReview = this.plugin.dataStorage.getDueNotesWithCustomOrder().filter((note) => paths.includes(note.path));
if (notesToReview.length === 0) {
new import_obsidian7.Notice("Selected notes are not currently due for review.");
return;
}
if (useMCQ) {
new import_obsidian7.Notice(`Preparing MCQs for ${notesToReview.length} selected notes. This may take a moment...`);
} else {
new import_obsidian7.Notice(`Starting review of ${notesToReview.length} selected notes.`);
}
const modal = new BatchReviewModal(this.plugin.app, this.plugin, notesToReview, useMCQ);
modal.open();
}
/**
* Review all notes with MCQs in a batch
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ = true) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new import_obsidian7.Notice("No notes due for review today!");
return;
}
if (useMCQ) {
new import_obsidian7.Notice("Preparing all MCQs. This may take a moment...");
}
const orderedNotes = [...todayNotes];
console.log("Review All with MCQ: Using sidebar order for consistent navigation");
console.log("Ordered notes:", orderedNotes.map((n) => n.path));
const modal = new BatchReviewModal(this.plugin.app, this.plugin, orderedNotes, useMCQ);
modal.open();
}
/**
* Regenerate MCQs for all notes due today
*/
async regenerateAllMCQs() {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new import_obsidian7.Notice("No notes due for review today!");
return;
}
if (!this.plugin.mcqController) {
new import_obsidian7.Notice("MCQ controller not initialized. Please check MCQ settings.");
return;
}
new import_obsidian7.Notice(`Regenerating MCQs for ${todayNotes.length} notes...`);
let generatedCount = 0;
for (const note of todayNotes) {
const success = await this.plugin.mcqController.generateMCQs(note.path);
if (success) {
generatedCount++;
}
}
new import_obsidian7.Notice(`Generated MCQs for ${generatedCount} out of ${todayNotes.length} notes`);
this.reviewAllNotesWithMCQ(true);
}
/**
* Postpone a specific set of notes
*
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths, days = 1) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
if (paths.length === 0) {
new import_obsidian7.Notice("No notes selected to postpone.");
return;
}
new import_obsidian7.Notice(`Postponing ${paths.length} notes by ${days} day(s)...`);
for (const path of paths) {
await this.plugin.dataStorage.postponeNote(path, days);
}
await this.plugin.savePluginData();
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
await reviewController.updateTodayNotes(true);
new import_obsidian7.Notice(`Postponed ${paths.length} notes by ${days} day(s).`);
}
/**
* Advance a specific set of notes by one day each, if eligible.
*
* @param paths Array of note paths to advance
*/
async advanceNotes(paths) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
if (paths.length === 0) {
new import_obsidian7.Notice("No notes selected to advance.");
return;
}
let advancedCount = 0;
for (const path of paths) {
const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
if (advanced) {
advancedCount++;
}
}
if (advancedCount > 0) {
await this.plugin.savePluginData();
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
await reviewController.updateTodayNotes(true);
new import_obsidian7.Notice(`Advanced ${advancedCount} note(s).`);
} else {
new import_obsidian7.Notice("No eligible notes were advanced.");
}
}
/**
* Remove a specific set of notes from the review schedule
*
* @param paths Array of note paths to remove
*/
async removeNotes(paths) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return;
if (paths.length === 0) {
new import_obsidian7.Notice("No notes selected to remove.");
return;
}
new import_obsidian7.Notice(`Removing ${paths.length} notes from review schedule...`);
for (const path of paths) {
await this.plugin.dataStorage.removeFromReview(path);
}
await this.plugin.savePluginData();
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
await reviewController.updateTodayNotes(true);
new import_obsidian7.Notice(`Removed ${paths.length} notes from review schedule.`);
}
};
// controllers/review-controller-mcq.ts
var import_obsidian9 = require("obsidian");
// ui/mcq-modal.ts
var import_obsidian8 = require("obsidian");
var MCQModal = class extends import_obsidian8.Modal {
constructor(plugin, notePath, mcqSet, onCompleteCallback = null) {
super(plugin.app);
this.questionStartTime = 0;
this.isFreshGeneration = false;
this.selectedAnswerIndex = -1;
this.plugin = plugin;
this.notePath = notePath;
this.mcqSet = mcqSet;
this.onCompleteCallback = onCompleteCallback;
this.isFreshGeneration = mcqSet.generatedAt > Date.now() - 6e4;
this.session = {
mcqSetId: `${mcqSet.notePath}_${mcqSet.generatedAt}`,
notePath,
answers: [],
score: 0,
currentQuestionIndex: 0,
completed: false,
startedAt: Date.now(),
completedAt: null
};
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("spaceforge-mcq-modal");
const headerContainer = contentEl.createDiv("mcq-header-container");
headerContainer.createEl("h2", { text: "Multiple Choice Review" });
if (!this.isFreshGeneration) {
const refreshBtn = headerContainer.createDiv("mcq-refresh-btn");
(0, import_obsidian8.setIcon)(refreshBtn, "refresh-cw");
refreshBtn.setAttribute("aria-label", "Generate new questions");
refreshBtn.addEventListener("click", async () => {
this.close();
const mcqGenerationService = this.plugin.mcqGenerationService;
if (mcqGenerationService && this.plugin.mcqController) {
const file = this.plugin.app.vault.getAbstractFileByPath(this.notePath);
if (file instanceof import_obsidian8.TFile) {
const content = await this.plugin.app.vault.read(file);
const newMcqSet = await mcqGenerationService.generateMCQs(this.notePath, content, this.plugin.settings);
if (newMcqSet) {
this.plugin.mcqService.saveMCQSet(newMcqSet);
await this.plugin.savePluginData();
const newModal = new MCQModal(this.plugin, this.notePath, newMcqSet, this.onCompleteCallback);
newModal.open();
} else {
new import_obsidian8.Notice("Failed to regenerate MCQs.");
}
} else {
new import_obsidian8.Notice("Could not find note file to regenerate MCQs.");
}
} else {
new import_obsidian8.Notice("MCQ generation service not available.");
}
});
}
const questionIndex = this.session.currentQuestionIndex;
const existingAnswer = this.session.answers.find((a) => a.questionIndex === questionIndex);
const progressEl = contentEl.createDiv("mcq-progress");
const progressPercent = Math.round((questionIndex + 1) / this.mcqSet.questions.length * 100);
contentEl.setAttribute("data-progress", progressPercent.toString());
if (existingAnswer) {
progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length} (Attempt ${existingAnswer.attempts + 1})`);
if (existingAnswer.attempts === 1) {
const warningEl = contentEl.createDiv("mcq-attempt-warning");
const warningIcon = warningEl.createSpan();
warningIcon.setText("\u26A0\uFE0F ");
const warningText = warningEl.createSpan();
warningText.setText("This is your last attempt before scoring 0 points for this question.");
warningEl.addClass("mcq-warning");
}
} else {
progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length}`);
}
this.questionStartTime = Date.now();
this.displayCurrentQuestion(contentEl);
this.registerKeyboardShortcuts();
}
registerKeyboardShortcuts() {
const keyDownHandler = (event) => {
if (this.session.completed)
return;
const questionIndex = this.session.currentQuestionIndex;
if (questionIndex >= this.mcqSet.questions.length)
return;
const question = this.mcqSet.questions[questionIndex];
if (!question || !question.choices)
return;
const choiceButtons = this.contentEl.querySelectorAll(".mcq-choice-btn");
if (choiceButtons.length === 0)
return;
if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) {
choiceButtons[this.selectedAnswerIndex].classList.remove("mcq-choice-selected");
}
let processAnswer = false;
if (event.key === "ArrowDown") {
event.preventDefault();
this.selectedAnswerIndex = (this.selectedAnswerIndex + 1) % question.choices.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
this.selectedAnswerIndex = (this.selectedAnswerIndex - 1 + question.choices.length) % question.choices.length;
} else if (event.key === "ArrowRight" || event.key === "Enter") {
event.preventDefault();
if (this.selectedAnswerIndex !== -1)
processAnswer = true;
}
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= question.choices.length) {
this.selectedAnswerIndex = num - 1;
processAnswer = true;
} else if (event.key.length === 1) {
const letterCode = event.key.toUpperCase().charCodeAt(0);
const index = letterCode - 65;
if (index >= 0 && index < question.choices.length) {
this.selectedAnswerIndex = index;
processAnswer = true;
}
}
if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) {
choiceButtons[this.selectedAnswerIndex].classList.add("mcq-choice-selected");
}
if (processAnswer && this.selectedAnswerIndex !== -1) {
if (this.selectedAnswerIndex < choiceButtons.length) {
const button = choiceButtons[this.selectedAnswerIndex];
button.classList.add("mcq-key-pressed");
setTimeout(() => {
button.classList.remove("mcq-key-pressed");
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
}, 150);
} else {
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
}
}
};
this.scope.register([], "ArrowDown", keyDownHandler);
this.scope.register([], "ArrowUp", keyDownHandler);
this.scope.register([], "ArrowRight", keyDownHandler);
this.scope.register([], "Enter", keyDownHandler);
for (let i = 1; i <= 9; i++) {
this.scope.register([], i.toString(), keyDownHandler);
}
for (let i = 0; i < 9; i++) {
this.scope.register([], String.fromCharCode(65 + i), keyDownHandler);
}
const originalOnClose = this.onClose;
this.onClose = () => {
originalOnClose.call(this);
if (!this.session.completed && this.session.answers.length > 0) {
this.session.completedAt = Date.now();
this.calculateScore();
this.plugin.mcqService.saveMCQSession(this.session);
this.plugin.savePluginData();
if (this.onCompleteCallback) {
this.onCompleteCallback(this.notePath, this.session.score, false);
}
} else if (!this.session.completed && this.onCompleteCallback) {
this.onCompleteCallback(this.notePath, 0, false);
}
};
}
displayCurrentQuestion(containerEl) {
const questionIndex = this.session.currentQuestionIndex;
if (questionIndex >= this.mcqSet.questions.length) {
this.completeSession();
return;
}
const question = this.mcqSet.questions[questionIndex];
if (!question || !question.choices || question.choices.length < 2) {
console.error("Invalid question data:", question);
new import_obsidian8.Notice("Error: Invalid question data. Moving to next question.");
this.session.currentQuestionIndex++;
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(containerEl);
} else {
this.completeSession();
}
return;
}
const existingQuestionContainer = containerEl.querySelector(".mcq-question-container");
if (existingQuestionContainer)
existingQuestionContainer.remove();
const newQuestionContainer = containerEl.createDiv("mcq-question-container");
const questionEl = newQuestionContainer.createDiv("mcq-question-text");
questionEl.setText(question.question);
const existingAnswer = this.session.answers.find((a) => a.questionIndex === questionIndex);
if (existingAnswer && existingAnswer.attempts >= 2) {
const skipContainer = newQuestionContainer.createDiv("mcq-skip-container");
const skipButton = skipContainer.createEl("button", { text: "Show Answer & Continue", cls: "mcq-skip-button" });
skipButton.addEventListener("click", () => {
const correctIndex = question.correctAnswerIndex;
const correctAnswerDisplay = newQuestionContainer.createDiv("mcq-correct-answer-display");
const correctLabel = correctAnswerDisplay.createDiv();
correctLabel.style.fontWeight = "bold";
correctLabel.style.marginBottom = "4px";
correctLabel.setText("Correct Answer:");
const correctText = correctAnswerDisplay.createDiv();
correctText.style.color = "#4caf50";
correctText.setText(String.fromCharCode(65 + correctIndex) + ") " + question.choices[correctIndex]);
if (existingAnswer) {
existingAnswer.selectedAnswerIndex = -1;
existingAnswer.correct = false;
existingAnswer.attempts += 1;
} else {
this.session.answers.push({
questionIndex,
selectedAnswerIndex: -1,
correct: false,
timeToAnswer: (Date.now() - this.questionStartTime) / 1e3,
attempts: 3
});
}
const continueBtn = correctAnswerDisplay.createEl("button", {
text: "Continue to Next Question",
cls: "mcq-continue-button"
});
continueBtn.addEventListener("click", () => {
this.session.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
const progressEl = contentEl.querySelector(".mcq-progress");
if (progressEl instanceof HTMLElement)
progressEl.textContent = `Question ${this.session.currentQuestionIndex + 1} of ${this.mcqSet.questions.length}`;
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(contentEl);
} else {
this.completeSession();
}
});
const choicesContainer2 = this.contentEl.querySelector(".mcq-choices-container");
if (choicesContainer2 instanceof HTMLElement)
choicesContainer2.style.display = "none";
skipButton.style.display = "none";
});
}
const choicesContainer = newQuestionContainer.createDiv("mcq-choices-container");
question.choices.forEach((choice, index) => {
const choiceEl = choicesContainer.createDiv("mcq-choice");
const choiceBtn = choiceEl.createEl("button", { cls: "mcq-choice-btn" });
const letterLabel = choiceBtn.createSpan("mcq-choice-letter");
letterLabel.setText(String.fromCharCode(65 + index) + ") ");
const textSpan = choiceBtn.createSpan("mcq-choice-text");
textSpan.setText(choice || "(Empty choice)");
const shortcutHint = choiceBtn.createSpan("mcq-shortcut-hint");
shortcutHint.setText(`${String.fromCharCode(65 + index)} or ${index + 1}`);
choiceBtn.addEventListener("click", () => this.handleAnswer(index));
});
this.selectedAnswerIndex = -1;
}
handleAnswer(selectedIndex) {
const questionIndex = this.session.currentQuestionIndex;
const question = this.mcqSet.questions[questionIndex];
const isCorrect = selectedIndex === question.correctAnswerIndex;
const timeToAnswer = (Date.now() - this.questionStartTime) / 1e3;
const existingAnswerIndex = this.session.answers.findIndex((a) => a.questionIndex === questionIndex);
let answer;
if (existingAnswerIndex >= 0) {
answer = this.session.answers[existingAnswerIndex];
if (isCorrect) {
answer.selectedAnswerIndex = selectedIndex;
answer.correct = true;
}
answer.timeToAnswer = timeToAnswer;
answer.attempts += 1;
if (answer.attempts >= 2 && !answer.correct) {
answer.selectedAnswerIndex = -1;
}
} else {
answer = { questionIndex, selectedAnswerIndex: isCorrect ? selectedIndex : -1, correct: isCorrect, timeToAnswer, attempts: 1 };
this.session.answers.push(answer);
}
this.highlightAnswer(selectedIndex, isCorrect);
setTimeout(() => {
if (isCorrect) {
this.session.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
const progressEl = contentEl.querySelector(".mcq-progress");
if (progressEl instanceof HTMLElement)
progressEl.textContent = `Question ${this.session.currentQuestionIndex + 1} of ${this.mcqSet.questions.length}`;
const newProgressPercent = Math.round((this.session.currentQuestionIndex + 1) / this.mcqSet.questions.length * 100);
contentEl.setAttribute("data-progress", newProgressPercent.toString());
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(contentEl);
} else {
this.completeSession();
}
}
}, 1e3);
}
highlightAnswer(selectedIndex, isCorrect) {
const choiceButtons = this.contentEl.querySelectorAll(".mcq-choice-btn");
choiceButtons.forEach((button) => button.classList.remove("mcq-choice-correct", "mcq-choice-incorrect", "mcq-choice-selected"));
if (selectedIndex < choiceButtons.length) {
const selectedBtn = choiceButtons[selectedIndex];
selectedBtn.classList.add(isCorrect ? "mcq-choice-correct" : "mcq-choice-incorrect");
}
}
completeSession() {
const { contentEl } = this;
contentEl.empty();
try {
this.calculateScore();
this.session.completed = true;
this.session.completedAt = Date.now();
this.plugin.mcqService.saveMCQSession(this.session);
this.plugin.savePluginData();
contentEl.createEl("h2", { text: "Review Complete" });
const scoreEl = contentEl.createDiv("mcq-score");
const scoreTextEl = scoreEl.createDiv("mcq-score-text");
const scorePercentage = this.session.score;
const reviewSchedule = this.plugin.reviewScheduleService.schedules[this.notePath];
let ratingText = "";
let ratingDetails = "";
if ((reviewSchedule == null ? void 0 : reviewSchedule.schedulingAlgorithm) === "fsrs") {
let fsrsRating = 1;
if (scorePercentage === 1)
fsrsRating = 4;
else if (scorePercentage >= 0.75)
fsrsRating = 3;
else if (scorePercentage >= 0.5)
fsrsRating = 2;
ratingText = `FSRS Rating: ${getFsrsRatingText(fsrsRating)} (${fsrsRating}/4)`;
if (reviewSchedule.fsrsData) {
const fsrs = reviewSchedule.fsrsData;
ratingDetails += `Stability: ${fsrs.stability.toFixed(2)}, Difficulty: ${fsrs.difficulty.toFixed(2)}, Interval: ${fsrs.scheduled_days}d, State: ${mapFsrsStateToString(fsrs.state)}, Reps: ${fsrs.reps}, Lapses: ${fsrs.lapses}`;
if (fsrs.last_review) {
const nextDueDate = new Date(fsrs.last_review);
nextDueDate.setDate(nextDueDate.getDate() + fsrs.scheduled_days);
ratingDetails += `, Next Due: ${nextDueDate.toLocaleDateString()}`;
}
}
} else if ((reviewSchedule == null ? void 0 : reviewSchedule.schedulingAlgorithm) === "sm2") {
let sm2Rating = 0;
if (scorePercentage >= 0.9)
sm2Rating = 5;
else if (scorePercentage >= 0.8)
sm2Rating = 4;
else if (scorePercentage >= 0.6)
sm2Rating = 3;
else if (scorePercentage >= 0.4)
sm2Rating = 2;
else if (scorePercentage >= 0.2)
sm2Rating = 1;
ratingText = `SM-2 Rating: ${getSm2RatingText(sm2Rating)} (${sm2Rating}/5)`;
if (reviewSchedule) {
ratingDetails += `Ease: ${(reviewSchedule.ease / 100).toFixed(2)}, Interval: ${reviewSchedule.interval}d, Next Due: ${new Date(reviewSchedule.nextReviewDate).toLocaleDateString()}`;
if (reviewSchedule.repetitionCount !== void 0)
ratingDetails += `, Reps: ${reviewSchedule.repetitionCount}`;
}
} else {
ratingText = `Score: ${Math.round(scorePercentage * 100)}%`;
}
scoreTextEl.setText(ratingText);
if (ratingDetails) {
const detailsEl = scoreEl.createDiv("mcq-score-details");
detailsEl.setText(ratingDetails);
}
const resultsEl = contentEl.createDiv("mcq-results");
resultsEl.createEl("h3", { text: "Question Results" });
if (this.session.answers.length === 0) {
resultsEl.createDiv({ cls: "mcq-no-answers", text: "No questions were answered in this session." });
} else {
this.session.answers.forEach((answer) => {
try {
const question = this.mcqSet.questions[answer.questionIndex];
if (!question || !question.choices)
return;
const resultItem = resultsEl.createDiv("mcq-result-item");
resultItem.createDiv({ cls: "mcq-result-question", text: question.question || "Question text missing" });
if (answer.attempts > 1) {
if (answer.selectedAnswerIndex !== -1) {
const yourAnswer = resultItem.createDiv("mcq-result-your-answer");
yourAnswer.createSpan({ cls: "mcq-result-label", text: "Your final answer (correct after multiple attempts): " });
yourAnswer.createSpan({ cls: "mcq-result-correct", text: question.choices[answer.selectedAnswerIndex] || "(invalid choice)" });
} else {
const yourAnswer = resultItem.createDiv("mcq-result-your-answer");
yourAnswer.createSpan({ cls: "mcq-result-label", text: "Your answer: " });
yourAnswer.createSpan({ cls: "mcq-result-incorrect", text: '(Incorrect - used "Show Answer" option)' });
}
const correctAnswer = resultItem.createDiv("mcq-result-correct-answer");
correctAnswer.createSpan({ cls: "mcq-result-label", text: "Correct answer: " });
correctAnswer.createSpan({ cls: "mcq-result-correct", text: question.choices[question.correctAnswerIndex] || "(invalid choice)" });
} else {
const yourAnswer = resultItem.createDiv("mcq-result-your-answer");
yourAnswer.createSpan({ cls: "mcq-result-label", text: "Your answer: " });
yourAnswer.createSpan({ cls: answer.correct ? "mcq-result-correct" : "mcq-result-incorrect", text: question.choices[answer.selectedAnswerIndex] || "(invalid choice)" });
if (!answer.correct) {
const correctAnswer = resultItem.createDiv("mcq-result-correct-answer");
correctAnswer.createSpan({ cls: "mcq-result-label", text: "Correct answer: " });
correctAnswer.createSpan({ cls: "mcq-result-correct", text: question.choices[question.correctAnswerIndex] || "(invalid choice)" });
}
}
resultItem.createDiv({ cls: "mcq-result-attempts", text: `Attempts: ${answer.attempts}` });
resultItem.createDiv({ cls: "mcq-result-time", text: `Time: ${Math.round(answer.timeToAnswer)} seconds` });
} catch (error) {
console.error("Error displaying answer result:", error);
}
});
}
const closeBtn = contentEl.createEl("button", { cls: "mcq-close-btn", text: "Close" });
closeBtn.addEventListener("click", () => {
if (this.onCompleteCallback) {
this.onCompleteCallback(this.notePath, this.session.score, true);
}
this.close();
});
} catch (error) {
console.error("Error completing MCQ session:", error);
contentEl.createEl("h2", { text: "Error Completing Session" });
contentEl.createEl("p", { text: "There was an error completing the MCQ session. Please try again." });
const errorCloseBtn = contentEl.createEl("button", { cls: "mcq-close-btn", text: "Close" });
errorCloseBtn.addEventListener("click", () => this.close());
}
}
calculateScore() {
let totalScore = 0;
this.session.answers.forEach((answer) => {
let questionScore = 0;
if (answer.correct && answer.selectedAnswerIndex !== -1) {
if (answer.attempts === 1)
questionScore = 1;
else if (answer.attempts === 2)
questionScore = 0.5;
}
if (questionScore > 0 && answer.timeToAnswer > this.plugin.settings.mcqTimeDeductionSeconds) {
questionScore -= this.plugin.settings.mcqTimeDeductionAmount;
questionScore = Math.max(0, questionScore);
}
totalScore += questionScore;
});
this.session.score = this.mcqSet.questions.length > 0 ? totalScore / this.mcqSet.questions.length : 0;
}
// onClose is now handled by the keyboard shortcut registration cleanup
};
function mapFsrsStateToString(state) {
switch (state) {
case 0:
return "New";
case 1:
return "Learning";
case 2:
return "Review";
case 3:
return "Relearning";
default:
return "Unknown";
}
}
function getFsrsRatingText(rating) {
switch (rating) {
case 1:
return "Again";
case 2:
return "Hard";
case 3:
return "Good";
case 4:
return "Easy";
default:
return "Unknown";
}
}
function getSm2RatingText(rating) {
switch (rating) {
case 0:
return "Complete Blackout";
case 1:
return "Incorrect Response";
case 2:
return "Incorrect But Familiar";
case 3:
return "Correct With Difficulty";
case 4:
return "Correct With Hesitation";
case 5:
return "Perfect Recall";
default:
return "Unknown";
}
}
// controllers/review-controller-mcq.ts
var MCQController = class {
/**
* Initialize MCQ controller
*
* @param plugin Reference to the main plugin
* @param mcqService Reference to the MCQ data service
* @param mcqGenerationService Reference to the MCQ generation service
*/
constructor(plugin, mcqService, mcqGenerationService) {
this.plugin = plugin;
this.mcqService = mcqService;
this.mcqGenerationService = mcqGenerationService;
}
/**
* Start an MCQ review session for a note
*
* @param notePath Path to the note
* @param onCompleteCallback Optional callback when the modal closes
*/
// Helper methods to map score to ratings
mapScoreToSm2Response(score) {
if (score >= 0.95)
return 5 /* PerfectRecall */;
if (score >= 0.8)
return 4 /* CorrectWithHesitation */;
if (score >= 0.6)
return 3 /* CorrectWithDifficulty */;
if (score >= 0.4)
return 2 /* IncorrectButFamiliar */;
if (score > 0)
return 1 /* IncorrectResponse */;
return 0 /* CompleteBlackout */;
}
mapScoreToFsrsRating(score) {
if (score >= 0.9)
return 4 /* Easy */;
if (score >= 0.7)
return 3 /* Good */;
if (score >= 0.5)
return 2 /* Hard */;
return 1 /* Again */;
}
async startMCQReview(notePath, externalOnCompleteCallback) {
if (!this.plugin.settings.enableMCQ) {
new import_obsidian9.Notice("MCQ feature is disabled in settings.");
if (externalOnCompleteCallback)
externalOnCompleteCallback(notePath, false);
return;
}
if (!this.mcqGenerationService) {
new import_obsidian9.Notice("MCQ generation service is not available. Check API provider settings.");
return;
}
try {
let mcqSet = this.mcqService.getMCQSetForNote(notePath);
if (mcqSet && mcqSet.needsQuestionRegeneration) {
new import_obsidian9.Notice("Questions for this note are flagged for regeneration. Generating new set...");
mcqSet = await this.generateMCQs(notePath, true);
if (mcqSet) {
mcqSet.needsQuestionRegeneration = false;
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
} else {
new import_obsidian9.Notice("Failed to regenerate MCQs. Using existing set if available.");
mcqSet = this.mcqService.getMCQSetForNote(notePath);
}
}
if (!mcqSet || mcqSet.questions.length === 0) {
new import_obsidian9.Notice("No MCQs found for this note. Generating new set...");
mcqSet = await this.generateMCQs(notePath);
if (!mcqSet) {
new import_obsidian9.Notice("Failed to generate MCQs for this note.");
return;
}
}
const session = {
notePath,
mcqSetId: `${mcqSet.notePath}_${mcqSet.generatedAt}`,
startedAt: Date.now(),
answers: [],
completed: false,
score: 0,
currentQuestionIndex: 0,
// Initialize required property
completedAt: null
// Initialize required property (removed duplicate)
};
const internalOnComplete = async (path, score, completed) => {
if (completed) {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (schedule) {
let rating;
if (schedule.schedulingAlgorithm === "fsrs") {
rating = this.mapScoreToFsrsRating(score);
new import_obsidian9.Notice(`MCQ complete for FSRS card. Score: ${(score * 100).toFixed(0)}%. Rating: ${FsrsRating[rating]}(${rating}).`);
} else {
rating = this.mapScoreToSm2Response(score);
new import_obsidian9.Notice(`MCQ complete for SM-2 card. Score: ${(score * 100).toFixed(0)}%. Rating: ${ReviewResponse[rating]}(${rating}).`);
}
await this.plugin.reviewController.processReviewResponse(path, rating);
} else {
new import_obsidian9.Notice(`MCQ complete. Score: ${(score * 100).toFixed(0)}%. Could not find schedule to update review status.`);
}
} else {
new import_obsidian9.Notice(`MCQ session for ${path} was not fully completed. Score (partial): ${(score * 100).toFixed(0)}%. Review not recorded.`);
}
if (externalOnCompleteCallback) {
externalOnCompleteCallback(path, completed && score >= 0.7);
}
};
new MCQModal(this.plugin, notePath, mcqSet, internalOnComplete).open();
} catch (error) {
console.error("Error starting MCQ review:", error);
new import_obsidian9.Notice("Error starting MCQ review. Please check console for details.");
if (externalOnCompleteCallback)
externalOnCompleteCallback(notePath, false);
}
}
/**
* Generate MCQs for a note
*
* @param notePath Path to the note
* @param forceRegeneration If true, will ignore existing fresh sets and generate new ones.
* @returns Generated MCQ set or null if failed
*/
async generateMCQs(notePath, forceRegeneration = false) {
if (!this.plugin.settings.enableMCQ || !this.mcqGenerationService) {
new import_obsidian9.Notice("MCQ feature is disabled or the generation service is not available. Check API provider settings.");
return null;
}
if (!forceRegeneration) {
const existingSet = this.mcqService.getMCQSetForNote(notePath);
if (existingSet) {
const twentyFourHours = 24 * 60 * 60 * 1e3;
if (Date.now() - existingSet.generatedAt < twentyFourHours && !existingSet.needsQuestionRegeneration) {
new import_obsidian9.Notice("Using recently generated MCQs for this note.");
return existingSet;
}
}
}
const file = this.plugin.app.vault.getAbstractFileByPath(notePath);
if (!(file instanceof import_obsidian9.TFile)) {
new import_obsidian9.Notice("Cannot generate MCQs: file not found");
return null;
}
const content = await this.plugin.app.vault.read(file);
const mcqSet = await this.mcqGenerationService.generateMCQs(notePath, content, this.plugin.settings);
if (mcqSet) {
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
new import_obsidian9.Notice("MCQs generated and saved successfully.");
return mcqSet;
} else {
return null;
}
}
// Methods to access MCQ sessions, delegated to mcqService
getMCQSessionsForNote(notePath) {
return this.mcqService.getMCQSessionsForNote(notePath);
}
getLatestMCQSessionForNote(notePath) {
return this.mcqService.getLatestMCQSessionForNote(notePath);
}
async saveMCQSession(session) {
this.mcqService.saveMCQSession(session);
await this.plugin.savePluginData();
}
/**
* Starts a consolidated MCQ review session for all notes due on the currently selected review date.
*/
async startConsolidatedMCQReviewForSelectedDate() {
if (!this.plugin.settings.enableMCQ) {
new import_obsidian9.Notice("MCQ feature is disabled in settings.");
return;
}
if (!this.mcqGenerationService) {
new import_obsidian9.Notice("MCQ generation service is not available. Check API provider settings.");
return;
}
const dueNotes = this.plugin.reviewController.getTodayNotes();
if (dueNotes.length === 0) {
new import_obsidian9.Notice("No notes due for review on the selected date.");
return;
}
const mcqSetsForReview = [];
let notesWithMCQsCount = 0;
new import_obsidian9.Notice(`Fetching MCQs for ${dueNotes.length} due note(s)...`);
for (const noteSchedule of dueNotes) {
const notePath = noteSchedule.path;
let mcqSet = this.mcqService.getMCQSetForNote(notePath);
if (mcqSet && mcqSet.needsQuestionRegeneration) {
new import_obsidian9.Notice(`Regenerating flagged MCQs for ${notePath}...`);
const regeneratedMcqSet = await this.generateMCQs(notePath, true);
if (regeneratedMcqSet) {
mcqSet = regeneratedMcqSet;
mcqSet.needsQuestionRegeneration = false;
this.mcqService.saveMCQSet(mcqSet);
} else {
new import_obsidian9.Notice(`Failed to regenerate MCQs for ${notePath}. Using existing set if available (might be outdated or empty).`);
}
}
if (!mcqSet || mcqSet.questions.length === 0) {
new import_obsidian9.Notice(`No MCQs found or set is empty for ${notePath}. Attempting to generate new set...`);
const newMcqSet = await this.generateMCQs(notePath, false);
if (newMcqSet) {
mcqSet = newMcqSet;
} else {
new import_obsidian9.Notice(`Failed to generate MCQs for ${notePath}. This note will be skipped in MCQ review.`);
}
}
if (mcqSet && mcqSet.questions.length > 0) {
const file = this.plugin.app.vault.getAbstractFileByPath(notePath);
mcqSetsForReview.push({
path: notePath,
mcqSet,
fileName: file instanceof import_obsidian9.TFile ? file.basename : notePath
});
notesWithMCQsCount++;
}
}
if (mcqSetsForReview.length === 0) {
new import_obsidian9.Notice("No MCQs available or generated for the due notes.");
return;
}
await this.plugin.savePluginData();
new import_obsidian9.Notice(`Starting consolidated MCQ review for ${notesWithMCQsCount} note(s) with ${mcqSetsForReview.reduce((sum, s) => sum + s.mcqSet.questions.length, 0)} questions.`);
const onConsolidatedComplete = async (results) => {
let reviewsProcessed = 0;
for (const result of results) {
const schedule = this.plugin.reviewScheduleService.schedules[result.path];
if (schedule && typeof result.score === "number") {
let rating;
if (schedule.schedulingAlgorithm === "fsrs") {
rating = this.mapScoreToFsrsRating(result.score);
new import_obsidian9.Notice(`MCQ for ${result.path} (FSRS) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${FsrsRating[rating]}(${rating})`);
} else {
rating = this.mapScoreToSm2Response(result.score);
new import_obsidian9.Notice(`MCQ for ${result.path} (SM-2) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${ReviewResponse[rating]}(${rating})`);
}
await this.plugin.reviewController.processReviewResponse(result.path, rating);
reviewsProcessed++;
} else if (schedule) {
console.warn(`MCQ result for ${result.path} did not have a score. Review not recorded via MCQ.`);
}
}
if (reviewsProcessed > 0) {
new import_obsidian9.Notice(`${reviewsProcessed} note review(s) updated based on consolidated MCQ session.`);
} else {
new import_obsidian9.Notice("No note reviews were updated from the MCQ session.");
}
};
new ConsolidatedMCQModal(this.plugin, mcqSetsForReview, onConsolidatedComplete).open();
}
};
// controllers/review-controller.ts
var ReviewController = class {
// Add batch controller
/**
* Constructor initializes the review controller
*
* @param plugin Reference to the main plugin
* @param mcqService Reference to the MCQ service
*/
constructor(plugin, mcqService) {
this.plugin = plugin;
this.coreController = new ReviewControllerCore(plugin);
this.navigationController = new ReviewNavigationController(plugin);
if (plugin.mcqGenerationService) {
this.mcqController = new MCQController(plugin, mcqService, plugin.mcqGenerationService);
} else {
console.warn("MCQ Generation Service not available during ReviewController initialization.");
}
this.batchController = new ReviewBatchController(plugin);
}
/**
* Update the list of today's due notes
* Delegates to core controller
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex = false) {
await this.coreController.updateTodayNotes(preserveCurrentIndex);
}
/**
* Get the currently loaded notes due for review
* Delegates to core controller
*/
getTodayNotes() {
return this.coreController.getTodayNotes();
}
/**
* Get the current index in today's notes
* Delegates to core controller
*/
getCurrentNoteIndex() {
return this.coreController.getCurrentNoteIndex();
}
/**
* Set the current index in today's notes
* Delegates to core controller
*
* @param index The new index
*/
setCurrentNoteIndex(index) {
this.coreController.setCurrentNoteIndex(index);
}
/**
* Review the current note
* Delegates to core controller
*/
async reviewCurrentNote() {
await this.coreController.reviewCurrentNote();
}
/**
* Review a specific note
* Delegates to core controller
*
* @param path Path to the note file
*/
async reviewNote(path) {
await this.coreController.reviewNote(path);
}
/**
* Postpone a note's review
* Delegates to core controller
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path, days = 1) {
await this.coreController.postponeNote(path, days);
}
/**
* Advance a note's review by one day, if eligible.
* Delegates to core controller.
*
* @param path Path to the note file
*/
async advanceNote(path) {
await this.coreController.advanceNote(path);
}
/**
* Handle a note being postponed, updating navigation state
* Delegates to core controller
*
* @param path Path to the postponed note
*/
async handleNotePostponed(path) {
await this.coreController.handleNotePostponed(path);
}
/**
* Show the review modal for a note
* Delegates to core controller
*
* @param path Path to the note file
*/
showReviewModal(path) {
this.coreController.showReviewModal(path);
}
/**
* Skip the review of a note and reschedule for tomorrow with penalty
* Delegates to core controller
*
* @param path Path to the note file
*/
async skipReview(path) {
await this.coreController.skipReview(path);
}
/**
* Process a review response
* Delegates to core controller
*
* @param path Path to the note file
* @param response User's response during review (SM-2 or FSRS)
*/
async processReviewResponse(path, response) {
await this.coreController.processReviewResponse(path, response);
}
/**
* Sets an override for the current review date.
* Delegates to core controller.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
async setReviewDateOverride(date) {
await this.coreController.setReviewDateOverride(date);
}
/**
* Gets the current review date override.
* Delegates to core controller.
* @returns Timestamp of the override, or null if no override is set.
*/
getCurrentReviewDateOverride() {
return this.coreController.getCurrentReviewDateOverride();
}
// Delegate methods to specialized controllers
/**
* Start reviewing all of today's notes
* Delegates to batch controller
*/
async reviewAllTodaysNotes() {
await this.batchController.reviewAllTodaysNotes();
}
/**
* Navigate to the next note
* Delegates to navigation controller
*/
async navigateToNextNote() {
await this.navigationController.navigateToNextNote();
}
/**
* Navigate to the previous note
* Delegates to navigation controller
*/
async navigateToPreviousNote() {
await this.navigationController.navigateToPreviousNote();
}
/**
* Start an MCQ review session for a note
* Delegates to MCQ controller
*
* @param notePath Path to the note
* @param onComplete Optional callback for when MCQ review is completed
*/
async startMCQReview(notePath, onComplete) {
if (this.mcqController) {
await this.mcqController.startMCQReview(notePath, onComplete);
} else {
console.error("MCQ Controller not initialized when calling startMCQReview");
if (onComplete) {
onComplete(notePath, false);
}
}
}
/**
* Review a specific set of notes
* Delegates to batch controller
*
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths, useMCQ = false) {
await this.batchController.reviewNotes(paths, useMCQ);
}
/**
* Postpone a specific set of notes
* Delegates to batch controller
*
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths, days = 1) {
await this.batchController.postponeNotes(paths, days);
}
/**
* Advance a specific set of notes by one day each, if eligible.
* Delegates to batch controller.
*
* @param paths Array of note paths to advance
*/
async advanceNotes(paths) {
await this.batchController.advanceNotes(paths);
}
/**
* Remove a specific set of notes from the review schedule
* Delegates to batch controller
*
* @param paths Array of note paths to remove
*/
async removeNotes(paths) {
await this.batchController.removeNotes(paths);
}
/**
* Open a note without showing the review modal
* Delegates to navigation controller
*
* @param path Path to the note file
*/
async openNoteWithoutReview(path) {
await this.navigationController.openNoteWithoutReview(path);
}
/**
* Swap two notes in the traversal order
* Delegates to navigation controller
*
* @param path1 Path to the first note
* @param path2 Path to the second note
*/
async swapNotes(path1, path2) {
await this.navigationController.swapNotes(path1, path2);
}
/**
* Review all notes with MCQs in a batch
* Delegates to batch controller
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ = true) {
await this.batchController.reviewAllNotesWithMCQ(useMCQ);
}
};
// utils/link-analyzer.ts
var import_obsidian10 = require("obsidian");
var LinkAnalyzer = class {
/**
* Analyze links in a folder and build a review hierarchy
*
* @param vault Obsidian vault
* @param folder Folder to analyze
* @param includeSubfolders Whether to include subfolders
* @returns Review hierarchy for the folder
*/
static async analyzeFolder(vault, folder, includeSubfolders) {
const files = vault.getMarkdownFiles().filter((file) => {
if (includeSubfolders) {
return file.path.startsWith(folder.path);
} else {
const parentPath = file.parent ? file.parent.path : "";
return parentPath === folder.path;
}
});
const nodes = {};
for (const file of files) {
nodes[file.path] = {
path: file.path,
outgoingLinks: [],
regularLinks: [],
embedLinks: [],
incomingLinks: [],
incomingLinkCount: 0
};
}
for (const file of files) {
try {
const content = await vault.read(file);
const node = nodes[file.path];
node.content = content;
const noteLinks = this.extractLinks(content);
for (const noteLink of noteLinks) {
const resolvedPath = this.resolveLink(noteLink.text, file.path, files);
if (resolvedPath && nodes[resolvedPath]) {
if (!node.outgoingLinks.includes(resolvedPath)) {
node.outgoingLinks.push(resolvedPath);
if (noteLink.isEmbed) {
if (!node.embedLinks.includes(resolvedPath)) {
node.embedLinks.push(resolvedPath);
}
} else {
if (!node.regularLinks.includes(resolvedPath)) {
node.regularLinks.push(resolvedPath);
}
}
}
const targetNode = nodes[resolvedPath];
if (!targetNode.incomingLinks.includes(file.path)) {
targetNode.incomingLinks.push(file.path);
targetNode.incomingLinkCount++;
}
}
}
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
}
}
const startingNode = this.findStartingNode(nodes);
const traversalOrder = this.createTraversalOrder(nodes, startingNode);
return {
rootNodes: startingNode,
nodes,
traversalOrder
};
}
/**
* Extract links from markdown content in the order they appear
*
* @param content Markdown content
* @returns Array of note links with information about whether they are embeds
*/
static extractLinks(content) {
const links = [];
let match;
this.LINK_REGEX.lastIndex = 0;
while ((match = this.LINK_REGEX.exec(content)) !== null) {
links.push({
text: match[2],
// The link text is now in the second capture group
isEmbed: match[1] === "!"
// True if it has an exclamation mark
});
}
return links;
}
/**
* Resolve a link to a full file path
*
* @param link Link text
* @param sourcePath Path of the source file
* @param allFiles All available files
* @returns Resolved file path or null if not found
*/
static resolveLink(link, sourcePath, allFiles) {
if (link.endsWith(".md")) {
const exactFile = allFiles.find((f) => f.path.endsWith("/" + link) || f.path === link);
if (exactFile) {
return exactFile.path;
}
}
const basename = link.split("/").pop();
if (!basename)
return null;
const matchingFiles = allFiles.filter((f) => f.basename === basename);
if (matchingFiles.length === 0) {
return null;
}
if (matchingFiles.length === 1) {
return matchingFiles[0].path;
}
const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf("/"));
const sameDir = matchingFiles.find((f) => f.path.startsWith(sourceDir + "/"));
return sameDir ? sameDir.path : matchingFiles[0].path;
}
/**
* Find the best starting node based on file naming and link structure
*
* @param nodes All file nodes
* @returns Array with the path of the best starting node
*/
static findStartingNode(nodes) {
var _a, _b, _c, _d;
if (Object.keys(nodes).length === 0) {
return [];
}
const folderNodes = {};
for (const path in nodes) {
const folderPath = path.substring(0, path.lastIndexOf("/"));
if (!folderNodes[folderPath]) {
folderNodes[folderPath] = [];
}
folderNodes[folderPath].push(path);
}
for (const folderPath in folderNodes) {
const folderName = ((_a = folderPath.split("/").pop()) == null ? void 0 : _a.toLowerCase()) || "";
const filesInFolder = folderNodes[folderPath];
for (const path of filesInFolder) {
const fileName = ((_b = path.split("/").pop()) == null ? void 0 : _b.toLowerCase().replace(/\.md$/, "")) || "";
if (fileName === folderName) {
return [path];
}
}
for (const path of filesInFolder) {
const fileName = ((_c = path.split("/").pop()) == null ? void 0 : _c.toLowerCase().replace(/\.md$/, "")) || "";
if (fileName.includes(folderName) || folderName.includes(fileName)) {
return [path];
}
}
for (const path of filesInFolder) {
const fileName = ((_d = path.split("/").pop()) == null ? void 0 : _d.toLowerCase().replace(/\.md$/, "")) || "";
if (fileName === "index" || fileName === "main" || fileName.includes("index") || fileName.includes("readme") || fileName.includes("main")) {
return [path];
}
}
}
const sortedNodes = Object.values(nodes).sort((a, b2) => {
const regularLinkDiff = b2.regularLinks.length - a.regularLinks.length;
if (regularLinkDiff !== 0) {
return regularLinkDiff;
}
return b2.outgoingLinks.length - a.outgoingLinks.length;
});
if (sortedNodes.length > 0 && (sortedNodes[0].regularLinks.length > 0 || sortedNodes[0].outgoingLinks.length > 0)) {
return [sortedNodes[0].path];
}
return Object.keys(nodes).length > 0 ? [Object.keys(nodes)[0]] : [];
}
/**
* Find root nodes (files with the most incoming links)
* For backward compatibility, retained but replaced by findStartingNode
*
* @param nodes All file nodes
* @returns Array of root node paths
*/
static findRootNodes(nodes) {
return this.findStartingNode(nodes);
}
/**
* Create a traversal order for reviewing files that respects the exact order of links
*
* @param nodes All file nodes
* @param startNodePath Path to the starting node
* @returns Array of file paths in traversal order
*/
static createOrderedTraversal(nodes, startNodePath) {
const visited = /* @__PURE__ */ new Set();
const traversalOrder = [];
const fileFolders = /* @__PURE__ */ new Map();
for (const path in nodes) {
const folderPath = path.substring(0, path.lastIndexOf("/"));
fileFolders.set(path, folderPath);
}
const mainFolder = fileFolders.get(startNodePath) || "";
const traverse = (currentNodePath, currentDepth = 0, currentMainFolder) => {
if (visited.has(currentNodePath)) {
return;
}
const node = nodes[currentNodePath];
if (!node) {
console.warn(`${" ".repeat(currentDepth)}LinkAnalyzer: Node not found for path: ${currentNodePath}`);
return;
}
visited.add(currentNodePath);
traversalOrder.push(currentNodePath);
const currentNodeFolder = fileFolders.get(currentNodePath) || "";
const linksToFollow = node.regularLinks.length > 0 ? node.regularLinks : node.outgoingLinks;
for (const linkedPath of linksToFollow) {
if (!nodes[linkedPath]) {
continue;
}
const linkedNodeFolder = fileFolders.get(linkedPath) || "";
if (nodes[linkedPath] && linkedNodeFolder.startsWith(currentMainFolder)) {
if (!visited.has(linkedPath)) {
traverse(linkedPath, currentDepth + 1, currentMainFolder);
} else {
}
} else {
}
}
};
if (nodes[startNodePath]) {
traverse(startNodePath, 0, mainFolder);
} else {
console.warn(`LinkAnalyzer: Start node ${startNodePath} not found in nodes. Traversal order might be empty or incomplete.`);
}
return traversalOrder;
}
/**
* Create a traversal order for reviewing files
*
* @param nodes All file nodes
* @param rootNodes Root node paths
* @returns Array of file paths in traversal order
*/
static createTraversalOrder(nodes, rootNodes) {
if (rootNodes.length === 1) {
return this.createOrderedTraversal(nodes, rootNodes[0]);
}
const visited = /* @__PURE__ */ new Set();
const traversalOrder = [];
const traverse = (nodePath) => {
if (visited.has(nodePath))
return;
visited.add(nodePath);
traversalOrder.push(nodePath);
const node = nodes[nodePath];
if (!node)
return;
for (const linkedPath of node.outgoingLinks) {
traverse(linkedPath);
}
};
for (const rootPath of rootNodes) {
traverse(rootPath);
}
for (const nodePath of Object.keys(nodes)) {
if (!visited.has(nodePath)) {
traversalOrder.push(nodePath);
visited.add(nodePath);
}
}
return traversalOrder;
}
/**
* Analyze links in a single note
*
* @param vault Obsidian vault
* @param filePath Path to the note file
* @param regularOnly Whether to include only regular wiki links (not embeds)
* @returns Array of resolved link paths in the order they appear
*/
static async analyzeNoteLinks(vault, filePath, regularOnly = false) {
const file = vault.getAbstractFileByPath(filePath);
if (!(file instanceof import_obsidian10.TFile)) {
return [];
}
try {
const content = await vault.read(file);
const noteLinks = this.extractLinks(content);
const resolvedLinks = [];
const seenLinks = /* @__PURE__ */ new Set();
const filteredLinks = regularOnly ? noteLinks.filter((link) => !link.isEmbed) : noteLinks;
for (const link of filteredLinks) {
const resolvedPath = this.resolveLink(
link.text,
filePath,
vault.getMarkdownFiles()
);
if (resolvedPath && !seenLinks.has(resolvedPath)) {
resolvedLinks.push(resolvedPath);
seenLinks.add(resolvedPath);
}
}
return resolvedLinks;
} catch (error) {
console.error(`LinkAnalyzer: Error analyzing links in ${filePath}:`, error);
return [];
}
}
};
/**
* Regular expression for finding wikilinks of both forms: [[filename]] and ![[filename]]
* First capture group matches the exclamation mark (if present)
* Second capture group matches the content inside the brackets
*/
LinkAnalyzer.LINK_REGEX = /(!?)(?:\[\[(.*?)\]\])/g;
// controllers/review-session-controller.ts
var ReviewSessionController = class {
/**
* Initialize session controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
/**
* Cache of linked notes to improve performance
*/
this.linkedNoteCache = /* @__PURE__ */ new Map();
this.plugin = plugin;
}
/**
* Get linked notes that are due today
*
* @param notePath Path of the note to get links from
* @returns Array of paths to linked notes that are due today
*/
getDueLinkedNotes(notePath) {
const reviewController = this.plugin.reviewController;
if (!reviewController)
return [];
const todayNotes = reviewController.getTodayNotes();
const links = this.linkedNoteCache.get(notePath) || [];
const duePaths = todayNotes.map((n) => n.path);
if (links.length === 0) {
this.analyzeNoteLinks(notePath).then((newLinks) => {
if (newLinks.length > 0) {
this.linkedNoteCache.set(notePath, newLinks);
}
});
return [];
}
return links.filter((link) => duePaths.includes(link));
}
/**
* Analyze links in a note and cache the results
*
* @param notePath Path to the note
* @returns Array of linked note paths
*/
async analyzeNoteLinks(notePath) {
try {
const links = await LinkAnalyzer.analyzeNoteLinks(
this.plugin.app.vault,
notePath,
true
// regularOnly = true - only include regular wiki links, not embeds
);
this.linkedNoteCache.set(notePath, links);
return links;
} catch (error) {
console.error(`Error analyzing links for ${notePath}:`, error);
return [];
}
}
/**
* Clear the link cache for a specific note or all notes
*
* @param notePath Optional path to clear cache for specific note
*/
clearLinkCache(notePath) {
if (notePath) {
this.linkedNoteCache.delete(notePath);
} else {
this.linkedNoteCache.clear();
}
}
};
// ui/context-menu.ts
var import_obsidian11 = require("obsidian");
var ContextMenuHandler = class {
/**
* Initialize context menu handler
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
this.plugin = plugin;
}
/**
* Register context menu handlers
*/
register() {
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-menu", this.handleFileMenuEvent.bind(this))
);
}
/**
* Handles the 'file-menu' event for TAbstractFile (could be TFile or TFolder).
*
* @param menu Context menu
* @param abstractFile Target file or folder
*/
handleFileMenuEvent(menu, abstractFile) {
if (abstractFile instanceof import_obsidian11.TFolder) {
this.addFolderMenuItems(menu, abstractFile);
} else if (abstractFile instanceof import_obsidian11.TFile && abstractFile.extension === "md") {
this.addFileMenuItems(menu, abstractFile);
}
}
/**
* Adds context menu items for a TFile.
*
* @param menu Context menu
* @param file Target file
*/
addFileMenuItems(menu, file) {
const isScheduled = !!this.plugin.reviewScheduleService.schedules[file.path];
menu.addItem((item) => {
item.setTitle(isScheduled ? "Update review schedule" : "Add to review schedule").setIcon("calendar-plus").onClick(async () => {
await this.plugin.reviewScheduleService.scheduleNoteForReview(file.path);
await this.plugin.savePluginData();
});
});
if (isScheduled) {
menu.addItem((item) => {
item.setTitle("Review now").setIcon("eye").onClick(() => this.plugin.reviewController.reviewNote(file.path));
});
menu.addItem((item) => {
item.setTitle("Remove from review").setIcon("calendar-minus").onClick(async () => {
await this.plugin.reviewScheduleService.removeFromReview(file.path);
await this.plugin.savePluginData();
});
});
}
if (file.parent) {
menu.addItem((item) => {
item.setTitle("Add note's folder to review schedule").setIcon("folder-plus").onClick(async () => {
if (file.parent) {
new import_obsidian11.Notice(`Adding folder "${file.parent.name}" to review schedule...`);
if (file.parent instanceof import_obsidian11.TFolder) {
await this.addFolderToReview(file.parent);
} else {
new import_obsidian11.Notice("Error: Parent is not a folder.");
console.error("Error: file.parent is not an instance of TFolder", file.parent);
}
}
});
});
}
}
/**
* Adds context menu items for a TFolder.
*
* @param menu Context menu
* @param folder Target folder
*/
addFolderMenuItems(menu, folder) {
try {
menu.addItem((item) => {
item.setTitle("Add folder to review").setIcon("calendar-plus").onClick(() => {
this.addFolderToReview(folder);
});
});
} catch (error) {
console.error("Error adding folder menu items:", error);
}
}
/**
* Add all markdown files in a folder to the review schedule
*
* @param folder Target folder
*/
async addFolderToReview(folder) {
new import_obsidian11.Notice(`Analyzing folder structure for "${folder.name}"...`);
try {
const allFiles = this.plugin.app.vault.getMarkdownFiles().filter((file) => {
if (this.plugin.settings.includeSubfolders) {
const folderPath = folder.path === "/" ? "/" : `${folder.path}/`;
return file.path.startsWith(folderPath);
} else {
const parentPath = file.parent ? file.parent.path : "";
return parentPath === folder.path;
}
});
if (allFiles.length === 0) {
new import_obsidian11.Notice("No markdown files found in folder.");
return;
}
const includeSubfolders = this.plugin.settings.includeSubfolders;
let mainFilePath = null;
for (const file of allFiles) {
const fileName = file.basename.toLowerCase();
const folderName = folder.name.toLowerCase();
if (fileName === folderName) {
mainFilePath = file.path;
break;
}
}
if (!mainFilePath) {
for (const file of allFiles) {
const fileName = file.basename.toLowerCase();
const folderName = folder.name.toLowerCase();
if (fileName.includes(folderName) || folderName.includes(fileName) || fileName === "index" || fileName === "main" || fileName.includes("index") || fileName.includes("main")) {
mainFilePath = file.path;
break;
}
}
}
if (!mainFilePath) {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (activeFile && activeFile.extension === "md" && allFiles.some((f) => f.path === activeFile.path)) {
mainFilePath = activeFile.path;
}
}
let traversalOrder = [];
const visited = /* @__PURE__ */ new Set();
const processLinksRecursively = async (path) => {
if (visited.has(path)) {
return;
}
visited.add(path);
traversalOrder.push(path);
const links = await LinkAnalyzer.analyzeNoteLinks(
this.plugin.app.vault,
path,
false
);
for (const link of links) {
const linkFile = this.plugin.app.vault.getAbstractFileByPath(link);
if (!(linkFile instanceof import_obsidian11.TFile) || linkFile.extension !== "md") {
continue;
}
if (allFiles.some((f) => f.path === linkFile.path)) {
await processLinksRecursively(linkFile.path);
} else {
if (!visited.has(linkFile.path)) {
visited.add(linkFile.path);
traversalOrder.push(linkFile.path);
}
}
}
};
if (mainFilePath) {
await processLinksRecursively(mainFilePath);
}
const sortedAllFiles = [...allFiles].sort((a, b2) => a.path.localeCompare(b2.path));
for (const file of sortedAllFiles) {
if (!visited.has(file.path)) {
await processLinksRecursively(file.path);
}
}
const count = await this.plugin.reviewScheduleService.scheduleNotesInOrder(traversalOrder);
if (count > 0) {
await this.plugin.savePluginData();
}
const startingFileName = traversalOrder.length > 0 ? traversalOrder[0].split("/").pop() : "unknown";
new import_obsidian11.Notice(`Added ${count} notes from "${folder.name}" to review schedule, starting with ${startingFileName}`);
await this.plugin.reviewController.updateTodayNotes();
} catch (error) {
console.error("Error adding folder to review:", error);
new import_obsidian11.Notice("Error adding folder to review schedule");
}
}
/**
* Create a hierarchical review session for a folder
*
* @param folder Target folder
*/
async createHierarchicalSession(folder) {
new import_obsidian11.Notice("Analyzing folder structure and links...");
const session = await this.plugin.reviewSessionService.createReviewSession(
folder.path,
folder.name
);
if (session) {
await this.plugin.savePluginData();
}
if (!session) {
new import_obsidian11.Notice("Failed to create review session");
return;
}
const fileCount = session.hierarchy.traversalOrder.length;
await this.plugin.reviewSessionService.setActiveSession(session.id);
await this.plugin.savePluginData();
const firstFilePath = this.plugin.reviewSessionService.getNextSessionFile();
if (firstFilePath) {
const scheduledCount = await this.plugin.reviewSessionService.scheduleSessionForReview(session.id);
if (scheduledCount > 0) {
await this.plugin.savePluginData();
}
new import_obsidian11.Notice(`Created hierarchical review session with ${fileCount} files.`);
const file = this.plugin.app.vault.getAbstractFileByPath(firstFilePath);
if (file instanceof import_obsidian11.TFile) {
this.plugin.reviewController.reviewNote(firstFilePath);
}
} else {
new import_obsidian11.Notice("No files found for review in this folder.");
await this.plugin.reviewSessionService.setActiveSession(null);
await this.plugin.savePluginData();
}
}
};
// ui/sidebar-view.ts
var import_obsidian16 = require("obsidian");
// ui/sidebar/pomodoro-ui-manager.ts
var import_obsidian12 = require("obsidian");
var PomodoroUIManager = class {
constructor(plugin) {
this.attachedContainer = null;
// The container provided by ListViewRenderer
// References to Pomodoro UI elements
this.pomodoroVisibilityToggleBtnContainer = null;
this.pomodoroVisibilityToggleBtn = null;
this.pomodoroRootEl = null;
// Container for the actual timer content
this.pomodoroTimerDisplayEl = null;
this.pomodoroStartBtn = null;
this.pomodoroStopBtn = null;
this.pomodoroSkipBtn = null;
this.pomodoroQuickSettingsPanelEl = null;
// private pomodoroQuickSettingsToggleBtn: HTMLElement | null = null; // Removed
this.pomodoroQuickWorkInput = null;
this.pomodoroQuickShortInput = null;
this.pomodoroQuickLongInput = null;
this.pomodoroQuickSessionsInput = null;
this.pomodoroCalculationResultEl = null;
// private isPomodoroSectionOpen: boolean = false; // No longer needed, section is always "open"
this.areButtonsVisible = true;
// For Play/Pause/Skip buttons
this.isTimerTextVisible = true;
// For the timer countdown text
this.longPressTimer = null;
this.veryLongPressTimer = null;
this.LONG_PRESS_DURATION = 500;
// ms
this.VERY_LONG_PRESS_DURATION = 1500;
// ms
this.didLongPress = false;
this.didVeryLongPress = false;
this.plugin = plugin;
}
/**
* Saves the current values from the Pomodoro quick settings input fields.
* @returns true if settings were valid and saved, false otherwise.
*/
_savePomodoroSettings() {
var _a, _b, _c, _d;
const work = parseInt(((_a = this.pomodoroQuickWorkInput) == null ? void 0 : _a.value) || "0");
const short = parseInt(((_b = this.pomodoroQuickShortInput) == null ? void 0 : _b.value) || "0");
const long = parseInt(((_c = this.pomodoroQuickLongInput) == null ? void 0 : _c.value) || "0");
const sessions = parseInt(((_d = this.pomodoroQuickSessionsInput) == null ? void 0 : _d.value) || "0");
if (work > 0 && short > 0 && long > 0 && sessions > 0) {
this.plugin.pomodoroService.updateDurations(work, short, long, sessions);
return true;
} else {
new import_obsidian12.Notice("Invalid Pomodoro durations. Settings not saved. Please enter positive numbers.");
if (this.pomodoroQuickWorkInput)
this.pomodoroQuickWorkInput.value = String(this.plugin.settings.pomodoroWorkDuration);
if (this.pomodoroQuickShortInput)
this.pomodoroQuickShortInput.value = String(this.plugin.settings.pomodoroShortBreakDuration);
if (this.pomodoroQuickLongInput)
this.pomodoroQuickLongInput.value = String(this.plugin.settings.pomodoroLongBreakDuration);
if (this.pomodoroQuickSessionsInput)
this.pomodoroQuickSessionsInput.value = String(this.plugin.settings.pomodoroSessionsUntilLongBreak);
return false;
}
}
/**
* Attaches the Pomodoro UI to a given container and renders its initial state.
* Creates necessary sub-containers if they don't exist.
* @param container The parent element where the Pomodoro UI should be placed.
*/
attachAndRender(container) {
var _a;
this.attachedContainer = container;
let rootElWasCreated = false;
if (!this.pomodoroRootEl || this.pomodoroRootEl.parentElement !== this.attachedContainer) {
(_a = this.pomodoroRootEl) == null ? void 0 : _a.remove();
this.pomodoroRootEl = this.attachedContainer.createDiv("pomodoro-section-content");
rootElWasCreated = true;
}
if (this.pomodoroRootEl) {
this.pomodoroRootEl.style.display = "";
}
if (rootElWasCreated || this.pomodoroRootEl && this.pomodoroRootEl.children.length === 0) {
this.renderPomodoroTimer(this.pomodoroRootEl);
}
this.updatePomodoroUI();
}
// getIsPomodoroSectionOpen, setIsPomodoroSectionOpen, setupPomodoroVisibilityToggleButton, updatePomodoroVisibility are no longer needed
// as the section is always visible and the toggle button is removed.
/** Controls the visibility of the entire attached Pomodoro UI section (e.g. for global plugin enable/disable) */
showPomodoroSection(show) {
if (this.attachedContainer) {
this.attachedContainer.style.display = show ? "" : "none";
}
}
/**
* Renders or updates the Pomodoro Timer UI elements into pomodoroRootEl.
* @param container The parent element to render into (this.pomodoroRootEl).
*/
renderPomodoroTimer(container) {
var _a, _b, _c, _d, _e;
let mainControlsRow = container.querySelector(".pomodoro-main-controls");
if (!mainControlsRow) {
mainControlsRow = container.createDiv("pomodoro-main-controls");
}
if (!this.pomodoroStartBtn || this.pomodoroStartBtn.parentElement !== mainControlsRow) {
(_a = this.pomodoroStartBtn) == null ? void 0 : _a.remove();
this.pomodoroStartBtn = mainControlsRow.createEl("button", { cls: "pomodoro-start-btn" });
(0, import_obsidian12.setIcon)(this.pomodoroStartBtn, "play");
this.pomodoroStartBtn.addEventListener("click", () => this.plugin.pomodoroService.start());
}
if (!this.pomodoroStopBtn || this.pomodoroStopBtn.parentElement !== mainControlsRow) {
(_b = this.pomodoroStopBtn) == null ? void 0 : _b.remove();
this.pomodoroStopBtn = mainControlsRow.createEl("button", { cls: "pomodoro-stop-btn" });
(0, import_obsidian12.setIcon)(this.pomodoroStopBtn, "pause");
this.pomodoroStopBtn.addEventListener("click", () => this.plugin.pomodoroService.stop());
}
this.pomodoroStopBtn.hide();
if (!this.pomodoroTimerDisplayEl || this.pomodoroTimerDisplayEl.parentElement !== mainControlsRow) {
(_c = this.pomodoroTimerDisplayEl) == null ? void 0 : _c.remove();
this.pomodoroTimerDisplayEl = mainControlsRow.createDiv("pomodoro-timer-display");
this.pomodoroTimerDisplayEl.addClass("pomodoro-timer-fade");
}
this.pomodoroTimerDisplayEl.setText(this.plugin.pomodoroService.getFormattedTimeLeft());
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.addEventListener("mousedown", (e) => {
e.preventDefault();
this.didLongPress = false;
this.didVeryLongPress = false;
this.longPressTimer = window.setTimeout(() => {
this.didLongPress = true;
}, this.LONG_PRESS_DURATION);
this.veryLongPressTimer = window.setTimeout(() => {
this.didVeryLongPress = true;
this.isTimerTextVisible = !this.isTimerTextVisible;
this.areButtonsVisible = this.isTimerTextVisible;
this.updateTimerTextDisplay();
this.updateButtonVisibility();
}, this.VERY_LONG_PRESS_DURATION);
});
const handlePressEnd = () => {
if (this.veryLongPressTimer)
clearTimeout(this.veryLongPressTimer);
if (this.longPressTimer)
clearTimeout(this.longPressTimer);
this.veryLongPressTimer = null;
this.longPressTimer = null;
if (this.didVeryLongPress) {
} else if (this.didLongPress) {
if (!this.isTimerTextVisible) {
this.isTimerTextVisible = true;
this.areButtonsVisible = true;
} else {
this.areButtonsVisible = !this.areButtonsVisible;
}
this.updateTimerTextDisplay();
this.updateButtonVisibility();
} else {
if (this.isTimerTextVisible) {
this.toggleSettingsPanel();
}
}
this.didLongPress = false;
this.didVeryLongPress = false;
};
this.pomodoroTimerDisplayEl.addEventListener("mouseup", handlePressEnd);
this.pomodoroTimerDisplayEl.addEventListener("touchend", handlePressEnd);
const cancelPress = () => {
if (this.veryLongPressTimer)
clearTimeout(this.veryLongPressTimer);
if (this.longPressTimer)
clearTimeout(this.longPressTimer);
this.veryLongPressTimer = null;
this.longPressTimer = null;
this.didLongPress = false;
this.didVeryLongPress = false;
};
this.pomodoroTimerDisplayEl.addEventListener("mouseleave", cancelPress);
this.pomodoroTimerDisplayEl.addEventListener("touchmove", cancelPress);
this.pomodoroTimerDisplayEl.addEventListener("touchstart", (e) => {
e.preventDefault();
this.didLongPress = false;
this.didVeryLongPress = false;
this.longPressTimer = window.setTimeout(() => {
this.didLongPress = true;
}, this.LONG_PRESS_DURATION);
this.veryLongPressTimer = window.setTimeout(() => {
this.didVeryLongPress = true;
this.isTimerTextVisible = !this.isTimerTextVisible;
this.areButtonsVisible = this.isTimerTextVisible;
this.updateTimerTextDisplay();
this.updateButtonVisibility();
}, this.VERY_LONG_PRESS_DURATION);
}, { passive: false });
}
if (!this.pomodoroSkipBtn || this.pomodoroSkipBtn.parentElement !== mainControlsRow) {
(_d = this.pomodoroSkipBtn) == null ? void 0 : _d.remove();
this.pomodoroSkipBtn = mainControlsRow.createEl("button", { cls: "pomodoro-skip-btn" });
(0, import_obsidian12.setIcon)(this.pomodoroSkipBtn, "skip-forward");
this.pomodoroSkipBtn.addEventListener("click", () => this.plugin.pomodoroService.skipSession());
}
let settingsPanelContainer = container.querySelector(".pomodoro-settings-panel-container");
if (!settingsPanelContainer) {
settingsPanelContainer = container.createDiv("pomodoro-settings-panel-container");
}
if (!this.pomodoroQuickSettingsPanelEl || this.pomodoroQuickSettingsPanelEl.parentElement !== settingsPanelContainer) {
(_e = this.pomodoroQuickSettingsPanelEl) == null ? void 0 : _e.remove();
this.pomodoroQuickSettingsPanelEl = settingsPanelContainer.createDiv("pomodoro-quick-settings-panel");
this.pomodoroQuickSettingsPanelEl.style.display = "none";
const createQuickSetting = (labelText, inputType = "number") => {
const settingDiv = this.pomodoroQuickSettingsPanelEl.createDiv("pomodoro-quick-setting");
settingDiv.createEl("label", { text: labelText });
const input = settingDiv.createEl("input", { type: inputType });
input.setAttr("min", "1");
return input;
};
this.pomodoroQuickWorkInput = createQuickSetting("Work (min):");
this.pomodoroQuickShortInput = createQuickSetting("Short Break (min):");
this.pomodoroQuickLongInput = createQuickSetting("Long Break (min):");
this.pomodoroQuickSessionsInput = createQuickSetting("Sessions/Long Break:");
const buttonsContainer = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-quick-settings-buttons" });
const calculateBtn = buttonsContainer.createEl("button", { text: "Calculate Reading Time", cls: "pomodoro-quick-calculate-btn" });
calculateBtn.addEventListener("click", async () => {
const settingsSaved = this._savePomodoroSettings();
if (settingsSaved) {
await this.calculateAndDisplayPomodoroEstimate();
}
});
this.pomodoroCalculationResultEl = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-calculation-result" });
this.pomodoroCalculationResultEl.style.display = "none";
}
this.updatePomodoroUI();
}
/**
* Calculates the estimated Pomodoro cycles for today's notes and displays it.
*/
async calculateAndDisplayPomodoroEstimate() {
if (!this.plugin || !this.pomodoroCalculationResultEl)
return;
const notesForEstimate = this.plugin.reviewController.getTodayNotes();
if (notesForEstimate.length === 0) {
const activeDate = this.plugin.reviewController.getCurrentReviewDateOverride();
const message = activeDate ? `No notes scheduled for ${new Date(activeDate).toLocaleDateString()} to calculate.` : "No notes currently due to calculate.";
this.pomodoroCalculationResultEl.setText(message);
this.pomodoroCalculationResultEl.style.display = "block";
return;
}
let totalReadingTimeInSeconds = 0;
for (const note of notesForEstimate) {
totalReadingTimeInSeconds += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
const totalReadingTimeInMinutes = totalReadingTimeInSeconds / 60;
const settings = this.plugin.settings;
const workDuration = settings.pomodoroWorkDuration;
const shortBreakDuration = settings.pomodoroShortBreakDuration;
const longBreakDuration = settings.pomodoroLongBreakDuration;
const sessionsUntilLongBreak = settings.pomodoroSessionsUntilLongBreak;
let pomodorosNeeded = 0;
let sessionsCompletedInCycle = 0;
let remainingReadingTimeMinutes = totalReadingTimeInMinutes;
let totalBreakTimeInMinutes = 0;
if (totalReadingTimeInMinutes === 0) {
this.pomodoroCalculationResultEl.setText("Estimated reading time is 0 minutes.");
this.pomodoroCalculationResultEl.style.display = "block";
return;
}
while (remainingReadingTimeMinutes > 0) {
pomodorosNeeded++;
remainingReadingTimeMinutes -= workDuration;
sessionsCompletedInCycle++;
if (remainingReadingTimeMinutes <= 0)
break;
if (sessionsCompletedInCycle >= sessionsUntilLongBreak) {
totalBreakTimeInMinutes += longBreakDuration;
sessionsCompletedInCycle = 0;
} else {
totalBreakTimeInMinutes += shortBreakDuration;
}
}
const totalTimeWithBreaksMinutes = pomodorosNeeded * workDuration + totalBreakTimeInMinutes;
const formattedTotalReadingTime = EstimationUtils.formatTime(totalReadingTimeInSeconds);
const formattedTotalTimeWithBreaks = EstimationUtils.formatTime(Math.ceil(totalTimeWithBreaksMinutes * 60));
this.pomodoroCalculationResultEl.empty();
this.pomodoroCalculationResultEl.createEl("p", { text: `Estimated reading time for ${notesForEstimate.length} note(s) in current view: ${formattedTotalReadingTime}.` });
this.pomodoroCalculationResultEl.createEl("p", { text: `Requires ~${pomodorosNeeded} Pomodoro work session(s).` });
this.pomodoroCalculationResultEl.createEl("p", { text: `Total time with breaks: ~${formattedTotalTimeWithBreaks}.` });
this.pomodoroCalculationResultEl.style.display = "block";
}
/**
* Updates the Pomodoro UI based on the current state from PomodoroService.
*/
toggleSettingsPanel() {
const panel = this.pomodoroQuickSettingsPanelEl;
if (!panel)
return;
const isCurrentlyHidden = panel.style.display === "none" || !panel.style.display;
panel.style.display = isCurrentlyHidden ? "flex" : "none";
if (isCurrentlyHidden) {
if (this.pomodoroQuickWorkInput)
this.pomodoroQuickWorkInput.value = String(this.plugin.settings.pomodoroWorkDuration);
if (this.pomodoroQuickShortInput)
this.pomodoroQuickShortInput.value = String(this.plugin.settings.pomodoroShortBreakDuration);
if (this.pomodoroQuickLongInput)
this.pomodoroQuickLongInput.value = String(this.plugin.settings.pomodoroLongBreakDuration);
if (this.pomodoroQuickSessionsInput)
this.pomodoroQuickSessionsInput.value = String(this.plugin.settings.pomodoroSessionsUntilLongBreak);
} else {
this._savePomodoroSettings();
}
}
updateTimerTextDisplay() {
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.style.opacity = this.isTimerTextVisible ? "1" : "0";
}
}
updateButtonVisibility() {
const buttonsVisibility = this.areButtonsVisible ? "" : "none";
const isRunning = this.plugin.pluginState.pomodoroIsRunning;
if (this.pomodoroStartBtn)
this.pomodoroStartBtn.style.display = isRunning ? "none" : buttonsVisibility;
if (this.pomodoroStopBtn)
this.pomodoroStopBtn.style.display = isRunning ? buttonsVisibility : "none";
if (this.pomodoroSkipBtn)
this.pomodoroSkipBtn.style.display = buttonsVisibility;
}
/**
* Updates the Pomodoro UI based on the current state from PomodoroService.
*/
updatePomodoroUI() {
var _a;
if (!this.attachedContainer || !this.pomodoroRootEl) {
return;
}
this.pomodoroRootEl.style.display = "";
const state = this.plugin.pluginState;
const service = this.plugin.pomodoroService;
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft());
this.pomodoroTimerDisplayEl.className = "pomodoro-timer-display pomodoro-timer-fade";
if (state.pomodoroCurrentMode !== "idle") {
this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`);
} else {
this.pomodoroTimerDisplayEl.addClass("mode-idle");
}
if (state.pomodoroIsRunning) {
this.pomodoroTimerDisplayEl.addClass("timer-visible");
} else {
this.pomodoroTimerDisplayEl.removeClass("timer-visible");
}
}
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft());
this.updateTimerTextDisplay();
this.pomodoroTimerDisplayEl.className = "pomodoro-timer-display pomodoro-timer-fade";
if (state.pomodoroCurrentMode !== "idle") {
this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`);
} else {
this.pomodoroTimerDisplayEl.addClass("mode-idle");
}
if (state.pomodoroIsRunning) {
this.pomodoroTimerDisplayEl.addClass("timer-visible");
} else {
this.pomodoroTimerDisplayEl.removeClass("timer-visible");
}
}
this.updateButtonVisibility();
this.pomodoroRootEl.toggleClass("is-running", state.pomodoroIsRunning);
this.pomodoroRootEl.toggleClass("is-paused", !state.pomodoroIsRunning && state.pomodoroCurrentMode !== "idle");
this.pomodoroRootEl.toggleClass("is-idle", state.pomodoroCurrentMode === "idle");
if (this.pomodoroCalculationResultEl && ((_a = this.pomodoroQuickSettingsPanelEl) == null ? void 0 : _a.style.display) === "none") {
this.pomodoroCalculationResultEl.style.display = "none";
}
}
};
// ui/sidebar/note-item-renderer.ts
var import_obsidian13 = require("obsidian");
// utils/dates.ts
var DateUtils = class {
/**
* Get start of day timestamp for a given date
*
* @param date Date to get start of day for
* @returns Timestamp for start of day
*/
static startOfDay(date = /* @__PURE__ */ new Date()) {
const newDate = new Date(date);
newDate.setHours(0, 0, 0, 0);
return newDate.getTime();
}
/**
* Add days to a timestamp
*
* @param timestamp Base timestamp
* @param days Number of days to add
* @returns New timestamp
*/
static addDays(timestamp, days) {
return timestamp + days * 24 * 60 * 60 * 1e3;
}
/**
* Format a timestamp as a readable date string
*
* @param timestamp Timestamp to format
* @param format Format type ('short', 'medium', 'long', 'relative')
* @param baseDateParam Optional base date for relative formatting
* @returns Formatted date string
*/
static formatDate(timestamp, format = "medium", baseDateParam) {
const noteEventDate = new Date(timestamp);
if (format === "relative") {
const referenceDateForCalc = baseDateParam ? new Date(baseDateParam) : /* @__PURE__ */ new Date();
const normalizedNoteEventDate = this.startOfDay(noteEventDate);
const normalizedReferenceDate = this.startOfDay(referenceDateForCalc);
const normalizedActualCurrentDate = this.startOfDay(/* @__PURE__ */ new Date());
if (normalizedNoteEventDate < normalizedActualCurrentDate) {
return "Due notes";
}
if (baseDateParam) {
const normalizedBaseDate = this.startOfDay(new Date(baseDateParam));
if (normalizedBaseDate === normalizedActualCurrentDate) {
return "Today";
} else if (normalizedBaseDate === this.startOfDay(new Date(this.addDays(normalizedActualCurrentDate, 1)))) {
return "Tomorrow";
} else {
return new Date(normalizedBaseDate).toLocaleDateString(void 0, { month: "short", day: "numeric" });
}
} else {
const diffInDays = Math.floor((normalizedNoteEventDate - normalizedActualCurrentDate) / (24 * 60 * 60 * 1e3));
if (diffInDays === 0) {
return "Today";
} else if (diffInDays === 1) {
return "Tomorrow";
} else {
return `In ${diffInDays} days`;
}
}
} else if (format === "short") {
return noteEventDate.toLocaleDateString();
} else if (format === "long") {
return noteEventDate.toLocaleDateString(void 0, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
});
} else {
return noteEventDate.toLocaleDateString(void 0, {
weekday: "short",
month: "short",
day: "numeric"
});
}
}
/**
* Get the day difference between two timestamps
*
* @param timestamp1 First timestamp
* @param timestamp2 Second timestamp
* @returns Difference in days
*/
static dayDifference(timestamp1, timestamp2) {
const date1 = new Date(timestamp1);
const date2 = new Date(timestamp2);
date1.setHours(0, 0, 0, 0);
date2.setHours(0, 0, 0, 0);
const diffTime = Math.abs(date2.getTime() - date1.getTime());
const diffDays = Math.floor(diffTime / (1e3 * 60 * 60 * 24));
return diffDays;
}
/**
* Get start of UTC day timestamp for a given date
*
* @param date Date to get start of UTC day for
* @returns Timestamp for start of UTC day (00:00:00.000Z)
*/
static startOfUTCDay(date = /* @__PURE__ */ new Date()) {
const newDate = new Date(date.getTime());
newDate.setUTCHours(0, 0, 0, 0);
return newDate.getTime();
}
/**
* Get end of UTC day timestamp for a given date
*
* @param date Date to get end of UTC day for
* @returns Timestamp for end of UTC day (23:59:59.999Z)
*/
static endOfUTCDay(date = /* @__PURE__ */ new Date()) {
const newDate = new Date(date.getTime());
newDate.setUTCHours(23, 59, 59, 999);
return newDate.getTime();
}
/**
* Get the day difference between two timestamps based on UTC days
*
* @param timestamp1 First timestamp
* @param timestamp2 Second timestamp
* @returns Difference in UTC days
*/
static dayDifferenceUTC(timestamp1, timestamp2) {
const date1UTCMidnight = this.startOfUTCDay(new Date(timestamp1));
const date2UTCMidnight = this.startOfUTCDay(new Date(timestamp2));
const diffTime = Math.abs(date2UTCMidnight - date1UTCMidnight);
const diffDays = Math.floor(diffTime / (1e3 * 60 * 60 * 24));
return diffDays;
}
/**
* Check if two dates are the same day, ignoring time.
* @param date1 The first date.
* @param date2 The second date.
* @returns True if both dates fall on the same day, false otherwise.
*/
static isSameDay(date1, date2) {
return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate();
}
};
// ui/sidebar/note-item-renderer.ts
var NoteItemRenderer = class {
constructor(plugin) {
this.plugin = plugin;
}
async _populateNoteItemDetails(noteEl, note, dateStr, selectedNotesArray) {
noteEl.dataset.notePath = note.path;
noteEl.removeClass("overdue-note");
noteEl.removeAttribute("title");
if (dateStr === "Due notes") {
noteEl.addClass("overdue-note");
const daysOverdue = Math.abs(Math.floor((note.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1e3)));
const originalDueDate = new Date(note.nextReviewDate).toLocaleDateString();
noteEl.setAttribute("title", `Originally due: ${originalDueDate} (${daysOverdue} ${daysOverdue === 1 ? "day" : "days"} overdue)`);
}
if (selectedNotesArray.includes(note.path)) {
noteEl.addClass("selected");
} else {
noteEl.removeClass("selected");
}
const titleEl = noteEl.querySelector(".review-note-title");
if (titleEl) {
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
titleEl.setText(file instanceof import_obsidian13.TFile ? file.basename : note.path);
}
const estimatedTime = await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
const formattedTime = EstimationUtils.formatTime(estimatedTime);
const phaseEl = noteEl.querySelector(".review-note-phase");
const timeElOld = noteEl.querySelector(".review-note-time");
if (timeElOld)
timeElOld.remove();
if (phaseEl) {
phaseEl.empty();
phaseEl.removeClass("review-phase-initial", "review-phase-graduated", "review-phase-spaced");
if (note.scheduleCategory === "initial") {
const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length;
const currentStepDisplay = note.reviewCount < totalInitialSteps ? note.reviewCount + 1 : totalInitialSteps;
phaseEl.createDiv({ title: "Initial", text: "Initial" });
phaseEl.createDiv({ title: `${currentStepDisplay}/${totalInitialSteps}`, text: `${currentStepDisplay}/${totalInitialSteps}` });
const phaseTimeEl = phaseEl.createDiv({ cls: "phase-time", title: formattedTime, text: formattedTime });
phaseEl.addClass("review-phase-initial");
} else {
phaseEl.setText(note.scheduleCategory === "graduated" ? "Graduated" : "Spaced");
phaseEl.addClass(note.scheduleCategory === "graduated" ? "review-phase-graduated" : "review-phase-spaced");
const timeElNew = noteEl.createDiv("review-note-time");
noteEl.insertBefore(timeElNew, phaseEl.nextSibling);
EstimationUtils.formatTimeWithColor(estimatedTime, timeElNew);
}
}
const buttonsEl = noteEl.querySelector(".review-note-buttons");
let dragHandleEl = buttonsEl == null ? void 0 : buttonsEl.querySelector(".review-note-drag-handle");
if (dragHandleEl) {
const isDraggable = dateStr === "Due notes" || dateStr === "Today";
dragHandleEl.classList.toggle("is-disabled", !isDraggable);
if (isDraggable && !dragHandleEl.hasAttribute("draggable")) {
} else if (!isDraggable) {
noteEl.removeAttribute("draggable");
}
}
const advanceBtn = noteEl.querySelector(".review-note-advance");
if (advanceBtn) {
const todayStartTs = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const noteReviewDayStartTs = DateUtils.startOfDay(new Date(note.nextReviewDate));
const isEligibleForAdvance = noteReviewDayStartTs > todayStartTs;
advanceBtn.disabled = !isEligibleForAdvance;
advanceBtn.style.display = isEligibleForAdvance ? "" : "none";
}
}
async updateNoteItem(noteEl, note, dateStr, selectedNotesArray) {
await this._populateNoteItemDetails(noteEl, note, dateStr, selectedNotesArray);
}
async renderNoteItem(notesContainer, noteToRender, dateStr, parentContainerForBulkActions, selectedNotesArray, lastSelectedNotePathRef, onSelectionChange, onNoteAction) {
if (!parentContainerForBulkActions) {
console.error("Spaceforge: parentContainerForBulkActions is null in renderNoteItem. This should not happen.");
parentContainerForBulkActions = document.body;
}
const noteEl = notesContainer.createDiv("review-note-item");
const titleEl = noteEl.createDiv("review-note-title");
titleEl.style.cursor = "pointer";
noteEl.createDiv("review-note-phase");
const buttonsEl = noteEl.createDiv("review-note-buttons");
const actionBtnsEl = buttonsEl.createDiv("review-note-actions");
const reviewBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-review" });
(0, import_obsidian13.setIcon)(reviewBtn, "play");
reviewBtn.title = "Review";
const advanceBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-advance" });
(0, import_obsidian13.setIcon)(advanceBtn, "arrow-left-circle");
advanceBtn.title = "Advance";
const postponeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-postpone" });
(0, import_obsidian13.setIcon)(postponeBtn, "arrow-right-circle");
postponeBtn.title = "Postpone";
const removeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-remove" });
(0, import_obsidian13.setIcon)(removeBtn, "trash-2");
removeBtn.title = "Remove";
const dragHandleEl = buttonsEl.createDiv("review-note-drag-handle");
dragHandleEl.setAttribute("aria-label", "Drag to reorder");
for (let i = 0; i < 3; i++) {
dragHandleEl.createDiv("drag-handle-line");
}
titleEl.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path)
this.plugin.reviewController.openNoteWithoutReview(path);
});
reviewBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
await this.plugin.reviewController.reviewNote(path);
await onNoteAction();
}
});
advanceBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (advanceBtn.disabled)
return;
const path = noteEl.dataset.notePath;
if (path) {
await this.plugin.reviewController.advanceNote(path);
await onNoteAction();
}
});
postponeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
try {
await this.plugin.reviewController.postponeNote(path);
await this.plugin.savePluginData();
new import_obsidian13.Notice(`Note postponed`);
await onNoteAction();
} catch (error) {
console.error("Error postponing note:", error);
new import_obsidian13.Notice("Failed to postpone note.");
await onNoteAction();
}
}
});
removeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof import_obsidian13.TFile ? file.basename : path}" from review schedule?`);
if (!confirmed)
return;
try {
await this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new import_obsidian13.Notice(`Note removed from review schedule`);
await onNoteAction();
} catch (error) {
console.error("Error removing note:", error);
new import_obsidian13.Notice("Failed to remove note from schedule.");
await onNoteAction();
}
}
});
dragHandleEl.addEventListener("mousedown", (e) => {
e.stopPropagation();
if (!dragHandleEl.classList.contains("is-disabled")) {
noteEl.setAttribute("draggable", "true");
}
});
noteEl.addEventListener("dragstart", (e) => {
var _a;
const path = noteEl.dataset.notePath;
if (path && noteEl.getAttribute("draggable") === "true") {
(_a = e.dataTransfer) == null ? void 0 : _a.setData("text/plain", path);
} else {
e.preventDefault();
}
});
noteEl.addEventListener("dragend", () => {
noteEl.removeAttribute("draggable");
});
noteEl.addEventListener("click", (e) => {
e.stopPropagation();
const currentPath = noteEl.dataset.notePath;
if (!currentPath)
return;
const allVisibleNoteElements = Array.from(parentContainerForBulkActions.querySelectorAll(".review-note-item[data-note-path]"));
const allVisibleNotePaths = allVisibleNoteElements.map((el) => el.dataset.notePath).filter((p2) => p2);
const currentIndex = allVisibleNotePaths.indexOf(currentPath);
if (e.shiftKey && lastSelectedNotePathRef.current && lastSelectedNotePathRef.current !== currentPath) {
const lastClickedIndexInVisible = allVisibleNotePaths.indexOf(lastSelectedNotePathRef.current);
if (lastClickedIndexInVisible !== -1 && currentIndex !== -1) {
const start = Math.min(lastClickedIndexInVisible, currentIndex);
const end = Math.max(lastClickedIndexInVisible, currentIndex);
const notesToSelectInRange = allVisibleNotePaths.slice(start, end + 1);
if (e.ctrlKey || e.metaKey) {
notesToSelectInRange.forEach((p2) => {
if (p2 && !selectedNotesArray.includes(p2))
selectedNotesArray.push(p2);
});
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(...notesToSelectInRange.filter((p2) => p2));
}
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(currentPath);
}
} else if (e.ctrlKey || e.metaKey) {
const indexInSelection = selectedNotesArray.indexOf(currentPath);
if (indexInSelection > -1) {
selectedNotesArray.splice(indexInSelection, 1);
} else {
selectedNotesArray.push(currentPath);
}
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(currentPath);
}
lastSelectedNotePathRef.current = currentPath;
onSelectionChange();
});
noteEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (!path)
return;
const menu = new import_obsidian13.Menu();
menu.addItem((item) => item.setTitle("Open note").setIcon("file-text").onClick(() => this.plugin.reviewController.openNoteWithoutReview(path)));
menu.addItem((item) => item.setTitle("Review note").setIcon("play-circle").onClick(async () => {
this.plugin.reviewController.reviewNote(path);
await onNoteAction();
}));
menu.addItem((item) => item.setTitle("Postpone by 1 day").setIcon("skip-forward").onClick(async () => {
await this.plugin.reviewController.postponeNote(path, 1);
await onNoteAction();
}));
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (schedule) {
const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const noteReviewDayStart = DateUtils.startOfDay(new Date(schedule.nextReviewDate));
if (noteReviewDayStart > todayStart) {
menu.addItem((item) => item.setTitle("Advance note").setIcon("arrow-left-circle").onClick(async () => {
await this.plugin.reviewController.advanceNote(path);
await onNoteAction();
}));
}
}
menu.addItem((item) => item.setTitle("Remove from review").setIcon("trash").onClick(async () => {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof import_obsidian13.TFile ? file.basename : path}" from review schedule?`);
if (confirmed) {
await this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new import_obsidian13.Notice("Note removed from review schedule.");
await onNoteAction();
}
}));
menu.showAtMouseEvent(e);
});
await this._populateNoteItemDetails(noteEl, noteToRender, dateStr, selectedNotesArray);
return noteEl;
}
};
// ui/sidebar/list-view-renderer.ts
var import_obsidian14 = require("obsidian");
var ListViewRenderer = class {
// Callback to trigger full refresh if needed
constructor(plugin, pomodoroUIManager, noteItemRenderer, stateAccessors) {
this.plugin = plugin;
this.pomodoroUIManager = pomodoroUIManager;
this.noteItemRenderer = noteItemRenderer;
this.getActiveListBaseDate = stateAccessors.getActiveListBaseDate;
this.getSelectedNotes = stateAccessors.getSelectedNotes;
this.setSelectedNotes = stateAccessors.setSelectedNotes;
this.getExpandedUpcomingDayKey = stateAccessors.getExpandedUpcomingDayKey;
this.setExpandedUpcomingDayKey = stateAccessors.setExpandedUpcomingDayKey;
this.getLastSelectedNotePath = stateAccessors.getLastSelectedNotePath;
this.setLastSelectedNotePath = stateAccessors.setLastSelectedNotePath;
this.refreshSidebarView = stateAccessors.refreshSidebarView;
}
/**
* Render the list view content into the provided container.
* @param container Container element for list view content
*/
async render(container) {
const activeListBaseDate = this.getActiveListBaseDate();
const selectedNotes = this.getSelectedNotes();
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
const notesForPomodoro = this.plugin.reviewController.getTodayNotes();
await this._ensureAndUpdateReviewButtonsSection(container, notesForPomodoro, selectedNotes);
this._ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate);
let notesToGroup;
let shouldIncludeFutureInGrouping = false;
if (activeListBaseDate) {
notesToGroup = Object.values(this.plugin.reviewScheduleService.schedules);
shouldIncludeFutureInGrouping = true;
} else {
notesToGroup = dueNotesForStats;
}
const groupedNotes = await this.groupNotesByDate(notesToGroup, shouldIncludeFutureInGrouping);
const sortedDateKeys = this.getSortedDateKeys(groupedNotes);
await this._ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes);
this._ensureAndUpdateActiveSessionSection(container);
if (!activeListBaseDate) {
await this._ensureAndUpdateUpcomingReviewsSection(container);
} else {
const existingUpcomingSection = container.querySelector(".review-upcoming-section");
if (existingUpcomingSection)
existingUpcomingSection.remove();
}
this.updateBulkActionButtonsVisibility(container);
}
// private async _ensureAndUpdateStatsSection(container: HTMLElement, dueNotesForStats: ReviewSchedule[]): Promise<void> {
// let statsEl = container.querySelector(".review-stats-list-view") as HTMLElement;
// if (!statsEl) {
// statsEl = container.createDiv("review-stats-list-view");
// }
// let statsCountEl = statsEl.querySelector(".review-stats-count") as HTMLElement;
// if (!statsCountEl) {
// statsCountEl = statsEl.createEl("div", { cls: "review-stats-count" });
// }
// const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfDay());
// let totalTime = 0;
// for (const note of dueNotesForStats) {
// totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
// }
// statsCountEl.setText(`${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`);
// }
async _ensureAndUpdateReviewButtonsSection(container, notesForDisplay, selectedNotes) {
let reviewButtonsContainer = container.querySelector(".review-buttons-container");
if (notesForDisplay.length > 0) {
if (!reviewButtonsContainer) {
reviewButtonsContainer = container.createDiv("review-buttons-container");
const navButtonsContainer = reviewButtonsContainer.createDiv("review-nav-buttons");
const prevNoteBtn = navButtonsContainer.createEl("button", { text: "Previous", title: "Navigate to Previous Note", cls: "review-all-button" });
prevNoteBtn.addEventListener("click", () => {
this.plugin.reviewController.navigateToPreviousNote();
});
const nextNoteBtn = navButtonsContainer.createEl("button", { text: "Next", title: "Navigate to Next Note", cls: "review-all-button" });
nextNoteBtn.addEventListener("click", () => {
this.plugin.reviewController.navigateToNextNote();
});
reviewButtonsContainer.createDiv("sidebar-pomodoro-button-container");
const reviewCurrentBtn = reviewButtonsContainer.createEl("button", { text: "Review Current Note", title: "Review the currently open note if it's due", cls: "review-all-button" });
reviewCurrentBtn.addEventListener("click", () => {
this.plugin.reviewController.reviewCurrentNote();
});
const reviewAllBtn = reviewButtonsContainer.createEl("button", { text: "Review All", title: "Start Reviewing All Due Notes", cls: "review-all-button" });
reviewAllBtn.addEventListener("click", () => {
this.plugin.reviewController.reviewAllTodaysNotes();
});
if (this.plugin.settings.enableMCQ) {
const reviewAllMCQBtn = reviewButtonsContainer.createEl("button", { text: "Review All with MCQs", cls: "review-all-mcq-button" });
reviewAllMCQBtn.addEventListener("click", () => {
this.plugin.reviewController.reviewAllNotesWithMCQ(true);
});
}
}
reviewButtonsContainer.style.display = "";
const pomodoroSectionContainerEl = reviewButtonsContainer.querySelector(".sidebar-pomodoro-button-container");
if (this.pomodoroUIManager && pomodoroSectionContainerEl) {
this.pomodoroUIManager.attachAndRender(pomodoroSectionContainerEl);
if (this.plugin.settings.pomodoroEnabled) {
this.pomodoroUIManager.showPomodoroSection(true);
this.pomodoroUIManager.updatePomodoroUI();
} else {
this.pomodoroUIManager.showPomodoroSection(false);
}
}
let bulkActionButtons = container.querySelector(".review-bulk-actions");
if (!bulkActionButtons) {
bulkActionButtons = container.createDiv("review-bulk-actions");
const reviewSelectedBtn = bulkActionButtons.createEl("button", { text: "Review Selected", cls: "review-bulk-button" });
reviewSelectedBtn.addEventListener("click", async () => {
await this.plugin.reviewController.reviewNotes(this.getSelectedNotes(), false);
this.setSelectedNotes([]);
await this.refreshSidebarView();
});
const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance Selected", cls: "review-bulk-button review-bulk-advance" });
advanceSelectedBtn.addEventListener("click", async () => {
const pathsToAdvance = [...this.getSelectedNotes()];
if (pathsToAdvance.length === 0) {
new import_obsidian14.Notice("No notes selected to advance.");
return;
}
await this.plugin.reviewController.advanceNotes(pathsToAdvance);
this.setSelectedNotes([]);
await this.refreshSidebarView();
});
const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone Selected", cls: "review-bulk-button review-bulk-postpone" });
postponeSelectedBtn.addEventListener("click", async () => {
const pathsToPostpone = [...this.getSelectedNotes()];
if (pathsToPostpone.length === 0) {
new import_obsidian14.Notice("No notes selected to postpone.");
return;
}
this.setSelectedNotes([]);
await this.plugin.reviewController.postponeNotes(pathsToPostpone);
await this.refreshSidebarView();
});
const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove Selected", cls: "review-bulk-button review-bulk-remove" });
removeSelectedBtn.addEventListener("click", async () => {
const pathsToRemove = [...this.getSelectedNotes()];
const confirmed = confirm(`Remove ${pathsToRemove.length} selected notes from review schedule?`);
if (!confirmed)
return;
this.setSelectedNotes([]);
await this.plugin.reviewController.removeNotes(pathsToRemove);
await this.plugin.savePluginData();
await this.refreshSidebarView();
new import_obsidian14.Notice(`Removed ${pathsToRemove.length} selected notes.`);
});
}
this.updateBulkActionButtonsVisibility(container);
} else if (reviewButtonsContainer) {
reviewButtonsContainer.style.display = "none";
const bulkActionButtons = container.querySelector(".review-bulk-actions");
if (bulkActionButtons)
bulkActionButtons.style.display = "none";
}
}
_ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate) {
let caughtUpEl = container.querySelector(".review-all-caught-up");
if (dueNotesForStats.length === 0 && !activeListBaseDate) {
if (!caughtUpEl) {
caughtUpEl = container.createDiv("review-all-caught-up");
const statsEl = container.querySelector(".review-stats-list-view");
const buttonsContainer = container.querySelector(".review-buttons-container");
const anchor = buttonsContainer || statsEl;
if (anchor && anchor.nextSibling) {
container.insertBefore(caughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(caughtUpEl);
} else {
container.prepend(caughtUpEl);
}
}
caughtUpEl.setText("All caught up! No notes due for review.");
caughtUpEl.style.display = "";
} else if (caughtUpEl) {
caughtUpEl.style.display = "none";
}
}
async _ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes) {
const existingSectionElements = Array.from(container.querySelectorAll(".review-date-section"));
const dataKeysInDom = new Set(existingSectionElements.map((el) => el.dataset.dateKey).filter(Boolean));
const dataKeysFromData = new Set(sortedDateKeys);
let notesDisplayed = false;
for (const sectionEl of existingSectionElements) {
if (!dataKeysFromData.has(sectionEl.dataset.dateKey)) {
sectionEl.remove();
}
}
for (const dateStr of sortedDateKeys) {
const notesForSection = groupedNotes[dateStr];
if (!notesForSection || notesForSection.length === 0)
continue;
notesDisplayed = true;
let dateSectionEl = container.querySelector(`.review-date-section[data-date-key="${dateStr}"]`);
let notesContainerEl;
if (!dateSectionEl) {
dateSectionEl = container.createDiv("review-date-section");
dateSectionEl.dataset.dateKey = dateStr;
const headerRow = dateSectionEl.createDiv("review-date-header");
const headerContainer2 = headerRow.createDiv("review-date-header-container");
headerContainer2.createEl("h3");
const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const sectionDateKeyIsFuture = !["Due notes", "Today"].includes(dateStr) && (notesForSection[0] && DateUtils.startOfDay(new Date(notesForSection[0].nextReviewDate)) > todayStart);
if (sectionDateKeyIsFuture) {
const advanceAllBtn = headerContainer2.createEl("button", { text: "Advance All", cls: "review-date-action-button review-date-advance-all" });
advanceAllBtn.title = `Advance all notes in this section by 1 day`;
advanceAllBtn.addEventListener("click", async () => {
const currentNotesForSection = groupedNotes[dateStr] || [];
if (currentNotesForSection.length === 0)
return;
const confirmed = confirm(`Advance all ${currentNotesForSection.length} notes from "${dateStr}" by 1 day? (Only future notes will be affected)`);
if (!confirmed)
return;
const paths = currentNotesForSection.map((note) => note.path);
await this.plugin.reviewController.advanceNotes(paths);
});
}
const postponeAllBtn = headerContainer2.createEl("button", { text: "Postpone All", cls: "review-date-action-button review-date-postpone-all" });
postponeAllBtn.title = `Postpone all notes in this section by 1 day`;
postponeAllBtn.addEventListener("click", async () => {
const currentNotesForSection = groupedNotes[dateStr] || [];
if (currentNotesForSection.length === 0)
return;
const daysToPostpone = 1;
const confirmed = confirm(`Postpone all ${currentNotesForSection.length} notes from "${dateStr}" by ${daysToPostpone} day(s)?`);
if (!confirmed)
return;
const paths = currentNotesForSection.map((note) => note.path);
await this.plugin.reviewController.postponeNotes(paths, daysToPostpone);
});
headerRow.createSpan("review-date-time");
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
} else {
notesContainerEl = dateSectionEl.querySelector(".review-notes-container");
if (!notesContainerEl) {
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
}
}
dateSectionEl.removeClass("review-date-section-overdue");
const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const isDefaultTodayView = !this.getActiveListBaseDate();
if (dateStr === "Due notes") {
dateSectionEl.addClass("review-date-section-overdue");
}
const headerContainer = dateSectionEl.querySelector(".review-date-header-container");
const dateHeading = headerContainer.querySelector("h3");
const reviewTimeEl = dateSectionEl.querySelector(".review-date-time");
let displayHeader = dateStr;
const noteCountText = `${notesForSection.length} ${notesForSection.length === 1 ? "note" : "notes"}`;
if (dateStr !== "Due notes" && notesForSection.length > 0) {
const actualGroupSampleDate = new Date(notesForSection[0].nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(void 0, { month: "short", day: "numeric" });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
dateHeading.setText(displayHeader);
let overdueBadge = dateHeading.querySelector(".review-overdue-badge");
const todayActualStart = DateUtils.startOfDay();
const shouldShowOverdueBadge = dateStr === "Due notes";
if (shouldShowOverdueBadge) {
const overdueNotesInThisSection = notesForSection;
if (overdueNotesInThisSection.length > 0) {
const daysDiff = overdueNotesInThisSection.map((note) => {
const referenceDateForDiff = isDefaultTodayView ? todayActualStart : DateUtils.startOfDay(this.getActiveListBaseDate());
return Math.floor((referenceDateForDiff - new Date(note.nextReviewDate).getTime()) / (24 * 60 * 60 * 1e3));
});
const maxDays = Math.max(0, ...daysDiff.filter((d) => d >= 0 && !isNaN(d)));
if (maxDays > 0) {
if (!overdueBadge) {
overdueBadge = dateHeading.createSpan("review-overdue-badge");
overdueBadge.style.fontSize = "0.8em";
overdueBadge.style.fontWeight = "normal";
overdueBadge.style.color = "var(--text-muted)";
}
overdueBadge.setText(` (${maxDays} ${maxDays === 1 ? "day" : "days"} overdue)`);
overdueBadge.style.display = "";
} else if (overdueBadge) {
overdueBadge.style.display = "none";
}
} else if (overdueBadge) {
overdueBadge.style.display = "none";
}
} else if (overdueBadge) {
overdueBadge.style.display = "none";
}
let sectionTime = 0;
for (const note of notesForSection) {
sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
reviewTimeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
await this._updateOrRenderNoteList(notesContainerEl, notesForSection, dateStr, container);
}
let noNotesForDateMsg = container.querySelector(".review-no-notes-for-date");
const activeListBaseDate = this.getActiveListBaseDate();
if (activeListBaseDate && !notesDisplayed) {
if (!noNotesForDateMsg) {
noNotesForDateMsg = container.createDiv("review-no-notes-for-date");
}
noNotesForDateMsg.setText(`No notes scheduled on or after ${activeListBaseDate.toLocaleDateString()}.`);
noNotesForDateMsg.style.display = "";
} else if (noNotesForDateMsg) {
noNotesForDateMsg.style.display = "none";
}
}
_ensureAndUpdateActiveSessionSection(container) {
const activeSession = this.plugin.reviewSessionService.getActiveSession();
let sessionSection = container.querySelector(".review-session-section");
if (activeSession) {
if (!sessionSection) {
sessionSection = container.createDiv("review-session-section");
sessionSection.createEl("h3", { text: "Active Review Session" });
const sessionInfo = sessionSection.createDiv("review-session-info");
sessionInfo.createDiv({ cls: "review-session-name" });
sessionInfo.createDiv({ cls: "review-session-progress" });
const progressBarContainer = sessionInfo.createDiv("review-session-progress-bar-container");
progressBarContainer.createDiv("review-session-progress-bar");
const continueBtn = sessionSection.createEl("button", { text: "Continue Session", cls: "review-session-continue" });
continueBtn.addEventListener("click", () => {
});
const endBtn = sessionSection.createEl("button", { text: "End Session", cls: "review-session-end" });
endBtn.addEventListener("click", () => {
this.plugin.reviewSessionService.setActiveSession(null);
this.refreshSidebarView();
});
}
sessionSection.style.display = "";
sessionSection.querySelector(".review-session-name").setText(activeSession.name);
sessionSection.querySelector(".review-session-progress").setText(`Progress: ${activeSession.currentIndex}/${activeSession.hierarchy.traversalOrder.length}`);
const progressBar = sessionSection.querySelector(".review-session-progress-bar");
const progressPercent = Math.min(100, Math.round(activeSession.currentIndex / activeSession.hierarchy.traversalOrder.length * 100));
progressBar.style.width = `${progressPercent}%`;
} else if (sessionSection) {
sessionSection.style.display = "none";
}
}
async _ensureAndUpdateUpcomingReviewsSection(container) {
const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules);
const upcomingGroupedNotes = await this.groupNotesByDate(allSchedules, true);
const upcomingKeys = this.getSortedDateKeys(upcomingGroupedNotes).filter((key) => {
if (key === "Due notes")
return false;
const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
if (key === DateUtils.formatDate(actualTodayStart, "relative", null))
return false;
if (key === DateUtils.formatDate(DateUtils.addDays(actualTodayStart, 1), "relative", null))
return false;
return true;
});
let upcomingSection = container.querySelector(".review-upcoming-section");
if (upcomingKeys.length > 0) {
if (!upcomingSection) {
upcomingSection = container.createDiv("review-upcoming-section");
upcomingSection.createEl("h3", { text: "Upcoming Reviews" });
upcomingSection.createDiv("review-upcoming-list");
}
upcomingSection.style.display = "";
const upcomingListEl = upcomingSection.querySelector(".review-upcoming-list");
if (!upcomingListEl)
return;
const existingDayItemElements = Array.from(upcomingListEl.querySelectorAll(".review-upcoming-day"));
const dayKeysInDom = new Set(existingDayItemElements.map((el) => el.dataset.dayKey).filter(Boolean));
for (const dayItemEl of existingDayItemElements) {
if (!upcomingKeys.includes(dayItemEl.dataset.dayKey)) {
dayItemEl.remove();
}
}
for (const dayKey of upcomingKeys) {
const notesForDay = upcomingGroupedNotes[dayKey];
if (!notesForDay || notesForDay.length === 0) {
const staleEmptyDayItem = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`);
if (staleEmptyDayItem)
staleEmptyDayItem.remove();
continue;
}
let dayItemEl = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`);
if (!dayItemEl) {
dayItemEl = upcomingListEl.createDiv("review-upcoming-day");
dayItemEl.addClass("clickable");
dayItemEl.dataset.dayKey = dayKey;
const daySummary = dayItemEl.createDiv("review-upcoming-day-summary");
daySummary.createEl("span", { cls: "review-upcoming-day-name" });
dayItemEl.addEventListener("click", async () => {
const currentDayKey = dayItemEl.dataset.dayKey;
if (!currentDayKey)
return;
const expandedUpcomingDayKey = this.getExpandedUpcomingDayKey();
const isCurrentlyExpanded = expandedUpcomingDayKey === currentDayKey;
this.setExpandedUpcomingDayKey(isCurrentlyExpanded ? null : currentDayKey);
await this.refreshSidebarView();
});
}
const daySummaryNameEl = dayItemEl.querySelector(".review-upcoming-day-summary .review-upcoming-day-name");
let upcomingDisplayHeader = dayKey;
if (notesForDay.length > 0) {
const sampleUpcomingDate = new Date(notesForDay[0].nextReviewDate);
const formattedUpcomingDate = DateUtils.formatDate(sampleUpcomingDate.getTime(), "medium");
if (["Today", "Tomorrow"].includes(dayKey) || dayKey.startsWith("In ")) {
upcomingDisplayHeader = `${dayKey} (${formattedUpcomingDate})`;
} else {
upcomingDisplayHeader = formattedUpcomingDate;
}
}
if (daySummaryNameEl)
daySummaryNameEl.setText(`${upcomingDisplayHeader}: ${notesForDay.length} ${notesForDay.length === 1 ? "note" : "notes"}`);
const isExpanded = this.getExpandedUpcomingDayKey() === dayKey;
dayItemEl.classList.toggle("is-expanded", isExpanded);
let notesContainerEl = dayItemEl.querySelector(".review-upcoming-notes-container");
if (isExpanded) {
if (!notesContainerEl) {
notesContainerEl = dayItemEl.createDiv("review-upcoming-notes-container");
}
notesContainerEl.style.display = "";
await this._updateOrRenderNoteList(notesContainerEl, notesForDay, dayKey, container);
} else if (notesContainerEl) {
notesContainerEl.style.display = "none";
}
}
} else if (upcomingSection) {
upcomingSection.style.display = "none";
}
}
/**
* Renders or updates a list of notes within a given container.
*/
async _updateOrRenderNoteList(notesContainer, notes, dateStr, parentContainerForBulkActions) {
const existingNoteElements = Array.from(notesContainer.querySelectorAll(".review-note-item[data-note-path]"));
const existingNotesMap = new Map(existingNoteElements.map((el) => [el.dataset.notePath, el]));
const notesInOrder = [];
const lastSelectedNotePath = this.getLastSelectedNotePath();
const lastSelectedNotePathRef = { current: lastSelectedNotePath };
for (const note of notes) {
let noteEl = existingNotesMap.get(note.path);
if (noteEl) {
await this.noteItemRenderer.updateNoteItem(
noteEl,
note,
dateStr,
this.getSelectedNotes()
);
existingNotesMap.delete(note.path);
} else {
noteEl = await this.noteItemRenderer.renderNoteItem(
notesContainer,
// Temporarily append here, will be reordered
note,
dateStr,
parentContainerForBulkActions,
this.getSelectedNotes(),
lastSelectedNotePathRef,
() => this.handleSelectionChange(parentContainerForBulkActions),
this.handleNoteAction.bind(this)
);
}
if (noteEl)
notesInOrder.push(noteEl);
}
for (const staleNoteEl of existingNotesMap.values()) {
staleNoteEl.remove();
}
notesContainer.empty();
for (const noteEl of notesInOrder) {
notesContainer.appendChild(noteEl);
}
this.setLastSelectedNotePath(lastSelectedNotePathRef.current);
}
/**
* Callback function passed to NoteItemRenderer to handle UI updates after selection changes.
*/
handleSelectionChange(container) {
this.updateSelectionClasses(container);
this.updateBulkActionButtonsVisibility(container);
}
/**
* Callback function passed to NoteItemRenderer for actions (like postpone, remove)
* that require a broader UI update (potentially a full refresh or targeted updates).
*/
async handleNoteAction() {
await this.refreshSidebarView();
}
/**
* Updates the 'selected' class on note items based on the selectedNotes array.
* (Called by handleSelectionChange)
*/
updateSelectionClasses(container) {
const selectedNotes = this.getSelectedNotes();
const allNoteElements = container.querySelectorAll(".review-note-item[data-note-path]");
allNoteElements.forEach((el) => {
const path = el.dataset.notePath;
if (path && selectedNotes.includes(path)) {
el.classList.add("selected");
} else {
el.classList.remove("selected");
}
});
}
/**
* Updates the visibility of the bulk action buttons based on selection count.
* (Called by handleSelectionChange and render)
*/
updateBulkActionButtonsVisibility(container) {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector(".review-bulk-actions");
if (bulkActionsContainer) {
bulkActionsContainer.style.display = selectedNotesPaths.length > 1 ? "flex" : "none";
const advanceSelectedBtn = bulkActionsContainer.querySelector(".review-bulk-advance");
if (advanceSelectedBtn) {
if (selectedNotesPaths.length > 1) {
const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const hasEligibleFutureNote = selectedNotesPaths.some((path) => {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return schedule && DateUtils.startOfDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.style.display = "";
} else {
advanceSelectedBtn.disabled = true;
}
}
}
}
/**
* Updates the main header statistics display.
*/
async updateHeaderStats(container) {
const headerStatsEl = container.querySelector(".review-stats-list-view .review-stats-count");
if (!headerStatsEl)
return;
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
const overdueNotes = dueNotesForStats.filter((note) => note.nextReviewDate < DateUtils.startOfDay());
let totalTime = 0;
for (const note of dueNotesForStats) {
totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
const statsText = `${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ""}`;
headerStatsEl.textContent = statsText;
const reviewButtonsContainer = container.querySelector(".review-buttons-container");
if (reviewButtonsContainer) {
reviewButtonsContainer.style.display = dueNotesForStats.length > 0 ? "" : "none";
}
this.updateBulkActionButtonsVisibility(container);
const allCaughtUpEl = container.querySelector(".review-all-caught-up");
const notesInCurrentContext = this.plugin.reviewController.getTodayNotes();
if (allCaughtUpEl) {
allCaughtUpEl.style.display = notesInCurrentContext.length === 0 ? "" : "none";
if (notesInCurrentContext.length === 0) {
allCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
}
} else if (notesInCurrentContext.length === 0) {
const buttonsContainer = container.querySelector(".review-buttons-container");
const newCaughtUpEl = container.createDiv("review-all-caught-up");
newCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
const anchor = buttonsContainer;
if (anchor && anchor.nextSibling) {
container.insertBefore(newCaughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(newCaughtUpEl);
} else {
container.prepend(newCaughtUpEl);
}
}
}
/**
* Updates the count and estimated time for a specific date section header, or removes the section if empty.
*/
async updateSectionCounts(sectionEl, container) {
if (!sectionEl || !container || !sectionEl.parentElement)
return;
const notesInSection = Array.from(sectionEl.querySelectorAll(".review-note-item[data-note-path]"));
const count = notesInSection.length;
if (count === 0) {
sectionEl.remove();
} else {
const headerTextEl = sectionEl.querySelector(".review-date-header-container h3");
const timeEl = sectionEl.querySelector(".review-date-time");
const dateStr = sectionEl.dataset.dateKey;
if (headerTextEl && timeEl && dateStr) {
let sectionTime = 0;
for (const noteEl of notesInSection) {
const path = noteEl.dataset.notePath;
if (path) {
sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(path);
}
}
timeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
let displayHeader = dateStr;
const noteCountText = `${count} ${count === 1 ? "note" : "notes"}`;
const firstNotePath = notesInSection[0].dataset.notePath;
const schedule = firstNotePath ? this.plugin.reviewScheduleService.schedules[firstNotePath] : null;
if (dateStr !== "Due notes" && schedule) {
const actualGroupSampleDate = new Date(schedule.nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(void 0, { month: "short", day: "numeric" });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
headerTextEl.textContent = displayHeader;
let overdueBadge = headerTextEl.querySelector(".review-overdue-badge");
if (dateStr === "Due notes") {
const daysDiff = await Promise.all(notesInSection.map(async (noteEl) => {
const path = noteEl.dataset.notePath;
const noteSchedule = path ? this.plugin.reviewScheduleService.schedules[path] : null;
return noteSchedule ? Math.abs(Math.floor((noteSchedule.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1e3))) : 0;
}));
const maxDays = Math.max(0, ...daysDiff.filter((d) => !isNaN(d)));
if (maxDays > 0) {
const badgeText = ` (${maxDays} ${maxDays === 1 ? "day" : "days"} overdue)`;
if (!overdueBadge) {
overdueBadge = headerTextEl.createSpan("review-overdue-badge");
overdueBadge.style.fontSize = "0.8em";
overdueBadge.style.fontWeight = "normal";
overdueBadge.style.color = "var(--text-muted)";
}
overdueBadge.setText(badgeText);
} else if (overdueBadge) {
overdueBadge.remove();
}
} else if (overdueBadge) {
overdueBadge.remove();
}
}
}
await this.updateHeaderStats(container);
}
/**
* Updates counts for all date sections currently in the DOM.
*/
async updateAllSectionCounts(container) {
const sections = container.querySelectorAll(".review-date-section");
for (const section of Array.from(sections)) {
await this.updateSectionCounts(section, container);
}
await this.updateHeaderStats(container);
}
/**
* Group notes by their review date, considering activeListBaseDate.
*/
async groupNotesByDate(notes, includeFuture = false) {
const grouped = {};
const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date());
const activeListBaseDate = this.getActiveListBaseDate();
const refDateForFilteringStart = activeListBaseDate ? DateUtils.startOfDay(new Date(activeListBaseDate)) : actualTodayStart;
for (const note of notes) {
const noteDate = new Date(note.nextReviewDate);
const noteDateStart = DateUtils.startOfDay(noteDate);
if (activeListBaseDate) {
if (noteDateStart !== refDateForFilteringStart) {
continue;
}
}
let dateStr = DateUtils.formatDate(note.nextReviewDate, "relative", activeListBaseDate);
if (!grouped[dateStr]) {
grouped[dateStr] = [];
}
grouped[dateStr].push(note);
}
return grouped;
}
/**
* Get sorted date keys in the preferred display order: Due notes, Today, Tomorrow, future dates.
*/
getSortedDateKeys(groupedNotes) {
const keys = Object.keys(groupedNotes);
const dateOrder = { "Due notes": 0, "Today": 1, "Tomorrow": 2 };
return keys.sort((a, b2) => {
const aIsSpecial = a in dateOrder;
const bIsSpecial = b2 in dateOrder;
if (aIsSpecial && bIsSpecial)
return dateOrder[a] - dateOrder[b2];
if (aIsSpecial)
return -1;
if (bIsSpecial)
return 1;
const numAMatch = a.match(/^In (\d+) days$/);
const numBMatch = b2.match(/^In (\d+) days$/);
if (numAMatch && numBMatch)
return parseInt(numAMatch[1]) - parseInt(numBMatch[1]);
if (numAMatch)
return -1;
if (numBMatch)
return 1;
return a.localeCompare(b2);
});
}
};
// ui/calendar-view.ts
var import_obsidian15 = require("obsidian");
var CalendarView = class {
/**
* Initialize calendar view
*
* @param containerEl Container element
* @param plugin Reference to the main plugin
*/
constructor(containerEl, plugin) {
/**
* Reviews grouped by date
*/
this.reviewsByDate = /* @__PURE__ */ new Map();
// Persistent UI elements
this.calendarHeaderEl = null;
this.monthTitleEl = null;
this.calendarGridEl = null;
this.containerEl = containerEl;
this.plugin = plugin;
this.currentDate = /* @__PURE__ */ new Date();
}
/**
* Render the calendar view
*/
async render() {
this.ensureCalendarBaseStructure();
this.updateCalendarHeader();
await this.loadReviewsData();
if (this.calendarGridEl) {
this.renderCalendarGridContent(this.calendarGridEl);
}
}
ensureCalendarBaseStructure() {
var _a, _b;
if (!this.containerEl)
return;
let calendarContainer = this.containerEl.querySelector(".calendar-container");
if (!calendarContainer) {
calendarContainer = this.containerEl.createDiv("calendar-container");
}
if (!this.calendarHeaderEl || !calendarContainer.contains(this.calendarHeaderEl)) {
(_a = this.calendarHeaderEl) == null ? void 0 : _a.remove();
this.calendarHeaderEl = calendarContainer.createDiv("calendar-header");
const prevMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn");
(0, import_obsidian15.setIcon)(prevMonthBtn, "chevron-left");
prevMonthBtn.addEventListener("click", () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.render();
});
this.monthTitleEl = this.calendarHeaderEl.createDiv("calendar-month-title");
const nextMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn");
(0, import_obsidian15.setIcon)(nextMonthBtn, "chevron-right");
nextMonthBtn.addEventListener("click", () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.render();
});
const todayBtn = this.calendarHeaderEl.createDiv("calendar-today-btn");
todayBtn.setText("Today");
todayBtn.addEventListener("click", () => {
this.currentDate = /* @__PURE__ */ new Date();
this.render();
});
}
if (!this.calendarGridEl || !calendarContainer.contains(this.calendarGridEl)) {
(_b = this.calendarGridEl) == null ? void 0 : _b.remove();
this.calendarGridEl = calendarContainer.createDiv("calendar-grid");
}
}
/**
* Update calendar header (month title)
*/
updateCalendarHeader() {
if (this.monthTitleEl) {
this.monthTitleEl.setText(
this.currentDate.toLocaleString("default", {
month: "long",
year: "numeric"
})
);
}
}
/**
* Load and organize review data by date
*/
async loadReviewsData() {
this.reviewsByDate = /* @__PURE__ */ new Map();
const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules);
for (const schedule of allSchedules) {
const scheduleDueDayStart = DateUtils.startOfDay(new Date(schedule.nextReviewDate));
const dateKey = scheduleDueDayStart.toString();
if (!this.reviewsByDate.has(dateKey)) {
this.reviewsByDate.set(dateKey, {
timestamp: scheduleDueDayStart,
// Store the start of day timestamp
notes: [],
totalTime: 0
});
}
const dateReviews = this.reviewsByDate.get(dateKey);
if (dateReviews) {
dateReviews.notes.push(schedule);
dateReviews.totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(schedule.path);
}
}
}
/**
* Render or update the calendar grid content
*
* @param gridEl The calendar grid element to populate
*/
renderCalendarGridContent(gridEl) {
if (!gridEl.querySelector(".calendar-weekday")) {
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
weekdays.forEach((day) => {
const dayHeader = gridEl.createDiv("calendar-weekday");
dayHeader.setText(day);
});
}
const { year, month, firstDay, daysInMonth } = this.getCalendarData();
const totalCells = 42;
let dayCells = Array.from(gridEl.querySelectorAll(".calendar-day"));
if (dayCells.length < totalCells) {
for (let i = dayCells.length; i < totalCells; i++) {
dayCells.push(gridEl.createDiv("calendar-day"));
}
} else if (dayCells.length > totalCells) {
for (let i = totalCells; i < dayCells.length; i++) {
dayCells[i].remove();
}
dayCells = dayCells.slice(0, totalCells);
}
let dayOfMonth = 1;
for (let i = 0; i < totalCells; i++) {
const dayCell = dayCells[i];
dayCell.empty();
dayCell.className = "calendar-day";
dayCell.removeAttribute("data-date-key");
dayCell.onclick = null;
if (i >= firstDay && dayOfMonth <= daysInMonth) {
const currentDateObj = new Date(year, month, dayOfMonth);
const cellDayStart = DateUtils.startOfDay(currentDateObj);
const dateKey = cellDayStart.toString();
dayCell.dataset.dateKey = dateKey;
const dayNumber = dayCell.createDiv("calendar-day-number");
dayNumber.setText(dayOfMonth.toString());
if (this.isToday(year, month, dayOfMonth)) {
dayCell.addClass("today");
}
const dateReviews = this.reviewsByDate.get(dateKey);
if (dateReviews && dateReviews.notes.length > 0) {
dayCell.addClass("has-reviews");
const reviewCount = dayCell.createDiv("calendar-review-count");
reviewCount.setText(dateReviews.notes.length.toString());
const timeEstimate = dayCell.createDiv("calendar-time-estimate");
timeEstimate.setText(EstimationUtils.formatTime(dateReviews.totalTime));
dayCell.addEventListener("click", async () => {
const today = /* @__PURE__ */ new Date();
const isClickedDateToday = DateUtils.isSameDay(currentDateObj, today);
this.plugin.settings.sidebarViewType = "list";
if (isClickedDateToday) {
this.plugin.clickedDateFromCalendar = null;
} else {
this.plugin.clickedDateFromCalendar = currentDateObj;
}
await this.plugin.savePluginData();
if (this.plugin.sidebarView && typeof this.plugin.sidebarView.refresh === "function") {
await this.plugin.sidebarView.refresh();
} else {
this.plugin.app.workspace.requestSaveLayout();
new import_obsidian15.Notice("Switched to list view. Sidebar will update.");
}
});
if (dateReviews.notes.length > 10)
dayCell.addClass("heavy-load");
else if (dateReviews.notes.length > 5)
dayCell.addClass("medium-load");
else
dayCell.addClass("light-load");
}
dayOfMonth++;
} else {
dayCell.addClass("empty");
}
}
}
/**
* Get calendar data for the current month
*
* @returns Calendar data object
*/
getCalendarData() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
return { year, month, firstDay, daysInMonth };
}
/**
* Check if a date is today
*
* @param year Year
* @param month Month
* @param day Day
* @returns True if the date is today
*/
isToday(year, month, day) {
const today = /* @__PURE__ */ new Date();
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
}
};
// ui/sidebar-view.ts
var ReviewSidebarView = class extends import_obsidian16.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.activeListBaseDate = null;
this.selectedNotes = [];
this.lastSelectedNotePath = null;
this.lastScrollPosition = 0;
this.expandedUpcomingDayKey = null;
// State for upcoming section
this.resizeObserver = null;
this.listViewRenderer = null;
// Persistent UI elements
this.mainContainer = null;
this.persistentHeaderEl = null;
this.listViewContentEl = null;
this.calendarViewContentEl = null;
this.plugin = plugin;
this.noteItemRenderer = new NoteItemRenderer(this.plugin);
}
getViewType() {
return "spaceforge-review-schedule";
}
getDisplayText() {
return "Spaceforge Review";
}
getIcon() {
return "calendar-clock";
}
async onOpen() {
this.plugin.events.on("sidebar-update", this.refresh.bind(this));
this.plugin.events.on("pomodoro-update", () => {
if (this.pomodoroUIManager) {
this.pomodoroUIManager.updatePomodoroUI();
}
});
await this.refresh();
}
async onClose() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.plugin.events.off("sidebar-update", this.refresh.bind(this));
}
ensureBaseStructure() {
const contentEl = this.containerEl;
if (!contentEl)
return;
if (!this.mainContainer) {
contentEl.empty();
this.mainContainer = contentEl.createDiv("spaceforge-container");
}
if (!this.persistentHeaderEl) {
this.persistentHeaderEl = this.mainContainer.createDiv("review-header");
this.persistentHeaderEl.createEl("h2", { text: "Review Schedule" });
const viewToggle = this.persistentHeaderEl.createDiv("review-view-toggle");
const listViewBtn = viewToggle.createDiv("review-view-btn");
listViewBtn.setText("List");
listViewBtn.addEventListener("click", async () => {
if (this.plugin.settings.sidebarViewType === "list")
return;
this.plugin.settings.sidebarViewType = "list";
this.activeListBaseDate = null;
await this.plugin.savePluginData();
await this.refresh();
});
const calendarViewBtn = viewToggle.createDiv("review-view-btn");
calendarViewBtn.setText("Calendar");
calendarViewBtn.addEventListener("click", async () => {
if (this.plugin.settings.sidebarViewType === "calendar")
return;
this.plugin.settings.sidebarViewType = "calendar";
await this.plugin.savePluginData();
await this.refresh();
});
}
this.updateViewToggleButtonsState();
if (!this.listViewContentEl) {
this.listViewContentEl = this.mainContainer.createDiv("list-view-content");
}
if (!this.pomodoroUIManager) {
this.pomodoroUIManager = new PomodoroUIManager(this.plugin);
}
if (!this.listViewRenderer) {
this.listViewRenderer = new ListViewRenderer(this.plugin, this.pomodoroUIManager, this.noteItemRenderer, {
getActiveListBaseDate: () => this.activeListBaseDate,
getSelectedNotes: () => this.selectedNotes,
setSelectedNotes: (notes) => {
this.selectedNotes = notes;
},
getExpandedUpcomingDayKey: () => this.expandedUpcomingDayKey,
setExpandedUpcomingDayKey: (key) => {
this.expandedUpcomingDayKey = key;
},
getLastSelectedNotePath: () => this.lastSelectedNotePath,
setLastSelectedNotePath: (path) => {
this.lastSelectedNotePath = path;
},
refreshSidebarView: this.refresh.bind(this)
});
}
if (!this.calendarViewContentEl) {
this.calendarViewContentEl = this.mainContainer.createDiv("calendar-view-content");
}
if (!this.calendarView && this.calendarViewContentEl) {
this.calendarView = new CalendarView(this.calendarViewContentEl, this.plugin);
}
}
updateViewToggleButtonsState() {
if (!this.persistentHeaderEl)
return;
const viewToggle = this.persistentHeaderEl.querySelector(".review-view-toggle");
if (!viewToggle)
return;
const listViewBtn = viewToggle.children[0];
const calendarViewBtn = viewToggle.children[1];
if (listViewBtn)
listViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === "list");
if (calendarViewBtn)
calendarViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === "calendar");
}
async refresh() {
const previousActiveListBaseDateEpoch = this.activeListBaseDate ? DateUtils.startOfDay(this.activeListBaseDate) : null;
let newTargetDate = this.activeListBaseDate;
if (this.plugin.clickedDateFromCalendar) {
newTargetDate = this.plugin.clickedDateFromCalendar;
this.plugin.clickedDateFromCalendar = null;
if (this.plugin.settings.sidebarViewType !== "list") {
this.plugin.settings.sidebarViewType = "list";
await this.plugin.savePluginData();
}
}
const newTargetDateEpoch = newTargetDate ? DateUtils.startOfDay(newTargetDate) : null;
let reviewDateChanged = false;
if (newTargetDateEpoch !== previousActiveListBaseDateEpoch) {
this.activeListBaseDate = newTargetDate;
reviewDateChanged = true;
}
const currentControllerOverrideEpoch = this.plugin.reviewController.getCurrentReviewDateOverride();
const targetControllerOverrideValue = this.activeListBaseDate ? DateUtils.startOfDay(this.activeListBaseDate) : null;
if (targetControllerOverrideValue !== currentControllerOverrideEpoch) {
await this.plugin.reviewController.setReviewDateOverride(targetControllerOverrideValue);
if (!reviewDateChanged) {
reviewDateChanged = true;
}
}
this.ensureBaseStructure();
let storedScrollPosition = this.lastScrollPosition;
if (this.mainContainer) {
storedScrollPosition = this.mainContainer.scrollTop;
}
this.updateViewToggleButtonsState();
await this.showCorrectViewPane();
if (this.mainContainer) {
requestAnimationFrame(() => {
if (this.mainContainer)
this.mainContainer.scrollTop = storedScrollPosition;
this.lastScrollPosition = storedScrollPosition;
});
}
}
async showCorrectViewPane() {
if (this.plugin.settings.sidebarViewType === "calendar") {
if (this.listViewContentEl)
this.listViewContentEl.hide();
if (this.calendarViewContentEl) {
this.calendarViewContentEl.show();
await this.renderCalendarViewContent(this.calendarViewContentEl);
}
} else {
if (this.calendarViewContentEl)
this.calendarViewContentEl.hide();
if (this.listViewContentEl) {
this.listViewContentEl.show();
await this.renderListViewContent(this.listViewContentEl);
}
}
}
/**
* Render the list view content by delegating to ListViewRenderer.
*/
async renderListViewContent(container) {
if (this.listViewRenderer) {
await this.listViewRenderer.render(container);
} else {
console.error("ListViewRenderer not initialized during renderListViewContent call!");
container.setText("Error: Could not render list view. Renderer not ready.");
}
}
/**
* Render the calendar view content
*/
async renderCalendarViewContent(container) {
if (this.calendarView) {
await this.calendarView.render();
} else {
console.error("CalendarView not initialized during renderCalendarViewContent call!");
container.setText("Error: Could not render calendar view. CalendarView not ready.");
return;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === this.containerEl) {
this.updateCalendarContainerClass();
}
}
});
if (this.containerEl) {
resizeObserver.observe(this.containerEl);
this.resizeObserver = resizeObserver;
this.updateCalendarContainerClass();
}
}
updateCalendarContainerClass() {
if (!this.containerEl || !this.calendarViewContentEl)
return;
const calendarContainer = this.calendarViewContentEl.querySelector(".calendar-container");
if (calendarContainer) {
const sidebarWidth = this.containerEl.clientWidth;
const collapsedThreshold = 300;
if (sidebarWidth < collapsedThreshold) {
calendarContainer.classList.add("is-collapsed");
} else {
calendarContainer.classList.remove("is-collapsed");
}
}
}
// --- Methods moved out ---
// --- Existing methods kept for view lifecycle / state ---
/**
* Move a note up in the list
* (Existing Method - Consider moving to controller)
*/
async moveNoteUp(dateStr, note) {
if (!this.listViewRenderer) {
console.error("Cannot move note up: ListViewRenderer not initialized.");
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
const dateNotes = notes[dateStr];
if (!dateNotes)
return;
const index = dateNotes.findIndex((n) => n.path === note.path);
if (index <= 0)
return;
const path1 = dateNotes[index].path;
const path2 = dateNotes[index - 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new import_obsidian16.Notice(`Moved note up`);
}
/**
* Move a note down in the list
* (Existing Method - Consider moving to controller)
*/
async moveNoteDown(dateStr, note) {
if (!this.listViewRenderer) {
console.error("Cannot move note down: ListViewRenderer not initialized.");
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
const dateNotes = notes[dateStr];
if (!dateNotes)
return;
const index = dateNotes.findIndex((n) => n.path === note.path);
if (index < 0 || index >= dateNotes.length - 1)
return;
const path1 = dateNotes[index].path;
const path2 = dateNotes[index + 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new import_obsidian16.Notice(`Moved note down`);
}
/**
* Group notes by their folder
* (Existing Method - Consider moving to utility/service)
*/
async groupNotesByFolder(notes) {
var _a;
const grouped = {};
for (const note of notes) {
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const folderPath = ((_a = file == null ? void 0 : file.parent) == null ? void 0 : _a.path) || "/";
if (!grouped[folderPath]) {
grouped[folderPath] = [];
}
grouped[folderPath].push(note);
}
return grouped;
}
// --- State Management for Obsidian View Lifecycle ---
getViewState() {
if (this.mainContainer) {
this.lastScrollPosition = this.mainContainer.scrollTop;
}
return {
activeListBaseDateISO: this.activeListBaseDate ? this.activeListBaseDate.toISOString() : null,
// isPomodoroSectionOpen: isPomodoroOpen, // Removed
expandedUpcomingDayKey: this.expandedUpcomingDayKey,
selectedNotes: this.selectedNotes,
lastScrollPosition: this.lastScrollPosition,
sidebarViewType: this.plugin.settings.sidebarViewType
};
}
async setViewState(state) {
var _a, _b, _c;
if (!state)
return;
this.activeListBaseDate = state.activeListBaseDateISO ? new Date(state.activeListBaseDateISO) : null;
this.expandedUpcomingDayKey = (_a = state.expandedUpcomingDayKey) != null ? _a : null;
this.selectedNotes = (_b = state.selectedNotes) != null ? _b : [];
this.lastScrollPosition = (_c = state.lastScrollPosition) != null ? _c : 0;
if (state.sidebarViewType) {
this.plugin.settings.sidebarViewType = state.sidebarViewType;
}
await this.refresh();
}
};
// ui/settings-tab.ts
var import_obsidian17 = require("obsidian");
// models/settings.ts
var DEFAULT_SETTINGS = {
showNavigationNotifications: true,
baseEase: 250,
// 2.5 in SM-2 format (recommended default from the original algorithm)
loadBalance: false,
// Disable load balancing by default for pure SM-2 compliance
maximumInterval: 365,
// Cap at 1 year (extension to original SM-2)
useCustomDataPath: false,
customDataPath: "",
// autoNextNote property removed, now always true by default
notifyBeforeDue: 120,
includeSubfolders: true,
readingSpeed: 100,
// Default WPM set to 100
sidebarViewType: "calendar",
useInitialSchedule: true,
initialScheduleCustomIntervals: [0, 3, 7, 14, 30],
// MCQ settings
enableMCQ: true,
mcqApiProvider: "ollama" /* Ollama */,
// Default API provider set to Ollama
openRouterApiKey: "",
openRouterModel: "openai/gpt-4.1-mini",
openaiApiKey: "",
openaiModel: "gpt-3.5-turbo",
ollamaApiUrl: "",
ollamaModel: "",
geminiApiKey: "",
geminiModel: "gemini-pro",
claudeApiKey: "",
claudeModel: "claude-3-sonnet-20240229",
togetherApiKey: "",
togetherModel: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
mcqPromptType: "detailed",
mcqQuestionsPerNote: 4,
mcqChoicesPerQuestion: 5,
mcqQuestionAmountMode: "fixed" /* Fixed */,
// Default to fixed number
mcqWordsPerQuestion: 100,
// Default words per question if mode is switched
mcqTimeDeductionAmount: 0.5,
mcqTimeDeductionSeconds: 90,
mcqDifficulty: "advanced" /* Advanced */,
mcqBasicSystemPrompt: "You are a tutor who creates clear, straightforward multiple-choice questions to test basic understanding of the given content. Focus on key concepts and important facts. Make questions simple and direct, with one clearly correct answer. Always mark the correct answer with [CORRECT] at the end of the line.",
mcqAdvancedSystemPrompt: "You are an expert tutor who creates challenging but fair multiple-choice questions to test deep understanding of the given content. Generate questions that assess comprehension, application, and analysis, not just memorization. Make incorrect choices plausible to encourage critical thinking. Always mark the correct answer with [CORRECT] at the end of the line.",
// Pomodoro Timer Defaults
pomodoroEnabled: true,
pomodoroSoundEnabled: true,
pomodoroWorkDuration: 25,
pomodoroShortBreakDuration: 5,
pomodoroLongBreakDuration: 15,
pomodoroSessionsUntilLongBreak: 4,
// MCQ Question Regeneration Settings
enableQuestionRegenerationOnRating: false,
minSm2RatingForQuestionRegeneration: 4,
// SM-2: 0 (Blackout) to 5 (Perfect Recall) - Defaulting to 4 (Correct with Hesitation)
minFsrsRatingForQuestionRegeneration: 3,
// FSRS: 1 (Again) to 4 (Easy) - Defaulting to 3 (Good)
// Algorithm Defaults
defaultSchedulingAlgorithm: "fsrs",
fsrsParameters: {
request_retention: 0.9,
maximum_interval: 36500,
enable_fuzz: true,
// Default FSRS weights from FSRS-4.5-Anki
w: [
0.4,
0.6,
2.4,
5.8,
4.93,
0.94,
0.86,
0.01,
1.49,
0.14,
0.94,
2.18,
0.05,
0.34,
1.26,
0.29,
2.61
],
learning_steps: [1, 10],
// 1 minute, 10 minutes
enable_short_term: false
// Default for enable_short_term
}
};
// models/plugin-data.ts
var DEFAULT_PLUGIN_STATE_DATA = {
schedules: {},
history: [],
reviewSessions: { sessions: {}, activeSessionId: null },
mcqSets: {},
mcqSessions: {},
customNoteOrder: [],
lastLinkAnalysisTimestamp: null,
version: "0.0.0",
// This will be updated from plugin.manifest.version on save
// Pomodoro Timer State Defaults
pomodoroCurrentMode: "idle",
pomodoroTimeLeftInSeconds: 25 * 60,
// Default to work duration
pomodoroSessionsCompletedInCycle: 0,
pomodoroIsRunning: false,
pomodoroEndTimeMs: null
};
var DEFAULT_APP_DATA = {
settings: DEFAULT_SETTINGS,
pluginState: DEFAULT_PLUGIN_STATE_DATA
};
// ui/settings-tab.ts
var SpaceforgeSettingTab = class extends import_obsidian17.PluginSettingTab {
/**
* Initialize settings tab
*
* @param app Obsidian app
* @param plugin Reference to the main plugin
*/
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
/**
* Display settings
*/
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Spaceforge Settings" });
const createCollapsible = (title, iconName, defaultOpen = true) => {
const sectionContainer = containerEl.createEl("div", { cls: "sf-settings-section" });
const header = sectionContainer.createEl("div", { cls: "sf-settings-section-header" });
if (iconName) {
const iconEl = header.createEl("span", { cls: "sf-settings-icon" });
(0, import_obsidian17.setIcon)(iconEl, iconName);
}
header.createEl("h3", { text: title });
const collapseIndicator = header.createEl("span", {
cls: "sf-settings-collapse-indicator",
text: defaultOpen ? "\u25BE" : "\u25B8"
});
const contentContainer = sectionContainer.createEl("div", {
cls: "sf-settings-section-content"
});
if (!defaultOpen) {
contentContainer.style.display = "none";
}
header.addEventListener("click", () => {
const isVisible = contentContainer.style.display !== "none";
contentContainer.style.display = isVisible ? "none" : "block";
collapseIndicator.textContent = isVisible ? "\u25B8" : "\u25BE";
});
return contentContainer;
};
const createActionButtons = () => {
const actionsContainer = containerEl.createEl("div", { cls: "sf-settings-actions" });
const exportBtn = actionsContainer.createEl("button", { text: "Export All Data" });
exportBtn.addEventListener("click", () => {
var _a;
const pluginStateToExport = {
schedules: this.plugin.reviewScheduleService.schedules,
history: this.plugin.reviewHistoryService.history,
reviewSessions: this.plugin.reviewSessionService.reviewSessions,
mcqSets: this.plugin.mcqService.mcqSets,
mcqSessions: this.plugin.mcqService.mcqSessions,
customNoteOrder: this.plugin.reviewScheduleService.customNoteOrder,
lastLinkAnalysisTimestamp: this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp,
version: this.plugin.manifest.version,
pomodoroCurrentMode: this.plugin.pluginState.pomodoroCurrentMode,
pomodoroTimeLeftInSeconds: this.plugin.pluginState.pomodoroTimeLeftInSeconds,
pomodoroSessionsCompletedInCycle: this.plugin.pluginState.pomodoroSessionsCompletedInCycle,
pomodoroIsRunning: this.plugin.pluginState.pomodoroIsRunning,
pomodoroEndTimeMs: (_a = this.plugin.pluginState.pomodoroEndTimeMs) != null ? _a : null
// Add the missing field
};
const dataToExport = {
settings: this.plugin.settings,
pluginState: pluginStateToExport
};
const dataJson = JSON.stringify(dataToExport, null, 2);
const blob = new Blob([dataJson], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "spaceforge-data.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
new import_obsidian17.Notice("All plugin data exported successfully");
});
const importBtn = actionsContainer.createEl("button", { text: "Import All Data" });
importBtn.addEventListener("click", () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async (e) => {
var _a, _b, _c;
const file = (_a = e.target.files) == null ? void 0 : _a[0];
if (file) {
try {
const text = await file.text();
const importedFullData = JSON.parse(text);
if (!importedFullData || typeof importedFullData.settings !== "object" || typeof importedFullData.pluginState !== "object") {
throw new Error("Invalid data file format. Expected settings and pluginState properties.");
}
this.plugin.settings = { ...DEFAULT_SETTINGS, ...importedFullData.settings };
this.plugin.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA, ...importedFullData.pluginState };
this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules || {};
this.plugin.reviewHistoryService.history = this.plugin.pluginState.history || [];
this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions || { sessions: {}, activeSessionId: null };
this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets || {};
this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions || {};
this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder || [];
this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.plugin.pluginState.lastLinkAnalysisTimestamp === "number" ? this.plugin.pluginState.lastLinkAnalysisTimestamp : null;
(_b = this.plugin.pomodoroService) == null ? void 0 : _b.onSettingsChanged();
(_c = this.plugin.pomodoroService) == null ? void 0 : _c.reinitializeTimerFromState();
this.plugin.initializeMCQComponents();
await this.plugin.savePluginData();
this.display();
new import_obsidian17.Notice("All plugin data imported successfully. Plugin may require a reload for all changes to take effect.");
} catch (error) {
console.error("Failed to import data:", error);
new import_obsidian17.Notice(`Failed to import data: ${error.message}`);
}
}
};
input.click();
});
const resetBtn = actionsContainer.createEl("button", { text: "Reset to Defaults" });
resetBtn.addEventListener("click", async () => {
var _a, _b, _c;
const confirmed = confirm("Are you sure you want to reset all plugin data (settings and state) to defaults? This cannot be undone.");
if (confirmed) {
this.plugin.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
this.plugin.pluginState = JSON.parse(JSON.stringify(DEFAULT_PLUGIN_STATE_DATA));
this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules;
this.plugin.reviewHistoryService.history = this.plugin.pluginState.history;
this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions;
this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets;
this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions;
this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder;
this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = (_a = this.plugin.pluginState.lastLinkAnalysisTimestamp) != null ? _a : null;
(_b = this.plugin.pomodoroService) == null ? void 0 : _b.onSettingsChanged();
(_c = this.plugin.pomodoroService) == null ? void 0 : _c.reinitializeTimerFromState();
this.plugin.initializeMCQComponents();
await this.plugin.savePluginData();
this.display();
new import_obsidian17.Notice("All plugin data reset to defaults.");
}
});
const clearScheduleBtn = actionsContainer.createEl("button", {
text: "Clear All Schedule Data",
cls: "sf-button-danger"
// Optional: Add a class for dangerous actions
});
clearScheduleBtn.addEventListener("click", async () => {
const confirmed = confirm("Are you sure you want to clear all review schedules, history, and session data? This action cannot be undone.");
if (confirmed) {
try {
this.plugin.pluginState.schedules = { ...DEFAULT_PLUGIN_STATE_DATA.schedules };
this.plugin.pluginState.history = [...DEFAULT_PLUGIN_STATE_DATA.history];
this.plugin.pluginState.reviewSessions = { ...DEFAULT_PLUGIN_STATE_DATA.reviewSessions };
this.plugin.pluginState.customNoteOrder = [...DEFAULT_PLUGIN_STATE_DATA.customNoteOrder];
this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules;
this.plugin.reviewHistoryService.history = this.plugin.pluginState.history;
this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions;
this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder;
if (this.plugin.reviewController) {
await this.plugin.reviewController.updateTodayNotes();
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
await this.plugin.savePluginData();
new import_obsidian17.Notice("All schedule data cleared successfully.");
this.display();
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
} catch (error) {
console.error("Failed to clear schedule data:", error);
new import_obsidian17.Notice("Failed to clear schedule data. Check console for details.");
}
}
});
return actionsContainer;
};
const spacedRepSection = createCollapsible("Spaced Repetition", "calendar-clock", true);
spacedRepSection.createEl("h3", { text: "Algorithm Configuration" });
const algoSelectionSetting = new import_obsidian17.Setting(spacedRepSection).setName("Default scheduling algorithm").setDesc("Choose the default algorithm for newly created notes.").addDropdown((dropdown) => dropdown.addOption("sm2", "SM-2").addOption("fsrs", "FSRS").setValue(this.plugin.settings.defaultSchedulingAlgorithm).onChange(async (value) => {
this.plugin.settings.defaultSchedulingAlgorithm = value;
await this.plugin.savePluginData();
this.display();
}));
const aboutAlgoContainer = spacedRepSection.createEl("div", { cls: "sf-info-box sf-algo-about-box" });
this.renderAboutAlgorithmSection(aboutAlgoContainer, this.plugin.settings.defaultSchedulingAlgorithm);
const sm2ParamsContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" });
const sm2Summary = sm2ParamsContainer.createEl("summary");
sm2Summary.createEl("h3", { text: "SM-2 Parameters" });
sm2ParamsContainer.open = false;
new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Base ease factor").setDesc("Initial ease factor for new SM-2 notes (2.5 is SM-2 default). Higher ease increases interval growth. Value shown is internal format (250 = 2.5).").addSlider((slider) => slider.setLimits(130, 500, 10).setValue(this.plugin.settings.baseEase).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.baseEase = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Use initial learning schedule").setDesc("For new SM-2 notes, use a fixed set of initial intervals before applying the full algorithm.").addToggle((toggle) => toggle.setValue(this.plugin.settings.useInitialSchedule).onChange(async (value) => {
this.plugin.settings.useInitialSchedule = value;
await this.plugin.savePluginData();
this.display();
}));
if (this.plugin.settings.useInitialSchedule) {
new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Custom initial intervals (days)").setDesc("Comma-separated list for initial SM-2 reviews (e.g., 0,1,3,7). Must start with 0.").addText((text) => text.setValue(this.plugin.settings.initialScheduleCustomIntervals.join(", ")).onChange(async (value) => {
const intervals = value.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n >= 0);
if (intervals.length > 0 && intervals[0] === 0) {
this.plugin.settings.initialScheduleCustomIntervals = intervals;
await this.plugin.savePluginData();
} else {
new import_obsidian17.Notice("Custom initial SM-2 intervals must start with 0 and be valid numbers.", 5e3);
text.setValue(this.plugin.settings.initialScheduleCustomIntervals.join(", "));
}
}));
}
new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Maximum interval (days)").setDesc("Longest possible interval between SM-2 reviews.").addText((text) => text.setValue(this.plugin.settings.maximumInterval.toString()).onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.maximumInterval = numValue;
await this.plugin.savePluginData();
}
}));
new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Load balancing").setDesc("Add slight randomness to SM-2 intervals to prevent reviews clumping on the same day.").addToggle((toggle) => toggle.setValue(this.plugin.settings.loadBalance).onChange(async (value) => {
this.plugin.settings.loadBalance = value;
await this.plugin.savePluginData();
}));
const fsrsParamsContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" });
const fsrsSummary = fsrsParamsContainer.createEl("summary");
fsrsSummary.createEl("h3", { text: "FSRS Parameters" });
fsrsParamsContainer.open = false;
new import_obsidian17.Setting(fsrsParamsContainer).setName("Request Retention").setDesc("Desired recall probability (0.7-0.99, default: 0.9). Higher values mean more frequent reviews.").addText((text) => {
var _a, _b, _c;
return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.request_retention) == null ? void 0 : _b.toString()) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.request_retention.toString()).onChange(async (value) => {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue >= 0.7 && numValue <= 0.99) {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, request_retention: numValue };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
} else {
new import_obsidian17.Notice("FSRS Request Retention must be between 0.7 and 0.99.");
}
});
});
new import_obsidian17.Setting(fsrsParamsContainer).setName("Maximum Interval (days)").setDesc("Longest possible interval FSRS will schedule.").addText((text) => {
var _a, _b, _c;
return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.maximum_interval) == null ? void 0 : _b.toString()) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.maximum_interval.toString()).onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, maximum_interval: numValue };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
} else {
new import_obsidian17.Notice("FSRS Maximum Interval must be a positive number.");
}
});
});
new import_obsidian17.Setting(fsrsParamsContainer).setName("Learning Steps (minutes)").setDesc("Comma-separated initial learning intervals in minutes (e.g., 1,10 for 1m, 10m).").addText((text) => {
var _a, _b, _c;
return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.learning_steps) == null ? void 0 : _b.join(",")) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.learning_steps.join(",")).onChange(async (value) => {
const steps = value.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0);
if (steps.length > 0) {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: steps };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
} else if (value.trim() === "") {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: [] };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
} else {
new import_obsidian17.Notice("FSRS Learning Steps must be valid comma-separated numbers > 0, or empty.");
}
});
});
new import_obsidian17.Setting(fsrsParamsContainer).setName("Enable Fuzz").setDesc("Add slight randomness to FSRS intervals (recommended).").addToggle((toggle) => {
var _a, _b;
return toggle.setValue((_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.enable_fuzz) != null ? _b : DEFAULT_SETTINGS.fsrsParameters.enable_fuzz).onChange(async (value) => {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_fuzz: value };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
});
});
new import_obsidian17.Setting(fsrsParamsContainer).setName("Enable Short Term Scheduling").setDesc("Use FSRS short-term memory model (affects initial learning steps).").addToggle((toggle) => {
var _a, _b;
return toggle.setValue((_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.enable_short_term) != null ? _b : DEFAULT_SETTINGS.fsrsParameters.enable_short_term).onChange(async (value) => {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_short_term: value };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
});
});
new import_obsidian17.Setting(fsrsParamsContainer).setName("Weights (W)").setDesc("FSRS algorithm parameters (17 numbers). Edit with caution. Default weights are generally good.").addTextArea((text) => {
var _a, _b, _c;
text.inputEl.rows = 3;
text.inputEl.style.width = "100%";
text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.w) == null ? void 0 : _b.join(",")) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.w.join(",")).onChange(async (value) => {
const weights = value.split(",").map((s) => parseFloat(s.trim()));
if (weights.length === 17 && weights.every((n) => !isNaN(n))) {
this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, w: weights };
await this.plugin.savePluginData();
this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
} else {
new import_obsidian17.Notice("FSRS Weights must be a comma-separated list of 17 valid numbers.");
}
});
});
const conversionContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" });
const conversionSummary = conversionContainer.createEl("summary");
conversionSummary.createEl("h3", { text: "Card Conversion Utilities" });
conversionContainer.open = false;
new import_obsidian17.Setting(conversionContainer).setName("Convert all SM-2 cards to FSRS").setDesc("Migrate all existing SM-2 cards to use the FSRS algorithm. This will reset their learning state for FSRS.").addButton((button) => button.setButtonText("Convert SM-2 to FSRS").setCta().onClick(async () => {
const confirmed = confirm("Are you sure you want to convert ALL SM-2 cards to FSRS? Their FSRS learning state will be reset. This action cannot be easily undone.");
if (confirmed) {
new import_obsidian17.Notice("Converting SM-2 cards to FSRS... This may take a moment.");
await this.plugin.reviewScheduleService.convertAllSm2ToFsrs();
await this.plugin.savePluginData();
new import_obsidian17.Notice("All SM-2 cards have been converted to FSRS.");
this.display();
}
}));
new import_obsidian17.Setting(conversionContainer).setName("Convert all FSRS cards to SM-2").setDesc("Migrate all existing FSRS cards to use the SM-2 algorithm. Their SM-2 learning state will be initialized with defaults.").addButton((button) => button.setButtonText("Convert FSRS to SM-2").setCta().onClick(async () => {
const confirmed = confirm("Are you sure you want to convert ALL FSRS cards to SM-2? Their SM-2 learning state will be reset to defaults. This action cannot be easily undone.");
if (confirmed) {
new import_obsidian17.Notice("Converting FSRS cards to SM-2... This may take a moment.");
await this.plugin.reviewScheduleService.convertAllFsrsToSm2();
await this.plugin.savePluginData();
new import_obsidian17.Notice("All FSRS cards have been converted to SM-2.");
this.display();
}
}));
const interfaceSection = createCollapsible("Interface & Behavior", "settings", false);
new import_obsidian17.Setting(interfaceSection).setName("Display Settings").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(interfaceSection).setName("Default view type").setDesc("Choose between list or calendar for the review sidebar").addDropdown((dropdown) => dropdown.addOption("list", "List view").addOption("calendar", "Calendar view").setValue(this.plugin.settings.sidebarViewType).onChange(async (value) => {
this.plugin.settings.sidebarViewType = value;
await this.plugin.savePluginData();
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
}));
new import_obsidian17.Setting(interfaceSection).setName("Show navigation notifications").setDesc("Display notifications when moving between notes").addToggle((toggle) => toggle.setValue(this.plugin.settings.showNavigationNotifications).onChange(async (value) => {
this.plugin.settings.showNavigationNotifications = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(interfaceSection).setName("Review Behavior").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(interfaceSection).setName("Include subfolders").setDesc("When adding a folder to review, include all notes in subfolders").addToggle((toggle) => toggle.setValue(this.plugin.settings.includeSubfolders).onChange(async (value) => {
this.plugin.settings.includeSubfolders = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(interfaceSection).setName("Notification time").setDesc("Minutes before due time to notify about upcoming reviews (0 to disable)").addText((text) => text.setValue(this.plugin.settings.notifyBeforeDue.toString()).onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 0) {
this.plugin.settings.notifyBeforeDue = numValue;
await this.plugin.savePluginData();
}
}));
new import_obsidian17.Setting(interfaceSection).setName("Reading speed (WPM)").setDesc("Words per minute for estimating review time").addSlider((slider) => slider.setLimits(100, 500, 10).setValue(this.plugin.settings.readingSpeed).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.readingSpeed = value;
await this.plugin.savePluginData();
}));
interfaceSection.createEl("div", {
cls: "sf-setting-explain",
text: "Average adults read 200-250 WPM for regular content, 100-150 WPM for technical content"
});
const mcqSection = createCollapsible("Multiple Choice Questions", "newspaper", false);
new import_obsidian17.Setting(mcqSection).setName("Enable MCQ feature").setDesc("Use AI-generated multiple-choice questions to test your knowledge").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableMCQ).onChange(async (value) => {
this.plugin.settings.enableMCQ = value;
await this.plugin.savePluginData();
this.plugin.initializeMCQComponents();
try {
const apiSettings = {
openRouterApiKey: this.plugin.settings.openRouterApiKey,
enableMCQ: value,
openRouterModel: this.plugin.settings.openRouterModel
};
window.localStorage.setItem("spaceforge-api-settings", JSON.stringify(apiSettings));
} catch (e) {
console.error("Error updating API settings backup:", e);
}
this.display();
}));
if (this.plugin.settings.enableMCQ) {
new import_obsidian17.Setting(mcqSection).setName("API Configuration").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(mcqSection).setName("API Provider").setDesc("Select the API provider for generating MCQs.").addDropdown((dropdown) => {
dropdown.addOption("openrouter" /* OpenRouter */, "OpenRouter").addOption("openai" /* OpenAI */, "OpenAI").addOption("ollama" /* Ollama */, "Ollama").addOption("gemini" /* Gemini */, "Gemini").addOption("claude" /* Claude */, "Claude").addOption("together" /* Together */, "Together AI").setValue(this.plugin.settings.mcqApiProvider).onChange(async (value) => {
this.plugin.settings.mcqApiProvider = value;
await this.plugin.savePluginData();
this.plugin.initializeMCQComponents();
this.display();
});
});
const provider = this.plugin.settings.mcqApiProvider;
if (provider === "openrouter" /* OpenRouter */) {
new import_obsidian17.Setting(mcqSection).setName("OpenRouter Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
const apiKeyContainer = mcqSection.createEl("div", { cls: "sf-setting-highlight" });
new import_obsidian17.Setting(apiKeyContainer).setName("OpenRouter API Key").setDesc("Required for generating MCQs via OpenRouter.").addText((text) => text.setPlaceholder("Enter your OpenRouter API key").setValue(this.plugin.settings.openRouterApiKey).onChange(async (value) => {
this.plugin.settings.openRouterApiKey = value;
await this.plugin.savePluginData();
}));
apiKeyContainer.createEl("div").setText("Get your API key at https://openrouter.ai/keys");
new import_obsidian17.Setting(mcqSection).setName("OpenRouter Model").setDesc("Model identifier from OpenRouter (e.g., openai/gpt-4.1-mini)").addText((text) => text.setPlaceholder("Enter OpenRouter model identifier").setValue(this.plugin.settings.openRouterModel).onChange(async (value) => {
this.plugin.settings.openRouterModel = value;
await this.plugin.savePluginData();
}));
} else if (provider === "openai" /* OpenAI */) {
new import_obsidian17.Setting(mcqSection).setName("OpenAI Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
new import_obsidian17.Setting(mcqSection).setName("OpenAI API Key").setDesc("Your OpenAI API key.").addText((text) => text.setPlaceholder("Enter your OpenAI API key (sk-...)").setValue(this.plugin.settings.openaiApiKey).onChange(async (value) => {
this.plugin.settings.openaiApiKey = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("OpenAI Model").setDesc("Model name (e.g., gpt-3.5-turbo, gpt-4)").addText((text) => text.setPlaceholder("Enter OpenAI model name").setValue(this.plugin.settings.openaiModel).onChange(async (value) => {
this.plugin.settings.openaiModel = value;
await this.plugin.savePluginData();
}));
} else if (provider === "ollama" /* Ollama */) {
new import_obsidian17.Setting(mcqSection).setName("Ollama Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
new import_obsidian17.Setting(mcqSection).setName("Ollama API URL").setDesc("URL of your running Ollama instance (e.g., http://localhost:11434)").addText((text) => text.setPlaceholder("http://localhost:11434").setValue(this.plugin.settings.ollamaApiUrl).onChange(async (value) => {
this.plugin.settings.ollamaApiUrl = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Ollama Model").setDesc("Name of the Ollama model to use (e.g., llama3, mistral)").addText((text) => text.setPlaceholder("Enter Ollama model name").setValue(this.plugin.settings.ollamaModel).onChange(async (value) => {
this.plugin.settings.ollamaModel = value;
await this.plugin.savePluginData();
}));
} else if (provider === "gemini" /* Gemini */) {
new import_obsidian17.Setting(mcqSection).setName("Gemini Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
new import_obsidian17.Setting(mcqSection).setName("Gemini API Key").setDesc("Your Google AI Gemini API key.").addText((text) => text.setPlaceholder("Enter your Gemini API key").setValue(this.plugin.settings.geminiApiKey).onChange(async (value) => {
this.plugin.settings.geminiApiKey = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Gemini Model").setDesc("Model name (e.g., gemini-pro)").addText((text) => text.setPlaceholder("Enter Gemini model name").setValue(this.plugin.settings.geminiModel).onChange(async (value) => {
this.plugin.settings.geminiModel = value;
await this.plugin.savePluginData();
}));
} else if (provider === "claude" /* Claude */) {
new import_obsidian17.Setting(mcqSection).setName("Claude Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
new import_obsidian17.Setting(mcqSection).setName("Claude API Key").setDesc("Your Anthropic Claude API key.").addText((text) => text.setPlaceholder("Enter your Claude API key").setValue(this.plugin.settings.claudeApiKey).onChange(async (value) => {
this.plugin.settings.claudeApiKey = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Claude Model").setDesc("Model name (e.g., claude-3-opus-20240229, claude-3-sonnet-20240229)").addText((text) => text.setPlaceholder("Enter Claude model name").setValue(this.plugin.settings.claudeModel).onChange(async (value) => {
this.plugin.settings.claudeModel = value;
await this.plugin.savePluginData();
}));
} else if (provider === "together" /* Together */) {
new import_obsidian17.Setting(mcqSection).setName("Together AI Configuration").setHeading().setClass("sf-settings-subsection-provider-header");
new import_obsidian17.Setting(mcqSection).setName("Together AI API Key").setDesc("Your Together AI API key.").addText((text) => text.setPlaceholder("Enter your Together AI API key").setValue(this.plugin.settings.togetherApiKey).onChange(async (value) => {
this.plugin.settings.togetherApiKey = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Together AI Model").setDesc("Model identifier from Together AI (e.g., meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8)").addText((text) => text.setPlaceholder("Enter Together AI model identifier").setValue(this.plugin.settings.togetherModel).onChange(async (value) => {
this.plugin.settings.togetherModel = value;
await this.plugin.savePluginData();
}));
}
new import_obsidian17.Setting(mcqSection).setName("Question Generation (Common)").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(mcqSection).setName("Question amount mode").setDesc("How to determine the number of questions per note.").addDropdown((dropdown) => dropdown.addOption("fixed" /* Fixed */, "Fixed Number").addOption("wordsPerQuestion" /* WordsPerQuestion */, "Per X Words").setValue(this.plugin.settings.mcqQuestionAmountMode).onChange(async (value) => {
this.plugin.settings.mcqQuestionAmountMode = value;
await this.plugin.savePluginData();
this.display();
}));
if (this.plugin.settings.mcqQuestionAmountMode === "fixed" /* Fixed */) {
new import_obsidian17.Setting(mcqSection).setName("Questions per note (Fixed)").setDesc("Number of questions to generate for each note.").addSlider((slider) => slider.setLimits(1, 10, 1).setValue(this.plugin.settings.mcqQuestionsPerNote).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.mcqQuestionsPerNote = value;
await this.plugin.savePluginData();
}));
} else if (this.plugin.settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
new import_obsidian17.Setting(mcqSection).setName("Words per question target").setDesc("Generate approximately 1 question for every X words in the note.").addText((text) => text.setPlaceholder("100").setValue(this.plugin.settings.mcqWordsPerQuestion.toString()).onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.mcqWordsPerQuestion = numValue;
await this.plugin.savePluginData();
} else {
new import_obsidian17.Notice("Words per question must be a positive number.");
text.setValue(this.plugin.settings.mcqWordsPerQuestion.toString());
}
}));
}
new import_obsidian17.Setting(mcqSection).setName("Choices per question").setDesc("Number of answer choices for each question").addSlider((slider) => slider.setLimits(2, 6, 1).setValue(this.plugin.settings.mcqChoicesPerQuestion).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.mcqChoicesPerQuestion = value;
await this.plugin.savePluginData();
}));
const mcqFormattingGrid = mcqSection.createEl("div", { cls: "sf-setting-grid" });
const promptTypeContainer = mcqFormattingGrid.createEl("div");
new import_obsidian17.Setting(promptTypeContainer).setName("Prompt type").setDesc("Format for MCQ generation").addDropdown((dropdown) => dropdown.addOption("basic", "Basic").addOption("detailed", "Detailed").setValue(this.plugin.settings.mcqPromptType).onChange(async (value) => {
this.plugin.settings.mcqPromptType = value;
await this.plugin.savePluginData();
}));
const difficultyContainer = mcqFormattingGrid.createEl("div");
new import_obsidian17.Setting(difficultyContainer).setName("MCQ difficulty").setDesc("Complexity level").addDropdown((dropdown) => dropdown.addOption("basic" /* Basic */, "Basic recall").addOption("advanced" /* Advanced */, "Advanced understanding").setValue(this.plugin.settings.mcqDifficulty).onChange(async (value) => {
this.plugin.settings.mcqDifficulty = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Scoring Settings").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(mcqSection).setName("Time deduction amount").setDesc("Score penalty for slow answers (0-1)").addSlider((slider) => slider.setLimits(0, 1, 0.1).setValue(this.plugin.settings.mcqTimeDeductionAmount).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.mcqTimeDeductionAmount = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Time deduction threshold").setDesc("Apply penalty after this many seconds").addSlider((slider) => slider.setLimits(10, 120, 5).setValue(this.plugin.settings.mcqTimeDeductionSeconds).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.mcqTimeDeductionSeconds = value;
await this.plugin.savePluginData();
}));
const systemPromptsContainer = mcqSection.createEl("details", { cls: "sf-system-prompts-container" });
systemPromptsContainer.createEl("summary", { text: "System Prompts (Advanced)", cls: "sf-settings-subsection" });
systemPromptsContainer.createEl("div", { text: "Basic Difficulty Prompt", cls: "sf-prompt-label" });
const basicTextarea = systemPromptsContainer.createEl("textarea", {
attr: {
placeholder: "Enter system prompt for basic difficulty",
rows: "6"
},
cls: "prompt-textarea"
});
basicTextarea.value = this.plugin.settings.mcqBasicSystemPrompt;
basicTextarea.addEventListener("change", async () => {
this.plugin.settings.mcqBasicSystemPrompt = basicTextarea.value;
await this.plugin.savePluginData();
});
systemPromptsContainer.createEl("div", { text: "Advanced Difficulty Prompt", cls: "sf-prompt-label" });
const advancedTextarea = systemPromptsContainer.createEl("textarea", {
attr: {
placeholder: "Enter system prompt for advanced difficulty",
rows: "6"
},
cls: "prompt-textarea"
});
advancedTextarea.value = this.plugin.settings.mcqAdvancedSystemPrompt;
advancedTextarea.addEventListener("change", async () => {
this.plugin.settings.mcqAdvancedSystemPrompt = advancedTextarea.value;
await this.plugin.savePluginData();
});
new import_obsidian17.Setting(mcqSection).setName("Advanced Question Behavior").setHeading().setClass("sf-settings-subsection-header");
const regenerationSetting = new import_obsidian17.Setting(mcqSection).setName("Enable question regeneration on rating").setDesc("Automatically regenerate questions for a note if its review rating meets or exceeds a specified value.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableQuestionRegenerationOnRating).onChange(async (value) => {
this.plugin.settings.enableQuestionRegenerationOnRating = value;
await this.plugin.savePluginData();
this.display();
}));
if (this.plugin.settings.enableQuestionRegenerationOnRating) {
new import_obsidian17.Setting(mcqSection).setName("Min SM-2 rating for MCQ regeneration").setDesc("For SM-2: Regenerate MCQs if review rating (0-5) is this value or higher. (0:Blackout, 5:Perfect)").addSlider((slider) => slider.setLimits(0, 5, 1).setValue(this.plugin.settings.minSm2RatingForQuestionRegeneration).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.minSm2RatingForQuestionRegeneration = value;
await this.plugin.savePluginData();
}));
new import_obsidian17.Setting(mcqSection).setName("Min FSRS rating for MCQ regeneration").setDesc("For FSRS: Regenerate MCQs if review rating (1-4) is this value or higher. (1:Again, 4:Easy)").addSlider((slider) => slider.setLimits(1, 4, 1).setValue(this.plugin.settings.minFsrsRatingForQuestionRegeneration).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.minFsrsRatingForQuestionRegeneration = value;
await this.plugin.savePluginData();
}));
}
} else {
const mcqDisabledMessage = mcqSection.createEl("div", { cls: "sf-info-box" });
mcqDisabledMessage.createEl("p", {
text: "Multiple Choice Questions are currently disabled. Enable them to generate AI-powered quizzes that test your understanding of notes."
});
}
const pomodoroSection = createCollapsible("Pomodoro Timer", "timer", false);
new import_obsidian17.Setting(pomodoroSection).setName("Enable Pomodoro Timer").setDesc("Show the Pomodoro timer in the sidebar.").addToggle((toggle) => toggle.setValue(this.plugin.settings.pomodoroEnabled).onChange(async (value) => {
var _a, _b;
this.plugin.settings.pomodoroEnabled = value;
await this.plugin.savePluginData();
(_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged();
(_b = this.plugin.sidebarView) == null ? void 0 : _b.refresh();
this.display();
}));
if (this.plugin.settings.pomodoroEnabled) {
new import_obsidian17.Setting(pomodoroSection).setName("Timer Durations (minutes)").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(pomodoroSection).setName("Work Duration").setDesc("Length of a work session.").addText((text) => text.setPlaceholder("25").setValue(this.plugin.settings.pomodoroWorkDuration.toString()).onChange(async (value) => {
var _a;
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.pomodoroWorkDuration = numValue;
await this.plugin.savePluginData();
(_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged();
}
}));
new import_obsidian17.Setting(pomodoroSection).setName("Short Break Duration").setDesc("Length of a short break.").addText((text) => text.setPlaceholder("5").setValue(this.plugin.settings.pomodoroShortBreakDuration.toString()).onChange(async (value) => {
var _a;
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.pomodoroShortBreakDuration = numValue;
await this.plugin.savePluginData();
(_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged();
}
}));
new import_obsidian17.Setting(pomodoroSection).setName("Long Break Duration").setDesc("Length of a long break.").addText((text) => text.setPlaceholder("15").setValue(this.plugin.settings.pomodoroLongBreakDuration.toString()).onChange(async (value) => {
var _a;
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.pomodoroLongBreakDuration = numValue;
await this.plugin.savePluginData();
(_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged();
}
}));
new import_obsidian17.Setting(pomodoroSection).setName("Sessions Until Long Break").setDesc("Number of work sessions before a long break starts.").addText((text) => text.setPlaceholder("4").setValue(this.plugin.settings.pomodoroSessionsUntilLongBreak.toString()).onChange(async (value) => {
var _a;
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.pomodoroSessionsUntilLongBreak = numValue;
await this.plugin.savePluginData();
(_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged();
}
}));
new import_obsidian17.Setting(pomodoroSection).setName("Notifications").setHeading().setClass("sf-settings-subsection-header");
new import_obsidian17.Setting(pomodoroSection).setName("Enable Sound Notifications").setDesc("Play a sound at the end of each work/break session.").addToggle((toggle) => toggle.setValue(this.plugin.settings.pomodoroSoundEnabled).onChange(async (value) => {
this.plugin.settings.pomodoroSoundEnabled = value;
await this.plugin.savePluginData();
}));
} else {
const pomodoroDisabledMessage = pomodoroSection.createEl("div", { cls: "sf-info-box" });
pomodoroDisabledMessage.createEl("p", {
text: "Pomodoro Timer is currently disabled. Enable it to configure durations and notifications."
});
}
containerEl.createEl("h2", { text: "Manage Plugin Data" });
createActionButtons();
}
renderAboutAlgorithmSection(container, algorithm) {
container.empty();
if (algorithm === "sm2") {
container.createEl("h4", { text: "About the Modified SM-2 Algorithm" });
container.createEl("p", {
text: "Spaceforge uses a modified version of the SuperMemo SM-2 algorithm (1991) which schedules reviews based on how well you recall information. When you rate your recall quality from 0-5, the algorithm adjusts the interval and difficulty (ease factor) accordingly."
});
container.createEl("p", {
text: "Our implementation includes specific handling for overdue or skipped items to prevent them from accumulating in a backlog:"
});
const sm2List = container.createEl("ul");
sm2List.createEl("li", { text: "Overdue items: Automatically set to review tomorrow with a quality rating of 0." });
sm2List.createEl("li", { text: "Postponed items: Explicitly moved to tomorrow with a one-step quality penalty." });
sm2List.createEl("li", { text: "Both cases reset the repetition count to 1 and update the ease factor." });
const ratingsTable = container.createEl("table", { cls: "sf-ratings-table" });
const thead = ratingsTable.createTHead();
const tbody = ratingsTable.createTBody();
const headerRow = thead.insertRow();
headerRow.createEl("th", { text: "Rating (0-5)" });
headerRow.createEl("th", { text: "Description" });
headerRow.createEl("th", { text: "Effect on Interval" });
const row1 = tbody.insertRow();
row1.createEl("td", { text: "0-2" });
row1.createEl("td", { text: "Incorrect / struggled" });
row1.createEl("td", { text: "Resets, shortest interval" });
const row2 = tbody.insertRow();
row2.createEl("td", { text: "3" });
row2.createEl("td", { text: "Correct with difficulty" });
row2.createEl("td", { text: "Small increase" });
const row3 = tbody.insertRow();
row3.createEl("td", { text: "4" });
row3.createEl("td", { text: "Correct with hesitation" });
row3.createEl("td", { text: "Moderate increase" });
const row4 = tbody.insertRow();
row4.createEl("td", { text: "5" });
row4.createEl("td", { text: "Perfect recall" });
row4.createEl("td", { text: "Largest increase" });
} else if (algorithm === "fsrs") {
container.createEl("h4", { text: "About the FSRS Algorithm" });
container.createEl("p", {
text: "FSRS (Free Spaced Repetition Scheduler) is a modern, evidence-based algorithm that models memory retention to optimize review schedules. It calculates card difficulty and stability dynamically based on your review history and aims for a target retention rate."
});
container.createEl("p", {
text: "Key concepts in FSRS:"
});
const fsrsList = container.createEl("ul");
fsrsList.createEl("li", { text: "Difficulty: How hard a card is to remember." });
fsrsList.createEl("li", { text: "Stability: How long a card is likely to be remembered." });
fsrsList.createEl("li", { text: "Retention: The probability of recalling a card at the time of review." });
fsrsList.createEl("li", { text: "Learning Steps: Initial short intervals for new cards (configurable)." });
const ratingsTable = container.createEl("table", { cls: "sf-ratings-table" });
const thead = ratingsTable.createTHead();
const tbody = ratingsTable.createTBody();
const headerRow = thead.insertRow();
headerRow.createEl("th", { text: "Rating (1-4)" });
headerRow.createEl("th", { text: "Description" });
headerRow.createEl("th", { text: "Effect on Stability/Difficulty" });
const row1 = tbody.insertRow();
row1.createEl("td", { text: "1 (Again)" });
row1.createEl("td", { text: "Forgot the card" });
row1.createEl("td", { text: "Decreases stability, may increase difficulty" });
const row2 = tbody.insertRow();
row2.createEl("td", { text: "2 (Hard)" });
row2.createEl("td", { text: "Recalled with significant difficulty" });
row2.createEl("td", { text: "Smaller increase in stability" });
const row3 = tbody.insertRow();
row3.createEl("td", { text: "3 (Good)" });
row3.createEl("td", { text: "Recalled correctly" });
row3.createEl("td", { text: "Standard increase in stability" });
const row4 = tbody.insertRow();
row4.createEl("td", { text: "4 (Easy)" });
row4.createEl("td", { text: "Recalled very easily" });
row4.createEl("td", { text: "Largest increase in stability, may decrease difficulty" });
container.createEl("p", { text: "FSRS parameters (weights, retention, etc.) can be tuned, but the defaults are generally effective." });
}
}
};
// api/openrouter-service.ts
var import_obsidian18 = require("obsidian");
var OpenRouterService = class {
/**
* Initialize OpenRouter service
*
* @param plugin Reference to the main plugin
*/
constructor(plugin) {
this.plugin = plugin;
}
/**
* Generate MCQs for a note
*
* @param notePath Path to the note
* @param noteContent Content of the note
* @param settings Current plugin settings
* @returns Generated MCQ set or null if failed
*/
async generateMCQs(notePath, noteContent, settings) {
if (!settings.openRouterApiKey) {
new import_obsidian18.Notice("OpenRouter API key is not set. Please add it in the settings.");
return null;
}
try {
new import_obsidian18.Notice("Generating MCQs using OpenRouter...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`OpenRouter: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`OpenRouter: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian18.Notice("Failed to generate valid MCQs from OpenRouter. Please try again.");
return null;
}
const mcqSet = {
notePath,
questions,
generatedAt: Date.now()
};
return mcqSet;
} catch (error) {
console.error("Error generating MCQs with OpenRouter:", error);
new import_obsidian18.Notice("Failed to generate MCQs with OpenRouter. Please check console for details.");
return null;
}
}
/**
* Generate prompt for the AI
*
* @param noteContent Content of the note
* @param settings Current plugin settings
* @param numQuestionsToGenerate The target number of questions to ask for
* @returns Prompt for the AI
*/
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === "basic") {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
if (difficulty === "basic") {
basePrompt += `
Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `
Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
/**
* Make API request to OpenRouter
*
* @param prompt Prompt for the AI
* @param settings Current plugin settings
* @returns AI response text
*/
async makeApiRequest(prompt, settings) {
const apiKey = settings.openRouterApiKey;
const model = settings.openRouterModel;
const difficulty = settings.mcqDifficulty;
try {
console.log(`Making API request to OpenRouter using model: ${model} with difficulty: ${difficulty}`);
const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
const response = await (0, import_obsidian18.requestUrl)({
url: "https://openrouter.ai/api/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"HTTP-Referer": "https://obsidian.md",
// Required by OpenRouter
"X-Title": "Spaceforge Plugin for Obsidian"
// Identifying the app
},
body: JSON.stringify({
model,
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: prompt
}
]
})
});
if (response.status !== 200) {
console.error("OpenRouter API error:", response.text);
throw new Error(`API request failed (${response.status}): ${response.text}`);
}
const data = response.json;
if (!data.choices || !data.choices.length || !data.choices[0].message) {
console.error("Invalid API response format from OpenRouter:", data);
throw new Error("Invalid API response format from OpenRouter - missing choices");
}
return data.choices[0].message.content;
} catch (error) {
console.error("Error in OpenRouter API request:", error);
new import_obsidian18.Notice(`OpenRouter API error: ${error.message}`);
throw error;
}
}
/**
* Parse the AI response to extract MCQs
*
* @param response AI response text
* @param settings Current plugin settings
* @param numQuestionsToGenerate The target number of questions expected
* @returns Array of parsed MCQ questions
*/
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from OpenRouter:", response);
let questionBlocks = [];
questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split("\n");
let currentQuestion = "";
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion) {
questionBlocks.push(currentQuestion);
}
currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n";
} else if (currentQuestion) {
currentQuestion += line + "\n";
}
}
if (currentQuestion) {
questionBlocks.push(currentQuestion);
}
}
console.log(`Found ${questionBlocks.length} question blocks from OpenRouter`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2) {
console.log("Skipping block with insufficient lines (OpenRouter):", block);
continue;
}
let questionText = lines[0].trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
console.log("Processing question (OpenRouter):", questionText);
console.log("Found choices (OpenRouter):", lines.slice(1));
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim();
choices.push(cleanedLine);
if (isCorrect) {
correctAnswerIndex = choices.length - 1;
console.log(`Found correct answer at index ${correctAnswerIndex} (OpenRouter): ${cleanedLine}`);
}
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) {
correctAnswerIndex = i;
console.log(`Found correct answer with alternative marker at index ${i} (OpenRouter): ${choices[i]}`);
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0) {
correctAnswerIndex = 0;
console.log(`No correct answer marker found, defaulting to first choice (OpenRouter): ${choices[0]}`);
}
if (questionText && choices.length >= 2) {
questions.push({
question: questionText,
choices,
correctAnswerIndex
});
} else {
console.log("Skipping invalid question (OpenRouter):", { questionText, choicesLength: choices.length });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from OpenRouter`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from OpenRouter:", error);
new import_obsidian18.Notice("Error parsing MCQ response from OpenRouter. Please try again.");
return [];
}
}
};
// api/openai-service.ts
var import_obsidian19 = require("obsidian");
var OpenAIService = class {
constructor(plugin) {
this.plugin = plugin;
}
async generateMCQs(notePath, noteContent, settings) {
if (!settings.openaiApiKey) {
new import_obsidian19.Notice("OpenAI API key is not set. Please add it in the Spaceforge settings.");
return null;
}
if (!settings.openaiModel) {
new import_obsidian19.Notice("OpenAI Model is not set. Please add it in the Spaceforge settings.");
return null;
}
try {
new import_obsidian19.Notice("Generating MCQs using OpenAI...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`OpenAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`OpenAI: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian19.Notice("Failed to generate valid MCQs from OpenAI. Please try again.");
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error("Error generating MCQs with OpenAI:", error);
new import_obsidian19.Notice("Failed to generate MCQs with OpenAI. Please check console for details.");
return null;
}
}
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === "basic") {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
if (difficulty === "basic") {
basePrompt += `
Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `
Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
async makeApiRequest(prompt, settings) {
var _a;
const apiKey = settings.openaiApiKey;
const model = settings.openaiModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to OpenAI using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await (0, import_obsidian19.requestUrl)({
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
]
})
});
if (response.status !== 200) {
const errorData = response.json || { message: response.text };
console.error("OpenAI API error:", response.status, errorData);
throw new Error(`API request failed (${response.status}): ${((_a = errorData.error) == null ? void 0 : _a.message) || errorData.message || "Unknown error"}`);
}
const data = response.json;
if (!data.choices || !data.choices.length || !data.choices[0].message || !data.choices[0].message.content) {
console.error("Invalid API response format from OpenAI:", data);
throw new Error("Invalid API response format from OpenAI - missing content");
}
return data.choices[0].message.content;
} catch (error) {
console.error("Error in OpenAI API request:", error);
new import_obsidian19.Notice(`OpenAI API error: ${error.message}`);
throw error;
}
}
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from OpenAI:", response);
let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split("\n");
let currentQuestion = "";
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion)
questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n";
} else if (currentQuestion) {
currentQuestion += line + "\n";
}
}
if (currentQuestion)
questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from OpenAI`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2)
continue;
let questionText = lines[0].trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim();
choices.push(cleanedLine);
if (isCorrect)
correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0)
correctAnswerIndex = 0;
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from OpenAI`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from OpenAI:", error);
new import_obsidian19.Notice("Error parsing MCQ response from OpenAI. Please try again.");
return [];
}
}
};
// api/ollama-service.ts
var import_obsidian20 = require("obsidian");
var OllamaService = class {
constructor(plugin) {
this.plugin = plugin;
}
async generateMCQs(notePath, noteContent, settings) {
if (!settings.ollamaApiUrl) {
new import_obsidian20.Notice("Ollama API URL is not set. Please add it in the Spaceforge settings.");
return null;
}
if (!settings.ollamaModel) {
new import_obsidian20.Notice("Ollama Model is not set. Please add it in the Spaceforge settings.");
return null;
}
try {
new import_obsidian20.Notice("Generating MCQs using Ollama...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Ollama: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Ollama: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian20.Notice("Failed to generate valid MCQs from Ollama. Please try again.");
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error("Error generating MCQs with Ollama:", error);
new import_obsidian20.Notice("Failed to generate MCQs with Ollama. Please check console for details.");
return null;
}
}
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === "basic") {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
if (difficulty === "basic") {
basePrompt += `
Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `
Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
async makeApiRequest(prompt, settings) {
const apiUrl = settings.ollamaApiUrl.endsWith("/") ? settings.ollamaApiUrl.slice(0, -1) : settings.ollamaApiUrl;
const model = settings.ollamaModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Ollama at ${apiUrl} using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await (0, import_obsidian20.requestUrl)({
url: `${apiUrl}/api/chat`,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
],
stream: false
// Ensure we get the full response, not a stream
})
});
if (response.status !== 200) {
const errorText = response.text;
console.error("Ollama API error:", response.status, errorText);
throw new Error(`API request failed (${response.status}): ${errorText}`);
}
const data = response.json;
if (!data.message || !data.message.content) {
console.error("Invalid API response format from Ollama:", data);
throw new Error("Invalid API response format from Ollama - missing message content");
}
return data.message.content;
} catch (error) {
console.error("Error in Ollama API request:", error);
new import_obsidian20.Notice(`Ollama API error: ${error.message}`);
throw error;
}
}
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from Ollama:", response);
let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split("\n");
let currentQuestion = "";
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion)
questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n";
} else if (currentQuestion) {
currentQuestion += line + "\n";
}
}
if (currentQuestion)
questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from Ollama`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2)
continue;
let questionText = lines[0].trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim();
choices.push(cleanedLine);
if (isCorrect)
correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0)
correctAnswerIndex = 0;
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Ollama`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from Ollama:", error);
new import_obsidian20.Notice("Error parsing MCQ response from Ollama. Please try again.");
return [];
}
}
};
// api/gemini-service.ts
var import_obsidian21 = require("obsidian");
var GeminiService = class {
constructor(plugin) {
this.plugin = plugin;
}
async generateMCQs(notePath, noteContent, settings) {
if (!settings.geminiApiKey) {
new import_obsidian21.Notice("Gemini API key is not set. Please add it in the Spaceforge settings.");
return null;
}
if (!settings.geminiModel) {
new import_obsidian21.Notice("Gemini Model is not set. Please add it in the Spaceforge settings.");
return null;
}
try {
new import_obsidian21.Notice("Generating MCQs using Gemini...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Gemini: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Gemini: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian21.Notice("Failed to generate valid MCQs from Gemini. Please try again.");
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error("Error generating MCQs with Gemini:", error);
new import_obsidian21.Notice("Failed to generate MCQs with Gemini. Please check console for details.");
return null;
}
}
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
const systemInstruction = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
if (promptType === "basic") {
basePrompt = `${systemInstruction}
Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `${systemInstruction}
Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
async makeApiRequest(prompt, settings) {
var _a;
const apiKey = settings.geminiApiKey;
const model = settings.geminiModel;
console.log(`Making API request to Gemini using model: ${model}`);
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
try {
const response = await (0, import_obsidian21.requestUrl)({
url: apiUrl,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
// Gemini API expects contents as an array of parts
contents: [{ parts: [{ text: prompt }] }]
// Optional: Add generationConfig if needed (e.g., temperature, maxOutputTokens)
// generationConfig: {
// temperature: 0.7,
// maxOutputTokens: 1024,
// }
})
});
if (response.status !== 200) {
const errorData = response.json;
console.error("Gemini API error:", response.status, errorData);
const errorMessage = ((_a = errorData == null ? void 0 : errorData.error) == null ? void 0 : _a.message) || (errorData == null ? void 0 : errorData.message) || "Unknown error";
throw new Error(`API request failed (${response.status}): ${errorMessage}`);
}
const data = response.json;
if (!data.candidates || !data.candidates.length || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts.length || !data.candidates[0].content.parts[0].text) {
console.error("Invalid API response format from Gemini:", data);
throw new Error("Invalid API response format from Gemini - missing content");
}
return data.candidates[0].content.parts[0].text;
} catch (error) {
console.error("Error in Gemini API request:", error);
new import_obsidian21.Notice(`Gemini API error: ${error.message}`);
throw error;
}
}
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from Gemini:", response);
let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split("\n");
let currentQuestion = "";
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion)
questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n";
} else if (currentQuestion) {
currentQuestion += line + "\n";
}
}
if (currentQuestion)
questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from Gemini`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2)
continue;
let questionText = lines[0].trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim();
choices.push(cleanedLine);
if (isCorrect)
correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0)
correctAnswerIndex = 0;
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Gemini`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from Gemini:", error);
new import_obsidian21.Notice("Error parsing MCQ response from Gemini. Please try again.");
return [];
}
}
};
// api/claude-service.ts
var import_obsidian22 = require("obsidian");
var ClaudeService = class {
constructor(plugin) {
this.plugin = plugin;
}
async generateMCQs(notePath, noteContent, settings) {
if (!settings.claudeApiKey) {
new import_obsidian22.Notice("Claude API key is not set. Please add it in the Spaceforge settings.");
return null;
}
if (!settings.claudeModel) {
new import_obsidian22.Notice("Claude Model is not set. Please add it in the Spaceforge settings.");
return null;
}
try {
new import_obsidian22.Notice("Generating MCQs using Claude...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Claude: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Claude: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian22.Notice("Failed to generate valid MCQs from Claude. Please try again.");
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error("Error generating MCQs with Claude:", error);
new import_obsidian22.Notice("Failed to generate MCQs with Claude. Please check console for details.");
return null;
}
}
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === "basic") {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
if (difficulty === "basic") {
basePrompt += `
Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `
Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
async makeApiRequest(prompt, settings) {
var _a;
const apiKey = settings.claudeApiKey;
const model = settings.claudeModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Claude using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await (0, import_obsidian22.requestUrl)({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01"
},
body: JSON.stringify({
model,
max_tokens: 2048,
// Adjust as needed
system: systemPrompt,
messages: [
{ role: "user", content: prompt }
]
})
});
if (response.status !== 200) {
const errorData = response.json || { message: response.text };
console.error("Claude API error:", response.status, errorData);
throw new Error(`API request failed (${response.status}): ${((_a = errorData.error) == null ? void 0 : _a.message) || errorData.message || "Unknown error"}`);
}
const data = response.json;
if (!data.content || !data.content.length || !data.content[0].text) {
console.error("Invalid API response format from Claude:", data);
throw new Error("Invalid API response format from Claude - missing content");
}
return data.content[0].text;
} catch (error) {
console.error("Error in Claude API request:", error);
new import_obsidian22.Notice(`Claude API error: ${error.message}`);
throw error;
}
}
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from Claude:", response);
let questionBlocks = response.split(/\n\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) {
if (!/^\d+\.\s+/.test(questionBlocks[0])) {
if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) {
questionBlocks = response.split(/\n(?=\d+\.\s)/);
if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) {
const potentialBlocks = response.split(/\n\n+/);
if (potentialBlocks.some((pb) => /^\d+\.\s/.test(pb.trimStart()))) {
questionBlocks = potentialBlocks;
} else {
}
}
}
}
}
if (questionBlocks.length === 0 || questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1.")) {
const lines = response.split("\n");
let currentQuestionBlock = "";
const tempBlocks = [];
for (const line of lines) {
if (/^\d+\.\s+/.test(line.trim())) {
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
currentQuestionBlock = line + "\n";
} else if (currentQuestionBlock.length > 0) {
currentQuestionBlock += line + "\n";
} else if (tempBlocks.length === 0 && line.trim().length > 0) {
currentQuestionBlock = line + "\n";
}
}
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
if (tempBlocks.length > 0)
questionBlocks = tempBlocks;
}
console.log(`Found ${questionBlocks.length} question blocks from Claude`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2)
continue;
let questionText = lines[0].replace(/^\d+\.\s*/, "").trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/gi, "").replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, "").trim();
if (cleanedLine.length > 0) {
choices.push(cleanedLine);
if (isCorrect)
correctAnswerIndex = choices.length - 1;
}
}
if (correctAnswerIndex === -1 && choices.length > 0) {
for (let i = 0; i < choices.length; i++) {
if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) {
choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim();
correctAnswerIndex = i;
break;
}
}
if (correctAnswerIndex === -1)
correctAnswerIndex = 0;
}
if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) {
questions.push({ question: questionText, choices, correctAnswerIndex });
} else if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Claude`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from Claude:", error);
new import_obsidian22.Notice("Error parsing MCQ response from Claude. Please try again.");
return [];
}
}
};
// api/together-service.ts
var import_obsidian23 = require("obsidian");
var TogetherService = class {
constructor(plugin) {
this.plugin = plugin;
}
async generateMCQs(notePath, noteContent, settings) {
if (!settings.togetherApiKey) {
new import_obsidian23.Notice("Together AI API key is not set. Please add it in the Spaceforge settings.");
return null;
}
if (!settings.togetherModel) {
new import_obsidian23.Notice("Together AI Model is not set. Please add it in the Spaceforge settings.");
return null;
}
try {
new import_obsidian23.Notice("Generating MCQs using Together AI...");
let numQuestionsToGenerate;
if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`TogetherAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else {
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`TogetherAI: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new import_obsidian23.Notice("Failed to generate valid MCQs from Together AI. Please try again.");
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error("Error generating MCQs with Together AI:", error);
new import_obsidian23.Notice("Failed to generate MCQs with Together AI. Please check console for details.");
return null;
}
}
generatePrompt(noteContent, settings, numQuestionsToGenerate) {
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === "basic") {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
if (difficulty === "basic") {
basePrompt += `
Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `
Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
return `${basePrompt}
Note Content:
${noteContent}`;
}
async makeApiRequest(prompt, settings) {
var _a;
const apiKey = settings.togetherApiKey;
const model = settings.togetherModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Together AI using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await (0, import_obsidian23.requestUrl)({
url: "https://api.together.xyz/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
max_tokens: 2048,
// Adjust as needed
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
]
})
});
if (response.status !== 200) {
const errorData = response.json || { message: response.text };
console.error("Together AI API error:", response.status, errorData);
throw new Error(`API request failed (${response.status}): ${((_a = errorData.error) == null ? void 0 : _a.message) || errorData.message || "Unknown error"}`);
}
const data = response.json;
if (!data.choices || !data.choices.length || !data.choices[0].message || !data.choices[0].message.content) {
console.error("Invalid API response format from Together AI:", data);
throw new Error("Invalid API response format from Together AI - missing content");
}
return data.choices[0].message.content;
} catch (error) {
console.error("Error in Together AI API request:", error);
new import_obsidian23.Notice(`Together AI API error: ${error.message}`);
throw error;
}
}
parseResponse(response, settings, numQuestionsToGenerate) {
const questions = [];
try {
console.log("Raw AI response from Together AI:", response);
let questionBlocks = response.split(/\n\d+\.\s+/).filter((block) => block.trim().length > 0);
if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) {
if (!/^\d+\.\s+/.test(questionBlocks[0])) {
if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) {
questionBlocks = response.split(/\n(?=\d+\.\s)/);
if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) {
const potentialBlocks = response.split(/\n\n+/);
if (potentialBlocks.some((pb) => /^\d+\.\s/.test(pb.trimStart()))) {
questionBlocks = potentialBlocks;
}
}
}
}
}
if (questionBlocks.length === 0 || questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1.")) {
const lines = response.split("\n");
let currentQuestionBlock = "";
const tempBlocks = [];
for (const line of lines) {
if (/^\d+\.\s+/.test(line.trim())) {
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
currentQuestionBlock = line + "\n";
} else if (currentQuestionBlock.length > 0) {
currentQuestionBlock += line + "\n";
} else if (tempBlocks.length === 0 && line.trim().length > 0) {
currentQuestionBlock = line + "\n";
}
}
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
if (tempBlocks.length > 0)
questionBlocks = tempBlocks;
}
console.log(`Found ${questionBlocks.length} question blocks from Together AI`);
for (const block of questionBlocks) {
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2)
continue;
let questionText = lines[0].replace(/^\d+\.\s*/, "").trim();
questionText = questionText.replace(/<think>/g, "").replace(/<\/think>/g, "");
const choices = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes("[CORRECT]");
const cleanedLine = line.replace(/\[CORRECT\]/gi, "").replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, "").trim();
if (cleanedLine.length > 0) {
choices.push(cleanedLine);
if (isCorrect)
correctAnswerIndex = choices.length - 1;
}
}
if (correctAnswerIndex === -1 && choices.length > 0) {
for (let i = 0; i < choices.length; i++) {
if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) {
choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim();
correctAnswerIndex = i;
break;
}
}
if (correctAnswerIndex === -1)
correctAnswerIndex = 0;
}
if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) {
questions.push({ question: questionText, choices, correctAnswerIndex });
} else if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Together AI`);
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error("Error parsing MCQ response from Together AI:", error);
new import_obsidian23.Notice("Error parsing MCQ response from Together AI. Please try again.");
return [];
}
}
};
// services/review-schedule-service.ts
var import_obsidian24 = require("obsidian");
// services/fsrs-schedule-service.ts
var FsrsScheduleService = class {
constructor(settings) {
this.pluginSettings = settings;
this.fsrsInstance = new W(this.mapSettingsToFsrsParams(settings.fsrsParameters));
}
mapSettingsToFsrsParams(params) {
var _a, _b, _c, _d;
const defaultWeights = [0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61];
const mappedParams = {
request_retention: (_a = params.request_retention) != null ? _a : 0.9,
maximum_interval: (_b = params.maximum_interval) != null ? _b : 36500,
w: params.w && params.w.length > 0 ? params.w : defaultWeights,
enable_fuzz: (_c = params.enable_fuzz) != null ? _c : true,
learning_steps: params.learning_steps && params.learning_steps.length > 0 ? params.learning_steps : [1, 10],
enable_short_term: (_d = params.enable_short_term) != null ? _d : false
// Added enable_short_term
};
return mappedParams;
}
updateFSRSInstance(settings) {
this.pluginSettings = settings;
this.fsrsInstance = new W(this.mapSettingsToFsrsParams(settings.fsrsParameters));
}
createNewFsrsCardData(creationDate = /* @__PURE__ */ new Date()) {
const emptyCard = v(creationDate);
return {
stability: emptyCard.stability,
difficulty: emptyCard.difficulty,
elapsed_days: emptyCard.elapsed_days,
scheduled_days: emptyCard.scheduled_days,
reps: emptyCard.reps,
lapses: emptyCard.lapses,
state: emptyCard.state,
// Cast to number; FsrsState is an enum
last_review: emptyCard.last_review ? emptyCard.last_review.getTime() : void 0
};
}
mapReviewScheduleFsrsDataToFsrsLibCard(fsrsData, now) {
return {
...fsrsData,
due: now,
// This will be the review date for the repeat() call
state: fsrsData.state,
last_review: fsrsData.last_review ? new Date(fsrsData.last_review) : void 0
};
}
mapFsrsLibRatingToTsFsrsRating(rating) {
switch (rating) {
case 1 /* Again */:
return l.Again;
case 2 /* Hard */:
return l.Hard;
case 3 /* Good */:
return l.Good;
case 4 /* Easy */:
return l.Easy;
default:
throw new Error(`Unknown FsrsRating: ${rating}`);
}
}
recordReview(currentFsrsData, rating, reviewDateTime) {
const fsrsLibCardToReview = this.mapReviewScheduleFsrsDataToFsrsLibCard(currentFsrsData, reviewDateTime);
const tsFsrsRating = this.mapFsrsLibRatingToTsFsrsRating(rating);
const schedulingResult = this.fsrsInstance.repeat(fsrsLibCardToReview, reviewDateTime);
const validRatingKey = tsFsrsRating;
const resultCard = schedulingResult[validRatingKey].card;
const resultLog = schedulingResult[validRatingKey].log;
const updatedData = {
stability: resultCard.stability,
difficulty: resultCard.difficulty,
elapsed_days: resultCard.elapsed_days,
scheduled_days: resultCard.scheduled_days,
reps: resultCard.reps,
lapses: resultCard.lapses,
state: resultCard.state,
last_review: resultCard.last_review ? resultCard.last_review.getTime() : void 0
};
return {
updatedData,
nextReviewDate: resultCard.due.getTime(),
log: resultLog
};
}
skipReview(currentFsrsData, reviewDateTime) {
return this.recordReview(currentFsrsData, 1 /* Again */, reviewDateTime);
}
};
// services/review-schedule-service.ts
var ReviewScheduleService = class {
/**
* 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, schedules, customNoteOrder, lastLinkAnalysisTimestamp, history) {
// Added FsrsScheduleService instance
/**
* Note schedules indexed by path
*/
this.schedules = {};
/**
* Custom order for notes (user-defined ordering)
*/
this.customNoteOrder = [];
/**
* Timestamp of the last time link analysis was performed for ordering
*/
this.lastLinkAnalysisTimestamp = null;
this.plugin = plugin;
this.schedules = schedules;
this.customNoteOrder = customNoteOrder;
this.lastLinkAnalysisTimestamp = lastLinkAnalysisTimestamp;
this.history = history;
this.fsrsService = new FsrsScheduleService(this.plugin.settings);
}
updateAlgorithmServicesForSettingsChange() {
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)
*/
async scheduleNoteForReview(path, daysFromNow = 0) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof import_obsidian24.TFile) || file.extension !== "md") {
new import_obsidian24.Notice("Only markdown files can be added to the review schedule");
return;
}
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm;
let newSchedule;
if (defaultAlgorithm === "fsrs") {
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,
// 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: void 0
// Not applicable to FSRS
};
} else {
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: void 0
};
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;
if (!this.customNoteOrder.includes(path)) {
this.customNoteOrder.push(path);
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
new import_obsidian24.Notice(`Note added to review schedule`);
}
/**
* 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
*/
isNoteDue(schedule, effectiveReviewDate) {
const reviewDateObj = new Date(effectiveReviewDate);
const effectiveUTCDayEnd = DateUtils.endOfUTCDay(reviewDateObj);
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
*/
async recordReview(path, response, isSkipped = false, currentReviewDate) {
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
const schedule = this.schedules[path];
if (!schedule)
return false;
const effectiveReviewDate = currentReviewDate || Date.now();
const isDue = this.isNoteDue(schedule, effectiveReviewDate);
if (!isDue) {
return false;
}
const reviewDateObj = new Date(effectiveReviewDate);
const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj);
let historyResponseValue;
if (Object.values(FsrsRating).includes(response) && typeof response === "number") {
switch (response) {
case 1 /* Again */:
historyResponseValue = 1 /* IncorrectResponse */;
break;
case 2 /* Hard */:
historyResponseValue = 2 /* IncorrectButFamiliar */;
break;
case 3 /* Good */:
historyResponseValue = 3 /* CorrectWithDifficulty */;
break;
case 4 /* Easy */:
historyResponseValue = 4 /* CorrectWithHesitation */;
break;
default:
historyResponseValue = 3 /* CorrectWithDifficulty */;
}
} else {
historyResponseValue = toSM2Quality(response);
}
if (!isSkipped) {
schedule.reviewCount = (schedule.reviewCount || 0) + 1;
}
schedule.lastReviewDate = effectiveUTCDayStart;
this.history.push({
path,
timestamp: effectiveReviewDate,
response: historyResponseValue,
interval: (_c = (_b = schedule.interval) != null ? _b : (_a = schedule.fsrsData) == null ? void 0 : _a.scheduled_days) != null ? _c : 0,
ease: (_e = schedule.ease) != null ? _e : ((_d = schedule.fsrsData) == null ? void 0 : _d.difficulty) ? Math.round(schedule.fsrsData.difficulty * 10) : this.plugin.settings.baseEase,
isSkipped
});
if (this.history.length > 1e3)
this.history.splice(0, this.history.length - 1e3);
if (schedule.schedulingAlgorithm === "fsrs") {
if (!schedule.fsrsData) {
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
let actualFsrsRating;
if (response === 5 /* Perfect */) {
actualFsrsRating = 4 /* Easy */;
} else if (response === 4 /* Good */) {
actualFsrsRating = 3 /* Good */;
} else if (response === 3 /* Fair */) {
actualFsrsRating = 2 /* Hard */;
} else if (response === 1 /* Hard */) {
actualFsrsRating = 1 /* Again */;
} else if (Object.values(FsrsRating).includes(response)) {
actualFsrsRating = response;
} else {
const quality = toSM2Quality(response);
if (quality >= 4)
actualFsrsRating = 4 /* Easy */;
else if (quality === 3)
actualFsrsRating = 3 /* Good */;
else if (quality === 2)
actualFsrsRating = 2 /* Hard */;
else
actualFsrsRating = 1 /* Again */;
}
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;
schedule.interval = updatedData.scheduled_days;
schedule.ease = Math.round(updatedData.difficulty * 10);
} else {
let qualityRating = toSM2Quality(response);
schedule.ease = (_f = schedule.ease) != null ? _f : this.plugin.settings.baseEase;
schedule.interval = (_g = schedule.interval) != null ? _g : 0;
schedule.repetitionCount = (_h = schedule.repetitionCount) != null ? _h : 0;
schedule.consecutive = (_i = schedule.consecutive) != null ? _i : 0;
schedule.scheduleCategory = (_j = schedule.scheduleCategory) != null ? _j : this.plugin.settings.useInitialSchedule ? "initial" : "spaced";
if (schedule.scheduleCategory === "initial") {
const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals || [];
if (schedule.repetitionCount < initialIntervals.length) {
schedule.interval = initialIntervals[schedule.repetitionCount];
} else {
schedule.scheduleCategory = "graduated";
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;
}
const q2 = qualityRating;
let newEase = schedule.ease / 100;
newEase = newEase + (0.1 - (5 - q2) * (0.08 + (5 - q2) * 0.02));
newEase = Math.max(1.3, newEase);
schedule.ease = Math.round(newEase * 100);
if (qualityRating >= 3 /* CorrectWithDifficulty */) {
schedule.consecutive += 1;
if (qualityRating >= 3) {
schedule.repetitionCount = (schedule.repetitionCount || 0) + 1;
} else {
schedule.repetitionCount = 0;
}
} else {
schedule.consecutive = 0;
schedule.repetitionCount = 0;
}
} else {
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 >= 3 /* CorrectWithDifficulty */) {
schedule.consecutive += 1;
} else {
schedule.consecutive = 0;
}
}
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, schedule.interval);
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
return true;
}
/**
* 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, currentEase, response, repetitionCount = 0, daysLate = 0, isSkipped = false) {
let qualityRating = toSM2Quality(response);
if (isSkipped || daysLate > 0) {
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
let ease2 = currentEase / 100;
ease2 = ease2 + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
ease2 = Math.max(1.3, ease2);
return {
interval: 1,
// Force next review to be tomorrow
ease: Math.round(ease2 * 100),
// Convert back to internal format
repetitionCount: 1
// Reset repetition count to 1
};
}
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval;
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
ease = Math.max(1.3, ease);
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1;
} else {
newRepetitionCount += 1;
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
interval = Math.round(currentInterval * ease);
}
}
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;
}
interval = Math.max(1, interval);
interval = Math.min(interval, this.plugin.settings.maximumInterval);
return {
interval: Math.round(interval),
// SM-2 uses whole days
ease: Math.round(ease * 100),
repetitionCount: newRepetitionCount
};
}
/**
* 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
*/
calculateSM2Schedule(currentInterval, currentEase, qualityRating, repetitionCount = 0, daysLate = 0, isSkipped = false) {
if (isSkipped || daysLate > 0) {
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
let ease2 = currentEase / 100;
ease2 = ease2 + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
ease2 = Math.max(1.3, ease2);
const result = {
interval: 1,
// Force next review to be tomorrow
ease: Math.round(ease2 * 100),
// Convert back to internal format
repetitionCount: 1
// Reset repetition count to 1
};
return result;
}
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval;
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
ease = Math.max(1.3, ease);
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1;
} else {
newRepetitionCount += 1;
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
interval = Math.round(currentInterval * ease);
}
}
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;
}
interval = Math.max(1, interval);
interval = Math.min(interval, this.plugin.settings.maximumInterval);
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 = Date.now(), matchExactDate = false) {
const targetUTCDayStartForSM2 = DateUtils.startOfUTCDay(new Date(date));
const targetUTCDayEndForFSRS = DateUtils.endOfUTCDay(new Date(date));
return Object.values(this.schedules).filter((schedule) => {
if (schedule.schedulingAlgorithm === "fsrs") {
if (matchExactDate) {
return schedule.nextReviewDate >= targetUTCDayStartForSM2 && schedule.nextReviewDate <= targetUTCDayEndForFSRS;
} else {
return schedule.nextReviewDate <= targetUTCDayEndForFSRS;
}
} else {
if (matchExactDate) {
return schedule.nextReviewDate === targetUTCDayStartForSM2;
}
return schedule.nextReviewDate <= targetUTCDayStartForSM2;
}
}).sort((a, b2) => a.nextReviewDate - b2.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) {
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, b2) => a.nextReviewDate - b2.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)
*/
async skipNote(path, response = 3 /* CorrectWithDifficulty */, currentReviewDate) {
const schedule = this.schedules[path];
if (!schedule)
return;
const effectiveReviewDate = currentReviewDate || Date.now();
const reviewDateObj = new Date(effectiveReviewDate);
if (schedule.schedulingAlgorithm === "fsrs") {
if (!schedule.fsrsData) {
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
const { updatedData, nextReviewDate: newNextReviewDateFsrs, log } = this.fsrsService.skipReview(
schedule.fsrsData,
reviewDateObj
// Pass exact moment for FSRS skip
);
schedule.fsrsData = updatedData;
schedule.nextReviewDate = newNextReviewDateFsrs;
schedule.lastReviewDate = DateUtils.startOfUTCDay(reviewDateObj);
this.history.push({
// Log FSRS skip
path,
timestamp: effectiveReviewDate,
response: 1 /* IncorrectResponse */,
// Approx. for log
interval: schedule.fsrsData.scheduled_days,
ease: Math.round(schedule.fsrsData.difficulty * 10),
isSkipped: true
});
} else {
let qualityRating = toSM2Quality(response);
qualityRating = Math.max(0, qualityRating - 1);
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;
if (schedule.scheduleCategory === "initial") {
schedule.interval = 1;
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, 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;
schedule.ease = ease;
schedule.repetitionCount = repetitionCount;
schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, interval);
}
schedule.consecutive = 0;
}
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)
*/
async postponeNote(path, days = 1) {
const schedule = this.schedules[path];
if (!schedule)
return;
schedule.nextReviewDate = DateUtils.addDays(schedule.nextReviewDate, days);
if (this.plugin.events) {
setTimeout(() => {
this.plugin.events.emit("sidebar-update");
}, 50);
}
new import_obsidian24.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.
*/
async advanceNote(path) {
const schedule = this.schedules[path];
if (!schedule) {
return false;
}
const todayUTCMidnight = DateUtils.startOfUTCDay(/* @__PURE__ */ new Date());
const noteReviewUTCDayStart = DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate));
if (noteReviewUTCDayStart <= todayUTCMidnight) {
return false;
}
const newPotentialNextReviewTimestamp = DateUtils.addDays(schedule.nextReviewDate, -1);
if (schedule.schedulingAlgorithm === "sm2") {
schedule.nextReviewDate = Math.max(todayUTCMidnight, DateUtils.startOfUTCDay(new Date(newPotentialNextReviewTimestamp)));
} else {
schedule.nextReviewDate = Math.max(todayUTCMidnight, newPotentialNextReviewTimestamp);
}
if (this.plugin.events) {
setTimeout(() => {
this.plugin.events.emit("sidebar-update");
}, 50);
}
return true;
}
/**
* Remove a note from the review schedule
*
* @param path Path to the note file
*/
async removeFromReview(path) {
if (this.schedules[path]) {
delete this.schedules[path];
this.customNoteOrder = this.customNoteOrder.filter((p2) => p2 !== path);
new import_obsidian24.Notice("Note removed from review schedule");
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
}
}
/**
* Clear all review schedules
*/
async clearAllSchedules() {
this.schedules = {};
this.customNoteOrder = [];
new import_obsidian24.Notice("All review schedules have been cleared");
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
if (this.plugin.reviewController) {
await 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) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof import_obsidian24.TFile))
return 60;
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch (error) {
return 60;
}
}
/**
* 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
*/
async scheduleNotesInOrder(paths, daysFromNow = 0) {
let count = 0;
for (const path of paths) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof import_obsidian24.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;
if (defaultAlgorithm === "fsrs") {
const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now));
newSchedule = {
path,
lastReviewDate: null,
nextReviewDate: now,
reviewCount: 0,
// FSRS due now
schedulingAlgorithm: "fsrs",
fsrsData,
ease: this.plugin.settings.baseEase,
interval: 0,
consecutive: 0,
repetitionCount: 0,
scheduleCategory: void 0
};
} else {
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: void 0
};
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;
if (!this.customNoteOrder.includes(path)) {
this.customNoteOrder.push(path);
}
count++;
}
if (count > 0) {
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
}
return count;
}
/**
* Update the custom note order - used to maintain user-defined ordering
*
* @param order Array of note paths in desired order
*/
async updateCustomNoteOrder(order) {
const uniqueValidPaths = Array.from(new Set(order)).filter((path) => this.schedules[path] !== void 0);
this.customNoteOrder = uniqueValidPaths;
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 = Date.now(), useCustomOrder = true, matchExactDate = false) {
const dueNotes = this.getDueNotes(date, matchExactDate);
if (!useCustomOrder || this.customNoteOrder.length === 0) {
return dueNotes;
}
const notesByPath = {};
dueNotes.forEach((note) => {
notesByPath[note.path] = note;
});
const notesInOrder = [];
const orderedPaths = /* @__PURE__ */ new Set();
for (const path of this.customNoteOrder) {
if (notesByPath[path]) {
notesInOrder.push(notesByPath[path]);
orderedPaths.add(path);
}
}
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, newPath) {
if (this.schedules[oldPath]) {
const schedule = this.schedules[oldPath];
delete this.schedules[oldPath];
schedule.path = newPath;
this.schedules[newPath] = schedule;
const oldPathIndex = this.customNoteOrder.indexOf(oldPath);
if (oldPathIndex > -1) {
this.customNoteOrder[oldPathIndex] = newPath;
} else {
if (!this.customNoteOrder.includes(newPath)) {
this.customNoteOrder.push(newPath);
}
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
}
}
// Helper method for backward compatibility (moved from DataStorage)
getRepetitionCount(interval) {
if (interval <= 1)
return 0;
if (interval <= 6)
return 1;
return 2;
}
async convertAllSm2ToFsrs() {
let convertedCount = 0;
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";
const baseDate = schedule.lastReviewDate ? new Date(schedule.lastReviewDate) : /* @__PURE__ */ new Date();
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(baseDate);
schedule.nextReviewDate = baseDate.getTime();
schedule.ease = this.plugin.settings.baseEase;
schedule.interval = 0;
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = void 0;
convertedCount++;
}
}
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
}
async convertAllFsrsToSm2() {
let convertedCount = 0;
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;
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = this.plugin.settings.useInitialSchedule ? "initial" : "spaced";
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
let nextReview = DateUtils.addDays(todayUTCStart, 0);
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 = void 0;
convertedCount++;
}
}
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
}
};
// services/review-history-service.ts
var ReviewHistoryService = class {
/**
* Initialize Review History Service
*
* @param history Reference to the history array in DataStorage
*/
constructor(history) {
this.history = history;
}
/**
* Get review history for a specific note
*
* @param path Path to the note file
* @returns Array of review history items for the note
*/
getNoteHistory(path) {
return this.history.filter((item) => item.path === path).sort((a, b2) => b2.timestamp - a.timestamp);
}
/**
* Add a history item (used by ReviewScheduleService)
* This method is here to centralize history management, even if called from another service.
*
* @param item The history item to add
*/
addHistoryItem(item) {
this.history.push(item);
if (this.history.length > 1e3) {
this.history = this.history.slice(-1e3);
}
}
};
// services/review-session-service.ts
var import_obsidian25 = require("obsidian");
// models/review-session.ts
function generateSessionId(prefix = "session") {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
function getNextFileInSession(session) {
if (!session.hierarchy.traversalOrder.length) {
return null;
}
if (session.currentIndex >= session.hierarchy.traversalOrder.length) {
return null;
}
return session.hierarchy.traversalOrder[session.currentIndex];
}
function advanceSession(session) {
return {
...session,
currentIndex: session.currentIndex + 1,
updatedAt: Date.now()
};
}
function isSessionComplete(session) {
return session.currentIndex >= session.hierarchy.traversalOrder.length;
}
// services/review-session-service.ts
var ReviewSessionService = class {
/**
* Initialize Review Session Service
*
* @param plugin Reference to the main plugin
* @param reviewSessions Reference to the reviewSessions object in DataStorage
*/
constructor(plugin, reviewSessions) {
this.plugin = plugin;
this.reviewSessions = reviewSessions;
}
/**
* Create a new review session for a folder
*
* @param folderPath Path to the folder
* @param name Name for the session
* @returns Created review session or null if failed
*/
async createReviewSession(folderPath, name) {
const folder = this.plugin.app.vault.getAbstractFileByPath(folderPath);
if (!folder || !(folder instanceof import_obsidian25.TFolder)) {
new import_obsidian25.Notice("Invalid folder for review session");
return null;
}
try {
const includeSubfolders = this.plugin.settings.includeSubfolders;
const hierarchy = await LinkAnalyzer.analyzeFolder(
this.plugin.app.vault,
folder,
includeSubfolders
);
const id = generateSessionId(folder.name);
const session = {
id,
name: name || folder.name,
path: folderPath,
hierarchy,
currentIndex: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
isActive: false
};
this.reviewSessions.sessions[id] = session;
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
return session;
} catch (error) {
console.error("Error creating review session:", error);
new import_obsidian25.Notice("Failed to create review session");
return null;
}
}
/**
* Set the active review session
*
* @param sessionId ID of the session to activate
* @returns Whether the session was activated
*/
async setActiveSession(sessionId) {
if (sessionId === null) {
this.reviewSessions.activeSessionId = null;
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
return true;
}
const session = this.reviewSessions.sessions[sessionId];
if (!session) {
return false;
}
this.reviewSessions.activeSessionId = sessionId;
session.isActive = true;
session.updatedAt = Date.now();
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
return true;
}
/**
* Get the active review session
*
* @returns Active review session or null if none
*/
getActiveSession() {
const id = this.reviewSessions.activeSessionId;
if (!id) {
return null;
}
return this.reviewSessions.sessions[id] || null;
}
/**
* Get the next file to review in the active session
*
* @returns Path to the next file or null if done
*/
getNextSessionFile() {
const session = this.getActiveSession();
if (!session) {
return null;
}
return getNextFileInSession(session);
}
/**
* Advance to the next file in the active session
*
* @returns Whether there are more files to review
*/
async advanceActiveSession() {
const session = this.getActiveSession();
if (!session) {
return false;
}
const updatedSession = advanceSession(session);
this.reviewSessions.sessions[session.id] = updatedSession;
if (isSessionComplete(updatedSession)) {
updatedSession.isActive = false;
this.reviewSessions.activeSessionId = null;
new import_obsidian25.Notice(`Completed review session: ${updatedSession.name}`);
}
if (this.plugin.events) {
this.plugin.events.emit("sidebar-update");
}
return !isSessionComplete(updatedSession);
}
/**
* Schedule all files in a session for review
* (This method depends on ReviewScheduleService, will need to pass it in or access via plugin)
*
* @param sessionId ID of the session
* @returns Number of files scheduled
*/
async scheduleSessionForReview(sessionId) {
const session = this.reviewSessions.sessions[sessionId];
if (!session) {
return 0;
}
if (!this.plugin.reviewScheduleService) {
console.error("ReviewScheduleService not available on plugin instance.");
return 0;
}
return await this.plugin.reviewScheduleService.scheduleNotesInOrder(session.hierarchy.traversalOrder);
}
};
// services/mcq-service.ts
var MCQService = class {
/**
* Initialize MCQ Service
*
* @param mcqSets Reference to the mcqSets object in DataStorage
* @param mcqSessions Reference to the mcqSessions object in DataStorage
*/
constructor(mcqSets, mcqSessions) {
this.mcqSets = mcqSets;
this.mcqSessions = mcqSessions;
}
/**
* Save an MCQ set
*
* @param mcqSet MCQ set to save
* @returns The ID of the saved MCQ set
*/
saveMCQSet(mcqSet) {
const id = `${mcqSet.notePath}_${mcqSet.generatedAt}`;
this.mcqSets[id] = mcqSet;
return id;
}
/**
* Get the latest MCQ set for a note
*
* @param notePath Path to the note
* @returns MCQ set or null if none exists
*/
getMCQSetForNote(notePath) {
try {
if (!notePath) {
console.error("Invalid notePath provided to getMCQSetForNote");
return null;
}
if (!this.mcqSets) {
console.warn("mcqSets not initialized");
this.mcqSets = {};
return null;
}
const sets = Object.values(this.mcqSets).filter((set) => set && set.notePath === notePath && set.questions && set.questions.length > 0).sort((a, b2) => b2.generatedAt - a.generatedAt);
if (sets.length > 0) {
return sets[0];
}
return null;
} catch (error) {
console.error("Error in getMCQSetForNote:", error);
return null;
}
}
/**
* Save an MCQ session
*
* @param session MCQ session to save
*/
saveMCQSession(session) {
try {
if (!session || !session.notePath || !session.mcqSetId) {
console.error("Invalid MCQ session data:", session);
return;
}
if (!this.mcqSessions) {
this.mcqSessions = {};
}
if (!this.mcqSessions[session.notePath]) {
this.mcqSessions[session.notePath] = [];
}
const existingIndex = this.mcqSessions[session.notePath].findIndex(
(s) => s && s.mcqSetId === session.mcqSetId && s.startedAt === session.startedAt
);
if (existingIndex >= 0) {
this.mcqSessions[session.notePath][existingIndex] = session;
} else {
this.mcqSessions[session.notePath].push(session);
}
if (this.mcqSessions[session.notePath].length > 10) {
this.mcqSessions[session.notePath].sort((a, b2) => b2.startedAt - a.startedAt);
this.mcqSessions[session.notePath] = this.mcqSessions[session.notePath].slice(0, 10);
}
} catch (error) {
console.error("Error saving MCQ session:", error);
}
}
/**
* Get all MCQ sessions for a note
*
* @param notePath Path to the note
* @returns Array of MCQ sessions
*/
getMCQSessionsForNote(notePath) {
return this.mcqSessions[notePath] || [];
}
/**
* Get the latest MCQ session for a note
*
* @param notePath Path to the note
* @returns Latest MCQ session or null
*/
getLatestMCQSessionForNote(notePath) {
const sessions = this.getMCQSessionsForNote(notePath).sort((a, b2) => b2.startedAt - a.startedAt);
return sessions.length > 0 ? sessions[0] : null;
}
/**
* Flags an MCQ set for regeneration.
* This is typically called when a note's review rating meets certain criteria.
*
* @param notePath Path to the note whose MCQ set should be flagged.
*/
flagMCQSetForRegeneration(notePath) {
const mcqSet = this.getMCQSetForNote(notePath);
if (mcqSet) {
mcqSet.needsQuestionRegeneration = true;
this.saveMCQSet(mcqSet);
} else {
}
}
};
// services/pomodoro-service.ts
var PomodoroService = class {
constructor(plugin) {
this.timerInterval = null;
this.plugin = plugin;
if (this.plugin.settings.pomodoroEnabled && this.plugin.pluginState.pomodoroIsRunning) {
this.recalculateTimeLeftFromEndTime();
this.startTimerInterval();
}
}
get settings() {
return this.plugin.settings;
}
get state() {
return this.plugin.pluginState;
}
// --- Public API ---
start() {
if (this.state.pomodoroIsRunning)
return;
this.state.pomodoroIsRunning = true;
if (this.state.pomodoroCurrentMode === "idle") {
this.switchToMode("work");
} else if (this.state.pomodoroTimeLeftInSeconds <= 0) {
this.handleTimerEnd();
}
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1e3;
this.startTimerInterval();
this.notifyUpdate();
this.plugin.savePluginData();
}
stop() {
if (!this.state.pomodoroIsRunning)
return;
this.stopTimerInterval();
this.state.pomodoroIsRunning = false;
if (this.state.pomodoroEndTimeMs) {
const remainingMs = this.state.pomodoroEndTimeMs - Date.now();
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1e3));
}
this.state.pomodoroEndTimeMs = null;
this.notifyUpdate();
this.plugin.savePluginData();
}
resetCurrentSession() {
this.stopTimerInterval();
this.state.pomodoroIsRunning = false;
this.state.pomodoroEndTimeMs = null;
this.resetTimeForMode(this.state.pomodoroCurrentMode === "idle" ? "work" : this.state.pomodoroCurrentMode);
this.notifyUpdate();
this.plugin.savePluginData();
}
skipSession() {
this.stopTimerInterval();
this.state.pomodoroIsRunning = false;
this.handleTimerEnd(true);
if (this.state.pomodoroIsRunning) {
this.startTimerInterval();
}
this.notifyUpdate();
this.plugin.savePluginData();
}
getFormattedTimeLeft() {
const minutes = Math.floor(this.state.pomodoroTimeLeftInSeconds / 60);
const seconds = this.state.pomodoroTimeLeftInSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
updateDurations(work, short, long, sessions) {
this.settings.pomodoroWorkDuration = work;
this.settings.pomodoroShortBreakDuration = short;
this.settings.pomodoroLongBreakDuration = long;
this.settings.pomodoroSessionsUntilLongBreak = sessions;
const activeModesForDurationUpdate = ["work", "shortBreak", "longBreak"];
if (!this.state.pomodoroIsRunning && activeModesForDurationUpdate.includes(this.state.pomodoroCurrentMode)) {
this.resetTimeForMode(this.state.pomodoroCurrentMode);
}
this.plugin.savePluginData();
this.notifyUpdate();
}
// --- Internal Logic ---
startTimerInterval() {
if (this.timerInterval !== null) {
window.clearInterval(this.timerInterval);
}
if (!this.settings.pomodoroEnabled || !this.state.pomodoroIsRunning)
return;
this.timerInterval = window.setInterval(() => {
this.tick();
}, 1e3);
}
stopTimerInterval() {
if (this.timerInterval !== null) {
window.clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
tick() {
if (!this.state.pomodoroIsRunning || !this.state.pomodoroEndTimeMs) {
this.stop();
return;
}
const now = Date.now();
const remainingMs = this.state.pomodoroEndTimeMs - now;
const remainingSeconds = Math.max(0, Math.round(remainingMs / 1e3));
this.state.pomodoroTimeLeftInSeconds = remainingSeconds;
if (remainingSeconds <= 0) {
this.handleTimerEnd();
} else {
this.notifyUpdate();
}
}
handleTimerEnd(skipped = false) {
this.stopTimerInterval();
if (this.settings.pomodoroSoundEnabled && !skipped) {
this.playSoundNotification();
}
const currentMode = this.state.pomodoroCurrentMode;
let nextMode = "idle";
if (currentMode === "work") {
this.state.pomodoroSessionsCompletedInCycle++;
if (this.state.pomodoroSessionsCompletedInCycle >= this.settings.pomodoroSessionsUntilLongBreak) {
nextMode = "longBreak";
} else {
nextMode = "shortBreak";
}
} else if (currentMode === "shortBreak" || currentMode === "longBreak") {
nextMode = "work";
if (currentMode === "longBreak") {
this.state.pomodoroSessionsCompletedInCycle = 0;
}
}
const wasRunning = this.state.pomodoroIsRunning;
this.switchToMode(nextMode);
if (nextMode !== "idle") {
this.state.pomodoroIsRunning = true;
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1e3;
if (!wasRunning) {
this.startTimerInterval();
} else if (!this.timerInterval) {
this.startTimerInterval();
}
} else {
this.state.pomodoroIsRunning = false;
this.state.pomodoroEndTimeMs = null;
this.stopTimerInterval();
}
this.plugin.savePluginData();
this.notifyUpdate();
}
switchToMode(mode) {
this.state.pomodoroCurrentMode = mode;
this.resetTimeForMode(mode);
}
resetTimeForMode(mode) {
switch (mode) {
case "work":
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60;
break;
case "shortBreak":
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroShortBreakDuration * 60;
break;
case "longBreak":
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroLongBreakDuration * 60;
break;
case "idle":
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60;
break;
}
}
playSoundNotification() {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (!audioContext)
return;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(440, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.5);
} catch (e) {
console.error("Could not play sound notification:", e);
}
}
notifyUpdate() {
this.plugin.events.emit("pomodoro-update");
}
// Call this when global settings change from the settings tab
onSettingsChanged() {
if (!this.settings.pomodoroEnabled) {
this.stop();
this.state.pomodoroCurrentMode = "idle";
this.resetTimeForMode("idle");
} else {
if (!this.state.pomodoroIsRunning) {
const currentMode = this.state.pomodoroCurrentMode;
const activeModes = ["work", "shortBreak", "longBreak"];
if (activeModes.includes(currentMode)) {
this.resetTimeForMode(currentMode);
}
}
}
this.notifyUpdate();
}
destroy() {
this.stopTimerInterval();
}
// Recalculate time left based on stored end time, useful on load/reinit
recalculateTimeLeftFromEndTime() {
if (this.state.pomodoroIsRunning && this.state.pomodoroEndTimeMs) {
const now = Date.now();
const remainingMs = this.state.pomodoroEndTimeMs - now;
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1e3));
if (remainingMs <= 0) {
this.handleTimerEnd(true);
}
} else if (!this.state.pomodoroIsRunning) {
this.state.pomodoroEndTimeMs = null;
}
}
// Call this after pluginState has been externally modified (e.g., by data import)
reinitializeTimerFromState() {
this.stopTimerInterval();
if (this.settings.pomodoroEnabled) {
this.recalculateTimeLeftFromEndTime();
if (this.state.pomodoroIsRunning) {
this.startTimerInterval();
}
} else {
this.stop();
this.state.pomodoroCurrentMode = "idle";
this.resetTimeForMode("idle");
this.state.pomodoroEndTimeMs = null;
}
this.notifyUpdate();
}
};
// main.ts
var SpaceforgePlugin = class extends import_obsidian26.Plugin {
constructor() {
super(...arguments);
this.stylesheetPath = "styles.css";
this.stylesheetId = "spaceforge-styles";
this.lastStylesModTime = null;
this.cssHotReloadIntervalId = null;
this.clickedDateFromCalendar = null;
}
async onload() {
var _a;
console.log("Loading Spaceforge plugin (version " + this.manifest.version + ")");
this.events = new EventEmitter();
this.settings = { ...DEFAULT_SETTINGS };
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
this.reviewScheduleService = new ReviewScheduleService(
this,
this.pluginState.schedules,
this.pluginState.customNoteOrder,
(_a = this.pluginState.lastLinkAnalysisTimestamp) != null ? _a : null,
// Coalesce undefined to null
this.pluginState.history
);
this.reviewHistoryService = new ReviewHistoryService(this.pluginState.history);
this.reviewSessionService = new ReviewSessionService(this, this.pluginState.reviewSessions);
this.mcqService = new MCQService(this.pluginState.mcqSets, this.pluginState.mcqSessions);
await this.loadPluginData();
this.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
this.pomodoroService = new PomodoroService(this);
this.registerView(
"spaceforge-review-schedule",
(leaf) => this.sidebarView = new ReviewSidebarView(leaf, this)
);
this.dataStorage = new DataStorage(
this,
this.reviewScheduleService,
this.reviewHistoryService,
this.reviewSessionService,
this.mcqService
);
this.reviewController = new ReviewController(this, this.mcqService);
this.navigationController = new ReviewNavigationController(this);
this.sessionController = new ReviewSessionController(this);
this.batchController = new ReviewBatchController(this);
this.contextMenuHandler = new ContextMenuHandler(this);
this.initializeMCQComponents();
EstimationUtils.setPlugin(this);
this.addIcons();
this.contextMenuHandler.register();
this.addRibbonIcon("calendar-clock", "Spaceforge Review", async () => {
await this.activateSidebarView();
});
this.addSettingTab(new SpaceforgeSettingTab(this.app, this));
this.addCommands();
this.addCommand({
id: "add-selected-file-to-review",
name: "Add Selected File to Review Schedule (File Explorer)",
callback: () => {
const fileExplorerLeaf = this.app.workspace.getLeavesOfType("file-explorer")[0];
const viewWithFile = fileExplorerLeaf == null ? void 0 : fileExplorerLeaf.view;
if (viewWithFile == null ? void 0 : viewWithFile.file) {
const selectedFile = viewWithFile.file;
if (selectedFile instanceof import_obsidian26.TFile && selectedFile.extension === "md") {
this.reviewScheduleService.scheduleNoteForReview(selectedFile.path).then(() => this.savePluginData());
new import_obsidian26.Notice(`Added "${selectedFile.path}" to review schedule.`);
} else {
new import_obsidian26.Notice("Selected item is not a markdown file.");
}
} else {
new import_obsidian26.Notice("No file selected in file explorer.");
}
}
});
this.registerEvent(this.app.workspace.on("file-open", (file) => {
}));
this.registerEvent(this.app.vault.on("delete", async (file) => {
if (file instanceof import_obsidian26.TFile && file.extension === "md") {
await this.reviewScheduleService.removeFromReview(file.path);
await this.savePluginData();
}
if (this.sidebarView)
this.sidebarView.refresh();
}));
this.registerEvent(this.app.vault.on("rename", async (file, oldPath) => {
if (file instanceof import_obsidian26.TFile && file.extension === "md") {
this.reviewScheduleService.handleNoteRename(oldPath, file.path);
await this.savePluginData();
}
if (this.sidebarView)
this.sidebarView.refresh();
}));
this.registerInterval(window.setInterval(() => {
if (this.sidebarView)
this.sidebarView.refresh();
}, 60 * 1e3));
this.app.workspace.onLayoutReady(() => this.activateSidebarView());
this.addStylesheet();
if (this.app.vault.adapter.stat && typeof this.app.vault.adapter.stat === "function") {
this.cssHotReloadIntervalId = window.setInterval(async () => {
try {
const stats = await this.app.vault.adapter.stat(this.stylesheetPath);
if (stats && (this.lastStylesModTime === null || this.lastStylesModTime < stats.mtime)) {
this.lastStylesModTime = stats.mtime;
console.log("Detected styles.css change, reloading stylesheet...");
this.addStylesheet();
}
} catch (error) {
}
}, 1e3);
this.registerInterval(this.cssHotReloadIntervalId);
}
if (this.settings.notifyBeforeDue > 0) {
this.registerInterval(window.setInterval(() => this.checkForDueNotes(), 5 * 60 * 1e3));
}
this.registerInterval(window.setInterval(async () => {
console.log("Auto-saving data...");
await this.savePluginData();
}, 5 * 60 * 1e3));
window.addEventListener("beforeunload", (event) => {
console.log("Window closing, saving data immediately...");
let existingData = {};
try {
const loadedData = this.loadData();
if (loadedData && !(loadedData instanceof Promise)) {
existingData = loadedData;
} else if (loadedData instanceof Promise) {
console.warn("Synchronous loadData not available in beforeunload.");
}
} catch (loadError) {
console.warn("Could not load existing data during unload:", loadError);
}
try {
const reviewData = {
schedules: this.reviewScheduleService.schedules,
history: this.reviewHistoryService.history,
reviewSessions: this.reviewSessionService.reviewSessions,
mcqSets: this.mcqService.mcqSets,
mcqSessions: this.mcqService.mcqSessions,
customNoteOrder: this.reviewScheduleService.customNoteOrder,
lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp,
version: this.manifest.version
};
const combinedData = { ...existingData, reviewData };
let backupStr = JSON.stringify(combinedData);
window.localStorage.setItem("spaceforge-backup", backupStr);
console.log(`Saved emergency backup to localStorage (${Math.round(backupStr.length / 1024)}KB)`);
this.savePluginData().catch((e) => console.error("Error saving to Obsidian storage during unload:", e));
} catch (error) {
console.error("Emergency data backup failed:", error);
try {
const minimalBackup = JSON.stringify({ settings: this.settings, reviewData: { schedules: this.reviewScheduleService.schedules || {}, customNoteOrder: this.reviewScheduleService.customNoteOrder || [], lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp, version: this.manifest.version } });
window.localStorage.setItem("spaceforge-minimal-backup", minimalBackup);
} catch (minimalError) {
console.error("Even minimal backup failed:", minimalError);
}
}
});
}
async onunload() {
console.log("Unloading Spaceforge plugin");
if (this.cssHotReloadIntervalId !== null) {
window.clearInterval(this.cssHotReloadIntervalId);
this.cssHotReloadIntervalId = null;
}
const styleEl = document.getElementById(this.stylesheetId);
if (styleEl)
styleEl.remove();
let existingData = {};
try {
const loaded = await this.loadData();
if (loaded) {
existingData = loaded;
}
} catch (loadError) {
console.warn("Could not load existing data during unload:", loadError);
}
try {
const reviewData = {
schedules: this.reviewScheduleService.schedules,
history: this.reviewHistoryService.history,
reviewSessions: this.reviewSessionService.reviewSessions,
mcqSets: this.mcqService.mcqSets,
mcqSessions: this.mcqService.mcqSessions,
customNoteOrder: this.reviewScheduleService.customNoteOrder,
lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp,
version: this.manifest.version
};
const combinedData = { ...existingData, reviewData };
const backupStr = JSON.stringify(combinedData);
window.localStorage.setItem("spaceforge-backup", backupStr);
} catch (backupError) {
console.error("Failed to create emergency backup before unload:", backupError);
}
try {
await this.savePluginData();
} catch (error) {
console.error("Error saving plugin data before unload:", error);
}
if (this.pomodoroService)
this.pomodoroService.destroy();
}
// private async _getEffectiveDataPathFromLocalStorage(): Promise<string | null> {
// const customPath = await this.app.loadLocalStorage('spaceforgeCustomDataPath');
// return (customPath && typeof customPath === 'string' && customPath.trim() !== '') ? customPath.trim() : null;
// }
async _getEffectiveDataPath() {
var _a, _b;
if ((_a = this.settings) == null ? void 0 : _a.useCustomDataPath) {
const relativePath = (_b = this.settings.customDataPath) == null ? void 0 : _b.trim();
if (relativePath && relativePath !== "") {
let pathForJson = relativePath;
if (!pathForJson.endsWith("/")) {
pathForJson += "/";
}
pathForJson += "data.json";
const vaultBasePath = this.app.vault.getRoot().path;
let absolutePath = (vaultBasePath ? vaultBasePath + "/" : "") + pathForJson;
absolutePath = (0, import_obsidian26.normalizePath)(absolutePath);
return absolutePath;
} else {
return null;
}
}
return null;
}
async loadPluginData() {
var _a, _b, _c, _d, _e, _f;
const lsUseCustomPath = await this.app.loadLocalStorage("spaceforge_useCustomDataPath");
const lsCustomPathRelative = await this.app.loadLocalStorage("spaceforge_customDataPathRelative");
this.settings = { ...DEFAULT_SETTINGS };
if (typeof lsUseCustomPath === "boolean") {
this.settings.useCustomDataPath = lsUseCustomPath;
}
if (typeof lsCustomPathRelative === "string") {
this.settings.customDataPath = lsCustomPathRelative;
}
let rawLoadedData;
const effectivePath = await this._getEffectiveDataPath();
const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
try {
if (effectivePath) {
if (await this.app.vault.adapter.exists(effectivePath)) {
const jsonData = await this.app.vault.adapter.read(effectivePath);
if (jsonData)
rawLoadedData = JSON.parse(jsonData);
new import_obsidian26.Notice(`Spaceforge: Loaded data from custom path: ${effectivePath}`, 3e3);
} else {
if (await this.app.vault.adapter.exists(defaultPluginDataPath)) {
new import_obsidian26.Notice(`Spaceforge: Custom data file not found at ${effectivePath}. Attempting to migrate from default location.`, 5e3);
try {
const oldJsonData = await this.app.vault.adapter.read(defaultPluginDataPath);
if (oldJsonData) {
rawLoadedData = JSON.parse(oldJsonData);
console.log(`Spaceforge: Data from default location will be migrated to ${effectivePath} on next save.`);
}
} catch (migrationReadError) {
console.error(`Spaceforge: Error reading data from default location for migration:`, migrationReadError);
}
}
if (!rawLoadedData) {
new import_obsidian26.Notice(`Spaceforge: No data file found at custom path ${effectivePath}. New data file will be created on save.`, 3e3);
}
}
} else {
rawLoadedData = await this.loadData();
}
let loadedSettings = {};
if ((rawLoadedData == null ? void 0 : rawLoadedData.settings) && typeof rawLoadedData.settings === "object") {
loadedSettings = rawLoadedData.settings;
}
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
if (typeof lsUseCustomPath === "boolean") {
this.settings.useCustomDataPath = lsUseCustomPath;
}
if (typeof lsCustomPathRelative === "string") {
this.settings.customDataPath = lsCustomPathRelative;
}
this.pluginState = { ...DEFAULT_APP_DATA.pluginState };
if (rawLoadedData == null ? void 0 : rawLoadedData.pluginState) {
this.pluginState = { ...this.pluginState, ...rawLoadedData.pluginState };
} else if (rawLoadedData && !rawLoadedData.pluginState && rawLoadedData.schedules) {
this.pluginState = { ...this.pluginState, ...rawLoadedData };
}
this.pluginState.pomodoroCurrentMode = this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode;
this.pluginState.pomodoroTimeLeftInSeconds = this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds;
this.pluginState.pomodoroSessionsCompletedInCycle = this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle;
this.pluginState.pomodoroIsRunning = typeof this.pluginState.pomodoroIsRunning === "boolean" ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning;
if (this.pluginState.schedules) {
for (const path in this.pluginState.schedules) {
if (Object.prototype.hasOwnProperty.call(this.pluginState.schedules, path)) {
const schedule = this.pluginState.schedules[path];
if (!schedule.schedulingAlgorithm) {
schedule.schedulingAlgorithm = "sm2";
schedule.fsrsData = void 0;
if (!schedule.scheduleCategory) {
schedule.scheduleCategory = this.settings.useInitialSchedule ? "initial" : "spaced";
}
}
if (schedule.schedulingAlgorithm === "sm2") {
schedule.ease = (_a = schedule.ease) != null ? _a : this.settings.baseEase;
schedule.interval = (_b = schedule.interval) != null ? _b : 0;
schedule.repetitionCount = (_c = schedule.repetitionCount) != null ? _c : 0;
schedule.consecutive = (_d = schedule.consecutive) != null ? _d : 0;
schedule.scheduleCategory = (_e = schedule.scheduleCategory) != null ? _e : this.settings.useInitialSchedule ? "initial" : "spaced";
}
}
}
}
this.reviewScheduleService.schedules = this.pluginState.schedules || {};
this.reviewHistoryService.history = this.pluginState.history || [];
this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null };
this.mcqService.mcqSets = this.pluginState.mcqSets || {};
this.mcqService.mcqSessions = this.pluginState.mcqSessions || {};
this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.pluginState.lastLinkAnalysisTimestamp === "number" ? this.pluginState.lastLinkAnalysisTimestamp : null;
} catch (error) {
console.error("Error loading plugin data:", error);
console.warn("Spaceforge: loadPluginData caught an error. Re-initializing settings and pluginState to defaults.");
this.settings = { ...DEFAULT_SETTINGS };
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
this.reviewScheduleService.schedules = this.pluginState.schedules || {};
this.reviewHistoryService.history = this.pluginState.history || [];
this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null };
this.mcqService.mcqSets = this.pluginState.mcqSets || {};
this.mcqService.mcqSessions = this.pluginState.mcqSessions || {};
this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = (_f = this.pluginState.lastLinkAnalysisTimestamp) != null ? _f : null;
if (this.reviewScheduleService) {
this.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
}
new import_obsidian26.Notice("Spaceforge: Error loading data, initialized with defaults.", 5e3);
}
}
async savePluginData() {
var _a, _b;
try {
if (!this.settings || typeof this.settings !== "object") {
console.warn("Spaceforge: Settings object was invalid, resetting to defaults before save.");
this.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
} else {
this.settings = { ...DEFAULT_SETTINGS, ...this.settings };
}
if (!this.pluginState) {
console.warn("Spaceforge: pluginState was undefined, initializing to default before save.");
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
}
const currentPluginState = {
schedules: this.reviewScheduleService.schedules || {},
history: this.reviewHistoryService.history || [],
reviewSessions: this.reviewSessionService.reviewSessions || { sessions: {}, activeSessionId: null },
mcqSets: this.mcqService.mcqSets || {},
mcqSessions: this.mcqService.mcqSessions || {},
customNoteOrder: this.reviewScheduleService.customNoteOrder || [],
lastLinkAnalysisTimestamp: (_a = this.reviewScheduleService.lastLinkAnalysisTimestamp) != null ? _a : null,
pomodoroCurrentMode: this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode,
pomodoroTimeLeftInSeconds: this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds,
pomodoroSessionsCompletedInCycle: this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle,
pomodoroIsRunning: typeof this.pluginState.pomodoroIsRunning === "boolean" ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning,
pomodoroEndTimeMs: (_b = this.pluginState.pomodoroEndTimeMs) != null ? _b : null,
// Add the missing field
version: this.manifest.version
};
this.pluginState = currentPluginState;
const dataToSave = {
settings: this.settings,
// Use the already prepared this.settings
pluginState: this.pluginState
};
const effectiveSavePath = await this._getEffectiveDataPath();
const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
if (effectiveSavePath) {
try {
const dirPathOnly = effectiveSavePath.substring(0, effectiveSavePath.lastIndexOf("/"));
if (dirPathOnly && !await this.app.vault.adapter.exists(dirPathOnly)) {
await this.app.vault.adapter.mkdir(dirPathOnly);
new import_obsidian26.Notice(`Spaceforge: Created directory for custom data: ${dirPathOnly}`, 3e3);
}
await this.app.vault.adapter.write(effectiveSavePath, JSON.stringify(dataToSave, null, 2));
if (await this.app.vault.adapter.exists(defaultPluginDataPath)) {
await this.app.vault.adapter.remove(defaultPluginDataPath);
new import_obsidian26.Notice(`Spaceforge: Removed old data file from default plugin folder as custom path is active.`, 5e3);
console.log(`Spaceforge: Removed old data file at ${defaultPluginDataPath}`);
}
} catch (writeError) {
console.error(`Error saving data to custom path ${effectiveSavePath}:`, writeError);
new import_obsidian26.Notice(`Error saving data to custom path ${effectiveSavePath}: ${writeError.message}. Falling back to default path for this save.`, 1e4);
try {
await this.saveData(dataToSave);
new import_obsidian26.Notice(`Spaceforge: Data saved to default plugin folder due to error with custom path.`, 5e3);
} catch (fallbackError) {
console.error(`Error saving data to default location after custom path failed:`, fallbackError);
new import_obsidian26.Notice(`CRITICAL: Spaceforge failed to save data to both custom and default locations.`, 1e4);
}
}
} else {
await this.saveData(dataToSave);
}
} catch (error) {
console.error("General error in savePluginData:", error);
new import_obsidian26.Notice("Error saving Spaceforge data. Check console for details.", 5e3);
}
}
async activateSidebarView() {
const existingLeaves = this.app.workspace.getLeavesOfType("spaceforge-review-schedule");
if (existingLeaves.length > 0) {
this.app.workspace.revealLeaf(existingLeaves[0]);
} else {
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({
type: "spaceforge-review-schedule",
active: true
});
this.app.workspace.revealLeaf(leaf);
} else {
console.error("Spaceforge: Could not get a leaf to activate the sidebar view.");
new import_obsidian26.Notice("Spaceforge: Could not open sidebar view.");
}
}
}
addCommands() {
this.addCommand({
id: "spaceforge-next-review-note",
name: "Next Review Note",
callback: () => {
this.navigationController.navigateToNextNote();
}
});
this.addCommand({
id: "spaceforge-previous-review-note",
name: "Previous Review Note",
callback: () => {
this.navigationController.navigateToPreviousNote();
}
});
this.addCommand({
id: "spaceforge-review-current-note",
name: "Review Current Note",
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof import_obsidian26.TFile && activeFile.extension === "md") {
this.reviewController.reviewNote(activeFile.path);
} else {
new import_obsidian26.Notice("No active markdown file to review.");
}
}
});
this.addCommand({
id: "spaceforge-add-current-note-to-review",
name: "Add Current Note to Review Schedule",
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof import_obsidian26.TFile && activeFile.extension === "md") {
this.reviewScheduleService.scheduleNoteForReview(activeFile.path).then(() => this.savePluginData());
new import_obsidian26.Notice(`Added "${activeFile.path}" to review schedule.`);
} else {
new import_obsidian26.Notice("No active markdown file to add to review.");
}
}
});
this.addCommand({
id: "spaceforge-add-current-folder-to-review",
name: "Add Current Note's Folder to Review Schedule",
callback: async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile.parent && activeFile.parent instanceof import_obsidian26.TFolder) {
const folder = activeFile.parent;
await this.contextMenuHandler.addFolderToReview(folder);
} else {
new import_obsidian26.Notice("Could not determine the current note's folder, no active file, or parent is not a folder.");
}
}
});
}
addIcons() {
}
initializeMCQComponents() {
this.mcqGenerationService = void 0;
this.mcqController = void 0;
if (this.settings.enableMCQ) {
console.log("Initializing MCQ components for provider:", this.settings.mcqApiProvider);
this.mcqGenerationService = this.createMcqGenerationService();
if (this.mcqGenerationService) {
this.mcqController = new MCQController(this, this.mcqService, this.mcqGenerationService);
} else {
new import_obsidian26.Notice("MCQ Generation Service could not be initialized. Check API provider settings in Spaceforge settings.");
console.warn(`Failed to create MCQ generation service for provider: ${this.settings.mcqApiProvider}`);
}
}
}
createMcqGenerationService() {
switch (this.settings.mcqApiProvider) {
case "openrouter" /* OpenRouter */:
if (!this.settings.openRouterApiKey) {
new import_obsidian26.Notice("OpenRouter API key is not set in Spaceforge settings.");
return void 0;
}
return new OpenRouterService(this);
case "openai" /* OpenAI */:
if (!this.settings.openaiApiKey) {
new import_obsidian26.Notice("OpenAI API key is not set in Spaceforge settings.");
return void 0;
}
return new OpenAIService(this);
case "ollama" /* Ollama */:
if (!this.settings.ollamaApiUrl || !this.settings.ollamaModel) {
new import_obsidian26.Notice("Ollama API URL or Model is not set in Spaceforge settings.");
return void 0;
}
return new OllamaService(this);
case "gemini" /* Gemini */:
if (!this.settings.geminiApiKey) {
new import_obsidian26.Notice("Gemini API key is not set in Spaceforge settings.");
return void 0;
}
return new GeminiService(this);
case "claude" /* Claude */:
if (!this.settings.claudeApiKey || !this.settings.claudeModel) {
new import_obsidian26.Notice("Claude API key or Model is not set in Spaceforge settings.");
return void 0;
}
return new ClaudeService(this);
case "together" /* Together */:
if (!this.settings.togetherApiKey || !this.settings.togetherModel) {
new import_obsidian26.Notice("Together AI API key or Model is not set in Spaceforge settings.");
return void 0;
}
return new TogetherService(this);
default:
new import_obsidian26.Notice(`Unsupported MCQ API provider selected: ${this.settings.mcqApiProvider}`);
console.error(`Unsupported MCQ API provider: ${this.settings.mcqApiProvider}`);
return void 0;
}
}
async checkForDueNotes() {
}
addStylesheet() {
}
async exportPluginData() {
}
async importPluginData(fileContent) {
}
};