feat: Enhance MCQ generation with difficulty settings, refactor UI components, update calendar event logic, and configure ESLint for improved code quality.

This commit is contained in:
dralkh 2025-12-08 16:58:14 +03:00
parent cb9a941597
commit 1726f366c4
46 changed files with 5697 additions and 2605 deletions

24
.eslintrc.js Normal file
View file

@ -0,0 +1,24 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:obsidianmd/recommended',
],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-unused-vars': 'off',
'no-prototype-builtins': 'off',
'@typescript-eslint/ban-types': 'off'
},
env: {
node: true,
browser: true,
es6: true
}
};

View file

@ -2,7 +2,7 @@ import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
export class ClaudeService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
@ -13,15 +13,18 @@ export class ClaudeService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
if (!settings.claudeApiKey) {
new Notice('Claude API key is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Claude API key is not set, please add it in the Spaceforge settings');
return null;
}
if (!settings.claudeModel) {
new Notice('Claude Model is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Claude model is not set, please add it in the Spaceforge settings');
return null;
}
try {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Generating MCQs using Claude...');
// Determine the number of questions to generate
@ -38,7 +41,8 @@ export class ClaudeService implements IMCQGenerationService {
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Claude. Please try again.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate valid MCQs from Claude, please try again');
return null;
}
@ -47,8 +51,9 @@ export class ClaudeService implements IMCQGenerationService {
questions,
generatedAt: Date.now()
};
} catch (error) {
new Notice('Failed to generate MCQs with Claude. Please check console for details.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate MCQs with Claude, please check console for details');
return null;
}
}
@ -66,7 +71,7 @@ export class ClaudeService implements IMCQGenerationService {
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.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`;
}
if (difficulty === 'basic') {
if (difficulty === MCQDifficulty.Basic) {
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
@ -78,9 +83,9 @@ export class ClaudeService implements IMCQGenerationService {
const apiKey = settings.claudeApiKey;
const model = settings.claudeModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
const systemPrompt = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
try {
@ -124,12 +129,12 @@ export class ClaudeService implements IMCQGenerationService {
// Claude might not start with "1. ", so we adjust the split logic if needed.
// The current logic tries to split by number then newline, which might be robust enough.
let questionBlocks: string[] = response.split(/\n\d+\.\s+/).filter(block => block.trim().length > 0);
// If the first block doesn't start with a number, prepend "1. " to it if it's not empty
if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) {
// If the original response starts with a question directly (no "1. ")
// If the original response starts with a question directly (no "1. ")
if (!/^\d+\.\s+/.test(questionBlocks[0])) {
// Check if the first block is the start of the first question
// Check if the first block is the start of the first question
if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) {
// This means the entire response is treated as a single block without "1."
// We might need a different splitting strategy or assume the first block is the first question
@ -149,7 +154,7 @@ export class ClaudeService implements IMCQGenerationService {
}
}
}
// If initial split by "\n\d+." fails or gives few blocks, try splitting by "\d+." then handling newlines
// If initial split by "\n\d+." fails or gives few blocks, try splitting by "\d+." then handling newlines
if (questionBlocks.length === 0 || (questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1."))) {
const lines = response.split('\n');
let currentQuestionBlock = '';
@ -162,7 +167,7 @@ export class ClaudeService implements IMCQGenerationService {
currentQuestionBlock = line + '\n';
} else if (currentQuestionBlock.length > 0) { // only add to current block if it has started
currentQuestionBlock += line + '\n';
} else if (tempBlocks.length === 0 && line.trim().length > 0) {
} else if (tempBlocks.length === 0 && line.trim().length > 0) {
// Handle case where the first question doesn't start with "1." but is the first content
currentQuestionBlock = line + '\n';
}
@ -191,8 +196,8 @@ export class ClaudeService implements IMCQGenerationService {
const isCorrect = line.includes('[CORRECT]');
// More robustly remove common choice markers (A., A), 1., 1), -, *)
const cleanedLine = line.replace(/\[CORRECT\]/gi, '') // Case-insensitive removal
.replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, '')
.trim();
.replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, '')
.trim();
if (cleanedLine.length > 0) { // Only add non-empty choices
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
@ -209,20 +214,21 @@ export class ClaudeService implements IMCQGenerationService {
break;
}
}
// If still not found, default to the first choice as per original logic
// If still not found, default to the first choice as per original logic
if (correctAnswerIndex === -1) correctAnswerIndex = 0;
}
if (questionText && choices.length >= settings.mcqChoicesPerQuestion -1 && choices.length > 0) { // Allow slightly fewer choices if parsing is tricky
if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) { // Allow slightly fewer choices if parsing is tricky
questions.push({ question: questionText, choices, correctAnswerIndex });
} else if (questionText && choices.length >= 2) { // Absolute minimum of 2 choices
questions.push({ question: questionText, choices, correctAnswerIndex });
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
new Notice('Error parsing MCQ response from Claude. Please try again.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from Claude, please try again');
return [];
}
}

View file

@ -2,12 +2,9 @@ import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
// Define a simple type for Gemini API parts, as it expects an array of these.
interface GeminiPart {
text: string;
}
export class GeminiService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
@ -18,15 +15,18 @@ export class GeminiService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
if (!settings.geminiApiKey) {
new Notice('Gemini API key is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Gemini API key is not set, please add it in the Spaceforge settings');
return null;
}
if (!settings.geminiModel) {
new Notice('Gemini Model is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Gemini model is not set, please add it in the Spaceforge settings');
return null;
}
try {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Generating MCQs using Gemini...');
// Determine the number of questions to generate
@ -43,7 +43,8 @@ export class GeminiService implements IMCQGenerationService {
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Gemini. Please try again.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate valid MCQs from Gemini, please try again');
return null;
}
@ -52,8 +53,9 @@ export class GeminiService implements IMCQGenerationService {
questions,
generatedAt: Date.now()
};
} catch (error) {
new Notice('Failed to generate MCQs with Gemini. Please check console for details.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate MCQs with Gemini, please check console for details');
return null;
}
}
@ -67,8 +69,8 @@ export class GeminiService implements IMCQGenerationService {
let basePrompt = "";
// Gemini prefers direct instructions. System prompts are handled differently or not at all in simpler SDKs.
// For direct API calls, we include system-like instructions at the beginning of the user prompt.
const systemInstruction = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
const systemInstruction = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
if (promptType === 'basic') {
@ -76,7 +78,7 @@ export class GeminiService implements IMCQGenerationService {
} else {
basePrompt = `${systemInstruction}\n\nGenerate ${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.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`;
}
// Difficulty instructions are already prepended via systemInstruction.
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
@ -126,7 +128,7 @@ export class GeminiService implements IMCQGenerationService {
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
const questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
@ -161,9 +163,9 @@ export class GeminiService implements IMCQGenerationService {
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -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('✓') || lines[i+1].includes('✔️'))) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes('correct') || lines[i + 1].includes('✓') || lines[i + 1].includes('✔️'))) {
correctAnswerIndex = i;
break;
}
@ -176,8 +178,9 @@ export class GeminiService implements IMCQGenerationService {
}
}
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
new Notice('Error parsing MCQ response from Gemini. Please try again.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from Gemini, please try again');
return [];
}
}

View file

@ -2,7 +2,7 @@ import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
export class OllamaService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
@ -13,15 +13,18 @@ export class OllamaService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
if (!settings.ollamaApiUrl) {
new Notice('Ollama API URL is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Ollama API URL is not set, please add it in the Spaceforge settings');
return null;
}
if (!settings.ollamaModel) {
new Notice('Ollama Model is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Ollama model is not set, please add it in the Spaceforge settings');
return null;
}
try {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Generating MCQs using Ollama...');
// Determine the number of questions to generate
@ -38,7 +41,8 @@ export class OllamaService implements IMCQGenerationService {
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Ollama. Please try again.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate valid MCQs from Ollama, please try again');
return null;
}
@ -47,8 +51,9 @@ export class OllamaService implements IMCQGenerationService {
questions,
generatedAt: Date.now()
};
} catch (error) {
new Notice('Failed to generate MCQs with Ollama. Please check console for details.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate MCQs with Ollama, please check console for details');
return null;
}
}
@ -66,7 +71,7 @@ export class OllamaService implements IMCQGenerationService {
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.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`;
}
if (difficulty === 'basic') {
if (difficulty === MCQDifficulty.Basic) {
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
@ -78,9 +83,9 @@ export class OllamaService implements IMCQGenerationService {
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
const systemPrompt = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
try {
@ -120,7 +125,7 @@ export class OllamaService implements IMCQGenerationService {
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
const questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
@ -155,9 +160,9 @@ export class OllamaService implements IMCQGenerationService {
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -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('✓') || lines[i+1].includes('✔️'))) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes('correct') || lines[i + 1].includes('✓') || lines[i + 1].includes('✔️'))) {
correctAnswerIndex = i;
break;
}
@ -170,8 +175,9 @@ export class OllamaService implements IMCQGenerationService {
}
}
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
new Notice('Error parsing MCQ response from Ollama. Please try again.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from Ollama, please try again');
return [];
}
}

View file

@ -2,7 +2,7 @@ import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
export class OpenAIService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
@ -13,11 +13,13 @@ export class OpenAIService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
if (!settings.openaiApiKey) {
new Notice('OpenAI API key is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('OpenAI API key is not set, please add it in the Spaceforge settings');
return null;
}
if (!settings.openaiModel) {
new Notice('OpenAI Model is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('OpenAI model is not set, please add it in the Spaceforge settings');
return null;
}
@ -38,7 +40,7 @@ export class OpenAIService implements IMCQGenerationService {
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from OpenAI. Please try again.');
new Notice('Failed to generate valid MCQs from OpenAI, please try again');
return null;
}
@ -47,8 +49,8 @@ export class OpenAIService implements IMCQGenerationService {
questions,
generatedAt: Date.now()
};
} catch (error) {
new Notice('Failed to generate MCQs with OpenAI. Please check console for details.');
} catch {
new Notice('Failed to generate MCQs with OpenAI, please check console for details');
return null;
}
}
@ -66,7 +68,7 @@ export class OpenAIService implements IMCQGenerationService {
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.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`;
}
if (difficulty === 'basic') {
if (difficulty === MCQDifficulty.Basic) {
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
@ -78,9 +80,9 @@ export class OpenAIService implements IMCQGenerationService {
const apiKey = settings.openaiApiKey;
const model = settings.openaiModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
const systemPrompt = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
try {
@ -119,7 +121,7 @@ export class OpenAIService implements IMCQGenerationService {
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
const questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
@ -156,7 +158,7 @@ export class OpenAIService implements IMCQGenerationService {
if (correctAnswerIndex === -1) { // Fallback if [CORRECT] not found
for (let i = 0; i < choices.length; i++) {
if (lines[i+1] && (lines[i+1].toLowerCase().includes('correct') || lines[i+1].includes('✓') || lines[i+1].includes('✔️'))) {
if (lines[i + 1] && (lines[i + 1].toLowerCase().includes('correct') || lines[i + 1].includes('✓') || lines[i + 1].includes('✔️'))) {
correctAnswerIndex = i;
break;
}
@ -169,8 +171,9 @@ export class OpenAIService implements IMCQGenerationService {
}
}
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
new Notice('Error parsing MCQ response from OpenAI. Please try again.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from OpenAI, please try again');
return [];
}
}

View file

@ -1,8 +1,8 @@
import { Notice, requestUrl } from 'obsidian'; // Removed TFile as it wasn't used
import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
/**
* Service for generating MCQs using the OpenRouter API
@ -12,7 +12,7 @@ export class OpenRouterService implements IMCQGenerationService {
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Initialize OpenRouter service
*
@ -21,7 +21,7 @@ export class OpenRouterService implements IMCQGenerationService {
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Generate MCQs for a note
*
@ -33,12 +33,14 @@ export class OpenRouterService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
// Check if API key is set
if (!settings.openRouterApiKey) {
new Notice('OpenRouter API key is not set. Please add it in the settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('OpenRouter API key is not set, please add it in the settings');
return null;
}
try {
// Show loading notice
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Generating MCQs using OpenRouter...');
// Determine the number of questions to generate
@ -46,41 +48,43 @@ export class OpenRouterService implements IMCQGenerationService {
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
// Ensure at least 1 question, and use ceiling to round up slightly
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
// Optional: Add a reasonable upper limit if desired, e.g., Math.min(numQuestionsToGenerate, 15);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
}
// Generate prompt based on settings and calculated question count
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
// Make API request
const response = await this.makeApiRequest(prompt, settings);
// Parse response to extract MCQs, respecting the target number
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
// Ensure we have at least one question if requested
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from OpenRouter. Please try again.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate valid MCQs from OpenRouter, please try again');
return null;
}
// Create MCQ set
const mcqSet: MCQSet = {
notePath,
questions,
generatedAt: Date.now()
};
return mcqSet;
} catch (error) {
new Notice('Failed to generate MCQs with OpenRouter. Please check console for details.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate MCQs with OpenRouter, please check console for details');
return null;
}
}
/**
* Generate prompt for the AI
*
@ -91,14 +95,14 @@ export class OpenRouterService implements IMCQGenerationService {
*/
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
// Use the passed numQuestionsToGenerate instead of settings.mcqQuestionsPerNote directly
const questionCount = numQuestionsToGenerate;
const questionCount = numQuestionsToGenerate;
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
// Base prompt template according to prompt type
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 {
@ -112,18 +116,18 @@ For example:
D) Madrid
E) Rome`;
}
// Add difficulty-specific instructions
if (difficulty === 'basic') {
if (difficulty === MCQDifficulty.Basic) {
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
}
// Add note content
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
/**
* Make API request to OpenRouter
*
@ -135,13 +139,13 @@ For example:
const apiKey = settings.openRouterApiKey; // Use key from settings argument
const model = settings.openRouterModel; // Use model from settings argument
const difficulty = settings.mcqDifficulty;
try {
// Get the appropriate system prompt based on difficulty
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
const systemPrompt = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
const response = await requestUrl({
url: 'https://openrouter.ai/api/v1/chat/completions',
method: 'POST',
@ -154,13 +158,13 @@ For example:
body: JSON.stringify({
model: model,
messages: [
{
role: 'system',
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: prompt
{
role: 'user',
content: prompt
}
]
})
@ -182,7 +186,7 @@ For example:
throw error;
}
}
/**
* Parse the AI response to extract MCQs
*
@ -193,21 +197,21 @@ For example:
*/
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
// Log the raw response for debugging
// Check for common response formats
let questionBlocks: string[] = [];
// Method 1: Split by numbered questions (1., 2., etc.)
questionBlocks = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
// If we didn't find questions, try another method
if (questionBlocks.length === 0) {
// Method 2: Look for line breaks with numbered patterns
const lines = response.split('\n');
let currentQuestion = '';
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion) {
@ -218,65 +222,65 @@ For example:
currentQuestion += line + '\n';
}
}
if (currentQuestion) {
questionBlocks.push(currentQuestion);
}
}
for (const block of questionBlocks) {
// Extract the question text and choices
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) {
continue;
}
let questionText = lines[0].trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
// Debug the question
// Extract choices and identify the correct answer
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes('[CORRECT]');
// Remove the [CORRECT] marker and any leading identifiers (A), B., etc.)
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 no answer was marked as correct, try to check for other indicators
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
// Check for any other correct answer indicator
if (lines[i+1] && (
lines[i+1].toLowerCase().includes('correct') ||
lines[i+1].includes('✓') ||
lines[i+1].includes('✔️')
if (lines[i + 1] && (
lines[i + 1].toLowerCase().includes('correct') ||
lines[i + 1].includes('✓') ||
lines[i + 1].includes('✔️')
)) {
correctAnswerIndex = i;
break;
}
}
}
// If still no correct answer found, default to the first answer as a fallback
if (correctAnswerIndex === -1 && choices.length > 0) {
correctAnswerIndex = 0;
}
// Only add if we found a valid question with choices
if (questionText && choices.length >= 2) {
questions.push({
@ -285,15 +289,17 @@ For example:
correctAnswerIndex
});
} else {
// no-op
}
}
// Log the parsed questions
// Limit to the target number of questions calculated earlier
// Use numQuestionsToGenerate instead of settings.mcqQuestionsPerNote
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
new Notice('Error parsing MCQ response from OpenRouter. Please try again.');
return questions.slice(0, numQuestionsToGenerate);
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from OpenRouter, please try again');
return [];
}
}

View file

@ -2,7 +2,7 @@ import { Notice, requestUrl } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; // Import MCQQuestionAmountMode
export class TogetherService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
@ -13,15 +13,18 @@ export class TogetherService implements IMCQGenerationService {
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
if (!settings.togetherApiKey) {
new Notice('Together AI API key is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Together AI API key is not set, please add it in the Spaceforge settings');
return null;
}
if (!settings.togetherModel) {
new Notice('Together AI Model is not set. Please add it in the Spaceforge settings.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Together AI model is not set, please add it in the Spaceforge settings');
return null;
}
try {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Generating MCQs using Together AI...');
// Determine the number of questions to generate
@ -38,7 +41,8 @@ export class TogetherService implements IMCQGenerationService {
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Together AI. Please try again.');
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate valid MCQs from Together AI, please try again');
return null;
}
@ -47,8 +51,9 @@ export class TogetherService implements IMCQGenerationService {
questions,
generatedAt: Date.now()
};
} catch (error) {
new Notice('Failed to generate MCQs with Together AI. Please check console for details.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Failed to generate MCQs with Together AI, please check console for details');
return null;
}
}
@ -66,7 +71,7 @@ export class TogetherService implements IMCQGenerationService {
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.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`;
}
if (difficulty === 'basic') {
if (difficulty === MCQDifficulty.Basic) {
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
} else {
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
@ -78,9 +83,9 @@ export class TogetherService implements IMCQGenerationService {
const apiKey = settings.togetherApiKey;
const model = settings.togetherModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
const systemPrompt = difficulty === MCQDifficulty.Basic
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
try {
@ -135,7 +140,7 @@ export class TogetherService implements IMCQGenerationService {
}
}
}
if (questionBlocks.length === 0 || (questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1."))) {
const lines = response.split('\n');
let currentQuestionBlock = '';
@ -173,8 +178,8 @@ export class TogetherService implements IMCQGenerationService {
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();
.replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, '')
.trim();
if (cleanedLine.length > 0) {
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
@ -192,15 +197,16 @@ export class TogetherService implements IMCQGenerationService {
if (correctAnswerIndex === -1) correctAnswerIndex = 0;
}
if (questionText && choices.length >= settings.mcqChoicesPerQuestion -1 && choices.length > 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 });
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
new Notice('Error parsing MCQ response from Together AI. Please try again.');
} catch {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice('Error parsing MCQ response from Together AI, please try again');
return [];
}
}

View file

@ -1,5 +1,4 @@
import { ReviewResponse, ReviewSchedule } from '../models/review-schedule';
import { MCQSet } from '../models/mcq';
/**
* Interface for the core review controller
@ -10,27 +9,27 @@ export interface IReviewController {
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
updateTodayNotes(preserveCurrentIndex?: boolean): Promise<void>;
updateTodayNotes(preserveCurrentIndex?: boolean): void;
/**
* Review the current note
*/
reviewCurrentNote(): Promise<void>;
/**
* Review a note
*
* @param path Path to the note file
*/
reviewNote(path: string): Promise<void>;
/**
* Show the review modal for a note
*
* @param path Path to the note file
*/
showReviewModal(path: string): void;
/**
* Process a review response
*
@ -38,7 +37,7 @@ export interface IReviewController {
* @param response User's response during review
*/
processReviewResponse(path: string, response: ReviewResponse): Promise<void>;
/**
* Skip the review of a note and reschedule for tomorrow with penalty
*
@ -53,14 +52,14 @@ export interface IReviewController {
* @param days Number of days to postpone (default: 1)
*/
postponeNote(path: string, days?: number): Promise<void>;
/**
* Handle a note being postponed, updating navigation state
*
* @param path Path to the postponed note
*/
handleNotePostponed(path: string): Promise<void>;
/**
* Get the currently loaded notes due for review
*/
@ -81,7 +80,7 @@ export interface IReviewController {
* Sets an override for the current review date.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
setReviewDateOverride(date: number | null): Promise<void>;
setReviewDateOverride(date: number | null): void;
/**
* Gets the current review date override.
@ -98,29 +97,29 @@ export interface IReviewNavigationController {
* Navigate to the next note following the current order
*/
navigateToNextNote(): Promise<void>;
/**
* Navigate to the previous note in the current order
*/
navigateToPreviousNote(): Promise<void>;
/**
* Navigate to the next note without recording a review
*/
navigateToNextNoteWithoutRating(): Promise<void>;
/**
* Navigate to the current note without showing review modal
*/
navigateToCurrentNoteWithoutModal(): Promise<void>;
/**
* Open a note without showing the review modal
*
* @param path Path to the note file
*/
openNoteWithoutReview(path: string): Promise<void>;
/**
* Swap two notes in the traversal order
*
@ -138,27 +137,27 @@ export interface IReviewBatchController {
* Start reviewing all of today's notes
*/
reviewAllTodaysNotes(): Promise<void>;
/**
* Review a specific set of notes
*
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing
*/
reviewNotes(paths: string[], useMCQ?: boolean): Promise<void>;
reviewNotes(paths: string[], useMCQ?: boolean): void;
/**
* Review all notes with MCQs in a batch
*
* @param useMCQ Whether to use MCQs for testing
*/
reviewAllNotesWithMCQ(useMCQ?: boolean): Promise<void>;
reviewAllNotesWithMCQ(useMCQ?: boolean): void;
/**
* Regenerate MCQs for all notes due today
*/
regenerateAllMCQs(): Promise<void>;
/**
* Postpone a specific set of notes
*
@ -166,7 +165,7 @@ export interface IReviewBatchController {
* @param days Number of days to postpone
*/
postponeNotes(paths: string[], days?: number): Promise<void>;
/**
* Remove a specific set of notes from the review schedule
*

View file

@ -1,9 +1,7 @@
import { Notice } from 'obsidian';
import SpaceforgePlugin from '../main';
import { IReviewBatchController } from './interfaces';
import { ReviewSchedule } from '../models/review-schedule';
import { BatchReviewModal } from '../ui/batch-review-modal';
import { ConsolidatedMCQModal } from '../ui/consolidated-mcq-modal';
/**
* Controller for batch reviewing multiple notes
@ -31,7 +29,7 @@ export class ReviewBatchController implements IReviewBatchController {
if (!reviewController) return;
// Update notes while preserving existing order
await reviewController.updateTodayNotes(true);
void reviewController.updateTodayNotes(true);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
@ -44,8 +42,8 @@ export class ReviewBatchController implements IReviewBatchController {
// We need to update the core controller's state
if (reviewController) {
// Force update to ensure we start at the first note
await reviewController.updateTodayNotes(false);
void reviewController.updateTodayNotes(false);
const note = todayNotes[0];
// Start the review with the selected note
@ -61,7 +59,7 @@ export class ReviewBatchController implements IReviewBatchController {
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths: string[], useMCQ: boolean = false): Promise<void> {
reviewNotes(paths: string[], useMCQ = false): void {
if (paths.length === 0) {
new Notice("No notes selected for review.");
return;
@ -76,7 +74,7 @@ export class ReviewBatchController implements IReviewBatchController {
}
if (useMCQ) {
new Notice(`Preparing MCQs for ${notesToReview.length} selected notes. This may take a moment...`);
new Notice(`Preparing MCQs for ${notesToReview.length} selected notes.This may take a moment...`);
} else {
new Notice(`Starting review of ${notesToReview.length} selected notes.`);
}
@ -91,13 +89,13 @@ export class ReviewBatchController implements IReviewBatchController {
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ: boolean = true): Promise<void> {
reviewAllNotesWithMCQ(useMCQ = true): void {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
// Update today's notes first, preserving custom order if it exists
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
void reviewController.updateTodayNotes(hasCustomOrder);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
@ -127,7 +125,7 @@ export class ReviewBatchController implements IReviewBatchController {
// Update notes respecting custom order if it exists
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
void reviewController.updateTodayNotes(hasCustomOrder);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
@ -136,7 +134,8 @@ export class ReviewBatchController implements IReviewBatchController {
}
if (!this.plugin.mcqController) {
new Notice("MCQ controller not initialized. Please check MCQ settings.");
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice("MCQ controller not initialized, please check MCQ settings");
return;
}
@ -162,7 +161,7 @@ export class ReviewBatchController implements IReviewBatchController {
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths: string[], days: number = 1): Promise<void> {
async postponeNotes(paths: string[], days = 1): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
@ -180,10 +179,10 @@ export class ReviewBatchController implements IReviewBatchController {
await this.plugin.savePluginData(); // Add save call after loop
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Update today's notes after postponing
await reviewController.updateTodayNotes(true);
void reviewController.updateTodayNotes(true);
new Notice(`Postponed ${paths.length} notes by ${days} day(s).`);
}
@ -216,9 +215,9 @@ export class ReviewBatchController implements IReviewBatchController {
await this.plugin.savePluginData(); // Save once after all operations
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Update today's notes after advancing
await reviewController.updateTodayNotes(true);
void reviewController.updateTodayNotes(true);
new Notice(`Advanced ${advancedCount} note(s).`);
} else {
new Notice("No eligible notes were advanced.");
@ -248,10 +247,10 @@ export class ReviewBatchController implements IReviewBatchController {
await this.plugin.savePluginData(); // Add save call after loop
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Update today's notes after removing
await reviewController.updateTodayNotes(true);
void reviewController.updateTodayNotes(true);
new Notice(`Removed ${paths.length} notes from review schedule.`);
}

View file

@ -1,9 +1,8 @@
import { Modal, Notice, TFile } from 'obsidian';
import { Notice, TFile } from 'obsidian';
import SpaceforgePlugin from '../main';
import { ReviewResponse, ReviewSchedule, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import { IReviewController } from './interfaces';
import { ReviewModal } from '../ui/review-modal';
import { getNextFileInSession, isSessionComplete } from '../models/review-session';
/**
* Core controller that handles the review process
@ -22,7 +21,7 @@ export class ReviewControllerCore implements IReviewController {
/**
* Current index in today's notes
*/
private currentNoteIndex: number = 0;
private currentNoteIndex = 0;
/**
* Cache of linked notes to improve performance
@ -61,9 +60,9 @@ export class ReviewControllerCore implements IReviewController {
* Sets an override for the current review date.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
public async setReviewDateOverride(date: number | null): Promise<void> {
public setReviewDateOverride(date: number | null): void {
this.currentReviewDateOverride = date;
await this.updateTodayNotes(); // Ensure notes are updated when the override changes
this.updateTodayNotes(); // Ensure notes are updated when the override changes
}
/**
@ -116,7 +115,7 @@ export class ReviewControllerCore implements IReviewController {
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex: boolean = false): Promise<void> {
updateTodayNotes(preserveCurrentIndex = false): void {
// Store current note path if we want to preserve the index
let currentNotePath: string | null = null;
if (preserveCurrentIndex && this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) {
@ -128,10 +127,6 @@ export class ReviewControllerCore implements IReviewController {
this.traversalOrder = [];
this.traversalPositions.clear();
// Save the existing traversal order and positions
const originalTraversalOrder = [...this.traversalOrder];
const originalPositions = new Map(this.traversalPositions);
// Get the latest due notes from storage, already sorted by custom order (if available)
// by getDueNotesWithCustomOrder, using the effective review date.
const effectiveDate = this.getEffectiveReviewDate();
@ -205,7 +200,7 @@ export class ReviewControllerCore implements IReviewController {
*/
async reviewCurrentNote(): Promise<void> {
if (this.todayNotes.length === 0) {
await this.updateTodayNotes();
this.updateTodayNotes();
if (this.todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
@ -241,20 +236,16 @@ export class ReviewControllerCore implements IReviewController {
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path: string, days: number = 1): Promise<void> {
async postponeNote(path: string, days = 1): Promise<void> {
// Store the current note path to potentially adjust currentNoteIndex later
// const currentPath = this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length // Not strictly needed here as handleNotePostponed re-evaluates
// ? this.todayNotes[this.currentNoteIndex].path
// : null;
await this.plugin.dataStorage.reviewScheduleService.postponeNote(path, days); // Corrected to call service via dataStorage
await this.plugin.savePluginData();
this.plugin.dataStorage.reviewScheduleService.postponeNote(path, days); // Corrected to call service via dataStorage
await this.plugin.savePluginData();
// Explicitly update navigation state in controller to ensure UI consistency
await this.handleNotePostponed(path);
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Notice is handled by the service for postponeNote
}
@ -264,13 +255,13 @@ export class ReviewControllerCore implements IReviewController {
* @param path Path to the note file
*/
async advanceNote(path: string): Promise<void> {
const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
const advanced = this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
if (advanced) {
await this.plugin.savePluginData();
await this.handleNoteAdvanced(path); // New handler for advancing
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
new Notice(`Note advanced.`); // Controller handles notice for advance
} else {
new Notice(`Note is not eligible to be advanced.`);
@ -283,14 +274,14 @@ export class ReviewControllerCore implements IReviewController {
*
* @param path Path to the advanced note
*/
async handleNoteAdvanced(path: string): Promise<void> {
handleNoteAdvanced(_path: string): void {
// Re-fetch and re-sort today's notes, preserving current selection if possible.
// updateTodayNotes(true) should correctly place the advanced note
// if it's now due, or remove it if it was advanced from future to still future.
// It also updates traversalOrder and currentNoteIndex.
await this.updateTodayNotes(true);
this.updateTodayNotes(true);
}
/**
* Handle a note being postponed, updating navigation state
*
@ -370,17 +361,17 @@ export class ReviewControllerCore implements IReviewController {
*/
async skipReview(path: string): Promise<void> {
const effectiveDate = this.getEffectiveReviewDate();
await this.plugin.dataStorage.reviewScheduleService.skipNote(path, ReviewResponse.CorrectWithDifficulty, effectiveDate); // Pass effectiveDate
this.plugin.dataStorage.reviewScheduleService.skipNote(path, ReviewResponse.CorrectWithDifficulty, effectiveDate); // Pass effectiveDate
await this.plugin.savePluginData(); // Add save call
// Show notification with more informative message
new Notice("Review postponed to tomorrow. Note will be easier to recover with a small penalty applied.");
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Update today's notes after skipping the review
await this.updateTodayNotes(true);
this.updateTodayNotes(true);
// Continue to the next note and show review modal
if (this.todayNotes.length > 0) {
@ -419,15 +410,15 @@ export class ReviewControllerCore implements IReviewController {
async processReviewResponse(path: string, response: ReviewResponse | FsrsRating): Promise<void> {
const effectiveDate = this.getEffectiveReviewDate();
// Record a normal review (not skipped) with the user's quality rating
const wasRecorded = await this.plugin.dataStorage.reviewScheduleService.recordReview(path, response, false, effectiveDate);
const wasRecorded = this.plugin.dataStorage.reviewScheduleService.recordReview(path, response, false, effectiveDate);
if (!wasRecorded) {
// Note was not due, this is just a preview
new Notice("Note previewed, not recorded");
return;
}
// await this.plugin.savePluginData(); // Moved save call to the end of the method
// Check for MCQ regeneration based on rating
const schedule = this.plugin.dataStorage.reviewScheduleService.schedules[path];
@ -454,11 +445,10 @@ export class ReviewControllerCore implements IReviewController {
// Show notification based on response
let responseText: string;
// const schedule = this.plugin.dataStorage.reviewScheduleService.schedules[path]; // Already defined above
if (schedule && schedule.schedulingAlgorithm === 'fsrs') {
// FSRS response text
switch(response as FsrsRating) { // Cast because we are in FSRS block
switch (response as FsrsRating) { // Cast because we are in FSRS block
case FsrsRating.Again: responseText = "Again (1)"; break;
case FsrsRating.Hard: responseText = "Hard (2)"; break;
case FsrsRating.Good: responseText = "Good (3)"; break;
@ -467,7 +457,7 @@ export class ReviewControllerCore implements IReviewController {
}
} else {
// SM-2 response text
switch(response as ReviewResponse) { // Cast because we are in SM-2 block
switch (response as ReviewResponse) { // Cast because we are in SM-2 block
case ReviewResponse.CompleteBlackout: responseText = "Complete Blackout (0)"; break;
case ReviewResponse.IncorrectResponse: responseText = "Incorrect Response (1)"; break;
case ReviewResponse.IncorrectButFamiliar: responseText = "Incorrect but Familiar (2)"; break;
@ -480,10 +470,10 @@ export class ReviewControllerCore implements IReviewController {
new Notice(`Note review recorded: ${responseText}`);
// Refresh the sidebar view if available
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
// Update today's notes after recording the review (preserve the index since we're in the middle of navigation)
await this.updateTodayNotes(true);
this.updateTodayNotes(true);
// Check if we're in a hierarchical review session
const activeSession = this.plugin.dataStorage.getActiveSession();
@ -491,7 +481,6 @@ export class ReviewControllerCore implements IReviewController {
if (activeSession) {
// Advance to the next file in the session
await this.plugin.dataStorage.advanceActiveSession();
// await this.plugin.savePluginData(); // Moved save call to the end of the method
// Get the next file to review
const nextFilePath = this.plugin.dataStorage.getNextSessionFile();
@ -506,11 +495,6 @@ export class ReviewControllerCore implements IReviewController {
}
// If not in a session, automatically go to the next note and show review modal
else if (this.todayNotes.length > 0) {
// Log the current state for debugging
// Simply move to the next note in today's notes list (which maintains user's custom order)
this.currentNoteIndex = (this.currentNoteIndex + 1) % this.todayNotes.length;
// Check if we've reviewed all notes (in case we've come full circle)
const currentPath = this.todayNotes[this.currentNoteIndex].path;
if (currentPath === path) {
@ -518,8 +502,6 @@ export class ReviewControllerCore implements IReviewController {
return;
}
// Log the new state after determining next note
// After finding the next note, show the review modal
if (this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) {
const nextNotePath = this.todayNotes[this.currentNoteIndex].path;
@ -535,7 +517,7 @@ export class ReviewControllerCore implements IReviewController {
} else {
new Notice("All caught up! No more notes due for review.");
}
// Save all accumulated plugin data once at the end
await this.plugin.savePluginData();
}

View file

@ -2,7 +2,7 @@ import { Notice, TFile } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQModal } from '../ui/mcq-modal';
import { ConsolidatedMCQModal } from '../ui/consolidated-mcq-modal'; // Import ConsolidatedMCQModal
import { MCQSet, MCQSession, MCQQuestion } from '../models/mcq'; // Corrected import
import { MCQSet, MCQSession } from '../models/mcq';
import { ReviewSchedule, ReviewResponse, FsrsRating } from '../models/review-schedule'; // Import rating enums and ReviewSchedule
import { MCQService } from '../services/mcq-service';
import { IMCQGenerationService } from '../api/mcq-generation-service';
@ -63,17 +63,17 @@ export class MCQController {
}
async startMCQReview(
notePath: string,
notePath: string,
// Keep the external callback signature for now, but we'll use an internal one for our logic
externalOnCompleteCallback?: (path: string, success: boolean) => void
): Promise<void> {
if (!this.plugin.settings.enableMCQ) {
new Notice('MCQ feature is disabled in settings.');
new Notice('MCQ feature is disabled in settings.'); // eslint-disable-line obsidianmd/ui/sentence-case
if (externalOnCompleteCallback) externalOnCompleteCallback(notePath, false);
return;
}
if (!this.mcqGenerationService) {
new Notice('MCQ generation service is not available. Check API provider settings.');
new Notice('MCQ generation service is not available. Check API provider settings.'); // eslint-disable-line obsidianmd/ui/sentence-case
return;
}
@ -82,14 +82,14 @@ export class MCQController {
if (mcqSet && mcqSet.needsQuestionRegeneration) {
new Notice('Questions for this note are flagged for regeneration. Generating new set...');
mcqSet = await this.generateMCQs(notePath, true);
mcqSet = await this.generateMCQs(notePath, true);
if (mcqSet) {
mcqSet.needsQuestionRegeneration = false;
this.mcqService.saveMCQSet(mcqSet);
mcqSet.needsQuestionRegeneration = false;
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
} else {
new Notice('Failed to regenerate MCQs. Using existing set if available.');
mcqSet = this.mcqService.getMCQSetForNote(notePath);
mcqSet = this.mcqService.getMCQSetForNote(notePath);
}
}
@ -102,49 +102,40 @@ export class MCQController {
}
}
const session: MCQSession = {
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)
};
// Define the internal callback for MCQModal that includes the score
const internalOnComplete = async (path: string, score: number, completed: boolean) => {
if (completed) {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (schedule) {
let rating: ReviewResponse | FsrsRating;
if (schedule.schedulingAlgorithm === 'fsrs') {
rating = this.mapScoreToFsrsRating(score);
new Notice(`MCQ complete for FSRS card. Score: ${(score * 100).toFixed(0)}%. Rating: ${FsrsRating[rating]}(${rating as number}).`);
const internalOnComplete = (path: string, score: number, completed: boolean) => {
void (async () => {
if (completed) {
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (schedule) {
let rating: ReviewResponse | FsrsRating;
if (schedule.schedulingAlgorithm === 'fsrs') {
rating = this.mapScoreToFsrsRating(score);
new Notice(`MCQ complete for FSRS card.Score: ${(score * 100).toFixed(0)}%.Rating: ${FsrsRating[rating]} (${rating as number}).`);
} else {
rating = this.mapScoreToSm2Response(score);
new Notice(`MCQ complete for SM - 2 card.Score: ${(score * 100).toFixed(0)}%.Rating: ${ReviewResponse[rating]} (${rating as number}).`);
}
// Process the review using the determined rating
await this.plugin.reviewController.processReviewResponse(path, rating);
} else {
rating = this.mapScoreToSm2Response(score);
new Notice(`MCQ complete for SM-2 card. Score: ${(score * 100).toFixed(0)}%. Rating: ${ReviewResponse[rating]}(${rating as number}).`);
new Notice(`MCQ complete.Score: ${(score * 100).toFixed(0)}%.Could not find schedule to update review status.`);
}
// Process the review using the determined rating
await this.plugin.reviewController.processReviewResponse(path, rating);
} else {
new Notice(`MCQ complete. Score: ${(score * 100).toFixed(0)}%. Could not find schedule to update review status.`);
new Notice(`MCQ session for ${path} was not fully completed.Score(partial): ${(score * 100).toFixed(0)}%.Review not recorded.`);
}
} else {
new Notice(`MCQ session for ${path} was not fully completed. Score (partial): ${(score * 100).toFixed(0)}%. Review not recorded.`);
}
// Call the original external callback if it was provided
if (externalOnCompleteCallback) {
externalOnCompleteCallback(path, completed && score >= 0.7);
}
// Call the original external callback if it was provided
if (externalOnCompleteCallback) {
externalOnCompleteCallback(path, completed && score >= 0.7);
}
})();
};
// Pass the new internal callback to the modal constructor
new MCQModal(this.plugin, notePath, mcqSet, internalOnComplete).open();
} catch (error) {
new Notice('Error starting MCQ review. Please check console for details.');
new MCQModal(this.plugin, notePath, mcqSet, internalOnComplete).open();
} catch {
new Notice('Error starting MCQ review. Please check console for details.'); // eslint-disable-line obsidianmd/ui/sentence-case
if (externalOnCompleteCallback) externalOnCompleteCallback(notePath, false);
}
}
@ -158,7 +149,7 @@ export class MCQController {
*/
async generateMCQs(notePath: string, forceRegeneration = false): Promise<MCQSet | null> {
if (!this.plugin.settings.enableMCQ || !this.mcqGenerationService) {
new Notice('MCQ feature is disabled or the generation service is not available. Check API provider settings.');
new Notice('MCQ feature is disabled or the generation service is not available. Check API provider settings.'); // eslint-disable-line obsidianmd/ui/sentence-case
return null;
}
@ -172,7 +163,7 @@ export class MCQController {
}
}
}
const file = this.plugin.app.vault.getAbstractFileByPath(notePath);
if (!(file instanceof TFile)) {
new Notice('Cannot generate MCQs: file not found');
@ -184,7 +175,7 @@ export class MCQController {
if (mcqSet) {
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
await this.plugin.savePluginData();
new Notice('MCQs generated and saved successfully.');
return mcqSet;
} else {
@ -211,11 +202,11 @@ export class MCQController {
*/
async startConsolidatedMCQReviewForSelectedDate(): Promise<void> {
if (!this.plugin.settings.enableMCQ) {
new Notice('MCQ feature is disabled in settings.');
new Notice('MCQ feature is disabled in settings.'); // eslint-disable-line obsidianmd/ui/sentence-case
return;
}
if (!this.mcqGenerationService) {
new Notice('MCQ generation service is not available. Check API provider settings.');
new Notice('MCQ generation service is not available. Check API provider settings.'); // eslint-disable-line obsidianmd/ui/sentence-case
return;
}
@ -245,19 +236,19 @@ export class MCQController {
this.mcqService.saveMCQSet(mcqSet);
// Overall plugin data save will happen once after the loop
} else {
new Notice(`Failed to regenerate MCQs for ${notePath}. Using existing set if available (might be outdated or empty).`);
new Notice(`Failed to regenerate MCQs for ${notePath}.Using existing set if available(might be outdated or empty).`);
// mcqSet remains the old one, or null if it didn't exist.
}
}
// If no MCQ set exists after potential regeneration, or if it's empty, try to generate one.
if (!mcqSet || mcqSet.questions.length === 0) {
new Notice(`No MCQs found or set is empty for ${notePath}. Attempting to generate new set...`);
new Notice(`No MCQs found or set is empty for ${notePath}.Attempting to generate new set...`);
const newMcqSet = await this.generateMCQs(notePath, false); // forceRegeneration = false (respects cache if fresh)
if (newMcqSet) {
mcqSet = newMcqSet;
} else {
new Notice(`Failed to generate MCQs for ${notePath}. This note will be skipped in MCQ review.`);
new Notice(`Failed to generate MCQs for ${notePath}.This note will be skipped in MCQ review.`);
}
}
@ -271,44 +262,43 @@ export class MCQController {
notesWithMCQsCount++;
}
}
if (mcqSetsForReview.length === 0) {
new Notice('No MCQs available or generated for the due notes.');
return;
}
await this.plugin.savePluginData(); // Save any changes from MCQ generation
new Notice(`Starting consolidated MCQ review for ${notesWithMCQsCount} note(s) with ${mcqSetsForReview.reduce((sum, s) => sum + s.mcqSet.questions.length, 0)} questions.`);
const onConsolidatedComplete = async (
const onConsolidatedComplete = (
results: Array<{ path: string; success: boolean; response: ReviewResponse; score?: number }>
) => {
let reviewsProcessed = 0;
for (const result of results) {
const schedule = this.plugin.reviewScheduleService.schedules[result.path];
if (schedule && typeof result.score === 'number') {
let rating: ReviewResponse | FsrsRating;
if (schedule.schedulingAlgorithm === 'fsrs') {
rating = this.mapScoreToFsrsRating(result.score);
new Notice(`MCQ for ${result.path} (FSRS) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${FsrsRating[rating]}(${rating as number})`);
} else { // SM-2
rating = this.mapScoreToSm2Response(result.score);
new Notice(`MCQ for ${result.path} (SM-2) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${ReviewResponse[rating]}(${rating as number})`);
void (async () => {
let reviewsProcessed = 0;
for (const result of results) {
const schedule = this.plugin.reviewScheduleService.schedules[result.path];
if (schedule && typeof result.score === 'number') {
let rating: ReviewResponse | FsrsRating;
if (schedule.schedulingAlgorithm === 'fsrs') {
rating = this.mapScoreToFsrsRating(result.score);
new Notice(`MCQ for ${result.path}(FSRS) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${FsrsRating[rating]} (${rating as number})`);
} else { // SM-2
rating = this.mapScoreToSm2Response(result.score);
new Notice(`MCQ for ${result.path}(SM - 2) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${ReviewResponse[rating]} (${rating as number})`);
}
await this.plugin.reviewController.processReviewResponse(result.path, rating);
reviewsProcessed++;
}
await this.plugin.reviewController.processReviewResponse(result.path, rating);
reviewsProcessed++;
} else if (schedule) {
// If score is undefined, it might mean the note had no questions or wasn't processed.
// We could use the 'response' from ConsolidatedMCQModal if it's meaningful without a score.
// For now, we only process if a score is present.
}
}
if (reviewsProcessed > 0) {
new Notice(`${reviewsProcessed} note review(s) updated based on consolidated MCQ session.`);
} else {
new Notice("No note reviews were updated from the MCQ session.");
}
if (reviewsProcessed > 0) {
new Notice(`${reviewsProcessed} note review(s) updated based on consolidated MCQ session.`);
} else {
// eslint-disable-next-line obsidianmd/ui/sentence-case
new Notice("No note reviews were updated from the MCQ session");
}
})();
};
new ConsolidatedMCQModal(this.plugin, mcqSetsForReview, onConsolidatedComplete).open();

View file

@ -1,14 +1,10 @@
import { Notice } from 'obsidian';
import SpaceforgePlugin from '../main';
import { ReviewControllerCore } from './review-controller-core';
import { ReviewNavigationController } from './review-navigation-controller';
import { ReviewSessionController } from './review-session-controller';
import { ReviewBatchController } from './review-batch-controller';
import { MCQController } from './review-controller-mcq'; // Import MCQController
import { IReviewController, IReviewNavigationController, IReviewBatchController, IReviewSessionController } from './interfaces';
import { ReviewResponse, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import { LinkAnalyzer } from '../utils/link-analyzer';
import { DateUtils } from '../utils/dates';
import { IReviewController } from './interfaces';
import { ReviewResponse, FsrsRating } from '../models/review-schedule';
import { MCQService } from '../services/mcq-service'; // Import MCQService
/**
@ -53,11 +49,11 @@ export class ReviewController implements IReviewController {
this.navigationController = new ReviewNavigationController(plugin); // Initialize navigation controller
// Pass the generation service if available
if (plugin.mcqGenerationService) {
this.mcqController = new MCQController(plugin, mcqService, plugin.mcqGenerationService);
this.mcqController = new MCQController(plugin, mcqService, plugin.mcqGenerationService);
} else {
// Handle case where service might not be initialized (e.g., MCQ disabled)
// Depending on usage, might need null checks later or ensure it's always initialized when enabled.
// this.mcqController = undefined; // Or handle appropriately
// Handle case where service might not be initialized (e.g., MCQ disabled)
// Depending on usage, might need null checks later or ensure it's always initialized when enabled.
// this.mcqController = undefined; // Or handle appropriately
}
this.batchController = new ReviewBatchController(plugin); // Initialize batch controller
}
@ -68,8 +64,8 @@ export class ReviewController implements IReviewController {
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex: boolean = false): Promise<void> {
await this.coreController.updateTodayNotes(preserveCurrentIndex);
updateTodayNotes(preserveCurrentIndex = false): void {
this.coreController.updateTodayNotes(preserveCurrentIndex);
}
/**
@ -123,7 +119,7 @@ export class ReviewController implements IReviewController {
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path: string, days: number = 1): Promise<void> {
async postponeNote(path: string, days = 1): Promise<void> {
await this.coreController.postponeNote(path, days);
}
@ -183,8 +179,8 @@ export class ReviewController implements IReviewController {
* Delegates to core controller.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
public async setReviewDateOverride(date: number | null): Promise<void> {
await this.coreController.setReviewDateOverride(date);
public setReviewDateOverride(date: number | null): void {
this.coreController.setReviewDateOverride(date);
}
/**
@ -251,8 +247,8 @@ export class ReviewController implements IReviewController {
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths: string[], useMCQ: boolean = false): Promise<void> {
await this.batchController.reviewNotes(paths, useMCQ);
reviewNotes(paths: string[], useMCQ = false): void {
this.batchController.reviewNotes(paths, useMCQ);
}
/**
@ -262,7 +258,7 @@ export class ReviewController implements IReviewController {
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths: string[], days: number = 1): Promise<void> {
async postponeNotes(paths: string[], days = 1): Promise<void> {
await this.batchController.postponeNotes(paths, days);
}
@ -313,7 +309,7 @@ export class ReviewController implements IReviewController {
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ: boolean = true): Promise<void> {
await this.batchController.reviewAllNotesWithMCQ(useMCQ);
reviewAllNotesWithMCQ(useMCQ = true): void {
this.batchController.reviewAllNotesWithMCQ(useMCQ);
}
}

View file

@ -1,8 +1,6 @@
import { Notice, TFile } from 'obsidian';
import SpaceforgePlugin from '../main';
import { IReviewNavigationController } from './interfaces';
import { LinkAnalyzer } from '../utils/link-analyzer';
import { DateUtils } from '../utils/dates';
/**
* Controller for navigating between notes in review
@ -33,7 +31,7 @@ export class ReviewNavigationController implements IReviewNavigationController {
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
await reviewController.updateTodayNotes();
void reviewController.updateTodayNotes();
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
@ -52,11 +50,11 @@ export class ReviewNavigationController implements IReviewNavigationController {
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
// Force update notes regardless of custom order
await reviewController.updateTodayNotes(false);
void reviewController.updateTodayNotes(false);
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
@ -86,7 +84,7 @@ export class ReviewNavigationController implements IReviewNavigationController {
const currentFolder = currentFile.parent ? currentFile.parent.path : null;
const nextFolder = nextFile.parent ? nextFile.parent.path : null;
if (this.plugin.sessionController &&
if (this.plugin.sessionController &&
this.plugin.sessionController.getDueLinkedNotes(todayNotes[currentNoteIndex].path).includes(nextPath)) {
messageType = "linked note";
} else if (currentFolder === nextFolder) {
@ -143,12 +141,12 @@ export class ReviewNavigationController implements IReviewNavigationController {
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
// Check for custom order when updating notes
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
void reviewController.updateTodayNotes(hasCustomOrder);
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
@ -211,7 +209,6 @@ export class ReviewNavigationController implements IReviewNavigationController {
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
// Find the indices of these notes in todayNotes
const index1 = todayNotes.findIndex((n) => n.path === path1);
@ -228,11 +225,11 @@ export class ReviewNavigationController implements IReviewNavigationController {
// Update the custom note order in persistent storage
const newOrder = newTodayNotes.map(note => note.path);
await this.plugin.reviewScheduleService.updateCustomNoteOrder(newOrder);
this.plugin.reviewScheduleService.updateCustomNoteOrder(newOrder);
await this.plugin.savePluginData(); // Add save call
// Force refresh the core controller state
await reviewController.updateTodayNotes(true);
void reviewController.updateTodayNotes(true);
// Debug log
}

View file

@ -1,4 +1,3 @@
import { Notice } from 'obsidian';
import SpaceforgePlugin from '../main';
import { IReviewSessionController } from './interfaces';
import { LinkAnalyzer } from '../utils/link-analyzer';
@ -42,7 +41,7 @@ export class ReviewSessionController implements IReviewSessionController {
// If we don't have links cached, try to analyze them
if (links.length === 0) {
(async () => {
void (async () => {
const newLinks = await this.analyzeNoteLinks(notePath);
if (newLinks.length > 0) {
this.linkedNoteCache.set(notePath, newLinks);
@ -74,9 +73,9 @@ export class ReviewSessionController implements IReviewSessionController {
// Store in cache for future use
this.linkedNoteCache.set(notePath, links);
return links;
} catch (error) {
} catch {
return [];
}
}

View file

@ -1,11 +1,9 @@
import { Notice, TFile, TFolder } from 'obsidian';
import { ReviewHistoryItem, ReviewResponse, ReviewSchedule, toSM2Quality } from './models/review-schedule';
import { TFile } from 'obsidian';
import SpaceforgePlugin from './main';
import { DateUtils } from './utils/dates';
import { EstimationUtils } from './utils/estimation';
import { ReviewSession, ReviewSessionStore, generateSessionId, getNextFileInSession, advanceSession, isSessionComplete } from './models/review-session';
import { LinkAnalyzer } from './utils/link-analyzer';
import { ReviewHistoryItem, ReviewSchedule, ReviewResponse } from './models/review-schedule';
import { ReviewSessionStore, ReviewSession } from './models/review-session';
import { MCQSet, MCQSession } from './models/mcq';
import { EstimationUtils } from './utils/estimation';
import { ReviewScheduleService } from './services/review-schedule-service';
import { ReviewHistoryService } from './services/review-history-service';
import { ReviewSessionService } from './services/review-session-service';
@ -142,12 +140,10 @@ export class DataStorage {
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;
}
@ -158,7 +154,6 @@ export class DataStorage {
!('ease' in s) || typeof s.ease !== 'number') {
delete this.reviewScheduleService.schedules[path];
invalidCount++;
isValid = false;
}
}
@ -198,11 +193,12 @@ export class DataStorage {
const hasMCQs = Object.keys(this.mcqService.mcqSets).length > 0;
if (noSchedules && hasMCQs) {
// no-op
}
return isValid;
}
public async cleanupNonExistentFiles(): Promise<boolean> {
public cleanupNonExistentFiles(): boolean {
let changesMade = false;
if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== 'object') {
this.reviewScheduleService.schedules = {};
@ -228,12 +224,11 @@ export class DataStorage {
preserved: false
};
try {
const allSchedules = {...this.reviewScheduleService.schedules};
const allSchedules = { ...this.reviewScheduleService.schedules };
const allFiles = new Set<string>();
try {
const mdFiles = this.plugin.app.vault.getMarkdownFiles();
mdFiles.forEach(file => allFiles.add(file.path));
} catch (listError) {
this.plugin.app.vault.getMarkdownFiles().forEach(file => allFiles.add(file.path));
} catch {
return changesMade;
}
if (allFiles.size === 0 && beforeCount > 0) {
@ -251,7 +246,8 @@ export class DataStorage {
cleanupCount++;
changesMade = true;
}
} catch (checkError) {
} catch {
// no-op
}
if (safetyCheck.missingSchedules > 0 &&
safetyCheck.missingSchedules === safetyCheck.checkedSchedules &&
@ -265,6 +261,7 @@ export class DataStorage {
}
}
if (cleanupCount > 0 && !safetyCheck.preserved) {
// no-op
}
const dataState = {
schedules: Object.keys(this.reviewScheduleService.schedules).length,
@ -274,8 +271,10 @@ export class DataStorage {
mcqSessions: Object.keys(this.mcqService.mcqSessions || {}).length
};
if (dataState.schedules === 0 && (dataState.history > 0 || dataState.reviewSessions > 0 || dataState.mcqSets > 0)) {
// no-op
}
} catch (error) {
} catch {
// no-op
}
return changesMade; // Return whether cleanup occurred
@ -287,13 +286,13 @@ export class DataStorage {
// but they now simply call the corresponding method on the service instance.
// REMOVED await this.saveData() from all these methods.
async scheduleNoteForReview(path: string, daysFromNow: number = 0): Promise<void> {
async scheduleNoteForReview(path: string, daysFromNow = 0): Promise<void> {
await this.reviewScheduleService.scheduleNoteForReview(path, daysFromNow);
// await this.saveData(); // Removed
}
async recordReview(path: string, response: ReviewResponse, isSkipped: boolean = false): Promise<boolean> {
return await this.reviewScheduleService.recordReview(path, response, isSkipped);
recordReview(path: string, response: ReviewResponse, isSkipped = false): boolean {
return this.reviewScheduleService.recordReview(path, response, isSkipped);
}
calculateNewSchedule(
@ -305,8 +304,8 @@ export class DataStorage {
// but keeping for potential external use or backward compatibility if needed.
// It should probably be moved to ReviewScheduleService if kept.
// For now, just calling the service method.
const result = this.reviewScheduleService.calculateNewSchedule(currentInterval, currentEase, response);
return { interval: result.interval, ease: result.ease };
const result = this.reviewScheduleService.calculateNewSchedule(currentInterval, currentEase, response);
return { interval: result.interval, ease: result.ease };
}
async skipNote(path: string, response: ReviewResponse = ReviewResponse.CorrectWithDifficulty): Promise<void> {
@ -314,7 +313,7 @@ export class DataStorage {
// await this.saveData(); // Removed
}
async postponeNote(path: string, days: number = 1): Promise<void> {
async postponeNote(path: string, days = 1): Promise<void> {
await this.reviewScheduleService.postponeNote(path, days);
// await this.saveData(); // Removed
}
@ -338,7 +337,7 @@ export class DataStorage {
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch (error) {
} catch {
return 60; // Default 1 minute
}
}
@ -373,7 +372,7 @@ export class DataStorage {
return moreFiles;
}
async scheduleNotesInOrder(paths: string[], daysFromNow: number = 0): Promise<number> {
async scheduleNotesInOrder(paths: string[], daysFromNow = 0): Promise<number> {
const count = await this.reviewScheduleService.scheduleNotesInOrder(paths, daysFromNow);
// if (count > 0) { // Removed save call
// await this.saveData();
@ -381,13 +380,13 @@ export class DataStorage {
return count;
}
async scheduleSessionForReview(sessionId: string): Promise<number> {
scheduleSessionForReview(sessionId: string): number {
// This method calls scheduleNotesInOrder internally, which saves data.
// No need to save again here.
return await this.reviewSessionService.scheduleSessionForReview(sessionId);
return this.reviewSessionService.scheduleSessionForReview(sessionId);
}
async saveMCQSet(mcqSet: MCQSet): Promise<string> {
saveMCQSet(mcqSet: MCQSet): string {
const id = this.mcqService.saveMCQSet(mcqSet);
// await this.saveData(); // Removed
return id;
@ -397,7 +396,7 @@ export class DataStorage {
return this.mcqService.getMCQSetForNote(notePath);
}
async saveMCQSession(session: MCQSession): Promise<void> {
saveMCQSession(session: MCQSession): void {
this.mcqService.saveMCQSession(session);
// await this.saveData(); // Removed
}
@ -415,7 +414,7 @@ export class DataStorage {
// await this.saveData(); // Removed
}
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder: boolean = true): ReviewSchedule[] {
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder = true): ReviewSchedule[] {
return this.reviewScheduleService.getDueNotesWithCustomOrder(date, useCustomOrder);
}

69
eslint.config.mjs Normal file
View file

@ -0,0 +1,69 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
import tsparser from "@typescript-eslint/parser";
export default [
{ ignores: ["dist/", "node_modules/", "main.js", ".eslintrc.js", "esbuild.config.mjs", "version-bump.mjs", "eslint-plugin/", "install.js"] },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
obsidianmd: obsidianmd
},
languageOptions: {
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"no-unused-vars": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"no-empty": "error",
// Obsidian Rules
"obsidianmd/commands/no-command-in-command-id": "error",
"obsidianmd/commands/no-command-in-command-name": "error",
"obsidianmd/commands/no-default-hotkeys": "error",
"obsidianmd/commands/no-plugin-id-in-command-id": "error",
"obsidianmd/commands/no-plugin-name-in-command-name": "error",
"obsidianmd/settings-tab/no-manual-html-headings": "error",
"obsidianmd/settings-tab/no-problematic-settings-headings": "error",
"obsidianmd/vault/iterate": "error",
"obsidianmd/detach-leaves": "error",
"obsidianmd/hardcoded-config-path": "error",
"obsidianmd/no-forbidden-elements": "error",
"obsidianmd/no-plugin-as-component": "error",
"obsidianmd/no-sample-code": "error",
"obsidianmd/no-tfile-tfolder-cast": "error",
"obsidianmd/no-view-references-in-plugin": "error",
"obsidianmd/no-static-styles-assignment": "error",
"obsidianmd/object-assign": "error",
"obsidianmd/platform": "error",
"obsidianmd/prefer-file-manager-trash-file": "warn",
"obsidianmd/prefer-abstract-input-suggest": "error",
"obsidianmd/regex-lookbehind": "error",
"obsidianmd/sample-names": "error",
"obsidianmd/validate-manifest": "error",
"obsidianmd/validate-license": ["error"],
"obsidianmd/ui/sentence-case": "warn"
},
},
];

213
main.ts
View file

@ -1,4 +1,4 @@
import { App, Notice, Plugin, ViewCreator, WorkspaceLeaf, addIcon, TFile, TFolder, normalizePath } from 'obsidian';
import { Notice, Plugin, TFile, normalizePath, TFolder } from 'obsidian';
import { EventEmitter } from './utils/event-emitter';
import { DataStorage } from './data-storage';
import { ReviewController } from './controllers/review-controller';
@ -69,9 +69,9 @@ export default class SpaceforgePlugin extends Plugin {
// Now services can be initialized, they will use the default settings initially
this.reviewScheduleService = new ReviewScheduleService(
this,
this.pluginState.schedules,
this.pluginState.customNoteOrder,
this,
this.pluginState.schedules,
this.pluginState.customNoteOrder,
this.pluginState.lastLinkAnalysisTimestamp ?? null, // Coalesce undefined to null
this.pluginState.history
);
@ -80,15 +80,15 @@ export default class SpaceforgePlugin extends Plugin {
this.mcqService = new MCQService(this.pluginState.mcqSets, this.pluginState.mcqSessions);
// Load stored data, which will override the defaults in this.settings and this.pluginState
await this.loadPluginData();
await this.loadPluginData();
// After settings are fully loaded (including from storage), ensure services are updated
// This is crucial for FsrsScheduleService to get the correct FSRS parameters.
this.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
// Initialize PomodoroService after settings are loaded
this.pomodoroService = new PomodoroService(this);
// Initialize CalendarEventService after settings are loaded
this.calendarEventService = new CalendarEventService();
const eventsArray = Object.values(this.pluginState.calendarEvents);
@ -121,7 +121,8 @@ export default class SpaceforgePlugin extends Plugin {
EstimationUtils.setPlugin(this);
this.addIcons();
this.contextMenuHandler.register();
this.addRibbonIcon('calendar-clock', 'Spaceforge Review', async () => {
// eslint-disable-next-line obsidianmd/ui/sentence-case
this.addRibbonIcon('calendar-clock', 'Spaceforge review', async () => {
await this.activateSidebarView();
});
this.addSettingTab(new SpaceforgeSettingTab(this.app, this));
@ -129,7 +130,8 @@ export default class SpaceforgePlugin extends Plugin {
this.addCommand({
id: 'add-selected-file-to-review',
name: 'Add Selected File to Review Schedule (File Explorer)',
// eslint-disable-next-line obsidianmd/ui/sentence-case
name: 'Add selected file to review schedule (file explorer)',
callback: async () => {
const fileExplorerLeaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
const viewWithFile = fileExplorerLeaf?.view as { file?: TFile };
@ -140,46 +142,48 @@ export default class SpaceforgePlugin extends Plugin {
await this.savePluginData();
new Notice(`Added "${selectedFile.path}" to review schedule.`);
} else {
new Notice("Selected item is not a markdown file.");
new Notice("Selected item is not a markdown file."); // eslint-disable-line obsidianmd/ui/sentence-case
}
} else {
new Notice("No file selected in file explorer.");
new Notice("No file selected in file explorer");
}
}
});
this.registerEvent(this.app.workspace.on('file-open', (file) => { /* ... */ }));
this.registerEvent(this.app.workspace.on('file-open', (_file) => { /* ... */ }));
this.registerEvent(this.app.vault.on('delete', async (file) => {
if (file instanceof TFile && file.extension === "md") {
await this.reviewScheduleService.removeFromReview(file.path);
await this.savePluginData();
}
this.getSidebarView()?.refresh();
void this.getSidebarView()?.refresh();
}));
this.registerEvent(this.app.vault.on('rename', async (file, oldPath) => {
if (file instanceof TFile && file.extension === "md") {
this.reviewScheduleService.handleNoteRename(oldPath, file.path);
await this.savePluginData();
}
this.getSidebarView()?.refresh();
void this.getSidebarView()?.refresh();
}));
this.registerInterval(window.setInterval(() => {
this.getSidebarView()?.refresh();
void this.getSidebarView()?.refresh();
}, 60 * 1000));
// this.app.workspace.onLayoutReady(() => this.activateSidebarView()); // Removed automatic activation
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;
this.addStylesheet();
}
} catch (error) { /* handle error */ }
this.cssHotReloadIntervalId = window.setInterval(() => {
void (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;
this.addStylesheet();
}
} catch { /* handle error */ }
})();
}, 1000);
this.registerInterval(this.cssHotReloadIntervalId);
}
@ -188,20 +192,16 @@ export default class SpaceforgePlugin extends Plugin {
this.registerInterval(window.setInterval(() => this.checkForDueNotes(), 5 * 60 * 1000));
}
this.registerInterval(window.setInterval(async () => {
await this.savePluginData();
// Removed specific openRouterApiKey backup from here, handled by general settings save.
this.registerInterval(window.setInterval(() => {
void (async () => {
await this.savePluginData();
// Removed specific openRouterApiKey backup from here, handled by general settings save.
})();
}, 5 * 60 * 1000));
this.beforeUnloadHandler = (event) => {
let existingData = {};
try {
const loadedData = this.loadData();
if (loadedData && !(loadedData instanceof Promise)) {
existingData = loadedData;
} else if (loadedData instanceof Promise) {
}
} catch (loadError) { /* handle error */ }
this.beforeUnloadHandler = (_event) => {
const existingData = {};
// Removed sync loadData call which returns a Promise and cannot be awaited here.
try {
const reviewData = {
@ -215,23 +215,24 @@ export default class SpaceforgePlugin extends Plugin {
version: this.manifest.version
};
const combinedData = { ...existingData, reviewData };
let backupStr = JSON.stringify(combinedData);
const backupStr = JSON.stringify(combinedData);
this.app.saveLocalStorage('spaceforge-backup', backupStr);
(async () => {
void (async () => {
try {
await this.savePluginData();
} catch (e) { /* handle error */ }
} catch { /* handle error */ }
})();
} catch (error) {
} catch (_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 }});
const minimalBackup = JSON.stringify({ settings: this.settings, reviewData: { schedules: this.reviewScheduleService.schedules || {}, customNoteOrder: this.reviewScheduleService.customNoteOrder || [], lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp, version: this.manifest.version } });
this.app.saveLocalStorage('spaceforge-minimal-backup', minimalBackup);
} catch (minimalError) { /* handle error */ }
} catch { /* handle error */ }
}
}; // Removed the extra closing parenthesis and semicolon, and moved the declaration.
window.addEventListener('beforeunload', this.beforeUnloadHandler);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
async onunload(): Promise<void> {
if (this.cssHotReloadIntervalId !== null) {
window.clearInterval(this.cssHotReloadIntervalId);
@ -246,13 +247,13 @@ export default class SpaceforgePlugin extends Plugin {
}
let existingData: Partial<SpaceforgePluginData> = {};
try {
try {
const loaded = await this.loadData();
if (loaded) {
existingData = loaded;
}
}
catch (loadError) { /* handle error */ }
catch { /* handle error */ }
try {
const reviewData = {
@ -268,11 +269,11 @@ export default class SpaceforgePlugin extends Plugin {
const combinedData = { ...existingData, reviewData };
const backupStr = JSON.stringify(combinedData);
this.app.saveLocalStorage('spaceforge-backup', backupStr);
} catch (backupError) { /* handle error */ }
} catch { /* handle error */ }
try {
await this.savePluginData();
} catch (error) { /* handle error */ }
} catch { /* handle error */ }
if (this.pomodoroService) this.pomodoroService.destroy();
}
@ -282,7 +283,7 @@ export default class SpaceforgePlugin extends Plugin {
// return (customPath && typeof customPath === 'string' && customPath.trim() !== '') ? customPath.trim() : null;
// }
private async _getEffectiveDataPath(): Promise<string | null> {
private _getEffectiveDataPath(): string | null {
if (this.settings?.useCustomDataPath) {
const relativePath = this.settings.customDataPath?.trim(); // Get the path
if (relativePath && relativePath !== '') { // Only proceed if path is non-empty
@ -292,7 +293,7 @@ export default class SpaceforgePlugin extends Plugin {
pathForJson += '/';
}
pathForJson += 'data.json';
const vaultBasePath = this.app.vault.getRoot().path; // Usually an empty string for vault root
let absolutePath = (vaultBasePath ? vaultBasePath + '/' : '') + pathForJson;
absolutePath = normalizePath(absolutePath);
@ -300,7 +301,7 @@ export default class SpaceforgePlugin extends Plugin {
} else {
// useCustomDataPath is true, but customDataPath is empty.
// Silently fall back to default plugin storage without a warning.
return null;
return null;
}
}
return null; // useCustomDataPath is false, use default plugin storage
@ -321,13 +322,13 @@ export default class SpaceforgePlugin extends Plugin {
if (typeof lsCustomPathRelative === 'string') {
this.settings.customDataPath = lsCustomPathRelative;
}
// Now, this.settings.useCustomDataPath and this.settings.customDataPath reflect the user's
// most recent choice from the UI (via localStorage) or defaults if never set.
let rawLoadedData: SpaceforgePluginData | undefined;
// let preliminarySettingsData: Partial<SpaceforgeSettings> | null = null; // No longer needed
const effectivePath = await this._getEffectiveDataPath(); // Uses the now-bootstrapped settings
const effectivePath = this._getEffectiveDataPath(); // Uses the now-bootstrapped settings
const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
try {
@ -350,10 +351,10 @@ export default class SpaceforgePlugin extends Plugin {
rawLoadedData = JSON.parse(oldJsonData);
// Data will be saved to new path by savePluginData later
}
} catch (migrationReadError) { /* handle error */ }
} catch (_migrationReadError) { /* handle error */ }
}
if (!rawLoadedData) {
new Notice(`Spaceforge: No data file found at custom path ${effectivePath}. New data file will be created on save.`, 3000);
new Notice(`Spaceforge: No data file found at custom path ${effectivePath}. New data file will be created on save.`, 3000);
}
}
} else {
@ -364,12 +365,12 @@ export default class SpaceforgePlugin extends Plugin {
let loadedSettings = {}; // Start with empty object
if (rawLoadedData?.settings && typeof rawLoadedData.settings === 'object') {
// If data was loaded and settings is an object, use its settings
loadedSettings = rawLoadedData.settings;
loadedSettings = rawLoadedData.settings;
}
// Merge defaults, loaded settings, and prioritize localStorage path settings
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
if (typeof lsUseCustomPath === 'boolean') {
this.settings.useCustomDataPath = lsUseCustomPath;
this.settings.useCustomDataPath = lsUseCustomPath;
}
if (typeof lsCustomPathRelative === 'string') {
this.settings.customDataPath = lsCustomPathRelative;
@ -382,12 +383,12 @@ export default class SpaceforgePlugin extends Plugin {
} else if (rawLoadedData && !rawLoadedData.pluginState && (rawLoadedData as Partial<PluginStateData>).schedules) { // Legacy check
this.pluginState = { ...this.pluginState, ...(rawLoadedData as Partial<PluginStateData>) };
}
// Ensure calendarEvents exists for legacy data
if (!this.pluginState.calendarEvents) {
this.pluginState.calendarEvents = {};
}
// Ensure pomodoro state is initialized if not present in loaded data
this.pluginState.pomodoroCurrentMode = this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode;
this.pluginState.pomodoroTimeLeftInSeconds = this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds;
@ -395,7 +396,7 @@ export default class SpaceforgePlugin extends Plugin {
this.pluginState.pomodoroIsRunning = typeof this.pluginState.pomodoroIsRunning === 'boolean' ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning;
if (this.pluginState.schedules) {
if (this.pluginState.schedules) {
// Data migration for schedulingAlgorithm
for (const path in this.pluginState.schedules) {
if (Object.prototype.hasOwnProperty.call(this.pluginState.schedules, path)) {
@ -403,7 +404,7 @@ export default class SpaceforgePlugin extends Plugin {
if (!schedule.schedulingAlgorithm) {
schedule.schedulingAlgorithm = 'sm2'; // Default to SM-2 for old schedules
// Ensure FSRS-specific data is undefined for these migrated SM-2 cards
schedule.fsrsData = undefined;
schedule.fsrsData = undefined;
// Ensure SM-2 specific 'scheduleCategory' has a default if missing
if (!schedule.scheduleCategory) {
schedule.scheduleCategory = this.settings.useInitialSchedule ? 'initial' : 'spaced';
@ -428,10 +429,10 @@ export default class SpaceforgePlugin extends Plugin {
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("Spaceforge: Error loading data:", error);
// SIMPLE RECOVERY: Try localStorage backup once, then use defaults
try {
const backupData = await this.app.loadLocalStorage('spaceforge-backup');
@ -440,7 +441,7 @@ export default class SpaceforgePlugin extends Plugin {
if (parsedBackup.reviewData) {
this.settings = { ...DEFAULT_SETTINGS, ...parsedBackup.settings };
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA, ...parsedBackup.reviewData };
new Notice("Spaceforge: Recovered data from backup.", 5000);
new Notice("Spaceforge: Recovered data from backup", 5000); // eslint-disable-line obsidianmd/ui/sentence-case
} else {
throw new Error("Invalid backup format");
}
@ -451,9 +452,9 @@ export default class SpaceforgePlugin extends Plugin {
console.warn("Spaceforge: Backup recovery failed, using defaults:", backupError);
this.settings = { ...DEFAULT_SETTINGS };
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
new Notice("Spaceforge: Error loading data. Using defaults to prevent crash.", 5000);
new Notice("Spaceforge: Error loading data, using defaults to prevent crash", 5000); // eslint-disable-line obsidianmd/ui/sentence-case
}
// Repopulate services
this.reviewScheduleService.schedules = this.pluginState.schedules || {};
this.reviewHistoryService.history = this.pluginState.history || [];
@ -462,7 +463,7 @@ export default class SpaceforgePlugin extends Plugin {
this.mcqService.mcqSessions = this.pluginState.mcqSessions || {};
this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = this.pluginState.lastLinkAnalysisTimestamp ?? null;
if (this.reviewScheduleService) {
this.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
}
@ -483,7 +484,7 @@ export default class SpaceforgePlugin extends Plugin {
if (!this.pluginState) {
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
}
const currentPluginState: PluginStateData = {
schedules: this.reviewScheduleService.schedules || {},
history: this.reviewHistoryService.history || [],
@ -513,7 +514,7 @@ export default class SpaceforgePlugin extends Plugin {
pluginState: this.pluginState,
};
const effectiveSavePath = await this._getEffectiveDataPath();
const effectiveSavePath = this._getEffectiveDataPath();
const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
if (effectiveSavePath) {
@ -524,7 +525,7 @@ export default class SpaceforgePlugin extends Plugin {
await this.app.vault.createFolder(dirPathOnly);
new Notice(`Spaceforge: Created directory for custom data: ${dirPathOnly}`, 3000);
}
// SIMPLE SAVE: Use Obsidian's standard file operations
const file = this.app.vault.getAbstractFileByPath(effectiveSavePath);
if (file instanceof TFile) {
@ -541,16 +542,16 @@ export default class SpaceforgePlugin extends Plugin {
if (oldFile instanceof TFile) {
// Instead of deleting, we'll keep the old file as a backup
// Users can manually clean it up later if needed
new Notice(`Spaceforge: Successfully saved to custom path. Original data file kept as backup for safety.`, 5000);
new Notice(`Spaceforge: Successfully saved to custom path, original data file kept as backup for safety`, 5000); // eslint-disable-line obsidianmd/ui/sentence-case
}
} catch (writeError) {
new Notice(`Error saving data to custom path ${effectiveSavePath}: ${writeError.message}. Falling back to default path for this save.`, 10000);
// Fallback save to default location if custom path write fails
try {
await this.saveData(dataToSave); // Plugin's internal save
new Notice(`Spaceforge: Data saved to default plugin folder due to error with custom path.`, 5000);
} catch (fallbackError) {
new Notice(`CRITICAL: Spaceforge failed to save data to both custom and default locations.`, 10000);
new Notice(`Spaceforge: Data saved to default plugin folder due to error with custom path`, 5000); // eslint-disable-line obsidianmd/ui/sentence-case
} catch (_error) {
new Notice(`CRITICAL: Spaceforge failed to save data to both custom and default locations`, 10000); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
} else {
@ -558,12 +559,12 @@ export default class SpaceforgePlugin extends Plugin {
await this.saveData(dataToSave); // Plugin's internal save
// new Notice(`Spaceforge: Data saved to default plugin storage.`, 3000); // Removed notice
}
// Removed localStorage backup for settings as it's not the source of truth for the path anymore.
// If user wants to backup settings, they can use the export feature.
} catch (error) {
new Notice("Error saving Spaceforge data. Check console for details.", 5000);
} catch (_error) {
new Notice("Error saving Spaceforge data, check console for details", 5000); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
@ -571,7 +572,7 @@ export default class SpaceforgePlugin extends Plugin {
const existingLeaves = this.app.workspace.getLeavesOfType('spaceforge-review-schedule');
if (existingLeaves.length > 0) {
// If a sidebar view already exists, reveal the first one found
this.app.workspace.revealLeaf(existingLeaves[0]);
void this.app.workspace.revealLeaf(existingLeaves[0]);
} else {
// If no sidebar view exists, create a new one
const leaf = this.app.workspace.getRightLeaf(false); // Default to right leaf if creating new
@ -580,61 +581,61 @@ export default class SpaceforgePlugin extends Plugin {
type: 'spaceforge-review-schedule',
active: true,
});
this.app.workspace.revealLeaf(leaf); // Reveal the newly created leaf
void this.app.workspace.revealLeaf(leaf); // Reveal the newly created leaf
} else {
new Notice("Spaceforge: Could not open sidebar view.");
new Notice("Spaceforge: Could not open sidebar view"); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
}
addCommands(): void {
this.addCommand({
id: 'spaceforge-next-review-note',
name: 'Next Review Note',
id: 'next-review-note',
name: 'Next review note',
callback: () => {
this.navigationController.navigateToNextNote();
void this.navigationController.navigateToNextNote();
},
});
this.addCommand({
id: 'spaceforge-previous-review-note',
name: 'Previous Review Note',
id: 'previous-review-note',
name: 'Previous review note',
callback: () => {
this.navigationController.navigateToPreviousNote();
void this.navigationController.navigateToPreviousNote();
},
});
this.addCommand({
id: 'spaceforge-review-current-note',
name: 'Review Current Note',
id: 'review-current-note',
name: 'Review current note',
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof TFile && activeFile.extension === 'md') {
this.reviewController.reviewNote(activeFile.path);
void this.reviewController.reviewNote(activeFile.path);
} else {
new Notice('No active markdown file to review.');
new Notice('No active markdown file to review'); // eslint-disable-line obsidianmd/ui/sentence-case
}
},
});
this.addCommand({
id: 'spaceforge-add-current-note-to-review',
name: 'Add Current Note to Review Schedule',
id: 'add-current-note-to-review',
name: 'Add current note to review schedule',
callback: async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof TFile && activeFile.extension === 'md') {
await this.reviewScheduleService.scheduleNoteForReview(activeFile.path);
await this.savePluginData();
new Notice(`Added "${activeFile.path}" to review schedule.`);
new Notice(`Added "${activeFile.path}" to review schedule`);
} else {
new Notice('No active markdown file to add to review.');
new Notice('No active markdown file to add to review'); // eslint-disable-line obsidianmd/ui/sentence-case
}
},
});
this.addCommand({
id: 'spaceforge-add-current-folder-to-review',
name: "Add Current Note's Folder to Review Schedule",
id: '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 TFolder) {
@ -642,7 +643,7 @@ export default class SpaceforgePlugin extends Plugin {
// Use the same logic as the context menu for consistency
await this.contextMenuHandler.addFolderToReview(folder);
} else {
new Notice("Could not determine the current note's folder, no active file, or parent is not a folder.");
new Notice("Could not determine the current note's folder, no active file, or parent is not a folder"); // eslint-disable-line obsidianmd/ui/sentence-case
}
},
});
@ -660,46 +661,46 @@ export default class SpaceforgePlugin extends Plugin {
// Pass the mcqService for data management, and the new mcqGenerationService for API calls
this.mcqController = new MCQController(this, this.mcqService, this.mcqGenerationService);
} else {
new Notice('MCQ Generation Service could not be initialized. Check API provider settings in Spaceforge settings.');
new Notice('MCQ generation service could not be initialized, check API provider settings in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
}
private createMcqGenerationService(): IMCQGenerationService | undefined {
switch (this.settings.mcqApiProvider) {
case ApiProvider.OpenRouter:
if (!this.settings.openRouterApiKey) {
new Notice('OpenRouter API key is not set in Spaceforge settings.');
new Notice('OpenRouter API key is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new OpenRouterService(this);
case ApiProvider.OpenAI:
if (!this.settings.openaiApiKey) {
new Notice('OpenAI API key is not set in Spaceforge settings.');
new Notice('OpenAI API key is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new OpenAIService(this);
case ApiProvider.Ollama:
if (!this.settings.ollamaApiUrl || !this.settings.ollamaModel) {
new Notice('Ollama API URL or Model is not set in Spaceforge settings.');
new Notice('Ollama API URL or model is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new OllamaService(this);
case ApiProvider.Gemini:
if (!this.settings.geminiApiKey) {
new Notice('Gemini API key is not set in Spaceforge settings.');
new Notice('Gemini API key is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new GeminiService(this);
case ApiProvider.Claude:
if (!this.settings.claudeApiKey || !this.settings.claudeModel) {
new Notice('Claude API key or Model is not set in Spaceforge settings.');
new Notice('Claude API key or model is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new ClaudeService(this);
case ApiProvider.Together:
if (!this.settings.togetherApiKey || !this.settings.togetherModel) {
new Notice('Together AI API key or Model is not set in Spaceforge settings.');
new Notice('Together AI API key or model is not set in Spaceforge settings'); // eslint-disable-line obsidianmd/ui/sentence-case
return undefined;
}
return new TogetherService(this);
@ -712,10 +713,10 @@ export default class SpaceforgePlugin extends Plugin {
}
}
async checkForDueNotes(): Promise<void> { /* ... */ }
checkForDueNotes(): void { /* ... */ }
private addStylesheet(): void { /* ... */ }
async exportPluginData(): Promise<void> { /* ... */ }
async importPluginData(fileContent: string): Promise<void> { /* ... */ }
exportPluginData(): void { /* ... */ }
importPluginData(_fileContent: string): void { /* ... */ }
getSidebarView(): ReviewSidebarView | null {
const leaves = this.app.workspace.getLeavesOfType('spaceforge-review-schedule');

View file

@ -6,38 +6,38 @@ export interface ReviewSchedule {
* Path to the note file - unique identifier
*/
path: string;
/**
* Timestamp of the last review
*/
lastReviewDate: number | null;
/**
* Timestamp of the next scheduled review
*/
nextReviewDate: number;
/**
* Ease factor - affects interval growth (higher = longer intervals)
* In SM-2, this is a value typically starting at 2.5 (stored as 250 internally)
*/
ease: number;
/**
* Current interval in days
*/
interval: number;
/**
* Number of consecutive successful reviews (for backward compatibility)
*/
consecutive: number;
/**
* Total number of completed reviews (for backward compatibility)
*/
reviewCount: number;
/**
* SM-2 repetition count - used to determine interval calculation (n)
* This is 1 for items in "learning" phase and increments for each successful review
@ -88,54 +88,31 @@ export enum ReviewResponse {
* Complete blackout (0) - No recognition at all
*/
CompleteBlackout = 0,
/**
* Incorrect response (1) - Wrong answer but upon seeing the correct answer, it felt familiar
*/
IncorrectResponse = 1,
/**
* Incorrect response (2) - Wrong answer but upon seeing the correct answer, it felt very familiar
*/
IncorrectButFamiliar = 2,
/**
* Correct with difficulty (3) - Correct answer but required significant effort to recall
*/
CorrectWithDifficulty = 3,
/**
* Correct with hesitation (4) - Correct answer after some hesitation
*/
CorrectWithHesitation = 4,
/**
* Perfect recall (5) - Correct answer with no hesitation
*/
PerfectRecall = 5,
// Legacy response values explicitly mapped to SM-2 equivalents
// for backward compatibility
/**
* Legacy "Hard" response - mapped to IncorrectResponse (1)
*/
Hard = 1,
/**
* Legacy "Fair" response - mapped to CorrectWithDifficulty (3)
*/
Fair = 3,
/**
* Legacy "Good" response - mapped to CorrectWithHesitation (4)
*/
Good = 4,
/**
* Legacy "Perfect" response - mapped to PerfectRecall (5)
*/
Perfect = 5
PerfectRecall = 5
}
/**
@ -149,21 +126,8 @@ export function toSM2Quality(response: ReviewResponse): number {
if (response >= 0 && response <= 5) {
return response;
}
// Map legacy responses to SM-2 quality ratings
switch(response) {
case ReviewResponse.Hard:
return ReviewResponse.IncorrectResponse;
case ReviewResponse.Fair:
return ReviewResponse.CorrectWithDifficulty;
case ReviewResponse.Good:
return ReviewResponse.CorrectWithHesitation;
case ReviewResponse.Perfect:
return ReviewResponse.PerfectRecall;
default:
// Default to fair recall if unknown
return ReviewResponse.CorrectWithDifficulty;
}
// Default fallback
return ReviewResponse.CorrectWithDifficulty;
}
/**
@ -174,27 +138,27 @@ export interface ReviewHistoryItem {
* Path to the note file
*/
path: string;
/**
* Timestamp of the review
*/
timestamp: number;
/**
* User's response during review
*/
response: ReviewResponse;
/**
* Interval at the time of review
*/
interval: number;
/**
* Ease at the time of review
*/
ease: number;
/**
* Whether this review was explicitly skipped by the user
*/

View file

@ -66,7 +66,7 @@ export interface ReviewSessionStore {
* @param prefix Prefix for the ID
* @returns Unique ID
*/
export function generateSessionId(prefix: string = 'session'): string {
export function generateSessionId(prefix = 'session'): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}

View file

@ -1,3 +0,0 @@
bug postpone all postpones all notes not just one note. from that specific days on entry.
change due date red color to something more subtle

3838
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"install-plugin": "node install.js"
"install-plugin": "node install.js",
"lint": "ESLINT_USE_FLAT_CONFIG=true eslint .",
"typecheck": "tsc -noEmit -skipLibCheck"
},
"keywords": [
"obsidian",
@ -17,18 +19,23 @@
"author": "Spaceforge Team",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@eslint/js": "^9.39.1",
"@types/fs-extra": "^11.0.4",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"builtin-modules": "3.3.0",
"esbuild": "^0.17.19",
"eslint": "^8.57.1",
"eslint-plugin-obsidianmd": "^0.1.9",
"globals": "^16.5.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^4.7.4"
"typescript": "^4.7.4",
"typescript-eslint": "^8.48.1"
},
"dependencies": {
"fs-extra": "^11.1.1",
"ts-fsrs": "^4.7.1"
}
}
}

View file

@ -107,7 +107,7 @@ export class CalendarEventService {
const eventDate = DateUtils.startOfDay(new Date(event.date));
const start = DateUtils.startOfDay(new Date(startDate));
const end = DateUtils.startOfDay(new Date(endDate));
return eventDate >= start && eventDate <= end;
});
}
@ -164,7 +164,7 @@ export class CalendarEventService {
* @param fromDate Optional start date (defaults to today)
* @returns Array of upcoming events
*/
getUpcomingEvents(daysAhead: number = 7, fromDate: Date = new Date()): UpcomingEvent[] {
getUpcomingEvents(daysAhead = 7, fromDate: Date = new Date()): UpcomingEvent[] {
const today = DateUtils.startOfDay(fromDate);
const endDate = DateUtils.addDays(today, daysAhead);
const eventsInRange = this.getEventsInRange(today, endDate);
@ -173,7 +173,7 @@ export class CalendarEventService {
.map(event => {
const eventDate = DateUtils.startOfDay(new Date(event.date));
const daysUntil = Math.floor((eventDate - today) / (24 * 60 * 60 * 1000));
return {
event,
daysUntil,
@ -186,16 +186,16 @@ export class CalendarEventService {
if (a.daysUntil !== b.daysUntil) {
return a.daysUntil - b.daysUntil;
}
// If same day, sort by time (all-day events first)
if (a.event.isAllDay && !b.event.isAllDay) return -1;
if (!a.event.isAllDay && b.event.isAllDay) return 1;
// If both have times, sort by time
if (a.event.time && b.event.time) {
return a.event.time.localeCompare(b.event.time);
}
return 0;
});
}
@ -219,8 +219,8 @@ export class CalendarEventService {
const eventDate = DateUtils.startOfDay(new Date(event.date));
const recurrenceEnd = event.recurrenceEndDate ? DateUtils.startOfDay(new Date(event.recurrenceEndDate)) : end;
let currentDate = new Date(eventDate);
const currentDate = new Date(eventDate);
while (currentDate.getTime() <= Math.min(end, recurrenceEnd)) {
if (currentDate.getTime() >= start) {
const instance: CalendarEvent = {
@ -269,7 +269,7 @@ export class CalendarEventService {
*/
searchEvents(query: string): CalendarEvent[] {
const lowerQuery = query.toLowerCase();
return this.getAllEvents().filter(event =>
return this.getAllEvents().filter(event =>
event.title.toLowerCase().includes(lowerQuery) ||
(event.description && event.description.toLowerCase().includes(lowerQuery))
);
@ -294,14 +294,14 @@ export class CalendarEventService {
try {
const events: CalendarEvent[] = JSON.parse(json);
let importedCount = 0;
events.forEach(event => {
if (event.id && event.title && event.date) {
this.events.set(event.id, event);
importedCount++;
}
});
return importedCount;
} catch (error) {
console.error('Failed to import events:', error);
@ -315,7 +315,7 @@ export class CalendarEventService {
* @returns Unique ID string
*/
private generateId(): string {
return `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return `event-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
@ -344,7 +344,7 @@ export class CalendarEventService {
[EventCategory.Social]: '#FF69B4',
[EventCategory.Other]: '#95A5A6'
};
return colors[category] || colors[EventCategory.Other];
}
}

View file

@ -1,7 +1,7 @@
import { FSRS, FSRSParameters, Card as FsrsLibCard, State as FsrsState, Rating as TsFsrsRating, createEmptyCard as createFsrsLibEmptyCard, ReviewLog as FsrsReviewLog } from 'ts-fsrs';
import { SpaceforgeSettings } from '../models/settings';
import { ReviewSchedule, FsrsRating } from '../models/review-schedule';
import { DateUtils } from '../utils/dates';
export class FsrsScheduleService {
private fsrsInstance: FSRS;
@ -56,7 +56,7 @@ export class FsrsScheduleService {
last_review: fsrsData.last_review ? new Date(fsrsData.last_review) : undefined,
};
}
private mapFsrsLibRatingToTsFsrsRating(rating: FsrsRating): TsFsrsRating {
switch (rating) {
case FsrsRating.Again: return TsFsrsRating.Again;
@ -76,12 +76,12 @@ export class FsrsScheduleService {
const tsFsrsRating = this.mapFsrsLibRatingToTsFsrsRating(rating);
const schedulingResult = this.fsrsInstance.repeat(fsrsLibCardToReview, reviewDateTime);
// Ensure that tsFsrsRating is a valid key for schedulingResult
// TsFsrsRating.Manual is a valid enum member but not a result of our mapping.
// The keys of schedulingResult are Again, Hard, Good, Easy.
const validRatingKey = tsFsrsRating as (TsFsrsRating.Again | TsFsrsRating.Hard | TsFsrsRating.Good | TsFsrsRating.Easy);
const resultCard = schedulingResult[validRatingKey].card;
const resultLog = schedulingResult[validRatingKey].log;

View file

@ -68,7 +68,7 @@ export class MCQService {
}
return null;
} catch (error) {
} catch {
return null;
}
}
@ -112,7 +112,8 @@ export class MCQService {
}
// Data saving is now handled by main.ts after this method returns
} catch (error) {
} catch {
// no-op
}
}
@ -151,6 +152,7 @@ export class MCQService {
mcqSet.needsQuestionRegeneration = true;
this.saveMCQSet(mcqSet); // Updates the in-memory reference; persistence handled by caller
} else {
// no-op
}
}
}

View file

@ -43,7 +43,7 @@ export class PomodoroService {
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1000;
this.startTimerInterval();
this.notifyUpdate();
this.plugin.savePluginData();
void this.plugin.savePluginData();
}
public stop(): void { // Pause functionality
@ -61,7 +61,7 @@ export class PomodoroService {
this.state.pomodoroEndTimeMs = null;
this.notifyUpdate();
this.plugin.savePluginData();
void this.plugin.savePluginData();
}
public resetCurrentSession(): void {
@ -70,9 +70,9 @@ export class PomodoroService {
this.state.pomodoroEndTimeMs = null; // Clear end time on reset
this.resetTimeForMode(this.state.pomodoroCurrentMode === 'idle' ? 'work' : this.state.pomodoroCurrentMode);
this.notifyUpdate();
this.plugin.savePluginData();
void this.plugin.savePluginData();
}
public skipSession(): void {
this.stopTimerInterval();
this.state.pomodoroIsRunning = false; // Stop current timer before switching
@ -85,7 +85,7 @@ export class PomodoroService {
this.startTimerInterval();
}
this.notifyUpdate(); // handleTimerEnd also notifies, but an extra one here is fine
this.plugin.savePluginData();
void this.plugin.savePluginData();
}
public getFormattedTimeLeft(): string {
@ -100,14 +100,14 @@ export class PomodoroService {
const currentShort = this.settings.pomodoroShortBreakDuration;
const currentLong = this.settings.pomodoroLongBreakDuration;
const currentSessions = this.settings.pomodoroSessionsUntilLongBreak;
const userChangedSettings = work !== currentWork || short !== currentShort || long !== currentLong || sessions !== currentSessions;
this.settings.pomodoroWorkDuration = work;
this.settings.pomodoroShortBreakDuration = short;
this.settings.pomodoroLongBreakDuration = long;
this.settings.pomodoroSessionsUntilLongBreak = sessions;
// If user manually changed settings, mark as modified and deactivate estimation
if (userChangedSettings) {
this.state.pomodoroUserHasModifiedSettings = true;
@ -115,13 +115,13 @@ export class PomodoroService {
this.state.pomodoroEstimatedTotalCycles = null;
this.state.pomodoroEstimatedWorkSessions = null;
}
// If current session is not running and its mode (which is an active timed mode) had its duration changed, update its timeLeft
const activeModesForDurationUpdate: PomodoroMode[] = ['work', 'shortBreak', 'longBreak'];
if (!this.state.pomodoroIsRunning && activeModesForDurationUpdate.includes(this.state.pomodoroCurrentMode)) {
this.resetTimeForMode(this.state.pomodoroCurrentMode);
this.resetTimeForMode(this.state.pomodoroCurrentMode);
}
this.plugin.savePluginData(); // Save settings and potentially updated state
void this.plugin.savePluginData(); // Save settings and potentially updated state
this.notifyUpdate();
}
@ -208,10 +208,10 @@ export class PomodoroService {
this.state.pomodoroEstimatedTotalCycles = Math.ceil(pomodorosNeeded / sessionsUntilLongBreak);
this.state.pomodoroEstimatedWorkSessions = pomodorosNeeded;
this.state.pomodoroIsEstimationActive = true;
this.plugin.savePluginData();
void this.plugin.savePluginData();
this.notifyUpdate();
// Return calculation results for UI display
return {
totalReadingTimeInSeconds,
@ -224,9 +224,9 @@ export class PomodoroService {
/**
* Get current cycle progress information
*/
public getCycleProgress(): {
current: number;
total: number;
public getCycleProgress(): {
current: number;
total: number;
workSessionsRemaining: number;
totalWorkSessions: number;
totalTimeMinutes: number;
@ -248,7 +248,7 @@ export class PomodoroService {
let totalBreakTime = 0;
let sessionsInCycle = 0;
for (let i = 0; i < totalWorkSessions; i++) {
sessionsInCycle++;
if (i < totalWorkSessions - 1) { // Don't add break after last session
@ -280,7 +280,7 @@ export class PomodoroService {
this.state.pomodoroEstimatedTotalCycles = null;
this.state.pomodoroEstimatedWorkSessions = null;
this.state.pomodoroUserHasModifiedSettings = false;
this.plugin.savePluginData();
void this.plugin.savePluginData();
this.notifyUpdate();
}
@ -327,7 +327,7 @@ export class PomodoroService {
}
}
private handleTimerEnd(skipped: boolean = false): void {
private handleTimerEnd(skipped = false): void {
this.stopTimerInterval(); // Stop the current interval
// pomodoroIsRunning might still be true if we auto-transition to a new session
@ -351,7 +351,7 @@ export class PomodoroService {
this.state.pomodoroSessionsCompletedInCycle = 0; // Reset for new cycle
}
} // If currentMode was 'idle' or another unhandled state, nextMode remains 'idle' as initialized.
const wasRunning = this.state.pomodoroIsRunning; // Capture state before potentially changing it
this.switchToMode(nextMode); // This resets timeLeft based on the new mode
@ -360,17 +360,17 @@ export class PomodoroService {
// Calculate new end time based on the duration of the *new* mode
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1000;
if (!wasRunning) { // Only start interval if it wasn't already running (e.g., transitioning from idle)
this.startTimerInterval();
this.startTimerInterval();
} else if (!this.timerInterval) { // Or if interval somehow stopped but should be running
this.startTimerInterval();
this.startTimerInterval();
}
} else {
this.state.pomodoroIsRunning = false;
this.state.pomodoroEndTimeMs = null; // No end time when idle
this.stopTimerInterval(); // Ensure interval is stopped when idle
}
this.plugin.savePluginData(); // Save state changes
void this.plugin.savePluginData(); // Save state changes
this.notifyUpdate();
}
@ -414,7 +414,7 @@ export class PomodoroService {
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.5); // Play for 0.5 seconds
} catch (e) { /* handle error */ }
} catch { /* handle error */ }
}
private notifyUpdate(): void {
@ -458,8 +458,8 @@ export class PomodoroService {
this.handleTimerEnd(true); // Treat as skipped to avoid sound, force transition logic
}
} else if (!this.state.pomodoroIsRunning) {
// If not running, ensure end time is null
this.state.pomodoroEndTimeMs = null;
// If not running, ensure end time is null
this.state.pomodoroEndTimeMs = null;
}
// If running but no end time (shouldn't happen), maybe log error or reset?
// For now, assume state is consistent or gets corrected by start/stop.

View file

@ -1,4 +1,4 @@
import { Notice, TFile, TFolder } from 'obsidian';
import { Notice, TFile } from 'obsidian';
import { ReviewSchedule, ReviewResponse, toSM2Quality, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import SpaceforgePlugin from '../main';
import { DateUtils } from '../utils/dates';
@ -57,7 +57,7 @@ export class ReviewScheduleService {
this.schedules = schedules;
this.customNoteOrder = customNoteOrder;
this.lastLinkAnalysisTimestamp = lastLinkAnalysisTimestamp;
this.history = history;
this.history = history;
this.fsrsService = new FsrsScheduleService(this.plugin.settings);
}
@ -72,18 +72,18 @@ export class ReviewScheduleService {
* @param path Path to the note file
* @param daysFromNow Days until first review (default: 0, same day)
*/
async scheduleNoteForReview(path: string, daysFromNow: number = 0): Promise<void> {
scheduleNoteForReview(path: string, daysFromNow = 0): void {
// Check if file exists and is a markdown file
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile) || file.extension !== "md") {
new Notice("Only markdown files can be added to the review schedule");
new Notice("Only markdown files can be added to the review schedule."); // eslint-disable-line obsidianmd/ui/sentence-case
return;
}
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm;
let newSchedule: ReviewSchedule;
if (defaultAlgorithm === 'fsrs') {
@ -98,7 +98,7 @@ export class ReviewScheduleService {
fsrsData: fsrsData,
// SM-2 fields can be undefined or default
ease: this.plugin.settings.baseEase, // Keep a base for potential conversion
interval: 0,
interval: 0,
consecutive: 0,
repetitionCount: 0,
scheduleCategory: undefined, // Not applicable to FSRS
@ -143,7 +143,7 @@ export class ReviewScheduleService {
this.plugin.events.emit('sidebar-update');
}
new Notice(`Note added to review schedule`);
new Notice(`Note added to review schedule.`);
}
/**
@ -156,7 +156,7 @@ export class ReviewScheduleService {
private isNoteDue(schedule: ReviewSchedule, effectiveReviewDate: number): boolean {
const reviewDateObj = new Date(effectiveReviewDate);
const effectiveUTCDayEnd = DateUtils.endOfUTCDay(reviewDateObj);
// Note is due if its nextReviewDate is on or before the effective date
// Allow notes due today or earlier to be reviewed
return schedule.nextReviewDate <= effectiveUTCDayEnd;
@ -171,15 +171,15 @@ export class ReviewScheduleService {
* @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: string, response: ReviewResponse | FsrsRating, isSkipped: boolean = false, currentReviewDate?: number): Promise<boolean> {
recordReview(path: string, response: ReviewResponse | FsrsRating, isSkipped = false, currentReviewDate?: number): boolean {
const schedule = this.schedules[path];
if (!schedule) return false;
const effectiveReviewDate = currentReviewDate || Date.now();
// Check if the note is actually due for review
const isDue = this.isNoteDue(schedule, effectiveReviewDate);
if (!isDue) {
// Note is not due, this is just a preview - don't record anything
return false;
@ -221,50 +221,50 @@ export class ReviewScheduleService {
if (schedule.schedulingAlgorithm === 'fsrs') {
if (!schedule.fsrsData) { // Should not happen if card is properly initialized
// FSRS service expects a Date object for the review time.
// reviewDateObj (created from effectiveReviewDate) is correct here.
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
// FSRS service expects a Date object for the review time.
// reviewDateObj (created from effectiveReviewDate) is correct here.
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
// --- START FIX: Determine correct FsrsRating ---
let actualFsrsRating: FsrsRating;
// Check if the response is one of the legacy ReviewResponse values from MCQ modal
if (response === ReviewResponse.Perfect) { // Value 5
actualFsrsRating = FsrsRating.Easy; // Map to 4
} else if (response === ReviewResponse.Good) { // Value 4
actualFsrsRating = FsrsRating.Good; // Map to 3
} else if (response === ReviewResponse.Fair) { // Value 3
actualFsrsRating = FsrsRating.Hard; // Map to 2
} else if (response === ReviewResponse.Hard) { // Value 1 (legacy)
actualFsrsRating = FsrsRating.Again; // Map to 1
} else if (Object.values(FsrsRating).includes(response as FsrsRating)) {
// If it's already a valid FsrsRating (1, 2, 3, 4), use it directly
actualFsrsRating = response as FsrsRating;
} else {
// Fallback for unexpected values (e.g., SM-2 specific 0, 2, potentially)
// Map based on general difficulty
const quality = toSM2Quality(response as ReviewResponse);
if (quality >= 4) actualFsrsRating = FsrsRating.Easy; // 4, 5 -> Easy
else if (quality === 3) actualFsrsRating = FsrsRating.Good; // 3 -> Good
else if (quality === 2) actualFsrsRating = FsrsRating.Hard; // 2 -> Hard
else actualFsrsRating = FsrsRating.Again; // 0, 1 -> Again
}
// --- END FIX ---
// --- START FIX: Determine correct FsrsRating ---
let actualFsrsRating: FsrsRating;
// Check if the response is one of the legacy ReviewResponse values from MCQ modal
if (response === ReviewResponse.PerfectRecall) { // Value 5
actualFsrsRating = FsrsRating.Easy; // Map to 4
} else if (response === ReviewResponse.CorrectWithHesitation) { // Value 4
actualFsrsRating = FsrsRating.Good; // Map to 3
} else if (response === ReviewResponse.CorrectWithDifficulty) { // Value 3
actualFsrsRating = FsrsRating.Hard; // Map to 2
} else if (response === ReviewResponse.IncorrectResponse) { // Value 1 (legacy)
actualFsrsRating = FsrsRating.Again; // Map to 1
} else if (Object.values(FsrsRating).includes(response as FsrsRating)) {
// If it's already a valid FsrsRating (1, 2, 3, 4), use it directly
actualFsrsRating = response as FsrsRating;
} else {
// Fallback for unexpected values (e.g., SM-2 specific 0, 2, potentially)
// Map based on general difficulty
const quality = toSM2Quality(response as ReviewResponse);
if (quality >= 4) actualFsrsRating = FsrsRating.Easy; // 4, 5 -> Easy
else if (quality === 3) actualFsrsRating = FsrsRating.Good; // 3 -> Good
else if (quality === 2) actualFsrsRating = FsrsRating.Hard; // 2 -> Hard
else actualFsrsRating = FsrsRating.Again; // 0, 1 -> Again
}
// --- END FIX ---
const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.recordReview(
schedule.fsrsData,
actualFsrsRating, // Pass the correctly determined FsrsRating
reviewDateObj // Pass the exact moment of review
);
schedule.fsrsData = updatedData;
schedule.nextReviewDate = newNextReviewDateFsrs; // This is already a UTC timestamp from FSRS
const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.recordReview(
schedule.fsrsData,
actualFsrsRating, // Pass the correctly determined FsrsRating
reviewDateObj // Pass the exact moment of review
);
schedule.fsrsData = updatedData;
schedule.nextReviewDate = newNextReviewDateFsrs; // This is already a UTC timestamp from FSRS
// SM-2 specific fields are not updated for FSRS cards
schedule.interval = updatedData.scheduled_days; // For display consistency if needed
schedule.ease = Math.round(updatedData.difficulty * 10); // Approximate for display
} else { // SM-2
let qualityRating = toSM2Quality(response as ReviewResponse);
const qualityRating = toSM2Quality(response as ReviewResponse);
// Initialize SM-2 fields if they are somehow missing (should not happen for SM-2 cards)
schedule.ease = schedule.ease ?? this.plugin.settings.baseEase;
schedule.interval = schedule.interval ?? 0;
@ -323,8 +323,8 @@ export class ReviewScheduleService {
schedule.consecutive += 1;
if (qualityRating >= 3) {
// repetitionCount for initial phase should increment if successful, reset if not.
// This is distinct from the SM-2 n.
schedule.repetitionCount = (schedule.repetitionCount || 0) + 1;
// This is distinct from the SM-2 n.
schedule.repetitionCount = (schedule.repetitionCount || 0) + 1;
} else { // q < 3
schedule.repetitionCount = 0; // Reset progress in initial steps
}
@ -379,193 +379,122 @@ export class ReviewScheduleService {
currentInterval: number,
currentEase: number,
response: ReviewResponse,
repetitionCount: number = 0,
daysLate: number = 0,
isSkipped: boolean = false
repetitionCount = 0,
daysLate = 0,
isSkipped = false
): { interval: number, ease: number, repetitionCount: number } {
// Ensure response is in the valid SM-2 range (0-5)
let qualityRating = toSM2Quality(response);
// Handle overdue or skipped items according to modified SM-2 algorithm
if (isSkipped || daysLate > 0) {
// Determine effective quality rating:
// - If explicitly skipped, reduce quality by 1 (but not below 0)
// - If overdue, set quality to 0
// - If both overdue and explicitly skipped, prioritize the skip logic (user choice)
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
// Calculate new ease factor using SM-2 formula with the effective quality
ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// Force next review to be tomorrow (interval = 1) regardless of computed interval
// and reset repetition count to 1
return {
interval: 1, // Force next review to be tomorrow
ease: Math.round(ease * 100), // Convert back to internal format
repetitionCount: 1 // Reset repetition count to 1
};
}
// For normal reviews, use the regular SM-2 implementation
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval: number;
// Calculate new ease factor using SM-2 formula
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// If response is less than 3, reset repetition count to 0 (per strict SM-2)
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1; // Next interval is 1 day for failed items
} else {
// Increment repetition count for correct responses
newRepetitionCount += 1;
// Calculate new interval based on SM-2 rules
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
// For n > 2, use the formula I_n = I_(n-1) * EF
interval = Math.round(currentInterval * ease);
}
}
// Apply load balancing if enabled (this is an extension to the algorithm)
if (this.plugin.settings.loadBalance) {
const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0;
interval = interval + Math.random() * fuzz * 2 - fuzz;
}
// Ensure interval is at least 1 day
interval = Math.max(1, interval);
// Enforce maximum interval (this is an extension to the algorithm)
interval = Math.min(interval, this.plugin.settings.maximumInterval);
// Convert ease back to internal format before returning
return {
interval: Math.round(interval), // SM-2 uses whole days
ease: Math.round(ease * 100),
repetitionCount: newRepetitionCount
};
return this.calculateSM2Schedule(
currentInterval,
currentEase,
toSM2Quality(response),
repetitionCount,
daysLate,
isSkipped
);
}
/**
* Calculate new schedule parameters using the enhanced SM-2 algorithm with lateness penalty
* (This is the core calculation logic used internally by recordReview and skipNote)
*
* @param currentInterval Current interval in days
* @param currentEase Current ease factor (expressed as a number where 2.5 = 250)
* @param qualityRating User's response during review, as a numeric quality rating (0-5)
* @param repetitionCount Current repetition count (n)
* @param daysLate How many days late the review is (0 if on time or early)
* @param isSkipped Whether the item was explicitly skipped by the user
* @returns New interval, ease, and repetition count
*/
private calculateSM2Schedule(
currentInterval: number,
currentEase: number,
qualityRating: number, // Changed parameter type from ReviewResponse to number
repetitionCount: number = 0,
daysLate: number = 0,
isSkipped: boolean = false
): { interval: number, ease: number, repetitionCount: number } {
// qualityRating is now expected to be a number (0-5) directly.
// The call to toSM2Quality(response) is removed.
/**
* Calculate new schedule parameters using the enhanced SM-2 algorithm with lateness penalty
* (This is the core calculation logic used internally by recordReview and skipNote)
*
* @param currentInterval Current interval in days
* @param currentEase Current ease factor (expressed as a number where 2.5 = 250)
* @param qualityRating User's response during review, as a numeric quality rating (0-5)
* @param repetitionCount Current repetition count (n)
* @param daysLate How many days late the review is (0 if on time or early)
* @param isSkipped Whether the item was explicitly skipped by the user
* @returns New interval, ease, and repetition count
*/
private calculateSM2Schedule(
currentInterval: number,
currentEase: number,
qualityRating: number, // Changed parameter type from ReviewResponse to number
repetitionCount = 0,
daysLate = 0,
isSkipped = false
): { interval: number, ease: number, repetitionCount: number } {
// qualityRating is now expected to be a number (0-5) directly.
// The call to toSM2Quality(response) is removed.
// Handle overdue or skipped items according to modified SM-2 algorithm
if (isSkipped || daysLate > 0) {
// Determine effective quality rating:
// - If explicitly skipped, reduce quality by 1 (but not below 0)
// - If overdue, set quality to 0
// - If both overdue and explicitly skipped, prioritize the skip logic (user choice)
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
// Handle overdue or skipped items according to modified SM-2 algorithm
if (isSkipped || daysLate > 0) {
// Determine effective quality rating:
// - If explicitly skipped, reduce quality by 1 (but not below 0)
// - If overdue, set quality to 0
// - If both overdue and explicitly skipped, prioritize the skip logic (user choice)
const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0;
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
// Calculate new ease factor using SM-2 formula with the effective quality
ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
// Calculate new ease factor using SM-2 formula with the effective quality
ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// Force next review to be tomorrow (interval = 1) regardless of computed interval
// and reset repetition count to 1
const result = {
interval: 1, // Force next review to be tomorrow
ease: Math.round(ease * 100), // Convert back to internal format
repetitionCount: 1 // Reset repetition count to 1
};
// Force next review to be tomorrow (interval = 1) regardless of computed interval
// and reset repetition count to 1
const result = {
interval: 1, // Force next review to be tomorrow
ease: Math.round(ease * 100), // Convert back to internal format
repetitionCount: 1 // Reset repetition count to 1
};
return result;
}
return result;
}
// For normal reviews, use the regular SM-2 implementation
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval: number;
// For normal reviews, use the regular SM-2 implementation
// Convert ease from internal format (250 = 2.5) to SM-2 format (2.5)
let ease = currentEase / 100;
let newRepetitionCount = repetitionCount;
let interval: number;
// Calculate new ease factor using SM-2 formula
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
// Calculate new ease factor using SM-2 formula
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02));
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// Apply minimum ease factor (SM-2 specifies 1.3 as the minimum)
ease = Math.max(1.3, ease);
// If response is less than 3, reset repetition count to 0 (per strict SM-2)
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1; // Next interval is 1 day for failed items
} else {
// Increment repetition count for correct responses
newRepetitionCount += 1;
// If response is less than 3, reset repetition count to 0 (per strict SM-2)
if (qualityRating < 3) {
newRepetitionCount = 0;
interval = 1; // Next interval is 1 day for failed items
} else {
// Increment repetition count for correct responses
newRepetitionCount += 1;
// Calculate new interval based on SM-2 rules
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
// For n > 2, use the formula I_n = I_(n-1) * EF
interval = Math.round(currentInterval * ease);
}
}
// Calculate new interval based on SM-2 rules
if (newRepetitionCount === 1) {
interval = 1;
} else if (newRepetitionCount === 2) {
interval = 6;
} else {
// For n > 2, use the formula I_n = I_(n-1) * EF
interval = Math.round(currentInterval * ease);
}
}
// Apply load balancing if enabled (this is an extension to the algorithm)
if (this.plugin.settings.loadBalance) {
const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0;
interval = interval + Math.random() * fuzz * 2 - fuzz;
}
// Apply load balancing if enabled (this is an extension to the algorithm)
if (this.plugin.settings.loadBalance) {
const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0;
interval = interval + Math.random() * fuzz * 2 - fuzz;
}
// Ensure interval is at least 1 day
interval = Math.max(1, interval);
// Ensure interval is at least 1 day
interval = Math.max(1, interval);
// Enforce maximum interval (this is an extension to the algorithm)
interval = Math.min(interval, this.plugin.settings.maximumInterval);
// Enforce maximum interval (this is an extension to the algorithm)
interval = Math.min(interval, this.plugin.settings.maximumInterval);
// Convert ease back to internal format before returning
return {
interval: Math.round(interval), // SM-2 uses whole days
ease: Math.round(ease * 100),
repetitionCount: newRepetitionCount
};
}
// Convert ease back to internal format before returning
return {
interval: Math.round(interval), // SM-2 uses whole days
ease: Math.round(ease * 100),
repetitionCount: newRepetitionCount
};
}
/**
@ -575,7 +504,7 @@ export class ReviewScheduleService {
* @param matchExactDate If true, only return notes due exactly on this date (ignoring time). Otherwise, notes due on or before this date.
* @returns Array of due note schedules sorted by due date
*/
getDueNotes(date: number = Date.now(), matchExactDate: boolean = false): ReviewSchedule[] {
getDueNotes(date: number = Date.now(), matchExactDate = false): ReviewSchedule[] {
const targetDate = new Date(date);
const targetUTCDayStart = DateUtils.startOfUTCDay(targetDate);
const targetUTCDayEnd = DateUtils.endOfUTCDay(targetDate);
@ -601,7 +530,7 @@ export class ReviewScheduleService {
* @param days Number of days to look ahead
* @returns Array of upcoming review schedules sorted by due date
*/
getUpcomingReviews(days: number = 7): ReviewSchedule[] {
getUpcomingReviews(days = 7): ReviewSchedule[] {
const now = Date.now();
const futureDate = DateUtils.addDays(now, days);
@ -625,10 +554,10 @@ export class ReviewScheduleService {
* @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: string, response: ReviewResponse | FsrsRating = ReviewResponse.CorrectWithDifficulty, currentReviewDate?: number): Promise<void> {
skipNote(path: string, response: ReviewResponse | FsrsRating = ReviewResponse.CorrectWithDifficulty, currentReviewDate?: number): void {
const schedule = this.schedules[path];
if (!schedule) return;
const effectiveReviewDate = currentReviewDate || Date.now();
const reviewDateObj = new Date(effectiveReviewDate);
@ -639,7 +568,7 @@ export class ReviewScheduleService {
if (!schedule.fsrsData) { // Should not happen
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj);
}
const { updatedData, nextReviewDate: newNextReviewDateFsrs, log } = this.fsrsService.skipReview(
const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.skipReview(
schedule.fsrsData,
reviewDateObj // Pass exact moment for FSRS skip
);
@ -696,7 +625,7 @@ export class ReviewScheduleService {
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path: string, days: number = 1): Promise<void> {
postponeNote(path: string, days = 1): void {
const schedule = this.schedules[path];
if (!schedule) return;
@ -715,7 +644,7 @@ export class ReviewScheduleService {
}, 50); // Small delay (e.g., 50ms)
}
new Notice(`Review postponed for ${days} day${days !== 1 ? 's' : ''}`);
new Notice(`Review postponed for ${days} day${days !== 1 ? 's' : ''}.`);
}
/**
@ -724,7 +653,7 @@ export class ReviewScheduleService {
* @param path Path to the note file
* @returns True if the note was advanced, false otherwise.
*/
async advanceNote(path: string): Promise<boolean> {
advanceNote(path: string): boolean {
const schedule = this.schedules[path];
if (!schedule) {
return false;
@ -743,7 +672,7 @@ export class ReviewScheduleService {
// New potential next review date is one day earlier.
// If FSRS, it's one day earlier from its exact time. If SM-2, one day earlier from its UTC midnight.
const newPotentialNextReviewTimestamp = DateUtils.addDays(schedule.nextReviewDate, -1);
// Ensure the new date (if SM-2, its UTC day start) is not before todayUTCMidnight
if (schedule.schedulingAlgorithm === 'sm2') {
schedule.nextReviewDate = Math.max(todayUTCMidnight, DateUtils.startOfUTCDay(new Date(newPotentialNextReviewTimestamp)));
@ -771,7 +700,7 @@ export class ReviewScheduleService {
*
* @param path Path to the note file
*/
async removeFromReview(path: string): Promise<void> {
removeFromReview(path: string): void {
if (this.schedules[path]) {
delete this.schedules[path];
@ -779,7 +708,7 @@ export class ReviewScheduleService {
this.customNoteOrder = this.customNoteOrder.filter(p => p !== path);
// Data saving is now handled by main.ts after this method returns
new Notice("Note removed from review schedule");
new Notice("Note removed from review schedule.");
// Note: The controller will be notified separately to update its state
// This prevents immediate reordering based on link analysis after removal.
@ -793,11 +722,11 @@ export class ReviewScheduleService {
/**
* Clear all review schedules
*/
async clearAllSchedules(): Promise<void> {
clearAllSchedules(): void {
this.schedules = {};
this.customNoteOrder = []; // Also clear custom order
// Data saving is now handled by main.ts after this method returns
new Notice("All review schedules have been cleared");
new Notice("All review schedules have been cleared.");
// Notify any listeners (for UI updates)
if (this.plugin.events) {
@ -807,7 +736,7 @@ export class ReviewScheduleService {
// Explicitly update the review controller's state
// This dependency might need to be managed differently, e.g., via events
if (this.plugin.reviewController) {
await this.plugin.reviewController.updateTodayNotes();
void this.plugin.reviewController.updateTodayNotes();
}
}
@ -824,7 +753,7 @@ export class ReviewScheduleService {
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch (error) {
} catch {
return 60; // Default 1 minute
}
}
@ -836,14 +765,14 @@ export class ReviewScheduleService {
* @param daysFromNow Days until first review (default: 0, same day)
* @returns Number of notes scheduled
*/
async scheduleNotesInOrder(paths: string[], daysFromNow: number = 0): Promise<number> {
scheduleNotesInOrder(paths: string[], daysFromNow = 0): number {
let count = 0;
// Schedule each note in the provided order
for (const path of paths) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile) || file.extension !== "md" || this.schedules[path]) {
continue;
continue;
}
const now = Date.now();
@ -860,9 +789,9 @@ export class ReviewScheduleService {
};
} else { // sm2
newSchedule = {
path, lastReviewDate: null,
path, lastReviewDate: null,
nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), // UTC midnight
ease: this.plugin.settings.baseEase, interval: daysFromNow,
ease: this.plugin.settings.baseEase, interval: daysFromNow,
consecutive: 0, reviewCount: 0, repetitionCount: 0,
scheduleCategory: this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced',
schedulingAlgorithm: 'sm2', fsrsData: undefined,
@ -878,7 +807,7 @@ export class ReviewScheduleService {
}
}
this.schedules[path] = newSchedule;
// Add to custom order if not already present
if (!this.customNoteOrder.includes(path)) {
this.customNoteOrder.push(path);
@ -902,11 +831,11 @@ export class ReviewScheduleService {
/**
* Update the custom note order - used to maintain user-defined ordering
*
* @param order Array of note paths in desired order
* @param newOrder Array of note paths in desired order
*/
async updateCustomNoteOrder(order: string[]): Promise<void> {
updateCustomNoteOrder(newOrder: string[]): void {
// Filter out duplicate paths and ensure we only store paths that exist in our schedules
const uniqueValidPaths = Array.from(new Set(order)).filter(path => this.schedules[path] !== undefined);
const uniqueValidPaths = Array.from(new Set(newOrder)).filter(path => this.schedules[path] !== undefined);
this.customNoteOrder = uniqueValidPaths;
// Data saving is now handled by main.ts after this method returns
@ -925,7 +854,7 @@ export class ReviewScheduleService {
* @param matchExactDate Passed to getDueNotes to filter by exact date if true.
* @returns Array of due note schedules sorted appropriately
*/
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder: boolean = true, matchExactDate: boolean = false): ReviewSchedule[] {
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder = true, matchExactDate = false): ReviewSchedule[] {
// First, get all due notes using the modified method
const dueNotes = this.getDueNotes(date, matchExactDate);
@ -989,7 +918,7 @@ export class ReviewScheduleService {
this.customNoteOrder.push(newPath);
}
}
// Notify any listeners (for UI updates)
// Data saving will be handled by the caller in main.ts
@ -1007,8 +936,7 @@ export class ReviewScheduleService {
return 2;
}
public async convertAllSm2ToFsrs(): Promise<void> {
let convertedCount = 0;
public convertAllSm2ToFsrs(): void {
for (const path in this.schedules) {
if (Object.prototype.hasOwnProperty.call(this.schedules, path)) {
const schedule = this.schedules[path];
@ -1018,15 +946,15 @@ export class ReviewScheduleService {
const baseDate = schedule.lastReviewDate ? new Date(schedule.lastReviewDate) : new Date();
schedule.fsrsData = this.fsrsService.createNewFsrsCardData(baseDate);
// New FSRS cards are typically due 'now' relative to their creation/conversion.
schedule.nextReviewDate = baseDate.getTime();
schedule.nextReviewDate = baseDate.getTime();
// Clear or nullify SM-2 specific fields
schedule.ease = this.plugin.settings.baseEase; // Keep a base ease for potential future conversion back
schedule.interval = 0; // Reset SM-2 interval
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = undefined;
convertedCount++;
}
}
}
@ -1035,8 +963,7 @@ export class ReviewScheduleService {
}
}
public async convertAllFsrsToSm2(): Promise<void> {
let convertedCount = 0;
public convertAllFsrsToSm2(): void {
for (const path in this.schedules) {
if (Object.prototype.hasOwnProperty.call(this.schedules, path)) {
const schedule = this.schedules[path];
@ -1047,7 +974,7 @@ export class ReviewScheduleService {
schedule.repetitionCount = 0;
schedule.consecutive = 0;
schedule.scheduleCategory = this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced';
// Set next review date based on SM-2 initial logic, using UTC days
const now = Date.now();
const todayUTCStart = DateUtils.startOfUTCDay(new Date(now));
@ -1057,9 +984,8 @@ export class ReviewScheduleService {
nextReview = DateUtils.addDays(todayUTCStart, schedule.interval);
}
schedule.nextReviewDate = nextReview;
schedule.fsrsData = undefined; // Clear FSRS data
convertedCount++;
}
}
}

View file

@ -78,7 +78,7 @@ export class ReviewSessionService {
}
return session;
} catch (error) {
} catch {
new Notice("Failed to create review session");
return null;
}
@ -90,7 +90,7 @@ export class ReviewSessionService {
* @param sessionId ID of the session to activate
* @returns Whether the session was activated
*/
async setActiveSession(sessionId: string | null): Promise<boolean> {
setActiveSession(sessionId: string | null): boolean {
if (sessionId === null) {
this.reviewSessions.activeSessionId = null;
// Data saving is now handled by main.ts after this method returns
@ -156,7 +156,7 @@ export class ReviewSessionService {
*
* @returns Whether there are more files to review
*/
async advanceActiveSession(): Promise<boolean> {
advanceActiveSession(): boolean {
const session = this.getActiveSession();
if (!session) {
return false;
@ -191,7 +191,7 @@ export class ReviewSessionService {
* @param sessionId ID of the session
* @returns Number of files scheduled
*/
async scheduleSessionForReview(sessionId: string): Promise<number> {
scheduleSessionForReview(sessionId: string): number {
const session = this.reviewSessions.sessions[sessionId];
if (!session) {
return 0;
@ -199,10 +199,10 @@ export class ReviewSessionService {
// Access ReviewScheduleService via plugin reference
if (!this.plugin.reviewScheduleService) {
return 0;
return 0;
}
// Call the method on the ReviewScheduleService instance
return await this.plugin.reviewScheduleService.scheduleNotesInOrder(session.hierarchy.traversalOrder);
return this.plugin.reviewScheduleService.scheduleNotesInOrder(session.hierarchy.traversalOrder);
}
}

View file

@ -13,26 +13,26 @@ export class BatchReviewModal extends Modal {
plugin: SpaceforgePlugin;
notes: ReviewSchedule[];
useMCQ: boolean;
currentIndex: number = 0;
currentIndex = 0;
results: Array<{
path: string,
success: boolean,
response: ReviewResponse,
score?: number
}> = [];
started: boolean = false;
started = false;
allMCQSets: {
path: string;
mcqSet: MCQSet;
fileName: string;
}[] = [];
collectingMCQs: boolean = false;
collectingMCQs = false;
constructor(
app: App,
plugin: SpaceforgePlugin,
notes: ReviewSchedule[],
useMCQ: boolean = false
useMCQ = false
) {
super(app);
this.plugin = plugin;
@ -40,25 +40,27 @@ export class BatchReviewModal extends Modal {
this.useMCQ = useMCQ;
}
async onOpen(): Promise<void> {
onOpen(): void {
const { contentEl } = this;
this.renderStartScreen(contentEl);
}
renderStartScreen(contentEl: HTMLElement): void {
contentEl.empty();
new Setting(contentEl).setName("Batch Review").setHeading();
new Setting(contentEl).setName("Batch review").setHeading();
const infoEl = contentEl.createDiv("batch-review-info");
infoEl.createEl("p", { text: `${this.notes.length} notes scheduled for review` });
this.estimateAndShowTime(infoEl);
void this.estimateAndShowTime(infoEl); // Explicitly ignore promise to satisfy linter
const buttonsEl = contentEl.createDiv("batch-review-buttons");
const startButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Start MCQ Review" : "Start Manual Review",
text: this.useMCQ ? "Start MCQ review" : "Start manual review",
cls: "batch-review-start-button"
});
startButton.addEventListener("click", () => this.startBatchReview());
startButton.addEventListener("click", () => {
void this.startBatchReview();
});
const toggleMCQButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Switch to Manual Review" : "Switch to MCQ Review",
text: this.useMCQ ? "Switch to manual review" : "Switch to MCQ review",
cls: "batch-review-toggle-button"
});
toggleMCQButton.addEventListener("click", () => {
@ -67,13 +69,13 @@ export class BatchReviewModal extends Modal {
});
if (this.useMCQ) {
const regenerateButton = buttonsEl.createEl("button", {
text: "Regenerate All MCQs",
text: "Regenerate all MCQs",
cls: "batch-review-regenerate-button"
});
regenerateButton.addEventListener("click", () => {
this.close();
if (this.plugin.batchController) {
this.plugin.batchController.regenerateAllMCQs();
void this.plugin.batchController.regenerateAllMCQs();
}
});
}
@ -111,7 +113,7 @@ export class BatchReviewModal extends Modal {
async collectAllMCQs(): Promise<void> {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl).setName("Collecting MCQs").setHeading();
new Setting(contentEl).setName("Collecting MCQs").setHeading(); // eslint-disable-line obsidianmd/ui/sentence-case
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 sf-progress-bar-container-batch");
@ -134,7 +136,7 @@ export class BatchReviewModal extends Modal {
try {
// generateMCQs now returns the set or null
mcqSet = await this.plugin.mcqController.generateMCQs(note.path);
} catch (error) {
} catch {
statusEl.setText(`Error generating MCQs for ${fileName}`);
await sleep(1000);
}
@ -159,23 +161,25 @@ export class BatchReviewModal extends Modal {
const consolidatedModal = new ConsolidatedMCQModal(
this.plugin,
this.allMCQSets,
async (results: Array<{ path: string, success: boolean, response: ReviewResponse, score?: number }>) => {
(results: Array<{ path: string, success: boolean, response: ReviewResponse, score?: number }>) => {
this.results = results;
await this.recordAllReviews(results);
this.open();
this.showSummary();
void (async () => {
await this.recordAllReviews(results);
this.open();
this.showSummary();
})();
}
);
consolidatedModal.open();
} catch (error) {
new Notice("Error showing MCQ review. Falling back to manual review.");
} catch (_error) {
new Notice("Error showing MCQ review. Falling back to manual review."); // eslint-disable-line obsidianmd/ui/sentence-case
this.open();
this.processNextManual();
void this.processNextManual();
}
} else {
new Notice("MCQ controller not available. Falling back to manual review.");
new Notice("MCQ controller not available. Falling back to manual review."); // eslint-disable-line obsidianmd/ui/sentence-case
this.open();
this.processNextManual();
void this.processNextManual();
}
}
@ -194,7 +198,7 @@ export class BatchReviewModal extends Modal {
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl).setName("MCQ Review in Progress").setHeading();
new Setting(contentEl).setName("MCQ review in progress").setHeading(); // eslint-disable-line obsidianmd/ui/sentence-case
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);
@ -213,7 +217,7 @@ export class BatchReviewModal extends Modal {
if (mcqSet) {
// Update the call to startMCQReview - remove the callback argument
this.plugin.mcqController.startMCQReview(note.path /* Removed callback */);
void this.plugin.mcqController.startMCQReview(note.path /* Removed callback */);
// NOTE: The logic to handle the result and move to the next note needs to be
// re-implemented if this flow is used instead of the consolidated modal.
// The original callback logic is commented out below for reference.
@ -237,22 +241,22 @@ export class BatchReviewModal extends Modal {
this.processNextMCQ(); // Continue
}
*/
// Since the callback is removed, we need a way to know when the individual modal closes.
// This flow is now broken and relies on the consolidated modal.
// Fallback or error handling might be needed here if consolidated fails.
this.open(); // Reopen immediately for now, but this won't wait for the MCQ modal.
this.showSummary(); // Go to summary as the flow is broken.
// Since the callback is removed, we need a way to know when the individual modal closes.
// This flow is now broken and relies on the consolidated modal.
// Fallback or error handling might be needed here if consolidated fails.
this.open(); // Reopen immediately for now, but this won't wait for the MCQ modal.
this.showSummary(); // Go to summary as the flow is broken.
} else {
new Notice(`Couldn't generate MCQs for ${fileName}, falling back to manual review`);
new Notice(`Couldn't generate MCQs for ${fileName}, falling back to manual review.`);
this.open();
this.processNextManual();
void this.processNextManual();
}
} else {
new Notice("MCQ controller not available, falling back to manual review");
new Notice("MCQ controller not available, falling back to manual review."); // eslint-disable-line obsidianmd/ui/sentence-case
this.open();
this.processNextManual();
void this.processNextManual();
}
}
@ -269,7 +273,7 @@ export class BatchReviewModal extends Modal {
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl).setName("Manual Review").setHeading();
new Setting(contentEl).setName("Manual review").setHeading();
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);
@ -281,44 +285,44 @@ export class BatchReviewModal extends Modal {
}
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, ReviewResponse.CompleteBlackout));
const incorrectButton = buttonsContainer.createEl("button", { text: "1: Incorrect Response", cls: "review-button review-button-incorrect" });
incorrectButton.addEventListener("click", () => this.recordManualResult(note.path, ReviewResponse.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, ReviewResponse.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, ReviewResponse.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, ReviewResponse.CorrectWithHesitation));
const perfectRecallButton = buttonsContainer.createEl("button", { text: "5: Perfect Recall", cls: "review-button review-button-perfect-recall" });
perfectRecallButton.addEventListener("click", () => this.recordManualResult(note.path, ReviewResponse.PerfectRecall));
const blackoutButton = buttonsContainer.createEl("button", { text: "0: Complete blackout", cls: "review-button review-button-complete-blackout" }); // eslint-disable-line obsidianmd/ui/sentence-case
blackoutButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.CompleteBlackout));
const incorrectButton = buttonsContainer.createEl("button", { text: "1: Incorrect response", cls: "review-button review-button-incorrect" }); // eslint-disable-line obsidianmd/ui/sentence-case
incorrectButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.IncorrectResponse));
const incorrectFamiliarButton = buttonsContainer.createEl("button", { text: "2: Incorrect but familiar", cls: "review-button review-button-incorrect-familiar" }); // eslint-disable-line obsidianmd/ui/sentence-case
incorrectFamiliarButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.IncorrectButFamiliar));
const correctDifficultyButton = buttonsContainer.createEl("button", { text: "3: Correct with difficulty", cls: "review-button review-button-correct-difficulty" }); // eslint-disable-line obsidianmd/ui/sentence-case
correctDifficultyButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.CorrectWithDifficulty));
const correctHesitationButton = buttonsContainer.createEl("button", { text: "4: Correct with hesitation", cls: "review-button review-button-correct-hesitation" }); // eslint-disable-line obsidianmd/ui/sentence-case
correctHesitationButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.CorrectWithHesitation));
const perfectRecallButton = buttonsContainer.createEl("button", { text: "5: Perfect recall", cls: "review-button review-button-perfect-recall" }); // eslint-disable-line obsidianmd/ui/sentence-case
perfectRecallButton.addEventListener("click", () => void this.recordManualResult(note.path, ReviewResponse.PerfectRecall));
const skipButton = buttonsContainer.createEl("button", { text: "Skip", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", () => {
this.currentIndex++;
this.processNextManual();
void this.processNextManual();
});
}
async recordManualResult(path: string, response: ReviewResponse): Promise<void> {
this.results.push({ path, success: response >= ReviewResponse.CorrectWithDifficulty, response }); // Assuming 3+ is success
// Use the data storage method to get the return value
const wasRecorded = await this.plugin.dataStorage.recordReview(path, response);
if (!wasRecorded) {
// Note was not due, this is just a preview
new Notice("Note previewed, not recorded");
new Notice("Note previewed, not recorded.");
}
this.currentIndex++;
this.processNextManual();
void this.processNextManual();
}
showSummary(): void {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl).setName("Batch Review Complete").setHeading();
new Setting(contentEl).setName('MCQ review complete').setHeading().setClass('mcq-review-complete-header'); // eslint-disable-line obsidianmd/ui/sentence-case
const statsEl = contentEl.createDiv("batch-review-summary-stats");
const totalNotes = this.results.length;
const successfulNotes = this.results.filter(r => r.success).length;
@ -326,7 +330,7 @@ export class BatchReviewModal extends Modal {
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");
new Setting(resultsEl).setHeading().setName("Individual Results");
new Setting(resultsEl).setHeading().setName("Individual results");
const resultsListEl = resultsEl.createDiv("batch-review-results-list");
for (const result of this.results) {
@ -335,7 +339,7 @@ export class BatchReviewModal extends Modal {
const fileName = file instanceof TFile ? file.basename : result.path;
resultItemEl.createEl("div", { text: fileName, cls: "batch-review-result-filename" });
let responseText: string; let responseClass: string;
switch(result.response) {
switch (result.response) {
case ReviewResponse.CompleteBlackout: responseText = "Complete Blackout (0)"; responseClass = "batch-review-complete-blackout"; break;
case ReviewResponse.IncorrectResponse: responseText = "Incorrect Response (1)"; responseClass = "batch-review-incorrect"; break;
case ReviewResponse.IncorrectButFamiliar: responseText = "Incorrect but Familiar (2)"; responseClass = "batch-review-incorrect-familiar"; break;
@ -352,7 +356,7 @@ export class BatchReviewModal extends Modal {
const closeButton = contentEl.createEl("button", { text: "Close", cls: "batch-review-close-button" });
closeButton.addEventListener("click", () => {
this.close();
this.plugin.getSidebarView()?.refresh();
void this.plugin.getSidebarView()?.refresh();
});
}
@ -360,7 +364,7 @@ export class BatchReviewModal extends Modal {
const { contentEl } = this;
contentEl.empty();
if (this.started && this.currentIndex < this.notes.length && this.results.length > 0) {
new Notice(`Batch review interrupted after ${this.results.length} notes`);
new Notice(`Batch review interrupted after ${this.results.length} notes.`);
}
}
}

View file

@ -1,7 +1,8 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { ReviewSchedule } from "../models/review-schedule";
import { CalendarEvent, EventCategory, EventRecurrence } from "../models/calendar-event";
import { CalendarEvent } from "../models/calendar-event";
import { DateUtils } from "../utils/dates";
import { EstimationUtils } from "../utils/estimation";
import { UpcomingEvents } from "./upcoming-events";
@ -32,12 +33,12 @@ export class CalendarView {
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Calendar container element
*/
containerEl: HTMLElement;
/**
* Current date being displayed
*/
@ -59,11 +60,11 @@ export class CalendarView {
private calendarGridEl: HTMLElement | null = null;
private upcomingEventsEl: HTMLElement | null = null;
private upcomingEventsComponent: UpcomingEvents | null = null;
// Tooltip elements
private tooltipEl: HTMLElement | null = null;
private tooltipTimeout: number | null = null;
/**
* Initialize calendar view
*
@ -75,18 +76,18 @@ export class CalendarView {
this.plugin = plugin;
this.currentDate = new Date();
}
/**
* Render the calendar view
*/
async render(): Promise<void> {
this.ensureCalendarBaseStructure(); // Ensures header and grid containers exist
this.updateCalendarHeader(); // Updates month title
await this.loadReviewsData(); // Preloads review data for the current view
await this.loadEventsData(); // Preloads event data for the current view
if (this.calendarGridEl) {
this.renderCalendarGridContent(this.calendarGridEl); // Renders/updates the grid
}
@ -108,30 +109,30 @@ export class CalendarView {
if (!this.calendarHeaderEl || !calendarContainer.contains(this.calendarHeaderEl)) {
this.calendarHeaderEl?.remove();
this.calendarHeaderEl = calendarContainer.createDiv("calendar-header");
const prevMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn");
setIcon(prevMonthBtn, "chevron-left");
prevMonthBtn.addEventListener("click", () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.render();
void this.render();
});
this.monthTitleEl = this.calendarHeaderEl.createDiv("calendar-month-title");
const nextMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn");
setIcon(nextMonthBtn, "chevron-right");
nextMonthBtn.addEventListener("click", () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.render();
void this.render();
});
const todayBtn = this.calendarHeaderEl.createDiv("calendar-today-btn");
todayBtn.setText("Today");
todayBtn.addEventListener("click", () => {
this.currentDate = new Date();
this.render();
void this.render();
});
const addEventBtn = this.calendarHeaderEl.createDiv("calendar-add-event-btn");
setIcon(addEventBtn, "plus");
addEventBtn.addEventListener("click", () => {
@ -151,28 +152,28 @@ export class CalendarView {
this.upcomingEventsComponent = new UpcomingEvents(this.upcomingEventsEl, this.plugin);
}
}
/**
* Update calendar header (month title)
*/
updateCalendarHeader(): void {
if (this.monthTitleEl) {
this.monthTitleEl.setText(
this.currentDate.toLocaleString('default', {
month: 'long',
year: 'numeric'
this.currentDate.toLocaleString('default', {
month: 'long',
year: 'numeric'
})
);
}
}
/**
* Load and organize review data by date
*/
async loadReviewsData(): Promise<void> {
this.reviewsByDate = new Map();
const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules);
for (const schedule of allSchedules) {
const scheduleDueDayStart = DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate));
const dateKey = scheduleDueDayStart.toString(); // Use timestamp as a robust key
@ -184,7 +185,7 @@ export class CalendarView {
totalTime: 0
});
}
const dateReviews = this.reviewsByDate.get(dateKey);
if (dateReviews) {
dateReviews.notes.push(schedule);
@ -196,9 +197,9 @@ export class CalendarView {
/**
* Load and organize event data by date
*/
async loadEventsData(): Promise<void> {
loadEventsData(): void {
this.eventsByDate = new Map();
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.calendarEventService) {
return;
}
@ -206,7 +207,7 @@ export class CalendarView {
const { year, month } = this.getCalendarData();
const startDate = new Date(year, month, 1);
const endDate = new Date(year, month + 1, 0); // Last day of month
const eventsInRange = this.plugin.calendarEventService.getEventsInRange(
startDate.getTime(),
endDate.getTime()
@ -222,14 +223,14 @@ export class CalendarView {
events: []
});
}
const dateEvents = this.eventsByDate.get(dateKey);
if (dateEvents) {
dateEvents.events.push(event);
}
}
}
/**
* Render or update the calendar grid content
*
@ -246,7 +247,7 @@ export class CalendarView {
dayHeader.setText(day);
});
}
const { year, month, firstDay, daysInMonth } = this.getCalendarData();
const totalCells = 42; // Standard 6 weeks * 7 days grid
let dayCells = Array.from(gridEl.querySelectorAll(".calendar-day")) as HTMLElement[];
@ -286,32 +287,34 @@ export class CalendarView {
const dateReviews = this.reviewsByDate.get(dateKey);
const dateEvents = this.eventsByDate.get(dateKey);
// Render reviews
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 = new Date();
const isClickedDateToday = DateUtils.isSameDay(currentDateObj, today);
dayCell.addEventListener("click", () => {
// today assignment removed
this.plugin.settings.sidebarViewType = 'list';
this.plugin.clickedDateFromCalendar = currentDateObj;
await this.plugin.savePluginData();
const sidebarView = this.plugin.getSidebarView();
if (sidebarView && typeof sidebarView.refresh === 'function') {
await sidebarView.refresh();
} else {
this.plugin.app.workspace.requestSaveLayout();
new Notice("Switched to list view. Sidebar will update.");
}
void (async () => {
await this.plugin.savePluginData();
const sidebarView = this.plugin.getSidebarView();
if (sidebarView && typeof sidebarView.refresh === 'function') {
await sidebarView.refresh();
} else {
this.plugin.app.workspace.requestSaveLayout();
new Notice("Switched to list view. Sidebar will update.");
}
})();
});
if (dateReviews.notes.length > 10) dayCell.addClass("heavy-load");
@ -322,45 +325,45 @@ export class CalendarView {
// Render events as small tabs
if (dateEvents && dateEvents.events.length > 0) {
dayCell.addClass("has-events");
const eventsContainer = dayCell.createDiv("calendar-events-container");
// Show up to 3 event tabs
const eventsToShow = dateEvents.events.slice(0, 3);
eventsToShow.forEach(event => {
const eventTab = eventsContainer.createDiv("calendar-event-tab");
eventTab.setText(event.title.substring(0, 8) + (event.title.length > 8 ? "..." : ""));
// Set color based on event category or custom color using CSS custom property
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
eventTab.style.setProperty('--event-color', eventColor);
// Add hover handler for tooltip
eventTab.addEventListener("mouseenter", (e) => {
eventTab.addEventListener("mouseenter", (_e) => {
this.showEventTooltip(eventTab, event);
});
eventTab.addEventListener("mouseleave", (e) => {
eventTab.addEventListener("mouseleave", (_e) => {
this.hideEventTooltip();
});
// Add click handler for event
eventTab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent day cell click
this.showEventDetails(event);
});
// Add double-click handler for editing
eventTab.addEventListener("dblclick", (e) => {
e.stopPropagation(); // Prevent day cell click
this.openEditEventModal(event);
});
});
// Show "more" indicator if there are more events
if (dateEvents.events.length > 3) {
const moreIndicator = eventsContainer.createDiv("calendar-events-more");
moreIndicator.setText(`+${dateEvents.events.length - 3}`);
moreIndicator.setText(`+ ${dateEvents.events.length - 3} `);
}
}
@ -377,7 +380,7 @@ export class CalendarView {
}
}
}
/**
* Get calendar data for the current month
*
@ -386,16 +389,16 @@ export class CalendarView {
getCalendarData(): { year: number, month: number, firstDay: number, daysInMonth: number } {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// First day of month (0-6, where 0 is Sunday)
const firstDay = new Date(year, month, 1).getDay();
// Days in month
const daysInMonth = new Date(year, month + 1, 0).getDate();
return { year, month, firstDay, daysInMonth };
}
/**
* Check if a date is today
*
@ -422,29 +425,29 @@ export class CalendarView {
// For now, just show a notice with event details
// TODO: Create a proper modal for event details
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
let details = `📅 ${event.title}\n📆 ${dateStr}`;
let details = `📅 ${event.title} \n📆 ${dateStr} `;
if (event.time) {
details += `\n🕐 ${event.time}`;
details += `\n🕐 ${event.time} `;
}
if (event.description) {
details += `\n📝 ${event.description}`;
details += `\n📝 ${event.description} `;
}
if (event.location) {
details += `\n📍 ${event.location}`;
details += `\n📍 ${event.location} `;
}
details += `\n🏷 ${event.category}`;
details += `\n🏷 ${event.category} `;
new Notice(details, 8000);
}
@ -456,9 +459,9 @@ export class CalendarView {
this.plugin.app,
this.plugin,
null,
async (savedEvent) => {
(_savedEvent) => {
// Refresh the calendar view after saving
await this.render();
void this.render();
}
);
modal.open();
@ -470,26 +473,17 @@ export class CalendarView {
* @param date Date to pre-fill in the modal
*/
openCreateEventModalForDate(date: Date): void {
// Create a default event with the selected date
const defaultEvent = {
title: "",
date: date.getTime(),
isAllDay: true,
category: (this.plugin.settings.defaultEventCategory as EventCategory) || EventCategory.Personal,
recurrence: EventRecurrence.None
};
const modal = new EventModal(
this.plugin.app,
this.plugin,
null,
async (savedEvent) => {
(_savedEvent) => {
// Refresh the calendar view after saving
await this.render();
void this.render();
}
);
modal.open();
// Pre-fill the date in the modal after it opens
window.setTimeout(() => {
const dateInput = modal.contentEl.querySelector('input[type="date"]') as HTMLInputElement;
@ -509,9 +503,9 @@ export class CalendarView {
this.plugin.app,
this.plugin,
event,
async (savedEvent) => {
(_savedEvent) => {
// Refresh the calendar view after saving
await this.render();
void this.render();
}
);
modal.open();
@ -522,7 +516,7 @@ export class CalendarView {
*/
destroy(): void {
this.hideEventTooltip();
// Clear other references
this.calendarHeaderEl = null;
this.monthTitleEl = null;
@ -579,54 +573,53 @@ export class CalendarView {
// Create tooltip element
this.tooltipEl = document.body.createEl("div");
this.tooltipEl.className = "calendar-event-tooltip";
// Format event details
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
// Clear existing content
this.tooltipEl.empty();
// Create tooltip content safely
const titleEl = this.tooltipEl.createDiv('tooltip-title');
titleEl.textContent = event.title;
const dateEl = this.tooltipEl.createDiv('tooltip-date');
dateEl.textContent = `📅 ${dateStr}`;
dateEl.textContent = `📅 ${dateStr} `;
if (event.time) {
const timeEl = this.tooltipEl.createDiv('tooltip-time');
timeEl.textContent = `🕐 ${event.time}`;
timeEl.textContent = `🕐 ${event.time} `;
}
if (event.description) {
const descEl = this.tooltipEl.createDiv('tooltip-description');
descEl.textContent = `📝 ${event.description}`;
descEl.textContent = `📝 ${event.description} `;
}
if (event.location) {
const locationEl = this.tooltipEl.createDiv('tooltip-location');
locationEl.textContent = `📍 ${event.location}`;
locationEl.textContent = `📍 ${event.location} `;
}
const categoryEl = this.tooltipEl.createDiv('tooltip-category');
categoryEl.textContent = `🏷️ ${event.category}`;
categoryEl.textContent = `🏷️ ${event.category} `;
// Position tooltip
const rect = targetEl.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
// Add to DOM temporarily to get dimensions
document.body.appendChild(this.tooltipEl);
// Calculate position
let left = rect.left + (rect.width / 2) - (this.tooltipEl.offsetWidth / 2);
let top = rect.bottom + 8;
// Adjust if tooltip goes off screen
if (left < 8) left = 8;
if (left + this.tooltipEl.offsetWidth > window.innerWidth - 8) {
@ -635,13 +628,13 @@ export class CalendarView {
if (top + this.tooltipEl.offsetHeight > window.innerHeight - 8) {
top = rect.top - this.tooltipEl.offsetHeight - 8;
}
this.tooltipEl.style.left = `${left}px`;
this.tooltipEl.style.top = `${top}px`;
this.tooltipEl.style.left = `${left} px`;
this.tooltipEl.style.top = `${top} px`;
// Add fade-in animation using CSS class
this.tooltipEl.classList.remove('visible');
requestAnimationFrame(() => {
if (this.tooltipEl) {
this.tooltipEl.classList.add('visible');

52
ui/confirmation-modal.ts Normal file
View file

@ -0,0 +1,52 @@
import { App, Modal, Setting } from 'obsidian';
export class ConfirmationModal extends Modal {
private title: string;
private message: string;
private onConfirm: () => void | Promise<void>;
private onCancel: () => void | Promise<void>;
constructor(
app: App,
title: string,
message: string,
onConfirm: () => void | Promise<void>,
onCancel: () => void | Promise<void> = () => { }
) {
super(app);
this.title = title;
this.message = message;
this.onConfirm = onConfirm;
this.onCancel = onCancel;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: this.title });
contentEl.createEl('p', { text: this.message });
const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' });
new Setting(buttonContainer)
.addButton(btn => btn
.setButtonText('Cancel')
.onClick(() => {
void this.onCancel();
this.close();
}))
.addButton(btn => btn
.setButtonText('Confirm')
.setCta()
.onClick(() => {
void this.onConfirm();
this.close();
}));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,4 +1,4 @@
import { Modal, Notice, TFile, setIcon, Setting } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQSet } from '../models/mcq';
import { ReviewResponse } from '../models/review-schedule';
@ -12,7 +12,7 @@ export class ConsolidatedMCQModal extends Modal {
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Collection of all MCQ sets
*/
@ -21,7 +21,7 @@ export class ConsolidatedMCQModal extends Modal {
mcqSet: MCQSet;
fileName: string;
}[];
/**
* Callback for when review is completed
*/
@ -31,7 +31,7 @@ export class ConsolidatedMCQModal extends Modal {
response: ReviewResponse,
score?: number
}>) => void;
/**
* All questions from all MCQ sets, flattened
*/
@ -44,12 +44,12 @@ export class ConsolidatedMCQModal extends Modal {
mcqSetId: string;
originalIndex: number;
}[] = [];
/**
* Current question index
*/
currentQuestionIndex: number = 0;
currentQuestionIndex = 0;
/**
* User's answers
*/
@ -62,12 +62,12 @@ export class ConsolidatedMCQModal extends Modal {
notePath: string;
fileName: string;
}[] = [];
/**
* Start time for current question
*/
questionStartTime: number = 0;
questionStartTime = 0;
/**
* Initialize consolidated MCQ modal
*
@ -93,7 +93,7 @@ export class ConsolidatedMCQModal extends Modal {
this.plugin = plugin;
this.mcqSets = mcqSets;
this.onComplete = onComplete;
// Flatten all questions from all MCQ sets
for (const set of mcqSets) {
set.mcqSet.questions.forEach((question, index) => {
@ -106,37 +106,37 @@ export class ConsolidatedMCQModal extends Modal {
});
});
}
// Optional: Shuffle questions for better learning
// this.allQuestions = this.shuffleArray(this.allQuestions);
}
/**
* Called when the modal is opened
*/
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
// Add a unique class to the modal content for specific styling
contentEl.addClass('spaceforge-mcq-modal');
// Create header container
const headerContainer = contentEl.createDiv('mcq-header-container');
// Add title
new Setting(headerContainer).setName('Multiple Choice Review').setHeading();
new Setting(headerContainer).setName('Multiple choice review').setHeading();
// Display progress with questions from all notes, with enhanced styling
const progressEl = contentEl.createDiv('mcq-progress');
// Calculate progress percentage for dynamic styling
const progressPercent = Math.round(((this.currentQuestionIndex + 1) / this.allQuestions.length) * 100);
// Set data-progress for proper progress bar display
contentEl.setAttribute('data-progress', progressPercent.toString());
progressEl.setText(`Question ${this.currentQuestionIndex + 1} of ${this.allQuestions.length}`);
// Display a progress counter badge
const progressCounter = contentEl.createDiv('mcq-progress-counter');
progressCounter.setText(`${this.allQuestions.length} questions from ${this.mcqSets.length} notes`);
@ -144,17 +144,17 @@ export class ConsolidatedMCQModal extends Modal {
// Display note information with enhanced styling
const noteInfoEl = contentEl.createDiv('mcq-note-info');
noteInfoEl.setText(`Question from: ${this.allQuestions[this.currentQuestionIndex].fileName}`);
// Start timing
this.questionStartTime = Date.now();
// Display current question
this.displayCurrentQuestion(contentEl);
// Add keyboard shortcuts
this.registerKeyboardShortcuts();
}
/**
* Register keyboard shortcuts for answering questions
*/
@ -163,17 +163,17 @@ export class ConsolidatedMCQModal extends Modal {
// Get the current question
const questionIndex = this.currentQuestionIndex;
if (questionIndex >= this.allQuestions.length) return;
const question = this.allQuestions[questionIndex];
if (!question || !question.choices) return;
// Handle number keys 1-9 (for choices)
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= question.choices.length) {
this.handleAnswer(num - 1);
return;
}
// Handle letter keys A-I (for choices)
if (event.key.length === 1) {
const letterCode = event.key.toUpperCase().charCodeAt(0);
@ -184,10 +184,10 @@ export class ConsolidatedMCQModal extends Modal {
}
}
};
// Add event listener
document.addEventListener('keydown', keyDownHandler);
// Clean up when modal is closed
this.onClose = () => {
document.removeEventListener('keydown', keyDownHandler);
@ -195,7 +195,7 @@ export class ConsolidatedMCQModal extends Modal {
contentEl.empty();
};
}
/**
* Display the current question
*
@ -203,18 +203,18 @@ export class ConsolidatedMCQModal extends Modal {
*/
displayCurrentQuestion(containerEl: HTMLElement): void {
const questionIndex = this.currentQuestionIndex;
if (questionIndex >= this.allQuestions.length) {
this.completeReview();
return;
}
const question = this.allQuestions[questionIndex];
// Verify we have a valid question
if (!question || !question.choices || question.choices.length < 2) {
new Notice('Error: Invalid question data. Moving to next question.');
new Notice('Error: Invalid question data. Moving to next question.'); // eslint-disable-line obsidianmd/ui/sentence-case
// Skip to next question
this.currentQuestionIndex++;
if (this.currentQuestionIndex < this.allQuestions.length) {
@ -224,51 +224,51 @@ export class ConsolidatedMCQModal extends Modal {
}
return;
}
// Clear previous question
const questionContainer = containerEl.querySelector('.mcq-question-container');
if (questionContainer) {
questionContainer.remove();
}
// Update note info
const noteInfoEl = containerEl.querySelector('.mcq-note-info');
if (noteInfoEl instanceof HTMLElement) {
noteInfoEl.setText(`Question from: ${question.fileName}`);
}
// Create question container
const newQuestionContainer = containerEl.createDiv('mcq-question-container');
// Display question text
const questionEl = newQuestionContainer.createDiv('mcq-question-text');
questionEl.setText(question.question);
// Display choices
const choicesContainer = newQuestionContainer.createDiv('mcq-choices-container');
question.choices.forEach((choice, index) => {
const choiceEl = choicesContainer.createDiv('mcq-choice');
// Create choice button
const choiceBtn = choiceEl.createEl('button', {
cls: 'mcq-choice-btn'
});
// Add letter label
const letterLabel = choiceBtn.createSpan('mcq-choice-letter');
letterLabel.setText(String.fromCharCode(65 + index) + ') ');
// Add choice text
const textSpan = choiceBtn.createSpan('mcq-choice-text');
textSpan.setText(choice || '(Empty choice)'); // Handle empty choices
choiceBtn.addEventListener('click', () => {
this.handleAnswer(index);
});
});
}
/**
* Handle user's answer selection
*
@ -278,15 +278,15 @@ export class ConsolidatedMCQModal extends Modal {
const questionIndex = this.currentQuestionIndex;
const question = this.allQuestions[questionIndex];
const isCorrect = selectedIndex === question.correctAnswerIndex;
// Calculate time to answer
const timeToAnswer = (Date.now() - this.questionStartTime) / 1000;
// Check if this question has been answered before
const existingAnswerIndex = this.answers.findIndex(
a => a.questionIndex === questionIndex
);
let answer: {
questionIndex: number;
selectedAnswerIndex: number;
@ -296,15 +296,15 @@ export class ConsolidatedMCQModal extends Modal {
notePath: string;
fileName: string;
};
if (existingAnswerIndex >= 0) {
// Update existing answer
answer = this.answers[existingAnswerIndex];
// Always update selected index and correctness based on the current attempt
answer.selectedAnswerIndex = selectedIndex;
answer.correct = isCorrect;
answer.correct = isCorrect;
// Always update the timing and attempts
answer.timeToAnswer = timeToAnswer;
answer.attempts += 1;
@ -321,29 +321,29 @@ export class ConsolidatedMCQModal extends Modal {
};
this.answers.push(answer);
}
// Highlight the selected answer
this.highlightAnswer(selectedIndex, isCorrect);
// Wait a moment before proceeding
window.setTimeout(() => {
if (isCorrect) {
// Move to next question if correct
this.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
// Update progress
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)`;
}
// Update progress percentage for the progress bar
const newProgressPercent = Math.round(((this.currentQuestionIndex + 1) / this.allQuestions.length) * 100);
contentEl.setAttribute('data-progress', newProgressPercent.toString());
// Show next question or complete review
if (this.currentQuestionIndex < this.allQuestions.length) {
this.displayCurrentQuestion(contentEl);
@ -353,19 +353,19 @@ export class ConsolidatedMCQModal extends Modal {
} else {
// For incorrect answers, show a hint
new Notice("Incorrect answer. Try again to proceed to the next question.");
// Remove the highlight after a short delay
window.setTimeout(() => {
const choiceButtons = document.querySelectorAll('.mcq-choice-btn');
if (choiceButtons.length <= selectedIndex) return;
const selectedBtn = choiceButtons[selectedIndex] as HTMLElement;
selectedBtn.classList.remove('mcq-choice-incorrect');
}, 500);
}
}, 1000);
}
/**
* Highlight the selected answer
*
@ -374,11 +374,11 @@ export class ConsolidatedMCQModal extends Modal {
*/
highlightAnswer(selectedIndex: number, isCorrect: boolean): void {
const choiceButtons = document.querySelectorAll('.mcq-choice-btn');
if (choiceButtons.length <= selectedIndex) return;
const selectedBtn = choiceButtons[selectedIndex] as HTMLElement;
// Add highlight to selected answer
if (isCorrect) {
// For correct answers, highlight it green
@ -388,7 +388,7 @@ export class ConsolidatedMCQModal extends Modal {
selectedBtn.classList.add('mcq-choice-incorrect');
}
}
/**
* Complete the review and show results
*/
@ -401,7 +401,7 @@ export class ConsolidatedMCQModal extends Modal {
notePath: string;
fileName: string;
}> = {};
// Ensure all questions have an answer entry, even if not attempted or skipped
// This is important for the detailed breakdown later.
// We assume that if a question is in allQuestions but not in this.answers,
@ -420,7 +420,7 @@ export class ConsolidatedMCQModal extends Modal {
fileName: answer.fileName
};
}
// Each answer corresponds to one question attempt from that note
// We need to count unique questions from each note that were answered.
// The current logic for totalQuestions in noteScores might be slightly off
@ -435,7 +435,7 @@ export class ConsolidatedMCQModal extends Modal {
// Recalculate totalQuestions per note based on allQuestions
for (const question of this.allQuestions) {
if (!noteScores[question.notePath]) {
noteScores[question.notePath] = {
noteScores[question.notePath] = {
totalQuestions: 0,
correctAnswers: 0,
score: 0,
@ -445,7 +445,7 @@ export class ConsolidatedMCQModal extends Modal {
}
noteScores[question.notePath].totalQuestions++;
}
// Calculate correct answers based on the final recorded answer for each question
const finalAnswersCorrectCount: Record<string, number> = {};
for (const question of this.allQuestions) {
@ -459,12 +459,12 @@ export class ConsolidatedMCQModal extends Modal {
// For scoring, we usually care about first-attempt correctness.
// The current logic `answer.correct && answer.attempts <= 1` is fine for scoring.
if (answer.correct && answer.attempts <= 1) {
if (noteScores[answer.notePath]) { // Ensure noteScore entry exists
if (noteScores[answer.notePath]) { // Ensure noteScore entry exists
noteScores[answer.notePath].correctAnswers++;
}
}
}
// Calculate scores
for (const notePath in noteScores) {
const noteScore = noteScores[notePath];
@ -475,7 +475,7 @@ export class ConsolidatedMCQModal extends Modal {
noteScore.score = 0; // No questions for this note, or no answers recorded
}
}
// Convert note scores to results for the callback
const results: Array<{
path: string;
@ -483,29 +483,29 @@ export class ConsolidatedMCQModal extends Modal {
response: ReviewResponse;
score?: number;
}> = [];
for (const notePath in noteScores) {
const noteScore = noteScores[notePath];
const score = noteScore.score;
// Determine success and response based on score
let success = false;
let response = ReviewResponse.Hard;
let response = ReviewResponse.IncorrectResponse;
if (score >= 0.9) {
success = true;
response = ReviewResponse.Perfect;
response = ReviewResponse.PerfectRecall;
} else if (score >= 0.7) {
success = true;
response = ReviewResponse.Good;
response = ReviewResponse.CorrectWithHesitation;
} else if (score >= 0.5) {
success = true;
response = ReviewResponse.Fair;
response = ReviewResponse.CorrectWithDifficulty;
} else {
success = false;
response = ReviewResponse.Hard;
response = ReviewResponse.IncorrectResponse;
}
results.push({
path: notePath,
success,
@ -513,41 +513,41 @@ export class ConsolidatedMCQModal extends Modal {
score
});
}
// Call the completion callback
this.onComplete(results);
// Show results
const { contentEl } = this;
contentEl.empty();
// Display results header with stylized heading
const headerEl = new Setting(contentEl).setName('MCQ Review Complete').setHeading().setClass('mcq-review-complete-header');
new Setting(contentEl).setName('MCQ review complete').setHeading().setClass('mcq-review-complete-header'); // eslint-disable-line obsidianmd/ui/sentence-case
// Display overall score with enhanced styling
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}%`);
scoreTextEl.setText(`Overall score: ${scorePercentOverall}%`);
// Add performance indicator based on score
const performanceIndicator = scoreEl.createDiv('mcq-performance-indicator');
if (scorePercentOverall >= 90) {
performanceIndicator.setText('🎓 Excellent Performance!');
performanceIndicator.setText('🎓 Excellent performance!'); // eslint-disable-line obsidianmd/ui/sentence-case
performanceIndicator.addClass('excellent');
} else if (scorePercentOverall >= 70) {
performanceIndicator.setText('👍 Good Work!');
performanceIndicator.setText('👍 Good work!'); // eslint-disable-line obsidianmd/ui/sentence-case
performanceIndicator.addClass('good');
} else if (scorePercentOverall >= 50) {
performanceIndicator.setText('🔄 Keep Practicing');
performanceIndicator.setText('🔄 Keep practicing'); // eslint-disable-line obsidianmd/ui/sentence-case
performanceIndicator.addClass('needs-improvement');
} else {
performanceIndicator.setText('📚 More Review Recommended');
performanceIndicator.setText('📚 More review recommended'); // eslint-disable-line obsidianmd/ui/sentence-case
performanceIndicator.addClass('review-recommended');
}
@ -557,22 +557,22 @@ export class ConsolidatedMCQModal extends Modal {
// Display note scores with enhanced styling
const noteScoresEl = contentEl.createDiv('mcq-note-scores');
const scoreHeading = new Setting(noteScoresEl).setHeading().setName('Scores by Note').settingEl;
new Setting(noteScoresEl).setHeading().setName('Scores by note');
// Sort notes by score for better visualization (highest first)
const sortedNotes = Object.keys(noteScores).sort((a, b) => noteScores[b].score - noteScores[a].score);
for (const notePath of sortedNotes) {
const noteScore = noteScores[notePath];
if (noteScore.totalQuestions === 0) continue; // Skip notes with no questions in the review set
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})`,
@ -604,7 +604,7 @@ export class ConsolidatedMCQModal extends Modal {
// --- Detailed Question Breakdown ---
const breakdownContainer = contentEl.createDiv('mcq-detailed-breakdown');
new Setting(breakdownContainer).setHeading().setName('Detailed Question Breakdown');
new Setting(breakdownContainer).setHeading().setName('Detailed question breakdown');
this.allQuestions.forEach((question, index) => {
const questionEl = breakdownContainer.createDiv('mcq-breakdown-item');
@ -647,15 +647,15 @@ export class ConsolidatedMCQModal extends Modal {
correctAnswerEl.createSpan({ text: 'Correct answer: ' });
correctAnswerEl.createSpan({ text: question.choices[question.correctAnswerIndex], cls: 'mcq-correct-answer-text' });
});
// Create close button
const closeBtn = contentEl.createEl('button', {cls: 'mcq-close-btn', text: 'Close'});
const closeBtn = contentEl.createEl('button', { cls: 'mcq-close-btn', text: 'Close' });
closeBtn.addEventListener('click', () => {
this.close();
});
}
/**
* Shuffle an array
*

View file

@ -58,27 +58,27 @@ export class ContextMenuHandler {
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();
});
.setIcon("calendar-plus")
.onClick(async () => {
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));
.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();
});
.setIcon("calendar-minus")
.onClick(async () => {
this.plugin.reviewScheduleService.removeFromReview(file.path);
await this.plugin.savePluginData();
});
});
}
@ -86,18 +86,18 @@ export class ContextMenuHandler {
if (file.parent) { // Ensure there is a parent folder
menu.addItem((item) => {
item.setTitle("Add note's folder to review schedule")
.setIcon("folder-plus") // Using a folder icon
.onClick(async () => {
if (file.parent) { // Parent already confirmed, but good for safety
new Notice(`Adding folder "${file.parent.name}" to review schedule...`);
// Ensure file.parent is indeed a TFolder before calling addFolderToReview
if (file.parent instanceof TFolder) {
await this.addFolderToReview(file.parent); // Call the existing folder logic
} else {
new Notice("Error: Parent is not a folder.");
}
}
});
.setIcon("folder-plus") // Using a folder icon
.onClick(async () => {
if (file.parent) { // Parent already confirmed, but good for safety
new Notice(`Adding folder "${file.parent.name}" to review schedule...`);
// Ensure file.parent is indeed a TFolder before calling addFolderToReview
if (file.parent instanceof TFolder) {
await this.addFolderToReview(file.parent); // Call the existing folder logic
} else {
new Notice("Error: parent is not a folder.");
}
}
});
});
}
}
@ -112,12 +112,13 @@ export class ContextMenuHandler {
try {
menu.addItem((item) => {
item.setTitle("Add folder to review")
.setIcon("calendar-plus")
.onClick(() => {
this.addFolderToReview(folder);
});
.setIcon("calendar-plus")
.onClick(() => {
void this.addFolderToReview(folder);
});
});
} catch (error) {
} catch {
// no-op
}
}
@ -146,12 +147,12 @@ export class ContextMenuHandler {
});
if (allFiles.length === 0) {
new Notice("No markdown files found in folder.");
new Notice("No Markdown files found in folder.");
return;
}
// Use the LinkAnalyzer to build a hierarchical structure with consistent main file selection
const includeSubfolders = this.plugin.settings.includeSubfolders;
// Use the LinkAnalyzer to build a hierarchical structure with consistent main file selection
// First, identify a potential main file that should be used as the starting point
let mainFilePath: string | null = null;
@ -197,7 +198,7 @@ export class ContextMenuHandler {
}
}
let traversalOrder: string[] = [];
const traversalOrder: string[] = [];
const visited = new Set<string>(); // Global visited set
const processLinksRecursively = async (path: string) => {
@ -210,13 +211,13 @@ export class ContextMenuHandler {
const links = await LinkAnalyzer.analyzeNoteLinks(
this.plugin.app.vault,
path,
false
false
);
for (const link of links) {
const linkFile = this.plugin.app.vault.getAbstractFileByPath(link);
if (!(linkFile instanceof TFile) || linkFile.extension !== 'md') {
continue;
continue;
}
if (allFiles.some(f => f.path === linkFile.path)) { // Is internal to the overall operation
@ -238,16 +239,16 @@ export class ContextMenuHandler {
// Then, iterate through all files (sorted for consistency) to catch other branches or unlinked files.
// This ensures that if mainFilePath didn't cover everything, or if there wasn't one,
// all top-level items and their linked children within allFiles are processed.
const sortedAllFiles = [...allFiles].sort((a,b) => a.path.localeCompare(b.path));
const sortedAllFiles = [...allFiles].sort((a, b) => a.path.localeCompare(b.path));
for (const file of sortedAllFiles) {
if (!visited.has(file.path)) {
await processLinksRecursively(file.path);
}
}
// Schedule notes in the traversal order using the service
const count = await this.plugin.reviewScheduleService.scheduleNotesInOrder(traversalOrder);
const count = this.plugin.reviewScheduleService.scheduleNotesInOrder(traversalOrder);
if (count > 0) {
await this.plugin.savePluginData(); // Add save call
}
@ -259,9 +260,9 @@ export class ContextMenuHandler {
new Notice(`Added ${count} notes from "${folder.name}" to review schedule, starting with ${startingFileName}`);
// Update today's notes in the review controller using our same methodology
await this.plugin.reviewController.updateTodayNotes();
this.plugin.reviewController.updateTodayNotes();
} catch (error) {
} catch {
new Notice("Error adding folder to review schedule");
}
}
@ -281,7 +282,7 @@ export class ContextMenuHandler {
);
// Save after session creation
if (session) {
await this.plugin.savePluginData(); // Add save call
await this.plugin.savePluginData(); // Add save call
}
if (!session) {
@ -305,7 +306,7 @@ export class ContextMenuHandler {
// We'll add the save call here after scheduleSessionForReview returns.
const scheduledCount = await this.plugin.reviewSessionService.scheduleSessionForReview(session.id);
if (scheduledCount > 0) {
await this.plugin.savePluginData(); // Add save call
await this.plugin.savePluginData(); // Add save call
}
// Show success message
@ -314,7 +315,7 @@ export class ContextMenuHandler {
// Open the first file for review
const file = this.plugin.app.vault.getAbstractFileByPath(firstFilePath);
if (file instanceof TFile) {
this.plugin.reviewController.reviewNote(firstFilePath);
void this.plugin.reviewController.reviewNote(firstFilePath);
}
} else {
new Notice("No files found for review in this folder.");

View file

@ -9,7 +9,7 @@ export class EventModal extends Modal {
plugin: SpaceforgePlugin;
event: CalendarEvent | null;
onSave: (event: CalendarEvent) => void;
// Form elements
titleInput: HTMLInputElement;
descriptionInput: HTMLTextAreaElement;
@ -21,7 +21,7 @@ export class EventModal extends Modal {
isAllDayToggle: HTMLInputElement;
recurrenceSelect: HTMLSelectElement;
recurrenceEndDateInput: HTMLInputElement;
constructor(app: App, plugin: SpaceforgePlugin, event: CalendarEvent | null = null, onSave: (event: CalendarEvent) => void) {
super(app);
this.plugin = plugin;
@ -33,26 +33,26 @@ export class EventModal extends Modal {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("event-modal");
// Compact header
const header = contentEl.createDiv("event-modal-header");
const headerIcon = header.createDiv("event-modal-header-icon");
setIcon(headerIcon, this.event ? "edit" : "calendar-plus");
const headerText = header.createDiv("event-modal-header-text");
const titleSetting = new Setting(headerText)
.setHeading()
.setName(this.event ? "Edit Event" : "New Event");
.setName(this.event ? "Edit event" : "New event");
titleSetting.settingEl.addClass("event-modal-title");
// Compact form container
const formContainer = contentEl.createDiv("event-modal-form");
// Title row
const titleRow = formContainer.createDiv("event-modal-row");
const titleGroup = titleRow.createDiv("event-modal-field-group");
titleGroup.createEl("label", { text: "Title", cls: "event-modal-label required" });
this.titleInput = titleGroup.createEl("input", {
this.titleInput = titleGroup.createEl("input", {
type: "text",
cls: "event-modal-input",
placeholder: "Event title..."
@ -66,7 +66,7 @@ export class EventModal extends Modal {
const dateTimeRow = formContainer.createDiv("event-modal-row");
const dateGroup = dateTimeRow.createDiv("event-modal-field-group");
dateGroup.createEl("label", { text: "Date", cls: "event-modal-label" });
this.dateInput = dateGroup.createEl("input", {
this.dateInput = dateGroup.createEl("input", {
type: "date",
cls: "event-modal-input"
});
@ -77,10 +77,10 @@ export class EventModal extends Modal {
this.dateInput.value = new Date().toISOString().split('T')[0];
}
this.dateInput.addEventListener("change", () => this.validateForm());
const timeGroup = dateTimeRow.createDiv("event-modal-field-group");
timeGroup.createEl("label", { text: "Time", cls: "event-modal-label" });
this.timeInput = timeGroup.createEl("input", {
this.timeInput = timeGroup.createEl("input", {
type: "time",
cls: "event-modal-input"
});
@ -92,14 +92,14 @@ export class EventModal extends Modal {
// All-day toggle row
const allDayRow = formContainer.createDiv("event-modal-row");
const allDayGroup = allDayRow.createDiv("event-modal-field-group");
this.isAllDayToggle = allDayGroup.createEl("input", {
this.isAllDayToggle = allDayGroup.createEl("input", {
type: "checkbox",
cls: "event-modal-checkbox"
}) as HTMLInputElement;
this.isAllDayToggle.checked = this.event?.isAllDay ?? false;
this.isAllDayToggle.addEventListener("change", () => this.toggleTimeInput());
const allDayLabel = allDayGroup.createEl("label", {
allDayGroup.createEl("label", {
text: "All-day event",
cls: "event-modal-checkbox-label"
});
@ -108,7 +108,7 @@ export class EventModal extends Modal {
const locationRow = formContainer.createDiv("event-modal-row");
const locationGroup = locationRow.createDiv("event-modal-field-group");
locationGroup.createEl("label", { text: "Location", cls: "event-modal-label" });
this.locationInput = locationGroup.createEl("input", {
this.locationInput = locationGroup.createEl("input", {
type: "text",
cls: "event-modal-input",
placeholder: "Location (optional)..."
@ -121,34 +121,34 @@ export class EventModal extends Modal {
const categoryColorRow = formContainer.createDiv("event-modal-row");
const categoryGroup = categoryColorRow.createDiv("event-modal-field-group");
categoryGroup.createEl("label", { text: "Category", cls: "event-modal-label" });
this.categorySelect = categoryGroup.createEl("select", {
this.categorySelect = categoryGroup.createEl("select", {
cls: "event-modal-select"
});
Object.values(EventCategory).forEach(category => {
const option = this.categorySelect.createEl("option", {
const option = this.categorySelect.createEl("option", {
value: category,
text: category.charAt(0).toUpperCase() + category.slice(1)
});
this.categorySelect.appendChild(option);
});
if (this.event?.category) {
this.categorySelect.value = this.event.category;
} else {
this.categorySelect.value = this.plugin.settings.defaultEventCategory || 'personal';
}
this.categorySelect.addEventListener("change", () => this.updateColorFromCategory());
const colorGroup = categoryColorRow.createDiv("event-modal-field-group");
colorGroup.createEl("label", { text: "Color", cls: "event-modal-label" });
const colorPickerContainer = colorGroup.createDiv("event-modal-color-picker-compact");
this.colorInput = colorPickerContainer.createEl("input", {
this.colorInput = colorPickerContainer.createEl("input", {
type: "color",
cls: "event-modal-color-input"
}) as HTMLInputElement;
if (this.event?.color) {
this.colorInput.value = this.event.color;
} else {
@ -159,7 +159,7 @@ export class EventModal extends Modal {
const descriptionRow = formContainer.createDiv("event-modal-row event-modal-full-width");
const descriptionGroup = descriptionRow.createDiv("event-modal-field-group");
descriptionGroup.createEl("label", { text: "Description", cls: "event-modal-label" });
this.descriptionInput = descriptionGroup.createEl("textarea", {
this.descriptionInput = descriptionGroup.createEl("textarea", {
cls: "event-modal-textarea",
placeholder: "Event details (optional)..."
});
@ -172,31 +172,31 @@ export class EventModal extends Modal {
const recurrenceRow = formContainer.createDiv("event-modal-row");
const recurrenceGroup = recurrenceRow.createDiv("event-modal-field-group");
recurrenceGroup.createEl("label", { text: "Recurrence", cls: "event-modal-label" });
this.recurrenceSelect = recurrenceGroup.createEl("select", {
this.recurrenceSelect = recurrenceGroup.createEl("select", {
cls: "event-modal-select"
});
Object.values(EventRecurrence).forEach(recurrence => {
const option = this.recurrenceSelect.createEl("option", {
const option = this.recurrenceSelect.createEl("option", {
value: recurrence,
text: this.getRecurrenceDisplayText(recurrence)
});
this.recurrenceSelect.appendChild(option);
});
if (this.event?.recurrence) {
this.recurrenceSelect.value = this.event.recurrence;
} else {
this.recurrenceSelect.value = EventRecurrence.None;
}
this.recurrenceSelect.addEventListener("change", () => this.toggleRecurrenceEndDate());
// Recurrence end date row (conditional)
const recurrenceEndRow = formContainer.createDiv("event-modal-row event-modal-recurrence-end-row");
const recurrenceEndGroup = recurrenceEndRow.createDiv("event-modal-field-group");
recurrenceEndGroup.createEl("label", { text: "End Date", cls: "event-modal-label" });
this.recurrenceEndDateInput = recurrenceEndGroup.createEl("input", {
recurrenceEndGroup.createEl("label", { text: "End date", cls: "event-modal-label" });
this.recurrenceEndDateInput = recurrenceEndGroup.createEl("input", {
type: "date",
cls: "event-modal-input"
});
@ -207,27 +207,27 @@ export class EventModal extends Modal {
// Enhanced buttons section
const buttonContainer = contentEl.createDiv("event-modal-actions");
const cancelButton = buttonContainer.createEl("button", {
const cancelButton = buttonContainer.createEl("button", {
text: "Cancel",
cls: "event-modal-btn event-modal-btn-secondary"
});
cancelButton.addEventListener("click", () => this.close());
// Add delete button only for existing events
if (this.event) {
const deleteButton = buttonContainer.createEl("button", {
const deleteButton = buttonContainer.createEl("button", {
text: "Delete",
cls: "event-modal-btn event-modal-btn-danger"
});
deleteButton.addEventListener("click", () => this.deleteEvent());
deleteButton.addEventListener("click", () => void this.deleteEvent());
}
const saveButton = buttonContainer.createEl("button", {
text: this.event ? "Update Event" : "Create Event",
const saveButton = buttonContainer.createEl("button", {
text: this.event ? "Update event" : "Create event",
cls: "event-modal-btn event-modal-btn-primary"
});
saveButton.addEventListener("click", () => this.saveEvent());
saveButton.addEventListener("click", () => void this.saveEvent());
// Initial setup
this.validateForm();
@ -248,7 +248,7 @@ export class EventModal extends Modal {
private validateForm(): void {
const saveButton = this.contentEl.querySelector(".event-modal-btn-primary") as HTMLButtonElement;
const isValid = this.titleInput.value.trim() !== "" && this.dateInput.value !== "";
if (saveButton) {
saveButton.disabled = !isValid;
}
@ -280,11 +280,11 @@ export class EventModal extends Modal {
private toggleTimeInput(): void {
const isAllDay = this.isAllDayToggle.checked;
const timeContainer = this.timeInput.closest(".event-modal-input-group") as HTMLElement;
if (timeContainer) {
timeContainer.style.display = isAllDay ? "none" : "block";
}
if (isAllDay) {
this.timeInput.value = "";
}
@ -307,11 +307,11 @@ export class EventModal extends Modal {
private toggleRecurrenceEndDate(): void {
const recurrence = this.recurrenceSelect.value as EventRecurrence;
const endDateContainer = this.recurrenceEndDateInput.closest(".event-modal-recurrence-end-container") as HTMLElement;
if (endDateContainer) {
endDateContainer.style.display = recurrence === EventRecurrence.None ? "none" : "block";
}
if (recurrence === EventRecurrence.None) {
this.recurrenceEndDateInput.value = "";
}
@ -336,17 +336,17 @@ export class EventModal extends Modal {
*/
private async deleteEvent(): Promise<void> {
if (!this.event) return;
try {
const deleted = this.plugin.calendarEventService?.deleteEvent(this.event.id);
if (deleted) {
new Notice("Event deleted successfully");
await this.plugin.savePluginData();
// Trigger calendar refresh through the onSave callback
this.onSave(this.event);
this.close();
} else {
new Notice("Failed to delete event");
@ -396,7 +396,7 @@ export class EventModal extends Modal {
recurrence,
recurrenceEndDate
};
const updatedEvent = this.plugin.calendarEventService?.updateEvent(this.event.id, eventData);
if (updatedEvent) {
this.onSave(updatedEvent);
@ -416,7 +416,7 @@ export class EventModal extends Modal {
recurrence,
recurrenceEndDate
};
const newEvent = this.plugin.calendarEventService?.createEvent(eventData);
if (newEvent) {
this.onSave(newEvent);
@ -426,7 +426,7 @@ export class EventModal extends Modal {
// Save plugin data
await this.plugin.savePluginData();
this.close();
} catch (error) {
console.error("Error saving event:", error);

View file

@ -1,6 +1,6 @@
import { Modal, Notice, setIcon, TFile, Setting } from 'obsidian'; // Ensure TFile is imported, Added Setting
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet, MCQAnswer, MCQSession } from '../models/mcq';
import { MCQSet, MCQAnswer, MCQSession } from '../models/mcq';
/**
* Modal for displaying and answering MCQs
@ -10,11 +10,11 @@ export class MCQModal extends Modal {
notePath: string;
mcqSet: MCQSet;
session: MCQSession;
questionStartTime: number = 0;
isFreshGeneration: boolean = false;
questionStartTime = 0;
isFreshGeneration = false;
// Modified callback to include score
private onCompleteCallback: ((path: string, score: number, completed: boolean) => void) | null;
private selectedAnswerIndex: number = -1;
private selectedAnswerIndex = -1;
constructor(
plugin: SpaceforgePlugin,
@ -45,37 +45,39 @@ export class MCQModal extends Modal {
contentEl.empty();
contentEl.addClass('spaceforge-mcq-modal');
const headerContainer = contentEl.createDiv('mcq-header-container');
new Setting(headerContainer).setName('Multiple Choice Review').setHeading();
new Setting(headerContainer).setName('Multiple choice review').setHeading();
if (!this.isFreshGeneration) {
const refreshBtn = headerContainer.createDiv('mcq-refresh-btn');
setIcon(refreshBtn, 'refresh-cw');
refreshBtn.setAttribute('aria-label', 'Generate new questions');
refreshBtn.addEventListener('click', async () => {
refreshBtn.addEventListener('click', () => {
this.close();
// Use mcqGenerationService
const mcqGenerationService = this.plugin.mcqGenerationService;
if (mcqGenerationService && this.plugin.mcqController) {
const file = this.plugin.app.vault.getAbstractFileByPath(this.notePath);
// Check if file is TFile before reading
if (file instanceof TFile) {
const content = await this.plugin.app.vault.read(file);
const newMcqSet = await mcqGenerationService.generateMCQs(this.notePath, content, this.plugin.settings);
if (newMcqSet) {
// Save via mcqService, then save plugin data
this.plugin.mcqService.saveMCQSet(newMcqSet);
await this.plugin.savePluginData();
const newModal = new MCQModal(this.plugin, this.notePath, newMcqSet, this.onCompleteCallback);
newModal.open();
void (async () => {
// Use mcqGenerationService
const mcqGenerationService = this.plugin.mcqGenerationService;
if (mcqGenerationService && this.plugin.mcqController) {
const file = this.plugin.app.vault.getAbstractFileByPath(this.notePath);
// Check if file is TFile before reading
if (file instanceof TFile) {
const content = await this.plugin.app.vault.read(file);
const newMcqSet = await mcqGenerationService.generateMCQs(this.notePath, content, this.plugin.settings);
if (newMcqSet) {
// Save via mcqService, then save plugin data
this.plugin.mcqService.saveMCQSet(newMcqSet);
await this.plugin.savePluginData();
const newModal = new MCQModal(this.plugin, this.notePath, newMcqSet, this.onCompleteCallback);
newModal.open();
} else {
new Notice('Failed to regenerate MCQs.');
}
} else {
new Notice('Failed to regenerate MCQs.');
new Notice('Could not find note file to regenerate MCQs.');
}
} else {
new Notice('Could not find note file to regenerate MCQs.');
new Notice('MCQ generation service not available.'); // eslint-disable-line obsidianmd/ui/sentence-case
}
} else {
new Notice('MCQ generation service not available.');
}
})();
});
}
@ -146,7 +148,7 @@ export class MCQModal extends Modal {
}
if (processAnswer && this.selectedAnswerIndex !== -1) {
if (this.selectedAnswerIndex < choiceButtons.length) {
if (this.selectedAnswerIndex < choiceButtons.length) {
const button = choiceButtons[this.selectedAnswerIndex] as HTMLElement;
button.classList.add('mcq-key-pressed');
window.setTimeout(() => {
@ -155,8 +157,8 @@ export class MCQModal extends Modal {
this.selectedAnswerIndex = -1;
}, 150);
} else {
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
}
}
};
@ -170,27 +172,27 @@ export class MCQModal extends Modal {
this.scope.register([], i.toString(), keyDownHandler);
}
for (let i = 0; i < 9; i++) { // A-I
this.scope.register([], String.fromCharCode(65 + i), keyDownHandler);
this.scope.register([], String.fromCharCode(65 + i), keyDownHandler);
}
// Cleanup handled by Modal's onClose automatically if using this.scope.register
// Original onClose logic for saving partial progress:
const originalOnClose = this.onClose;
this.onClose = () => {
originalOnClose.call(this);
originalOnClose.call(this);
if (!this.session.completed && this.session.answers.length > 0) {
// Session was closed prematurely but some answers were given
this.session.completedAt = Date.now();
this.session.completedAt = Date.now();
this.calculateScore(); // Calculate score based on answers so far
this.plugin.mcqService.saveMCQSession(this.session);
this.plugin.savePluginData();
void this.plugin.savePluginData();
if (this.onCompleteCallback) {
// Indicate it was not fully completed if onCompleteCallback expects a 'completed' flag
this.onCompleteCallback(this.notePath, this.session.score, false);
this.onCompleteCallback(this.notePath, this.session.score, false);
}
} else if (!this.session.completed && this.onCompleteCallback) {
// Closed before any answers or completion
this.onCompleteCallback(this.notePath, 0, false);
this.onCompleteCallback(this.notePath, 0, false);
}
};
}
@ -203,7 +205,7 @@ export class MCQModal extends Modal {
}
const question = this.mcqSet.questions[questionIndex];
if (!question || !question.choices || question.choices.length < 2) {
new Notice('Error: Invalid question data. Moving to next question.');
new Notice('Error: Invalid question data. Moving to next question.'); // eslint-disable-line obsidianmd/ui/sentence-case
this.session.currentQuestionIndex++;
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(containerEl);
@ -222,12 +224,12 @@ export class MCQModal extends Modal {
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' });
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({ cls: 'sf-mcq-correct-label' });
correctLabel.setText('Correct Answer:');
correctLabel.setText('Correct answer:');
const correctText = correctAnswerDisplay.createDiv({ cls: 'sf-mcq-correct-text' });
correctText.setText(String.fromCharCode(65 + correctIndex) + ') ' + question.choices[correctIndex]);
@ -246,7 +248,7 @@ export class MCQModal extends Modal {
}
const continueBtn = correctAnswerDisplay.createEl('button', {
text: 'Continue to Next Question',
text: 'Continue to next question',
cls: 'mcq-continue-button'
});
continueBtn.addEventListener('click', () => {
@ -337,9 +339,9 @@ export class MCQModal extends Modal {
this.session.completedAt = Date.now();
// Save via mcqService
this.plugin.mcqService.saveMCQSession(this.session);
this.plugin.savePluginData(); // Persist
void this.plugin.savePluginData(); // Persist
new Setting(contentEl).setName('Review Complete').setHeading();
new Setting(contentEl).setName('Review complete').setHeading();
const scoreEl = contentEl.createDiv('mcq-score');
const scoreTextEl = scoreEl.createDiv('mcq-score-text');
const scorePercentage = this.session.score; // Score is 0-1
@ -391,7 +393,7 @@ export class MCQModal extends Modal {
// Removed the old score indicator text (🎯 Excellent, etc.)
const resultsEl = contentEl.createDiv('mcq-results');
new Setting(resultsEl).setHeading().setName('Question Results');
new Setting(resultsEl).setHeading().setName('Question results');
if (this.session.answers.length === 0) {
resultsEl.createDiv({ cls: 'mcq-no-answers', text: 'No questions were answered in this session.' });
} else {
@ -428,7 +430,7 @@ export class MCQModal extends Modal {
resultItem.createDiv({ cls: 'mcq-result-time', text: `Time: ${Math.round(answer.timeToAnswer)} seconds` });
// FSRS/SM-2 data per question removed from here
} catch (error) { /* Error displaying answer result: ${error} */ }
} catch (_error) { /* Error displaying answer result: ${error} */ }
});
}
const closeBtn = contentEl.createEl('button', { cls: 'mcq-close-btn', text: 'Close' });
@ -439,9 +441,9 @@ export class MCQModal extends Modal {
}
this.close();
});
} catch (error) {
new Setting(contentEl).setName('Error Completing Session').setHeading();
contentEl.createEl('p', { text: 'There was an error completing the MCQ session. Please try again.' });
} catch (_error) {
new Setting(contentEl).setName('Error completing session').setHeading();
contentEl.createEl('p', { text: 'There was an error completing the MCQ session. Please try again.' }); // eslint-disable-line obsidianmd/ui/sentence-case
const errorCloseBtn = contentEl.createEl('button', { cls: 'mcq-close-btn', text: 'Close' });
errorCloseBtn.addEventListener('click', () => this.close());
}
@ -508,12 +510,12 @@ function getFsrsRatingText(rating: number): string {
// Helper function to map SM-2 rating number to text
function getSm2RatingText(rating: number): string {
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";
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 hHesitation";
case 5: return "Perfect recall";
default: return "Unknown";
}
}

View file

@ -1,8 +1,8 @@
import { App, Modal, Notice, TFile, setIcon, Setting } from 'obsidian';
import SpaceforgePlugin from '../main';
import { ReviewResponse, ReviewSchedule, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import { ReviewResponse, FsrsRating } from '../models/review-schedule'; // Added FsrsRating
import { State as FsrsState } from 'ts-fsrs'; // For displaying FSRS state name
import { MCQController } from '../controllers/review-controller-mcq'; // Import MCQController for type hinting
/**
* Modal for reviewing a note
@ -19,7 +19,7 @@ export class ReviewModal extends Modal {
onOpen(): void {
const { contentEl } = this;
new Setting(contentEl).setName("Review Note").setHeading();
new Setting(contentEl).setName("Review note").setHeading();
const buttonsContainer = contentEl.createDiv("review-buttons-container");
const schedule = this.plugin.reviewScheduleService.schedules[this.path];
@ -29,7 +29,7 @@ export class ReviewModal extends Modal {
const createFsrsButton = (text: string, clsSuffix: string, rating: FsrsRating) => {
const button = buttonsContainer.createEl("button", { text, cls: `review-button review-button-fsrs-${clsSuffix}` });
button.addEventListener("click", () => {
this.plugin.reviewController.processReviewResponse(this.path, rating);
void this.plugin.reviewController.processReviewResponse(this.path, rating);
this.close();
});
};
@ -42,33 +42,33 @@ export class ReviewModal extends Modal {
const createSm2Button = (text: string, cls: string, response: ReviewResponse) => {
const button = buttonsContainer.createEl("button", { text, cls });
button.addEventListener("click", () => {
this.plugin.reviewController.processReviewResponse(this.path, response);
void this.plugin.reviewController.processReviewResponse(this.path, response);
this.close();
});
};
createSm2Button("0: Complete Blackout", "review-button review-button-complete-blackout", ReviewResponse.CompleteBlackout);
createSm2Button("1: Incorrect Response", "review-button review-button-incorrect", ReviewResponse.IncorrectResponse);
createSm2Button("2: Incorrect but Familiar", "review-button review-button-incorrect-familiar", ReviewResponse.IncorrectButFamiliar);
createSm2Button("3: Correct with Difficulty", "review-button review-button-correct-difficulty", ReviewResponse.CorrectWithDifficulty);
createSm2Button("4: Correct with Hesitation", "review-button review-button-correct-hesitation", ReviewResponse.CorrectWithHesitation);
createSm2Button("5: Perfect Recall", "review-button review-button-perfect-recall", ReviewResponse.PerfectRecall);
createSm2Button("0: Complete blackout", "review-button review-button-complete-blackout", ReviewResponse.CompleteBlackout);
createSm2Button("1: Incorrect response", "review-button review-button-incorrect", ReviewResponse.IncorrectResponse);
createSm2Button("2: Incorrect but familiar", "review-button review-button-incorrect-familiar", ReviewResponse.IncorrectButFamiliar);
createSm2Button("3: Correct with difficulty", "review-button review-button-correct-difficulty", ReviewResponse.CorrectWithDifficulty);
createSm2Button("4: Correct with hesitation", "review-button review-button-correct-hesitation", ReviewResponse.CorrectWithHesitation);
createSm2Button("5: Perfect recall", "review-button review-button-perfect-recall", ReviewResponse.PerfectRecall);
}
buttonsContainer.createEl("div", { cls: "review-button-separator" });
// Postpone Button
const postponeButton = buttonsContainer.createEl("button", { text: "Postpone to Tomorrow", cls: "review-button review-button-postpone" });
const postponeButton = buttonsContainer.createEl("button", { text: "Postpone to tomorrow", cls: "review-button review-button-postpone" });
postponeButton.addEventListener("click", () => {
this.plugin.reviewController.skipReview(this.path); // skipReview handles postponement
void this.plugin.reviewController.skipReview(this.path); // skipReview handles postponement
this.close();
});
// Skip/Next Button
const skipButton = buttonsContainer.createEl("button", { text: "Skip/Next", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", async () => {
const skipButton = buttonsContainer.createEl("button", { text: "Skip/next", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", () => {
this.close();
if (this.plugin.navigationController) {
await this.plugin.navigationController.navigateToNextNoteWithoutRating();
void this.plugin.navigationController.navigateToNextNoteWithoutRating();
}
});
@ -76,15 +76,16 @@ export class ReviewModal extends Modal {
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"); setIcon(mcqIconSpan, "mcq-quiz");
const textSpan = mcqButton.createSpan("mcq-button-text"); textSpan.setText("Test with MCQs");
const textSpan = mcqButton.createSpan("mcq-button-text"); textSpan.setText("Test with MCQs"); // eslint-disable-line obsidianmd/ui/sentence-case
mcqButton.addEventListener("click", () => {
const mcqController = this.plugin.mcqController;
if (mcqController) {
// Call startMCQReview without the callback
mcqController.startMCQReview(this.path);
void mcqController.startMCQReview(this.path);
// The logic for handling completion should ideally be managed elsewhere,
// perhaps by observing an event or checking state after the modal closes,
// or the MCQModal itself could trigger the review processing upon completion.
@ -96,10 +97,10 @@ export class ReviewModal extends Modal {
const initializedMcqController = this.plugin.mcqController;
if (initializedMcqController) {
// Call startMCQReview without the callback
initializedMcqController.startMCQReview(this.path);
void initializedMcqController.startMCQReview(this.path);
this.close();
} else {
new Notice("MCQ feature could not be initialized. Please check settings.");
new Notice("MCQ feature could not be initialized. Please check settings."); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
});
@ -109,19 +110,21 @@ export class ReviewModal extends Modal {
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"); setIcon(refreshIconSpan, "refresh-cw");
const refreshTextSpan = refreshMcqButton.createSpan("mcq-button-text"); refreshTextSpan.setText("Generate New MCQs");
const refreshTextSpan = refreshMcqButton.createSpan("mcq-button-text"); refreshTextSpan.setText("Generate new MCQs"); // eslint-disable-line obsidianmd/ui/sentence-case
refreshMcqButton.addEventListener("click", async () => {
refreshMcqButton.addEventListener("click", () => {
if (mcqController) {
new Notice("Generating new MCQs...");
const success = await mcqController.generateMCQs(this.path, true); // Force regeneration
if (success) {
// Start review with the newly generated MCQs, without callback
mcqController.startMCQReview(this.path);
this.close();
} else {
new Notice("Failed to generate new MCQs");
}
void (async () => {
const success = await mcqController.generateMCQs(this.path, true); // Force regeneration
if (success) {
// Start review with the newly generated MCQs, without callback
void mcqController.startMCQReview(this.path);
this.close();
} else {
new Notice("Failed to generate new MCQs.");
}
})();
}
});
}
@ -147,13 +150,13 @@ export class ReviewModal extends Modal {
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: `Algorithm: FSRS`, cls: "review-phase-fsrs" }); // eslint-disable-line obsidianmd/ui/sentence-case
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: ${FsrsState[schedule.fsrsData.state]}` }); // Display FSRS state name
infoText.createEl("p", { text: `Interval: ${schedule.fsrsData.scheduled_days} days` });
} else { // SM-2 or fallback
infoText.createEl("p", { text: `Algorithm: SM-2`, cls: "review-phase-sm2" });
infoText.createEl("p", { text: `Algorithm: SM-2`, cls: "review-phase-sm2" }); // eslint-disable-line obsidianmd/ui/sentence-case
let phaseText: string; let phaseClass: string;
if (schedule.scheduleCategory === 'initial') {
const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length;

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,11 @@
import { ItemView, Menu, Notice, TFile, WorkspaceLeaf, setIcon, Setting } from "obsidian";
import { ItemView, Notice, WorkspaceLeaf, Setting } from "obsidian";
import SpaceforgePlugin from "../main";
import { PomodoroUIManager } from "./sidebar/pomodoro-ui-manager";
import { NoteItemRenderer } from "./sidebar/note-item-renderer";
import { ListViewRenderer } from "./sidebar/list-view-renderer"; // Import ListViewRenderer
import { ReviewSchedule } from "../models/review-schedule";
import { DateUtils } from "../utils/dates";
import { EstimationUtils } from "../utils/estimation";
import { CalendarView } from "./calendar-view";
/**
@ -28,7 +28,7 @@ export class ReviewSidebarView extends ItemView {
public activeListBaseDate: Date | null = null;
selectedNotes: string[] = [];
private lastSelectedNotePath: string | null = null;
private lastScrollPosition: number = 0;
private lastScrollPosition = 0;
private expandedUpcomingDayKey: string | null = null; // State for upcoming section
private resizeObserver: ResizeObserver | null = null;
@ -52,7 +52,7 @@ export class ReviewSidebarView extends ItemView {
}
getViewType(): string { return "spaceforge-review-schedule"; }
getDisplayText(): string { return "Spaceforge Review"; }
getDisplayText(): string { return "Spaceforge Review"; } // eslint-disable-line obsidianmd/ui/sentence-case
getIcon(): string { return "calendar-clock"; }
async onOpen(): Promise<void> {
@ -64,15 +64,16 @@ export class ReviewSidebarView extends ItemView {
});
// Ensure structure and render on first open
await this.refresh();
await this.refresh();
}
async onClose(): Promise<void> {
onClose(): Promise<void> {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.plugin.events.off('sidebar-update', this.refresh.bind(this));
// No need to explicitly unregister pomodoro-update as the manager handles its own logic
return Promise.resolve();
}
private ensureBaseStructure(): void {
@ -86,26 +87,30 @@ export class ReviewSidebarView extends ItemView {
if (!this.persistentHeaderEl) {
this.persistentHeaderEl = this.mainContainer.createDiv("review-header");
new Setting(this.persistentHeaderEl).setHeading().setName("Review Schedule");
new Setting(this.persistentHeaderEl).setHeading().setName("Review schedule");
const viewToggle = this.persistentHeaderEl.createDiv("review-view-toggle");
const listViewBtn = viewToggle.createDiv("review-view-btn");
listViewBtn.setText("List");
listViewBtn.addEventListener("click", async () => {
listViewBtn.addEventListener("click", () => {
if (this.plugin.settings.sidebarViewType === 'list') return;
this.plugin.settings.sidebarViewType = 'list';
this.activeListBaseDate = null;
await this.plugin.savePluginData();
await this.refresh();
void (async () => {
await this.plugin.savePluginData();
await this.refresh();
})();
});
const calendarViewBtn = viewToggle.createDiv("review-view-btn");
calendarViewBtn.setText("Calendar");
calendarViewBtn.addEventListener("click", async () => {
calendarViewBtn.addEventListener("click", () => {
if (this.plugin.settings.sidebarViewType === 'calendar') return;
this.plugin.settings.sidebarViewType = 'calendar';
await this.plugin.savePluginData();
await this.refresh();
void (async () => {
await this.plugin.savePluginData();
await this.refresh();
})();
});
}
this.updateViewToggleButtonsState();
@ -114,8 +119,8 @@ export class ReviewSidebarView extends ItemView {
this.listViewContentEl = this.mainContainer.createDiv("list-view-content");
}
// Ensure managers are initialized *after* their container exists
if (!this.pomodoroUIManager) {
this.pomodoroUIManager = new PomodoroUIManager(this.plugin);
if (!this.pomodoroUIManager) {
this.pomodoroUIManager = new PomodoroUIManager(this.plugin);
}
if (!this.listViewRenderer) {
this.listViewRenderer = new ListViewRenderer(this.plugin, this.pomodoroUIManager, this.noteItemRenderer, {
@ -126,10 +131,10 @@ export class ReviewSidebarView extends ItemView {
setExpandedUpcomingDayKey: (key) => { this.expandedUpcomingDayKey = key; },
getLastSelectedNotePath: () => this.lastSelectedNotePath,
setLastSelectedNotePath: (path) => { this.lastSelectedNotePath = path; },
refreshSidebarView: this.refresh.bind(this)
refreshSidebarView: this.refresh.bind(this)
});
}
if (!this.calendarViewContentEl) {
this.calendarViewContentEl = this.mainContainer.createDiv("calendar-view-content");
}
@ -152,10 +157,10 @@ export class ReviewSidebarView extends ItemView {
async refresh(): Promise<void> {
// Capture the state of activeListBaseDate *before* any potential changes in this refresh cycle.
const previousActiveListBaseDateEpoch = this.activeListBaseDate ? DateUtils.startOfUTCDay(this.activeListBaseDate) : null;
// Determine the target date for this refresh. It defaults to the current activeListBaseDate
// unless a calendar click provides a new date.
let newTargetDate: Date | null = this.activeListBaseDate;
let newTargetDate: Date | null = this.activeListBaseDate;
if (this.plugin.clickedDateFromCalendar) {
newTargetDate = this.plugin.clickedDateFromCalendar;
@ -180,7 +185,7 @@ export class ReviewSidebarView extends ItemView {
this.activeListBaseDate = newTargetDate; // Update the sidebar's primary date state
reviewDateChanged = true;
}
// Synchronize the ReviewController's date override with the sidebar's `activeListBaseDate`.
// `this.activeListBaseDate` is now the definitive date context for the sidebar's list view.
const currentControllerOverrideEpoch = this.plugin.reviewController.getCurrentReviewDateOverride();
@ -204,7 +209,7 @@ export class ReviewSidebarView extends ItemView {
let storedScrollPosition = this.lastScrollPosition; // Use the stored state
if (this.mainContainer) {
// Update storedScrollPosition if the container is currently scrolled
storedScrollPosition = this.mainContainer.scrollTop;
storedScrollPosition = this.mainContainer.scrollTop;
}
this.updateViewToggleButtonsState();
@ -213,14 +218,14 @@ export class ReviewSidebarView extends ItemView {
// Apply scroll position *after* rendering is complete
if (this.mainContainer) {
requestAnimationFrame(() => {
if (this.mainContainer) this.mainContainer.scrollTop = storedScrollPosition;
if (this.mainContainer) this.mainContainer.scrollTop = storedScrollPosition;
this.lastScrollPosition = storedScrollPosition; // Update state after applying
});
}
}
private async showCorrectViewPane(): Promise<void> {
if (this.plugin.settings.sidebarViewType === 'calendar') {
if (this.plugin.settings.sidebarViewType === 'calendar') {
if (this.listViewContentEl) this.listViewContentEl.hide();
if (this.calendarViewContentEl) {
this.calendarViewContentEl.show();
@ -231,7 +236,7 @@ export class ReviewSidebarView extends ItemView {
if (this.listViewContentEl) { // Check if container exists
this.listViewContentEl.show();
// Now delegate rendering to the ListViewRenderer
await this.renderListViewContent(this.listViewContentEl);
await this.renderListViewContent(this.listViewContentEl);
}
}
}
@ -243,7 +248,7 @@ export class ReviewSidebarView extends ItemView {
if (this.listViewRenderer) { // Check if renderer is initialized
await this.listViewRenderer.render(container);
} else {
container.setText("Error: Could not render list view. Renderer not ready.");
container.setText("Error: Could not render list view. Renderer not ready."); // eslint-disable-line obsidianmd/ui/sentence-case
}
}
@ -254,15 +259,15 @@ export class ReviewSidebarView extends ItemView {
// container is this.calendarViewContentEl, which is persistent.
// CalendarView instance is now also persistent and initialized in ensureBaseStructure.
// It manages its own content within this.calendarViewContentEl.
// No longer empty the container here, CalendarView manages its own content.
// No longer create a temporary wrapper here.
// CalendarView instance is already created.
if (this.calendarView) { // Should always exist due to ensureBaseStructure
await this.calendarView.render();
await this.calendarView.render();
} else {
container.setText("Error: Could not render calendar view. CalendarView not ready.");
container.setText("Error: Could not render calendar view. CalendarView not ready."); // eslint-disable-line obsidianmd/ui/sentence-case
return; // Avoid further errors if calendarView is somehow null
}
@ -278,9 +283,9 @@ export class ReviewSidebarView extends ItemView {
});
if (this.containerEl) {
resizeObserver.observe(this.containerEl);
this.resizeObserver = resizeObserver;
this.updateCalendarContainerClass();
resizeObserver.observe(this.containerEl);
this.resizeObserver = resizeObserver;
this.updateCalendarContainerClass();
}
}
@ -302,19 +307,19 @@ export class ReviewSidebarView extends ItemView {
// --- 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: string, note: ReviewSchedule): Promise<void> {
if (!this.listViewRenderer) {
return;
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
const notes = this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
);
const dateNotes = notes[dateStr];
if (!dateNotes) return;
@ -324,8 +329,8 @@ export class ReviewSidebarView extends ItemView {
const path1 = dateNotes[index].path;
const path2 = dateNotes[index - 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new Notice(`Moved note up`);
await this.refresh();
new Notice(`Moved note up.`);
}
/**
@ -333,13 +338,13 @@ export class ReviewSidebarView extends ItemView {
* (Existing Method - Consider moving to controller)
*/
async moveNoteDown(dateStr: string, note: ReviewSchedule): Promise<void> {
if (!this.listViewRenderer) {
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
if (!this.listViewRenderer) {
return;
}
const notes = this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
);
const dateNotes = notes[dateStr];
if (!dateNotes) return;
@ -349,15 +354,15 @@ export class ReviewSidebarView extends ItemView {
const path1 = dateNotes[index].path;
const path2 = dateNotes[index + 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new Notice(`Moved note down`);
await this.refresh();
new Notice(`Moved note down.`);
}
/**
* Group notes by their folder
* (Existing Method - Consider moving to utility/service)
*/
async groupNotesByFolder(notes: ReviewSchedule[]): Promise<Record<string, ReviewSchedule[]>> {
groupNotesByFolder(notes: ReviewSchedule[]): Record<string, ReviewSchedule[]> {
const grouped: Record<string, ReviewSchedule[]> = {};
for (const note of notes) {
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
@ -374,14 +379,14 @@ export class ReviewSidebarView extends ItemView {
// const isPomodoroOpen = this.pomodoroUIManager ? this.pomodoroUIManager.getIsPomodoroSectionOpen() : false; // Removed
// Capture scroll position just before saving state
if (this.mainContainer) {
this.lastScrollPosition = this.mainContainer.scrollTop;
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,
lastScrollPosition: this.lastScrollPosition,
sidebarViewType: this.plugin.settings.sidebarViewType,
};
}
@ -400,14 +405,14 @@ export class ReviewSidebarView extends ItemView {
}
// Refresh the view first to ensure elements and managers are created/initialized
await this.refresh();
await this.refresh();
// Now that elements exist (due to refresh), set the state for components like Pomodoro
// Ensure pomodoroUIManager is initialized before setting state
// if (this.pomodoroUIManager) { // Logic related to isPomodoroSectionOpen removed
// this.pomodoroUIManager.setIsPomodoroSectionOpen(state.isPomodoroSectionOpen ?? false);
// No need to call updatePomodoroUI here, as refresh -> showCorrectViewPane -> renderListViewContent -> render (in ListViewRenderer)
// should call pomodoroUIManager.updatePomodoroUI() if the section is enabled.
// this.pomodoroUIManager.setIsPomodoroSectionOpen(state.isPomodoroSectionOpen ?? false);
// No need to call updatePomodoroUI here, as refresh -> showCorrectViewPane -> renderListViewContent -> render (in ListViewRenderer)
// should call pomodoroUIManager.updatePomodoroUI() if the section is enabled.
// } else {
// }

View file

@ -1,10 +1,12 @@
import { App, TFile, Notice, Setting } from "obsidian";
import { Notice, Setting } from "obsidian";
import SpaceforgePlugin from "../../main";
import { ReviewSchedule } from "../../models/review-schedule";
import { DateUtils } from "../../utils/dates";
import { EstimationUtils } from "../../utils/estimation";
import { PomodoroUIManager } from "./pomodoro-ui-manager";
import { NoteItemRenderer } from "./note-item-renderer";
import { ConfirmationModal } from "../confirmation-modal";
/**
* Manages the rendering of the list view within the sidebar.
@ -68,9 +70,8 @@ export class ListViewRenderer {
// --- Review Buttons & Pomodoro ---
// Pass this.plugin.reviewController.getTodayNotes() to ensure Pomodoro uses notes for the selected date context
const notesForPomodoro = this.plugin.reviewController.getTodayNotes();
await this._ensureAndUpdateReviewButtonsSection(container, notesForPomodoro, selectedNotes);
await this._ensureAndUpdateReviewButtonsSection(container, this.plugin.reviewController.getTodayNotes(), selectedNotes);
// --- "All Caught Up" Message ---
this._ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate);
@ -85,12 +86,12 @@ export class ListViewRenderer {
notesToGroup = dueNotesForStats; // Use already fetched due notes
}
const groupedNotes = await this.groupNotesByDate(notesToGroup, shouldIncludeFutureInGrouping);
const groupedNotes = this.groupNotesByDate(notesToGroup, shouldIncludeFutureInGrouping);
const sortedDateKeys = this.getSortedDateKeys(groupedNotes);
// --- Render Sections for Main List ---
await this._ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes);
// --- Active Session Section ---
this._ensureAndUpdateActiveSessionSection(container);
@ -101,7 +102,7 @@ export class ListViewRenderer {
const existingUpcomingSection = container.querySelector(".review-upcoming-section");
if (existingUpcomingSection) existingUpcomingSection.remove();
}
this.updateBulkActionButtonsVisibility(container); // Ensure bulk actions visibility is correct at the end
}
@ -124,14 +125,14 @@ export class ListViewRenderer {
// statsCountEl.setText(`${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`);
// }
private async _ensureAndUpdateReviewButtonsSection(container: HTMLElement, notesForDisplay: ReviewSchedule[], selectedNotes: string[]): Promise<void> {
private _ensureAndUpdateReviewButtonsSection(container: HTMLElement, notesForDisplay: ReviewSchedule[], _selectedNotes: string[]): void {
// --- Pomodoro Section (Always visible when enabled, independent of notes) ---
let pomodoroContainer = container.querySelector(".sidebar-pomodoro-section") as HTMLElement;
if (!pomodoroContainer) {
pomodoroContainer = container.createDiv("sidebar-pomodoro-section");
const pomodoroSectionContainerEl = pomodoroContainer.createDiv("sidebar-pomodoro-button-container");
pomodoroContainer.createDiv("pomodoro-section-container");
}
// Update Pomodoro section visibility and content
if (this.pomodoroUIManager && pomodoroContainer) {
const pomodoroSectionContainerEl = pomodoroContainer.querySelector(".sidebar-pomodoro-button-container") as HTMLElement;
@ -141,9 +142,9 @@ export class ListViewRenderer {
pomodoroContainer.classList.remove('sf-hidden');
this.pomodoroUIManager.showPomodoroSection(true);
this.pomodoroUIManager.updatePomodoroUI();
// Automatically calculate estimation for current notes
this.pomodoroUIManager.calculateAndDisplayEstimation();
void this.pomodoroUIManager.calculateAndDisplayEstimation();
} else {
pomodoroContainer.classList.add('sf-hidden');
this.pomodoroUIManager.showPomodoroSection(false);
@ -160,73 +161,84 @@ export class ListViewRenderer {
reviewButtonsContainer = container.createDiv("review-buttons-container");
// Create all buttons once
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(); });
const prevNoteBtn = navButtonsContainer.createEl("button", { text: "Previous", title: "Navigate to previous note", cls: "review-all-button" });
prevNoteBtn.addEventListener("click", () => { void this.plugin.reviewController.navigateToPreviousNote(); });
const nextNoteBtn = navButtonsContainer.createEl("button", { text: "Next", title: "Navigate to next note", cls: "review-all-button" });
nextNoteBtn.addEventListener("click", () => { void this.plugin.reviewController.navigateToNextNote(); });
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", () => { void 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", () => { void this.plugin.reviewController.reviewAllTodaysNotes(); });
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" });
const reviewAllMCQBtn = reviewButtonsContainer.createEl("button", { text: "Review all with MCQs", cls: "review-all-mcq-button" });
reviewAllMCQBtn.addEventListener("click", () => { this.plugin.reviewController.reviewAllNotesWithMCQ(true); });
}
}
reviewButtonsContainer.classList.remove('sf-hidden');
// Bulk action buttons
let bulkActionButtons = container.querySelector(".review-bulk-actions") as HTMLElement;
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); // Use getter
const reviewSelectedBtn = bulkActionButtons.createEl("button", { text: "Review selected", cls: "review-bulk-button" });
reviewSelectedBtn.addEventListener("click", () => {
void this.plugin.reviewController.reviewNotes(this.getSelectedNotes(), false); // Use getter
this.setSelectedNotes([]);
await this.refreshSidebarView();
void this.refreshSidebarView();
});
const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance Selected", cls: "review-bulk-button review-bulk-advance" });
advanceSelectedBtn.addEventListener("click", async () => {
const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance selected", cls: "review-bulk-button review-bulk-advance" });
advanceSelectedBtn.addEventListener("click", () => {
const pathsToAdvance = [...this.getSelectedNotes()];
if (pathsToAdvance.length === 0) {
new Notice("No notes selected to advance.");
return;
}
// The controller's advanceNotes will handle eligibility and notices
await this.plugin.reviewController.advanceNotes(pathsToAdvance);
this.setSelectedNotes([]); // Clear selection after action
// advanceNotes in controller already calls savePluginData and refreshSidebarView (via updateTodayNotes)
// A direct refresh here might be redundant but ensures UI update if controller logic changes.
await this.refreshSidebarView();
void (async () => {
await this.plugin.reviewController.advanceNotes(pathsToAdvance);
this.setSelectedNotes([]); // Clear selection after action
// advanceNotes in controller already calls savePluginData and refreshSidebarView (via updateTodayNotes)
// A direct refresh here might be redundant but ensures UI update if controller logic changes.
await this.refreshSidebarView();
})();
});
const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone Selected", cls: "review-bulk-button review-bulk-postpone" });
postponeSelectedBtn.addEventListener("click", async () => {
const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone selected", cls: "review-bulk-button review-bulk-postpone" });
postponeSelectedBtn.addEventListener("click", () => {
const pathsToPostpone = [...this.getSelectedNotes()];
if (pathsToPostpone.length === 0) {
new Notice("No notes selected to postpone.");
return;
}
this.setSelectedNotes([]);
await this.plugin.reviewController.postponeNotes(pathsToPostpone);
// postponeNotes in controller handles save and refresh.
await this.refreshSidebarView();
// Notice is handled by controller/service for postpone.
void (async () => {
await this.plugin.reviewController.postponeNotes(pathsToPostpone);
// postponeNotes in controller handles save and refresh.
await this.refreshSidebarView();
// Notice is handled by controller/service for postpone.
})();
});
const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove Selected", cls: "review-bulk-button review-bulk-remove" });
removeSelectedBtn.addEventListener("click", async () => {
const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove selected", cls: "review-bulk-button review-bulk-remove" });
removeSelectedBtn.addEventListener("click", () => {
const pathsToRemove = [...this.getSelectedNotes()]; // Use getter
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 Notice(`Removed ${pathsToRemove.length} selected notes.`);
new ConfirmationModal(
this.plugin.app,
'Remove Selected Notes',
`Remove ${pathsToRemove.length} selected notes from review schedule?`,
() => {
this.setSelectedNotes([]);
void (async () => {
await this.plugin.reviewController.removeNotes(pathsToRemove);
await this.plugin.savePluginData();
await this.refreshSidebarView();
new Notice(`Removed ${pathsToRemove.length} selected notes.`);
})();
}
).open();
});
}
this.updateBulkActionButtonsVisibility(container); // Update visibility based on current selection
@ -234,7 +246,7 @@ export class ListViewRenderer {
} else if (reviewButtonsContainer) {
reviewButtonsContainer.classList.add('sf-hidden');
const bulkActionButtons = container.querySelector(".review-bulk-actions") as HTMLElement;
if (bulkActionButtons) bulkActionButtons.style.display = 'none';
if (bulkActionButtons) bulkActionButtons.toggleClass('sf-hidden', true);
}
}
@ -252,29 +264,29 @@ export class ListViewRenderer {
} else if (anchor) {
container.appendChild(caughtUpEl);
} else {
container.prepend(caughtUpEl); // Fallback
container.prepend(caughtUpEl); // Fallback
}
}
caughtUpEl.setText("All caught up! No notes due for review.");
caughtUpEl.style.display = '';
caughtUpEl.toggleClass('sf-hidden', false);
} else if (caughtUpEl) {
caughtUpEl.style.display = 'none';
caughtUpEl.toggleClass('sf-hidden', true);
}
}
private async _ensureAndUpdateDateSections(container: HTMLElement, sortedDateKeys: string[], groupedNotes: Record<string, ReviewSchedule[]>): Promise<void> {
const existingSectionElements = Array.from(container.querySelectorAll(".review-date-section")) as HTMLElement[];
const dataKeysInDom = new Set(existingSectionElements.map(el => el.dataset.dateKey).filter(Boolean));
const dataKeysFromData = new Set(sortedDateKeys);
let notesDisplayed = false;
// Remove stale sections
for (const sectionEl of existingSectionElements) {
if (!dataKeysFromData.has(sectionEl.dataset.dateKey!)) {
const key = sectionEl.dataset.dateKey;
if (key && !dataKeysFromData.has(key)) {
sectionEl.remove();
}
}
// Update existing or create new sections
for (const dateStr of sortedDateKeys) {
const notesForSection = groupedNotes[dateStr];
@ -291,41 +303,45 @@ export class ListViewRenderer {
const headerRow = dateSectionEl.createDiv("review-date-header");
const headerContainer = headerRow.createDiv("review-date-header-container");
headerContainer.createEl("h3"); // Placeholder for heading
// "Advance All" button for future sections
const todayStart = DateUtils.startOfDay(new Date()); // Returns timestamp
const sectionDateKeyIsFuture = !["Due notes", "Today"].includes(dateStr) &&
(notesForSection[0] && DateUtils.startOfDay(new Date(notesForSection[0].nextReviewDate)) > todayStart);
const sectionDateKeyIsFuture = !["Due notes", "Today"].includes(dateStr) &&
(notesForSection[0] && DateUtils.startOfDay(new Date(notesForSection[0].nextReviewDate)) > todayStart);
if (sectionDateKeyIsFuture) {
const advanceAllBtn = headerContainer.createEl("button", { text: "Advance All", cls: "review-date-action-button review-date-advance-all" });
const advanceAllBtn = headerContainer.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 () => {
advanceAllBtn.addEventListener("click", () => {
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);
// Notices and refresh are handled by advanceNotes in controller.
// await this.refreshSidebarView(); // May be redundant
new ConfirmationModal(
this.plugin.app,
'Advance All Notes',
`Advance all ${currentNotesForSection.length} notes from "${dateStr}" by 1 day? (Only future notes will be affected)`,
() => {
const paths = currentNotesForSection.map(note => note.path);
void this.plugin.reviewController.advanceNotes(paths);
}
).open();
});
}
const postponeAllBtn = headerContainer.createEl("button", { text: "Postpone All", cls: "review-date-action-button review-date-postpone-all" });
const postponeAllBtn = headerContainer.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 () => {
postponeAllBtn.addEventListener("click", () => {
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);
// Notices and refresh are handled by postponeNotes in controller.
// await this.refreshSidebarView(); // May be redundant
new ConfirmationModal(
this.plugin.app,
'Postpone All Notes',
`Postpone all ${currentNotesForSection.length} notes from "${dateStr}" by ${daysToPostpone} day(s)?`,
() => {
const paths = currentNotesForSection.map(note => note.path);
void this.plugin.reviewController.postponeNotes(paths, daysToPostpone);
}
).open();
});
headerRow.createSpan("review-date-time"); // Placeholder for time
@ -336,11 +352,10 @@ export class ListViewRenderer {
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
}
}
// Update header content
dateSectionEl.removeClass("review-date-section-overdue");
const actualTodayStart = DateUtils.startOfDay(new Date());
const isDefaultTodayView = !this.getActiveListBaseDate();
// const isDefaultTodayView = !this.getActiveListBaseDate(); // Unused
// A section is considered "overdue" if its category key is "Due notes".
// This applies whether in default view (actual overdue notes)
@ -385,7 +400,8 @@ export class ListViewRenderer {
// - If viewing a specific past date from calendar (isDefaultTodayView = false),
// "Due notes" might be the category for notes *on* that day.
// In this case, overdue days relative to that selected past date will be 0.
const referenceDateForDiff = isDefaultTodayView ? todayActualStart : DateUtils.startOfDay(this.getActiveListBaseDate()!);
const baseDate = this.getActiveListBaseDate();
const referenceDateForDiff = baseDate ? DateUtils.startOfDay(baseDate) : todayActualStart;
return Math.floor((referenceDateForDiff - new Date(note.nextReviewDate).getTime()) / (24 * 60 * 60 * 1000));
});
const maxDays = Math.max(0, ...daysDiff.filter(d => d >= 0 && !isNaN(d))); // Ensure positive days and filter NaN
@ -395,15 +411,15 @@ export class ListViewRenderer {
overdueBadge = dateHeading.createSpan("review-overdue-badge sf-overdue-badge");
}
overdueBadge.setText(` (${maxDays} ${maxDays === 1 ? 'day' : 'days'} overdue)`);
overdueBadge.style.display = '';
overdueBadge.toggleClass('sf-hidden', false);
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // No positive overdue days
overdueBadge.toggleClass('sf-hidden', true); // No positive overdue days
}
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // No overdue notes in section
overdueBadge.toggleClass('sf-hidden', true); // No overdue notes in section
}
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // Conditions for badge not met
overdueBadge.toggleClass('sf-hidden', true); // Conditions for badge not met
}
let sectionTime = 0;
@ -412,7 +428,7 @@ export class ListViewRenderer {
await this._updateOrRenderNoteList(notesContainerEl, notesForSection, dateStr, container);
}
// Message for activeListBaseDate with no notes
let noNotesForDateMsg = container.querySelector(".review-no-notes-for-date") as HTMLElement;
const activeListBaseDate = this.getActiveListBaseDate();
@ -421,9 +437,9 @@ export class ListViewRenderer {
noNotesForDateMsg = container.createDiv("review-no-notes-for-date"); // Similar to all-caught-up
}
noNotesForDateMsg.setText(`No notes scheduled on or after ${activeListBaseDate.toLocaleDateString()}.`);
noNotesForDateMsg.style.display = '';
noNotesForDateMsg.toggleClass('sf-hidden', false);
} else if (noNotesForDateMsg) {
noNotesForDateMsg.style.display = 'none';
noNotesForDateMsg.toggleClass('sf-hidden', true);
}
}
@ -434,28 +450,28 @@ export class ListViewRenderer {
if (activeSession) {
if (!sessionSection) {
sessionSection = container.createDiv("review-session-section");
new Setting(sessionSection).setHeading().setName("Active Review Session");
new Setting(sessionSection).setHeading().setName("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" });
const continueBtn = sessionSection.createEl("button", { text: "Continue session", cls: "review-session-continue" });
continueBtn.addEventListener("click", () => { /* ... continue logic ... */ }); // TODO: Implement continue logic if not already present
const endBtn = sessionSection.createEl("button", { text: "End Session", cls: "review-session-end" });
endBtn.addEventListener("click", () => {
this.plugin.reviewSessionService.setActiveSession(null);
this.refreshSidebarView();
const endBtn = sessionSection.createEl("button", { text: "End session", cls: "review-session-end" });
endBtn.addEventListener("click", () => {
this.plugin.reviewSessionService.setActiveSession(null);
void this.refreshSidebarView();
});
}
sessionSection.style.display = '';
sessionSection.toggleClass('sf-hidden', false);
(sessionSection.querySelector(".review-session-name") as HTMLElement).setText(activeSession.name);
(sessionSection.querySelector(".review-session-progress") as HTMLElement).setText(`Progress: ${activeSession.currentIndex}/${activeSession.hierarchy.traversalOrder.length}`);
const progressBar = sessionSection.querySelector(".review-session-progress-bar") as HTMLElement;
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';
sessionSection.toggleClass('sf-hidden', true);
}
}
@ -476,19 +492,20 @@ export class ListViewRenderer {
if (upcomingKeys.length > 0) {
if (!upcomingSection) {
upcomingSection = container.createDiv("review-upcoming-section");
new Setting(upcomingSection).setHeading().setName("Upcoming Reviews");
new Setting(upcomingSection).setHeading().setName("Upcoming reviews");
upcomingSection.createDiv("review-upcoming-list"); // List container
}
upcomingSection.style.display = '';
upcomingSection.toggleClass('sf-hidden', false);
const upcomingListEl = upcomingSection.querySelector(".review-upcoming-list") as HTMLElement;
if (!upcomingListEl) return; // Should not happen
const existingDayItemElements = Array.from(upcomingListEl.querySelectorAll(".review-upcoming-day")) as HTMLElement[];
const dayKeysInDom = new Set(existingDayItemElements.map(el => el.dataset.dayKey).filter(Boolean));
// Remove stale day items
for (const dayItemEl of existingDayItemElements) {
if (!upcomingKeys.includes(dayItemEl.dataset.dayKey!)) {
const key = dayItemEl.dataset.dayKey;
if (key && !upcomingKeys.includes(key)) {
dayItemEl.remove();
}
}
@ -511,21 +528,21 @@ export class ListViewRenderer {
const daySummary = dayItemEl.createDiv("review-upcoming-day-summary");
daySummary.createEl("span", { cls: "review-upcoming-day-name" }); // Placeholder for name
dayItemEl.addEventListener("click", async () => { // Attach listener once
dayItemEl.addEventListener("click", () => { // Attach listener once
const currentDayKey = dayItemEl.dataset.dayKey;
if (!currentDayKey) return;
const expandedUpcomingDayKey = this.getExpandedUpcomingDayKey();
const isCurrentlyExpanded = expandedUpcomingDayKey === currentDayKey;
this.setExpandedUpcomingDayKey(isCurrentlyExpanded ? null : currentDayKey);
// Visual state and note list rendering will be handled by the main render pass
await this.refreshSidebarView(); // Trigger a re-render to reflect expansion
void this.refreshSidebarView(); // Trigger a re-render to reflect expansion
});
}
// Update summary
const daySummaryNameEl = dayItemEl.querySelector(".review-upcoming-day-summary .review-upcoming-day-name") as HTMLElement;
let upcomingDisplayHeader = dayKey;
if (notesForDay.length > 0) { // Should always be true here
if (notesForDay.length > 0) { // Should always be true here
const sampleUpcomingDate = new Date(notesForDay[0].nextReviewDate);
const formattedUpcomingDate = DateUtils.formatDate(sampleUpcomingDate.getTime(), 'medium');
if (["Today", "Tomorrow"].includes(dayKey) || dayKey.startsWith("In ")) {
@ -545,15 +562,15 @@ export class ListViewRenderer {
if (!notesContainerEl) {
notesContainerEl = dayItemEl.createDiv("review-upcoming-notes-container");
}
notesContainerEl.style.display = '';
notesContainerEl.toggleClass('sf-hidden', false);
await this._updateOrRenderNoteList(notesContainerEl, notesForDay, dayKey, container);
} else if (notesContainerEl) {
notesContainerEl.style.display = 'none'; // Hide instead of removing
notesContainerEl.toggleClass('sf-hidden', true); // Hide instead of removing
}
}
} else if (upcomingSection) {
upcomingSection.style.display = 'none';
upcomingSection.toggleClass('sf-hidden', true);
}
}
@ -561,9 +578,9 @@ export class ListViewRenderer {
* Renders or updates a list of notes within a given container.
*/
private async _updateOrRenderNoteList(
notesContainer: HTMLElement,
notes: ReviewSchedule[],
dateStr: string,
notesContainer: HTMLElement,
notes: ReviewSchedule[],
dateStr: string,
parentContainerForBulkActions: HTMLElement
): Promise<void> {
const existingNoteElements = Array.from(notesContainer.querySelectorAll('.review-note-item[data-note-path]')) as HTMLElement[];
@ -608,7 +625,7 @@ export class ListViewRenderer {
for (const noteEl of notesInOrder) {
notesContainer.appendChild(noteEl);
}
this.setLastSelectedNotePath(lastSelectedNotePathRef.current);
}
@ -653,9 +670,9 @@ export class ListViewRenderer {
private updateBulkActionButtonsVisibility(container: HTMLElement): void {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector('.review-bulk-actions') as HTMLElement;
if (bulkActionsContainer) {
bulkActionsContainer.style.display = selectedNotesPaths.length > 1 ? 'flex' : 'none';
bulkActionsContainer.toggleClass('sf-hidden', selectedNotesPaths.length <= 1);
// Handle visibility/disabled state of "Advance Selected" button
const advanceSelectedBtn = bulkActionsContainer.querySelector('.review-bulk-advance') as HTMLButtonElement | null;
@ -667,9 +684,9 @@ export class ListViewRenderer {
return schedule && DateUtils.startOfDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.style.display = ''; // Always show if bulk actions are visible, rely on disabled state
advanceSelectedBtn.toggleClass('sf-hidden', false); // Always show if bulk actions are visible, rely on disabled state
} else {
advanceSelectedBtn.disabled = true;
advanceSelectedBtn.disabled = true;
// advanceSelectedBtn.style.display = 'none'; // Or hide if no selection
}
}
@ -694,7 +711,7 @@ export class ListViewRenderer {
const reviewButtonsContainer = container.querySelector('.review-buttons-container');
if (reviewButtonsContainer) {
(reviewButtonsContainer as HTMLElement).style.display = dueNotesForStats.length > 0 ? '' : 'none';
(reviewButtonsContainer as HTMLElement).toggleClass('sf-hidden', dueNotesForStats.length === 0);
}
this.updateBulkActionButtonsVisibility(container);
@ -703,15 +720,15 @@ export class ListViewRenderer {
const notesInCurrentContext = this.plugin.reviewController.getTodayNotes();
if (allCaughtUpEl) {
(allCaughtUpEl as HTMLElement).style.display = notesInCurrentContext.length === 0 ? '' : 'none';
(allCaughtUpEl as HTMLElement).toggleClass('sf-hidden', notesInCurrentContext.length !== 0);
if (notesInCurrentContext.length === 0) {
allCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
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.");
// Insert after stats or buttons if they exist
// const statsEl = container.querySelector(".review-stats-list-view"); // Stats section is removed
const anchor = buttonsContainer; // || statsEl; // Stats section removed
@ -720,7 +737,7 @@ export class ListViewRenderer {
} else if (anchor) {
container.appendChild(newCaughtUpEl);
} else {
container.prepend(newCaughtUpEl); // Fallback
container.prepend(newCaughtUpEl); // Fallback
}
}
}
@ -770,11 +787,11 @@ export class ListViewRenderer {
let overdueBadge = headerTextEl.querySelector(".review-overdue-badge") as HTMLElement | null;
if (dateStr === "Due notes") {
const daysDiff = await Promise.all(notesInSection.map(async noteEl => {
const daysDiff = notesInSection.map(noteEl => {
const path = (noteEl as HTMLElement).dataset.notePath;
const noteSchedule = path ? this.plugin.reviewScheduleService.schedules[path] : null;
return noteSchedule ? Math.abs(Math.floor((noteSchedule.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1000))) : 0;
}));
});
const maxDays = Math.max(0, ...daysDiff.filter(d => !isNaN(d)));
if (maxDays > 0) {
const badgeText = ` (${maxDays} ${maxDays === 1 ? 'day' : 'days'} overdue)`;
@ -807,7 +824,7 @@ export class ListViewRenderer {
/**
* Group notes by their review date, considering activeListBaseDate.
*/
async groupNotesByDate(notes: ReviewSchedule[], includeFuture: boolean = false): Promise<Record<string, ReviewSchedule[]>> {
groupNotesByDate(notes: ReviewSchedule[], _includeFuture = false): Record<string, ReviewSchedule[]> {
const grouped: Record<string, ReviewSchedule[]> = {};
const actualTodayStart = DateUtils.startOfDay(new Date());
const activeListBaseDate = this.getActiveListBaseDate();
@ -831,7 +848,7 @@ export class ListViewRenderer {
// Determine the group string.
// If activeListBaseDate is set, format relative to that date.
// If activeListBaseDate is null (default view), format relative to actual current time.
let dateStr = DateUtils.formatDate(note.nextReviewDate, 'relative', activeListBaseDate);
const dateStr = DateUtils.formatDate(note.nextReviewDate, 'relative', activeListBaseDate);
// Overdue notes will now naturally be grouped under "Due notes" (or similar key from DateUtils)
// by the dateStr determined above, rather than being merged into "Today".

View file

@ -3,6 +3,7 @@ import SpaceforgePlugin from "../../main";
import { ReviewSchedule } from "../../models/review-schedule";
import { DateUtils } from "../../utils/dates";
import { EstimationUtils } from "../../utils/estimation";
import { ConfirmationModal } from "../confirmation-modal";
export class NoteItemRenderer {
private plugin: SpaceforgePlugin;
@ -60,7 +61,7 @@ export class NoteItemRenderer {
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.createDiv({ cls: "phase-time", title: formattedTime, text: formattedTime });
phaseEl.addClass("review-phase-initial");
} else {
phaseEl.setText(note.scheduleCategory === 'graduated' ? "Graduated" : "Spaced");
@ -71,16 +72,16 @@ export class NoteItemRenderer {
EstimationUtils.formatTimeWithColor(estimatedTime, timeElNew);
}
}
// Drag handle visibility (buttons are static, drag handle might change)
const buttonsEl = noteEl.querySelector(".review-note-buttons") as HTMLElement;
let dragHandleEl = buttonsEl?.querySelector(".review-note-drag-handle") as HTMLElement;
const dragHandleEl = buttonsEl?.querySelector(".review-note-drag-handle") as HTMLElement;
if (dragHandleEl) { // If it exists, update its state or recreate if logic is complex
const isDraggable = (dateStr === 'Due notes' || dateStr === 'Today');
dragHandleEl.classList.toggle("is-disabled", !isDraggable);
// Ensure draggable attribute is managed if it was set directly
if (isDraggable && !dragHandleEl.hasAttribute("draggable")) {
// No need to set draggable here, mousedown does it.
// No need to set draggable here, mousedown does it.
} else if (!isDraggable) {
noteEl.removeAttribute("draggable"); // Ensure main element is not draggable
}
@ -146,7 +147,7 @@ export class NoteItemRenderer {
const advanceBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-advance" });
setIcon(advanceBtn, "arrow-left-circle"); // Or 'chevrons-left', 'skip-back'
advanceBtn.title = "Advance";
const postponeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-postpone" });
setIcon(postponeBtn, "arrow-right-circle"); // Or 'chevrons-right', 'skip-forward'
postponeBtn.title = "Postpone";
@ -154,7 +155,7 @@ export class NoteItemRenderer {
const removeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-remove" });
setIcon(removeBtn, "trash-2");
removeBtn.title = "Remove";
const dragHandleEl = buttonsEl.createDiv("review-note-drag-handle"); // Create drag handle structure
dragHandleEl.setAttribute('aria-label', 'Drag to reorder');
for (let i = 0; i < 3; i++) {
@ -164,72 +165,83 @@ export class NoteItemRenderer {
// --- Attach Event Listeners (once) ---
// Note: these listeners will use 'noteToRender' from the outer scope at the time of creation.
// If note data within the item needs to be fresh for these handlers, they might need to re-fetch it via noteEl.dataset.notePath
titleEl.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath; // Get current path from element
if (path) this.plugin.reviewController.openNoteWithoutReview(path);
if (path) void this.plugin.reviewController.openNoteWithoutReview(path);
});
reviewBtn.addEventListener("click", async (e) => {
reviewBtn.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
await this.plugin.reviewController.reviewNote(path);
// onNoteAction is often called by the review process itself,
// but calling here ensures refresh if reviewNote doesn't trigger it.
await onNoteAction();
void (async () => {
await this.plugin.reviewController.reviewNote(path);
// onNoteAction is often called by the review process itself,
// but calling here ensures refresh if reviewNote doesn't trigger it.
await onNoteAction();
})();
}
});
advanceBtn.addEventListener("click", async (e) => {
advanceBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (advanceBtn.disabled) return;
const path = noteEl.dataset.notePath;
if (path) {
await this.plugin.reviewController.advanceNote(path);
// The controller's advanceNote method handles notices and sidebar refresh.
// onNoteAction ensures this renderer's parent (ListViewRenderer) refreshes.
await onNoteAction();
void (async () => {
await this.plugin.reviewController.advanceNote(path);
// The controller's advanceNote method handles notices and sidebar refresh.
// onNoteAction ensures this renderer's parent (ListViewRenderer) refreshes.
await onNoteAction();
})();
}
});
postponeBtn.addEventListener("click", async (e) => {
postponeBtn.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
try {
await this.plugin.reviewController.postponeNote(path);
await this.plugin.savePluginData();
new Notice(`Note postponed`);
await onNoteAction();
} catch (error) {
new Notice("Failed to postpone note.");
await onNoteAction();
}
void (async () => {
try {
await this.plugin.reviewController.postponeNote(path);
new Notice(`Note postponed`);
await onNoteAction();
} catch (_error) {
new Notice("Failed to postpone note.");
await onNoteAction();
}
})();
}
});
removeBtn.addEventListener("click", async (e) => {
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`);
if (!confirmed) return;
try {
await this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice(`Note removed from review schedule`);
await onNoteAction();
} catch (error) {
new Notice("Failed to remove note from schedule.");
await onNoteAction();
}
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
try {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice(`Note removed from review schedule`);
await onNoteAction();
} catch (_error) {
new Notice("Failed to remove note from schedule.");
await onNoteAction();
}
})();
}
).open();
}
});
dragHandleEl.addEventListener("mousedown", (e) => {
e.stopPropagation();
// Only enable dragging if not disabled (checked by _populateNoteItemDetails via class)
@ -240,7 +252,7 @@ export class NoteItemRenderer {
noteEl.addEventListener("dragstart", (e) => {
const path = noteEl.dataset.notePath;
if (path && noteEl.getAttribute("draggable") === "true") { // Check if draggable
e.dataTransfer?.setData("text/plain", path);
e.dataTransfer?.setData("text/plain", path);
} else {
e.preventDefault(); // Prevent drag if not supposed to be draggable
}
@ -291,7 +303,7 @@ export class NoteItemRenderer {
lastSelectedNotePathRef.current = currentPath;
onSelectionChange();
});
noteEl.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -306,9 +318,9 @@ export class NoteItemRenderer {
menu.addItem((item) => item
.setTitle("Review note")
.setIcon("play-circle")
.onClick(async () => {
this.plugin.reviewController.reviewNote(path);
await onNoteAction();
.onClick(() => {
void this.plugin.reviewController.reviewNote(path);
void onNoteAction();
}));
menu.addItem((item) => item
.setTitle("Postpone by 1 day")
@ -340,15 +352,21 @@ export class NoteItemRenderer {
menu.addItem((item) => item
.setTitle("Remove from review")
.setIcon("trash")
.onClick(async () => {
.onClick(() => {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`);
if (confirmed) {
await this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice("Note removed from review schedule.");
await onNoteAction();
}
new ConfirmationModal(
this.plugin.app,
'Remove Note',
`Remove "${file instanceof TFile ? file.basename : path}" from review schedule?`,
() => {
void (async () => {
this.plugin.reviewScheduleService.removeFromReview(path);
await this.plugin.savePluginData();
new Notice("Note removed from review schedule.");
await onNoteAction();
})();
}
).open();
}));
menu.showAtMouseEvent(e);
});

View file

@ -7,8 +7,7 @@ export class PomodoroUIManager {
private attachedContainer: HTMLElement | null = null; // The container provided by ListViewRenderer
// References to Pomodoro UI elements
private pomodoroVisibilityToggleBtnContainer: HTMLElement | null = null;
private pomodoroVisibilityToggleBtn: HTMLButtonElement | null = null;
// private pomodoroVisibilityToggleBtnContainer & Btn removed as unused
public pomodoroRootEl: HTMLElement | null = null; // Container for the actual timer content
private pomodoroTimerDisplayEl: HTMLElement | null = null;
private pomodoroStartBtn: HTMLButtonElement | null = null;
@ -21,24 +20,24 @@ export class PomodoroUIManager {
private pomodoroQuickLongInput: HTMLInputElement | null = null;
private pomodoroQuickSessionsInput: HTMLInputElement | null = null;
private pomodoroCalculationResultEl: HTMLElement | null = null;
// New estimation and cycle tracking elements (moved to calculation panel)
private pomodoroCycleProgressEl: HTMLElement | null = null;
// User override input elements
private pomodoroUserOverrideHoursInput: HTMLInputElement | null = null;
private pomodoroUserOverrideMinutesInput: HTMLInputElement | null = null;
private pomodoroUserAddToEstimationCheckbox: HTMLInputElement | null = null;
// private isPomodoroSectionOpen: boolean = false; // No longer needed, section is always "open"
private areButtonsVisible: boolean = true; // For Play/Pause/Skip buttons
private isTimerTextVisible: boolean = true; // For the timer countdown text
private areButtonsVisible = true; // For Play/Pause/Skip buttons
private isTimerTextVisible = true; // For the timer countdown text
private longPressTimer: number | null = null;
private veryLongPressTimer: number | null = null;
private readonly LONG_PRESS_DURATION = 500; // ms
private readonly VERY_LONG_PRESS_DURATION = 1500; // ms
private didLongPress: boolean = false;
private didVeryLongPress: boolean = false;
private didLongPress = false;
private didVeryLongPress = false;
/**
@ -56,12 +55,12 @@ export class PomodoroUIManager {
// new Notice("Pomodoro durations updated."); // Removed notification
return true;
} else {
new Notice("Invalid Pomodoro durations. Settings not saved. Please enter positive numbers.");
new Notice("Invalid Pomodoro durations. Settings not saved. Please enter positive numbers."); // eslint-disable-line obsidianmd/ui/sentence-case
// Re-populate with current valid settings to prevent saving invalid on next close if not corrected
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);
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;
}
}
@ -78,7 +77,7 @@ export class PomodoroUIManager {
*/
public attachAndRender(container: HTMLElement): void {
this.attachedContainer = container;
// Ensure pomodoroRootEl exists and is parented correctly
// The old pomodoroVisibilityToggleBtnContainer is now hidden by CSS and its logic removed.
let rootElWasCreated = false;
@ -98,9 +97,9 @@ export class PomodoroUIManager {
if (rootElWasCreated || (this.pomodoroRootEl && this.pomodoroRootEl.children.length === 0)) {
this.renderPomodoroTimer(this.pomodoroRootEl);
}
// Always update UI details as the section is considered always open
this.updatePomodoroUI();
this.updatePomodoroUI();
}
// getIsPomodoroSectionOpen, setIsPomodoroSectionOpen, setupPomodoroVisibilityToggleButton, updatePomodoroVisibility are no longer needed
@ -154,15 +153,14 @@ export class PomodoroUIManager {
// Timer Display
if (!this.pomodoroTimerDisplayEl || this.pomodoroTimerDisplayEl.parentElement !== mainControlsRow) {
this.pomodoroTimerDisplayEl?.remove();
this.pomodoroTimerDisplayEl = mainControlsRow.createDiv("pomodoro-timer-display");
this.pomodoroTimerDisplayEl.addClass("pomodoro-timer-fade");
this.pomodoroTimerDisplayEl = mainControlsRow.createDiv({ cls: "pomodoro-timer-display pomodoro-timer-fade" });
}
this.pomodoroTimerDisplayEl.setText(this.plugin.pomodoroService.getFormattedTimeLeft());
// Add event listeners for long-press and click on timer display
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.addEventListener("mousedown", (e) => {
// Prevent text selection during long press
e.preventDefault();
e.preventDefault();
this.didLongPress = false;
this.didVeryLongPress = false;
@ -173,11 +171,11 @@ export class PomodoroUIManager {
}, this.LONG_PRESS_DURATION);
this.veryLongPressTimer = window.setTimeout(() => {
this.didVeryLongPress = true;
this.didVeryLongPress = true;
this.isTimerTextVisible = !this.isTimerTextVisible; // Toggle timer text
// If timer text is now hidden, also hide buttons. If shown, show buttons.
this.areButtonsVisible = this.isTimerTextVisible;
this.updateTimerTextDisplay();
this.areButtonsVisible = this.isTimerTextVisible;
this.updateTimerTextDisplay();
this.updateButtonVisibility();
}, this.VERY_LONG_PRESS_DURATION);
});
@ -210,7 +208,7 @@ export class PomodoroUIManager {
this.pomodoroTimerDisplayEl.addEventListener("mouseup", handlePressEnd);
this.pomodoroTimerDisplayEl.addEventListener("touchend", handlePressEnd);
const cancelPress = () => {
if (this.veryLongPressTimer) window.clearTimeout(this.veryLongPressTimer);
if (this.longPressTimer) window.clearTimeout(this.longPressTimer);
@ -222,10 +220,10 @@ export class PomodoroUIManager {
this.pomodoroTimerDisplayEl.addEventListener("mouseleave", cancelPress);
this.pomodoroTimerDisplayEl.addEventListener("touchmove", cancelPress);
// Touchstart needs to mirror mousedown logic for setting up timers
this.pomodoroTimerDisplayEl.addEventListener("touchstart", (e) => {
e.preventDefault();
this.pomodoroTimerDisplayEl.addEventListener("touchstart", (e) => {
e.preventDefault();
this.didLongPress = false;
this.didVeryLongPress = false;
@ -257,7 +255,7 @@ export class PomodoroUIManager {
this.pomodoroCycleProgressEl?.remove();
this.pomodoroCycleProgressEl = container.createDiv("pomodoro-cycle-progress");
}
// Quick Settings Toggle Button - REMOVED
// if (!this.pomodoroQuickSettingsToggleBtn || this.pomodoroQuickSettingsToggleBtn.parentElement !== mainControlsRow) {
// this.pomodoroQuickSettingsToggleBtn?.remove();
@ -267,8 +265,14 @@ export class PomodoroUIManager {
// this.pomodoroQuickSettingsToggleBtn.addEventListener("click", () => {
// const panel = this.pomodoroQuickSettingsPanelEl;
// if (!panel) return;
// const isCurrentlyHidden = panel.style.display === 'none' || !panel.style.display;
// panel.style.display = isCurrentlyHidden ? 'flex' : 'none';
// const isCurrentlyHidden = panel.classList.contains('sf-hidden');
// if (isCurrentlyHidden) {
// panel.classList.remove('sf-hidden');
// panel.classList.add('sf-visible');
// } else {
// panel.classList.remove('sf-visible');
// panel.classList.add('sf-hidden');
// }
// if (isCurrentlyHidden) { // Populate inputs when opening
// if(this.pomodoroQuickWorkInput) this.pomodoroQuickWorkInput.value = String(this.plugin.settings.pomodoroWorkDuration);
@ -278,22 +282,23 @@ export class PomodoroUIManager {
// }
// });
// }
// Quick Settings Panel
// The panel is now created directly under the mainControlsRow for better layout control with dropdown
let settingsPanelContainer = container.querySelector(".pomodoro-settings-panel-container") as HTMLElement;
if (!settingsPanelContainer) {
settingsPanelContainer = container.createDiv("pomodoro-settings-panel-container");
}
if (!this.pomodoroQuickSettingsPanelEl || this.pomodoroQuickSettingsPanelEl.parentElement !== settingsPanelContainer) {
this.pomodoroQuickSettingsPanelEl?.remove();
this.pomodoroQuickSettingsPanelEl = settingsPanelContainer.createDiv("pomodoro-quick-settings-panel");
this.pomodoroQuickSettingsPanelEl.classList.add('sf-hidden'); // Initial state
// Recreate inputs and buttons if panel is new
const createQuickSetting = (labelText: string, inputType: string = 'number'): HTMLInputElement => {
const settingDiv = this.pomodoroQuickSettingsPanelEl!.createDiv("pomodoro-quick-setting");
const panel = this.pomodoroQuickSettingsPanelEl;
const createQuickSetting = (labelText: string, inputType = 'number'): HTMLInputElement => {
const settingDiv = panel.createDiv("pomodoro-quick-setting");
settingDiv.createEl("label", { text: labelText });
const input = settingDiv.createEl("input", { type: inputType });
input.setAttr("min", "1");
@ -324,46 +329,46 @@ export class PomodoroUIManager {
// });
// User override time inputs
const overrideContainer = this.pomodoroQuickSettingsPanelEl.createDiv("pomodoro-override-container");
const overrideLabel = overrideContainer.createEl("label", { text: "Override time (optional):", cls: "pomodoro-override-label" });
const overrideContainer = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-override-container" });
overrideContainer.createEl("label", { text: "Override time (optional):", cls: "pomodoro-override-label" });
const overrideInputsContainer = overrideContainer.createDiv("pomodoro-override-inputs");
this.pomodoroUserOverrideHoursInput = overrideInputsContainer.createEl("input", { type: "number", cls: "pomodoro-override-hours" });
this.pomodoroUserOverrideHoursInput.setAttr("min", "0");
this.pomodoroUserOverrideHoursInput.setAttr("placeholder", "H");
this.pomodoroUserOverrideHoursInput.value = String(this.plugin.pluginState.pomodoroUserOverrideHours);
const hoursLabel = overrideInputsContainer.createSpan("pomodoro-override-label-small");
hoursLabel.setText("h");
hoursLabel.setText("h"); // eslint-disable-line obsidianmd/ui/sentence-case
this.pomodoroUserOverrideMinutesInput = overrideInputsContainer.createEl("input", { type: "number", cls: "pomodoro-override-minutes" });
this.pomodoroUserOverrideMinutesInput.setAttr("min", "0");
this.pomodoroUserOverrideMinutesInput.setAttr("placeholder", "M");
this.pomodoroUserOverrideMinutesInput.value = String(this.plugin.pluginState.pomodoroUserOverrideMinutes);
const minutesLabel = overrideInputsContainer.createSpan("pomodoro-override-label-small");
minutesLabel.setText("m");
minutesLabel.setText("m"); // eslint-disable-line obsidianmd/ui/sentence-case
// Add to estimation toggle
const toggleContainer = overrideContainer.createDiv("pomodoro-override-toggle-container");
this.pomodoroUserAddToEstimationCheckbox = toggleContainer.createEl("input", { type: "checkbox", cls: "pomodoro-add-to-estimation" });
this.pomodoroUserAddToEstimationCheckbox.checked = this.plugin.pluginState.pomodoroUserAddToEstimation;
const toggleLabel = toggleContainer.createEl("label", { text: "Add to estimated time", cls: "pomodoro-toggle-label" });
toggleLabel.setAttribute("for", "pomodoro-add-to-estimation");
const calculateBtn = buttonsContainer.createEl("button", { text: "Calculate Reading Time", cls: "pomodoro-quick-calculate-btn" });
calculateBtn.addEventListener("click", async () => {
const calculateBtn = buttonsContainer.createEl("button", { text: "Calculate reading time", cls: "pomodoro-quick-calculate-btn" });
calculateBtn.addEventListener("click", () => {
const settingsSaved = this._savePomodoroSettings();
if (settingsSaved) {
await this.calculateAndDisplayPomodoroEstimate();
void this.calculateAndDisplayPomodoroEstimate();
}
});
this.pomodoroCalculationResultEl = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-calculation-result" });
this.pomodoroCalculationResultEl.classList.add('sf-hidden');
}
this.updatePomodoroUI(); // Ensure UI reflects current state after potential recreation
}
@ -377,16 +382,16 @@ export class PomodoroUIManager {
this.saveUserOverrideSettings();
// Use notes from the review controller, which are context-aware (selected date or actual today)
const notesForEstimate = this.plugin.reviewController.getTodayNotes();
const notesForEstimate = this.plugin.reviewController.getTodayNotes();
// Check if we have either notes or user override
const userOverrideHours = this.plugin.pluginState.pomodoroUserOverrideHours || 0;
const userOverrideMinutes = this.plugin.pluginState.pomodoroUserOverrideMinutes || 0;
const userOverrideTimeInMinutes = (userOverrideHours * 60) + userOverrideMinutes;
if (notesForEstimate.length === 0 && userOverrideTimeInMinutes === 0) {
const activeDate = this.plugin.reviewController.getCurrentReviewDateOverride();
const message = activeDate
const message = activeDate
? `No notes scheduled for ${new Date(activeDate).toLocaleDateString()} to calculate.`
: "No notes currently due to calculate.";
this.pomodoroCalculationResultEl.setText(message);
@ -397,23 +402,22 @@ export class PomodoroUIManager {
// Use the PomodoroService to calculate estimation (this also resets and updates cycle tracking)
const result = await this.plugin.pomodoroService.calculateEstimationFromNotes(notesForEstimate);
if (!result) {
this.pomodoroCalculationResultEl.setText("Unable to calculate estimation.");
this.pomodoroCalculationResultEl.style.display = 'block';
this.pomodoroCalculationResultEl.removeClass('sf-hidden');
return;
}
const { totalReadingTimeInSeconds, totalReadingTimeInMinutes, pomodorosNeeded, totalTimeWithBreaksMinutes } = result;
const { totalReadingTimeInSeconds, pomodorosNeeded, totalTimeWithBreaksMinutes } = result;
// Get user override values for display (already declared above)
const addToEstimation = this.plugin.pluginState.pomodoroUserAddToEstimation || false;
const formattedTotalReadingTime = EstimationUtils.formatTime(totalReadingTimeInSeconds);
const formattedTotalTimeWithBreaks = EstimationUtils.formatTime(Math.ceil(totalTimeWithBreaksMinutes * 60));
this.pomodoroCalculationResultEl.empty();
// Show base reading time if we have notes and it wasn't completely overridden
if (notesForEstimate.length > 0 && totalReadingTimeInSeconds > 0 && (!userOverrideTimeInMinutes || addToEstimation)) {
// Calculate base reading time without overrides for display
@ -427,19 +431,19 @@ export class PomodoroUIManager {
// Show message when using only override time
this.pomodoroCalculationResultEl.createEl("p", { text: `Using override time only (no notes).` });
}
// Show user override information if applicable
if (userOverrideTimeInMinutes > 0) {
const overrideText = addToEstimation
const overrideText = addToEstimation
? `Added ${userOverrideHours}h ${userOverrideMinutes}m override time.`
: `Using ${userOverrideHours}h ${userOverrideMinutes}m override time (replacing estimate).`;
this.pomodoroCalculationResultEl.createEl("p", { text: overrideText, cls: "pomodoro-override-info" });
}
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';
this.pomodoroCalculationResultEl.removeClass('sf-hidden');
// Update the cycle progress display to show the new estimation
this.updateCycleProgressDisplay();
}
@ -455,8 +459,8 @@ export class PomodoroUIManager {
this.plugin.pluginState.pomodoroUserOverrideHours = hours;
this.plugin.pluginState.pomodoroUserOverrideMinutes = minutes;
this.plugin.pluginState.pomodoroUserAddToEstimation = addToEstimation;
this.plugin.savePluginData();
void this.plugin.savePluginData();
}
/**
@ -475,10 +479,10 @@ export class PomodoroUIManager {
}
if (isCurrentlyHidden) { // Populate inputs when opening
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);
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 {
// Panel is being closed, so save the settings
this._savePomodoroSettings();
@ -494,14 +498,14 @@ export class PomodoroUIManager {
// Using opacity is generally smoother and preserves layout/border.
}
}
private updateButtonVisibility(): void {
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;
if (this.pomodoroStartBtn) this.pomodoroStartBtn.toggleClass('sf-hidden', isRunning);
if (this.pomodoroStopBtn) this.pomodoroStopBtn.toggleClass('sf-hidden', !isRunning);
if (this.pomodoroSkipBtn) this.pomodoroSkipBtn.toggleClass('sf-hidden', !this.areButtonsVisible);
}
@ -509,13 +513,13 @@ export class PomodoroUIManager {
* Updates the Pomodoro UI based on the current state from PomodoroService.
*/
public updatePomodoroUI(): void {
// Don't update if the UI hasn't been attached yet or pomodoroRootEl is not created
// Don't update if the UI hasn't been attached yet or pomodoroRootEl is not created
if (!this.attachedContainer || !this.pomodoroRootEl) {
return;
return;
}
// Ensure the root container is visible (it should always be, managed by attachAndRender)
this.pomodoroRootEl.style.display = '';
this.pomodoroRootEl.removeClass('sf-hidden');
const state = this.plugin.pluginState;
const service = this.plugin.pomodoroService;
@ -526,45 +530,45 @@ export class PomodoroUIManager {
if (state.pomodoroCurrentMode !== 'idle') {
this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`);
} else {
this.pomodoroTimerDisplayEl.addClass('mode-idle');
this.pomodoroTimerDisplayEl.addClass('mode-idle');
}
if (state.pomodoroIsRunning) {
this.pomodoroTimerDisplayEl.addClass('timer-visible');
} else {
this.pomodoroTimerDisplayEl.removeClass('timer-visible');
this.pomodoroTimerDisplayEl.removeClass('timer-visible');
}
}
// This function now primarily updates timer text, mode classes, and calls helper visibility functions.
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft()); // Always set text for screen readers / state
this.updateTimerTextDisplay(); // Then apply visual visibility for the text
this.pomodoroTimerDisplayEl.className = 'pomodoro-timer-display pomodoro-timer-fade'; // Reset classes
if (state.pomodoroCurrentMode !== 'idle') {
this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`);
} else {
this.pomodoroTimerDisplayEl.addClass('mode-idle');
this.pomodoroTimerDisplayEl.addClass('mode-idle');
}
// 'timer-visible' class might be redundant if opacity is used, but keep for now if it affects other styles.
if (state.pomodoroIsRunning) {
this.pomodoroTimerDisplayEl.addClass('timer-visible');
} else {
this.pomodoroTimerDisplayEl.removeClass('timer-visible');
this.pomodoroTimerDisplayEl.removeClass('timer-visible');
}
}
this.updateButtonVisibility(); // Update button visibility based on their state
// Update classes on the root element itself for styling paused/idle states
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');
// Hide calculation result if quick settings panel is closed
if (this.pomodoroCalculationResultEl && this.pomodoroQuickSettingsPanelEl?.style.display === 'none') {
this.pomodoroCalculationResultEl.style.display = 'none';
this.pomodoroCalculationResultEl.addClass('sf-hidden');
}
// Update cycle progress display
@ -580,23 +584,23 @@ export class PomodoroUIManager {
if (!this.pomodoroCycleProgressEl) return;
const cycleProgress = this.plugin.pomodoroService.getCycleProgress();
if (cycleProgress) {
const { current, total, workSessionsRemaining, totalWorkSessions, totalTimeMinutes } = cycleProgress;
// Calculate completed sessions
const completedSessions = totalWorkSessions - workSessionsRemaining;
// Format total time
const totalHours = Math.floor(totalTimeMinutes / 60);
const totalMinutes = Math.round(totalTimeMinutes % 60);
const timeString = totalHours > 0 ? `${totalHours}H/${totalMinutes}M` : `${totalMinutes}M`;
this.pomodoroCycleProgressEl.setText(`Cycles ${current}/${total} - Sessions ${completedSessions}/${totalWorkSessions} - ${timeString}`);
this.pomodoroCycleProgressEl.style.display = '';
this.pomodoroCycleProgressEl.removeClass('sf-hidden');
this.pomodoroCycleProgressEl.addClass('cycle-active');
} else {
this.pomodoroCycleProgressEl.style.display = 'none';
this.pomodoroCycleProgressEl.addClass('sf-hidden');
}
}
@ -606,7 +610,7 @@ export class PomodoroUIManager {
private formatTime(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {

View file

@ -1,7 +1,7 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { CalendarEvent, UpcomingEvent } from "../models/calendar-event";
import { DateUtils } from "../utils/dates";
/**
* Upcoming events component for calendar view
@ -11,12 +11,12 @@ export class UpcomingEvents {
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Container element for upcoming events
*/
containerEl: HTMLElement;
/**
* Upcoming events data
*/
@ -36,7 +36,7 @@ export class UpcomingEvents {
/**
* Render upcoming events list
*/
async render(): Promise<void> {
render(): void {
if (!this.plugin.settings.enableCalendarEvents || !this.plugin.settings.showUpcomingEvents) {
this.containerEl.empty();
return;
@ -48,7 +48,7 @@ export class UpcomingEvents {
}
// Load upcoming events data
await this.loadUpcomingEvents();
this.loadUpcomingEvents();
// Clear container
this.containerEl.empty();
@ -64,7 +64,7 @@ export class UpcomingEvents {
/**
* Load upcoming events data
*/
async loadUpcomingEvents(): Promise<void> {
loadUpcomingEvents(): void {
this.upcomingEvents = this.plugin.calendarEventService.getUpcomingEvents(
this.plugin.settings.upcomingEventsDays || 7
);
@ -75,13 +75,13 @@ export class UpcomingEvents {
*/
private renderEmptyState(): void {
const emptyState = this.containerEl.createDiv("upcoming-events-empty");
const emptyIcon = emptyState.createDiv("upcoming-events-empty-icon");
setIcon(emptyIcon, "calendar");
const emptyText = emptyState.createDiv("upcoming-events-empty-text");
emptyText.setText("No upcoming events");
const emptySubtext = emptyState.createDiv("upcoming-events-empty-subtext");
emptySubtext.setText("Events will appear here once you create them");
}
@ -92,10 +92,10 @@ export class UpcomingEvents {
private renderEventsList(): void {
// Create header
const header = this.containerEl.createDiv("upcoming-events-header");
const headerTitle = header.createDiv("upcoming-events-title");
headerTitle.setText("Upcoming Events");
headerTitle.setText("Upcoming events");
const headerSubtitle = header.createDiv("upcoming-events-subtitle");
const daysText = this.plugin.settings.upcomingEventsDays || 7;
headerSubtitle.setText(`Next ${daysText} days`);
@ -120,17 +120,17 @@ export class UpcomingEvents {
this.upcomingEvents.forEach(upcomingEvent => {
let dayKey: string;
if (upcomingEvent.isToday) {
dayKey = "Today";
} else if (upcomingEvent.isTomorrow) {
dayKey = "Tomorrow";
} else {
const eventDate = new Date(upcomingEvent.event.date);
dayKey = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
dayKey = eventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
@ -138,7 +138,10 @@ export class UpcomingEvents {
eventsByDay.set(dayKey, []);
}
eventsByDay.get(dayKey)!.push(upcomingEvent);
const dayList = eventsByDay.get(dayKey);
if (dayList) {
dayList.push(upcomingEvent);
}
});
return eventsByDay;
@ -149,13 +152,13 @@ export class UpcomingEvents {
*/
private renderDaySection(container: HTMLElement, dayKey: string, dayEvents: UpcomingEvent[]): void {
const daySection = container.createDiv("upcoming-events-day-section");
// Day header
const dayHeader = daySection.createDiv("upcoming-events-day-header");
const dayTitle = dayHeader.createDiv("upcoming-events-day-title");
dayTitle.setText(dayKey);
if (dayKey === "Today") {
daySection.addClass("today");
} else if (dayKey === "Tomorrow") {
@ -164,7 +167,7 @@ export class UpcomingEvents {
// Events for this day
const dayEventsContainer = daySection.createDiv("upcoming-events-day-events");
dayEvents.forEach(upcomingEvent => {
this.renderEventItem(dayEventsContainer, upcomingEvent);
});
@ -176,21 +179,21 @@ export class UpcomingEvents {
private renderEventItem(container: HTMLElement, upcomingEvent: UpcomingEvent): void {
const event = upcomingEvent.event;
const eventItem = container.createDiv("upcoming-events-event-item");
// Event color indicator
const colorIndicator = eventItem.createDiv("upcoming-events-event-color");
const eventColor = this.plugin.calendarEventService?.getEventColor(event) || '#95A5A6';
colorIndicator.style.setProperty('--event-color', eventColor);
// Event content
const eventContent = eventItem.createDiv("upcoming-events-event-content");
// Event title and time
const eventHeader = eventContent.createDiv("upcoming-events-event-header");
const eventTitle = eventHeader.createDiv("upcoming-events-event-title");
eventTitle.setText(event.title);
if (event.time) {
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
eventTime.setText(event.time);
@ -198,37 +201,37 @@ export class UpcomingEvents {
const eventTime = eventHeader.createDiv("upcoming-events-event-time");
eventTime.setText("All day");
}
// Event details
if (event.description || event.location) {
const eventDetails = eventContent.createDiv("upcoming-events-event-details");
if (event.location) {
const eventLocation = eventDetails.createDiv("upcoming-events-event-location");
setIcon(eventLocation, "map-pin");
eventLocation.appendText(" " + event.location);
}
if (event.description) {
const eventDescription = eventDetails.createDiv("upcoming-events-event-description");
eventDescription.setText(event.description.substring(0, 100) + (event.description.length > 100 ? "..." : ""));
}
}
// Event category
const eventCategory = eventContent.createDiv("upcoming-events-event-category");
eventCategory.setText(event.category);
// Click handler to show event details
eventItem.addEventListener("click", () => {
this.showEventDetails(event);
});
// Add hover effect
eventItem.addEventListener("mouseenter", () => {
eventItem.addClass("hover");
});
eventItem.addEventListener("mouseleave", () => {
eventItem.removeClass("hover");
});
@ -241,29 +244,29 @@ export class UpcomingEvents {
*/
private showEventDetails(event: CalendarEvent): void {
const eventDate = new Date(event.date);
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
const dateStr = eventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
let details = `📅 ${event.title}\n📆 ${dateStr}`;
if (event.time) {
details += `\n🕐 ${event.time}`;
}
if (event.description) {
details += `\n📝 ${event.description}`;
}
if (event.location) {
details += `\n📍 ${event.location}`;
}
details += `\n🏷 ${event.category}`;
new Notice(details, 8000);
}

View file

@ -13,7 +13,7 @@ export class DateUtils {
newDate.setHours(0, 0, 0, 0);
return newDate.getTime();
}
/**
* Add days to a timestamp
*
@ -24,7 +24,7 @@ export class DateUtils {
static addDays(timestamp: number, days: number): number {
return timestamp + (days * 24 * 60 * 60 * 1000);
}
/**
* Format a timestamp as a readable date string
*
@ -38,11 +38,9 @@ export class DateUtils {
if (format === 'relative') {
// Determine the reference date for relative calculations
const referenceDateForCalc = baseDateParam ? new Date(baseDateParam) : new Date();
// Normalize dates to their start of day for accurate day-based comparison
const normalizedNoteEventDate = this.startOfDay(noteEventDate);
const normalizedReferenceDate = this.startOfDay(referenceDateForCalc);
const normalizedActualCurrentDate = this.startOfDay(new Date()); // Actual current day, for "Due notes"
// "Due notes" are always relative to the *actual* current day, regardless of baseDateParam
@ -83,22 +81,22 @@ export class DateUtils {
} else if (format === 'short') {
return noteEventDate.toLocaleDateString();
} else if (format === 'long') {
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
} else {
// Medium format (default)
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
}
/**
* Get the day difference between two timestamps
*
@ -109,15 +107,15 @@ export class DateUtils {
static dayDifference(timestamp1: number, timestamp2: number): number {
const date1 = new Date(timestamp1);
const date2 = new Date(timestamp2);
// Reset to midnight
date1.setHours(0, 0, 0, 0);
date2.setHours(0, 0, 0, 0);
// Calculate difference in days
const diffTime = Math.abs(date2.getTime() - date1.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
@ -155,11 +153,11 @@ export class DateUtils {
static dayDifferenceUTC(timestamp1: number, timestamp2: number): number {
const date1UTCMidnight = this.startOfUTCDay(new Date(timestamp1));
const date2UTCMidnight = this.startOfUTCDay(new Date(timestamp2));
// Calculate difference in days
const diffTime = Math.abs(date2UTCMidnight - date1UTCMidnight);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
@ -171,7 +169,7 @@ export class DateUtils {
*/
static isSameDay(date1: Date, date2: Date): boolean {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
}
}

View file

@ -15,22 +15,22 @@ export class EstimationUtils {
fiction: 250, // Fiction/prose
simple: 300 // Simple content
};
/**
* Average English word length in characters (including spaces)
*/
private static readonly AVG_WORD_LENGTH = 5.5;
/**
* Minimum review time in seconds
*/
private static readonly MIN_REVIEW_TIME = 30;
/**
* Reference to the plugin (for access to settings)
*/
private static plugin: SpaceforgePlugin;
/**
* Set the plugin reference
*
@ -39,7 +39,7 @@ export class EstimationUtils {
static setPlugin(plugin: SpaceforgePlugin): void {
this.plugin = plugin;
}
/**
* Get the user's reading speed from settings
*
@ -49,17 +49,17 @@ export class EstimationUtils {
static getReadingSpeed(contentType?: keyof typeof EstimationUtils.READING_SPEEDS): number {
// Get base reading speed from settings
const baseSpeed = this.plugin?.settings.readingSpeed || 200;
// Apply content-specific adjustment if specified
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
*
@ -68,35 +68,35 @@ export class EstimationUtils {
* @param contentType Type of content for reading speed adjustment
* @returns Estimated review time in seconds
*/
static async estimateReviewTime(
file: TFile,
fileContent?: string,
static estimateReviewTime(
file: TFile,
fileContent?: string,
contentType: keyof typeof EstimationUtils.READING_SPEEDS = 'notes'
): Promise<number> {
): number {
if (!file) {
return this.MIN_REVIEW_TIME;
}
// Use file size as a rough proxy if content is not available
if (!fileContent) {
const sizeEstimate = Math.ceil(file.stat.size / (this.AVG_WORD_LENGTH * 7)) * 60;
return Math.max(this.MIN_REVIEW_TIME, sizeEstimate);
}
// Count words in content
const wordCount = this.countWords(fileContent);
// Calculate reading time based on words and reading speed
const readingSpeed = this.getReadingSpeed(contentType);
const readingTimeMinutes = wordCount / readingSpeed;
// Add some buffer time for actual review (thinking, interacting)
const reviewTimeSeconds = Math.ceil(readingTimeMinutes * 60);
// Return at least minimum review time
return Math.max(this.MIN_REVIEW_TIME, reviewTimeSeconds);
}
/**
* Calculate aggregate review time for multiple notes
*
@ -107,15 +107,15 @@ export class EstimationUtils {
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
*
@ -131,12 +131,12 @@ export class EstimationUtils {
.replace(/\*\*.*?\*\*/g, '$1') // Bold to plain text
.replace(/\*.*?\*/g, '$1') // Italic to plain text
.replace(/~~.*?~~/g, '$1'); // Strikethrough to plain text
// Count words (sequences of non-whitespace characters)
const words = cleanText.match(/\S+/g) || [];
return words.length;
}
/**
* Format seconds as a readable time string
*
@ -145,13 +145,13 @@ export class EstimationUtils {
*/
static formatTime(seconds: number): string {
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 {
@ -159,7 +159,7 @@ export class EstimationUtils {
}
}
}
/**
* Format a time estimate with color coding based on duration
*
@ -170,7 +170,7 @@ export class EstimationUtils {
static formatTimeWithColor(seconds: number, element: HTMLElement): void {
const formattedTime = this.formatTime(seconds);
element.setText(formattedTime);
// Color code based on duration
if (seconds < 5 * 60) { // Less than 5 minutes
element.addClass("review-time-short");

View file

@ -5,22 +5,22 @@ export class EventEmitter {
/**
* Event listeners by event name
*/
private listeners: Record<string, Function[]> = {};
private listeners: Record<string, ((...args: any[]) => void)[]> = {};
/**
* Register a listener for an event
*
* @param event Event name
* @param callback Function to call when event is emitted
*/
on(event: string, callback: Function): void {
on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
/**
* Emit an event
*
@ -31,26 +31,26 @@ export class EventEmitter {
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: string, callback: Function): void {
off(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners[event]) {
return;
}
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
}
/**
* Remove all listeners for an event
*

View file

@ -8,7 +8,7 @@ export interface NoteLink {
* The text content of the link
*/
text: string;
/**
* Whether this is an embed link (![[...]]) or a regular link ([[...]])
*/
@ -23,32 +23,32 @@ export interface FileNode {
* Path to the file
*/
path: string;
/**
* File content
*/
content?: string;
/**
* Outgoing links (files this file links to)
*/
outgoingLinks: string[];
/**
* Regular (non-embed) outgoing links
*/
regularLinks: string[];
/**
* Embed outgoing links
*/
embedLinks: string[];
/**
* Incoming links (files that link to this file)
*/
incomingLinks: string[];
/**
* Count of incoming links (used for ranking)
*/
@ -63,12 +63,12 @@ export interface ReviewHierarchy {
* Root nodes in the hierarchy (starting points)
*/
rootNodes: string[];
/**
* All nodes in the hierarchy
*/
nodes: Record<string, FileNode>;
/**
* Traversal order for review
*/
@ -85,7 +85,7 @@ export class LinkAnalyzer {
* Second capture group matches the content inside the brackets
*/
private static readonly LINK_REGEX = /(!?)(?:\[\[(.*?)\]\])/g;
/**
* Analyze links in a folder and build a review hierarchy
*
@ -95,8 +95,8 @@ export class LinkAnalyzer {
* @returns Review hierarchy for the folder
*/
static async analyzeFolder(
vault: Vault,
folder: TFolder,
vault: Vault,
folder: TFolder,
includeSubfolders: boolean
): Promise<ReviewHierarchy> {
// Get all markdown files in the folder
@ -108,7 +108,7 @@ export class LinkAnalyzer {
return parentPath === folder.path;
}
});
// Initialize nodes
const nodes: Record<string, FileNode> = {};
for (const file of files) {
@ -121,17 +121,17 @@ export class LinkAnalyzer {
incomingLinkCount: 0
};
}
// Read file contents and extract links, preserving order
for (const file of files) {
try {
const content = await vault.read(file);
const node = nodes[file.path];
node.content = content;
// Extract links in the order they appear
const noteLinks = this.extractLinks(content);
// Process each link in order
for (const noteLink of noteLinks) {
// Try to resolve the link to a full path
@ -140,7 +140,7 @@ export class LinkAnalyzer {
// Add to appropriate outgoing links arrays
if (!node.outgoingLinks.includes(resolvedPath)) {
node.outgoingLinks.push(resolvedPath);
// Also add to the appropriate type-specific array
if (noteLink.isEmbed) {
if (!node.embedLinks.includes(resolvedPath)) {
@ -152,7 +152,7 @@ export class LinkAnalyzer {
}
}
}
// Update incoming links for the target
const targetNode = nodes[resolvedPath];
if (!targetNode.incomingLinks.includes(file.path)) {
@ -161,22 +161,22 @@ export class LinkAnalyzer {
}
}
}
} catch (error) { /* handle error */ }
} catch (_error) { /* handle error */ }
}
// Find the node with the most outgoing links as the starting point
const startingNode = this.findStartingNode(nodes);
// Create traversal order that respects the order of links
const traversalOrder = this.createTraversalOrder(nodes, startingNode);
return {
rootNodes: startingNode,
nodes,
traversalOrder
};
}
/**
* Extract links from markdown content in the order they appear
*
@ -186,10 +186,10 @@ export class LinkAnalyzer {
static extractLinks(content: string): NoteLink[] {
const links: NoteLink[] = [];
let match;
// Reset regex lastIndex to ensure we start from the beginning
this.LINK_REGEX.lastIndex = 0;
// Find all [[...]] or ![[...]] links in order
while ((match = this.LINK_REGEX.exec(content)) !== null) {
links.push({
@ -197,10 +197,10 @@ export class LinkAnalyzer {
isEmbed: match[1] === '!' // True if it has an exclamation mark
});
}
return links;
}
/**
* Resolve a link to a full file path
*
@ -218,29 +218,29 @@ export class LinkAnalyzer {
return exactFile.path;
}
}
// Try to find by basename (without extension)
const basename = link.split('/').pop();
if (!basename) return null;
// Look for files with matching basename
const matchingFiles = allFiles.filter(f => f.basename === basename);
if (matchingFiles.length === 0) {
return null;
}
if (matchingFiles.length === 1) {
return matchingFiles[0].path;
}
// If multiple matches, try to find the one in the same folder
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
*
@ -252,10 +252,10 @@ export class LinkAnalyzer {
if (Object.keys(nodes).length === 0) {
return [];
}
// Organize nodes into a folder structure for better matching
const folderNodes: Record<string, string[]> = {};
for (const path in nodes) {
const folderPath = path.substring(0, path.lastIndexOf('/'));
if (!folderNodes[folderPath]) {
@ -263,12 +263,12 @@ export class LinkAnalyzer {
}
folderNodes[folderPath].push(path);
}
// For each folder, look for the best starting node
for (const folderPath in folderNodes) {
const folderName = folderPath.split('/').pop()?.toLowerCase() || '';
const filesInFolder = folderNodes[folderPath];
// HIGHEST PRIORITY: Exact match with folder name
for (const path of filesInFolder) {
const fileName = path.split('/').pop()?.toLowerCase().replace(/\.md$/, '') || '';
@ -293,7 +293,7 @@ export class LinkAnalyzer {
}
}
}
// FOURTH PRIORITY: Look for files with the most outgoing links
// Prefer regular links over embeds
const sortedNodes = Object.values(nodes)
@ -303,21 +303,21 @@ export class LinkAnalyzer {
if (regularLinkDiff !== 0) {
return regularLinkDiff;
}
// If they have the same number of regular links, check total outgoing links
return b.outgoingLinks.length - a.outgoingLinks.length;
});
if (sortedNodes.length > 0 &&
if (sortedNodes.length > 0 &&
(sortedNodes[0].regularLinks.length > 0 || sortedNodes[0].outgoingLinks.length > 0)) {
// Return the node with the most links
return [sortedNodes[0].path];
}
// FALLBACK: Use all files as root nodes if none of the above criteria match
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
@ -328,7 +328,7 @@ export class LinkAnalyzer {
private static findRootNodes(nodes: Record<string, FileNode>): string[] {
return this.findStartingNode(nodes);
}
/**
* Create a traversal order for reviewing files that respects the exact order of links
*
@ -342,7 +342,7 @@ export class LinkAnalyzer {
): string[] {
const visited = new Set<string>();
const traversalOrder: string[] = [];
// Track which folder each file belongs to
const fileFolders = new Map<string, string>();
for (const path in nodes) {
@ -350,11 +350,11 @@ export class LinkAnalyzer {
const folderPath = path.substring(0, path.lastIndexOf('/'));
fileFolders.set(path, folderPath);
}
const mainFolder = fileFolders.get(startNodePath) || "";
// Helper function for depth-first traversal, respecting link order and folder constraints
const traverse = (currentNodePath: string, currentDepth: number = 0, currentMainFolder: string) => {
const traverse = (currentNodePath: string, currentDepth = 0, currentMainFolder: string) => {
if (visited.has(currentNodePath)) {
return;
}
@ -363,15 +363,13 @@ export class LinkAnalyzer {
if (!node) {
return;
}
visited.add(currentNodePath);
traversalOrder.push(currentNodePath);
const currentNodeFolder = fileFolders.get(currentNodePath) || "";
// Prefer regular links over embeds, then all outgoing links if no regular ones
const linksToFollow = node.regularLinks.length > 0 ? node.regularLinks : node.outgoingLinks;
for (const linkedPath of linksToFollow) {
if (!nodes[linkedPath]) { // Ensure the linked path is a known node in the current analysis context
continue;
@ -384,28 +382,30 @@ export class LinkAnalyzer {
if (!visited.has(linkedPath)) { // Avoid re-traversing already processed branches
traverse(linkedPath, currentDepth + 1, currentMainFolder);
} else {
// no-op
}
} else {
// This link goes outside the main folder being analyzed or is not a known node.
}
}
};
// Start traversal from the designated startNodePath, if it exists
if (nodes[startNodePath]) {
traverse(startNodePath, 0, mainFolder);
} else {
// no-op
}
// The traversal initiated by traverse(startNodePath, 0) should now correctly capture
// only the linked hierarchy within the same folder. We no longer add all other
// unlinked files from the mainFolder.
// Log the traversal order summary
return traversalOrder;
}
/**
* Create a traversal order for reviewing files
*
@ -421,32 +421,32 @@ export class LinkAnalyzer {
if (rootNodes.length === 1) {
return this.createOrderedTraversal(nodes, rootNodes[0]);
}
// Otherwise use the original implementation for multiple root nodes
const visited = new Set<string>();
const traversalOrder: string[] = [];
// Helper function for depth-first traversal
const traverse = (nodePath: string) => {
if (visited.has(nodePath)) return;
visited.add(nodePath);
traversalOrder.push(nodePath);
const node = nodes[nodePath];
if (!node) return;
// Traverse outgoing links depth-first
for (const linkedPath of node.outgoingLinks) {
traverse(linkedPath);
}
};
// Start traversal from each root node
for (const rootPath of rootNodes) {
traverse(rootPath);
}
// Add any remaining unvisited nodes
for (const nodePath of Object.keys(nodes)) {
if (!visited.has(nodePath)) {
@ -454,10 +454,10 @@ export class LinkAnalyzer {
visited.add(nodePath);
}
}
return traversalOrder;
}
/**
* Analyze links in a single note
*
@ -467,29 +467,29 @@ export class LinkAnalyzer {
* @returns Array of resolved link paths in the order they appear
*/
static async analyzeNoteLinks(
vault: Vault,
filePath: string,
regularOnly: boolean = false
vault: Vault,
filePath: string,
regularOnly = false
): Promise<string[]> {
const file = vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
return [];
}
try {
const content = await vault.read(file);
const noteLinks = this.extractLinks(content);
const resolvedLinks: string[] = [];
// Maintain a record of links that appear multiple times
// We'll only include the first occurrence in our results
const seenLinks = new Set<string>();
// Filter links if regularOnly is true
const filteredLinks = regularOnly
? noteLinks.filter(link => !link.isEmbed)
const filteredLinks = regularOnly
? noteLinks.filter(link => !link.isEmbed)
: noteLinks;
// Process links in the exact order they appear in the document
for (const link of filteredLinks) {
const resolvedPath = this.resolveLink(
@ -497,15 +497,15 @@ export class LinkAnalyzer {
filePath,
vault.getMarkdownFiles()
);
if (resolvedPath && !seenLinks.has(resolvedPath)) {
resolvedLinks.push(resolvedPath);
seenLinks.add(resolvedPath);
}
}
return resolvedLinks;
} catch (error) { /* handle error */ return []; }
} catch (_error) { /* handle error */ return []; }
}
}