mirror of
https://github.com/dralkh/spaceforge.git
synced 2026-07-22 06:45:03 +00:00
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:
parent
cb9a941597
commit
1726f366c4
46 changed files with 5697 additions and 2605 deletions
24
.eslintrc.js
Normal file
24
.eslintrc.js
Normal 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
|
||||
}
|
||||
};
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
69
eslint.config.mjs
Normal 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
213
main.ts
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
3
note.md
3
note.md
|
|
@ -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
3838
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
52
ui/confirmation-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
104
ui/mcq-modal.ts
104
ui/mcq-modal.ts
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 {
|
||||
// }
|
||||
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 []; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue