spaceforge

This commit is contained in:
Dralk 2025-07-31 07:03:20 +03:00
commit 54e2b782ba
67 changed files with 34321 additions and 0 deletions

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 dralkh
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

144
README.md Normal file
View file

@ -0,0 +1,144 @@
# Spaceforge: Advanced Spaced Repetition & AI-Powered Study Plugin for Obsidian
**Spaceforge: Your ultimate Obsidian plugin for efficient knowledge management, memory retention, and exam preparation. Featuring advanced spaced repetition (FSRS & SM-2), AI-powered MCQ generation, and an integrated Pomodoro study timer.**
Spaceforge transforms your Obsidian notes into a powerful learning and revision tool. It enhances your learning workflow by providing intelligent flashcards, automated multiple-choice question generation, and focused study sessions, all designed for optimal memory retention and active recall.
## Preview
https://github.com/user-attachments/assets/10bb251e-360c-41ae-a128-e90f3f3e2576
## Key Features
* **Advanced Spaced Repetition & Smart Flashcards:**
* Master active recall with cutting-edge spaced repetition algorithms: **FSRS (Free Spaced Repetition Scheduler)**, a modern, scientifically optimized algorithm for maximum memory retention, or an **enhanced SM-2 algorithm** for efficient review scheduling.
* Customize your learning journey with tailored scheduling parameters, turning your notes into intelligent flashcards.
* **AI-Powered MCQ Generation & Quiz Creation:**
* Effortlessly create Multiple Choice Questions (MCQs) directly from your Obsidian notes, transforming them into interactive quizzes.
* Leverage powerful AI models from various providers: **OpenAI, OpenRouter, Ollama, Gemini, Claude, and Together** for intelligent question generation.
* Seamlessly configure API keys and AI models within the plugin settings for personalized quiz creation.
* **Integrated Pomodoro Timer:**
* Boost productivity with a built-in Pomodoro timer.
* Customize work, short break, and long break durations.
* Track sessions and manage your focus directly within Obsidian.
* **Comprehensive Review Management:**
* A dedicated sidebar view displays upcoming reviews, estimated completion times, and allows for easy navigation.
* Calendar view for visualizing your review schedule.
* **Flexible Note Addition:**
* Add individual notes or entire folders to your review schedule via the right-click context menu.
* Use Obsidian commands to add the current note or its folder.
* **Centralized Data, No Frontmatter Clutter:**
* All review schedules and plugin data are stored centrally, keeping your note frontmatter clean.
* Option to specify a custom data path for your Spaceforge data.
* **Customizable Settings:**
* Extensive settings tab to configure scheduling algorithms, API providers, Pomodoro timer, appearance, and behavior.
## Getting Started: Install & Configure Your Obsidian Study Plugin
1. **Installation:** Easily install Spaceforge directly from the Obsidian Community Plugins browser. Alternatively, for manual installation or development:
```bash
# Clone the Spaceforge repository
git clone https://github.com/dralkh/spaceforge.git
cd spaceforge
# Install dependencies, build the plugin, and deploy to your Obsidian vault
npm install && npm run build && node install.js --d /home/user/Documents/vault/.obsidian/plugins
```
2. **Configuration:** Optimize Spaceforge for your learning style:
* Access the Spaceforge settings within Obsidian.
* Select your preferred Spaced Repetition Algorithm (FSRS is highly recommended for its efficiency).
* For AI-powered MCQ Generation, choose your desired AI provider (e.g., OpenAI, Gemini) and input the necessary API key and model details.
* Personalize Pomodoro timer durations to fit your study habits.
> **Disclaimer:** When updating Spaceforge, please ensure you preserve your `data.json` file, which contains all your review schedules and MCQ questions. striving to maintain backward compatibility, it may not always work flawlessly.
### How to Use
#### Adding Notes to Review
* **File Explorer:** Right-click on a note or a folder in the file explorer and select "Add to review schedule."
* **Automatic linking heirachy:** Notes will be based upon main folder name note which all links would go through serially, if none is found it will find note with most links to be main focal point of the specific folder. Outgoing link to be included but not its concurrent folder's notes.
* **Command Palette:**
* Use "Spaceforge: Add Current Note to Review Schedule."
* Use "Spaceforge: Add Current Note's Folder to Review Schedule."
#### Reviewing Notes
1. Open the Spaceforge sidebar (usually on the right, or activate via the ribbon icon/command "Spaceforge: Open Review Sidebar").
2. The sidebar lists notes due for review.
3. Click on a note to open it or click the "Review" button.
4. After reviewing a standard note, rate your recall (e.g., Again, Hard, Good, Easy). The note will be rescheduled accordingly.
5. For MCQs, answer the questions presented.
#### Using the Pomodoro Timer
1. Access Pomodoro controls from the Spaceforge sidebar.
2. Start, pause, reset, or skip Pomodoro sessions (Work, Short Break, Long Break).
3. The timer and current session type are displayed in the sidebar.
## Core Concepts
### Scheduling Algorithms: Optimize Your Learning
* **FSRS (Free Spaced Repetition Scheduler):** A cutting-edge, modern algorithm rooted in memory science that dynamically optimizes your review intervals for maximum retention and learning efficiency. Customize FSRS parameters in settings to fine-tune your spaced repetition system.
* **SM-2 (SuperMemo 2):** A proven, classic spaced repetition algorithm. Spaceforge utilizes an enhanced version of SM-2 to intelligently manage overdue items and facilitate comprehensive note reviews, ensuring effective knowledge recall.
### AI-Powered MCQ Generation: Create Quizzes from Your Notes
Spaceforge seamlessly integrates with various AI services to automatically generate Multiple Choice Questions from your note content. This powerful feature transforms your study material into interactive quizzes, providing an alternative and effective way to test your understanding and reinforce learning. Ensure your chosen AI provider is configured correctly in the settings to unlock this intelligent quiz creation capability.
## Settings Overview
The Spaceforge settings tab allows you to customize:
* **General Settings:** Default review views, notification preferences.
* **Scheduling Algorithm:** Select and configure FSRS or SM-2 parameters.
* **MCQ Generation:** Enable/disable MCQs, select API provider, enter API keys and models.
* **Pomodoro Timer:** Enable/disable, set durations for work/break sessions, sound notifications.
* **Data Management:** Set a custom path for plugin data, import/export data.
## Commands & Shortcuts
Spaceforge adds several commands to the Obsidian command palette:
* `Spaceforge: Next Review Note`
* `Spaceforge: Previous Review Note`
* `Spaceforge: Review Current Note`
* `Spaceforge: Add Current Note to Review Schedule`
* `Spaceforge: Add Current Note's Folder to Review Schedule`
* `Spaceforge: Open Review Sidebar`
You can assign custom keyboard shortcuts to these commands via Obsidian's hotkey settings.
## Support & Contribution
If you encounter any issues, have feature requests, or would like to contribute, please let me know.
## License
This plugin is licensed under the MIT License.
## Future Enhancements (Ideas)
**I. Enhanced Question Generation & Active Recall Tools:**
* **Diverse Question Types:** Beyond MCQs, support for generating Cloze Deletions (fill-in-the-blanks), True/False questions, and short-answer prompts to enhance active recall.
* **Question Quality Feedback:** Enable users to rate generated questions, potentially influencing future AI generation or flagging sets for review and improvement.
* **Context-Aware Generation:** Optionally use content from linked notes to create questions that test understanding of connections and interdisciplinary knowledge.
**II. Advanced Spaced Repetition & Learning Analytics:**
* **"Leech" Management:** Detect and provide special handling for notes that are consistently difficult for the user (e.g., options to suspend, reset, or refactor for improved learning).
* **Advanced Statistics & Visualizations:** Gain deeper insights into your learning progress with review heatmaps, success rate charts over time, forecasted workload visualizations, and ease/difficulty distribution charts.
* **FSRS Parameter Optimization Helper:** An in-plugin tool to help users fine-tune FSRS weights based on their personal review history, maximizing memory retention.
* **Flexible Manual Rescheduling:** More options to manually set a specific next review date for a note, offering greater control over your study schedule.
* **"Deck" / Topic-Based Reviews:** Allow focusing review sessions on specific topics (e.g., via tags or folders) or interleaving notes from different topics for comprehensive study.
* **Focus Mode:** A less cluttered UI during active review sessions to minimize distractions and enhance concentration.
**III. User Experience & Study Workflow Improvements:**
* **Enhanced List Filtering & Sorting:** More options to filter (by text, tags) and sort (by title, ease, interval) notes in the review list with variable reading time, improving study organization.
* **Improved Batch Operations:** Perform actions (postpone, remove, generate MCQs) on multiple selected notes in the sidebar, boosting productivity.
* **Customizable Sidebar Sections:** Allow users to show/hide or reorder sections within the Spaceforge sidebar for a personalized study environment.
* **More Informative Note Items:** Display more details on note items (e.g., icons for MCQ availability, leech status, FSRS/SM-2 type; hover for more stats) for quick insights.
* **Calendar View Enhancements:** More interactive calendar with direct review options, visual density indicators, and hover details for better schedule management.
* **Pomodoro UI Refinements:** Visual timer, session history, and more sound customization options for an improved focus timer experience.
**IV. Integrations & Data Management:**
* **Anki Sync (Import/Export):** More robust Anki compatibility, creation of .md files based on batch, subdivided links based on amount of content with predefined questions, facilitating seamless data transfer and flashcard management.

240
api/claude-service.ts Normal file
View file

@ -0,0 +1,240 @@
import { Notice } 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
export class ClaudeService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
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.');
return null;
}
if (!settings.claudeModel) {
new Notice('Claude Model is not set. Please add it in the Spaceforge settings.');
return null;
}
try {
new Notice('Generating MCQs using Claude...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Claude: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Claude: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Claude. Please try again.');
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error('Error generating MCQs with Claude:', error);
new Notice('Failed to generate MCQs with Claude. Please check console for details.');
return null;
}
}
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
const questionCount = numQuestionsToGenerate; // Use calculated number
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === 'basic') {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\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') {
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.`;
}
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiKey = settings.claudeApiKey;
const model = settings.claudeModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Claude using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: model,
max_tokens: 2048, // Adjust as needed
system: systemPrompt,
messages: [
{ role: 'user', content: prompt }
]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
console.error('Claude API error:', response.status, errorData);
throw new Error(`API request failed (${response.status}): ${errorData.error?.message || errorData.message || 'Unknown error'}`);
}
const data = await response.json();
if (!data.content || !data.content.length || !data.content[0].text) {
console.error('Invalid API response format from Claude:', data);
throw new Error('Invalid API response format from Claude - missing content');
}
return data.content[0].text;
} catch (error) {
console.error('Error in Claude API request:', error);
new Notice(`Claude API error: ${error.message}`);
throw error;
}
}
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
console.log('Raw AI response from Claude:', response);
// 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 (!/^\d+\.\s+/.test(questionBlocks[0])) {
// 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
// For now, let's try to process it as is, or re-split differently.
// A common pattern is just a list of questions.
questionBlocks = response.split(/\n(?=\d+\.\s)/); // Split on newline only if followed by "number."
if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) {
// If still one block and no number, try splitting by double newline as a fallback for less structured lists
const potentialBlocks = response.split(/\n\n+/);
if (potentialBlocks.some(pb => /^\d+\.\s/.test(pb.trimStart()))) {
questionBlocks = potentialBlocks;
} else {
// If no numbered list, assume each paragraph could be a question block
// This is a very loose fallback.
}
}
}
}
}
// 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 = '';
const tempBlocks = [];
for (const line of lines) {
if (/^\d+\.\s+/.test(line.trim())) {
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
currentQuestionBlock = line + '\n';
} else if (currentQuestionBlock.length > 0) { // only add to current block if it has started
currentQuestionBlock += line + '\n';
} 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';
}
}
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
if (tempBlocks.length > 0) questionBlocks = tempBlocks;
}
console.log(`Found ${questionBlocks.length} question blocks from Claude`);
for (const block of questionBlocks) {
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) continue; // Need at least a question and one choice
// Remove the leading "X. " from the question text if present
let questionText = lines[0].replace(/^\d+\.\s*/, '').trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
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();
if (cleanedLine.length > 0) { // Only add non-empty choices
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
}
// Fallback for [CORRECT] if not found (e.g. if model forgets)
if (correctAnswerIndex === -1 && choices.length > 0) {
// A simple heuristic: if a choice text itself contains "correct" (less likely for Claude)
for (let i = 0; i < choices.length; i++) {
if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) {
choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim();
correctAnswerIndex = i;
break;
}
}
// If 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
questions.push({ question: questionText, choices, correctAnswerIndex });
} else if (questionText && choices.length >= 2) { // Absolute minimum of 2 choices
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Claude`);
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
console.error('Error parsing MCQ response from Claude:', error);
new Notice('Error parsing MCQ response from Claude. Please try again.');
return [];
}
}
}

194
api/gemini-service.ts Normal file
View file

@ -0,0 +1,194 @@
import { Notice } 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
// 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;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
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.');
return null;
}
if (!settings.geminiModel) {
new Notice('Gemini Model is not set. Please add it in the Spaceforge settings.');
return null;
}
try {
new Notice('Generating MCQs using Gemini...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Gemini: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Gemini: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Gemini. Please try again.');
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error('Error generating MCQs with Gemini:', error);
new Notice('Failed to generate MCQs with Gemini. Please check console for details.');
return null;
}
}
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
const questionCount = numQuestionsToGenerate; // Use calculated number
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
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
: settings.mcqAdvancedSystemPrompt;
if (promptType === 'basic') {
basePrompt = `${systemInstruction}\n\nGenerate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `${systemInstruction}\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}`;
}
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiKey = settings.geminiApiKey;
const model = settings.geminiModel; // e.g., 'gemini-pro'
console.log(`Making API request to Gemini using model: ${model}`);
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// Gemini API expects contents as an array of parts
contents: [{ parts: [{ text: prompt }] }],
// Optional: Add generationConfig if needed (e.g., temperature, maxOutputTokens)
// generationConfig: {
// temperature: 0.7,
// maxOutputTokens: 1024,
// }
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
console.error('Gemini API error:', response.status, errorData);
const errorMessage = errorData?.error?.message || errorData?.message || 'Unknown error';
throw new Error(`API request failed (${response.status}): ${errorMessage}`);
}
const data = await response.json();
// Gemini response structure: data.candidates[0].content.parts[0].text
if (!data.candidates || !data.candidates.length || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts.length || !data.candidates[0].content.parts[0].text) {
console.error('Invalid API response format from Gemini:', data);
throw new Error('Invalid API response format from Gemini - missing content');
}
return data.candidates[0].content.parts[0].text;
} catch (error) {
console.error('Error in Gemini API request:', error);
new Notice(`Gemini API error: ${error.message}`);
throw error;
}
}
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
console.log('Raw AI response from Gemini:', response);
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
let currentQuestion = '';
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion) questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, '') + '\n';
} else if (currentQuestion) {
currentQuestion += line + '\n';
}
}
if (currentQuestion) questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from Gemini`);
for (const block of questionBlocks) {
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) continue;
let questionText = lines[0].trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes('[CORRECT]');
const cleanedLine = line.replace(/\[CORRECT\]/g, '').replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, '').trim();
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i+1] && (lines[i+1].toLowerCase().includes('correct') || lines[i+1].includes('✓') || lines[i+1].includes('✔️'))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0) correctAnswerIndex = 0;
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Gemini`);
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
console.error('Error parsing MCQ response from Gemini:', error);
new Notice('Error parsing MCQ response from Gemini. Please try again.');
return [];
}
}
}

View file

@ -0,0 +1,8 @@
import { SpaceforgeSettings } from '../models/settings';
import { MCQSet } from '../models/mcq';
import SpaceforgePlugin from '../main';
export interface IMCQGenerationService {
plugin: SpaceforgePlugin; // To allow implementations to access plugin features like settings, notices
generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null>;
}

189
api/ollama-service.ts Normal file
View file

@ -0,0 +1,189 @@
import { Notice } 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
export class OllamaService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
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.');
return null;
}
if (!settings.ollamaModel) {
new Notice('Ollama Model is not set. Please add it in the Spaceforge settings.');
return null;
}
try {
new Notice('Generating MCQs using Ollama...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`Ollama: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`Ollama: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Ollama. Please try again.');
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error('Error generating MCQs with Ollama:', error);
new Notice('Failed to generate MCQs with Ollama. Please check console for details.');
return null;
}
}
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
const questionCount = numQuestionsToGenerate; // Use calculated number
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === 'basic') {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\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') {
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.`;
}
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiUrl = settings.ollamaApiUrl.endsWith('/') ? settings.ollamaApiUrl.slice(0, -1) : settings.ollamaApiUrl;
const model = settings.ollamaModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Ollama at ${apiUrl} using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await fetch(`${apiUrl}/api/chat`, { // Common Ollama chat endpoint
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
],
stream: false // Ensure we get the full response, not a stream
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('Ollama API error:', response.status, errorText);
throw new Error(`API request failed (${response.status}): ${errorText}`);
}
const data = await response.json();
// Ollama's non-streaming chat response structure is typically { model, created_at, message: { role, content }, done }
if (!data.message || !data.message.content) {
console.error('Invalid API response format from Ollama:', data);
throw new Error('Invalid API response format from Ollama - missing message content');
}
return data.message.content;
} catch (error) {
console.error('Error in Ollama API request:', error);
new Notice(`Ollama API error: ${error.message}`);
throw error;
}
}
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
console.log('Raw AI response from Ollama:', response);
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
let currentQuestion = '';
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion) questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, '') + '\n';
} else if (currentQuestion) {
currentQuestion += line + '\n';
}
}
if (currentQuestion) questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from Ollama`);
for (const block of questionBlocks) {
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) continue;
let questionText = lines[0].trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes('[CORRECT]');
const cleanedLine = line.replace(/\[CORRECT\]/g, '').replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, '').trim();
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) {
for (let i = 0; i < choices.length; i++) {
if (lines[i+1] && (lines[i+1].toLowerCase().includes('correct') || lines[i+1].includes('✓') || lines[i+1].includes('✔️'))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0) correctAnswerIndex = 0;
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Ollama`);
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
console.error('Error parsing MCQ response from Ollama:', error);
new Notice('Error parsing MCQ response from Ollama. Please try again.');
return [];
}
}
}

188
api/openai-service.ts Normal file
View file

@ -0,0 +1,188 @@
import { Notice } 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
export class OpenAIService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
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.');
return null;
}
if (!settings.openaiModel) {
new Notice('OpenAI Model is not set. Please add it in the Spaceforge settings.');
return null;
}
try {
new Notice('Generating MCQs using OpenAI...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`OpenAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`OpenAI: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from OpenAI. Please try again.');
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error('Error generating MCQs with OpenAI:', error);
new Notice('Failed to generate MCQs with OpenAI. Please check console for details.');
return null;
}
}
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
const questionCount = numQuestionsToGenerate; // Use calculated number
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === 'basic') {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\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') {
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.`;
}
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiKey = settings.openaiApiKey;
const model = settings.openaiModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to OpenAI using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
console.error('OpenAI API error:', response.status, errorData);
throw new Error(`API request failed (${response.status}): ${errorData.error?.message || errorData.message || 'Unknown error'}`);
}
const data = await response.json();
if (!data.choices || !data.choices.length || !data.choices[0].message || !data.choices[0].message.content) {
console.error('Invalid API response format from OpenAI:', data);
throw new Error('Invalid API response format from OpenAI - missing content');
}
return data.choices[0].message.content;
} catch (error) {
console.error('Error in OpenAI API request:', error);
new Notice(`OpenAI API error: ${error.message}`);
throw error;
}
}
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
console.log('Raw AI response from OpenAI:', response);
let questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length === 0) {
const lines = response.split('\n');
let currentQuestion = '';
for (const line of lines) {
if (/^\d+\./.test(line.trim())) {
if (currentQuestion) questionBlocks.push(currentQuestion);
currentQuestion = line.replace(/^\d+\.\s*/, '') + '\n';
} else if (currentQuestion) {
currentQuestion += line + '\n';
}
}
if (currentQuestion) questionBlocks.push(currentQuestion);
}
console.log(`Found ${questionBlocks.length} question blocks from OpenAI`);
for (const block of questionBlocks) {
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) continue;
let questionText = lines[0].trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes('[CORRECT]');
const cleanedLine = line.replace(/\[CORRECT\]/g, '').replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, '').trim();
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
if (correctAnswerIndex === -1) { // 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('✔️'))) {
correctAnswerIndex = i;
break;
}
}
}
if (correctAnswerIndex === -1 && choices.length > 0) correctAnswerIndex = 0; // Default to first if still not found
if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from OpenAI`);
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
console.error('Error parsing MCQ response from OpenAI:', error);
new Notice('Error parsing MCQ response from OpenAI. Please try again.');
return [];
}
}
}

323
api/openrouter-service.ts Normal file
View file

@ -0,0 +1,323 @@
import { Notice } from 'obsidian'; // Removed TFile as it wasn't used
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet } from '../models/mcq';
import { IMCQGenerationService } from './mcq-generation-service';
import { SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; // Import MCQQuestionAmountMode
/**
* Service for generating MCQs using the OpenRouter API
*/
export class OpenRouterService implements IMCQGenerationService {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Initialize OpenRouter service
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Generate MCQs for a note
*
* @param notePath Path to the note
* @param noteContent Content of the note
* @param settings Current plugin settings
* @returns Generated MCQ set or null if failed
*/
async generateMCQs(notePath: 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.');
return null;
}
try {
// Show loading notice
new Notice('Generating MCQs using OpenRouter...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
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));
// Optional: Add a reasonable upper limit if desired, e.g., Math.min(numQuestionsToGenerate, 15);
console.log(`OpenRouter: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`OpenRouter: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
// 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.');
return null;
}
// Create MCQ set
const mcqSet: MCQSet = {
notePath,
questions,
generatedAt: Date.now()
};
return mcqSet;
} catch (error) {
console.error('Error generating MCQs with OpenRouter:', error);
new Notice('Failed to generate MCQs with OpenRouter. Please check console for details.');
return null;
}
}
/**
* Generate prompt for the AI
*
* @param noteContent Content of the note
* @param settings Current plugin settings
* @param numQuestionsToGenerate The target number of questions to ask for
* @returns Prompt for the AI
*/
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
// Use the passed numQuestionsToGenerate instead of settings.mcqQuestionsPerNote directly
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 {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.
For example:
1. What is the capital of France?
A) London
B) Berlin
C) Paris [CORRECT]
D) Madrid
E) Rome`;
}
// Add difficulty-specific instructions
if (difficulty === '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
*
* @param prompt Prompt for the AI
* @param settings Current plugin settings
* @returns AI response text
*/
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiKey = settings.openRouterApiKey; // Use key from settings argument
const model = settings.openRouterModel; // Use model from settings argument
const difficulty = settings.mcqDifficulty;
try {
console.log(`Making API request to OpenRouter using model: ${model} with difficulty: ${difficulty}`);
// Get the appropriate system prompt based on difficulty
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'https://obsidian.md', // Required by OpenRouter
'X-Title': 'Spaceforge Plugin for Obsidian' // Identifying the app
},
body: JSON.stringify({
model: model,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: prompt
}
]
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('OpenRouter API error:', errorText);
throw new Error(`API request failed (${response.status}): ${errorText}`);
}
const data = await response.json();
if (!data.choices || !data.choices.length || !data.choices[0].message) {
console.error('Invalid API response format from OpenRouter:', data);
throw new Error('Invalid API response format from OpenRouter - missing choices');
}
return data.choices[0].message.content;
} catch (error) {
console.error('Error in OpenRouter API request:', error);
new Notice(`OpenRouter API error: ${error.message}`);
throw error;
}
}
/**
* Parse the AI response to extract MCQs
*
* @param response AI response text
* @param settings Current plugin settings
* @param numQuestionsToGenerate The target number of questions expected
* @returns Array of parsed MCQ questions
*/
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
// Log the raw response for debugging
console.log('Raw AI response from OpenRouter:', response);
// 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) {
questionBlocks.push(currentQuestion);
}
currentQuestion = line.replace(/^\d+\.\s*/, '') + '\n';
} else if (currentQuestion) {
currentQuestion += line + '\n';
}
}
if (currentQuestion) {
questionBlocks.push(currentQuestion);
}
}
console.log(`Found ${questionBlocks.length} question blocks from OpenRouter`);
for (const block of questionBlocks) {
// Extract the question text and choices
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) {
console.log('Skipping block with insufficient lines (OpenRouter):', block);
continue;
}
let questionText = lines[0].trim();
// 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
console.log('Processing question (OpenRouter):', questionText);
console.log('Found choices (OpenRouter):', lines.slice(1));
// 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;
console.log(`Found correct answer at index ${correctAnswerIndex} (OpenRouter): ${cleanedLine}`);
}
}
// 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('✔️')
)) {
correctAnswerIndex = i;
console.log(`Found correct answer with alternative marker at index ${i} (OpenRouter): ${choices[i]}`);
break;
}
}
}
// If still no correct answer found, default to the first answer as a fallback
if (correctAnswerIndex === -1 && choices.length > 0) {
correctAnswerIndex = 0;
console.log(`No correct answer marker found, defaulting to first choice (OpenRouter): ${choices[0]}`);
}
// Only add if we found a valid question with choices
if (questionText && choices.length >= 2) {
questions.push({
question: questionText,
choices,
correctAnswerIndex
});
} else {
console.log('Skipping invalid question (OpenRouter):', { questionText, choicesLength: choices.length });
}
}
// Log the parsed questions
console.log(`Successfully parsed ${questions.length} MCQ questions from OpenRouter`);
// Limit to the target number of questions calculated earlier
// Use numQuestionsToGenerate instead of settings.mcqQuestionsPerNote
return questions.slice(0, numQuestionsToGenerate);
} catch (error) {
console.error('Error parsing MCQ response from OpenRouter:', error);
new Notice('Error parsing MCQ response from OpenRouter. Please try again.');
return [];
}
}
}

218
api/together-service.ts Normal file
View file

@ -0,0 +1,218 @@
import { Notice } 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
export class TogetherService implements IMCQGenerationService {
plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
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.');
return null;
}
if (!settings.togetherModel) {
new Notice('Together AI Model is not set. Please add it in the Spaceforge settings.');
return null;
}
try {
new Notice('Generating MCQs using Together AI...');
// Determine the number of questions to generate
let numQuestionsToGenerate: number;
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
console.log(`TogetherAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`);
} else { // Fixed mode
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
console.log(`TogetherAI: Using fixed number of questions: ${numQuestionsToGenerate}`);
}
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
const response = await this.makeApiRequest(prompt, settings);
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
if (questions.length === 0) {
new Notice('Failed to generate valid MCQs from Together AI. Please try again.');
return null;
}
return {
notePath,
questions,
generatedAt: Date.now()
};
} catch (error) {
console.error('Error generating MCQs with Together AI:', error);
new Notice('Failed to generate MCQs with Together AI. Please check console for details.');
return null;
}
}
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
const questionCount = numQuestionsToGenerate; // Use calculated number
const choiceCount = settings.mcqChoicesPerQuestion;
const promptType = settings.mcqPromptType;
const difficulty = settings.mcqDifficulty;
let basePrompt = "";
if (promptType === 'basic') {
basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`;
} else {
basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\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') {
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.`;
}
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
}
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
const apiKey = settings.togetherApiKey;
const model = settings.togetherModel;
const difficulty = settings.mcqDifficulty;
const systemPrompt = difficulty === 'basic'
? settings.mcqBasicSystemPrompt
: settings.mcqAdvancedSystemPrompt;
console.log(`Making API request to Together AI using model: ${model} with difficulty: ${difficulty}`);
try {
const response = await fetch('https://api.together.xyz/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
max_tokens: 2048, // Adjust as needed
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
console.error('Together AI API error:', response.status, errorData);
throw new Error(`API request failed (${response.status}): ${errorData.error?.message || errorData.message || 'Unknown error'}`);
}
const data = await response.json();
if (!data.choices || !data.choices.length || !data.choices[0].message || !data.choices[0].message.content) {
console.error('Invalid API response format from Together AI:', data);
throw new Error('Invalid API response format from Together AI - missing content');
}
return data.choices[0].message.content;
} catch (error) {
console.error('Error in Together AI API request:', error);
new Notice(`Together AI API error: ${error.message}`);
throw error;
}
}
private parseResponse(response: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
const questions: MCQQuestion[] = [];
try {
console.log('Raw AI response from Together AI:', response);
let questionBlocks: string[] = response.split(/\n\d+\.\s+/).filter(block => block.trim().length > 0);
if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) {
if (!/^\d+\.\s+/.test(questionBlocks[0])) {
if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) {
questionBlocks = response.split(/\n(?=\d+\.\s)/);
if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) {
const potentialBlocks = response.split(/\n\n+/);
if (potentialBlocks.some(pb => /^\d+\.\s/.test(pb.trimStart()))) {
questionBlocks = potentialBlocks;
}
}
}
}
}
if (questionBlocks.length === 0 || (questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1."))) {
const lines = response.split('\n');
let currentQuestionBlock = '';
const tempBlocks = [];
for (const line of lines) {
if (/^\d+\.\s+/.test(line.trim())) {
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
currentQuestionBlock = line + '\n';
} else if (currentQuestionBlock.length > 0) {
currentQuestionBlock += line + '\n';
} else if (tempBlocks.length === 0 && line.trim().length > 0) {
currentQuestionBlock = line + '\n';
}
}
if (currentQuestionBlock.trim().length > 0) {
tempBlocks.push(currentQuestionBlock.trim());
}
if (tempBlocks.length > 0) questionBlocks = tempBlocks;
}
console.log(`Found ${questionBlocks.length} question blocks from Together AI`);
for (const block of questionBlocks) {
const lines = block.split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) continue;
let questionText = lines[0].replace(/^\d+\.\s*/, '').trim();
// Remove <think> and </think> tags from the question text
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
const choices: string[] = [];
let correctAnswerIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
const isCorrect = line.includes('[CORRECT]');
const cleanedLine = line.replace(/\[CORRECT\]/gi, '')
.replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, '')
.trim();
if (cleanedLine.length > 0) {
choices.push(cleanedLine);
if (isCorrect) correctAnswerIndex = choices.length - 1;
}
}
if (correctAnswerIndex === -1 && choices.length > 0) {
for (let i = 0; i < choices.length; i++) {
if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) {
choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim();
correctAnswerIndex = i;
break;
}
}
if (correctAnswerIndex === -1) correctAnswerIndex = 0;
}
if (questionText && choices.length >= settings.mcqChoicesPerQuestion -1 && choices.length > 0) {
questions.push({ question: questionText, choices, correctAnswerIndex });
} else if (questionText && choices.length >= 2) {
questions.push({ question: questionText, choices, correctAnswerIndex });
}
}
console.log(`Successfully parsed ${questions.length} MCQ questions from Together AI`);
return questions.slice(0, numQuestionsToGenerate); // Use calculated number
} catch (error) {
console.error('Error parsing MCQ response from Together AI:', error);
new Notice('Error parsing MCQ response from Together AI. Please try again.');
return [];
}
}
}

24
controllers/README.md Normal file
View file

@ -0,0 +1,24 @@
# Controllers Directory
This directory contains controller files that handle the business logic of the Spaceforge spaced repetition system.
## Files
- `interfaces.ts` - Contains interfaces for all controllers
- `review-controller.ts` - Main controller that coordinates between specialized controllers
- `review-controller-core.ts` - Handles core review functionality
- `review-navigation-controller.ts` - Manages navigation between notes
- `review-session-controller.ts` - Handles session management and link analysis
- `review-batch-controller.ts` - Manages batch review operations
- `review-controller-mcq.ts` - Manages the Multiple Choice Question (MCQ) functionality
## Architecture
The controllers follow a modular architecture:
1. The `ReviewController` acts as a facade that delegates to specialized controllers
2. Each specialized controller has a specific responsibility
3. Controllers communicate through the main plugin instance
4. UI components in the `ui/` directory interact with controllers through well-defined interfaces
This modular approach makes the codebase more maintainable and easier to extend.

189
controllers/interfaces.ts Normal file
View file

@ -0,0 +1,189 @@
import { ReviewResponse, ReviewSchedule } from '../models/review-schedule';
import { MCQSet } from '../models/mcq';
/**
* Interface for the core review controller
*/
export interface IReviewController {
/**
* Update the list of today's due notes
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
updateTodayNotes(preserveCurrentIndex?: boolean): Promise<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
*
* @param path Path to the note file
* @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
*
* @param path Path to the note file
*/
skipReview(path: string): Promise<void>;
/**
* Postpone a note's review
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
postponeNote(path: string, days?: 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
*/
getTodayNotes(): ReviewSchedule[];
/**
* Get the current index in today's notes
*/
getCurrentNoteIndex(): number;
/**
* Set the current index in today's notes
* @param index The new index
*/
setCurrentNoteIndex(index: number): void;
/**
* 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>;
/**
* Gets the current review date override.
* @returns Timestamp of the override, or null if no override is set.
*/
getCurrentReviewDateOverride(): number | null;
}
/**
* Interface for the navigation controller
*/
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
*
* @param path1 Path to the first note
* @param path2 Path to the second note
*/
swapNotes(path1: string, path2: string): Promise<void>;
}
/**
* Interface for the batch review controller
*/
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>;
/**
* Review all notes with MCQs in a batch
*
* @param useMCQ Whether to use MCQs for testing
*/
reviewAllNotesWithMCQ(useMCQ?: boolean): Promise<void>;
/**
* Regenerate MCQs for all notes due today
*/
regenerateAllMCQs(): Promise<void>;
/**
* Postpone a specific set of notes
*
* @param paths Array of note paths to postpone
* @param days Number of days to postpone
*/
postponeNotes(paths: string[], days?: number): Promise<void>;
/**
* Remove a specific set of notes from the review schedule
*
* @param paths Array of note paths to remove
*/
removeNotes(paths: string[]): Promise<void>;
}
/**
* Interface for the session controller
*/
export interface IReviewSessionController {
/**
* Get linked notes that are due today
*
* @param notePath Path of the note to get links from
* @returns Array of paths to linked notes that are due today
*/
getDueLinkedNotes(notePath: string): string[];
}

View file

@ -0,0 +1,269 @@
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
*/
export class ReviewBatchController implements IReviewBatchController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Initialize batch controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Start reviewing all of today's notes
*/
async reviewAllTodaysNotes(): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
// Update notes while preserving existing order
await reviewController.updateTodayNotes(true);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
console.log("Review All Notes - Today's Notes Order:", todayNotes.map(n => n.path));
// Always start with first note in current order
// 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);
const note = todayNotes[0];
console.log(`Review All: Starting with note at index 0: ${note.path}`);
// Start the review with the selected note
await reviewController.reviewNote(note.path);
}
new Notice(`Starting review of all ${todayNotes.length} notes due today`);
}
/**
* Review a specific set of notes
*
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths: string[], useMCQ: boolean = false): Promise<void> {
if (paths.length === 0) {
new Notice("No notes selected for review.");
return;
}
// Get the ReviewSchedule objects for the given paths
const notesToReview = this.plugin.dataStorage.getDueNotesWithCustomOrder().filter(note => paths.includes(note.path));
if (notesToReview.length === 0) {
new Notice("Selected notes are not currently due for review.");
return;
}
if (useMCQ) {
new Notice(`Preparing MCQs for ${notesToReview.length} selected notes. This may take a moment...`);
} else {
new Notice(`Starting review of ${notesToReview.length} selected notes.`);
}
// Create a new batch review modal with the selected notes
const modal = new BatchReviewModal(this.plugin.app, this.plugin, notesToReview, useMCQ);
modal.open();
}
/**
* Review all notes with MCQs in a batch
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ: boolean = true): Promise<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);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
if (useMCQ) {
new Notice("Preparing all MCQs. This may take a moment...");
}
// Use notes directly from todayNotes - they're already in the correct order
// Custom order is already applied by updateTodayNotes if available
const orderedNotes = [...todayNotes];
console.log("Review All with MCQ: Using sidebar order for consistent navigation");
console.log("Ordered notes:", orderedNotes.map(n => n.path));
// Create a new batch review modal with the ordered notes
const modal = new BatchReviewModal(this.plugin.app, this.plugin, orderedNotes, useMCQ);
modal.open();
}
/**
* Regenerate MCQs for all notes due today
*/
async regenerateAllMCQs(): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
// Update notes respecting custom order if it exists
const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0;
await reviewController.updateTodayNotes(hasCustomOrder);
const todayNotes = reviewController.getTodayNotes();
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
if (!this.plugin.mcqController) {
new Notice("MCQ controller not initialized. Please check MCQ settings.");
return;
}
new Notice(`Regenerating MCQs for ${todayNotes.length} notes...`);
let generatedCount = 0;
for (const note of todayNotes) {
const success = await this.plugin.mcqController.generateMCQs(note.path);
if (success) {
generatedCount++;
}
}
new Notice(`Generated MCQs for ${generatedCount} out of ${todayNotes.length} notes`);
// Start batch review with the newly generated MCQs
this.reviewAllNotesWithMCQ(true);
}
/**
* Postpone a specific set of notes
*
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths: string[], days: number = 1): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
if (paths.length === 0) {
new Notice("No notes selected to postpone.");
return;
}
new Notice(`Postponing ${paths.length} notes by ${days} day(s)...`);
for (const path of paths) {
await this.plugin.dataStorage.postponeNote(path, days);
// No need to call handleNotePostponed here for each note,
// as a single refresh of the sidebar after the loop is sufficient.
}
await this.plugin.savePluginData(); // Add save call after loop
// Refresh the sidebar view if available
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Update today's notes after postponing
await reviewController.updateTodayNotes(true);
new Notice(`Postponed ${paths.length} notes by ${days} day(s).`);
}
/**
* Advance a specific set of notes by one day each, if eligible.
*
* @param paths Array of note paths to advance
*/
async advanceNotes(paths: string[]): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
if (paths.length === 0) {
new Notice("No notes selected to advance.");
return;
}
let advancedCount = 0;
for (const path of paths) {
// Directly call the core controller's advanceNote, which handles eligibility and notices
// The service's advanceNote returns a boolean indicating success
const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
if (advanced) {
advancedCount++;
}
}
if (advancedCount > 0) {
await this.plugin.savePluginData(); // Save once after all operations
// Refresh the sidebar view if available
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Update today's notes after advancing
await reviewController.updateTodayNotes(true);
new Notice(`Advanced ${advancedCount} note(s).`);
} else {
new Notice("No eligible notes were advanced.");
}
}
/**
* Remove a specific set of notes from the review schedule
*
* @param paths Array of note paths to remove
*/
async removeNotes(paths: string[]): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
if (paths.length === 0) {
new Notice("No notes selected to remove.");
return;
}
new Notice(`Removing ${paths.length} notes from review schedule...`);
for (const path of paths) {
await this.plugin.dataStorage.removeFromReview(path);
// No need to call handleNotePostponed here for each note,
// as a single refresh of the sidebar after the loop is sufficient.
}
await this.plugin.savePluginData(); // Add save call after loop
// Refresh the sidebar view if available
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Update today's notes after removing
await reviewController.updateTodayNotes(true);
new Notice(`Removed ${paths.length} notes from review schedule.`);
}
}

View file

@ -0,0 +1,561 @@
import { Modal, 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
*/
export class ReviewControllerCore implements IReviewController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Currently loaded notes due for review
*/
private todayNotes: ReviewSchedule[] = [];
/**
* Current index in today's notes
*/
private currentNoteIndex: number = 0;
/**
* Cache of linked notes to improve performance
*/
private linkedNoteCache: Map<string, string[]> = new Map();
/**
* Traversal order of notes for hierarchical navigation
*/
private traversalOrder: string[] = [];
/**
* Map of paths to their position in the traversal order
* Used for fast lookups during navigation
*/
private traversalPositions: Map<string, number> = new Map();
/**
* Optional override for the current date, for testing or reviewing past/future notes.
* If null, Date.now() is used.
*/
private currentReviewDateOverride: number | null = null;
/**
* Initialize review controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
// Initialize today's notes
this.updateTodayNotes();
}
/**
* Sets an override for the current review date.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
public async setReviewDateOverride(date: number | null): Promise<void> {
this.currentReviewDateOverride = date;
console.log(`Review date override set to: ${date ? new Date(date).toISOString() : 'null (use current time)'}`);
await this.updateTodayNotes(); // Ensure notes are updated when the override changes
}
/**
* Gets the effective review date (override or actual Date.now()).
* @returns Timestamp for the effective review date.
*/
private getEffectiveReviewDate(): number {
return this.currentReviewDateOverride ?? Date.now();
}
/**
* Gets the current review date override.
* @returns Timestamp of the override, or null if no override is set.
*/
public getCurrentReviewDateOverride(): number | null {
return this.currentReviewDateOverride;
}
/**
* Get the currently loaded notes due for review
*/
getTodayNotes(): ReviewSchedule[] {
return this.todayNotes;
}
/**
* Get the current index in today's notes
*/
getCurrentNoteIndex(): number {
return this.currentNoteIndex;
}
/**
* Set the current index in today's notes
*
* @param index The new index
*/
setCurrentNoteIndex(index: number): void {
// Ensure index is within bounds
if (index >= 0 && index < this.todayNotes.length) {
this.currentNoteIndex = index;
console.log(`Set currentNoteIndex to: ${index}`);
} else {
console.warn(`Attempted to set invalid currentNoteIndex: ${index}. Max index is ${this.todayNotes.length - 1}`);
// Optionally clamp the index or handle the error differently
this.currentNoteIndex = Math.max(0, Math.min(index, this.todayNotes.length - 1));
}
}
/**
* Update the list of today's due notes
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex: boolean = false): Promise<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) {
currentNotePath = this.todayNotes[this.currentNoteIndex].path;
}
// Clear all cached data
this.linkedNoteCache.clear();
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();
// If currentReviewDateOverride is set, we are viewing a specific date from the calendar, so match exactly.
// Otherwise (override is null), we are in the default "today" view, so get all notes due up to today.
const matchExactDate = this.currentReviewDateOverride !== null;
const newDueNotes = this.plugin.dataStorage.reviewScheduleService.getDueNotesWithCustomOrder(effectiveDate, true, matchExactDate);
this.todayNotes = newDueNotes;
// The traversal order should directly reflect the order of todayNotes,
// which respects the custom order followed by any remaining due notes.
this.traversalOrder = this.todayNotes.map(note => note.path);
// Rebuild positions map based on the final traversalOrder
this.traversalPositions = new Map();
this.traversalOrder.forEach((path, index) => {
this.traversalPositions.set(path, index);
});
// Reset the note index unless we want to preserve it
if (!preserveCurrentIndex) {
this.currentNoteIndex = 0;
}
// Clear the link cache to ensure fresh data
this.linkedNoteCache.clear();
// No notes to process? Return early
if (this.todayNotes.length === 0) {
return;
}
// If we want to preserve the current index, try to find the same note
if (preserveCurrentIndex && currentNotePath) {
const newIndex = this.todayNotes.findIndex(note => note.path === currentNotePath);
if (newIndex !== -1) {
this.currentNoteIndex = newIndex;
} else {
// If the note is no longer in the list, reset to the beginning
this.currentNoteIndex = 0;
}
// If the previous note is no longer in the list, reset to the beginning or nearest valid index
this.currentNoteIndex = Math.min(Math.max(0, newIndex), this.todayNotes.length - 1);
}
console.log(`Updated today's notes list with ${this.todayNotes.length} notes.`);
}
/**
* Review the current note
*/
async reviewCurrentNote(): Promise<void> {
if (this.todayNotes.length === 0) {
await this.updateTodayNotes();
if (this.todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
}
const note = this.todayNotes[this.currentNoteIndex];
await this.reviewNote(note.path);
}
/**
* Start a review for a note
*
* @param path Path to the note file
*/
async reviewNote(path: string): Promise<void> {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
new Notice("Cannot review: file not found");
return;
}
// Open the file
await this.plugin.app.workspace.getLeaf().openFile(file);
// Show review modal when file is opened
this.showReviewModal(path);
}
/**
* Postpone a note's review
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path: string, days: number = 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();
// Explicitly update navigation state in controller to ensure UI consistency
await this.handleNotePostponed(path);
// Refresh the sidebar view if available
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Notice is handled by the service for postponeNote
}
/**
* Advance a note's review by one day, if eligible.
*
* @param path Path to the note file
*/
async advanceNote(path: string): Promise<void> {
const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path);
if (advanced) {
await this.plugin.savePluginData();
await this.handleNoteAdvanced(path); // New handler for advancing
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
new Notice(`Note advanced.`); // Controller handles notice for advance
} else {
new Notice(`Note is not eligible to be advanced.`);
}
}
/**
* Handle a note being advanced, updating navigation state.
* This primarily involves re-evaluating the todayNotes list.
*
* @param path Path to the advanced note
*/
async handleNoteAdvanced(path: string): Promise<void> {
console.log(`Handling advanced note: ${path}`);
// 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);
console.log(`After advance - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`);
console.log(`Current note after advance update: ${this.todayNotes[this.currentNoteIndex]?.path}, index: ${this.currentNoteIndex}`);
}
/**
* Handle a note being postponed, updating navigation state
*
* @param path Path to the postponed note
*/
async handleNotePostponed(path: string): Promise<void> {
console.log(`Handling postponed note: ${path}`);
if (this.todayNotes.length === 0) return;
const postponedIndex = this.todayNotes.findIndex(note => note.path === path);
if (postponedIndex === -1) {
console.log(`Note ${path} not found in today's notes`);
return;
}
const wasCurrentNote = postponedIndex === this.currentNoteIndex;
const currentNotePath = this.currentNoteIndex < this.todayNotes.length
? this.todayNotes[this.currentNoteIndex].path
: null;
console.log(`Current note before update: ${currentNotePath}, index: ${this.currentNoteIndex}`);
// Remove from traversal order first
this.traversalOrder = this.traversalOrder.filter(p => p !== path);
this.traversalPositions.delete(path);
// Update positions for remaining notes
this.traversalOrder.forEach((p, i) => this.traversalPositions.set(p, i));
// Remove from todayNotes
this.todayNotes = this.todayNotes.filter(n => n.path !== path);
// Update current index
if (wasCurrentNote) {
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
} else if (currentNotePath) {
const newIndex = this.todayNotes.findIndex(n => n.path === currentNotePath);
this.currentNoteIndex = newIndex !== -1 ? newIndex : Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
}
// Update custom order in storage
await this.plugin.dataStorage.reviewScheduleService.updateCustomNoteOrder(this.traversalOrder);
// Removed redundant savePluginData() here; postponeNote already saves.
console.log(`After postpone - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`);
// If the postponed note was the current one, select a new note
if (wasCurrentNote) {
console.log(`Postponed note was the current note, selecting new current note`);
// Keep the current index, but make sure it's valid
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
} else if (currentNotePath) {
// If we were viewing a different note, try to keep that selected
const newIndex = this.todayNotes.findIndex(note => note.path === currentNotePath);
if (newIndex !== -1) {
console.log(`Keeping previously selected note: ${currentNotePath}`);
this.currentNoteIndex = newIndex;
} else {
// If the current note is no longer in the list, adjust the index
console.log(`Previously selected note no longer in list, adjusting index`);
this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1);
}
} else {
// Adjust current index if necessary to prevent out-of-bounds
console.log(`Adjusting index to remain in bounds`);
if (this.currentNoteIndex >= this.todayNotes.length) {
this.currentNoteIndex = Math.max(0, this.todayNotes.length - 1);
}
}
console.log(`Current note after update: ${this.todayNotes[this.currentNoteIndex]?.path}, index: ${this.currentNoteIndex}`);
}
/**
* Show the review modal for a note
*
* @param path Path to the note file
*/
showReviewModal(path: string): void {
const modal = new ReviewModal(this.plugin.app, this.plugin, path);
modal.open();
}
/**
* Skip the review of a note and reschedule for tomorrow with penalty
*
* @param path Path to the note file
*/
async skipReview(path: string): Promise<void> {
const effectiveDate = this.getEffectiveReviewDate();
await 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
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Update today's notes after skipping the review
await this.updateTodayNotes(true);
// Continue to the next note and show review modal
if (this.todayNotes.length > 0) {
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) {
new Notice("All caught up! No more notes due for review.");
return;
}
if (this.plugin.navigationController) {
// 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;
await this.plugin.navigationController.openNoteWithoutReview(nextNotePath);
this.showReviewModal(nextNotePath);
} else {
new Notice("All caught up! No more notes due for review.");
}
} else {
new Notice("All caught up! No more notes due for review.");
}
} else {
new Notice("All caught up! No more notes due for review.");
}
}
/**
* Process a review response
*
* @param path Path to the note file
* @param response User's response during review (SM-2 or FSRS)
*/
async processReviewResponse(path: 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);
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];
let triggerRegeneration = false;
if (schedule && this.plugin.settings.enableQuestionRegenerationOnRating && this.plugin.mcqService && typeof response === 'number') {
if (schedule.schedulingAlgorithm === 'fsrs') {
// FSRS: response is FsrsRating (1-4), setting is minFsrsRatingForQuestionRegeneration (1-4)
if (response >= this.plugin.settings.minFsrsRatingForQuestionRegeneration) {
triggerRegeneration = true;
console.log(`FSRS Rating ${response} met/exceeded threshold (${this.plugin.settings.minFsrsRatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`);
}
} else { // SM-2
// SM-2: response is ReviewResponse (0-5), setting is minSm2RatingForQuestionRegeneration (0-5)
if (response >= this.plugin.settings.minSm2RatingForQuestionRegeneration) {
triggerRegeneration = true;
console.log(`SM-2 Rating ${response} met/exceeded threshold (${this.plugin.settings.minSm2RatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`);
}
}
}
if (triggerRegeneration) {
this.plugin.mcqService.flagMCQSetForRegeneration(path);
// The savePluginData call at the end of processReviewResponse will persist this.
}
// 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
case FsrsRating.Again: responseText = "Again (1)"; break;
case FsrsRating.Hard: responseText = "Hard (2)"; break;
case FsrsRating.Good: responseText = "Good (3)"; break;
case FsrsRating.Easy: responseText = "Easy (4)"; break;
default: responseText = "Unknown FSRS Rating";
}
} else {
// SM-2 response text
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;
case ReviewResponse.CorrectWithDifficulty: responseText = "Correct with Difficulty (3)"; break;
case ReviewResponse.CorrectWithHesitation: responseText = "Correct with Hesitation (4)"; break;
case ReviewResponse.PerfectRecall: responseText = "Perfect Recall (5)"; break;
default: responseText = "Unknown SM-2 Rating";
}
}
new Notice(`Note review recorded: ${responseText}`);
// Refresh the sidebar view if available
if (this.plugin.sidebarView) {
this.plugin.sidebarView.refresh();
}
// Update today's notes after recording the review (preserve the index since we're in the middle of navigation)
await this.updateTodayNotes(true);
// Check if we're in a hierarchical review session
const activeSession = this.plugin.dataStorage.getActiveSession();
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();
if (nextFilePath) {
// Continue with the session
await this.reviewNote(nextFilePath);
} else {
// Session complete
new Notice("Hierarchical review session complete!");
}
}
// 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
console.log("Before navigation - Current Index:", this.currentNoteIndex);
console.log("Before navigation - Current Note:", this.todayNotes[this.currentNoteIndex]?.path);
console.log("Before navigation - Traversal Order:", this.traversalOrder);
// 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;
console.log(`Moving to next note in sidebar order at index ${this.currentNoteIndex}`);
// Check if we've reviewed all notes (in case we've come full circle)
const currentPath = this.todayNotes[this.currentNoteIndex].path;
if (currentPath === path) {
console.log("All notes have been reviewed");
new Notice("All caught up! No more notes due for review.");
return;
}
// Log the new state after determining next note
console.log("After navigation - Current Index:", this.currentNoteIndex);
console.log("After navigation - Current Note:", this.todayNotes[this.currentNoteIndex]?.path);
// 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;
console.log("Opening review modal for:", nextNotePath);
if (this.plugin.navigationController) {
await this.plugin.navigationController.openNoteWithoutReview(nextNotePath);
this.showReviewModal(nextNotePath);
} else {
console.log("No navigation controller available");
new Notice("All caught up! No more notes due for review.");
}
} else {
console.log("No more notes to review - reached the end");
new Notice("All caught up! No more notes due for review.");
}
} else {
new Notice("All caught up! No more notes due for review.");
}
// Save all accumulated plugin data once at the end
await this.plugin.savePluginData();
}
}

View file

@ -0,0 +1,318 @@
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 { 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';
/**
* Controller for managing Multiple-Choice Question (MCQ) reviews
*/
export class MCQController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Reference to the MCQ data service
*/
private mcqService: MCQService;
/**
* Reference to the MCQ generation service (e.g., OpenRouter, OpenAI)
*/
private mcqGenerationService: IMCQGenerationService;
/**
* Initialize MCQ controller
*
* @param plugin Reference to the main plugin
* @param mcqService Reference to the MCQ data service
* @param mcqGenerationService Reference to the MCQ generation service
*/
constructor(plugin: SpaceforgePlugin, mcqService: MCQService, mcqGenerationService: IMCQGenerationService) {
this.plugin = plugin;
this.mcqService = mcqService;
this.mcqGenerationService = mcqGenerationService;
}
/**
* Start an MCQ review session for a note
*
* @param notePath Path to the note
* @param onCompleteCallback Optional callback when the modal closes
*/
// Helper methods to map score to ratings
private mapScoreToSm2Response(score: number): ReviewResponse {
if (score >= 0.95) return ReviewResponse.PerfectRecall; // 5
if (score >= 0.80) return ReviewResponse.CorrectWithHesitation; // 4
if (score >= 0.60) return ReviewResponse.CorrectWithDifficulty; // 3
if (score >= 0.40) return ReviewResponse.IncorrectButFamiliar; // 2
if (score > 0) return ReviewResponse.IncorrectResponse; // 1
return ReviewResponse.CompleteBlackout; // 0
}
private mapScoreToFsrsRating(score: number): FsrsRating {
if (score >= 0.90) return FsrsRating.Easy; // 4
if (score >= 0.70) return FsrsRating.Good; // 3
if (score >= 0.50) return FsrsRating.Hard; // 2
return FsrsRating.Again; // 1
}
async startMCQReview(
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.');
if (externalOnCompleteCallback) externalOnCompleteCallback(notePath, false);
return;
}
if (!this.mcqGenerationService) {
new Notice('MCQ generation service is not available. Check API provider settings.');
return;
}
try {
let mcqSet = this.mcqService.getMCQSetForNote(notePath);
if (mcqSet && mcqSet.needsQuestionRegeneration) {
new Notice('Questions for this note are flagged for regeneration. Generating new set...');
mcqSet = await this.generateMCQs(notePath, true);
if (mcqSet) {
mcqSet.needsQuestionRegeneration = false;
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
} else {
new Notice('Failed to regenerate MCQs. Using existing set if available.');
mcqSet = this.mcqService.getMCQSetForNote(notePath);
}
}
if (!mcqSet || mcqSet.questions.length === 0) {
new Notice('No MCQs found for this note. Generating new set...');
mcqSet = await this.generateMCQs(notePath);
if (!mcqSet) {
new Notice('Failed to generate MCQs for this note.');
return;
}
}
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}).`);
} 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 {
new Notice(`MCQ complete. Score: ${(score * 100).toFixed(0)}%. Could not find schedule to update review status.`);
}
} 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);
}
};
// Pass the new internal callback to the modal constructor
new MCQModal(this.plugin, notePath, mcqSet, internalOnComplete).open();
} catch (error) {
console.error('Error starting MCQ review:', error);
new Notice('Error starting MCQ review. Please check console for details.');
if (externalOnCompleteCallback) externalOnCompleteCallback(notePath, false);
}
}
/**
* Generate MCQs for a note
*
* @param notePath Path to the note
* @param forceRegeneration If true, will ignore existing fresh sets and generate new ones.
* @returns Generated MCQ set or null if failed
*/
async generateMCQs(notePath: 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.');
return null;
}
if (!forceRegeneration) {
const existingSet = this.mcqService.getMCQSetForNote(notePath);
if (existingSet) {
const twentyFourHours = 24 * 60 * 60 * 1000;
if ((Date.now() - existingSet.generatedAt) < twentyFourHours && !existingSet.needsQuestionRegeneration) {
new Notice('Using recently generated MCQs for this note.');
return existingSet;
}
}
}
const file = this.plugin.app.vault.getAbstractFileByPath(notePath);
if (!(file instanceof TFile)) {
new Notice('Cannot generate MCQs: file not found');
return null;
}
const content = await this.plugin.app.vault.read(file);
const mcqSet = await this.mcqGenerationService.generateMCQs(notePath, content, this.plugin.settings);
if (mcqSet) {
this.mcqService.saveMCQSet(mcqSet);
await this.plugin.savePluginData();
new Notice('MCQs generated and saved successfully.');
return mcqSet;
} else {
return null;
}
}
// Methods to access MCQ sessions, delegated to mcqService
getMCQSessionsForNote(notePath: string): MCQSession[] {
return this.mcqService.getMCQSessionsForNote(notePath);
}
getLatestMCQSessionForNote(notePath: string): MCQSession | null {
return this.mcqService.getLatestMCQSessionForNote(notePath);
}
async saveMCQSession(session: MCQSession): Promise<void> {
this.mcqService.saveMCQSession(session);
await this.plugin.savePluginData();
}
/**
* Starts a consolidated MCQ review session for all notes due on the currently selected review date.
*/
async startConsolidatedMCQReviewForSelectedDate(): Promise<void> {
if (!this.plugin.settings.enableMCQ) {
new Notice('MCQ feature is disabled in settings.');
return;
}
if (!this.mcqGenerationService) {
new Notice('MCQ generation service is not available. Check API provider settings.');
return;
}
const dueNotes: ReviewSchedule[] = this.plugin.reviewController.getTodayNotes();
if (dueNotes.length === 0) {
new Notice('No notes due for review on the selected date.');
return;
}
const mcqSetsForReview: { path: string; mcqSet: MCQSet; fileName: string }[] = [];
let notesWithMCQsCount = 0;
new Notice(`Fetching MCQs for ${dueNotes.length} due note(s)...`);
for (const noteSchedule of dueNotes) {
const notePath = noteSchedule.path;
let mcqSet = this.mcqService.getMCQSetForNote(notePath);
// If MCQ set needs regeneration, attempt to regenerate it.
if (mcqSet && mcqSet.needsQuestionRegeneration) {
new Notice(`Regenerating flagged MCQs for ${notePath}...`);
const regeneratedMcqSet = await this.generateMCQs(notePath, true); // forceRegeneration = true
if (regeneratedMcqSet) {
mcqSet = regeneratedMcqSet; // Use the new set
mcqSet.needsQuestionRegeneration = false; // Clear the flag
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).`);
// 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...`);
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.`);
}
}
if (mcqSet && mcqSet.questions.length > 0) {
const file = this.plugin.app.vault.getAbstractFileByPath(notePath);
mcqSetsForReview.push({
path: notePath,
mcqSet,
fileName: file instanceof TFile ? file.basename : notePath,
});
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 (
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})`);
}
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.
console.warn(`MCQ result for ${result.path} did not have a score. Review not recorded via MCQ.`);
}
}
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.");
}
};
new ConsolidatedMCQModal(this.plugin, mcqSetsForReview, onConsolidatedComplete).open();
}
}

View file

@ -0,0 +1,321 @@
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 { MCQService } from '../services/mcq-service'; // Import MCQService
/**
* Main controller that coordinates review functionality across the plugin
* Acts as a facade that delegates to specialized controllers
*/
export class ReviewController implements IReviewController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Core controller for review functionality
*/
private coreController: ReviewControllerCore;
/**
* Navigation controller for review functionality
*/
private navigationController: ReviewNavigationController; // Add navigation controller
/**
* MCQ controller for review functionality
*/
private mcqController: MCQController; // Add MCQ controller
/**
* Batch controller for review functionality
*/
private batchController: ReviewBatchController; // Add batch controller
/**
* Constructor initializes the review controller
*
* @param plugin Reference to the main plugin
* @param mcqService Reference to the MCQ service
*/
constructor(plugin: SpaceforgePlugin, mcqService: MCQService) { // Accept MCQService
this.plugin = plugin;
this.coreController = new ReviewControllerCore(plugin);
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);
} 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.
console.warn("MCQ Generation Service not available during ReviewController initialization.");
// this.mcqController = undefined; // Or handle appropriately
}
this.batchController = new ReviewBatchController(plugin); // Initialize batch controller
}
/**
* Update the list of today's due notes
* Delegates to core controller
*
* @param preserveCurrentIndex Whether to try to preserve the current note index
*/
async updateTodayNotes(preserveCurrentIndex: boolean = false): Promise<void> {
await this.coreController.updateTodayNotes(preserveCurrentIndex);
}
/**
* Get the currently loaded notes due for review
* Delegates to core controller
*/
getTodayNotes() {
return this.coreController.getTodayNotes();
}
/**
* Get the current index in today's notes
* Delegates to core controller
*/
getCurrentNoteIndex() {
return this.coreController.getCurrentNoteIndex();
}
/**
* Set the current index in today's notes
* Delegates to core controller
*
* @param index The new index
*/
setCurrentNoteIndex(index: number): void {
this.coreController.setCurrentNoteIndex(index);
}
/**
* Review the current note
* Delegates to core controller
*/
async reviewCurrentNote(): Promise<void> {
await this.coreController.reviewCurrentNote();
}
/**
* Review a specific note
* Delegates to core controller
*
* @param path Path to the note file
*/
async reviewNote(path: string): Promise<void> {
await this.coreController.reviewNote(path);
}
/**
* Postpone a note's review
* Delegates to core controller
*
* @param path Path to the note file
* @param days Number of days to postpone (default: 1)
*/
async postponeNote(path: string, days: number = 1): Promise<void> {
await this.coreController.postponeNote(path, days);
}
/**
* Advance a note's review by one day, if eligible.
* Delegates to core controller.
*
* @param path Path to the note file
*/
async advanceNote(path: string): Promise<void> {
await this.coreController.advanceNote(path);
}
/**
* Handle a note being postponed, updating navigation state
* Delegates to core controller
*
* @param path Path to the postponed note
*/
async handleNotePostponed(path: string): Promise<void> {
await this.coreController.handleNotePostponed(path);
}
/**
* Show the review modal for a note
* Delegates to core controller
*
* @param path Path to the note file
*/
showReviewModal(path: string): void {
this.coreController.showReviewModal(path);
}
/**
* Skip the review of a note and reschedule for tomorrow with penalty
* Delegates to core controller
*
* @param path Path to the note file
*/
async skipReview(path: string): Promise<void> {
await this.coreController.skipReview(path);
}
/**
* Process a review response
* Delegates to core controller
*
* @param path Path to the note file
* @param response User's response during review (SM-2 or FSRS)
*/
async processReviewResponse(path: string, response: ReviewResponse | FsrsRating): Promise<void> {
await this.coreController.processReviewResponse(path, response);
}
/**
* Sets an override for the current review date.
* Delegates to core controller.
* @param date Timestamp of the date to simulate, or null to use actual Date.now().
*/
public async setReviewDateOverride(date: number | null): Promise<void> {
await this.coreController.setReviewDateOverride(date);
}
/**
* Gets the current review date override.
* Delegates to core controller.
* @returns Timestamp of the override, or null if no override is set.
*/
public getCurrentReviewDateOverride(): number | null {
return this.coreController.getCurrentReviewDateOverride();
}
// Delegate methods to specialized controllers
/**
* Start reviewing all of today's notes
* Delegates to batch controller
*/
async reviewAllTodaysNotes(): Promise<void> {
await this.batchController.reviewAllTodaysNotes();
}
/**
* Navigate to the next note
* Delegates to navigation controller
*/
async navigateToNextNote(): Promise<void> {
await this.navigationController.navigateToNextNote();
}
/**
* Navigate to the previous note
* Delegates to navigation controller
*/
async navigateToPreviousNote(): Promise<void> {
await this.navigationController.navigateToPreviousNote();
}
/**
* Start an MCQ review session for a note
* Delegates to MCQ controller
*
* @param notePath Path to the note
* @param onComplete Optional callback for when MCQ review is completed
*/
async startMCQReview(
notePath: string,
onComplete?: (path: string, success: boolean) => void
): Promise<void> {
// Pass only notePath as the callback is handled internally by MCQModal now
if (this.mcqController) {
await this.mcqController.startMCQReview(notePath, onComplete); // Pass the callback here as MCQController expects it
} else {
console.error("MCQ Controller not initialized when calling startMCQReview");
// Optionally call the callback with failure if provided
if (onComplete) {
onComplete(notePath, false);
}
}
}
/**
* Review a specific set of notes
* Delegates to batch controller
*
* @param paths Array of note paths to review
* @param useMCQ Whether to use MCQs for testing (default: false)
*/
async reviewNotes(paths: string[], useMCQ: boolean = false): Promise<void> {
await this.batchController.reviewNotes(paths, useMCQ);
}
/**
* Postpone a specific set of notes
* Delegates to batch controller
*
* @param paths Array of note paths to postpone
* @param days Number of days to postpone (default: 1)
*/
async postponeNotes(paths: string[], days: number = 1): Promise<void> {
await this.batchController.postponeNotes(paths, days);
}
/**
* Advance a specific set of notes by one day each, if eligible.
* Delegates to batch controller.
*
* @param paths Array of note paths to advance
*/
async advanceNotes(paths: string[]): Promise<void> {
await this.batchController.advanceNotes(paths);
}
/**
* Remove a specific set of notes from the review schedule
* Delegates to batch controller
*
* @param paths Array of note paths to remove
*/
async removeNotes(paths: string[]): Promise<void> {
await this.batchController.removeNotes(paths);
}
/**
* Open a note without showing the review modal
* Delegates to navigation controller
*
* @param path Path to the note file
*/
async openNoteWithoutReview(path: string): Promise<void> {
await this.navigationController.openNoteWithoutReview(path);
}
/**
* Swap two notes in the traversal order
* Delegates to navigation controller
*
* @param path1 Path to the first note
* @param path2 Path to the second note
*/
async swapNotes(path1: string, path2: string): Promise<void> {
await this.navigationController.swapNotes(path1, path2);
}
/**
* Review all notes with MCQs in a batch
* Delegates to batch controller
*
* @param useMCQ Whether to use MCQs for testing
*/
async reviewAllNotesWithMCQ(useMCQ: boolean = true): Promise<void> {
await this.batchController.reviewAllNotesWithMCQ(useMCQ);
}
}

View file

@ -0,0 +1,236 @@
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
*/
export class ReviewNavigationController implements IReviewNavigationController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Initialize navigation controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Navigate to the current note without showing review modal
*/
async navigateToCurrentNoteWithoutModal(): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
await reviewController.updateTodayNotes();
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
}
const note = todayNotes[currentNoteIndex];
await this.openNoteWithoutReview(note.path);
}
/**
* Navigate to the next note following the current order
*/
async navigateToNextNote(): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
let currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length === 0) {
// Force update notes regardless of custom order
await reviewController.updateTodayNotes(false);
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
}
if (todayNotes.length === 1) {
// Only one note, nothing to navigate to
await this.navigateToCurrentNoteWithoutModal();
return;
}
// Simple circular navigation through todayNotes which already has the correct order
const nextIndex = (currentNoteIndex + 1) % todayNotes.length;
const nextNote = todayNotes[nextIndex];
const nextPath = nextNote.path;
console.log(`Navigating to next note: ${nextIndex + 1}/${todayNotes.length} (${nextPath})`);
// Check the traversal order structure
// Display appropriate message
let messageType = "next note";
// Get file information for context message
const currentFile = this.plugin.app.vault.getAbstractFileByPath(todayNotes[currentNoteIndex].path);
const nextFile = this.plugin.app.vault.getAbstractFileByPath(nextPath);
if (currentFile instanceof TFile && nextFile instanceof TFile) {
const currentFolder = currentFile.parent ? currentFile.parent.path : null;
const nextFolder = nextFile.parent ? nextFile.parent.path : null;
if (this.plugin.sessionController &&
this.plugin.sessionController.getDueLinkedNotes(todayNotes[currentNoteIndex].path).includes(nextPath)) {
messageType = "linked note";
} else if (currentFolder === nextFolder) {
messageType = "next note in folder";
} else {
messageType = "next note in different folder";
}
}
// Update core controller current index
if (this.plugin.reviewController) {
// Set the index directly in the core controller
this.plugin.reviewController.setCurrentNoteIndex(nextIndex);
}
// Navigate to the note
await this.openNoteWithoutReview(nextPath);
if (this.plugin.settings.showNavigationNotifications) {
new Notice(`Navigated to ${messageType} (${nextIndex + 1}/${todayNotes.length})`);
}
}
/**
* Navigate to the next note without recording a review
* Uses the same traversal logic as navigateToNextNote
*/
async navigateToNextNoteWithoutRating(): Promise<void> {
// First navigate to the next note
await this.navigateToNextNote();
// Then automatically open the review modal for the next note
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
const currentNoteIndex = reviewController.getCurrentNoteIndex();
if (todayNotes.length > 0) {
const nextNote = todayNotes[currentNoteIndex];
reviewController.showReviewModal(nextNote.path);
}
}
/**
* Navigate to the previous note in the current order
*/
async navigateToPreviousNote(): Promise<void> {
const reviewController = this.plugin.reviewController;
if (!reviewController) return;
const todayNotes = reviewController.getTodayNotes();
let 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);
if (todayNotes.length === 0) {
new Notice("No notes due for review today!");
return;
}
}
if (todayNotes.length === 1) {
// Only one note, nothing to navigate to
await this.navigateToCurrentNoteWithoutModal();
return;
}
// Simple circular navigation through todayNotes which already has the correct order
const prevIndex = (currentNoteIndex - 1 + todayNotes.length) % todayNotes.length;
const prevNote = todayNotes[prevIndex];
const prevPath = prevNote.path;
console.log(`Navigating to previous note: ${prevIndex + 1}/${todayNotes.length} (${prevPath})`);
// Update core controller current index
if (this.plugin.reviewController) {
// Set the index directly in the core controller
this.plugin.reviewController.setCurrentNoteIndex(prevIndex);
}
// Navigate to the note
await this.openNoteWithoutReview(prevPath);
if (this.plugin.settings.showNavigationNotifications) {
new Notice(`Navigated to previous note (${prevIndex + 1}/${todayNotes.length})`);
}
}
/**
* Open a note without showing the review modal
*
* @param path Path to the note file
*/
async openNoteWithoutReview(path: string): Promise<void> {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
new Notice("Cannot navigate: file not found");
return;
}
// Open the file without showing review modal
await this.plugin.app.workspace.getLeaf().openFile(file);
}
/**
* Swap two notes in the traversal order
*
* @param path1 Path to the first note
* @param path2 Path to the second note
*/
async swapNotes(path1: string, path2: string): Promise<void> {
const reviewController = this.plugin.reviewController;
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);
const index2 = todayNotes.findIndex((n) => n.path === path2);
// If either note is not found, do nothing
if (index1 < 0 || index2 < 0) return;
// Create a new array with swapped notes
const newTodayNotes = [...todayNotes];
const temp = newTodayNotes[index1];
newTodayNotes[index1] = newTodayNotes[index2];
newTodayNotes[index2] = temp;
// Update the custom note order in persistent storage
const newOrder = newTodayNotes.map(note => note.path);
await this.plugin.reviewScheduleService.updateCustomNoteOrder(newOrder);
await this.plugin.savePluginData(); // Add save call
// Force refresh the core controller state
await reviewController.updateTodayNotes(true);
// Debug log
console.log(`Swapped notes: ${path1} and ${path2}`);
}
}

View file

@ -0,0 +1,96 @@
import { Notice } from 'obsidian';
import SpaceforgePlugin from '../main';
import { IReviewSessionController } from './interfaces';
import { LinkAnalyzer } from '../utils/link-analyzer';
/**
* Controller for managing review sessions
*/
export class ReviewSessionController implements IReviewSessionController {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Cache of linked notes to improve performance
*/
private linkedNoteCache: Map<string, string[]> = new Map();
/**
* Initialize session controller
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Get linked notes that are due today
*
* @param notePath Path of the note to get links from
* @returns Array of paths to linked notes that are due today
*/
getDueLinkedNotes(notePath: string): string[] {
const reviewController = this.plugin.reviewController;
if (!reviewController) return [];
const todayNotes = reviewController.getTodayNotes();
const links = this.linkedNoteCache.get(notePath) || [];
const duePaths = todayNotes.map(n => n.path);
// If we don't have links cached, try to analyze them
if (links.length === 0) {
this.analyzeNoteLinks(notePath).then(newLinks => {
if (newLinks.length > 0) {
this.linkedNoteCache.set(notePath, newLinks);
}
});
// For now, return an empty array since we're still analyzing
return [];
}
// Filter to only include links to notes that are due today
return links.filter(link => duePaths.includes(link));
}
/**
* Analyze links in a note and cache the results
*
* @param notePath Path to the note
* @returns Array of linked note paths
*/
private async analyzeNoteLinks(notePath: string): Promise<string[]> {
try {
// Only use regular wiki links (not embeds) for navigation
const links = await LinkAnalyzer.analyzeNoteLinks(
this.plugin.app.vault,
notePath,
true // regularOnly = true - only include regular wiki links, not embeds
);
// Store in cache for future use
this.linkedNoteCache.set(notePath, links);
return links;
} catch (error) {
console.error(`Error analyzing links for ${notePath}:`, error);
return [];
}
}
/**
* Clear the link cache for a specific note or all notes
*
* @param notePath Optional path to clear cache for specific note
*/
clearLinkCache(notePath?: string): void {
if (notePath) {
this.linkedNoteCache.delete(notePath);
} else {
this.linkedNoteCache.clear();
}
}
}

514
data-storage.ts Normal file
View file

@ -0,0 +1,514 @@
import { Notice, TFile, TFolder } from 'obsidian';
import { ReviewHistoryItem, ReviewResponse, ReviewSchedule, toSM2Quality } from './models/review-schedule';
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 { MCQSet, MCQSession } from './models/mcq';
import { ReviewScheduleService } from './services/review-schedule-service';
import { ReviewHistoryService } from './services/review-history-service';
import { ReviewSessionService } from './services/review-session-service';
import { MCQService } from './services/mcq-service';
/**
* Data storage interface for plugin data
*/
export interface SpaceforgeData {
/**
* Review schedules for all notes
*/
schedules: Record<string, ReviewSchedule>;
/**
* Review history items
*/
history: ReviewHistoryItem[];
/**
* Review sessions
*/
reviewSessions: ReviewSessionStore;
/**
* Multiple-choice question sets
*/
mcqSets: Record<string, MCQSet>;
/**
* Multiple-choice question session history
*/
mcqSessions: Record<string, MCQSession[]>;
/**
* Custom order for notes (user-defined ordering)
*/
customNoteOrder: string[];
/**
* Last used version (for migrations)
*/
version: string;
/**
* Timestamp of the last time link analysis was performed for ordering
*/
lastLinkAnalysisTimestamp?: number | null;
}
/**
* Handles storage and retrieval of spaced repetition data
*/
export class DataStorage {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Service for managing review schedules
*/
reviewScheduleService: ReviewScheduleService;
/**
* Service for managing review history
*/
reviewHistoryService: ReviewHistoryService;
/**
* Service for managing review sessions
*/
reviewSessionService: ReviewSessionService;
/**
* Service for managing MCQ data
*/
mcqService: MCQService;
/**
* Initialize data storage
*
* @param plugin Reference to the main plugin
* @param reviewScheduleService Instance of ReviewScheduleService
* @param reviewHistoryService Instance of ReviewHistoryService
* @param reviewSessionService Instance of ReviewSessionService
* @param mcqService Instance of MCQService
*/
constructor(
plugin: SpaceforgePlugin,
reviewScheduleService: ReviewScheduleService,
reviewHistoryService: ReviewHistoryService,
reviewSessionService: ReviewSessionService,
mcqService: MCQService
) {
this.plugin = plugin;
this.reviewScheduleService = reviewScheduleService;
this.reviewHistoryService = reviewHistoryService;
this.reviewSessionService = reviewSessionService;
this.mcqService = mcqService;
// Constructor no longer calls ensureDataLoaded()
}
// Removed ensureDataLoaded() method
// Removed loadData() method (and all localStorage logic within it)
// Removed saveData() method (and all localStorage logic within it)
/**
* Initialize default data when no data is available
* This is now primarily for internal use during integrity checks,
* as main.ts handles initial default loading.
*/
private initializeDefaultData(): void {
this.reviewScheduleService.schedules = {};
this.reviewHistoryService.history = [];
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
this.mcqService.mcqSets = {};
this.mcqService.mcqSessions = {};
this.reviewScheduleService.customNoteOrder = [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = null;
}
/**
* Verify data integrity and fix any issues
* @returns true if data is valid, false if it needed to be fixed
*/
public verifyDataIntegrity(): boolean {
console.log("Verifying data integrity...");
let isValid = true;
// Check schedules (using service data)
if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== 'object') {
console.warn("Invalid schedules data structure");
this.reviewScheduleService.schedules = {};
isValid = false;
} else {
// Check each schedule for required properties
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;
}
// Use a type assertion to check properties safely
const s = schedule as Record<string, any>;
if (!('path' in s) || typeof s.path !== 'string' ||
!('lastReviewDate' in s) || (s.lastReviewDate !== null && typeof s.lastReviewDate !== 'number') ||
!('nextReviewDate' in s) || typeof s.nextReviewDate !== 'number' ||
!('ease' in s) || typeof s.ease !== 'number') {
delete this.reviewScheduleService.schedules[path];
invalidCount++;
isValid = false;
}
}
if (invalidCount > 0) {
console.warn(`Removed ${invalidCount} invalid schedules`);
}
}
// Check history (using service data)
if (!Array.isArray(this.reviewHistoryService.history)) {
console.warn("Invalid history data structure");
this.reviewHistoryService.history = [];
isValid = false;
}
// Check review sessions (using service data)
if (!this.reviewSessionService.reviewSessions ||
typeof this.reviewSessionService.reviewSessions !== 'object' ||
!this.reviewSessionService.reviewSessions.sessions ||
typeof this.reviewSessionService.reviewSessions.sessions !== 'object') {
console.warn("Invalid review sessions data structure");
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
isValid = false;
}
// Check MCQ sets (using service data)
if (!this.mcqService.mcqSets || typeof this.mcqService.mcqSets !== 'object') {
console.warn("Invalid MCQ sets data structure");
this.mcqService.mcqSets = {};
isValid = false;
}
// Check MCQ sessions (using service data)
if (!this.mcqService.mcqSessions || typeof this.mcqService.mcqSessions !== 'object') {
console.warn("Invalid MCQ sessions data structure");
this.mcqService.mcqSessions = {};
isValid = false;
}
// Check custom note order (using service data)
if (!Array.isArray(this.reviewScheduleService.customNoteOrder)) {
console.warn("Invalid custom note order data structure");
this.reviewScheduleService.customNoteOrder = [];
isValid = false;
}
// Check last link analysis timestamp (using service data)
if (this.reviewScheduleService.lastLinkAnalysisTimestamp !== null && typeof this.reviewScheduleService.lastLinkAnalysisTimestamp !== 'number') {
console.warn("Invalid last link analysis timestamp data structure");
this.reviewScheduleService.lastLinkAnalysisTimestamp = null;
isValid = false;
}
// Check if data is suspiciously empty (using service data)
const noSchedules = Object.keys(this.reviewScheduleService.schedules).length === 0;
const hasMCQs = Object.keys(this.mcqService.mcqSets).length > 0;
if (noSchedules && hasMCQs) {
console.warn("WARNING: No schedules found but MCQ data exists - possible data inconsistency");
// Removed localStorage check here as it's deprecated
}
// Log final data state (using service data)
console.log("Data integrity check complete:", {
isValid,
schedules: Object.keys(this.reviewScheduleService.schedules).length,
history: this.reviewHistoryService.history.length,
reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions).length,
mcqSets: Object.keys(this.mcqService.mcqSets).length,
mcqSessions: Object.keys(this.mcqService.mcqSessions).length
});
return isValid; // Returns true if valid, false if fixes were needed
}
/**
* Verify data integrity and remove schedules for files that no longer exist
* @returns {Promise<boolean>} True if any cleanup was performed, false otherwise.
*/
public async cleanupNonExistentFiles(): Promise<boolean> {
console.log("Verifying data integrity and cleaning up non-existent files...");
let changesMade = false; // Track if any cleanup occurred
// First, check that we have valid data structures (using service data)
if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== 'object') {
console.warn("Schedules is not a valid object, resetting it");
this.reviewScheduleService.schedules = {};
changesMade = true; // Resetting counts as a change
}
if (!Array.isArray(this.reviewHistoryService.history)) {
console.warn("History is not a valid array, resetting it");
this.reviewHistoryService.history = [];
changesMade = true;
}
if (!this.reviewSessionService.reviewSessions || typeof this.reviewSessionService.reviewSessions !== 'object' || !this.reviewSessionService.reviewSessions.sessions) {
console.warn("Review sessions is not a valid object, resetting it");
this.reviewSessionService.reviewSessions = {
sessions: {},
activeSessionId: null
};
changesMade = true;
}
// Then, remove schedules for files that no longer exist (using service data)
let cleanupCount = 0;
const beforeCount = Object.keys(this.reviewScheduleService.schedules).length;
// CRITICAL FIX: Add a safeguard to prevent removing all schedules
const safetyCheck = {
totalSchedules: beforeCount,
checkedSchedules: 0,
missingSchedules: 0,
preserved: false
};
try {
const allSchedules = {...this.reviewScheduleService.schedules}; // Create a copy
const allFiles = new Set<string>();
// Preload all markdown files
try {
const mdFiles = this.plugin.app.vault.getMarkdownFiles();
mdFiles.forEach(file => allFiles.add(file.path));
console.log(`Preloaded ${allFiles.size} markdown files for checking`);
} catch (listError) {
console.error("Error listing markdown files:", listError);
return changesMade; // Return current state if file listing fails
}
// Safety check: If no files found but schedules exist
if (allFiles.size === 0 && beforeCount > 0) {
console.warn("No files found in vault but schedules exist - preserving schedules");
safetyCheck.preserved = true;
return changesMade; // No cleanup performed, return current state
}
for (const path in allSchedules) {
try {
safetyCheck.checkedSchedules++;
if (allFiles.has(path)) continue; // File exists
// Double-check with direct vault access
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!file) {
safetyCheck.missingSchedules++;
delete this.reviewScheduleService.schedules[path]; // Delete from service data
cleanupCount++;
changesMade = true; // Mark that changes were made
}
} catch (checkError) {
console.warn(`Error checking file at path ${path}:`, checkError);
}
// SAFETY CHECK: Abort if removing too many
if (safetyCheck.missingSchedules > 0 &&
safetyCheck.missingSchedules === safetyCheck.checkedSchedules &&
safetyCheck.checkedSchedules >= 5) {
console.warn(`SAFETY ALERT: Preventing removal of all schedules (${safetyCheck.missingSchedules}/${safetyCheck.totalSchedules} would be removed)`);
this.reviewScheduleService.schedules = allSchedules; // Restore service data
cleanupCount = 0;
changesMade = false; // Revert changesMade flag
safetyCheck.preserved = true;
break;
}
}
console.log(`Cleanup complete: ${beforeCount} schedules before, ${Object.keys(this.reviewScheduleService.schedules).length} after (${cleanupCount} removed)`);
console.log(`Safety check: ${safetyCheck.checkedSchedules} checked, ${safetyCheck.missingSchedules} missing, preserved: ${safetyCheck.preserved}`);
// Removed the saveData call from here
if (cleanupCount > 0 && !safetyCheck.preserved) {
console.log(`Spaceforge: Cleaned up ${cleanupCount} non-existent files from schedules. Data needs saving.`);
}
// Log final state after potential cleanup
const dataState = {
schedules: Object.keys(this.reviewScheduleService.schedules).length,
history: this.reviewHistoryService.history.length,
reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions || {}).length,
mcqSets: Object.keys(this.mcqService.mcqSets || {}).length,
mcqSessions: Object.keys(this.mcqService.mcqSessions || {}).length
};
console.log("Final data state after cleanup check:", dataState);
if (dataState.schedules === 0 && (dataState.history > 0 || dataState.reviewSessions > 0 || dataState.mcqSets > 0)) {
console.warn("WARNING: No schedules found but other data exists - possible data inconsistency");
}
} catch (error) {
console.error("Error during cleanup:", error);
}
return changesMade; // Return whether cleanup occurred
}
// The following methods are now delegated to the respective service classes.
// They are kept here as public methods to maintain the public API of DataStorage,
// but they now simply call the corresponding method on the service instance.
// REMOVED await this.saveData() from all these methods.
async scheduleNoteForReview(path: string, daysFromNow: number = 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);
}
calculateNewSchedule(
currentInterval: number,
currentEase: number,
response: ReviewResponse
): { interval: number, ease: number } {
// This method is likely redundant now that recordReview uses calculateSM2Schedule directly,
// 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 };
}
async skipNote(path: string, response: ReviewResponse = ReviewResponse.CorrectWithDifficulty): Promise<void> {
await this.reviewScheduleService.skipNote(path, response);
// await this.saveData(); // Removed
}
async postponeNote(path: string, days: number = 1): Promise<void> {
await this.reviewScheduleService.postponeNote(path, days);
// await this.saveData(); // Removed
}
async removeFromReview(path: string): Promise<void> {
await this.reviewScheduleService.removeFromReview(path);
// await this.saveData(); // Removed
}
async clearAllSchedules(): Promise<void> {
await this.reviewScheduleService.clearAllSchedules();
// await this.saveData(); // Removed
}
async estimateReviewTime(path: string): Promise<number> {
// This method doesn't directly modify data, so it can stay or be moved to a utility
// Keeping it here for now, but it doesn't use service data directly.
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return 60; // Default 1 minute
try {
const content = await this.plugin.app.vault.read(file);
return EstimationUtils.estimateReviewTime(file, content);
} catch (error) {
console.error("Error estimating review time:", error);
return 60; // Default 1 minute
}
}
async createReviewSession(folderPath: string, name: string): Promise<ReviewSession | null> {
const session = await this.reviewSessionService.createReviewSession(folderPath, name);
// if (session) { // Removed save call
// await this.saveData();
// }
return session;
}
async setActiveSession(sessionId: string | null): Promise<boolean> {
const success = await this.reviewSessionService.setActiveSession(sessionId);
// if (success) { // Removed save call
// await this.saveData();
// }
return success;
}
getActiveSession(): ReviewSession | null {
return this.reviewSessionService.getActiveSession();
}
getNextSessionFile(): string | null {
return this.reviewSessionService.getNextSessionFile();
}
async advanceActiveSession(): Promise<boolean> {
const moreFiles = await this.reviewSessionService.advanceActiveSession();
// await this.saveData(); // Removed
return moreFiles;
}
async scheduleNotesInOrder(paths: string[], daysFromNow: number = 0): Promise<number> {
const count = await this.reviewScheduleService.scheduleNotesInOrder(paths, daysFromNow);
// if (count > 0) { // Removed save call
// await this.saveData();
// }
return count;
}
async scheduleSessionForReview(sessionId: string): Promise<number> {
// This method calls scheduleNotesInOrder internally, which saves data.
// No need to save again here.
return await this.reviewSessionService.scheduleSessionForReview(sessionId);
}
async saveMCQSet(mcqSet: MCQSet): Promise<string> {
const id = this.mcqService.saveMCQSet(mcqSet);
// await this.saveData(); // Removed
return id;
}
getMCQSetForNote(notePath: string): MCQSet | null {
return this.mcqService.getMCQSetForNote(notePath);
}
async saveMCQSession(session: MCQSession): Promise<void> {
this.mcqService.saveMCQSession(session);
// await this.saveData(); // Removed
}
getMCQSessionsForNote(notePath: string): MCQSession[] {
return this.mcqService.getMCQSessionsForNote(notePath);
}
getLatestMCQSessionForNote(notePath: string): MCQSession | null {
return this.mcqService.getLatestMCQSessionForNote(notePath);
}
async updateCustomNoteOrder(order: string[]): Promise<void> {
await this.reviewScheduleService.updateCustomNoteOrder(order);
// await this.saveData(); // Removed
}
getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder: boolean = true): ReviewSchedule[] {
return this.reviewScheduleService.getDueNotesWithCustomOrder(date, useCustomOrder);
}
// Removed internal helper methods that were moved to services
}

54
esbuild.config.mjs Normal file
View file

@ -0,0 +1,54 @@
import esbuild from "esbuild";
import process from "node:process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === 'production');
const buildOptions = {
banner: {
js: banner,
},
entryPoints: ['main.ts', 'styles/styles.css'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
'tslib',
...builtins],
format: 'cjs',
target: 'es2018',
logLevel: "info",
sourcemap: prod ? false : 'inline',
treeShaking: true,
outdir: '.',
entryNames: '[name]',
};
if (!prod) {
// For development, set up watch mode
const context = await esbuild.context(buildOptions);
await context.watch();
console.log('⚡ esbuild is running in watch mode ⚡');
} else {
// For production, just build once
await esbuild.build(buildOptions);
console.log('⚡ esbuild build complete ⚡');
}

91
install.js Normal file
View file

@ -0,0 +1,91 @@
const fs = require('fs-extra');
const path = require('path');
const { exec } = require('child_process');
const readline = require('readline');
const projectDir = process.cwd();
// Parse command line arguments
function parseArgs() {
const args = process.argv.slice(2);
const flags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--d' || arg === '--directory') {
flags.directory = args[i + 1];
i++; // Skip next argument as it's the directory value
} else if (arg.startsWith('--d=') || arg.startsWith('--directory=')) {
flags.directory = arg.split('=')[1];
}
}
return flags;
}
function validateDirectory(dirPath) {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
console.error(`Error: Directory '${dirPath}' not found.`);
return false;
}
return true;
}
function installPlugin(obsidianPluginsDir) {
if (!validateDirectory(obsidianPluginsDir)) {
process.exit(1);
}
const pluginName = 'spaceforge';
const pluginDir = path.join(obsidianPluginsDir, pluginName);
// Ensure plugin directory exists
fs.ensureDirSync(pluginDir);
console.log('Installing plugin files...');
// Always copy these files (overwrite if exists)
const filesToCopy = ['main.js', 'manifest.json', 'styles.css'];
filesToCopy.forEach(file => {
const sourcePath = path.join(projectDir, file);
const destPath = path.join(pluginDir, file);
if (fs.existsSync(sourcePath)) {
fs.copySync(sourcePath, destPath, { overwrite: true });
console.log(`✓ Copied ${file}`);
} else {
console.log(`⚠ Warning: ${file} not found in project directory`);
}
});
console.log(`\nPlugin installed successfully in '${pluginDir}'!`);
console.log('You may need to restart Obsidian to see the changes.');
}
// Main execution
const flags = parseArgs();
if (flags.directory) {
// Directory provided via command line - skip interactive prompt
console.log(`Using provided directory: ${flags.directory}`);
installPlugin(flags.directory);
} else {
// Interactive mode - prompt user for directory
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Enter the path to your Obsidian plugins directory: ', (obsidianPluginsDir) => {
installPlugin(obsidianPluginsDir);
rl.close();
});
// Handle Ctrl+C gracefully
rl.on('SIGINT', () => {
console.log('\nInstallation cancelled.');
rl.close();
process.exit(0);
});
}

10515
main.js Normal file

File diff suppressed because it is too large Load diff

684
main.ts Normal file
View file

@ -0,0 +1,684 @@
import { App, Notice, Plugin, ViewCreator, WorkspaceLeaf, addIcon, TFile, TFolder, normalizePath } from 'obsidian';
import { EventEmitter } from './utils/event-emitter';
import { DataStorage } from './data-storage';
import { ReviewController } from './controllers/review-controller';
import { ReviewNavigationController } from './controllers/review-navigation-controller';
import { ReviewSessionController } from './controllers/review-session-controller';
import { ReviewBatchController } from './controllers/review-batch-controller';
import { MCQController } from './controllers/review-controller-mcq';
import { ContextMenuHandler } from './ui/context-menu';
import { ReviewSidebarView } from './ui/sidebar-view';
import { SpaceforgeSettingTab } from './ui/settings-tab';
import { DEFAULT_SETTINGS, SpaceforgeSettings, ApiProvider } from './models/settings';
import { SpaceforgePluginData, PluginStateData, DEFAULT_APP_DATA, DEFAULT_PLUGIN_STATE_DATA } from './models/plugin-data';
import { EstimationUtils } from './utils/estimation';
import { OpenRouterService } from './api/openrouter-service';
import { OpenAIService } from './api/openai-service';
import { OllamaService } from './api/ollama-service';
import { GeminiService } from './api/gemini-service';
import { ClaudeService } from './api/claude-service';
import { TogetherService } from './api/together-service';
import { IMCQGenerationService } from './api/mcq-generation-service';
import { ReviewScheduleService } from './services/review-schedule-service';
import { ReviewHistoryService } from './services/review-history-service';
import { ReviewSessionService } from './services/review-session-service';
import { MCQService } from './services/mcq-service';
import { PomodoroService } from './services/pomodoro-service';
/**
* Spaceforge: Spaced Repetition Plugin for Obsidian
*/
export default class SpaceforgePlugin extends Plugin {
settings: SpaceforgeSettings;
pluginState: PluginStateData;
dataStorage: DataStorage;
reviewScheduleService: ReviewScheduleService;
reviewHistoryService: ReviewHistoryService;
reviewSessionService: ReviewSessionService;
mcqService: MCQService;
pomodoroService: PomodoroService;
private readonly stylesheetPath: string = "styles.css";
private readonly stylesheetId: string = "spaceforge-styles";
private lastStylesModTime: number | null = null;
private cssHotReloadIntervalId: number | null = null;
reviewController: ReviewController;
navigationController: ReviewNavigationController;
sessionController: ReviewSessionController;
batchController: ReviewBatchController;
mcqController: MCQController | undefined;
mcqGenerationService: IMCQGenerationService | undefined; // Changed from openRouterService
contextMenuHandler: ContextMenuHandler;
sidebarView: ReviewSidebarView;
public clickedDateFromCalendar: Date | null = null;
events: EventEmitter;
async onload(): Promise<void> {
console.log('Loading Spaceforge plugin (version ' + this.manifest.version + ')');
this.events = new EventEmitter();
// Initialize settings with defaults FIRST
this.settings = { ...DEFAULT_SETTINGS };
// Initialize pluginState with defaults
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
// Now services can be initialized, they will use the default settings initially
this.reviewScheduleService = new ReviewScheduleService(
this,
this.pluginState.schedules,
this.pluginState.customNoteOrder,
this.pluginState.lastLinkAnalysisTimestamp ?? null, // Coalesce undefined to null
this.pluginState.history
);
this.reviewHistoryService = new ReviewHistoryService(this.pluginState.history);
this.reviewSessionService = new ReviewSessionService(this, this.pluginState.reviewSessions);
this.mcqService = new MCQService(this.pluginState.mcqSets, this.pluginState.mcqSessions);
// Load stored data, which will override the defaults in this.settings and this.pluginState
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);
this.registerView(
'spaceforge-review-schedule',
(leaf) => (this.sidebarView = new ReviewSidebarView(leaf, this))
);
this.dataStorage = new DataStorage(
this,
this.reviewScheduleService,
this.reviewHistoryService,
this.reviewSessionService,
this.mcqService
);
this.reviewController = new ReviewController(this, this.mcqService);
this.navigationController = new ReviewNavigationController(this);
this.sessionController = new ReviewSessionController(this);
this.batchController = new ReviewBatchController(this);
this.contextMenuHandler = new ContextMenuHandler(this);
this.initializeMCQComponents(); // This will now use mcqGenerationService
EstimationUtils.setPlugin(this);
this.addIcons();
this.contextMenuHandler.register();
this.addRibbonIcon('calendar-clock', 'Spaceforge Review', async () => {
await this.activateSidebarView();
});
this.addSettingTab(new SpaceforgeSettingTab(this.app, this));
this.addCommands();
this.addCommand({
id: 'add-selected-file-to-review',
name: 'Add Selected File to Review Schedule (File Explorer)',
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: 's' }],
callback: () => {
const fileExplorerLeaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (fileExplorerLeaf && fileExplorerLeaf.view && (fileExplorerLeaf.view as any).file) {
const selectedFile = (fileExplorerLeaf.view as any).file;
if (selectedFile instanceof TFile && selectedFile.extension === 'md') {
this.reviewScheduleService.scheduleNoteForReview(selectedFile.path)
.then(() => this.savePluginData());
new Notice(`Added "${selectedFile.path}" to review schedule.`);
} else {
new Notice("Selected item is not a markdown file.");
}
} else {
new Notice("No file selected in file explorer.");
}
}
});
this.registerEvent(this.app.workspace.on('file-open', (file) => { /* ... */ }));
this.registerEvent(this.app.vault.on('delete', async (file) => {
if (file instanceof TFile && file.extension === "md") {
await this.reviewScheduleService.removeFromReview(file.path);
await this.savePluginData();
}
if (this.sidebarView) this.sidebarView.refresh();
}));
this.registerEvent(this.app.vault.on('rename', async (file, oldPath) => {
if (file instanceof TFile && file.extension === "md") {
this.reviewScheduleService.handleNoteRename(oldPath, file.path);
await this.savePluginData();
}
if (this.sidebarView) this.sidebarView.refresh();
}));
this.registerInterval(window.setInterval(() => {
if (this.sidebarView) this.sidebarView.refresh();
}, 60 * 1000));
this.app.workspace.onLayoutReady(() => this.activateSidebarView());
this.addStylesheet();
if (this.app.vault.adapter.stat && typeof this.app.vault.adapter.stat === 'function') {
this.cssHotReloadIntervalId = window.setInterval(async () => {
try {
const stats = await this.app.vault.adapter.stat(this.stylesheetPath);
if (stats && (this.lastStylesModTime === null || this.lastStylesModTime < stats.mtime)) {
this.lastStylesModTime = stats.mtime;
console.log("Detected styles.css change, reloading stylesheet...");
this.addStylesheet();
}
} catch (error) { /* console.warn("Error checking stylesheet modification time:", error); */ }
}, 1000);
this.registerInterval(this.cssHotReloadIntervalId);
}
if (this.settings.notifyBeforeDue > 0) {
this.registerInterval(window.setInterval(() => this.checkForDueNotes(), 5 * 60 * 1000));
}
this.registerInterval(window.setInterval(async () => {
console.log("Auto-saving data...");
await this.savePluginData();
// Removed specific openRouterApiKey backup from here, handled by general settings save.
}, 5 * 60 * 1000));
window.addEventListener('beforeunload', (event) => {
console.log("Window closing, saving data immediately...");
let existingData = {};
try {
const loadedData = this.loadData();
if (loadedData && !(loadedData instanceof Promise)) {
existingData = loadedData;
} else if (loadedData instanceof Promise) {
console.warn("Synchronous loadData not available in beforeunload.");
}
} catch (loadError) { console.warn("Could not load existing data during unload:", loadError); }
try {
const reviewData = {
schedules: this.reviewScheduleService.schedules,
history: this.reviewHistoryService.history,
reviewSessions: this.reviewSessionService.reviewSessions,
mcqSets: this.mcqService.mcqSets,
mcqSessions: this.mcqService.mcqSessions,
customNoteOrder: this.reviewScheduleService.customNoteOrder,
lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp,
version: this.manifest.version
};
const combinedData = { ...existingData, reviewData };
let backupStr = JSON.stringify(combinedData);
window.localStorage.setItem('spaceforge-backup', backupStr);
console.log(`Saved emergency backup to localStorage (${Math.round(backupStr.length/1024)}KB)`);
// Removed specific openRouterApiKey backup from here.
this.savePluginData().catch(e => console.error("Error saving to Obsidian storage during unload:", e));
} catch (error) {
console.error("Emergency data backup failed:", error);
try {
const minimalBackup = JSON.stringify({ settings: this.settings, reviewData: { schedules: this.reviewScheduleService.schedules || {}, customNoteOrder: this.reviewScheduleService.customNoteOrder || [], lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp, version: this.manifest.version }});
window.localStorage.setItem('spaceforge-minimal-backup', minimalBackup);
} catch (minimalError) { console.error("Even minimal backup failed:", minimalError); }
}
});
}
async onunload(): Promise<void> {
console.log('Unloading Spaceforge plugin');
if (this.cssHotReloadIntervalId !== null) {
window.clearInterval(this.cssHotReloadIntervalId);
this.cssHotReloadIntervalId = null;
}
const styleEl = document.getElementById(this.stylesheetId);
if (styleEl) styleEl.remove();
let existingData = {};
try { existingData = await this.loadData() || {}; }
catch (loadError) { console.warn("Could not load existing data during unload:", loadError); }
try {
const reviewData = {
schedules: this.reviewScheduleService.schedules,
history: this.reviewHistoryService.history,
reviewSessions: this.reviewSessionService.reviewSessions,
mcqSets: this.mcqService.mcqSets,
mcqSessions: this.mcqService.mcqSessions,
customNoteOrder: this.reviewScheduleService.customNoteOrder,
lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp,
version: this.manifest.version
};
const combinedData = { ...existingData, reviewData };
const backupStr = JSON.stringify(combinedData);
window.localStorage.setItem('spaceforge-backup', backupStr);
} catch (backupError) { console.error("Failed to create emergency backup before unload:", backupError); }
try {
await this.savePluginData();
} catch (error) { console.error('Error saving plugin data before unload:', error); }
if (this.pomodoroService) this.pomodoroService.destroy();
this.app.workspace.detachLeavesOfType('spaceforge-review-schedule');
}
// private async _getEffectiveDataPathFromLocalStorage(): Promise<string | null> {
// const customPath = await this.app.loadLocalStorage('spaceforgeCustomDataPath');
// return (customPath && typeof customPath === 'string' && customPath.trim() !== '') ? customPath.trim() : null;
// }
private async _getEffectiveDataPath(): Promise<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
let pathForJson = relativePath;
// Assume customDataPath is a FOLDER, append /data.json
if (!pathForJson.endsWith('/')) {
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);
return absolutePath;
} else {
// useCustomDataPath is true, but customDataPath is empty.
// Silently fall back to default plugin storage without a warning.
return null;
}
}
return null; // useCustomDataPath is false, use default plugin storage
}
async loadPluginData(): Promise<void> {
// Step 1: Bootstrap critical path settings from localStorage
const lsUseCustomPath = await this.app.loadLocalStorage('spaceforge_useCustomDataPath');
const lsCustomPathRelative = await this.app.loadLocalStorage('spaceforge_customDataPathRelative');
// Initialize settings with defaults
this.settings = { ...DEFAULT_SETTINGS };
// Override with localStorage values if they exist
if (typeof lsUseCustomPath === 'boolean') {
this.settings.useCustomDataPath = lsUseCustomPath;
}
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 defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
try {
if (effectivePath) {
// Using custom path
if (await this.app.vault.adapter.exists(effectivePath)) {
const jsonData = await this.app.vault.adapter.read(effectivePath);
if (jsonData) rawLoadedData = JSON.parse(jsonData);
new Notice(`Spaceforge: Loaded data from custom path: ${effectivePath}`, 3000);
} else {
// Custom path specified but file doesn't exist.
// Check if old default data.json exists for migration.
if (await this.app.vault.adapter.exists(defaultPluginDataPath)) {
new Notice(`Spaceforge: Custom data file not found at ${effectivePath}. Attempting to migrate from default location.`, 5000);
try {
const oldJsonData = await this.app.vault.adapter.read(defaultPluginDataPath);
if (oldJsonData) {
rawLoadedData = JSON.parse(oldJsonData);
// Data will be saved to new path by savePluginData later
console.log(`Spaceforge: Data from default location will be migrated to ${effectivePath} on next save.`);
}
} catch (migrationReadError) {
console.error(`Spaceforge: Error reading data from default location for migration:`, migrationReadError);
}
}
if (!rawLoadedData) {
new Notice(`Spaceforge: No data file found at custom path ${effectivePath}. New data file will be created on save.`, 3000);
}
}
} else {
// Using default plugin storage path
rawLoadedData = await this.loadData(); // This is the plugin's internal save/load
}
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;
}
// Merge defaults, loaded settings, and prioritize localStorage path settings
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
if (typeof lsUseCustomPath === 'boolean') {
this.settings.useCustomDataPath = lsUseCustomPath;
}
if (typeof lsCustomPathRelative === 'string') {
this.settings.customDataPath = lsCustomPathRelative;
}
this.pluginState = { ...DEFAULT_APP_DATA.pluginState };
if (rawLoadedData?.pluginState) {
this.pluginState = { ...this.pluginState, ...rawLoadedData.pluginState };
} else if (rawLoadedData && !rawLoadedData.pluginState && (rawLoadedData as any).schedules) { // Legacy check
this.pluginState = { ...this.pluginState, ...(rawLoadedData as any) };
}
// 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;
this.pluginState.pomodoroSessionsCompletedInCycle = this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle;
this.pluginState.pomodoroIsRunning = typeof this.pluginState.pomodoroIsRunning === 'boolean' ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning;
if (this.pluginState.schedules) {
// Data migration for schedulingAlgorithm
for (const path in this.pluginState.schedules) {
if (Object.prototype.hasOwnProperty.call(this.pluginState.schedules, path)) {
const schedule = this.pluginState.schedules[path];
if (!schedule.schedulingAlgorithm) {
schedule.schedulingAlgorithm = 'sm2'; // Default to SM-2 for old schedules
// Ensure FSRS-specific data is undefined for these migrated SM-2 cards
schedule.fsrsData = undefined;
// Ensure SM-2 specific 'scheduleCategory' has a default if missing
if (!schedule.scheduleCategory) {
schedule.scheduleCategory = this.settings.useInitialSchedule ? 'initial' : 'spaced';
}
}
// Ensure SM-2 fields are present for SM-2 cards if somehow missing
if (schedule.schedulingAlgorithm === 'sm2') {
schedule.ease = schedule.ease ?? this.settings.baseEase;
schedule.interval = schedule.interval ?? 0;
schedule.repetitionCount = schedule.repetitionCount ?? 0;
schedule.consecutive = schedule.consecutive ?? 0;
schedule.scheduleCategory = schedule.scheduleCategory ?? (this.settings.useInitialSchedule ? 'initial' : 'spaced');
}
}
}
}
this.reviewScheduleService.schedules = this.pluginState.schedules || {};
this.reviewHistoryService.history = this.pluginState.history || [];
this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null };
this.mcqService.mcqSets = this.pluginState.mcqSets || {};
this.mcqService.mcqSessions = this.pluginState.mcqSessions || {};
this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.pluginState.lastLinkAnalysisTimestamp === 'number' ? this.pluginState.lastLinkAnalysisTimestamp : null;
} catch (error) {
console.error("Error loading plugin data:", error);
// Fallback to complete defaults if any error during loading sequence
console.warn("Spaceforge: loadPluginData caught an error. Re-initializing settings and pluginState to defaults.");
this.settings = { ...DEFAULT_SETTINGS };
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
// Repopulate services with these fresh defaults
this.reviewScheduleService.schedules = this.pluginState.schedules || {};
this.reviewHistoryService.history = this.pluginState.history || [];
this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null };
this.mcqService.mcqSets = this.pluginState.mcqSets || {};
this.mcqService.mcqSessions = this.pluginState.mcqSessions || {};
this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || [];
this.reviewScheduleService.lastLinkAnalysisTimestamp = this.pluginState.lastLinkAnalysisTimestamp ?? null;
// Ensure FSRS service is also updated with these default settings
if (this.reviewScheduleService) {
this.reviewScheduleService.updateAlgorithmServicesForSettingsChange();
}
new Notice("Spaceforge: Error loading data, initialized with defaults.", 5000);
}
}
async savePluginData(): Promise<void> {
try {
// Ensure settings object exists and is valid, applying defaults if necessary
if (!this.settings || typeof this.settings !== 'object') {
console.warn("Spaceforge: Settings object was invalid, resetting to defaults before save.");
this.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
} else {
// Ensure all default keys are present
this.settings = { ...DEFAULT_SETTINGS, ...this.settings };
}
// Ensure pluginState object exists
if (!this.pluginState) {
console.warn("Spaceforge: pluginState was undefined, initializing to default before save.");
this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA };
}
const currentPluginState: PluginStateData = {
schedules: this.reviewScheduleService.schedules || {},
history: this.reviewHistoryService.history || [],
reviewSessions: this.reviewSessionService.reviewSessions || { sessions: {}, activeSessionId: null },
mcqSets: this.mcqService.mcqSets || {},
mcqSessions: this.mcqService.mcqSessions || {},
customNoteOrder: this.reviewScheduleService.customNoteOrder || [],
lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp ?? null,
pomodoroCurrentMode: this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode,
pomodoroTimeLeftInSeconds: this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds,
pomodoroSessionsCompletedInCycle: this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle,
pomodoroIsRunning: typeof this.pluginState.pomodoroIsRunning === 'boolean' ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning,
pomodoroEndTimeMs: this.pluginState.pomodoroEndTimeMs ?? null, // Add the missing field
version: this.manifest.version,
};
this.pluginState = currentPluginState; // Update the live pluginState
const dataToSave: SpaceforgePluginData = {
settings: this.settings, // Use the already prepared this.settings
pluginState: this.pluginState,
};
const effectiveSavePath = await this._getEffectiveDataPath();
const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`;
if (effectiveSavePath) {
// Saving to custom path
try {
const dirPathOnly = effectiveSavePath.substring(0, effectiveSavePath.lastIndexOf('/'));
if (dirPathOnly && !(await this.app.vault.adapter.exists(dirPathOnly))) {
await this.app.vault.adapter.mkdir(dirPathOnly);
new Notice(`Spaceforge: Created directory for custom data: ${dirPathOnly}`, 3000);
}
await this.app.vault.adapter.write(effectiveSavePath, JSON.stringify(dataToSave, null, 2));
// new Notice(`Spaceforge: Data saved to custom path: ${effectiveSavePath}`, 3000); // Removed notice
// Migration/Cleanup: If we just successfully saved to a custom path,
// and the old default data.json exists, remove it.
if (await this.app.vault.adapter.exists(defaultPluginDataPath)) {
// Check if this is a migration scenario (old data was loaded and new path was empty)
// This check is a bit implicit. A more robust way would be a flag.
// For now, if custom path is active and default exists, assume it's post-migration or user switched.
await this.app.vault.adapter.remove(defaultPluginDataPath);
new Notice(`Spaceforge: Removed old data file from default plugin folder as custom path is active.`, 5000);
console.log(`Spaceforge: Removed old data file at ${defaultPluginDataPath}`);
}
} catch (writeError) {
console.error(`Error saving data to custom path ${effectiveSavePath}:`, 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) {
console.error(`Error saving data to default location after custom path failed:`, fallbackError);
new Notice(`CRITICAL: Spaceforge failed to save data to both custom and default locations.`, 10000);
}
}
} else {
// Saving to default plugin storage path
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) {
console.error("General error in savePluginData:", error);
new Notice("Error saving Spaceforge data. Check console for details.", 5000);
}
}
async activateSidebarView(): Promise<void> {
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]);
} else {
// If no sidebar view exists, create a new one
const leaf = this.app.workspace.getRightLeaf(false); // Default to right leaf if creating new
if (leaf) {
await leaf.setViewState({
type: 'spaceforge-review-schedule',
active: true,
});
this.app.workspace.revealLeaf(leaf); // Reveal the newly created leaf
} else {
console.error("Spaceforge: Could not get a leaf to activate the sidebar view.");
new Notice("Spaceforge: Could not open sidebar view.");
}
}
}
addCommands(): void {
this.addCommand({
id: 'spaceforge-next-review-note',
name: 'Next Review Note',
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: '/' }],
callback: () => {
this.navigationController.navigateToNextNote();
},
});
this.addCommand({
id: 'spaceforge-previous-review-note',
name: 'Previous Review Note',
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: '.' }],
callback: () => {
this.navigationController.navigateToPreviousNote();
},
});
this.addCommand({
id: 'spaceforge-review-current-note',
name: 'Review Current Note',
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: ',' }],
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof TFile && activeFile.extension === 'md') {
this.reviewController.reviewNote(activeFile.path);
} else {
new Notice('No active markdown file to review.');
}
},
});
this.addCommand({
id: 'spaceforge-add-current-note-to-review',
name: 'Add Current Note to Review Schedule',
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: '\\\\' }],
callback: () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile instanceof TFile && activeFile.extension === 'md') {
this.reviewScheduleService.scheduleNoteForReview(activeFile.path)
.then(() => this.savePluginData());
new Notice(`Added "${activeFile.path}" to review schedule.`);
} else {
new Notice('No active markdown file to add to review.');
}
},
});
this.addCommand({
id: 'spaceforge-add-current-folder-to-review',
name: "Add Current Note's Folder to Review Schedule",
hotkeys: [{ modifiers: ['Alt', 'Shift'], key: "'" }],
callback: async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile.parent && activeFile.parent instanceof TFolder) {
const folder = activeFile.parent;
// 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.");
}
},
});
}
addIcons(): void { /* ... */ }
initializeMCQComponents(): void {
this.mcqGenerationService = undefined; // Clear previous instance
this.mcqController = undefined;
if (this.settings.enableMCQ) {
console.log('Initializing MCQ components for provider:', this.settings.mcqApiProvider);
this.mcqGenerationService = this.createMcqGenerationService();
if (this.mcqGenerationService) {
// 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.');
console.warn(`Failed to create MCQ generation service for provider: ${this.settings.mcqApiProvider}`);
}
}
}
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.');
return undefined;
}
return new OpenRouterService(this);
case ApiProvider.OpenAI:
if (!this.settings.openaiApiKey) {
new Notice('OpenAI API key is not set in Spaceforge settings.');
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.');
return undefined;
}
return new OllamaService(this);
case ApiProvider.Gemini:
if (!this.settings.geminiApiKey) {
new Notice('Gemini API key is not set in Spaceforge settings.');
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.');
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.');
return undefined;
}
return new TogetherService(this);
default:
// It's good practice to handle unexpected enum values, even if TypeScript provides some safety.
// This could happen if settings data is corrupted or from an older version.
// const exhaustiveCheck: never = this.settings.mcqApiProvider; // This will cause a type error now, which is good!
new Notice(`Unsupported MCQ API provider selected: ${this.settings.mcqApiProvider}`);
console.error(`Unsupported MCQ API provider: ${this.settings.mcqApiProvider}`);
return undefined;
}
}
async checkForDueNotes(): Promise<void> { /* ... */ }
private addStylesheet(): void { /* ... */ }
async exportPluginData(): Promise<void> { /* ... */ }
async importPluginData(fileContent: string): Promise<void> { /* ... */ }
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "spaceforge",
"name": "Spaceforge",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "A spaced repetition FSRS & SM-2 Algorithm powered by AI plugin for efficient knowledge review.",
"author": "dralkh",
"authorUrl": "https://github.com/dralkh/spaceforge",
"isDesktopOnly": false
}

120
models/mcq.ts Normal file
View file

@ -0,0 +1,120 @@
/**
* Represents a single multiple-choice question
*/
export interface MCQQuestion {
/**
* The question text
*/
question: string;
/**
* Available answer choices
*/
choices: string[];
/**
* Index of the correct answer in the choices array
*/
correctAnswerIndex: number;
}
/**
* A set of multiple-choice questions for a note
*/
export interface MCQSet {
/**
* Path to the note these questions are for
*/
notePath: string;
/**
* Array of questions
*/
questions: MCQQuestion[];
/**
* When the set was generated (timestamp)
*/
generatedAt: number;
/**
* Flag indicating if this MCQ set needs regeneration before next review
* Optional for backward compatibility.
*/
needsQuestionRegeneration?: boolean;
}
/**
* User's answer to a question
*/
export interface MCQAnswer {
/**
* Index of the question in the MCQSet
*/
questionIndex: number;
/**
* Index of the selected answer
*/
selectedAnswerIndex: number;
/**
* Whether the answer was correct
*/
correct: boolean;
/**
* Time taken to answer in seconds
*/
timeToAnswer: number;
/**
* Number of attempts made
*/
attempts: number;
}
/**
* A session of answering MCQs
*/
export interface MCQSession {
/**
* ID of the MCQ set being used
*/
mcqSetId: string;
/**
* Path to the note
*/
notePath: string;
/**
* User's answers to questions
*/
answers: MCQAnswer[];
/**
* Overall score (0-1)
*/
score: number;
/**
* Current question index
*/
currentQuestionIndex: number;
/**
* Whether the session is completed
*/
completed: boolean;
/**
* When the session started
*/
startedAt: number;
/**
* When the session was completed (null if not completed)
*/
completedAt: number | null;
}

65
models/plugin-data.ts Normal file
View file

@ -0,0 +1,65 @@
import { SpaceforgeSettings, DEFAULT_SETTINGS } from './settings';
import { ReviewSchedule, ReviewHistoryItem } from './review-schedule';
import { ReviewSessionStore } from './review-session';
import { MCQSet, MCQSession } from './mcq';
/**
* Interface for the plugin's operational state (excluding settings).
* This corresponds to the data previously managed by DataStorage.
*/
export interface PluginStateData {
schedules: Record<string, ReviewSchedule>;
history: ReviewHistoryItem[];
reviewSessions: ReviewSessionStore;
mcqSets: Record<string, MCQSet>;
mcqSessions: Record<string, MCQSession[]>;
customNoteOrder: string[];
lastLinkAnalysisTimestamp?: number | null;
version: string; // To track data structure version for migrations
// Pomodoro Timer State
pomodoroCurrentMode: 'work' | 'shortBreak' | 'longBreak' | 'idle';
pomodoroTimeLeftInSeconds: number;
pomodoroSessionsCompletedInCycle: number;
pomodoroIsRunning: boolean;
pomodoroEndTimeMs: number | null; // Timestamp when the current session ends
}
/**
* Default values for the plugin's operational state.
*/
export const DEFAULT_PLUGIN_STATE_DATA: PluginStateData = {
schedules: {},
history: [],
reviewSessions: { sessions: {}, activeSessionId: null },
mcqSets: {},
mcqSessions: {},
customNoteOrder: [],
lastLinkAnalysisTimestamp: null,
version: "0.0.0", // This will be updated from plugin.manifest.version on save
// Pomodoro Timer State Defaults
pomodoroCurrentMode: 'idle',
pomodoroTimeLeftInSeconds: 25 * 60, // Default to work duration
pomodoroSessionsCompletedInCycle: 0,
pomodoroIsRunning: false,
pomodoroEndTimeMs: null,
};
/**
* Unified data structure for everything persisted in data.json.
* This includes both plugin settings and the plugin's operational state.
*/
export interface SpaceforgePluginData {
settings: SpaceforgeSettings;
pluginState: PluginStateData;
}
/**
* Default values for the entire application data, including settings and state.
* Used for new installations or when data.json is missing/corrupted.
*/
export const DEFAULT_APP_DATA: SpaceforgePluginData = {
settings: DEFAULT_SETTINGS,
pluginState: DEFAULT_PLUGIN_STATE_DATA,
};

202
models/review-schedule.ts Normal file
View file

@ -0,0 +1,202 @@
/**
* Represents a note's review schedule in the spaced repetition system
*/
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
*/
repetitionCount?: number;
/**
* Defines the scheduling category for the note.
* 'initial': Note is following the initial learning intervals.
* 'spaced': Note is using standard SM-2 from the start.
* 'graduated': Note has completed initial intervals and now uses SM-2.
*/
scheduleCategory?: 'initial' | 'spaced' | 'graduated'; // Made optional for FSRS cards
// --- FSRS Specific Data ---
fsrsData?: {
stability: number;
difficulty: number;
elapsed_days: number;
scheduled_days: number;
reps: number;
lapses: number;
state: number; // ts-fsrs.State enum (0:New, 1:Learning, 2:Review, 3:Relearning)
last_review?: number; // Timestamp of last FSRS review for this card
};
schedulingAlgorithm: 'sm2' | 'fsrs'; // Determines which algo rules apply
}
// Removed INITIAL_INTERVALS, isInitialPhase, and getInitialInterval.
// This logic will now be handled in ReviewScheduleService using settings.initialScheduleCustomIntervals.
/**
* Represents a user's response for FSRS (1-4 rating)
*/
export enum FsrsRating {
Again = 1,
Hard = 2,
Good = 3,
Easy = 4,
}
/**
* Represents a user's response during review (SM-2 quality rating 0-5)
*/
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
}
/**
* Convert any response to SM-2 quality ratings (0-5)
*
* @param response The review response to convert
* @returns SM-2 quality rating (0-5)
*/
export function toSM2Quality(response: ReviewResponse): number {
// Ensure response is within 0-5 range
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;
}
}
/**
* Review history entry
*/
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
*/
isSkipped?: boolean;
}

113
models/review-session.ts Normal file
View file

@ -0,0 +1,113 @@
import { ReviewHierarchy } from "../utils/link-analyzer";
/**
* Represents a review session for a folder or group of files
*/
export interface ReviewSession {
/**
* Unique identifier for the session
*/
id: string;
/**
* Name of the session (typically folder name)
*/
name: string;
/**
* Path to the folder or starting file
*/
path: string;
/**
* Review hierarchy for the session
*/
hierarchy: ReviewHierarchy;
/**
* Current position in the traversal order
*/
currentIndex: number;
/**
* Timestamp when the session was created
*/
createdAt: number;
/**
* Timestamp when the session was last updated
*/
updatedAt: number;
/**
* Whether this session is active (being reviewed)
*/
isActive: boolean;
}
/**
* Data store for review sessions
*/
export interface ReviewSessionStore {
/**
* All review sessions indexed by ID
*/
sessions: Record<string, ReviewSession>;
/**
* ID of the currently active session
*/
activeSessionId: string | null;
}
/**
* Generate a unique ID for a review session
*
* @param prefix Prefix for the ID
* @returns Unique ID
*/
export function generateSessionId(prefix: string = 'session'): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Get the next file to review in a session
*
* @param session Review session
* @returns Path to the next file or null if done
*/
export function getNextFileInSession(session: ReviewSession): string | null {
if (!session.hierarchy.traversalOrder.length) {
return null;
}
if (session.currentIndex >= session.hierarchy.traversalOrder.length) {
return null;
}
return session.hierarchy.traversalOrder[session.currentIndex];
}
/**
* Advance to the next file in a session
*
* @param session Review session
* @returns Updated session
*/
export function advanceSession(session: ReviewSession): ReviewSession {
return {
...session,
currentIndex: session.currentIndex + 1,
updatedAt: Date.now(),
};
}
/**
* Check if a session is complete
*
* @param session Review session
* @returns True if the session is complete
*/
export function isSessionComplete(session: ReviewSession): boolean {
return session.currentIndex >= session.hierarchy.traversalOrder.length;
}

383
models/settings.ts Normal file
View file

@ -0,0 +1,383 @@
/**
* MCQ difficulty levels
*/
export enum MCQDifficulty {
/**
* Basic difficulty - focuses on recall and simple understanding
*/
Basic = 'basic',
/**
* Advanced difficulty - focuses on deeper understanding and application
*/
Advanced = 'advanced'
}
/**
* How the number of MCQs per note is determined
*/
export enum MCQQuestionAmountMode {
/** User sets a fixed number of questions per note */
Fixed = 'fixed',
/** Number of questions is based on the note's word count */
WordsPerQuestion = 'wordsPerQuestion'
}
/**
* Plugin settings for Spaceforge
*/
export interface SpaceforgeSettings {
/**
* Show notifications when navigating between notes
* Default: true
*/
showNavigationNotifications: boolean;
/**
* Base ease factor for new notes (higher = longer intervals)
* In SM-2, the default initial value is 2.5, stored as 250 internally
* Default: 250 (2.5 in SM-2)
*/
baseEase: number;
/**
* Add slight randomness to intervals to balance the workload
* Default: true
*/
loadBalance: boolean;
/**
* Maximum interval between reviews (in days)
* Default: 365
*/
maximumInterval: number;
/**
* Use a custom data path for storing plugin data
* Default: false
*/
useCustomDataPath: boolean;
/**
* Custom path for storing plugin data (relative to vault root)
* Default: ''
*/
customDataPath: string;
// Auto-review is now the default behavior
/**
* Notification time for upcoming reviews (in minutes before due)
* Default: 120
*/
notifyBeforeDue: number;
/**
* Include subfolders when adding a folder to review
* Default: true
*/
includeSubfolders: boolean;
/**
* Reading speed in words per minute (used for time estimation)
* Default: 120 WPM
*/
readingSpeed: number;
/**
* View type for the sidebar (list or calendar)
* Default: 'list'
*/
sidebarViewType: 'list' | 'calendar';
/**
* Use fixed schedule for initial reviews before switching to full SM-2 algorithm
*
* When enabled, the first 5 reviews will use fixed intervals:
* Review 1: Same day (0 days)
* Review 2: 3 days
* Review 3: 7 days
* Review 4: 14 days
* Review 5: 30 days
*
* After these initial reviews, it switches to the standard SM-2 algorithm.
* This approach helps build a solid foundation before longer intervals.
*
* Default: true
*/
useInitialSchedule: boolean;
/**
* Custom intervals for the initial learning phase (in days)
* Default: [0, 3, 7, 14, 30]
*/
initialScheduleCustomIntervals: number[];
/**
* Enable MCQ feature
* Default: false
*/
enableMCQ: boolean;
/**
* OpenRouter API key for generating MCQs
* Default: ''
*/
openRouterApiKey: string;
/**
* Model to use for generating MCQs
* Default: 'anthropic/claude-3-opus'
*/
openRouterModel: string;
/**
* Type of prompt to use for generating MCQs
* Default: 'detailed'
*/
mcqPromptType: 'basic' | 'detailed';
/**
* Number of questions to generate per note
* Default: 4
*/
mcqQuestionsPerNote: number;
/**
* Number of choices per question
* Default: 5
*/
mcqChoicesPerQuestion: number;
/**
* How to determine the number of questions per note
* Default: 'fixed'
*/
mcqQuestionAmountMode: MCQQuestionAmountMode;
/**
* Target number of words per question when using WordsPerQuestion mode
* Default: 100
*/
mcqWordsPerQuestion: number;
/**
* Amount to deduct from score for slow answers (0-1)
* Default: 0.5
*/
mcqTimeDeductionAmount: number;
/**
* Time threshold in seconds after which to apply time deduction
* Default: 90
*/
mcqTimeDeductionSeconds: number;
/**
* MCQ difficulty level
* Default: 'advanced'
*/
mcqDifficulty: MCQDifficulty;
/**
* API Provider for MCQ generation
* Default: 'openrouter'
*/
mcqApiProvider: ApiProvider;
/**
* OpenAI API Key
* Default: ''
*/
openaiApiKey: string;
/**
* OpenAI Model
* Default: 'gpt-3.5-turbo'
*/
openaiModel: string;
/**
* Ollama API URL
* Default: ''
*/
ollamaApiUrl: string;
/**
* Ollama Model
* Default: ''
*/
ollamaModel: string;
/**
* Gemini API Key
* Default: ''
*/
geminiApiKey: string;
/**
* Gemini Model
* Default: 'gemini-pro'
*/
geminiModel: string;
/**
* Claude API Key
* Default: ''
*/
claudeApiKey: string;
/**
* Claude Model
* Default: 'claude-3-sonnet-20240229'
*/
claudeModel: string;
/**
* Together AI API Key
* Default: ''
*/
togetherApiKey: string;
/**
* Together AI Model
* Default: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8'
*/
togetherModel: string;
/**
* System prompt for basic difficulty MCQs
*/
mcqBasicSystemPrompt: string;
/**
* System prompt for advanced difficulty MCQs
*/
mcqAdvancedSystemPrompt: string;
// Pomodoro Timer Settings
pomodoroEnabled: boolean;
pomodoroSoundEnabled: boolean;
pomodoroWorkDuration: number; // in minutes
pomodoroShortBreakDuration: number; // in minutes
pomodoroLongBreakDuration: number; // in minutes
pomodoroSessionsUntilLongBreak: number;
// MCQ Question Regeneration Settings
/**
* Enable automatic regeneration of MCQs based on review rating
* Default: false
*/
enableQuestionRegenerationOnRating: boolean;
/**
* Minimum SM-2 review rating (0-5) to trigger MCQ regeneration.
* Ratings at or above this value will trigger regeneration.
* Default: 5 (Perfect Recall)
*/
minSm2RatingForQuestionRegeneration: number;
/**
* Minimum FSRS review rating (1-4) to trigger MCQ regeneration.
* Ratings at or above this value will trigger regeneration.
* Default: 4 (Easy)
*/
minFsrsRatingForQuestionRegeneration: number;
// --- Algorithm Settings ---
defaultSchedulingAlgorithm: 'sm2' | 'fsrs';
// --- FSRS Specific Settings ---
fsrsParameters: {
request_retention?: number;
maximum_interval?: number;
w?: number[]; // FSRS weights (array of numbers)
enable_fuzz?: boolean;
learning_steps?: number[]; // In minutes
enable_short_term?: boolean; // Added for FSRSParameters requirement
};
}
/**
* API Providers for MCQ generation
*/
export enum ApiProvider {
OpenRouter = 'openrouter',
OpenAI = 'openai',
Ollama = 'ollama',
Gemini = 'gemini',
Claude = 'claude',
Together = 'together'
}
/**
* Default settings for the plugin
*/
export const DEFAULT_SETTINGS: SpaceforgeSettings = {
showNavigationNotifications: true,
baseEase: 250, // 2.5 in SM-2 format (recommended default from the original algorithm)
loadBalance: false, // Disable load balancing by default for pure SM-2 compliance
maximumInterval: 365, // Cap at 1 year (extension to original SM-2)
useCustomDataPath: false,
customDataPath: '',
// autoNextNote property removed, now always true by default
notifyBeforeDue: 120,
includeSubfolders: true,
readingSpeed: 100, // Default WPM set to 100
sidebarViewType: 'calendar',
useInitialSchedule: true,
initialScheduleCustomIntervals: [0, 3, 7, 14, 30],
// MCQ settings
enableMCQ: true,
mcqApiProvider: ApiProvider.Ollama, // Default API provider set to Ollama
openRouterApiKey: '',
openRouterModel: 'openai/gpt-4.1-mini',
openaiApiKey: '',
openaiModel: 'gpt-3.5-turbo',
ollamaApiUrl: '',
ollamaModel: '',
geminiApiKey: '',
geminiModel: 'gemini-pro',
claudeApiKey: '',
claudeModel: 'claude-3-sonnet-20240229',
togetherApiKey: '',
togetherModel: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8',
mcqPromptType: 'detailed',
mcqQuestionsPerNote: 4,
mcqChoicesPerQuestion: 5,
mcqQuestionAmountMode: MCQQuestionAmountMode.Fixed, // Default to fixed number
mcqWordsPerQuestion: 100, // Default words per question if mode is switched
mcqTimeDeductionAmount: 0.5,
mcqTimeDeductionSeconds: 90,
mcqDifficulty: MCQDifficulty.Advanced,
mcqBasicSystemPrompt: 'You are a tutor who creates clear, straightforward multiple-choice questions to test basic understanding of the given content. Focus on key concepts and important facts. Make questions simple and direct, with one clearly correct answer. Always mark the correct answer with [CORRECT] at the end of the line.',
mcqAdvancedSystemPrompt: 'You are an expert tutor who creates challenging but fair multiple-choice questions to test deep understanding of the given content. Generate questions that assess comprehension, application, and analysis, not just memorization. Make incorrect choices plausible to encourage critical thinking. Always mark the correct answer with [CORRECT] at the end of the line.',
// Pomodoro Timer Defaults
pomodoroEnabled: true,
pomodoroSoundEnabled: true,
pomodoroWorkDuration: 25,
pomodoroShortBreakDuration: 5,
pomodoroLongBreakDuration: 15,
pomodoroSessionsUntilLongBreak: 4,
// MCQ Question Regeneration Settings
enableQuestionRegenerationOnRating: false,
minSm2RatingForQuestionRegeneration: 4, // SM-2: 0 (Blackout) to 5 (Perfect Recall) - Defaulting to 4 (Correct with Hesitation)
minFsrsRatingForQuestionRegeneration: 3, // FSRS: 1 (Again) to 4 (Easy) - Defaulting to 3 (Good)
// Algorithm Defaults
defaultSchedulingAlgorithm: 'fsrs',
fsrsParameters: {
request_retention: 0.9,
maximum_interval: 36500,
enable_fuzz: true,
// Default FSRS weights from FSRS-4.5-Anki
w: [
0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61
],
learning_steps: [1, 10], // 1 minute, 10 minutes
enable_short_term: false, // Default for enable_short_term
},
};

2473
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "spaceforge",
"version": "1.0.0",
"description": "A spaced repetition plugin for efficient knowledge review in Obsidian",
"main": "main.js",
"scripts": {
"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"
},
"keywords": [
"obsidian",
"spaced-repetition",
"learning"
],
"author": "Spaceforge Team",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/fs-extra": "^11.0.4",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.17.19",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^4.7.4"
},
"dependencies": {
"fs-extra": "^11.1.1",
"ts-fsrs": "^4.7.1"
}
}

View file

@ -0,0 +1,113 @@
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;
private pluginSettings: SpaceforgeSettings;
constructor(settings: SpaceforgeSettings) {
this.pluginSettings = settings;
this.fsrsInstance = new FSRS(this.mapSettingsToFsrsParams(settings.fsrsParameters));
}
private mapSettingsToFsrsParams(params: SpaceforgeSettings['fsrsParameters']): FSRSParameters {
// Ensure all required FSRSParameters are present, even if with defaults from ts-fsrs if not in our settings
// For now, we assume our settings structure matches FSRSParameters for the fields we care about.
// The 'w' parameter (weights) is crucial and should come from settings.
// If 'w' is not in params, FSRS will use its internal defaults.
const defaultWeights = [0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61];
const mappedParams = {
request_retention: params.request_retention ?? 0.9,
maximum_interval: params.maximum_interval ?? 36500,
w: params.w && params.w.length > 0 ? params.w : defaultWeights,
enable_fuzz: params.enable_fuzz ?? true,
learning_steps: params.learning_steps && params.learning_steps.length > 0 ? params.learning_steps : [1, 10],
enable_short_term: params.enable_short_term ?? false, // Added enable_short_term
};
return mappedParams as FSRSParameters; // Cast to FSRSParameters
}
public updateFSRSInstance(settings: SpaceforgeSettings): void {
this.pluginSettings = settings;
this.fsrsInstance = new FSRS(this.mapSettingsToFsrsParams(settings.fsrsParameters));
}
public createNewFsrsCardData(creationDate: Date = new Date()): Required<ReviewSchedule>['fsrsData'] {
const emptyCard: FsrsLibCard = createFsrsLibEmptyCard(creationDate);
return {
stability: emptyCard.stability,
difficulty: emptyCard.difficulty,
elapsed_days: emptyCard.elapsed_days,
scheduled_days: emptyCard.scheduled_days,
reps: emptyCard.reps,
lapses: emptyCard.lapses,
state: emptyCard.state as number, // Cast to number; FsrsState is an enum
last_review: emptyCard.last_review ? emptyCard.last_review.getTime() : undefined,
};
}
private mapReviewScheduleFsrsDataToFsrsLibCard(fsrsData: Required<ReviewSchedule>['fsrsData'], now: Date): FsrsLibCard {
return {
...fsrsData,
due: now, // This will be the review date for the repeat() call
state: fsrsData.state as FsrsState,
last_review: fsrsData.last_review ? new Date(fsrsData.last_review) : undefined,
};
}
private mapFsrsLibRatingToTsFsrsRating(rating: FsrsRating): TsFsrsRating {
switch (rating) {
case FsrsRating.Again: return TsFsrsRating.Again;
case FsrsRating.Hard: return TsFsrsRating.Hard;
case FsrsRating.Good: return TsFsrsRating.Good;
case FsrsRating.Easy: return TsFsrsRating.Easy;
default: throw new Error(`Unknown FsrsRating: ${rating}`);
}
}
public recordReview(
currentFsrsData: Required<ReviewSchedule>['fsrsData'],
rating: FsrsRating,
reviewDateTime: Date
): { updatedData: Required<ReviewSchedule>['fsrsData']; nextReviewDate: number; log: FsrsReviewLog } {
const fsrsLibCardToReview = this.mapReviewScheduleFsrsDataToFsrsLibCard(currentFsrsData, reviewDateTime);
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;
const updatedData: Required<ReviewSchedule>['fsrsData'] = {
stability: resultCard.stability,
difficulty: resultCard.difficulty,
elapsed_days: resultCard.elapsed_days,
scheduled_days: resultCard.scheduled_days,
reps: resultCard.reps,
lapses: resultCard.lapses,
state: resultCard.state as number,
last_review: resultCard.last_review ? resultCard.last_review.getTime() : undefined,
};
return {
updatedData,
nextReviewDate: resultCard.due.getTime(),
log: resultLog,
};
}
public skipReview(
currentFsrsData: Required<ReviewSchedule>['fsrsData'],
reviewDateTime: Date
): { updatedData: Required<ReviewSchedule>['fsrsData']; nextReviewDate: number; log: FsrsReviewLog } {
// Skipping in FSRS is typically handled by rating "Again"
return this.recordReview(currentFsrsData, FsrsRating.Again, reviewDateTime);
}
}

165
services/mcq-service.ts Normal file
View file

@ -0,0 +1,165 @@
import { MCQSet, MCQSession } from '../models/mcq';
/**
* Handles management of Multiple-Choice Question (MCQ) data
*/
export class MCQService {
/**
* MCQ sets indexed by ID (notePath_timestamp) (This will be a reference to the mcqSets object in DataStorage)
*/
mcqSets: Record<string, MCQSet>;
/**
* MCQ sessions by note path (This will be a reference to the mcqSessions object in DataStorage)
*/
mcqSessions: Record<string, MCQSession[]>;
/**
* Initialize MCQ Service
*
* @param mcqSets Reference to the mcqSets object in DataStorage
* @param mcqSessions Reference to the mcqSessions object in DataStorage
*/
constructor(mcqSets: Record<string, MCQSet>, mcqSessions: Record<string, MCQSession[]>) {
this.mcqSets = mcqSets; // Store reference to the shared mcqSets object
this.mcqSessions = mcqSessions; // Store reference to the shared mcqSessions object
}
/**
* Save an MCQ set
*
* @param mcqSet MCQ set to save
* @returns The ID of the saved MCQ set
*/
saveMCQSet(mcqSet: MCQSet): string {
const id = `${mcqSet.notePath}_${mcqSet.generatedAt}`;
this.mcqSets[id] = mcqSet;
// Data saving is now handled by main.ts after this method returns
return id;
}
/**
* Get the latest MCQ set for a note
*
* @param notePath Path to the note
* @returns MCQ set or null if none exists
*/
getMCQSetForNote(notePath: string): MCQSet | null {
try {
// Verify parameters
if (!notePath) {
console.error('Invalid notePath provided to getMCQSetForNote');
return null;
}
// Make sure we have mcqSets initialized
if (!this.mcqSets) {
console.warn('mcqSets not initialized');
this.mcqSets = {};
return null;
}
// Find the latest MCQ set for this note
const sets = Object.values(this.mcqSets)
.filter(set => set && set.notePath === notePath)
.sort((a, b) => b.generatedAt - a.generatedAt);
// Verify the set is valid before returning
if (sets.length > 0 && sets[0].questions && sets[0].questions.length > 0) {
return sets[0];
} else if (sets.length > 0) {
console.warn('Found MCQ set but it contains no valid questions:', sets[0]);
}
return null;
} catch (error) {
console.error('Error in getMCQSetForNote:', error);
return null;
}
}
/**
* Save an MCQ session
*
* @param session MCQ session to save
*/
saveMCQSession(session: MCQSession): void {
try {
// Validate session
if (!session || !session.notePath || !session.mcqSetId) {
console.error('Invalid MCQ session data:', session);
return;
}
// Initialize sessions array if it doesn't exist
if (!this.mcqSessions) {
this.mcqSessions = {};
}
if (!this.mcqSessions[session.notePath]) {
this.mcqSessions[session.notePath] = [];
}
// Update if exists, add if new
const existingIndex = this.mcqSessions[session.notePath].findIndex(
s => s && s.mcqSetId === session.mcqSetId && s.startedAt === session.startedAt
);
if (existingIndex >= 0) {
this.mcqSessions[session.notePath][existingIndex] = session;
} else {
this.mcqSessions[session.notePath].push(session);
}
// Limit the number of stored sessions per note (keep the most recent 10)
if (this.mcqSessions[session.notePath].length > 10) {
this.mcqSessions[session.notePath].sort((a, b) => b.startedAt - a.startedAt);
this.mcqSessions[session.notePath] = this.mcqSessions[session.notePath].slice(0, 10);
}
// Data saving is now handled by main.ts after this method returns
} catch (error) {
console.error('Error saving MCQ session:', error);
}
}
/**
* Get all MCQ sessions for a note
*
* @param notePath Path to the note
* @returns Array of MCQ sessions
*/
getMCQSessionsForNote(notePath: string): MCQSession[] {
return this.mcqSessions[notePath] || [];
}
/**
* Get the latest MCQ session for a note
*
* @param notePath Path to the note
* @returns Latest MCQ session or null
*/
getLatestMCQSessionForNote(notePath: string): MCQSession | null {
const sessions = this.getMCQSessionsForNote(notePath)
.sort((a, b) => b.startedAt - a.startedAt);
return sessions.length > 0 ? sessions[0] : null;
}
/**
* Flags an MCQ set for regeneration.
* This is typically called when a note's review rating meets certain criteria.
*
* @param notePath Path to the note whose MCQ set should be flagged.
*/
flagMCQSetForRegeneration(notePath: string): void {
const mcqSet = this.getMCQSetForNote(notePath);
if (mcqSet) {
mcqSet.needsQuestionRegeneration = true;
this.saveMCQSet(mcqSet); // Updates the in-memory reference; persistence handled by caller
console.log(`MCQSet for ${notePath} flagged for regeneration.`);
} else {
console.log(`No MCQSet found for ${notePath} to flag for regeneration.`);
}
}
}

View file

@ -0,0 +1,316 @@
import SpaceforgePlugin from "../main";
import { PluginStateData } from "../models/plugin-data";
import { SpaceforgeSettings } from "../models/settings";
export type PomodoroMode = 'work' | 'shortBreak' | 'longBreak' | 'idle';
export class PomodoroService {
private plugin: SpaceforgePlugin;
private timerInterval: number | null = null;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
// Initialize state from plugin.pluginState if needed, or rely on defaults
if (this.plugin.settings.pomodoroEnabled && this.plugin.pluginState.pomodoroIsRunning) {
// If resuming from a saved state where timer was running, recalculate timeLeft
this.recalculateTimeLeftFromEndTime();
this.startTimerInterval(); // Resume timer interval
}
}
private get settings(): SpaceforgeSettings {
return this.plugin.settings;
}
private get state(): PluginStateData {
return this.plugin.pluginState;
}
// --- Public API ---
public start(): void {
if (this.state.pomodoroIsRunning) return;
this.state.pomodoroIsRunning = true;
if (this.state.pomodoroCurrentMode === 'idle') {
this.switchToMode('work');
} else if (this.state.pomodoroTimeLeftInSeconds <= 0) {
// If timer ended and user clicks start, transition to next appropriate mode
this.handleTimerEnd();
}
// Calculate end time based on current timeLeft
this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1000;
this.startTimerInterval();
this.notifyUpdate();
this.plugin.savePluginData();
}
public stop(): void { // Pause functionality
if (!this.state.pomodoroIsRunning) return;
this.stopTimerInterval(); // Stop the interval first
this.state.pomodoroIsRunning = false;
// Calculate and store remaining time based on end time
if (this.state.pomodoroEndTimeMs) {
const remainingMs = this.state.pomodoroEndTimeMs - Date.now();
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1000));
}
// Clear the end time as it's no longer actively counting down
this.state.pomodoroEndTimeMs = null;
this.notifyUpdate();
this.plugin.savePluginData();
}
public resetCurrentSession(): void {
this.stopTimerInterval();
this.state.pomodoroIsRunning = false;
this.state.pomodoroEndTimeMs = null; // Clear end time on reset
this.resetTimeForMode(this.state.pomodoroCurrentMode === 'idle' ? 'work' : this.state.pomodoroCurrentMode);
this.notifyUpdate();
this.plugin.savePluginData();
}
public skipSession(): void {
this.stopTimerInterval();
this.state.pomodoroIsRunning = false; // Stop current timer before switching
this.handleTimerEnd(true); // true to indicate a skip
// handleTimerEnd will call start if it transitions to a new timed session
// If it transitions to idle, it remains stopped.
// If it transitions and starts, pomodoroIsRunning will be true.
// We need to ensure the interval is started if it's running.
if (this.state.pomodoroIsRunning) {
this.startTimerInterval();
}
this.notifyUpdate(); // handleTimerEnd also notifies, but an extra one here is fine
this.plugin.savePluginData();
}
public getFormattedTimeLeft(): string {
const minutes = Math.floor(this.state.pomodoroTimeLeftInSeconds / 60);
const seconds = this.state.pomodoroTimeLeftInSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
public updateDurations(work: number, short: number, long: number, sessions: number): void {
this.settings.pomodoroWorkDuration = work;
this.settings.pomodoroShortBreakDuration = short;
this.settings.pomodoroLongBreakDuration = long;
this.settings.pomodoroSessionsUntilLongBreak = sessions;
// 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.plugin.savePluginData(); // Save settings and potentially updated state
this.notifyUpdate();
}
// --- Internal Logic ---
private startTimerInterval(): void {
if (this.timerInterval !== null) {
window.clearInterval(this.timerInterval);
}
if (!this.settings.pomodoroEnabled || !this.state.pomodoroIsRunning) return;
this.timerInterval = window.setInterval(() => {
this.tick();
}, 1000);
}
private stopTimerInterval(): void {
if (this.timerInterval !== null) {
window.clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
private tick(): void {
if (!this.state.pomodoroIsRunning || !this.state.pomodoroEndTimeMs) {
// Should not happen if running, but safety check
this.stop(); // Stop if state is inconsistent
return;
}
const now = Date.now();
const remainingMs = this.state.pomodoroEndTimeMs - now;
const remainingSeconds = Math.max(0, Math.round(remainingMs / 1000));
this.state.pomodoroTimeLeftInSeconds = remainingSeconds;
if (remainingSeconds <= 0) {
this.handleTimerEnd();
// handleTimerEnd will notifyUpdate and savePluginData
} else {
this.notifyUpdate();
// No need to savePluginData on every tick
}
}
private handleTimerEnd(skipped: boolean = false): void {
this.stopTimerInterval(); // Stop the current interval
// pomodoroIsRunning might still be true if we auto-transition to a new session
if (this.settings.pomodoroSoundEnabled && !skipped) {
this.playSoundNotification();
}
const currentMode = this.state.pomodoroCurrentMode;
let nextMode: PomodoroMode = 'idle';
if (currentMode === 'work') {
this.state.pomodoroSessionsCompletedInCycle++;
if (this.state.pomodoroSessionsCompletedInCycle >= this.settings.pomodoroSessionsUntilLongBreak) {
nextMode = 'longBreak';
} else {
nextMode = 'shortBreak';
}
} else if (currentMode === 'shortBreak' || currentMode === 'longBreak') {
nextMode = 'work';
if (currentMode === 'longBreak') {
this.state.pomodoroSessionsCompletedInCycle = 0; // 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
if (nextMode !== 'idle') {
this.state.pomodoroIsRunning = true; // Auto-start next session
// 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();
} else if (!this.timerInterval) { // Or if interval somehow stopped but should be running
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
this.notifyUpdate();
}
private switchToMode(mode: PomodoroMode): void {
this.state.pomodoroCurrentMode = mode;
this.resetTimeForMode(mode);
}
private resetTimeForMode(mode: PomodoroMode): void {
switch (mode) {
case 'work':
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60;
break;
case 'shortBreak':
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroShortBreakDuration * 60;
break;
case 'longBreak':
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroLongBreakDuration * 60;
break;
case 'idle':
this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60; // Default to work duration when idle
break;
}
}
private playSoundNotification(): void {
// Simple beep sound using AudioContext
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
if (!audioContext) return;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine'; // sine, square, sawtooth, triangle
oscillator.frequency.setValueAtTime(440, audioContext.currentTime); // A4 note
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); // Volume
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.5); // Play for 0.5 seconds
} catch (e) {
console.error("Could not play sound notification:", e);
}
}
private notifyUpdate(): void {
this.plugin.events.emit('pomodoro-update');
}
// Call this when global settings change from the settings tab
public onSettingsChanged(): void {
if (!this.settings.pomodoroEnabled) {
this.stop();
this.state.pomodoroCurrentMode = 'idle';
this.resetTimeForMode('idle'); // Reset to default work time but keep idle
} else {
// If timer is not running, and current mode is an active timed mode
if (!this.state.pomodoroIsRunning) {
const currentMode = this.state.pomodoroCurrentMode;
const activeModes: PomodoroMode[] = ['work', 'shortBreak', 'longBreak'];
if (activeModes.includes(currentMode)) {
this.resetTimeForMode(currentMode);
}
}
}
this.notifyUpdate();
}
public destroy(): void {
this.stopTimerInterval();
}
// Recalculate time left based on stored end time, useful on load/reinit
private recalculateTimeLeftFromEndTime(): void {
if (this.state.pomodoroIsRunning && this.state.pomodoroEndTimeMs) {
const now = Date.now();
const remainingMs = this.state.pomodoroEndTimeMs - now;
this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1000));
if (remainingMs <= 0) {
// Timer should have ended while plugin was inactive/closed
// We could try to simulate the transition, but it gets complex.
// Simplest: Stop the timer, reset to the expected next state's duration.
console.log("Pomodoro timer ended while inactive. Handling potential transition.");
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 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.
}
// Call this after pluginState has been externally modified (e.g., by data import)
public reinitializeTimerFromState(): void {
this.stopTimerInterval(); // Stop any existing timer first
if (this.settings.pomodoroEnabled) {
// Recalculate time left based on potentially loaded end time
this.recalculateTimeLeftFromEndTime();
// Start interval only if it should be running *after* recalculation
if (this.state.pomodoroIsRunning) {
this.startTimerInterval();
}
} else {
// If Pomodoro is disabled, ensure it's stopped and idle
this.stop();
this.state.pomodoroCurrentMode = 'idle';
this.resetTimeForMode('idle');
this.state.pomodoroEndTimeMs = null;
}
this.notifyUpdate(); // Ensure UI reflects the potentially new state
}
}

View file

@ -0,0 +1,46 @@
import { ReviewHistoryItem } from '../models/review-schedule';
/**
* Handles management of review history data
*/
export class ReviewHistoryService {
/**
* Review history (This will be a reference to the history array in DataStorage)
*/
history: ReviewHistoryItem[];
/**
* Initialize Review History Service
*
* @param history Reference to the history array in DataStorage
*/
constructor(history: ReviewHistoryItem[]) {
this.history = history; // Store reference to the shared history array
}
/**
* Get review history for a specific note
*
* @param path Path to the note file
* @returns Array of review history items for the note
*/
getNoteHistory(path: string): ReviewHistoryItem[] {
return this.history
.filter(item => item.path === path)
.sort((a, b) => b.timestamp - a.timestamp);
}
/**
* Add a history item (used by ReviewScheduleService)
* This method is here to centralize history management, even if called from another service.
*
* @param item The history item to add
*/
addHistoryItem(item: ReviewHistoryItem): void {
this.history.push(item);
// Limit history size (keeping this logic here for now)
if (this.history.length > 1000) {
this.history = this.history.slice(-1000);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
import { Notice, TFolder } from 'obsidian';
import { ReviewSession, ReviewSessionStore, generateSessionId, getNextFileInSession, advanceSession, isSessionComplete } from '../models/review-session';
import SpaceforgePlugin from '../main';
import { LinkAnalyzer } from '../utils/link-analyzer';
/**
* Handles management of review sessions
*/
export class ReviewSessionService {
/**
* Reference to the main plugin
*/
private plugin: SpaceforgePlugin;
/**
* Review sessions store (This will be a reference to the reviewSessions object in DataStorage)
*/
reviewSessions: ReviewSessionStore;
/**
* Initialize Review Session Service
*
* @param plugin Reference to the main plugin
* @param reviewSessions Reference to the reviewSessions object in DataStorage
*/
constructor(plugin: SpaceforgePlugin, reviewSessions: ReviewSessionStore) {
this.plugin = plugin;
this.reviewSessions = reviewSessions; // Store reference to the shared reviewSessions object
}
/**
* Create a new review session for a folder
*
* @param folderPath Path to the folder
* @param name Name for the session
* @returns Created review session or null if failed
*/
async createReviewSession(folderPath: string, name: string): Promise<ReviewSession | null> {
const folder = this.plugin.app.vault.getAbstractFileByPath(folderPath);
if (!folder || !(folder instanceof TFolder)) {
new Notice("Invalid folder for review session");
return null;
}
try {
// Analyze the folder to create a hierarchy
const includeSubfolders = this.plugin.settings.includeSubfolders;
const hierarchy = await LinkAnalyzer.analyzeFolder(
this.plugin.app.vault,
folder,
includeSubfolders
);
// Generate a unique ID for the session
const id = generateSessionId(folder.name);
// Create the session
const session: ReviewSession = {
id,
name: name || folder.name,
path: folderPath,
hierarchy,
currentIndex: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
isActive: false
};
// Add to sessions store (using the shared reviewSessions object)
this.reviewSessions.sessions[id] = session;
// Data saving is now handled by main.ts after this method returns
// Notify of update
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
return session;
} catch (error) {
console.error("Error creating review session:", error);
new Notice("Failed to create review session");
return null;
}
}
/**
* Set the active review session
*
* @param sessionId ID of the session to activate
* @returns Whether the session was activated
*/
async setActiveSession(sessionId: string | null): Promise<boolean> {
if (sessionId === null) {
this.reviewSessions.activeSessionId = null;
// Data saving is now handled by main.ts after this method returns
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
return true;
}
const session = this.reviewSessions.sessions[sessionId];
if (!session) {
return false;
}
// Update active session (using the shared reviewSessions object)
this.reviewSessions.activeSessionId = sessionId;
// Mark the session as active
session.isActive = true;
session.updatedAt = Date.now();
// Data saving is now handled by main.ts after this method returns
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
return true;
}
/**
* Get the active review session
*
* @returns Active review session or null if none
*/
getActiveSession(): ReviewSession | null {
const id = this.reviewSessions.activeSessionId;
if (!id) {
return null;
}
return this.reviewSessions.sessions[id] || null;
}
/**
* Get the next file to review in the active session
*
* @returns Path to the next file or null if done
*/
getNextSessionFile(): string | null {
const session = this.getActiveSession();
if (!session) {
return null;
}
return getNextFileInSession(session);
}
/**
* Advance to the next file in the active session
*
* @returns Whether there are more files to review
*/
async advanceActiveSession(): Promise<boolean> {
const session = this.getActiveSession();
if (!session) {
return false;
}
// Update the session (using the shared reviewSessions object)
const updatedSession = advanceSession(session);
this.reviewSessions.sessions[session.id] = updatedSession;
// If the session is complete, deactivate it
if (isSessionComplete(updatedSession)) {
updatedSession.isActive = false;
this.reviewSessions.activeSessionId = null;
// Show completion notification
new Notice(`Completed review session: ${updatedSession.name}`);
}
// Data saving is now handled by main.ts after this method returns
if (this.plugin.events) {
this.plugin.events.emit('sidebar-update');
}
return !isSessionComplete(updatedSession);
}
/**
* Schedule all files in a session for review
* (This method depends on ReviewScheduleService, will need to pass it in or access via plugin)
*
* @param sessionId ID of the session
* @returns Number of files scheduled
*/
async scheduleSessionForReview(sessionId: string): Promise<number> {
const session = this.reviewSessions.sessions[sessionId];
if (!session) {
return 0;
}
// Access ReviewScheduleService via plugin reference
if (!this.plugin.reviewScheduleService) {
console.error("ReviewScheduleService not available on plugin instance.");
return 0;
}
// Call the method on the ReviewScheduleService instance
return await this.plugin.reviewScheduleService.scheduleNotesInOrder(session.hierarchy.traversalOrder);
}
}

3165
styles.css Normal file

File diff suppressed because it is too large Load diff

67
styles/_animations.css Normal file
View file

@ -0,0 +1,67 @@
/* Spaceforge - Animations and Common Elements */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0.8; transform: scale(1); }
}
@keyframes shimmer {
0% { background-position: -100% 0; }
100% { background-position: 200% 0; }
}
@keyframes progressDots {
from { background-position: 0 0; }
to { background-position: 20px 0; }
}
@keyframes celebrationConfetti {
0% {
opacity: 0;
transform: translateY(0) rotate(0deg);
}
10% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(-100px) rotate(720deg);
}
}
@keyframes correctPulse {
0% { box-shadow: 0 0 0 0 rgba(56, 161, 105, 0.3); }
100% { box-shadow: 0 2px 8px rgba(56, 161, 105, 0.1); }
}
@keyframes ripple {
0% {
opacity: 1;
transform: scale(0, 0) translate(-50%, -50%);
}
100% {
opacity: 0;
transform: scale(20, 20) translate(-50%, -50%);
}
}
@keyframes keyPress {
0% { transform: scale(0.98); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
/* Animation classes */
.sf-animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
.sf-animate-pulse {
animation: pulse 2s infinite;
}

81
styles/_buttons.css Normal file
View file

@ -0,0 +1,81 @@
/* Spaceforge - Buttons & Interactive Elements */
.sf-btn {
padding: 8px 16px;
border-radius: var(--sf-radius-md);
font-weight: 500;
cursor: pointer;
transition: var(--sf-transition);
border: none;
font-size: 14px;
line-height: 1.4;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
}
.sf-btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
box-shadow: var(--sf-shadow);
}
.sf-btn:active {
transform: translateY(0);
}
.sf-btn-primary {
background-color: var(--sf-primary);
color: var(--sf-text-on-primary);
}
.sf-btn-success {
background-color: var(--sf-success);
color: white;
}
.sf-btn-warning {
background-color: var(--sf-warning);
color: white;
}
.sf-btn-danger {
background-color: var(--sf-danger);
color: white;
}
.sf-btn-ghost {
background-color: transparent;
color: var(--sf-text);
border: 1px solid var(--background-modifier-border);
}
.sf-btn-ghost:hover {
background-color: var(--sf-bg-modifier);
}
.sf-btn-block {
display: flex;
width: 100%;
}
.sf-icon-btn {
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: transparent;
color: var(--sf-text-muted);
cursor: pointer;
transition: var(--sf-transition);
}
.sf-icon-btn:hover {
color: var(--sf-primary);
background-color: var(--sf-primary-light);
}

161
styles/_components.css Normal file
View file

@ -0,0 +1,161 @@
/* Spaceforge - Cards, Containers, Badges & Components */
/* ====== Cards & Containers ====== */
.sf-card {
background-color: var(--sf-bg-secondary);
border-radius: var(--sf-radius-md);
padding: var(--sf-space-md);
box-shadow: var(--sf-shadow);
transition: var(--sf-transition);
}
.sf-card:hover {
box-shadow: var(--sf-shadow-lg);
}
.sf-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--sf-space-md);
padding-bottom: var(--sf-space-sm);
border-bottom: 1px solid var(--background-modifier-border);
}
.sf-card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
/* ====== Badges & Tags ====== */
.sf-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
}
.sf-badge-primary {
background-color: var(--sf-primary-light);
color: var(--sf-primary);
}
.sf-badge-success {
background-color: var(--sf-success-light);
color: var(--sf-success);
}
.sf-badge-warning {
background-color: var(--sf-warning-light);
color: var(--sf-warning);
}
.sf-badge-danger {
background-color: var(--sf-danger-light);
color: var(--sf-danger);
}
/* ====== Progress Bars ====== */
.sf-progress-container {
width: 100%;
height: 8px;
background-color: var(--background-modifier-border);
border-radius: 4px;
overflow: hidden;
margin: var(--sf-space-md) 0;
}
.sf-progress-bar {
height: 100%;
background-color: var(--sf-primary);
transition: width 0.5s ease;
position: relative;
overflow: hidden;
}
.sf-progress-bar::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite linear;
}
.sf-progress-bar-success {
background-color: var(--sf-success);
}
.sf-progress-bar-warning {
background-color: var(--sf-warning);
}
.sf-progress-bar-danger {
background-color: var(--sf-danger);
}
/* Enhanced time indicators with modern styling */
.review-time-short {
color: #38a169; /* Modern green */
font-weight: 500;
background-color: rgba(56, 161, 105, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.review-time-medium {
color: #dd6b20; /* Modern orange */
font-weight: 500;
background-color: rgba(221, 107, 32, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.review-time-long {
color: #e53e3e; /* Modern red */
font-weight: 500;
background-color: rgba(229, 62, 62, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.review-phase-initial {
color: var(--text-accent); /* Use Obsidian accent color */
font-weight: 500;
background-color: var(--background-modifier-success-hover);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.review-phase-spaced {
color: var(--text-success, #50fa7b); /* Use Obsidian success color */
font-weight: 500;
background-color: var(--background-modifier-success-hover);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.phase-time {
font-size: 10px;
color: var(--sf-text-muted);
margin-top: 2px;
}

65
styles/_variables.css Normal file
View file

@ -0,0 +1,65 @@
/* Spaceforge - CSS Variables & Theme Integration */
:root {
/* Primary theme colors - will inherit from Obsidian theme */
--sf-primary: var(--interactive-accent, #5e81ac);
--sf-primary-light: color-mix(in srgb, var(--sf-primary), white 75%); /* Adjusted for less white in light mode */
--sf-primary-dark: color-mix(in srgb, var(--sf-primary), black 20%);
--sf-secondary: var(--text-accent, #7ba1df);
/* Semantic colors */
--sf-success: #4caf50;
--sf-success-light: rgba(76, 175, 80, 0.15); /* Adjusted opacity */
--sf-warning: #ff9800;
--sf-warning-light: rgba(255, 152, 0, 0.15); /* Adjusted opacity */
--sf-danger: #f44336;
--sf-danger-light: rgba(244, 67, 54, 0.15); /* Adjusted opacity */
--sf-info: #2196f3;
--sf-info-light: rgba(33, 150, 243, 0.15); /* Adjusted opacity */
/* Text colors */
--sf-text: var(--text-normal, #333);
--sf-text-muted: var(--text-muted, #888);
--sf-text-on-primary: var(--text-on-accent, white);
/* Background colors */
--sf-bg-primary: var(--background-primary, white);
--sf-bg-secondary: var(--background-secondary, #f5f5f5);
--sf-bg-modifier: var(--background-modifier-hover);
/* Spacing */
--sf-space-xs: 4px;
--sf-space-sm: 8px;
--sf-space-md: 16px;
--sf-space-lg: 24px;
--sf-space-xl: 32px;
/* Card styling */
--sf-radius-sm: 4px;
--sf-radius-md: 8px;
--sf-radius-lg: 12px;
--sf-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--sf-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.12);
/* Transitions */
--sf-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--sf-transition: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--sf-transition-bounce: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Dark mode adjustments */
.theme-dark {
--sf-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
--sf-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.3);
--sf-primary-light: color-mix(in srgb, var(--sf-primary), black 65%); /* Adjusted for less black in dark mode */
--sf-success-light: rgba(76, 175, 80, 0.2); /* Adjusted opacity */
--sf-warning-light: rgba(255, 152, 0, 0.2); /* Adjusted opacity */
--sf-danger-light: rgba(244, 67, 54, 0.2); /* Adjusted opacity */
--sf-info-light: rgba(33, 150, 243, 0.2); /* Adjusted opacity */
--sf-bg-primary: var(--background-primary-alt);
--sf-bg-secondary: var(--background-secondary-alt);
}
.theme-dark .mcq-question-text {
color: var(--text-normal); /* Ensure question text is visible in dark mode */
}

514
styles/calendar.css Normal file
View file

@ -0,0 +1,514 @@
/* Spaceforge - Calendar View Styles */
.calendar-container-wrapper {
height: 100%;
animation: fadeIn 0.5s ease-out;
}
.calendar-container {
padding: var(--sf-space-md);
width: 100%;
background-color: var(--background-primary);
border-radius: var(--radius-m, 8px);
box-shadow: var(--sf-shadow-md); /* Added subtle shadow */
}
/* Styles for collapsed sidebar */
.is-collapsed .calendar-container-wrapper {
/* Adjust styles for a narrower container */
}
.is-collapsed .calendar-container {
padding: var(--sf-space-sm); /* Reduced padding */
}
.is-collapsed .calendar-header {
margin-bottom: var(--sf-space-sm); /* Reduced margin */
}
.is-collapsed .calendar-month-title {
font-size: 16px; /* Smaller font size */
}
.is-collapsed .calendar-nav-btn {
padding: 4px 8px; /* Reduced padding */
font-size: 13px; /* Smaller font size */
}
.is-collapsed .calendar-today-btn {
margin-left: 5px; /* Reduced margin */
padding: 4px 8px; /* Reduced padding */
font-size: 12px; /* Smaller font size */
}
.is-collapsed .calendar-grid {
gap: 1px; /* Further reduced gap */
grid-auto-rows: minmax(40px, auto); /* Further reduced minimum height */
}
.is-collapsed .calendar-weekday {
font-size: 10px; /* Further smaller font size */
padding: 2px 0; /* Further reduced padding */
}
.is-collapsed .calendar-day {
min-height: 40px; /* Further reduced minimum height */
padding: 2px; /* Further reduced padding */
font-size: 10px; /* Further smaller font size */
border-radius: 3px; /* Further smaller border radius */
}
.is-collapsed .calendar-day-number {
font-size: 11px; /* Further smaller font size */
margin-bottom: 1px; /* Further reduced margin */
}
.is-collapsed .calendar-day.today .calendar-day-number::after {
width: 10px; /* Further reduced underline width */
}
.is-collapsed .calendar-review-count {
width: 16px; /* Further smaller size */
height: 16px; /* Further smaller size */
font-size: 8px; /* Further smaller font size */
top: 2px; /* Adjusted position */
right: 2px; /* Adjusted position */
}
.is-collapsed .calendar-time-estimate {
font-size: 8px; /* Further smaller font size */
padding: 0 2px; /* Further reduced padding */
}
.is-collapsed .calendar-day {
min-height: 40px; /* Reduced minimum height */
padding: 4px; /* Reduced padding */
font-size: 12px; /* Smaller font size */
border-radius: 4px; /* Smaller border radius */
}
.is-collapsed .calendar-day-number {
font-size: 13px; /* Smaller font size */
margin-bottom: 2px; /* Reduced margin */
}
.is-collapsed .calendar-day.today .calendar-day-number::after {
width: 12px; /* Reduced underline width */
}
.is-collapsed .calendar-review-count {
width: 20px; /* Smaller size */
height: 20px; /* Smaller size */
font-size: 10px; /* Smaller font size */
top: 4px; /* Adjusted position */
right: 4px; /* Adjusted position */
}
.is-collapsed .calendar-time-estimate {
font-size: 10px; /* Smaller font size */
padding: 1px 3px; /* Reduced padding */
}
/* Mobile optimization */
@media (max-width: 768px) {
.calendar-grid {
gap: 2px;
grid-template-columns: repeat(7, 1fr); /* Ensure 7 equal columns on smaller screens */
}
.calendar-day {
min-height: 30px; /* Further adjusted minimum height */
padding: 3px; /* Reduced padding */
font-size: 11px; /* Slightly smaller font */
border-radius: 4px; /* Smaller border radius */
aspect-ratio: auto; /* Allow aspect ratio to adjust */
}
.calendar-weekday {
font-size: 10px; /* Slightly smaller font */
padding: 3px 0; /* Reduced padding */
}
.calendar-day-number {
font-size: 12px; /* Adjusted font size */
margin-bottom: 3px; /* Adjusted margin */
}
.calendar-review-count {
width: 18px; /* Adjusted size */
height: 18px; /* Adjusted size */
font-size: 9px; /* Adjusted font size */
top: 3px; /* Adjusted position */
right: 3px; /* Adjusted position */
}
.calendar-time-estimate {
font-size: 9px; /* Adjusted font size */
padding: 1px 3px; /* Adjusted padding */
}
}
.calendar-header {
display: flex;
align-items: center;
margin-bottom: var(--sf-space-md);
padding-bottom: 5px;
border-bottom: 1px solid var(--background-modifier-border);
}
.calendar-month-title {
flex: 1;
text-align: center;
font-weight: 600;
font-size: 18px; /* Slightly larger font */
color: var(--text-normal);
}
.calendar-nav-btn {
cursor: pointer;
padding: 6px 12px; /* Increased padding */
border-radius: var(--sf-radius-sm);
background-color: transparent;
transition: var(--sf-transition);
color: var(--text-muted);
font-size: 15px; /* Slightly larger font */
}
.calendar-nav-btn:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.calendar-today-btn {
margin-left: 10px;
font-size: 14px;
cursor: pointer;
padding: 6px 12px;
border-radius: 20px; /* More rounded */
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
transition: var(--sf-transition);
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.calendar-today-btn:hover {
background-color: var(--interactive-accent-hover);
transform: translateY(-1px);
}
/* Calendar grid with equal-sized cells */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr); /* Ensure 7 equal columns */
grid-auto-rows: minmax(50px, auto); /* Force equal heights */
gap: 4px; /* Consistent spacing */
margin-top: var(--sf-space-md);
}
.calendar-weekday {
text-align: center;
font-weight: 600;
font-size: 14px;
padding: 8px 0;
color: var(--text-muted);
}
/* Standardized calendar day cells */
.calendar-day {
position: relative;
height: auto; /* Changed from fixed height */
min-height: 50px; /* Minimum height */
width: 100%; /* Ensure full width */
padding: 8px; /* Increased padding */
border: 1px solid var(--background-modifier-border);
border-radius: 8px; /* Slightly more rounded */
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
background-color: var(--background-secondary-alt);
box-sizing: border-box; /* Include borders in size calculation */
}
.calendar-day:hover {
border-color: var(--interactive-accent);
box-shadow: var(--sf-shadow-lg); /* Using defined shadow variable */
transform: translateY(-2px);
z-index: 10;
}
.calendar-day.empty {
background-color: var(--background-secondary);
cursor: default;
opacity: 0.5;
}
.calendar-day.empty:hover {
transform: none;
box-shadow: none;
border-color: var(--background-modifier-border);
}
.calendar-day.today {
background-color: rgba(var(--interactive-accent-rgb), 0.15);
border-color: var(--interactive-accent);
font-weight: 600;
box-shadow: var(--sf-shadow-sm); /* Added subtle shadow for today */
}
.calendar-day.has-reviews {
background-color: rgba(var(--interactive-accent-rgb), 0.1);
border-color: var(--interactive-accent);
box-shadow: var(--sf-shadow-sm); /* Added subtle shadow for days with reviews */
}
.calendar-day.has-reviews:hover {
background-color: rgba(var(--interactive-accent-rgb), 0.2);
}
.calendar-day-number {
font-weight: 600;
margin-bottom: 6px;
font-size: 15px;
}
.calendar-day.today .calendar-day-number {
color: var(--interactive-accent);
position: relative;
}
.calendar-day.today .calendar-day-number::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
width: 16px;
background-color: var(--interactive-accent);
border-radius: 1px;
}
/* Colored review count indicators with fade */
.calendar-day.light-load .calendar-review-count {
background-color: var(--sf-success); /* Green */
animation: fadeScale 0.3s ease-out forwards;
}
.calendar-day.medium-load .calendar-review-count {
background-color: var(--sf-warning); /* Orange */
animation: fadeScale 0.3s ease-out forwards;
}
.calendar-day.heavy-load .calendar-review-count {
background-color: var(--sf-danger); /* Red */
animation: fadeScale 0.3s ease-out forwards;
}
/* Improved review count indicator */
.calendar-review-count {
position: absolute;
top: 8px; /* Adjusted position */
right: 8px; /* Adjusted position */
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
color: white;
box-shadow: var(--sf-shadow-sm); /* Using defined shadow variable */
transition: transform 0.2s ease;
}
.calendar-day:hover .calendar-review-count {
transform: scale(1.1);
}
/* Improved time estimate with fade */
.calendar-time-estimate {
margin-top: auto; /* Push to bottom */
align-self: flex-end; /* Align to the right */
font-size: 11px; /* Slightly smaller font */
font-weight: 500;
color: var(--text-muted);
padding: 2px 5px; /* Adjusted padding */
background-color: var(--background-modifier-hover); /* Using theme variable */
border-radius: 4px;
opacity: 1;
transition: opacity 0.2s ease;
}
.day-reviews-container {
padding: var(--sf-space-md);
animation: fadeIn 0.4s ease-out;
}
.day-reviews-header {
display: flex;
align-items: center;
margin-bottom: var(--sf-space-md);
}
.day-reviews-back-btn {
margin-right: 10px;
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: var(--sf-transition);
background-color: var(--sf-bg-secondary);
color: var(--sf-text-muted);
}
.day-reviews-back-btn:hover {
background-color: var(--sf-primary-light);
color: var(--sf-primary);
}
.day-reviews-header-content {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
.day-reviews-date {
font-weight: 600;
color: var(--sf-primary);
font-size: 16px;
}
.day-reviews-postpone-all {
padding: 4px 10px;
font-size: 12px;
border-radius: var(--sf-radius-sm);
cursor: pointer;
background-color: var(--sf-bg-secondary);
color: var(--sf-text);
border: 1px solid var(--background-modifier-border);
transition: var(--sf-transition);
}
.day-reviews-postpone-all:hover {
background-color: var(--sf-warning);
color: white;
border-color: var(--sf-warning);
}
.day-reviews-stats {
margin-bottom: var(--sf-space-md);
font-size: 14px;
color: var(--sf-text-muted);
padding: var(--sf-space-sm) var(--sf-space-md);
background-color: var(--sf-bg-secondary);
border-radius: var(--sf-radius-md);
display: flex;
justify-content: space-between;
align-items: center;
}
.day-reviews-count {
font-weight: 500;
color: var(--sf-primary);
}
.day-reviews-time {
font-style: italic;
}
.day-reviews-all-button {
width: 100%;
padding: var(--sf-space-sm) var(--sf-space-md);
margin-bottom: var(--sf-space-md);
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
background-color: var(--sf-primary);
color: var(--sf-text-on-primary);
border: none;
transition: var(--sf-transition);
box-shadow: var(--sf-shadow);
}
.day-reviews-all-button:hover {
transform: translateY(-2px);
box-shadow: var(--sf-shadow-lg);
filter: brightness(1.05);
}
.day-reviews-notes {
margin-top: var(--sf-space-md);
display: flex;
flex-direction: column;
gap: var(--sf-space-sm);
}
.day-reviews-skip-next-button {
width: 100%;
padding: var(--sf-space-sm) var(--sf-space-md);
margin-bottom: var(--sf-space-md);
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
background-color: var(--warning-color, #e78a4e);
color: white;
border: none;
transition: var(--sf-transition);
box-shadow: var(--sf-shadow);
margin-top: var(--sf-space-sm);
}
.day-reviews-skip-next-button:hover {
transform: translateY(-2px);
box-shadow: var(--sf-shadow-lg);
filter: brightness(1.05);
}
/* Mobile optimization */
@media (max-width: 768px) {
.calendar-grid {
gap: 2px;
grid-template-columns: repeat(7, 1fr); /* Ensure 7 equal columns on smaller screens */
}
.calendar-day {
min-height: 30px; /* Further adjusted minimum height */
padding: 3px; /* Reduced padding */
font-size: 11px; /* Slightly smaller font */
border-radius: 4px; /* Smaller border radius */
aspect-ratio: auto; /* Allow aspect ratio to adjust */
}
.calendar-weekday {
font-size: 10px; /* Slightly smaller font */
padding: 3px 0; /* Reduced padding */
}
.calendar-day-number {
font-size: 12px; /* Adjusted font size */
margin-bottom: 3px; /* Adjusted margin */
}
.calendar-review-count {
width: 18px; /* Adjusted size */
height: 18px; /* Adjusted size */
font-size: 9px; /* Adjusted font size */
top: 3px; /* Adjusted position */
right: 3px; /* Adjusted position */
}
.calendar-time-estimate {
font-size: 9px; /* Adjusted font size */
padding: 1px 3px; /* Adjusted padding */
}
}

1015
styles/mcq.css Normal file

File diff suppressed because it is too large Load diff

225
styles/pomodoro.css Normal file
View file

@ -0,0 +1,225 @@
/* Pomodoro Styles */
.pomodoro-visibility-toggle-container {
display: none !important; /* Hide the old toggle button */
}
.sidebar-pomodoro-section-container {
width: 100%; /* Make it fit the width of its parent in the sidebar */
margin-top: 8px; /* Space above Pomodoro section */
margin-bottom: 8px; /* Space below Pomodoro section */
padding: 8px; /* Padding inside the Pomodoro container */
background-color: var(--background-secondary); /* Match sidebar section bg */
border-radius: var(--radius-m); /* Consistent border radius */
border: 1px solid var(--background-modifier-border); /* Consistent border */
box-sizing: border-box; /* Ensure padding and border are within width */
}
.pomodoro-visibility-toggle-container {
margin-bottom: 8px; /* Space between toggle and timer content */
}
.pomodoro-visibility-toggle-container .pomodoro-visibility-toggle-btn {
width: 100%;
padding: 6px 12px;
background-color: var(--background-modifier-border); /* Subtle background */
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
font-weight: 500;
text-align: center;
cursor: pointer;
transition: var(--sf-transition);
}
.pomodoro-visibility-toggle-container .pomodoro-visibility-toggle-btn:hover {
background-color: var(--background-modifier-hover);
}
/* .pomodoro-section-content is pomodoroRootEl, container for timer and quick settings */
/* No specific styles needed here unless for overriding children */
.pomodoro-main-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px; /* Space between control elements */
width: 100%;
}
.pomodoro-main-controls .pomodoro-start-btn,
.pomodoro-main-controls .pomodoro-stop-btn,
.pomodoro-main-controls .pomodoro-skip-btn {
padding: 6px; /* Smaller padding for icon buttons */
border-radius: var(--radius-s);
background-color: var(--background-secondary-alt);
border: 1px solid var(--background-modifier-border);
color: var(--text-normal);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--sf-transition);
}
.pomodoro-main-controls .pomodoro-start-btn:hover,
.pomodoro-main-controls .pomodoro-stop-btn:hover,
.pomodoro-main-controls .pomodoro-skip-btn:hover {
background-color: var(--background-modifier-hover);
border-color: var(--interactive-accent-hover); /* Use a variable if possible */
}
.pomodoro-main-controls .pomodoro-start-btn svg,
.pomodoro-main-controls .pomodoro-stop-btn svg,
.pomodoro-main-controls .pomodoro-skip-btn svg {
width: 18px; /* Adjust icon size */
height: 18px;
}
.pomodoro-timer-display {
font-size: 20px;
font-weight: 600;
font-family: monospace;
color: var(--text-normal);
padding: 4px 10px;
border-radius: var(--radius-s);
background-color: var(--background-primary);
min-width: 60px;
text-align: center;
flex-grow: 1;
margin: 0 5px;
border: 1px solid var(--background-modifier-border);
cursor: pointer; /* Make it look clickable */
transition: background-color 0.2s ease-in-out; /* Subtle hover for timer display */
}
.pomodoro-timer-display:hover {
background-color: var(--background-secondary-alt); /* Subtle hover */
}
.pomodoro-timer-display.mode-work {
border-left-color: var(--interactive-accent); /* Use existing variable */
border-right-color: var(--interactive-accent);
}
.pomodoro-timer-display.mode-shortBreak {
border-left-color: var(--sf-success); /* Use existing variable from _variables.css */
border-right-color: var(--sf-success);
}
.pomodoro-timer-display.mode-longBreak {
border-left-color: var(--sf-info); /* Use existing variable from _variables.css */
border-right-color: var(--sf-info);
}
.pomodoro-timer-display.mode-idle {
border-left-color: var(--text-muted);
border-right-color: var(--text-muted);
color: var(--text-muted);
}
/* Removed .pomodoro-quick-settings-toggle styles as the element is gone */
/* New container for the settings panel to allow absolute positioning of the panel itself */
.pomodoro-settings-panel-container {
position: relative; /* Anchor for the absolute positioned panel */
width: 100%;
z-index: 10; /* Ensure dropdown is above other elements if necessary */
}
.pomodoro-quick-settings-panel {
position: absolute;
top: 0; /* Position it right below where the toggle was (conceptually) */
left: 0;
right: 0;
background-color: var(--background-secondary); /* Modern dropdown bg */
border: 1px solid var(--background-modifier-border);
border-top: none; /* Remove top border as it connects visually */
border-radius: 0 0 var(--radius-m) var(--radius-m); /* Rounded bottom corners */
box-shadow: 0 4px 12px rgba(0,0,0,0.15); /* Modern shadow */
padding: 0; /* Initial padding for transition */
/* display: flex; is handled by JS */
flex-direction: column;
gap: 8px; /* Increased gap for better spacing */
overflow: hidden; /* Crucial for max-height transition */
max-height: 0; /* Initially hidden */
opacity: 0; /* Initially transparent */
transition: max-height 0.35s cubic-bezier(0.25, 0.1, 0.25, 1), /* Smoother ease */
opacity 0.3s ease-out,
padding 0.35s cubic-bezier(0.25, 0.1, 0.25, 1);
/* margin-top: 0; /* Removed original margin-top */
}
/* Styles when the panel is open (display: flex is set by JS) */
.pomodoro-quick-settings-panel[style*="display: flex"] {
padding: 12px; /* Restored padding */
max-height: 500px; /* Large enough to fit content */
opacity: 1;
border-top: 1px solid var(--background-modifier-border); /* Add a separator line when open */
}
.pomodoro-quick-setting { /* Added for individual setting item styling */
display: flex;
justify-content: space-between;
align-items: center;
}
.pomodoro-quick-setting label {
font-size: 13px;
color: var(--text-muted); /* Ensure consistency */
}
.pomodoro-quick-setting input[type=number] {
width: 50px;
background-color: var(--background-secondary); /* Ensure consistency */
border: 1px solid var(--background-modifier-border); /* Ensure consistency */
border-radius: var(--radius-s); /* Ensure consistency */
padding: 4px 6px; /* Ensure consistency */
text-align: right; /* Ensure consistency */
}
.pomodoro-quick-settings-buttons {
display: flex;
justify-content: flex-end; /* Align buttons to the right */
align-items: center; /* Align children along their text baseline */
gap: 8px; /* Space between buttons */
margin-top: 8px;
}
.pomodoro-quick-save-btn, .pomodoro-quick-calculate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
font-size: 13px;
line-height: 1.3; /* Normalize line-height for consistent baseline */
border-radius: var(--radius-s);
cursor: pointer;
transition: var(--sf-transition);
border: none;
box-sizing: border-box; /* Important for consistent box model */
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
text-align: center; /* Fallback text alignment */
}
.pomodoro-quick-save-btn {
/* background-color: var(--interactive-accent); */
/* color: var(--text-on-accent); */
display: none; /* Hide the save button */
}
.pomodoro-quick-save-btn:hover {
/* background-color: var(--interactive-accent-hover); */ /* Use existing variable if available */
}
.pomodoro-quick-calculate-btn {
background-color: var(--background-modifier-border);
color: var(--text-normal);
/* position: relative; */ /* Allow fine-tuning of position */
/* top: -1px; */ /* Nudge it up slightly */
}
.pomodoro-quick-calculate-btn:hover {
background-color: var(--background-modifier-hover);
}
.pomodoro-calculation-result {
font-size: 0.9em;
margin-top: 8px; /* Increased margin */
padding: 8px; /* Increased padding */
background-color: var(--background-secondary-alt);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border); /* Add border for definition */
}
.pomodoro-calculation-result p {
margin: 4px 0; /* Spacing for paragraphs within result */
}

175
styles/responsive.css Normal file
View file

@ -0,0 +1,175 @@
/* Spaceforge - Responsive Design Styles */
/* Tablet and smaller devices */
@media (max-width: 768px) {
.sf-space-lg {
--sf-space-lg: 16px;
}
.sf-space-xl {
--sf-space-xl: 24px;
}
.mcq-header-container h2 {
font-size: 1.5rem;
}
.mcq-question-text {
font-size: 1.1em;
}
.mcq-choice-btn {
padding: var(--sf-space-sm) var(--sf-space-md);
}
.mcq-score-text {
font-size: 1.6em;
}
.review-note-item {
flex-direction: column;
align-items: flex-start;
}
.review-note-title {
margin-right: 0;
margin-bottom: 5px;
width: 100%;
}
.review-note-info {
margin-right: 0;
margin-bottom: 5px;
width: 100%;
}
.review-note-buttons {
width: 100%;
justify-content: space-between;
}
.review-note-actions {
flex-grow: 1;
justify-content: flex-start;
}
.review-note-drag-handle {
margin-left: 0;
}
.sf-setting-grid {
grid-template-columns: 1fr;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
}
.setting-item-info {
margin-bottom: 8px;
}
.setting-item-control {
width: 100%;
}
}
/* Mobile devices */
@media (max-width: 480px) {
.mcq-header-container h2 {
font-size: 1.3rem;
}
.mcq-refresh-btn {
width: 30px;
height: 30px;
}
.review-stats {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.review-view-toggle {
margin-top: 12px;
}
.calendar-day {
min-height: 40px;
padding: 2px;
}
.calendar-weekday {
font-size: 10px;
}
.calendar-review-count {
width: 16px;
height: 16px;
font-size: 9px;
}
.review-date-header {
flex-direction: column;
align-items: flex-start;
}
.review-date-postpone-all {
margin-top: 8px;
}
.sf-settings-section-header h3 {
font-size: 16px;
}
.sf-settings-section-content {
padding-left: 0;
}
}
/* Landscape mode optimization */
@media (max-height: 580px) and (orientation: landscape) {
.mcq-question-container {
max-height: 60vh;
min-height: 200px;
}
.mcq-choice-btn {
min-height: 50px;
padding: 10px 16px;
}
.calendar-day {
height: 40px;
}
.mcq-progress {
margin-bottom: 12px;
height: 24px;
}
}
/* Large screens optimization */
@media (min-width: 1600px) {
.sf-space-lg {
--sf-space-lg: 28px;
}
.sf-space-xl {
--sf-space-xl: 36px;
}
.mcq-question-container {
max-height: 1000px;
}
.calendar-grid {
grid-auto-rows: minmax(90px, auto);
}
.calendar-day {
height: 90px;
}
}

261
styles/review-buttons.css Normal file
View file

@ -0,0 +1,261 @@
/* Spaceforge - Review Button Styles */
.review-buttons-container {
display: flex;
flex-direction: column;
gap: var(--sf-space-sm);
margin: var(--sf-space-md) 0;
}
.review-button-complete-blackout,
.review-button-incorrect,
.review-button-incorrect-familiar,
.review-button-correct-difficulty,
.review-button-correct-hesitation,
.review-button-perfect-recall,
.review-button-hard,
.review-button-fair,
.review-button-good,
.review-button-perfect,
.review-button-postpone,
.review-button-skip,
.review-button-mcq {
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
border-radius: var(--sf-radius-md);
cursor: pointer;
transition: var(--sf-transition);
border: none;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
/* Modern color palette for review buttons */
.review-button-complete-blackout {
background-color: #e53e3e; /* Modern red */
color: white;
box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2);
}
.review-button-incorrect {
background-color: #e53e3e; /* Modern red */
color: white;
box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2);
}
.review-button-incorrect-familiar {
background-color: #feb2b2; /* Light red */
color: #c53030; /* Dark red */
box-shadow: 0 1px 3px rgba(229, 62, 62, 0.1);
}
.review-button-correct-difficulty {
background-color: #fbd38d; /* Light amber */
color: #c05621; /* Dark amber */
box-shadow: 0 1px 3px rgba(192, 86, 33, 0.1);
}
.review-button-correct-hesitation {
background-color: #c6f6d5; /* Light green */
color: #2f855a; /* Dark green */
box-shadow: 0 1px 3px rgba(47, 133, 90, 0.1);
}
.review-button-perfect-recall {
background-color: #38a169; /* Modern green */
color: white;
box-shadow: 0 1px 3px rgba(56, 161, 105, 0.2);
}
/* Legacy button styles with modern colors */
.review-button-hard {
background-color: #e53e3e; /* Modern red */
color: white;
box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2);
}
.review-button-fair {
background-color: #fbd38d; /* Light amber */
color: #c05621; /* Dark amber */
box-shadow: 0 1px 3px rgba(192, 86, 33, 0.1);
}
.review-button-good {
background-color: #c6f6d5; /* Light green */
color: #2f855a; /* Dark green */
box-shadow: 0 1px 3px rgba(47, 133, 90, 0.1);
}
.review-button-perfect {
background-color: #38a169; /* Modern green */
color: white;
box-shadow: 0 1px 3px rgba(56, 161, 105, 0.2);
}
.review-button-postpone {
background-color: #ed8936; /* Modern orange */
color: white;
box-shadow: 0 1px 3px rgba(237, 137, 54, 0.2);
}
.review-button-skip {
background-color: #f7fafc; /* Light gray */
color: #4a5568; /* Dark gray */
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.review-button-mcq {
background-color: #4299e1; /* Modern blue */
color: white;
box-shadow: 0 1px 3px rgba(66, 153, 225, 0.2);
}
.review-button-mcq-refresh {
background-color: #667eea; /* Modern indigo */
color: white;
box-shadow: 0 1px 3px rgba(102, 126, 234, 0.2);
}
.review-button-advance,
.review-note-advance, /* For individual note item button if specific styling needed */
.review-bulk-advance, /* For bulk action button */
.review-date-advance-all /* For date section button */ {
background-color: #38B2AC; /* Teal */
color: white;
box-shadow: 0 1px 3px rgba(56, 178, 172, 0.2); /* Teal shadow */
}
/* Button hover states */
.review-button-complete-blackout:hover,
.review-button-incorrect:hover,
.review-button-incorrect-familiar:hover,
.review-button-correct-difficulty:hover,
.review-button-correct-hesitation:hover,
.review-button-perfect-recall:hover,
.review-button-hard:hover,
.review-button-fair:hover,
.review-button-good:hover,
.review-button-perfect:hover,
.review-button-postpone:hover,
.review-button-skip:hover,
.review-button-mcq:hover,
.review-button-mcq-refresh:hover,
.review-button-advance:hover,
.review-note-advance:hover,
.review-bulk-advance:hover,
.review-date-advance-all:hover {
transform: translateY(-2px);
box-shadow: var(--sf-shadow);
filter: brightness(1.1);
}
/* Disabled state for buttons */
.review-note-button:disabled, /* General class for individual note buttons */
.review-bulk-button:disabled, /* General class for bulk action buttons */
.review-date-action-button:disabled, /* General class for date section buttons */
button.disabled { /* Fallback for any button with .disabled class */
opacity: 0.5;
cursor: not-allowed !important; /* Ensure cursor changes */
filter: grayscale(60%);
box-shadow: none;
}
.review-note-button:disabled:hover,
.review-bulk-button:disabled:hover,
.review-date-action-button:disabled:hover,
button.disabled:hover {
transform: none; /* No hover effect */
filter: grayscale(60%); /* Maintain disabled look on hover */
box-shadow: none;
}
.review-button-complete-blackout:active,
.review-button-incorrect:active,
.review-button-incorrect-familiar:active,
.review-button-correct-difficulty:active,
.review-button-correct-hesitation:active,
.review-button-perfect-recall:active,
.review-button-hard:active,
.review-button-fair:active,
.review-button-good:active,
.review-button-perfect:active,
.review-button-postpone:active,
.review-button-skip:active,
.review-button-mcq:active,
.review-button-mcq-refresh:active,
.review-button-advance:active,
.review-note-advance:active,
.review-bulk-advance:active,
.review-date-advance-all:active {
transform: translateY(0);
}
.review-button-separator {
border-top: 1px solid var(--background-modifier-border);
margin: 10px 0;
}
.review-info-text {
margin-top: var(--sf-space-md);
padding: var(--sf-space-md);
background-color: var(--sf-bg-secondary);
border-radius: var(--sf-radius-md);
border-left: 4px solid var(--sf-primary);
line-height: 1.5;
}
.review-session-info {
font-weight: bold;
color: var(--sf-primary);
}
.review-nav-buttons {
display: flex;
gap: var(--sf-space-sm);
margin-bottom: var(--sf-space-sm);
width: 100%;
}
.review-nav-buttons button {
flex: 1;
padding: var(--sf-space-sm) var(--sf-space-md);
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
background-color: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
transition: var(--sf-transition);
}
.review-skip-next-day-button {
background-color: var(--warning-color, #e78a4e);
color: white;
padding: var(--sf-space-sm) var(--sf-space-md);
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
border: none;
margin-top: var(--sf-space-sm);
transition: var(--sf-transition);
width: 100%;
}
.review-skip-next-day-button:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.review-nav-buttons button:hover {
background-color: var(--sf-primary-light);
border-color: var(--sf-primary);
color: var(--sf-primary);
}

187
styles/settings.css Normal file
View file

@ -0,0 +1,187 @@
/* Spaceforge - Settings Tab Styles */
.sf-settings-section {
margin-bottom: var(--sf-space-lg);
animation: fadeIn 0.3s ease-out;
}
.sf-settings-section-header {
display: flex;
align-items: center;
padding: var(--sf-space-sm) var(--sf-space-md);
margin-top: var(--sf-space-lg);
margin-bottom: var(--sf-space-sm);
border-bottom: 1px solid var(--background-modifier-border);
cursor: pointer;
border-radius: var(--sf-radius-sm);
transition: var(--sf-transition);
}
.sf-settings-section-header:hover {
background-color: var(--sf-bg-secondary);
}
.sf-settings-icon {
margin-right: var(--sf-space-md);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.sf-settings-section-header h3 {
margin: 0;
flex-grow: 1;
font-size: 18px;
}
.sf-settings-collapse-indicator {
margin-left: var(--sf-space-sm);
font-size: 12px;
transition: var(--sf-transition);
}
.sf-settings-section-content {
padding-left: var(--sf-space-md);
margin-bottom: var(--sf-space-lg);
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.sf-settings-subsection {
margin: var(--sf-space-lg) 0 var(--sf-space-md) 0;
font-weight: 600;
font-size: 16px;
color: var(--sf-text);
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 6px;
}
.sf-setting-explain {
font-size: 12px;
color: var(--sf-text-muted);
margin-top: -8px;
margin-bottom: var(--sf-space-md);
margin-left: 26px;
font-style: italic;
}
.sf-setting-highlight {
background-color: var(--sf-bg-secondary);
border-left: 3px solid var(--sf-primary);
padding: var(--sf-space-md);
margin: var(--sf-space-md) 0;
border-radius: var(--sf-radius-sm);
}
.sf-setting-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--sf-space-md);
margin: var(--sf-space-md) 0;
}
.sf-settings-actions {
display: flex;
flex-wrap: wrap;
gap: var(--sf-space-md);
margin: var(--sf-space-md) 0 var(--sf-space-lg) 0;
}
.sf-settings-actions button {
flex: 1;
min-width: 120px;
padding: var(--sf-space-sm) var(--sf-space-md);
background-color: var(--sf-primary);
color: var(--sf-text-on-primary);
border: none;
border-radius: var(--sf-radius-sm);
cursor: pointer;
font-weight: 500;
transition: var(--sf-transition);
}
.sf-settings-actions button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.sf-info-box {
background-color: var(--sf-bg-secondary);
padding: var(--sf-space-md);
border-radius: var(--sf-radius-md);
margin: var(--sf-space-md) 0;
box-shadow: var(--sf-shadow);
}
.sf-info-box h4 {
margin-top: 0;
margin-bottom: var(--sf-space-sm);
color: var(--sf-primary);
}
.sf-prompt-label {
font-weight: 500;
margin: var(--sf-space-md) 0 var(--sf-space-sm) 0;
}
.sf-system-prompts-container {
margin-top: var(--sf-space-lg);
border: 1px solid var(--background-modifier-border);
border-radius: var(--sf-radius-md);
padding: 0 var(--sf-space-md) var(--sf-space-md) var(--sf-space-md);
}
.sf-system-prompts-container summary {
cursor: pointer;
padding: var(--sf-space-md) 0;
margin-bottom: var(--sf-space-md);
font-weight: 500;
}
.sf-danger-zone {
background-color: var(--sf-danger-light);
padding: var(--sf-space-md);
border-radius: var(--sf-radius-md);
border: 1px solid var(--sf-danger);
margin-top: var(--sf-space-lg);
}
.sf-danger-zone h4 {
color: var(--sf-danger);
margin-top: 0;
}
/* Enhance setting items */
.setting-item {
padding: var(--sf-space-sm) 0;
border-top: none !important;
}
.setting-item-info {
font-size: 14px;
}
.setting-item-control {
justify-content: flex-end;
}
/* Textarea styling */
.prompt-textarea {
width: 100%;
font-family: monospace;
font-size: 13px;
min-height: 100px;
background-color: var(--sf-bg-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--sf-radius-sm);
padding: var(--sf-space-sm);
resize: vertical;
transition: var(--sf-transition);
}
.prompt-textarea:focus {
border-color: var(--sf-primary);
box-shadow: 0 0 0 2px var(--sf-primary-light);
outline: none;
}

890
styles/sidebar.css Normal file
View file

@ -0,0 +1,890 @@
/* Spaceforge - Sidebar View Styles */
.spaceforge-container {
padding: var(--sf-space-md);
overflow-y: auto;
height: 100%;
background: var(--background-primary); /* Use Obsidian background */
color: var(--text-normal); /* Use Obsidian text color */
box-shadow: var(--sf-shadow-md); /* Added subtle shadow */
}
/* Upcoming Reviews Section */
.review-upcoming-section {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid var(--background-modifier-border);
}
.review-upcoming-list {
display: flex;
flex-direction: column;
gap: 5px; /* Space between day items */
}
.review-upcoming-day {
padding: 5px 8px;
border-radius: var(--radius-s);
transition: background-color 0.2s ease;
}
.review-upcoming-day.clickable {
cursor: pointer;
}
.review-upcoming-day.clickable:hover {
background-color: var(--background-modifier-hover);
}
.review-upcoming-day-summary {
display: flex;
justify-content: space-between;
align-items: center;
}
.review-upcoming-day-name {
font-weight: 500;
color: var(--text-normal);
}
.review-upcoming-day-count {
font-size: 0.9em;
color: var(--text-muted);
}
/* Style for expanded day */
.review-upcoming-day.is-expanded {
background-color: var(--background-secondary);
margin-bottom: 5px; /* Add some space below expanded item */
}
.review-upcoming-notes-container {
margin-top: 8px;
padding-left: 10px; /* Indent notes slightly */
border-left: 2px solid var(--background-modifier-border-hover); /* Visual indicator */
display: flex;
flex-direction: column;
gap: 5px; /* Space between notes in expanded view */
}
/* Ensure notes within upcoming section have consistent styling */
.review-upcoming-notes-container .review-note-item {
/* Inherit most styles from .review-note-item */
/* Add any specific overrides if needed */
padding: 5px 0; /* Adjust padding if necessary */
}
/* Disable drag handle visually for upcoming notes */
.review-note-drag-handle.is-disabled {
opacity: 0.3;
cursor: default;
}
.review-note-drag-handle.is-disabled .drag-handle-line {
background-color: var(--text-faint);
}
.review-header {
display: flex;
flex-direction: column;
margin-bottom: var(--sf-space-md);
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: var(--sf-space-sm);
}
.review-header h2 {
margin: 0 0 var(--sf-space-sm) 0;
font-size: 20px;
color: var(--text-normal); /* Use Obsidian text color */
font-weight: 600;
letter-spacing: -0.01em;
}
.review-stats {
margin-top: var(--sf-space-xs);
font-size: 14px;
color: var(--text-muted); /* Using theme variable */
display: flex;
gap: var(--sf-space-md);
flex-wrap: wrap;
align-items: center;
justify-content: center; /* Center the content */
}
.review-stats-count {
font-weight: 500;
display: flex;
align-items: center;
gap: 5px;
text-align: center; /* Ensure text inside is centered if it wraps */
}
/* Removed ::before to remove bullet point */
.review-stats-time {
font-style: italic;
display: flex;
align-items: center;
gap: 5px;
}
.review-stats-time::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-accent); /* Using theme variable */
}
.review-stats-custom-order {
color: var(--interactive-accent) !important; /* Using theme variable */
font-style: italic;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 5px;
}
.review-stats-custom-order::before {
content: "⭐";
font-style: normal;
}
/* Container for all primary action buttons */
.review-buttons-container {
display: flex;
flex-direction: column;
gap: 4px; /* Use a smaller fixed gap for tighter spacing */
margin-bottom: var(--sf-space-md); /* Space below the entire button group */
}
/* Force ALL direct children to have zero margins to let gap control spacing */
.review-buttons-container > * {
margin: 0 !important; /* Override any potential conflicting margins */
}
.review-all-button,
.review-all-mcq-button {
width: 100%;
padding: var(--sf-space-sm) var(--sf-space-md);
margin: 0 !important; /* Ensure no margin */
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
background-color: var(--interactive-accent); /* Using theme variable */
color: var(--text-on-accent); /* Using theme variable */
border: none;
transition: var(--sf-transition);
box-shadow: var(--sf-shadow-sm); /* Using defined shadow variable */
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.review-all-mcq-button {
background-color: var(--text-accent); /* Using theme variable */
}
.review-all-button:hover,
.review-all-mcq-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(66, 153, 225, 0.4);
filter: brightness(1.05);
}
.review-all-button:active,
.review-all-mcq-button:active {
transform: translateY(0);
}
.review-view-toggle {
display: flex;
margin-top: var(--sf-space-sm);
border: 1px solid var(--background-modifier-border);
border-radius: var(--sf-radius-md);
overflow: hidden;
}
.review-view-btn {
flex: 1;
text-align: center;
padding: 6px;
cursor: pointer;
color: var(--text-normal);
background-color: var(--background-secondary);
transition: var(--sf-transition);
font-weight: 500;
}
.review-view-btn.active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.review-bulk-actions {
display: flex;
gap: var(--sf-space-sm);
margin-bottom: var(--sf-space-md);
flex-wrap: wrap;
}
.review-bulk-button {
flex: 1;
padding: var(--sf-space-sm) var(--sf-space-md);
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
border: none;
transition: var(--sf-transition);
box-shadow: var(--sf-shadow-sm);
background-color: var(--background-modifier-border);
color: var(--text-normal);
}
.review-bulk-button:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.review-bulk-button:active {
transform: translateY(0);
}
.review-date-section {
margin-bottom: var(--sf-space-lg);
border-radius: var(--radius-m, 6px); /* Use Obsidian radius if available */
overflow: hidden;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.review-date-section-overdue {
border-left: 3px solid var(--text-error); /* Use Obsidian error color */
background-color: var(--background-secondary);
}
.review-date-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--sf-space-sm) var(--sf-space-md);
background-color: var(--background-secondary-alt);
border-bottom: 1px solid var(--background-modifier-border);
}
.review-date-header-container {
display: flex;
align-items: center;
gap: var(--sf-space-sm);
flex-grow: 1; /* Allow container to grow */
flex-shrink: 1; /* Allow container to shrink */
overflow: hidden; /* Prevent content from overflowing container */
margin-right: var(--sf-space-sm); /* Add some space before the time estimate */
}
.review-date-header h3 {
margin: 0;
font-size: 16px;
color: var(--text-normal);
font-weight: 600;
white-space: normal; /* Allow header text to wrap */
overflow-wrap: break-word; /* Break long words if necessary */
flex-grow: 1; /* Allow h3 to take available space */
min-width: 0; /* Allow h3 to shrink below its content size if needed */
}
.review-date-section-overdue .review-date-header h3 {
color: var(--text-error);
}
.review-date-postpone-all {
padding: 4px 10px;
font-size: 12px;
border-radius: 6px; /* Rounder buttons */
flex-shrink: 0; /* Prevent button from shrinking */
cursor: pointer;
background-color: #edf2f7; /* Very light gray */
color: #4a5568; /* Dark slate */
border: none;
transition: var(--sf-transition);
}
.review-date-postpone-all:hover {
background-color: var(--sf-warning); /* Using defined warning color */
color: white;
}
.review-date-time {
font-size: 12px;
color: var(--text-muted); /* Using theme variable */
font-style: italic;
padding: 3px 8px;
background-color: var(--background-primary); /* Using theme variable */
border-radius: 100px;
border: 1px solid var(--background-modifier-border); /* Using theme variable */
}
.review-notes-container {
padding: var(--sf-space-sm);
background-color: var(--sf-bg-primary);
}
.review-note-item {
display: flex;
align-items: flex-start; /* Align items to the top to allow title wrapping */
padding: var(--sf-space-sm);
margin-bottom: var(--sf-space-sm);
border-radius: var(--radius-s, 4px);
background-color: var(--background-secondary);
justify-content: space-between;
flex-wrap: wrap;
transition: var(--sf-transition);
border: 1px solid transparent;
}
.review-note-item:last-child {
margin-bottom: 0;
}
.review-note-item:hover {
border-color: var(--interactive-hover);
background-color: var(--background-modifier-hover);
}
.review-note-item.selected {
background-color: var(--background-modifier-form-focus); /* Highlight color for selected items */
border-color: var(--interactive-accent);
}
.review-note-item.overdue-note {
background-color: var(--background-modifier-error-hover);
border-left: 3px solid var(--text-error);
}
.review-note-title {
font-weight: 500;
flex-grow: 1;
margin-right: 10px;
min-width: 150px; /* Ensure a minimum width for the title */
overflow: visible; /* Allow content to overflow */
text-overflow: clip; /* Prevent ellipsis */
white-space: normal; /* Allow text to wrap */
position: relative;
}
.review-note-info {
display: flex;
align-items: center;
gap: var(--sf-space-sm);
margin-right: 10px;
}
.review-note-phase {
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
background-color: var(--sf-bg-primary);
border: 1px solid var(--background-modifier-border);
}
.review-note-time {
font-size: 12px;
color: var(--sf-text-muted);
}
.review-note-buttons {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.review-note-actions {
display: flex;
gap: 5px;
flex-shrink: 0;
}
.review-note-button,
.review-note-postpone,
.review-note-remove {
padding: 4px 10px;
font-size: 12px;
border-radius: var(--radius-s, 4px);
cursor: pointer;
border: 1px solid var(--background-modifier-border);
transition: var(--sf-transition);
background-color: var(--background-primary);
}
/* Adjust button order when space is limited */
@media (max-width: 400px) { /* Adjust breakpoint as needed */
.review-note-actions {
flex-direction: column; /* Stack buttons vertically */
width: 100%; /* Allow buttons to take full width */
}
.review-note-button,
.review-note-postpone,
.review-note-remove {
width: 100%; /* Make buttons take full width when stacked */
margin-bottom: 5px; /* Add some space between stacked buttons */
}
.review-note-remove {
order: -1; /* Place remove button above others when stacked */
}
}
.review-note-button {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
padding: 6px 12px; /* Increased padding */
font-size: 13px; /* Slightly larger font */
}
.review-note-postpone {
background-color: var(--background-modifier-border);
color: var(--text-normal);
padding: 6px 12px; /* Increased padding */
font-size: 13px; /* Slightly larger font */
}
.review-note-remove {
background-color: var(--text-error);
color: white;
border-color: var(--text-error);
padding: 6px 12px; /* Increased padding */
font-size: 13px; /* Slightly larger font */
}
.review-note-button:hover,
.review-note-postpone:hover,
.review-note-remove:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.review-note-drag-handle {
display: flex;
flex-direction: column;
gap: 2px;
cursor: grab;
padding: 6px 8px; /* Increased padding */
margin-left: 5px;
flex-shrink: 0;
border-radius: var(--sf-radius-sm);
transition: var(--sf-transition);
}
.review-note-drag-handle:hover {
background-color: var(--sf-bg-modifier);
}
.drag-handle-line {
width: 15px;
height: 2px;
background-color: var(--sf-text-muted);
border-radius: 1px;
transition: var(--sf-transition);
}
.review-note-drag-handle:hover .drag-handle-line {
background-color: var(--sf-primary);
}
.review-note-drag-handle:active {
cursor: grabbing;
}
.review-note-item.dragging {
opacity: 0.5;
box-shadow: var(--sf-shadow-lg);
}
.review-note-item.drag-over {
background-color: var(--sf-primary-light);
border: 1px dashed var(--sf-primary);
}
.review-all-caught-up {
padding: var(--sf-space-md);
text-align: center;
font-weight: 500;
color: var(--sf-text-muted);
background-color: var(--sf-bg-secondary);
border-radius: var(--sf-radius-md);
box-shadow: var(--sf-shadow-md); /* Using defined shadow variable */
border: 1px solid var(--background-modifier-border);
position: relative;
overflow: hidden;
}
.review-all-caught-up::before {
content: "🎉";
font-size: 24px;
display: block;
margin-bottom: var(--sf-space-sm);
}
.review-all-caught-up::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 300%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
animation: shimmer 2s infinite;
}
.review-session-section {
margin-bottom: var(--sf-space-lg);
padding: var(--sf-space-md);
background-color: var(--sf-bg-secondary);
border-radius: var(--sf-radius-md);
box-shadow: var(--sf-shadow-md); /* Using defined shadow variable */
border-left: 4px solid var(--sf-primary);
}
.review-session-info {
margin-bottom: var(--sf-space-md);
}
.review-session-name {
font-weight: 500;
margin-bottom: 5px;
font-size: 16px;
color: var(--sf-primary);
}
.review-session-progress {
font-size: 12px;
color: var(--sf-text-muted);
margin-bottom: 5px;
}
.review-session-progress-bar-container {
height: 5px;
background-color: var(--background-modifier-border);
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
position: relative;
}
.review-session-progress-bar {
height: 100%;
background-color: var(--sf-primary);
position: relative;
overflow: hidden;
}
.review-session-progress-bar::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
animation: shimmer 2s infinite;
}
.review-session-continue,
.review-session-end {
width: 100%;
padding: 8px 12px;
margin-bottom: 5px;
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
border: none;
transition: var(--sf-transition);
}
.review-session-continue {
background-color: var(--sf-primary);
color: var(--sf-text-on-primary);
}
.review-session-end {
background-color: var(--background-modifier-border);
color: var(--sf-text);
}
.review-session-continue:hover,
.review-session-end:hover {
transform: translateY(-2px);
box-shadow: var(--sf-shadow-lg); /* Using defined shadow variable */
}
.review-upcoming-section {
margin-top: var(--sf-space-lg);
}
.review-upcoming-section h3 {
font-size: 16px;
margin-bottom: var(--sf-space-sm);
color: var(--sf-text);
font-weight: 600;
}
.review-upcoming-list {
margin-top: var(--sf-space-xs);
display: flex;
flex-direction: column;
gap: var(--sf-space-xs);
}
.review-upcoming-day {
padding: var(--sf-space-xs) var(--sf-space-sm);
border-radius: var(--sf-radius-sm);
background-color: var(--sf-bg-secondary);
transition: var(--sf-transition);
border: 1px solid transparent;
display: flex;
justify-content: space-between;
}
.review-upcoming-day:hover {
border-color: var(--interactive-accent); /* Using theme variable */
transform: translateX(3px);
}
.review-upcoming-day-name {
font-weight: 500;
}
.review-upcoming-day-count {
color: var(--sf-text-muted);
font-size: 12px;
background-color: var(--sf-bg-primary);
padding: 1px 6px;
border-radius: 100px;
}
/* --- Pomodoro Timer Styles --- */
/* Styles for Pomodoro section when inside review-buttons-container */
.review-buttons-container .pomodoro-toggle-container {
width: 100%; /* Make toggle container take full width */
margin: 0 !important; /* Ensure no margin */
}
.review-buttons-container .pomodoro-visibility-toggle-btn {
width: 100%;
padding: calc(var(--sf-space-xs) / 1.5) var(--sf-space-sm); /* Reduced vertical padding */
margin: 0 !important; /* Ensure no margin */
border-radius: var(--sf-radius-md);
cursor: pointer;
font-weight: 500;
text-align: center;
background-color: var(--background-modifier-border);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
transition: var(--sf-transition);
}
.review-buttons-container .pomodoro-visibility-toggle-btn:hover {
background-color: var(--background-modifier-hover);
}
.review-buttons-container .pomodoro-section-content {
width: 100%; /* Make content take full width */
margin: 0 !important; /* Ensure no margin */
}
.pomodoro-container {
/* margin-top is removed as it's handled by pomodoro-toggle-container or pomodoro-section-content */
padding: 2px var(--sf-space-xs); /* Minimal vertical padding, keep horizontal */
margin: 0 !important; /* Ensure no margin */
background-color: var(--background-secondary); /* Slightly different background */
border-radius: var(--radius-m);
border: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
gap: var(--sf-space-xs); /* Reduced gap */
}
.pomodoro-main-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px; /* Further reduced gap for compactness */
}
.pomodoro-timer-display {
font-size: 22px; /* Further reduced font size for compact row */
font-weight: 600;
font-family: monospace; /* Monospaced font for alignment */
color: var(--text-normal);
padding: calc(var(--sf-space-xs) / 2) 8px; /* Explicit symmetrical horizontal padding */
border-radius: var(--radius-s);
background-color: var(--background-primary);
min-width: 65px; /* Keep min-width */
text-align: center;
order: 2; /* Order for flex: Start(1) Timer(2) Skip(3) Cog(4) */
flex-grow: 1; /* Allow timer to take some space */
border: none; /* Base border is none */
transition: border-color 0.3s ease; /* Transition border color */
}
/* Fade effect for timer display */
.pomodoro-timer-fade {
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
}
.pomodoro-timer-fade.timer-visible {
opacity: 1;
visibility: visible;
}
/* .timer-hidden is no longer used to hide, opacity is handled by .is-paused or .is-idle */
/* Mode-specific styling (using border-left and border-right) */
.pomodoro-timer-display {
border-left: 3px solid transparent; /* Default transparent border */
border-right: 3px solid transparent; /* Default transparent border */
background-color: var(--background-primary); /* Reset background */
color: var(--text-normal); /* Reset text color */
}
.pomodoro-timer-display.mode-work {
border-left-color: var(--interactive-accent);
border-right-color: var(--interactive-accent);
}
.pomodoro-timer-display.mode-shortBreak {
border-left-color: var(--color-green);
border-right-color: var(--color-green);
}
.pomodoro-timer-display.mode-longBreak {
border-left-color: var(--color-blue);
border-right-color: var(--color-blue);
}
.pomodoro-timer-display.mode-idle {
border-left-color: var(--text-muted);
border-right-color: var(--text-muted);
color: var(--text-muted); /* Keep text muted for idle */
}
.pomodoro-quick-settings-toggle {
cursor: pointer;
padding: 5px;
order: 4; /* Cog at the end */
border-radius: var(--radius-s);
display: flex; /* Use flex to center icon */
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.pomodoro-quick-settings-toggle:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.pomodoro-quick-settings-toggle svg { /* Target the icon */
width: 18px;
height: 18px;
}
/* Styling for buttons within pomodoro-main-controls */
.pomodoro-main-controls .pomodoro-start-btn,
.pomodoro-main-controls .pomodoro-stop-btn,
.pomodoro-main-controls .pomodoro-skip-btn {
flex-grow: 0; /* Don't allow buttons to grow excessively */
flex-shrink: 0;
padding: 4px 8px; /* Reduced padding for buttons */
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary-alt);
cursor: pointer;
transition: var(--sf-transition);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-normal);
}
.pomodoro-main-controls .pomodoro-start-btn { order: 1; }
.pomoro-main-controls .pomodoro-stop-btn { order: 1; } /* Same order as start, one will be hidden */
.pomodoro-main-controls .pomodoro-skip-btn { order: 3; }
.pomodoro-main-controls button:hover {
background-color: var(--background-modifier-hover);
border-color: var(--interactive-accent-hover);
}
.pomodoro-main-controls button svg { /* Target icons inside buttons */
width: 16px;
height: 16px;
}
.pomodoro-quick-settings-panel {
padding: var(--sf-space-xs); /* Reduced padding */
border-top: 1px solid var(--background-modifier-border);
margin-top: var(--sf-space-xs); /* Reduced margin-top */
display: flex; /* Changed from none by JS to flex */
flex-direction: column;
gap: calc(var(--sf-space-xs) / 2); /* Reduced gap */
background-color: var(--background-primary); /* Slightly different background */
border-radius: var(--radius-s);
}
.pomodoro-quick-setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sf-space-sm);
}
.pomodoro-quick-setting label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.pomodoro-quick-setting input[type="number"] {
width: 60px; /* Fixed width for number inputs */
text-align: right;
padding: 4px 6px;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
}
.pomodoro-quick-save-btn {
margin-top: var(--sf-space-xs);
padding: 6px 12px;
border-radius: var(--radius-s);
border: none;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
transition: var(--sf-transition);
align-self: flex-end; /* Align button to the right */
}
.pomodoro-quick-save-btn:hover {
background-color: var(--interactive-accent-hover);
}
/* Container state classes */
.pomodoro-container.is-paused .pomodoro-timer-display {
opacity: 0.7; /* Dim timer when paused */
}
.pomodoro-container.is-idle .pomodoro-timer-display {
opacity: 0.5; /* Dim timer more when idle */
}

14
styles/styles.css Normal file
View file

@ -0,0 +1,14 @@
/* Spaceforge - Main CSS File
This file imports all the modular CSS components */
@import url('./_variables.css');
@import url('./_animations.css');
@import url('./_buttons.css');
@import url('./_components.css');
@import url('./review-buttons.css');
@import url('./settings.css');
@import url('./sidebar.css');
@import url('./calendar.css');
@import url('./mcq.css');
@import url('./pomodoro.css'); /* Added Pomodoro styles */
@import url('./responsive.css');

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2017",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2017"
]
},
"include": [
"**/*.ts"
]
}

374
ui/batch-review-modal.ts Normal file
View file

@ -0,0 +1,374 @@
import { Modal, Notice, TFile } from 'obsidian';
import SpaceforgePlugin from '../main';
import { ReviewResponse, ReviewSchedule } from '../models/review-schedule';
import { MCQSet } from '../models/mcq';
import { EstimationUtils } from '../utils/estimation';
import { ConsolidatedMCQModal } from './consolidated-mcq-modal'; // Import ConsolidatedMCQModal
/**
* Modal for batch reviewing multiple notes
*/
export class BatchReviewModal extends Modal {
plugin: SpaceforgePlugin;
notes: ReviewSchedule[];
useMCQ: boolean;
currentIndex: number = 0;
results: Array<{
path: string,
success: boolean,
response: ReviewResponse,
score?: number
}> = [];
started: boolean = false;
allMCQSets: {
path: string;
mcqSet: MCQSet;
fileName: string;
}[] = [];
collectingMCQs: boolean = false;
constructor(
app: any,
plugin: SpaceforgePlugin,
notes: ReviewSchedule[],
useMCQ: boolean = false
) {
super(app);
this.plugin = plugin;
this.notes = notes;
this.useMCQ = useMCQ;
}
async onOpen(): Promise<void> {
const { contentEl } = this;
this.renderStartScreen(contentEl);
}
renderStartScreen(contentEl: HTMLElement): void {
contentEl.empty();
contentEl.createEl("h2", { text: "Batch Review" });
const infoEl = contentEl.createDiv("batch-review-info");
infoEl.createEl("p", { text: `${this.notes.length} notes scheduled for review` });
this.estimateAndShowTime(infoEl);
const buttonsEl = contentEl.createDiv("batch-review-buttons");
const startButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Start MCQ Review" : "Start Manual Review",
cls: "batch-review-start-button"
});
startButton.addEventListener("click", () => this.startBatchReview());
const toggleMCQButton = buttonsEl.createEl("button", {
text: this.useMCQ ? "Switch to Manual Review" : "Switch to MCQ Review",
cls: "batch-review-toggle-button"
});
toggleMCQButton.addEventListener("click", () => {
this.useMCQ = !this.useMCQ;
this.renderStartScreen(contentEl);
});
if (this.useMCQ) {
const regenerateButton = buttonsEl.createEl("button", {
text: "Regenerate All MCQs",
cls: "batch-review-regenerate-button"
});
regenerateButton.addEventListener("click", () => {
this.close();
if (this.plugin.batchController) {
this.plugin.batchController.regenerateAllMCQs();
}
});
}
const cancelButton = buttonsEl.createEl("button", { text: "Cancel", cls: "batch-review-cancel-button" });
cancelButton.addEventListener("click", () => this.close());
}
async estimateAndShowTime(containerEl: HTMLElement): Promise<void> {
let totalTime = 0;
for (const note of this.notes) {
totalTime += await this.plugin.dataStorage.estimateReviewTime(note.path);
}
if (this.useMCQ) {
totalTime += this.notes.length * 15; // Estimate for MCQ generation
}
containerEl.createEl("p", { text: `Estimated time: ${EstimationUtils.formatTime(totalTime)}`, cls: "batch-review-time" });
}
async startBatchReview(): Promise<void> {
this.started = true;
if (this.useMCQ) {
this.collectingMCQs = true;
await this.collectAllMCQs();
if (this.allMCQSets.length > 0) {
this.showConsolidatedMCQUI();
} else {
new Notice("Could not generate any MCQs. Falling back to manual review.");
await this.processNextManual();
}
} else {
await this.processNextManual();
}
}
async collectAllMCQs(): Promise<void> {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Collecting MCQs" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Preparing MCQs for ${this.notes.length} notes...` });
const progressBar = contentEl.createDiv("mcq-collection-progress");
progressBar.style.width = "100%"; progressBar.style.height = "10px"; progressBar.style.marginTop = "20px"; progressBar.style.backgroundColor = "var(--background-modifier-border)"; progressBar.style.borderRadius = "5px"; progressBar.style.overflow = "hidden";
const progressFill = progressBar.createDiv();
progressFill.style.width = "0%"; progressFill.style.height = "100%"; progressFill.style.backgroundColor = "var(--interactive-accent)"; progressFill.style.transition = "width 0.3s ease";
const statusEl = contentEl.createDiv("batch-review-status");
statusEl.style.marginTop = "10px"; statusEl.style.color = "var(--text-muted)";
this.allMCQSets = [];
for (let i = 0; i < this.notes.length; i++) {
const note = this.notes[i];
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof TFile ? file.basename : note.path;
progressFill.style.width = `${((i + 1) / this.notes.length) * 100}%`;
statusEl.setText(`Processing ${i + 1}/${this.notes.length}: ${fileName}`);
await new Promise(resolve => setTimeout(resolve, 10));
let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path);
// Use mcqGenerationService instead of openRouterService
if (!mcqSet && this.plugin.mcqGenerationService && this.plugin.mcqController) {
statusEl.setText(`Generating MCQs for ${i + 1}/${this.notes.length}: ${fileName}...`);
try {
// generateMCQs now returns the set or null
mcqSet = await this.plugin.mcqController.generateMCQs(note.path);
} catch (error) {
console.error("Error generating MCQs:", error);
statusEl.setText(`Error generating MCQs for ${fileName}`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (mcqSet) {
this.allMCQSets.push({ path: note.path, mcqSet, fileName });
}
}
statusEl.setText(`Collected MCQs for ${this.allMCQSets.length}/${this.notes.length} notes`);
await new Promise(resolve => setTimeout(resolve, 1000));
this.collectingMCQs = false;
}
showConsolidatedMCQUI(): void {
if (this.allMCQSets.length === 0) {
this.showSummary();
return;
}
if (this.plugin.mcqController) {
try {
this.close();
const consolidatedModal = new ConsolidatedMCQModal(
this.plugin,
this.allMCQSets,
(results: Array<{ path: string, success: boolean, response: ReviewResponse, score?: number }>) => {
this.results = results;
console.log("Consolidated MCQ review complete with results:", results);
this.recordAllReviews(results).then(() => {
this.open();
this.showSummary();
});
}
);
consolidatedModal.open();
console.log(`Opened consolidated MCQ modal with ${this.allMCQSets.length} MCQ sets`);
} catch (error) {
console.error("Error showing consolidated MCQ UI:", error);
new Notice("Error showing MCQ review. Falling back to manual review.");
this.open();
this.processNextManual();
}
} else {
new Notice("MCQ controller not available. Falling back to manual review.");
this.open();
this.processNextManual();
}
}
async recordAllReviews(results: Array<{ path: string, success: boolean, response: ReviewResponse, score?: number }>): Promise<void> {
for (const result of results) {
await this.plugin.dataStorage.recordReview(result.path, result.response);
}
}
// This method is likely unused now due to the consolidated modal approach, but kept for reference/potential fallback
async processNextMCQ(): Promise<void> {
if (this.currentIndex >= this.notes.length) {
this.showSummary();
return;
}
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "MCQ Review in Progress" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Processing note ${this.currentIndex + 1}/${this.notes.length}` });
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof TFile ? file.basename : note.path;
progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" });
this.close(); // Close this modal to open the individual MCQ modal
if (this.plugin.mcqController) {
let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path);
// Use mcqGenerationService instead of openRouterService
if (!mcqSet && this.plugin.mcqGenerationService) {
new Notice(`Generating MCQs for ${fileName}...`);
mcqSet = await this.plugin.mcqController.generateMCQs(note.path);
}
if (mcqSet) {
// Update the call to startMCQReview - remove the callback argument
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.
/*
, (path: string, success: boolean) => {
let response: ReviewResponse;
const score = this.getLatestMCQScore(note.path) || 0;
if (success) {
if (score >= 0.9) response = ReviewResponse.PerfectRecall;
else if (score >= 0.7) response = ReviewResponse.CorrectWithHesitation;
else response = ReviewResponse.CorrectWithDifficulty;
} else {
if (score >= 0.4) response = ReviewResponse.IncorrectButFamiliar;
else if (score >= 0.2) response = ReviewResponse.IncorrectResponse;
else response = ReviewResponse.CompleteBlackout;
}
this.results.push({ path: note.path, success: success, response: response, score: score });
this.plugin.dataStorage.recordReview(note.path, response);
this.currentIndex++;
this.open(); // Reopen this 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.
console.warn("processNextMCQ flow is currently inactive due to consolidated modal implementation.");
// 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`);
this.open();
this.processNextManual();
}
} else {
new Notice("MCQ controller not available, falling back to manual review");
this.open();
this.processNextManual();
}
}
getLatestMCQScore(path: string): number | undefined {
const session = this.plugin.dataStorage.getLatestMCQSessionForNote(path);
return session?.score;
}
async processNextManual(): Promise<void> {
if (this.currentIndex >= this.notes.length) {
this.showSummary();
return;
}
const note = this.notes[this.currentIndex];
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Manual Review" });
const progressEl = contentEl.createDiv("batch-review-progress");
progressEl.createEl("p", { text: `Note ${this.currentIndex + 1}/${this.notes.length}` });
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const fileName = file instanceof TFile ? file.basename : note.path;
progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" });
if (this.plugin.navigationController) {
await this.plugin.navigationController.openNoteWithoutReview(note.path);
}
const buttonsContainer = contentEl.createDiv("batch-review-buttons");
const blackoutButton = buttonsContainer.createEl("button", { text: "0: Complete Blackout", cls: "review-button review-button-complete-blackout" });
blackoutButton.addEventListener("click", () => this.recordManualResult(note.path, 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 skipButton = buttonsContainer.createEl("button", { text: "Skip", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", () => {
this.currentIndex++;
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");
}
this.currentIndex++;
this.processNextManual();
}
showSummary(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Batch Review Complete" });
const statsEl = contentEl.createDiv("batch-review-summary-stats");
const totalNotes = this.results.length;
const successfulNotes = this.results.filter(r => r.success).length;
const successRate = totalNotes > 0 ? Math.round((successfulNotes / totalNotes) * 100) : 0;
statsEl.createEl("p", { text: `Completed: ${totalNotes}/${this.notes.length} notes` });
statsEl.createEl("p", { text: `Success rate: ${successRate}% (${successfulNotes}/${totalNotes})`, cls: successRate >= 70 ? "batch-review-success" : "batch-review-needs-improvement" });
const resultsEl = contentEl.createDiv("batch-review-results");
resultsEl.createEl("h3", { text: "Individual Results" });
const resultsListEl = resultsEl.createDiv("batch-review-results-list");
for (const result of this.results) {
const resultItemEl = resultsListEl.createDiv("batch-review-result-item");
const file = this.plugin.app.vault.getAbstractFileByPath(result.path);
const fileName = file instanceof TFile ? file.basename : result.path;
resultItemEl.createEl("div", { text: fileName, cls: "batch-review-result-filename" });
let responseText: string; let responseClass: string;
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;
case ReviewResponse.CorrectWithDifficulty: responseText = "Correct with Difficulty (3)"; responseClass = "batch-review-correct-difficulty"; break;
case ReviewResponse.CorrectWithHesitation: responseText = "Correct with Hesitation (4)"; responseClass = "batch-review-correct-hesitation"; break;
case ReviewResponse.PerfectRecall: responseText = "Perfect Recall (5)"; responseClass = "batch-review-perfect-recall"; break;
default: responseText = "Unknown"; responseClass = "";
}
resultItemEl.createEl("div", { text: responseText, cls: `batch-review-result-response ${responseClass}` });
if (result.score !== undefined) {
resultItemEl.createEl("div", { text: `MCQ Score: ${Math.round(result.score * 100)}%`, cls: "batch-review-result-mcq-score" });
}
}
const closeButton = contentEl.createEl("button", { text: "Close", cls: "batch-review-close-button" });
closeButton.addEventListener("click", () => {
this.close();
if (this.plugin.sidebarView) this.plugin.sidebarView.refresh();
});
}
onClose(): void {
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`);
}
}
}

285
ui/calendar-view.ts Normal file
View file

@ -0,0 +1,285 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../main";
import { ReviewSchedule } from "../models/review-schedule";
import { DateUtils } from "../utils/dates";
import { EstimationUtils } from "../utils/estimation";
/**
* Interface for grouped reviews by date
*/
interface DateReviews {
timestamp: number;
notes: ReviewSchedule[];
totalTime: number;
}
/**
* Calendar view component for review schedule
*/
export class CalendarView {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Calendar container element
*/
containerEl: HTMLElement;
/**
* Current date being displayed
*/
currentDate: Date;
/**
* Reviews grouped by date
*/
reviewsByDate: Map<string, DateReviews> = new Map();
// Persistent UI elements
private calendarHeaderEl: HTMLElement | null = null;
private monthTitleEl: HTMLElement | null = null;
private calendarGridEl: HTMLElement | null = null;
/**
* Initialize calendar view
*
* @param containerEl Container element
* @param plugin Reference to the main plugin
*/
constructor(containerEl: HTMLElement, plugin: SpaceforgePlugin) {
this.containerEl = containerEl;
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
if (this.calendarGridEl) {
this.renderCalendarGridContent(this.calendarGridEl); // Renders/updates the grid
}
}
private ensureCalendarBaseStructure(): void {
if (!this.containerEl) return;
let calendarContainer = this.containerEl.querySelector(".calendar-container") as HTMLElement;
if (!calendarContainer) {
calendarContainer = this.containerEl.createDiv("calendar-container");
}
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();
});
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();
});
const todayBtn = this.calendarHeaderEl.createDiv("calendar-today-btn");
todayBtn.setText("Today");
todayBtn.addEventListener("click", () => {
this.currentDate = new Date();
this.render();
});
}
if (!this.calendarGridEl || !calendarContainer.contains(this.calendarGridEl)) {
this.calendarGridEl?.remove();
this.calendarGridEl = calendarContainer.createDiv("calendar-grid");
}
}
/**
* Update calendar header (month title)
*/
updateCalendarHeader(): void {
if (this.monthTitleEl) {
this.monthTitleEl.setText(
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.startOfDay(new Date(schedule.nextReviewDate));
const dateKey = scheduleDueDayStart.toString(); // Use timestamp as a robust key
if (!this.reviewsByDate.has(dateKey)) {
this.reviewsByDate.set(dateKey, {
timestamp: scheduleDueDayStart, // Store the start of day timestamp
notes: [],
totalTime: 0
});
}
const dateReviews = this.reviewsByDate.get(dateKey);
if (dateReviews) {
dateReviews.notes.push(schedule);
dateReviews.totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(schedule.path);
}
}
}
/**
* Render or update the calendar grid content
*
* @param gridEl The calendar grid element to populate
*/
renderCalendarGridContent(gridEl: HTMLElement): void {
// gridEl.empty(); // Clear only the grid content -- REMOVED
// Ensure weekday headers exist (create once)
if (!gridEl.querySelector(".calendar-weekday")) {
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
weekdays.forEach(day => {
const dayHeader = gridEl.createDiv("calendar-weekday");
dayHeader.setText(day);
});
}
const { year, month, firstDay, daysInMonth } = this.getCalendarData();
const totalCells = 42; // Standard 6 weeks * 7 days grid
let dayCells = Array.from(gridEl.querySelectorAll(".calendar-day")) as HTMLElement[];
// Adjust number of day cell elements if necessary
if (dayCells.length < totalCells) {
for (let i = dayCells.length; i < totalCells; i++) {
dayCells.push(gridEl.createDiv("calendar-day"));
}
} else if (dayCells.length > totalCells) {
for (let i = totalCells; i < dayCells.length; i++) {
dayCells[i].remove();
}
dayCells = dayCells.slice(0, totalCells);
}
let dayOfMonth = 1;
for (let i = 0; i < totalCells; i++) {
const dayCell = dayCells[i];
dayCell.empty(); // Clear previous content of the cell before repopulating
dayCell.className = 'calendar-day'; // Reset classes
dayCell.removeAttribute("data-date-key");
dayCell.onclick = null; // Remove previous click listener
if (i >= firstDay && dayOfMonth <= daysInMonth) {
const currentDateObj = new Date(year, month, dayOfMonth);
const cellDayStart = DateUtils.startOfDay(currentDateObj);
const dateKey = cellDayStart.toString(); // Use the same key format for lookup
dayCell.dataset.dateKey = dateKey;
const dayNumber = dayCell.createDiv("calendar-day-number");
dayNumber.setText(dayOfMonth.toString());
if (this.isToday(year, month, dayOfMonth)) {
dayCell.addClass("today");
}
const dateReviews = this.reviewsByDate.get(dateKey);
if (dateReviews && dateReviews.notes.length > 0) {
dayCell.addClass("has-reviews");
const reviewCount = dayCell.createDiv("calendar-review-count");
reviewCount.setText(dateReviews.notes.length.toString());
const timeEstimate = dayCell.createDiv("calendar-time-estimate");
timeEstimate.setText(EstimationUtils.formatTime(dateReviews.totalTime));
dayCell.addEventListener("click", async () => {
const today = new Date();
const isClickedDateToday = DateUtils.isSameDay(currentDateObj, today);
this.plugin.settings.sidebarViewType = 'list';
if (isClickedDateToday) {
this.plugin.clickedDateFromCalendar = null;
} else {
this.plugin.clickedDateFromCalendar = currentDateObj;
}
await this.plugin.savePluginData();
if (this.plugin.sidebarView && typeof this.plugin.sidebarView.refresh === 'function') {
await this.plugin.sidebarView.refresh();
} else {
this.plugin.app.workspace.requestSaveLayout();
new Notice("Switched to list view. Sidebar will update.");
}
});
if (dateReviews.notes.length > 10) dayCell.addClass("heavy-load");
else if (dateReviews.notes.length > 5) dayCell.addClass("medium-load");
else dayCell.addClass("light-load");
}
dayOfMonth++;
} else {
dayCell.addClass("empty");
}
}
}
/**
* Get calendar data for the current month
*
* @returns Calendar data object
*/
getCalendarData(): { 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
*
* @param year Year
* @param month Month
* @param day Day
* @returns True if the date is today
*/
isToday(year: number, month: number, day: number): boolean {
const today = new Date();
return (
today.getFullYear() === year &&
today.getMonth() === month &&
today.getDate() === day
);
}
}

View file

@ -0,0 +1,717 @@
import { Modal, Notice, TFile, setIcon } from 'obsidian';
import SpaceforgePlugin from '../main';
import { MCQSet } from '../models/mcq';
import { ReviewResponse } from '../models/review-schedule';
/**
* Modal for consolidated MCQ review
* This processes all questions from multiple notes in one series
*/
export class ConsolidatedMCQModal extends Modal {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Collection of all MCQ sets
*/
mcqSets: {
path: string;
mcqSet: MCQSet;
fileName: string;
}[];
/**
* Callback for when review is completed
*/
onComplete: (results: Array<{
path: string,
success: boolean,
response: ReviewResponse,
score?: number
}>) => void;
/**
* All questions from all MCQ sets, flattened
*/
allQuestions: {
question: string;
choices: string[];
correctAnswerIndex: number;
notePath: string;
fileName: string;
mcqSetId: string;
originalIndex: number;
}[] = [];
/**
* Current question index
*/
currentQuestionIndex: number = 0;
/**
* User's answers
*/
answers: {
questionIndex: number;
selectedAnswerIndex: number; // Stores the actual index selected by the user
correct: boolean;
timeToAnswer: number;
attempts: number;
notePath: string;
fileName: string;
}[] = [];
/**
* Start time for current question
*/
questionStartTime: number = 0;
/**
* Initialize consolidated MCQ modal
*
* @param plugin Reference to the main plugin
* @param mcqSets Collection of all MCQ sets
* @param onComplete Callback for when review is completed
*/
constructor(
plugin: SpaceforgePlugin,
mcqSets: {
path: string;
mcqSet: MCQSet;
fileName: string;
}[],
onComplete: (results: Array<{
path: string,
success: boolean,
response: ReviewResponse,
score?: number
}>) => void
) {
super(plugin.app);
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) => {
this.allQuestions.push({
...question,
notePath: set.path,
fileName: set.fileName,
mcqSetId: `${set.path}_${set.mcqSet.generatedAt}`,
originalIndex: index
});
});
}
// 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
headerContainer.createEl('h2', { text: 'Multiple Choice Review' });
// 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();
progressCounter.style.textAlign = 'center';
progressCounter.style.marginBottom = '12px';
progressCounter.style.fontSize = '0.9em';
progressCounter.style.color = 'var(--mcq-text-muted)';
progressCounter.setText(`${this.allQuestions.length} questions from ${this.mcqSets.length} notes`);
// 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
*/
registerKeyboardShortcuts(): void {
const keyDownHandler = (event: KeyboardEvent) => {
// 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);
const index = letterCode - 65; // A=0, B=1, etc.
if (index >= 0 && index < question.choices.length) {
this.handleAnswer(index);
return;
}
}
};
// Add event listener
document.addEventListener('keydown', keyDownHandler);
// Clean up when modal is closed
this.onClose = () => {
document.removeEventListener('keydown', keyDownHandler);
const { contentEl } = this;
contentEl.empty();
};
}
/**
* Display the current question
*
* @param containerEl Container element
*/
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) {
console.error('Invalid question data:', question);
new Notice('Error: Invalid question data. Moving to next question.');
// Skip to next question
this.currentQuestionIndex++;
if (this.currentQuestionIndex < this.allQuestions.length) {
this.displayCurrentQuestion(containerEl);
} else {
this.completeReview();
}
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
*
* @param selectedIndex Index of the selected answer
*/
handleAnswer(selectedIndex: number): void {
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;
correct: boolean;
timeToAnswer: number;
attempts: number;
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;
// Always update the timing and attempts
answer.timeToAnswer = timeToAnswer;
answer.attempts += 1;
} else {
// Create new answer
answer = {
questionIndex,
selectedAnswerIndex: selectedIndex, // Always record the selected index
correct: isCorrect,
timeToAnswer,
attempts: 1,
notePath: question.notePath,
fileName: question.fileName
};
this.answers.push(answer);
}
// Highlight the selected answer
this.highlightAnswer(selectedIndex, isCorrect);
// Wait a moment before proceeding
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);
} else {
this.completeReview();
}
} 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
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
*
* @param selectedIndex Index of the selected answer
* @param isCorrect Whether the answer is correct
*/
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
selectedBtn.classList.add('mcq-choice-correct');
} else {
// For incorrect answers, only highlight the selected one red
selectedBtn.classList.add('mcq-choice-incorrect');
}
}
/**
* Complete the review and show results
*/
completeReview(): void {
// Calculate scores by note
const noteScores: Record<string, {
totalQuestions: number;
correctAnswers: number;
score: number;
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,
// it means it wasn't answered (e.g., if the modal was closed prematurely).
// For the purpose of score calculation, only answered questions count.
// However, for display, we might want to show all.
// For now, the existing logic for noteScores only considers `this.answers`.
for (const answer of this.answers) {
if (!noteScores[answer.notePath]) {
noteScores[answer.notePath] = {
totalQuestions: 0,
correctAnswers: 0,
score: 0,
notePath: answer.notePath,
fileName: answer.fileName
};
}
// 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
// if a question is answered multiple times. Let's refine.
// This loop iterates through recorded answers.
// To get total questions *from the original set* for a note:
// const questionsFromThisNote = this.allQuestions.filter(q => q.notePath === answer.notePath).length;
// noteScores[answer.notePath].totalQuestions = questionsFromThisNote; // This should be set once per note.
}
// Recalculate totalQuestions per note based on allQuestions
for (const question of this.allQuestions) {
if (!noteScores[question.notePath]) {
noteScores[question.notePath] = {
totalQuestions: 0,
correctAnswers: 0,
score: 0,
notePath: question.notePath,
fileName: question.fileName // Assuming fileName is consistent for the notePath
};
}
noteScores[question.notePath].totalQuestions++;
}
// Calculate correct answers based on the final recorded answer for each question
const finalAnswersCorrectCount: Record<string, number> = {};
for (const question of this.allQuestions) {
finalAnswersCorrectCount[question.notePath] = 0;
}
for (const answer of this.answers) {
// Only count as correct if the *final* state of the answer for that questionIndex is correct
// and it was correct on the first attempt for scoring purposes.
// The `answer.correct` here reflects the status of the *last attempt* for that question during the session.
// 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
noteScores[answer.notePath].correctAnswers++;
}
}
}
// Calculate scores
for (const notePath in noteScores) {
const noteScore = noteScores[notePath];
// Ensure totalQuestions is not zero to avoid division by zero
if (noteScore.totalQuestions > 0) {
noteScore.score = noteScore.correctAnswers / noteScore.totalQuestions;
} else {
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;
success: boolean;
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;
if (score >= 0.9) {
success = true;
response = ReviewResponse.Perfect;
} else if (score >= 0.7) {
success = true;
response = ReviewResponse.Good;
} else if (score >= 0.5) {
success = true;
response = ReviewResponse.Fair;
} else {
success = false;
response = ReviewResponse.Hard;
}
results.push({
path: notePath,
success,
response,
score
});
}
// Call the completion callback
this.onComplete(results);
// Show results
const { contentEl } = this;
contentEl.empty();
// Display results header with stylized heading
const headerEl = contentEl.createEl('h2', { text: 'MCQ Review Complete' });
headerEl.style.color = 'var(--mcq-primary)';
headerEl.style.textAlign = 'center';
headerEl.style.marginBottom = '24px';
// 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}%`);
// Add performance indicator based on score
const performanceIndicator = scoreEl.createDiv();
performanceIndicator.style.marginTop = '8px';
performanceIndicator.style.fontSize = '1.1em';
if (scorePercentOverall >= 90) {
performanceIndicator.setText('🎓 Excellent Performance!');
performanceIndicator.style.color = 'var(--mcq-correct)';
} else if (scorePercentOverall >= 70) {
performanceIndicator.setText('👍 Good Work!');
performanceIndicator.style.color = 'var(--mcq-primary)';
} else if (scorePercentOverall >= 50) {
performanceIndicator.setText('🔄 Keep Practicing');
performanceIndicator.style.color = 'var(--mcq-warning)';
} else {
performanceIndicator.setText('📚 More Review Recommended');
performanceIndicator.style.color = 'var(--mcq-text-muted)';
}
// Add stats summary
const statsEl = scoreEl.createDiv();
statsEl.style.marginTop = '12px';
statsEl.style.fontSize = '0.9em';
statsEl.style.color = 'var(--mcq-text-muted)';
statsEl.setText(`${totalCorrectOverall} correct out of ${totalQuestionsOverall} questions`);
// Display note scores with enhanced styling
const noteScoresEl = contentEl.createDiv('mcq-note-scores');
const scoreHeading = noteScoresEl.createEl('h3', { text: 'Scores by Note' });
scoreHeading.style.marginTop = '20px'; // Added margin
// 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})`,
cls: 'mcq-note-score-value'
});
// Style score badge based on performance
if (noteScore.score >= 0.7) {
scoreTextValueEl.style.backgroundColor = 'rgba(76, 175, 80, 0.1)';
scoreTextValueEl.style.color = 'var(--mcq-correct)';
scoreTextValueEl.style.border = '1px solid var(--mcq-correct)';
} else if (noteScore.score >= 0.5) {
scoreTextValueEl.style.backgroundColor = 'rgba(255, 152, 0, 0.1)';
scoreTextValueEl.style.color = 'var(--mcq-warning)';
scoreTextValueEl.style.border = '1px solid var(--mcq-warning)';
} else {
scoreTextValueEl.style.backgroundColor = 'rgba(244, 67, 54, 0.1)';
scoreTextValueEl.style.color = 'var(--mcq-incorrect)';
scoreTextValueEl.style.border = '1px solid var(--mcq-incorrect)';
}
// Add a visual progress bar
const progressBar = noteScoreEl.createDiv();
progressBar.style.height = '4px';
progressBar.style.backgroundColor = 'var(--background-modifier-border)';
progressBar.style.borderRadius = '2px';
progressBar.style.marginTop = '6px';
progressBar.style.position = 'relative';
progressBar.style.overflow = 'hidden';
const progressFill = progressBar.createDiv();
progressFill.style.position = 'absolute';
progressFill.style.left = '0';
progressFill.style.top = '0';
progressFill.style.height = '100%';
progressFill.style.width = `${scorePercent}%`;
progressFill.style.transition = 'width 0.5s ease';
if (noteScore.score >= 0.7) {
progressFill.style.backgroundColor = 'var(--mcq-correct)';
} else if (noteScore.score >= 0.5) {
progressFill.style.backgroundColor = 'var(--mcq-warning)';
} else {
progressFill.style.backgroundColor = 'var(--mcq-incorrect)';
}
}
// --- Detailed Question Breakdown ---
const breakdownContainer = contentEl.createDiv('mcq-detailed-breakdown');
breakdownContainer.style.marginTop = '24px';
breakdownContainer.createEl('h3', { text: 'Detailed Question Breakdown' });
this.allQuestions.forEach((question, index) => {
const questionEl = breakdownContainer.createDiv('mcq-breakdown-item');
questionEl.style.marginBottom = '12px';
questionEl.style.padding = '8px';
questionEl.style.border = '1px solid var(--background-modifier-border)';
questionEl.style.borderRadius = '4px';
const questionHeader = questionEl.createDiv();
questionHeader.createSpan({ text: `Q${index + 1} (from ${question.fileName}): `, cls: 'mcq-breakdown-q-header' });
questionHeader.createSpan({ text: question.question });
const userAnswer = this.answers.find(a => a.questionIndex === index);
const userAnswerTextEl = questionEl.createDiv();
userAnswerTextEl.style.marginLeft = '10px';
let userAnswerDisplay = "Not answered";
if (userAnswer && userAnswer.selectedAnswerIndex !== -1 && userAnswer.selectedAnswerIndex < question.choices.length) {
userAnswerDisplay = question.choices[userAnswer.selectedAnswerIndex];
} else if (userAnswer && userAnswer.selectedAnswerIndex === -1) {
// This case should ideally not happen with the new logic in handleAnswer
// but good to have a fallback.
userAnswerDisplay = "Attempted, but no valid choice recorded";
}
if (userAnswer) {
const correctnessText = userAnswer.correct ? ' (Correct)' : ' (Incorrect)';
userAnswerTextEl.createSpan({ text: 'Your answer: ' });
const userAnswerSpan = userAnswerTextEl.createSpan({ text: userAnswerDisplay });
const correctnessSpan = userAnswerTextEl.createSpan({ text: correctnessText });
correctnessSpan.style.fontWeight = 'bold';
if (userAnswer.correct) {
userAnswerSpan.style.color = 'var(--mcq-correct)';
correctnessSpan.style.color = 'var(--mcq-correct)';
} else {
userAnswerSpan.style.color = 'var(--mcq-incorrect)';
correctnessSpan.style.color = 'var(--mcq-incorrect)';
}
} else {
userAnswerTextEl.createSpan({ text: 'Your answer: ' + userAnswerDisplay });
userAnswerTextEl.style.fontStyle = 'italic';
}
const correctAnswerEl = questionEl.createDiv();
correctAnswerEl.style.marginLeft = '10px';
correctAnswerEl.createSpan({ text: 'Correct answer: ' });
const correctAnswerSpan = correctAnswerEl.createSpan({ text: question.choices[question.correctAnswerIndex] });
correctAnswerSpan.style.color = 'var(--mcq-correct-answer-text)'; // A distinct color for the correct answer text itself
correctAnswerSpan.style.fontWeight = 'bold';
});
// Create close button
const closeBtn = contentEl.createEl('button', 'mcq-close-btn');
closeBtn.setText('Close');
closeBtn.style.marginTop = '24px'; // Added margin
closeBtn.addEventListener('click', () => {
this.close();
});
}
/**
* Shuffle an array
*
* @param array Array to shuffle
* @returns Shuffled array
*/
shuffleArray<T>(array: T[]): T[] {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
}

344
ui/context-menu.ts Normal file
View file

@ -0,0 +1,344 @@
import { Menu, Notice, TFile, TFolder, TAbstractFile } from "obsidian";
import SpaceforgePlugin from "../main";
import { LinkAnalyzer } from "../utils/link-analyzer";
/**
* Handles context menu integration
*/
export class ContextMenuHandler {
/**
* Reference to the main plugin
*/
plugin: SpaceforgePlugin;
/**
* Initialize context menu handler
*
* @param plugin Reference to the main plugin
*/
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
/**
* Register context menu handlers
*/
register(): void {
// Register for the 'file-menu' event, which handles both files and folders.
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-menu", this.handleFileMenuEvent.bind(this))
);
console.log("Unified context menu handler registered for 'file-menu'.");
}
/**
* Handles the 'file-menu' event for TAbstractFile (could be TFile or TFolder).
*
* @param menu Context menu
* @param abstractFile Target file or folder
*/
handleFileMenuEvent(menu: Menu, abstractFile: TAbstractFile): void {
if (abstractFile instanceof TFolder) {
// Handle folder context menu
console.log("Folder right-clicked:", abstractFile.path);
this.addFolderMenuItems(menu, abstractFile);
} else if (abstractFile instanceof TFile && abstractFile.extension === "md") {
// Handle markdown file context menu
console.log("Markdown file right-clicked:", abstractFile.path);
this.addFileMenuItems(menu, abstractFile);
}
}
/**
* Adds context menu items for a TFile.
*
* @param menu Context menu
* @param file Target file
*/
private addFileMenuItems(menu: Menu, file: TFile): void {
// Check if the file is already scheduled using the service
const isScheduled = !!this.plugin.reviewScheduleService.schedules[file.path];
menu.addItem((item) => {
item.setTitle(isScheduled ? "Update review schedule" : "Add to review schedule")
.setIcon("calendar-plus")
.onClick(async () => {
await this.plugin.reviewScheduleService.scheduleNoteForReview(file.path);
await this.plugin.savePluginData();
});
});
if (isScheduled) {
menu.addItem((item) => {
item.setTitle("Review now")
.setIcon("eye")
.onClick(() => this.plugin.reviewController.reviewNote(file.path));
});
menu.addItem((item) => {
item.setTitle("Remove from review")
.setIcon("calendar-minus")
.onClick(async () => {
await this.plugin.reviewScheduleService.removeFromReview(file.path);
await this.plugin.savePluginData();
});
});
}
// New item: Add current note's folder to review schedule
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.");
console.error("Error: file.parent is not an instance of TFolder", file.parent);
}
}
});
});
}
}
/**
* Adds context menu items for a TFolder.
*
* @param menu Context menu
* @param folder Target folder
*/
private addFolderMenuItems(menu: Menu, folder: TFolder): void {
console.log("Adding menu items for folder:", folder.path);
try {
menu.addItem((item) => {
item.setTitle("Add folder to review")
.setIcon("calendar-plus")
.onClick(() => {
console.log("Context menu: Adding folder to review:", folder.path);
this.addFolderToReview(folder);
});
});
console.log("Menu items added successfully for folder:", folder.path);
} catch (error) {
console.error("Error adding folder menu items:", error);
}
}
/**
* Add all markdown files in a folder to the review schedule
*
* @param folder Target folder
*/
async addFolderToReview(folder: TFolder): Promise<void> {
console.log("Processing folder:", folder.path);
// Show immediate notification
new Notice(`Analyzing folder structure for "${folder.name}"...`);
try {
// Get all markdown files in the folder
const allFiles = this.plugin.app.vault.getMarkdownFiles().filter(file => {
if (this.plugin.settings.includeSubfolders) {
// Add trailing slash to ensure we're matching folder path correctly
const folderPath = folder.path === "/" ? "/" : `${folder.path}/`;
return file.path.startsWith(folderPath);
} else {
// Include only files directly in the folder
const parentPath = file.parent ? file.parent.path : "";
return parentPath === folder.path;
}
});
if (allFiles.length === 0) {
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;
// First, identify a potential main file that should be used as the starting point
let mainFilePath: string | null = null;
// Priority 1: Check for a file with the same name as the folder
// This should be the highest priority per the user's requirements
for (const file of allFiles) {
const fileName = file.basename.toLowerCase();
const folderName = folder.name.toLowerCase();
// Exact match first
if (fileName === folderName) {
mainFilePath = file.path;
console.log(`Found main file with exact folder name match: ${mainFilePath}`);
break;
}
}
// If no exact match, try partial matches or index/main files
if (!mainFilePath) {
for (const file of allFiles) {
const fileName = file.basename.toLowerCase();
const folderName = folder.name.toLowerCase();
if (fileName.includes(folderName) ||
folderName.includes(fileName) ||
fileName === 'index' ||
fileName === 'main' ||
fileName.includes('index') ||
fileName.includes('main')) {
mainFilePath = file.path;
console.log(`Found main file by partial name match: ${mainFilePath}`);
break;
}
}
}
// Priority 2: Check if the active file is in this folder
if (!mainFilePath) {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (activeFile &&
activeFile.extension === "md" &&
allFiles.some(f => f.path === activeFile.path)) {
mainFilePath = activeFile.path;
console.log(`Using active note as main file: ${mainFilePath}`);
}
}
let traversalOrder: string[] = [];
const visited = new Set<string>(); // Global visited set
const processLinksRecursively = async (path: string) => {
if (visited.has(path)) {
return; // Already processed
}
visited.add(path);
traversalOrder.push(path);
// console.log(`Added to traversal: ${path}`); // Keep console logs minimal for production
const links = await LinkAnalyzer.analyzeNoteLinks(
this.plugin.app.vault,
path,
false
);
for (const link of links) {
const linkFile = this.plugin.app.vault.getAbstractFileByPath(link);
if (!(linkFile instanceof TFile) || linkFile.extension !== 'md') {
continue;
}
if (allFiles.some(f => f.path === linkFile.path)) { // Is internal to the overall operation
await processLinksRecursively(linkFile.path);
} else { // Is external
if (!visited.has(linkFile.path)) {
visited.add(linkFile.path);
traversalOrder.push(linkFile.path);
// console.log(`Added external link (no recursion): ${linkFile.path}`);
}
}
}
};
// If a mainFilePath was identified, process it first.
if (mainFilePath) {
console.log(`Starting traversal from identified main file: ${mainFilePath}`);
await processLinksRecursively(mainFilePath);
}
// 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));
for (const file of sortedAllFiles) {
if (!visited.has(file.path)) {
console.log(`Processing new branch or unlinked file: ${file.path}`);
await processLinksRecursively(file.path);
}
}
console.log(`Final traversal order determined with ${traversalOrder.length} files.`);
// Schedule notes in the traversal order using the service
const count = await this.plugin.reviewScheduleService.scheduleNotesInOrder(traversalOrder);
if (count > 0) {
await this.plugin.savePluginData(); // Add save call
}
// Show success message
const startingFileName = traversalOrder.length > 0 ?
traversalOrder[0].split('/').pop() : "unknown";
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();
} catch (error) {
console.error("Error adding folder to review:", error);
new Notice("Error adding folder to review schedule");
}
}
/**
* Create a hierarchical review session for a folder
*
* @param folder Target folder
*/
async createHierarchicalSession(folder: TFolder): Promise<void> {
new Notice("Analyzing folder structure and links...");
// Create a review session for the folder using the service
const session = await this.plugin.reviewSessionService.createReviewSession(
folder.path,
folder.name
);
// Save after session creation
if (session) {
await this.plugin.savePluginData(); // Add save call
}
if (!session) {
new Notice("Failed to create review session");
return;
}
// Count files in the hierarchy
const fileCount = session.hierarchy.traversalOrder.length;
// Activate the session using the service
await this.plugin.reviewSessionService.setActiveSession(session.id);
await this.plugin.savePluginData(); // Add save call
// Get the first file to review using the service
const firstFilePath = this.plugin.reviewSessionService.getNextSessionFile();
if (firstFilePath) {
// Also schedule all files in the session for future review using the service
// scheduleSessionForReview calls scheduleNotesInOrder, which now requires a save call after it completes.
// 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
}
// Show success message
new Notice(`Created hierarchical review session with ${fileCount} files.`);
// Open the first file for review
const file = this.plugin.app.vault.getAbstractFileByPath(firstFilePath);
if (file instanceof TFile) {
this.plugin.reviewController.reviewNote(firstFilePath);
}
} else {
new Notice("No files found for review in this folder.");
// Deactivate session using the service
await this.plugin.reviewSessionService.setActiveSession(null);
await this.plugin.savePluginData(); // Add save call
}
}
}

503
ui/mcq-modal.ts Normal file
View file

@ -0,0 +1,503 @@
import { Modal, Notice, setIcon, TFile } from 'obsidian'; // Ensure TFile is imported
import SpaceforgePlugin from '../main';
import { MCQQuestion, MCQSet, MCQAnswer, MCQSession } from '../models/mcq';
/**
* Modal for displaying and answering MCQs
*/
export class MCQModal extends Modal {
plugin: SpaceforgePlugin;
notePath: string;
mcqSet: MCQSet;
session: MCQSession;
questionStartTime: number = 0;
isFreshGeneration: boolean = false;
// Modified callback to include score
private onCompleteCallback: ((path: string, score: number, completed: boolean) => void) | null;
private selectedAnswerIndex: number = -1;
constructor(
plugin: SpaceforgePlugin,
notePath: string,
mcqSet: MCQSet,
onCompleteCallback: ((path: string, score: number, completed: boolean) => void) | null = null
) {
super(plugin.app);
this.plugin = plugin;
this.notePath = notePath;
this.mcqSet = mcqSet;
this.onCompleteCallback = onCompleteCallback;
this.isFreshGeneration = mcqSet.generatedAt > Date.now() - 60000;
this.session = {
mcqSetId: `${mcqSet.notePath}_${mcqSet.generatedAt}`,
notePath,
answers: [],
score: 0,
currentQuestionIndex: 0,
completed: false,
startedAt: Date.now(),
completedAt: null
};
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('spaceforge-mcq-modal');
const headerContainer = contentEl.createDiv('mcq-header-container');
headerContainer.createEl('h2', { text: 'Multiple Choice Review' });
if (!this.isFreshGeneration) {
const refreshBtn = headerContainer.createDiv('mcq-refresh-btn');
setIcon(refreshBtn, 'refresh-cw');
refreshBtn.setAttribute('aria-label', 'Generate new questions');
refreshBtn.addEventListener('click', async () => {
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();
} else {
new Notice('Failed to regenerate MCQs.');
}
} else {
new Notice('Could not find note file to regenerate MCQs.');
}
} else {
new Notice('MCQ generation service not available.');
}
});
}
const questionIndex = this.session.currentQuestionIndex;
const existingAnswer = this.session.answers.find(a => a.questionIndex === questionIndex);
const progressEl = contentEl.createDiv('mcq-progress');
const progressPercent = Math.round(((questionIndex + 1) / this.mcqSet.questions.length) * 100);
contentEl.setAttribute('data-progress', progressPercent.toString());
if (existingAnswer) {
progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length} (Attempt ${existingAnswer.attempts + 1})`);
if (existingAnswer.attempts === 1) {
const warningEl = contentEl.createDiv('mcq-attempt-warning');
const warningIcon = warningEl.createSpan(); warningIcon.setText('⚠️ ');
const warningText = warningEl.createSpan(); warningText.setText('This is your last attempt before scoring 0 points for this question.');
warningEl.addClass('mcq-warning');
}
} else {
progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length}`);
}
this.questionStartTime = Date.now();
this.displayCurrentQuestion(contentEl);
this.registerKeyboardShortcuts();
}
registerKeyboardShortcuts(): void {
const keyDownHandler = (event: KeyboardEvent) => {
if (this.session.completed) return;
const questionIndex = this.session.currentQuestionIndex;
if (questionIndex >= this.mcqSet.questions.length) return;
const question = this.mcqSet.questions[questionIndex];
if (!question || !question.choices) return;
const choiceButtons = this.contentEl.querySelectorAll('.mcq-choice-btn'); // Use this.contentEl
if (choiceButtons.length === 0) return;
if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) {
(choiceButtons[this.selectedAnswerIndex] as HTMLElement).classList.remove('mcq-choice-selected');
}
let processAnswer = false;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedAnswerIndex = (this.selectedAnswerIndex + 1) % question.choices.length;
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedAnswerIndex = (this.selectedAnswerIndex - 1 + question.choices.length) % question.choices.length;
} else if (event.key === 'ArrowRight' || event.key === 'Enter') { // Added Enter key
event.preventDefault();
if (this.selectedAnswerIndex !== -1) processAnswer = true;
}
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= question.choices.length) {
this.selectedAnswerIndex = num - 1;
processAnswer = true;
} else if (event.key.length === 1) {
const letterCode = event.key.toUpperCase().charCodeAt(0);
const index = letterCode - 65;
if (index >= 0 && index < question.choices.length) {
this.selectedAnswerIndex = index;
processAnswer = true;
}
}
if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) {
(choiceButtons[this.selectedAnswerIndex] as HTMLElement).classList.add('mcq-choice-selected');
}
if (processAnswer && this.selectedAnswerIndex !== -1) {
if (this.selectedAnswerIndex < choiceButtons.length) {
const button = choiceButtons[this.selectedAnswerIndex] as HTMLElement;
button.classList.add('mcq-key-pressed');
setTimeout(() => {
button.classList.remove('mcq-key-pressed');
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
}, 150);
} else {
this.handleAnswer(this.selectedAnswerIndex);
this.selectedAnswerIndex = -1;
}
}
};
// Use modal's scope for event listener management
this.scope.register([], 'ArrowDown', keyDownHandler);
this.scope.register([], 'ArrowUp', keyDownHandler);
this.scope.register([], 'ArrowRight', keyDownHandler);
this.scope.register([], 'Enter', keyDownHandler);
for (let i = 1; i <= 9; i++) {
this.scope.register([], i.toString(), keyDownHandler);
}
for (let i = 0; i < 9; i++) { // A-I
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);
if (!this.session.completed && this.session.answers.length > 0) {
// Session was closed prematurely but some answers were given
this.session.completedAt = Date.now();
this.calculateScore(); // Calculate score based on answers so far
this.plugin.mcqService.saveMCQSession(this.session);
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);
}
} else if (!this.session.completed && this.onCompleteCallback) {
// Closed before any answers or completion
this.onCompleteCallback(this.notePath, 0, false);
}
};
}
displayCurrentQuestion(containerEl: HTMLElement): void {
const questionIndex = this.session.currentQuestionIndex;
if (questionIndex >= this.mcqSet.questions.length) {
this.completeSession();
return;
}
const question = this.mcqSet.questions[questionIndex];
if (!question || !question.choices || question.choices.length < 2) {
console.error('Invalid question data:', question);
new Notice('Error: Invalid question data. Moving to next question.');
this.session.currentQuestionIndex++;
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(containerEl);
} else {
this.completeSession();
}
return;
}
const existingQuestionContainer = containerEl.querySelector('.mcq-question-container');
if (existingQuestionContainer) existingQuestionContainer.remove();
const newQuestionContainer = containerEl.createDiv('mcq-question-container');
const questionEl = newQuestionContainer.createDiv('mcq-question-text');
questionEl.setText(question.question);
const existingAnswer = this.session.answers.find(a => a.questionIndex === questionIndex);
if (existingAnswer && existingAnswer.attempts >= 2) {
const skipContainer = newQuestionContainer.createDiv('mcq-skip-container');
skipContainer.style.marginBottom = '12px'; skipContainer.style.textAlign = 'right';
const skipButton = skipContainer.createEl('button', { text: 'Show Answer & Continue', cls: 'mcq-skip-button' });
skipButton.style.backgroundColor = 'var(--text-muted)'; skipButton.style.color = 'white'; skipButton.style.padding = '4px 8px'; skipButton.style.borderRadius = '4px'; skipButton.style.cursor = 'pointer'; skipButton.style.border = 'none';
skipButton.addEventListener('click', () => {
const correctIndex = question.correctAnswerIndex;
const correctAnswerDisplay = newQuestionContainer.createDiv('mcq-correct-answer-display');
correctAnswerDisplay.style.backgroundColor = 'rgba(76, 175, 80, 0.1)'; correctAnswerDisplay.style.padding = '10px'; correctAnswerDisplay.style.borderRadius = '4px'; correctAnswerDisplay.style.marginBottom = '10px'; correctAnswerDisplay.style.border = '1px solid #4caf50';
const correctLabel = correctAnswerDisplay.createDiv(); correctLabel.style.fontWeight = 'bold'; correctLabel.style.marginBottom = '4px'; correctLabel.setText('Correct Answer:');
const correctText = correctAnswerDisplay.createDiv(); correctText.style.color = '#4caf50'; correctText.setText(String.fromCharCode(65 + correctIndex) + ') ' + question.choices[correctIndex]);
if (existingAnswer) {
existingAnswer.selectedAnswerIndex = -1; existingAnswer.correct = false; existingAnswer.attempts += 1;
} else {
this.session.answers.push({ questionIndex, selectedAnswerIndex: -1, correct: false, timeToAnswer: (Date.now() - this.questionStartTime) / 1000, attempts: 3 });
}
const continueBtn = correctAnswerDisplay.createEl('button', { text: 'Continue to Next Question', cls: 'mcq-continue-button' });
continueBtn.style.marginTop = '10px'; continueBtn.style.backgroundColor = 'var(--interactive-accent)'; continueBtn.style.color = 'white'; continueBtn.style.padding = '6px 12px'; continueBtn.style.borderRadius = '4px'; continueBtn.style.cursor = 'pointer'; continueBtn.style.border = 'none';
continueBtn.addEventListener('click', () => {
this.session.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
const progressEl = contentEl.querySelector('.mcq-progress');
if (progressEl instanceof HTMLElement) progressEl.textContent = `Question ${this.session.currentQuestionIndex + 1} of ${this.mcqSet.questions.length}`;
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(contentEl);
} else {
this.completeSession();
}
});
const choicesContainer = this.contentEl.querySelector('.mcq-choices-container'); // Use this.contentEl
if (choicesContainer instanceof HTMLElement) choicesContainer.style.display = 'none';
skipButton.style.display = 'none';
});
}
const choicesContainer = newQuestionContainer.createDiv('mcq-choices-container');
question.choices.forEach((choice, index) => {
const choiceEl = choicesContainer.createDiv('mcq-choice');
const choiceBtn = choiceEl.createEl('button', { cls: 'mcq-choice-btn' });
const letterLabel = choiceBtn.createSpan('mcq-choice-letter'); letterLabel.setText(String.fromCharCode(65 + index) + ') ');
const textSpan = choiceBtn.createSpan('mcq-choice-text'); textSpan.setText(choice || '(Empty choice)');
const shortcutHint = choiceBtn.createSpan('mcq-shortcut-hint');
shortcutHint.style.fontSize = '0.8em'; shortcutHint.style.color = 'var(--mcq-text-muted)'; shortcutHint.style.marginLeft = 'auto'; shortcutHint.style.paddingLeft = '10px';
shortcutHint.setText(`${String.fromCharCode(65 + index)} or ${index + 1}`);
choiceBtn.addEventListener('click', () => this.handleAnswer(index));
});
this.selectedAnswerIndex = -1;
}
handleAnswer(selectedIndex: number): void {
const questionIndex = this.session.currentQuestionIndex;
const question = this.mcqSet.questions[questionIndex];
const isCorrect = selectedIndex === question.correctAnswerIndex;
const timeToAnswer = (Date.now() - this.questionStartTime) / 1000;
const existingAnswerIndex = this.session.answers.findIndex(a => a.questionIndex === questionIndex);
let answer: MCQAnswer;
if (existingAnswerIndex >= 0) {
answer = this.session.answers[existingAnswerIndex];
if (isCorrect) { answer.selectedAnswerIndex = selectedIndex; answer.correct = true; }
answer.timeToAnswer = timeToAnswer; answer.attempts += 1;
if (answer.attempts >= 2 && !answer.correct) {
console.log(`Question ${questionIndex+1} has ${answer.attempts} attempts, marking as zero points`);
answer.selectedAnswerIndex = -1; // Mark as failed but eventually correct
}
} else {
answer = { questionIndex, selectedAnswerIndex: isCorrect ? selectedIndex : -1, correct: isCorrect, timeToAnswer, attempts: 1 };
this.session.answers.push(answer);
}
this.highlightAnswer(selectedIndex, isCorrect);
setTimeout(() => {
if (isCorrect) {
this.session.currentQuestionIndex++;
this.questionStartTime = Date.now();
const { contentEl } = this;
const progressEl = contentEl.querySelector('.mcq-progress');
if (progressEl instanceof HTMLElement) progressEl.textContent = `Question ${this.session.currentQuestionIndex + 1} of ${this.mcqSet.questions.length}`;
const newProgressPercent = Math.round(((this.session.currentQuestionIndex + 1) / this.mcqSet.questions.length) * 100);
contentEl.setAttribute('data-progress', newProgressPercent.toString());
if (this.session.currentQuestionIndex < this.mcqSet.questions.length) {
this.displayCurrentQuestion(contentEl);
} else {
this.completeSession();
}
}
}, 1000);
}
highlightAnswer(selectedIndex: number, isCorrect: boolean): void {
const choiceButtons = this.contentEl.querySelectorAll('.mcq-choice-btn'); // Use this.contentEl
choiceButtons.forEach(button => button.classList.remove('mcq-choice-correct', 'mcq-choice-incorrect', 'mcq-choice-selected'));
if (selectedIndex < choiceButtons.length) {
const selectedBtn = choiceButtons[selectedIndex] as HTMLElement;
selectedBtn.classList.add(isCorrect ? 'mcq-choice-correct' : 'mcq-choice-incorrect');
}
}
completeSession(): void {
const { contentEl } = this;
contentEl.empty();
try {
this.calculateScore();
this.session.completed = true;
this.session.completedAt = Date.now();
// Save via mcqService
this.plugin.mcqService.saveMCQSession(this.session);
this.plugin.savePluginData(); // Persist
contentEl.createEl('h2').setText('Review Complete'); // Corrected
const scoreEl = contentEl.createDiv('mcq-score');
const scoreTextEl = scoreEl.createDiv('mcq-score-text');
const scorePercentage = this.session.score; // Score is 0-1
const reviewSchedule = this.plugin.reviewScheduleService.schedules[this.notePath];
let ratingText = '';
let ratingDetails = '';
if (reviewSchedule?.schedulingAlgorithm === 'fsrs') {
let fsrsRating = 1; // Default to Again
if (scorePercentage === 1.0) fsrsRating = 4; // Easy - only if perfect
else if (scorePercentage >= 0.75) fsrsRating = 3; // Good
else if (scorePercentage >= 0.50) fsrsRating = 2; // Hard
// else fsrsRating remains 1 (Again) for scores < 0.50
ratingText = `FSRS Rating: ${getFsrsRatingText(fsrsRating)} (${fsrsRating}/4)`;
if (reviewSchedule.fsrsData) {
const fsrs = reviewSchedule.fsrsData;
ratingDetails += `Stability: ${fsrs.stability.toFixed(2)}, Difficulty: ${fsrs.difficulty.toFixed(2)}, Interval: ${fsrs.scheduled_days}d, State: ${mapFsrsStateToString(fsrs.state)}, Reps: ${fsrs.reps}, Lapses: ${fsrs.lapses}`;
if (fsrs.last_review) {
const nextDueDate = new Date(fsrs.last_review);
nextDueDate.setDate(nextDueDate.getDate() + fsrs.scheduled_days);
ratingDetails += `, Next Due: ${nextDueDate.toLocaleDateString()}`;
}
}
} else if (reviewSchedule?.schedulingAlgorithm === 'sm2') {
let sm2Rating = 0;
if (scorePercentage >= 0.90) sm2Rating = 5; // Perfect Recall
else if (scorePercentage >= 0.80) sm2Rating = 4; // Correct With Hesitation
else if (scorePercentage >= 0.60) sm2Rating = 3; // Correct With Difficulty
else if (scorePercentage >= 0.40) sm2Rating = 2; // Incorrect But Familiar
else if (scorePercentage >= 0.20) sm2Rating = 1; // Incorrect Response
// else sm2Rating = 0; // Complete Blackout (default)
ratingText = `SM-2 Rating: ${getSm2RatingText(sm2Rating)} (${sm2Rating}/5)`;
if (reviewSchedule) {
ratingDetails += `Ease: ${(reviewSchedule.ease / 100).toFixed(2)}, Interval: ${reviewSchedule.interval}d, Next Due: ${new Date(reviewSchedule.nextReviewDate).toLocaleDateString()}`;
if (reviewSchedule.repetitionCount !== undefined) ratingDetails += `, Reps: ${reviewSchedule.repetitionCount}`;
}
} else {
// Fallback if no schedule or unknown algorithm
ratingText = `Score: ${Math.round(scorePercentage * 100)}%`;
}
scoreTextEl.setText(ratingText);
if (ratingDetails) {
const detailsEl = scoreEl.createDiv('mcq-score-details');
detailsEl.setText(ratingDetails);
detailsEl.style.fontSize = '0.9em';
detailsEl.style.color = 'var(--text-muted)';
detailsEl.style.marginTop = '4px';
}
// Removed the old score indicator text (🎯 Excellent, etc.)
const resultsEl = contentEl.createDiv('mcq-results');
resultsEl.createEl('h3').setText('Question Results');
if (this.session.answers.length === 0) {
resultsEl.createDiv('mcq-no-answers').setText('No questions were answered in this session.');
} else {
this.session.answers.forEach(answer => {
try {
const question = this.mcqSet.questions[answer.questionIndex];
if (!question || !question.choices) return;
const resultItem = resultsEl.createDiv('mcq-result-item');
resultItem.createDiv('mcq-result-question').setText(question.question || 'Question text missing');
if (answer.attempts > 1) {
if (answer.selectedAnswerIndex !== -1) {
const yourAnswer = resultItem.createDiv('mcq-result-your-answer');
yourAnswer.createSpan('mcq-result-label').setText('Your final answer (correct after multiple attempts): ');
yourAnswer.createSpan('mcq-result-correct').setText(question.choices[answer.selectedAnswerIndex] || '(invalid choice)');
} else {
const yourAnswer = resultItem.createDiv('mcq-result-your-answer');
yourAnswer.createSpan('mcq-result-label').setText('Your answer: ');
yourAnswer.createSpan('mcq-result-incorrect').setText('(Incorrect - used "Show Answer" option)');
}
const correctAnswer = resultItem.createDiv('mcq-result-correct-answer');
correctAnswer.createSpan('mcq-result-label').setText('Correct answer: ');
correctAnswer.createSpan('mcq-result-correct').setText(question.choices[question.correctAnswerIndex] || '(invalid choice)');
} else {
const yourAnswer = resultItem.createDiv('mcq-result-your-answer');
yourAnswer.createSpan('mcq-result-label').setText('Your answer: ');
yourAnswer.createSpan(answer.correct ? 'mcq-result-correct' : 'mcq-result-incorrect').setText(question.choices[answer.selectedAnswerIndex] || '(invalid choice)');
if (!answer.correct) {
const correctAnswer = resultItem.createDiv('mcq-result-correct-answer');
correctAnswer.createSpan('mcq-result-label').setText('Correct answer: ');
correctAnswer.createSpan('mcq-result-correct').setText(question.choices[question.correctAnswerIndex] || '(invalid choice)');
}
}
resultItem.createDiv('mcq-result-attempts').setText(`Attempts: ${answer.attempts}`);
resultItem.createDiv('mcq-result-time').setText(`Time: ${Math.round(answer.timeToAnswer)} seconds`);
// FSRS/SM-2 data per question removed from here
} catch (error) { console.error('Error displaying answer result:', error); }
});
}
const closeBtn = contentEl.createEl('button', { cls: 'mcq-close-btn' });
closeBtn.setText('Close');
closeBtn.addEventListener('click', () => {
if (this.onCompleteCallback) {
// Pass the actual score and completion status
this.onCompleteCallback(this.notePath, this.session.score, true);
}
this.close();
});
} catch (error) {
console.error('Error completing MCQ session:', error);
contentEl.createEl('h2').setText('Error Completing Session'); // Corrected
contentEl.createEl('p').setText('There was an error completing the MCQ session. Please try again.'); // Corrected
const errorCloseBtn = contentEl.createEl('button', { cls: 'mcq-close-btn' });
errorCloseBtn.setText('Close'); // Set text separately
errorCloseBtn.addEventListener('click', () => this.close());
}
}
calculateScore(): void {
let totalScore = 0;
this.session.answers.forEach(answer => {
let questionScore = 0;
if (answer.correct && answer.selectedAnswerIndex !== -1) {
if (answer.attempts === 1) questionScore = 1.0;
else if (answer.attempts === 2) questionScore = 0.5;
}
if (questionScore > 0 && answer.timeToAnswer > this.plugin.settings.mcqTimeDeductionSeconds) {
questionScore -= this.plugin.settings.mcqTimeDeductionAmount;
questionScore = Math.max(0, questionScore);
}
totalScore += questionScore;
});
this.session.score = this.mcqSet.questions.length > 0 ? totalScore / this.mcqSet.questions.length : 0;
}
// onClose is now handled by the keyboard shortcut registration cleanup
}
// Helper function to map FSRS state number to a string
function mapFsrsStateToString(state: number): string {
switch (state) {
case 0: return "New";
case 1: return "Learning";
case 2: return "Review";
case 3: return "Relearning";
default: return "Unknown";
}
}
// Helper function to map FSRS rating number to text
function getFsrsRatingText(rating: number): string {
switch (rating) {
case 1: return "Again";
case 2: return "Hard";
case 3: return "Good";
case 4: return "Easy";
default: return "Unknown";
}
}
// 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";
default: return "Unknown";
}
}

176
ui/review-modal.ts Normal file
View file

@ -0,0 +1,176 @@
import { Modal, Notice, TFile, setIcon } from 'obsidian';
import SpaceforgePlugin from '../main';
import { ReviewResponse, ReviewSchedule, 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
*/
export class ReviewModal extends Modal {
plugin: SpaceforgePlugin;
path: string;
constructor(app: any, plugin: SpaceforgePlugin, path: string) {
super(app);
this.plugin = plugin;
this.path = path;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Review Note" });
const buttonsContainer = contentEl.createDiv("review-buttons-container");
const schedule = this.plugin.reviewScheduleService.schedules[this.path];
if (schedule && schedule.schedulingAlgorithm === 'fsrs') {
// FSRS Buttons (1-4)
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);
this.close();
});
};
createFsrsButton("1: Again", "again", FsrsRating.Again);
createFsrsButton("2: Hard", "hard", FsrsRating.Hard);
createFsrsButton("3: Good", "good", FsrsRating.Good);
createFsrsButton("4: Easy", "easy", FsrsRating.Easy);
} else {
// SM-2 Buttons (0-5) - Default if schedule not found or is SM-2
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);
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);
}
buttonsContainer.createEl("div", { cls: "review-button-separator" });
// Postpone Button
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
this.close();
});
// Skip/Next Button
const skipButton = buttonsContainer.createEl("button", { text: "Skip/Next", cls: "review-button review-button-skip" });
skipButton.addEventListener("click", async () => {
this.close();
if (this.plugin.navigationController) {
await this.plugin.navigationController.navigateToNextNoteWithoutRating();
}
});
// MCQ Buttons (if enabled)
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");
mcqButton.addEventListener("click", () => {
const mcqController = this.plugin.mcqController;
if (mcqController) {
// Call startMCQReview without the callback
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.
// For now, we just close this modal.
this.close();
} else {
// Attempt to initialize if not available (might happen on first load)
this.plugin.initializeMCQComponents();
const initializedMcqController = this.plugin.mcqController;
if (initializedMcqController) {
// Call startMCQReview without the callback
initializedMcqController.startMCQReview(this.path);
this.close();
} else {
new Notice("MCQ feature could not be initialized. Please check settings.");
}
}
});
// Refresh MCQ Button (if set exists)
const mcqController = this.plugin.mcqController;
if (mcqController && this.plugin.mcqService.getMCQSetForNote(this.path)) {
const refreshMcqButton = buttonsContainer.createEl("button", { cls: "review-button review-button-mcq-refresh" });
const refreshIconSpan = refreshMcqButton.createSpan("mcq-button-icon"); setIcon(refreshIconSpan, "refresh-cw");
const refreshTextSpan = refreshMcqButton.createSpan("mcq-button-text"); refreshTextSpan.setText("Generate New MCQs");
refreshMcqButton.addEventListener("click", async () => {
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");
}
}
});
}
}
// Note Info Section
const infoText = contentEl.createDiv("review-info-text");
// const schedule = this.plugin.reviewScheduleService.schedules[this.path]; // Already fetched
if (schedule) {
const file = this.app.vault.getAbstractFileByPath(this.path);
const fileName = file instanceof TFile ? file.basename : this.path;
infoText.createEl("p", { text: `Reviewing: ${fileName}` });
const activeSession = this.plugin.reviewSessionService.getActiveSession();
if (activeSession) {
const currentIndex = activeSession.currentIndex;
const totalFiles = activeSession.hierarchy.traversalOrder.length;
infoText.createEl("p", { text: `Session: ${activeSession.name} (${currentIndex + 1}/${totalFiles})`, cls: "review-session-info" });
}
if (schedule.lastReviewDate) infoText.createEl("p", { text: `Last reviewed: ${new Date(schedule.lastReviewDate).toLocaleDateString()}` });
if (schedule.schedulingAlgorithm === 'fsrs' && schedule.fsrsData) {
infoText.createEl("p", { text: `Algorithm: FSRS`, cls: "review-phase-fsrs" });
infoText.createEl("p", { text: `Stability: ${schedule.fsrsData.stability.toFixed(2)}` });
infoText.createEl("p", { text: `Difficulty: ${schedule.fsrsData.difficulty.toFixed(2)}` });
infoText.createEl("p", { text: `State: ${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" });
let phaseText: string; let phaseClass: string;
if (schedule.scheduleCategory === 'initial') {
const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length;
const currentStepDisplay = (schedule.reviewCount || 0) < totalInitialSteps ? (schedule.reviewCount || 0) + 1 : totalInitialSteps;
phaseText = `Initial phase (${currentStepDisplay}/${totalInitialSteps})`; phaseClass = "review-phase-initial";
} else if (schedule.scheduleCategory === 'graduated') {
phaseText = "Graduated (Spaced Repetition)"; phaseClass = "review-phase-graduated";
} else { phaseText = "Spaced Repetition"; phaseClass = "review-phase-spaced"; }
infoText.createEl("p", { text: phaseText, cls: phaseClass });
infoText.createEl("p", { text: `Current ease: ${schedule.ease}` });
infoText.createEl("p", { text: `Current interval: ${schedule.interval} days` });
}
}
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
console.log("Review modal closed for path: " + this.path);
}
}

1197
ui/settings-tab.ts Normal file

File diff suppressed because it is too large Load diff

420
ui/sidebar-view.ts Normal file
View file

@ -0,0 +1,420 @@
import { ItemView, Menu, Notice, TFile, WorkspaceLeaf, setIcon } 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";
/**
* Represents the state of the ReviewSidebarView.
*/
interface ReviewSidebarViewState {
activeListBaseDateISO?: string | null;
// isPomodoroSectionOpen?: boolean; // Removed as Pomodoro section is always "open"
expandedUpcomingDayKey?: string | null;
selectedNotes?: string[];
lastScrollPosition?: number;
sidebarViewType?: 'list' | 'calendar';
}
/**
* Sidebar view for displaying review schedules. Acts as an orchestrator for sub-components.
*/
export class ReviewSidebarView extends ItemView {
plugin: SpaceforgePlugin;
public activeListBaseDate: Date | null = null;
selectedNotes: string[] = [];
private lastSelectedNotePath: string | null = null;
private lastScrollPosition: number = 0;
private expandedUpcomingDayKey: string | null = null; // State for upcoming section
// Sub-components for rendering different parts
private pomodoroUIManager: PomodoroUIManager;
private noteItemRenderer: NoteItemRenderer;
private listViewRenderer: ListViewRenderer | null = null; // Initialize as null
calendarView: CalendarView;
// Persistent UI elements
private mainContainer: HTMLElement | null = null;
private persistentHeaderEl: HTMLElement | null = null;
private listViewContentEl: HTMLElement | null = null;
private calendarViewContentEl: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: SpaceforgePlugin) {
super(leaf);
this.plugin = plugin;
this.noteItemRenderer = new NoteItemRenderer(this.plugin);
// PomodoroUIManager and ListViewRenderer are initialized in ensureBaseStructure
}
getViewType(): string { return "spaceforge-review-schedule"; }
getDisplayText(): string { return "Spaceforge Review"; }
getIcon(): string { return "calendar-clock"; }
async onOpen(): Promise<void> {
this.plugin.events.on('sidebar-update', this.refresh.bind(this));
this.plugin.events.on('pomodoro-update', () => {
if (this.pomodoroUIManager) {
this.pomodoroUIManager.updatePomodoroUI();
}
});
// Ensure structure and render on first open
await this.refresh();
}
async onClose(): Promise<void> {
if ((this as any).resizeObserver) {
(this as any).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
}
private ensureBaseStructure(): void {
const contentEl = this.containerEl || (this as any).contentEl;
if (!contentEl) return;
if (!this.mainContainer) {
contentEl.empty();
this.mainContainer = contentEl.createDiv("spaceforge-container");
}
if (!this.persistentHeaderEl) {
this.persistentHeaderEl = this.mainContainer.createDiv("review-header");
this.persistentHeaderEl.createEl("h2", { text: "Review Schedule" });
const viewToggle = this.persistentHeaderEl.createDiv("review-view-toggle");
const listViewBtn = viewToggle.createDiv("review-view-btn");
listViewBtn.setText("List");
listViewBtn.addEventListener("click", async () => {
if (this.plugin.settings.sidebarViewType === 'list') return;
this.plugin.settings.sidebarViewType = 'list';
this.activeListBaseDate = null;
await this.plugin.savePluginData();
await this.refresh();
});
const calendarViewBtn = viewToggle.createDiv("review-view-btn");
calendarViewBtn.setText("Calendar");
calendarViewBtn.addEventListener("click", async () => {
if (this.plugin.settings.sidebarViewType === 'calendar') return;
this.plugin.settings.sidebarViewType = 'calendar';
await this.plugin.savePluginData();
await this.refresh();
});
}
this.updateViewToggleButtonsState();
if (!this.listViewContentEl) {
this.listViewContentEl = this.mainContainer.createDiv("list-view-content");
}
// Ensure managers are initialized *after* their container exists
if (!this.pomodoroUIManager) {
this.pomodoroUIManager = new PomodoroUIManager(this.plugin);
}
if (!this.listViewRenderer) {
this.listViewRenderer = new ListViewRenderer(this.plugin, this.pomodoroUIManager, this.noteItemRenderer, {
getActiveListBaseDate: () => this.activeListBaseDate,
getSelectedNotes: () => this.selectedNotes,
setSelectedNotes: (notes) => { this.selectedNotes = notes; },
getExpandedUpcomingDayKey: () => this.expandedUpcomingDayKey,
setExpandedUpcomingDayKey: (key) => { this.expandedUpcomingDayKey = key; },
getLastSelectedNotePath: () => this.lastSelectedNotePath,
setLastSelectedNotePath: (path) => { this.lastSelectedNotePath = path; },
refreshSidebarView: this.refresh.bind(this)
});
}
if (!this.calendarViewContentEl) {
this.calendarViewContentEl = this.mainContainer.createDiv("calendar-view-content");
}
// Ensure CalendarView is instantiated here, once, with the persistent container
if (!this.calendarView && this.calendarViewContentEl) {
this.calendarView = new CalendarView(this.calendarViewContentEl, this.plugin);
}
}
private updateViewToggleButtonsState(): void {
if (!this.persistentHeaderEl) return;
const viewToggle = this.persistentHeaderEl.querySelector(".review-view-toggle");
if (!viewToggle) return;
const listViewBtn = viewToggle.children[0] as HTMLElement;
const calendarViewBtn = viewToggle.children[1] as HTMLElement;
if (listViewBtn) listViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === 'list');
if (calendarViewBtn) calendarViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === 'calendar');
}
async refresh(): Promise<void> {
// Capture the state of activeListBaseDate *before* any potential changes in this refresh cycle.
const previousActiveListBaseDateEpoch = this.activeListBaseDate ? DateUtils.startOfDay(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;
if (this.plugin.clickedDateFromCalendar) {
newTargetDate = this.plugin.clickedDateFromCalendar;
this.plugin.clickedDateFromCalendar = null; // Consume the calendar click event
// If a date was clicked from the calendar, ensure the view switches to 'list' mode.
if (this.plugin.settings.sidebarViewType !== 'list') {
this.plugin.settings.sidebarViewType = 'list';
await this.plugin.savePluginData(); // Persist the change in view type
}
}
// Note: User clicks on "List" or "Calendar" view toggle buttons in the header
// directly modify `this.plugin.settings.sidebarViewType` and (for "List") `this.activeListBaseDate`
// *before* calling `this.refresh()`. So, `this.activeListBaseDate` will already reflect such changes here.
// Calculate the epoch for the new target date. `null` signifies "today" (no specific override).
const newTargetDateEpoch = newTargetDate ? DateUtils.startOfDay(newTargetDate) : null;
let reviewDateChanged = false;
// If the effective date for the list view has changed, update the view's state.
if (newTargetDateEpoch !== previousActiveListBaseDateEpoch) {
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();
const targetControllerOverrideValue = this.activeListBaseDate ? DateUtils.startOfDay(this.activeListBaseDate) : null;
if (targetControllerOverrideValue !== currentControllerOverrideEpoch) {
await this.plugin.reviewController.setReviewDateOverride(targetControllerOverrideValue);
// If the controller's date context was updated, it's a significant change
// that warrants treating as `reviewDateChanged` for UI refresh purposes.
if (!reviewDateChanged) {
reviewDateChanged = true;
}
}
// At this point:
// - `this.activeListBaseDate` reflects the correct date for the view.
// - `this.plugin.reviewController` is synchronized with this date.
// - `reviewDateChanged` is true if the date context effectively changed.
this.ensureBaseStructure(); // Ensures containers and managers are initialized
let storedScrollPosition = this.lastScrollPosition; // Use the stored state
if (this.mainContainer) {
// Update storedScrollPosition if the container is currently scrolled
storedScrollPosition = this.mainContainer.scrollTop;
}
this.updateViewToggleButtonsState();
await this.showCorrectViewPane(); // Handles showing/hiding and rendering content
// Apply scroll position *after* rendering is complete
if (this.mainContainer) {
requestAnimationFrame(() => {
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.listViewContentEl) this.listViewContentEl.hide();
if (this.calendarViewContentEl) {
this.calendarViewContentEl.show();
await this.renderCalendarViewContent(this.calendarViewContentEl);
}
} else { // 'list'
if (this.calendarViewContentEl) this.calendarViewContentEl.hide();
if (this.listViewContentEl) { // Check if container exists
this.listViewContentEl.show();
// Now delegate rendering to the ListViewRenderer
await this.renderListViewContent(this.listViewContentEl);
}
}
}
/**
* Render the list view content by delegating to ListViewRenderer.
*/
async renderListViewContent(container: HTMLElement): Promise<void> {
if (this.listViewRenderer) { // Check if renderer is initialized
await this.listViewRenderer.render(container);
} else {
console.error("ListViewRenderer not initialized during renderListViewContent call!");
container.setText("Error: Could not render list view. Renderer not ready.");
}
}
/**
* Render the calendar view content
*/
async renderCalendarViewContent(container: HTMLElement): Promise<void> {
// 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();
} else {
console.error("CalendarView not initialized during renderCalendarViewContent call!");
container.setText("Error: Could not render calendar view. CalendarView not ready.");
return; // Avoid further errors if calendarView is somehow null
}
// Add resize observer logic
// The resizeObserver should still observe this.containerEl (the root of the sidebar view)
// but updateCalendarContainerClass needs to target the correct element within CalendarView's structure.
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.target === this.containerEl) {
this.updateCalendarContainerClass();
}
}
});
if (this.containerEl) {
resizeObserver.observe(this.containerEl);
(this as any).resizeObserver = resizeObserver;
this.updateCalendarContainerClass();
}
}
updateCalendarContainerClass(): void {
if (!this.containerEl || !this.calendarViewContentEl) return;
// CalendarView creates a div with class "calendar-container" inside its root element (this.calendarViewContentEl)
const calendarContainer = this.calendarViewContentEl.querySelector(".calendar-container");
if (calendarContainer) {
const sidebarWidth = this.containerEl.clientWidth; // sidebarWidth is the width of the entire sidebar ItemView
const collapsedThreshold = 300;
if (sidebarWidth < collapsedThreshold) {
calendarContainer.classList.add("is-collapsed");
} else {
calendarContainer.classList.remove("is-collapsed");
}
}
}
// --- Methods moved out ---
// --- Existing methods kept for view lifecycle / state ---
/**
* Move a note up in the list
* (Existing Method - Consider moving to controller)
*/
async moveNoteUp(dateStr: string, note: ReviewSchedule): Promise<void> {
if (!this.listViewRenderer) {
console.error("Cannot move note up: ListViewRenderer not initialized.");
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
const dateNotes = notes[dateStr];
if (!dateNotes) return;
const index = dateNotes.findIndex(n => n.path === note.path);
if (index <= 0) return;
const path1 = dateNotes[index].path;
const path2 = dateNotes[index - 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new Notice(`Moved note up`);
}
/**
* Move a note down in the list
* (Existing Method - Consider moving to controller)
*/
async moveNoteDown(dateStr: string, note: ReviewSchedule): Promise<void> {
if (!this.listViewRenderer) {
console.error("Cannot move note down: ListViewRenderer not initialized.");
return;
}
const notes = await this.listViewRenderer.groupNotesByDate(
this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true),
false
);
const dateNotes = notes[dateStr];
if (!dateNotes) return;
const index = dateNotes.findIndex(n => n.path === note.path);
if (index < 0 || index >= dateNotes.length - 1) return;
const path1 = dateNotes[index].path;
const path2 = dateNotes[index + 1].path;
await this.plugin.reviewController.swapNotes(path1, path2);
await this.refresh();
new Notice(`Moved note down`);
}
/**
* Group notes by their folder
* (Existing Method - Consider moving to utility/service)
*/
async groupNotesByFolder(notes: ReviewSchedule[]): Promise<Record<string, ReviewSchedule[]>> {
const grouped: Record<string, ReviewSchedule[]> = {};
for (const note of notes) {
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
const folderPath = file?.parent?.path || '/';
if (!grouped[folderPath]) { grouped[folderPath] = []; }
grouped[folderPath].push(note);
}
return grouped;
}
// --- State Management for Obsidian View Lifecycle ---
getViewState(): ReviewSidebarViewState {
// const isPomodoroOpen = this.pomodoroUIManager ? this.pomodoroUIManager.getIsPomodoroSectionOpen() : false; // Removed
// Capture scroll position just before saving state
if (this.mainContainer) {
this.lastScrollPosition = this.mainContainer.scrollTop;
}
return {
activeListBaseDateISO: this.activeListBaseDate ? this.activeListBaseDate.toISOString() : null,
// isPomodoroSectionOpen: isPomodoroOpen, // Removed
expandedUpcomingDayKey: this.expandedUpcomingDayKey,
selectedNotes: this.selectedNotes,
lastScrollPosition: this.lastScrollPosition,
sidebarViewType: this.plugin.settings.sidebarViewType,
};
}
async setViewState(state: ReviewSidebarViewState, e?: any): Promise<void> {
if (!state) return;
// Restore non-UI-dependent state first
this.activeListBaseDate = state.activeListBaseDateISO ? new Date(state.activeListBaseDateISO) : null;
this.expandedUpcomingDayKey = state.expandedUpcomingDayKey ?? null;
this.selectedNotes = state.selectedNotes ?? [];
this.lastScrollPosition = state.lastScrollPosition ?? 0; // Restore scroll state
if (state.sidebarViewType) {
this.plugin.settings.sidebarViewType = state.sidebarViewType;
}
// Refresh the view first to ensure elements and managers are created/initialized
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.
// } else {
// console.warn("PomodoroUIManager not initialized during setViewState");
// }
// Scroll position is applied at the end of refresh() using requestAnimationFrame
}
}

View file

@ -0,0 +1,865 @@
import { Notice, TFile } 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";
/**
* Manages the rendering of the list view within the sidebar.
*/
export class ListViewRenderer {
private plugin: SpaceforgePlugin;
private pomodoroUIManager: PomodoroUIManager;
private noteItemRenderer: NoteItemRenderer;
// State properties needed from ReviewSidebarView (passed during render or managed via callbacks)
private getActiveListBaseDate: () => Date | null;
private getSelectedNotes: () => string[];
private setSelectedNotes: (notes: string[]) => void;
private getExpandedUpcomingDayKey: () => string | null;
private setExpandedUpcomingDayKey: (key: string | null) => void;
private getLastSelectedNotePath: () => string | null;
private setLastSelectedNotePath: (path: string | null) => void;
private refreshSidebarView: () => Promise<void>; // Callback to trigger full refresh if needed
constructor(
plugin: SpaceforgePlugin,
pomodoroUIManager: PomodoroUIManager,
noteItemRenderer: NoteItemRenderer,
stateAccessors: {
getActiveListBaseDate: () => Date | null;
getSelectedNotes: () => string[];
setSelectedNotes: (notes: string[]) => void;
getExpandedUpcomingDayKey: () => string | null;
setExpandedUpcomingDayKey: (key: string | null) => void;
getLastSelectedNotePath: () => string | null;
setLastSelectedNotePath: (path: string | null) => void;
refreshSidebarView: () => Promise<void>;
}
) {
this.plugin = plugin;
this.pomodoroUIManager = pomodoroUIManager;
this.noteItemRenderer = noteItemRenderer;
this.getActiveListBaseDate = stateAccessors.getActiveListBaseDate;
this.getSelectedNotes = stateAccessors.getSelectedNotes;
this.setSelectedNotes = stateAccessors.setSelectedNotes;
this.getExpandedUpcomingDayKey = stateAccessors.getExpandedUpcomingDayKey;
this.setExpandedUpcomingDayKey = stateAccessors.setExpandedUpcomingDayKey;
this.getLastSelectedNotePath = stateAccessors.getLastSelectedNotePath;
this.setLastSelectedNotePath = stateAccessors.setLastSelectedNotePath;
this.refreshSidebarView = stateAccessors.refreshSidebarView;
}
/**
* Render the list view content into the provided container.
* @param container Container element for list view content
*/
async render(container: HTMLElement): Promise<void> {
// container.empty(); // Clear only the list view content area -- REMOVED
const activeListBaseDate = this.getActiveListBaseDate();
const selectedNotes = this.getSelectedNotes();
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
// --- Stats Section (REMOVED as per user request) ---
// await this._ensureAndUpdateStatsSection(container, dueNotesForStats);
// --- 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);
// --- "All Caught Up" Message ---
this._ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate);
// --- Main List Grouping and Rendering ---
let notesToGroup: ReviewSchedule[];
let shouldIncludeFutureInGrouping = false;
if (activeListBaseDate) {
notesToGroup = Object.values(this.plugin.reviewScheduleService.schedules);
shouldIncludeFutureInGrouping = true;
} else {
notesToGroup = dueNotesForStats; // Use already fetched due notes
}
const groupedNotes = await 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);
// --- Upcoming Reviews Section (Conditional) ---
if (!activeListBaseDate) {
await this._ensureAndUpdateUpcomingReviewsSection(container);
} else {
const existingUpcomingSection = container.querySelector(".review-upcoming-section");
if (existingUpcomingSection) existingUpcomingSection.remove();
}
this.updateBulkActionButtonsVisibility(container); // Ensure bulk actions visibility is correct at the end
}
// private async _ensureAndUpdateStatsSection(container: HTMLElement, dueNotesForStats: ReviewSchedule[]): Promise<void> {
// let statsEl = container.querySelector(".review-stats-list-view") as HTMLElement;
// if (!statsEl) {
// statsEl = container.createDiv("review-stats-list-view");
// }
// let statsCountEl = statsEl.querySelector(".review-stats-count") as HTMLElement;
// if (!statsCountEl) {
// statsCountEl = statsEl.createEl("div", { cls: "review-stats-count" });
// }
// const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfDay());
// let totalTime = 0;
// for (const note of dueNotesForStats) {
// totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
// }
// statsCountEl.setText(`${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`);
// }
private async _ensureAndUpdateReviewButtonsSection(container: HTMLElement, notesForDisplay: ReviewSchedule[], selectedNotes: string[]): Promise<void> {
let reviewButtonsContainer = container.querySelector(".review-buttons-container") as HTMLElement;
// Visibility of review buttons should depend on whether there are notes in the current context (notesForDisplay)
if (notesForDisplay.length > 0) {
if (!reviewButtonsContainer) {
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(); });
reviewButtonsContainer.createDiv("sidebar-pomodoro-button-container"); // Placeholder for Pomodoro
const reviewCurrentBtn = reviewButtonsContainer.createEl("button", { text: "Review Current Note", title: "Review the currently open note if it's due", cls: "review-all-button" });
reviewCurrentBtn.addEventListener("click", () => { this.plugin.reviewController.reviewCurrentNote(); });
const reviewAllBtn = reviewButtonsContainer.createEl("button", { text: "Review All", title: "Start Reviewing All Due Notes", cls: "review-all-button" });
reviewAllBtn.addEventListener("click", () => { this.plugin.reviewController.reviewAllTodaysNotes(); });
if (this.plugin.settings.enableMCQ) {
const reviewAllMCQBtn = reviewButtonsContainer.createEl("button", { text: "Review All with MCQs", cls: "review-all-mcq-button" });
reviewAllMCQBtn.addEventListener("click", () => { this.plugin.reviewController.reviewAllNotesWithMCQ(true); });
}
}
reviewButtonsContainer.style.display = '';
// Pomodoro section
const pomodoroSectionContainerEl = reviewButtonsContainer.querySelector(".sidebar-pomodoro-button-container") as HTMLElement;
if (this.pomodoroUIManager && pomodoroSectionContainerEl) {
this.pomodoroUIManager.attachAndRender(pomodoroSectionContainerEl); // This method will be refactored to be non-destructive
if (this.plugin.settings.pomodoroEnabled) {
this.pomodoroUIManager.showPomodoroSection(true); // Controls overall visibility
this.pomodoroUIManager.updatePomodoroUI(); // Updates internal state and button text
} else {
this.pomodoroUIManager.showPomodoroSection(false);
}
}
// 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
this.setSelectedNotes([]);
await this.refreshSidebarView();
});
const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance Selected", cls: "review-bulk-button review-bulk-advance" });
advanceSelectedBtn.addEventListener("click", async () => {
const pathsToAdvance = [...this.getSelectedNotes()];
if (pathsToAdvance.length === 0) {
new 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();
});
const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone Selected", cls: "review-bulk-button review-bulk-postpone" });
postponeSelectedBtn.addEventListener("click", async () => {
const pathsToPostpone = [...this.getSelectedNotes()];
if (pathsToPostpone.length === 0) {
new 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.
});
const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove Selected", cls: "review-bulk-button review-bulk-remove" });
removeSelectedBtn.addEventListener("click", async () => {
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.`);
});
}
this.updateBulkActionButtonsVisibility(container); // Update visibility based on current selection
} else if (reviewButtonsContainer) {
reviewButtonsContainer.style.display = 'none';
const bulkActionButtons = container.querySelector(".review-bulk-actions") as HTMLElement;
if (bulkActionButtons) bulkActionButtons.style.display = 'none';
}
}
private _ensureAndUpdateAllCaughtUpMessage(container: HTMLElement, dueNotesForStats: ReviewSchedule[], activeListBaseDate: Date | null): void {
let caughtUpEl = container.querySelector(".review-all-caught-up") as HTMLElement;
if (dueNotesForStats.length === 0 && !activeListBaseDate) {
if (!caughtUpEl) {
caughtUpEl = container.createDiv("review-all-caught-up");
// Insert after stats or buttons if they exist
const statsEl = container.querySelector(".review-stats-list-view");
const buttonsContainer = container.querySelector(".review-buttons-container");
const anchor = buttonsContainer || statsEl;
if (anchor && anchor.nextSibling) {
container.insertBefore(caughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(caughtUpEl);
} else {
container.prepend(caughtUpEl); // Fallback
}
}
caughtUpEl.setText("All caught up! No notes due for review.");
caughtUpEl.style.display = '';
} else if (caughtUpEl) {
caughtUpEl.style.display = 'none';
}
}
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!)) {
sectionEl.remove();
}
}
// Update existing or create new sections
for (const dateStr of sortedDateKeys) {
const notesForSection = groupedNotes[dateStr];
if (!notesForSection || notesForSection.length === 0) continue;
notesDisplayed = true;
let dateSectionEl = container.querySelector(`.review-date-section[data-date-key="${dateStr}"]`) as HTMLElement;
let notesContainerEl: HTMLElement;
if (!dateSectionEl) {
dateSectionEl = container.createDiv("review-date-section");
dateSectionEl.dataset.dateKey = dateStr;
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);
if (sectionDateKeyIsFuture) {
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 () => {
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
});
}
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 () => {
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
});
headerRow.createSpan("review-date-time"); // Placeholder for time
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
} else {
notesContainerEl = dateSectionEl.querySelector(".review-notes-container") as HTMLElement;
if (!notesContainerEl) { // Should not happen if structure is consistent
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();
// A section is considered "overdue" if its category key is "Due notes".
// This applies whether in default view (actual overdue notes)
// or when viewing a past date from calendar (where "Due notes" might be the category for that day's notes).
if (dateStr === "Due notes") {
dateSectionEl.addClass("review-date-section-overdue");
}
const headerContainer = dateSectionEl.querySelector(".review-date-header-container") as HTMLElement;
const dateHeading = headerContainer.querySelector("h3") as HTMLElement;
const reviewTimeEl = dateSectionEl.querySelector(".review-date-time") as HTMLElement;
let displayHeader = dateStr;
const noteCountText = `${notesForSection.length} ${notesForSection.length === 1 ? 'note' : 'notes'}`;
if (dateStr !== "Due notes" && notesForSection.length > 0) {
const actualGroupSampleDate = new Date(notesForSection[0].nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
dateHeading.setText(displayHeader);
let overdueBadge = dateHeading.querySelector(".review-overdue-badge") as HTMLElement | null;
const todayActualStart = DateUtils.startOfDay(); // Timestamp for actual today's midnight
// Condition for showing overdue badge:
// The overdue badge should only show for the "Due notes" section.
const shouldShowOverdueBadge = (dateStr === "Due notes");
if (shouldShowOverdueBadge) {
// If the section is "Due notes", all notes within it are candidates for the overdue calculation.
const overdueNotesInThisSection = notesForSection;
if (overdueNotesInThisSection.length > 0) {
const daysDiff = overdueNotesInThisSection.map(note => {
// When dateStr is "Due notes":
// - If in default view (isDefaultTodayView = true), overdue is relative to actual today.
// - 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()!);
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
if (maxDays > 0) {
if (!overdueBadge) {
overdueBadge = dateHeading.createSpan("review-overdue-badge");
overdueBadge.style.fontSize = "0.8em";
overdueBadge.style.fontWeight = "normal";
overdueBadge.style.color = "var(--text-muted)";
}
overdueBadge.setText(` (${maxDays} ${maxDays === 1 ? 'day' : 'days'} overdue)`);
overdueBadge.style.display = '';
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // No positive overdue days
}
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // No overdue notes in section
}
} else if (overdueBadge) {
overdueBadge.style.display = 'none'; // Conditions for badge not met
}
let sectionTime = 0;
for (const note of notesForSection) { sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); }
reviewTimeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
await this._updateOrRenderNoteList(notesContainerEl, notesForSection, dateStr, container);
}
// Message for activeListBaseDate with no notes
let noNotesForDateMsg = container.querySelector(".review-no-notes-for-date") as HTMLElement;
const activeListBaseDate = this.getActiveListBaseDate();
if (activeListBaseDate && !notesDisplayed) {
if (!noNotesForDateMsg) {
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 = '';
} else if (noNotesForDateMsg) {
noNotesForDateMsg.style.display = 'none';
}
}
private _ensureAndUpdateActiveSessionSection(container: HTMLElement): void {
const activeSession = this.plugin.reviewSessionService.getActiveSession();
let sessionSection = container.querySelector(".review-session-section") as HTMLElement;
if (activeSession) {
if (!sessionSection) {
sessionSection = container.createDiv("review-session-section");
sessionSection.createEl("h3", { text: "Active Review Session" });
const sessionInfo = sessionSection.createDiv("review-session-info");
sessionInfo.createDiv({ cls: "review-session-name" });
sessionInfo.createDiv({ cls: "review-session-progress" });
const progressBarContainer = sessionInfo.createDiv("review-session-progress-bar-container");
progressBarContainer.createDiv("review-session-progress-bar");
const continueBtn = sessionSection.createEl("button", { text: "Continue Session", cls: "review-session-continue" });
continueBtn.addEventListener("click", () => { /* ... 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();
});
}
sessionSection.style.display = '';
(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';
}
}
private async _ensureAndUpdateUpcomingReviewsSection(container: HTMLElement): Promise<void> {
const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules);
const upcomingGroupedNotes = await this.groupNotesByDate(allSchedules, true);
const upcomingKeys = this.getSortedDateKeys(upcomingGroupedNotes)
.filter(key => {
if (key === 'Due notes') return false;
const actualTodayStart = DateUtils.startOfDay(new Date());
if (key === DateUtils.formatDate(actualTodayStart, 'relative', null)) return false; // Exclude "Today" if it's empty or handled by main list
if (key === DateUtils.formatDate(DateUtils.addDays(actualTodayStart, 1), 'relative', null)) return false; // Exclude "Tomorrow"
return true;
});
let upcomingSection = container.querySelector(".review-upcoming-section") as HTMLElement;
if (upcomingKeys.length > 0) {
if (!upcomingSection) {
upcomingSection = container.createDiv("review-upcoming-section");
upcomingSection.createEl("h3", { text: "Upcoming Reviews" });
upcomingSection.createDiv("review-upcoming-list"); // List container
}
upcomingSection.style.display = '';
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!)) {
dayItemEl.remove();
}
}
// Update or create day items
for (const dayKey of upcomingKeys) {
const notesForDay = upcomingGroupedNotes[dayKey];
if (!notesForDay || notesForDay.length === 0) {
// Ensure any existing DOM element for this empty dayKey is removed
const staleEmptyDayItem = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`);
if (staleEmptyDayItem) staleEmptyDayItem.remove();
continue;
}
let dayItemEl = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`) as HTMLElement;
if (!dayItemEl) {
dayItemEl = upcomingListEl.createDiv("review-upcoming-day");
dayItemEl.addClass("clickable");
dayItemEl.dataset.dayKey = dayKey;
const daySummary = dayItemEl.createDiv("review-upcoming-day-summary");
daySummary.createEl("span", { cls: "review-upcoming-day-name" }); // Placeholder for name
dayItemEl.addEventListener("click", async () => { // 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
});
}
// 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
const sampleUpcomingDate = new Date(notesForDay[0].nextReviewDate);
const formattedUpcomingDate = DateUtils.formatDate(sampleUpcomingDate.getTime(), 'medium');
if (["Today", "Tomorrow"].includes(dayKey) || dayKey.startsWith("In ")) {
upcomingDisplayHeader = `${dayKey} (${formattedUpcomingDate})`;
} else {
upcomingDisplayHeader = formattedUpcomingDate;
}
}
if (daySummaryNameEl) daySummaryNameEl.setText(`${upcomingDisplayHeader}: ${notesForDay.length} ${notesForDay.length === 1 ? 'note' : 'notes'}`);
// Handle expanded state
const isExpanded = this.getExpandedUpcomingDayKey() === dayKey;
dayItemEl.classList.toggle("is-expanded", isExpanded);
let notesContainerEl = dayItemEl.querySelector(".review-upcoming-notes-container") as HTMLElement;
if (isExpanded) {
if (!notesContainerEl) {
notesContainerEl = dayItemEl.createDiv("review-upcoming-notes-container");
}
notesContainerEl.style.display = '';
await this._updateOrRenderNoteList(notesContainerEl, notesForDay, dayKey, container);
} else if (notesContainerEl) {
notesContainerEl.style.display = 'none'; // Hide instead of removing
}
}
} else if (upcomingSection) {
upcomingSection.style.display = 'none';
}
}
/**
* Renders or updates a list of notes within a given container.
*/
private async _updateOrRenderNoteList(
notesContainer: HTMLElement,
notes: ReviewSchedule[],
dateStr: string,
parentContainerForBulkActions: HTMLElement
): Promise<void> {
const existingNoteElements = Array.from(notesContainer.querySelectorAll('.review-note-item[data-note-path]')) as HTMLElement[];
const existingNotesMap = new Map(existingNoteElements.map(el => [el.dataset.notePath, el]));
const notesInOrder: HTMLElement[] = [];
const lastSelectedNotePath = this.getLastSelectedNotePath();
const lastSelectedNotePathRef = { current: lastSelectedNotePath };
for (const note of notes) {
let noteEl = existingNotesMap.get(note.path);
if (noteEl) {
await this.noteItemRenderer.updateNoteItem(
noteEl,
note,
dateStr,
this.getSelectedNotes()
);
existingNotesMap.delete(note.path); // Mark as processed
} else {
noteEl = await this.noteItemRenderer.renderNoteItem(
notesContainer, // Temporarily append here, will be reordered
note,
dateStr,
parentContainerForBulkActions,
this.getSelectedNotes(),
lastSelectedNotePathRef,
() => this.handleSelectionChange(parentContainerForBulkActions),
this.handleNoteAction.bind(this)
);
}
if (noteEl) notesInOrder.push(noteEl);
}
// Remove stale notes
for (const staleNoteEl of existingNotesMap.values()) {
staleNoteEl.remove();
}
// Ensure correct order
notesContainer.empty(); // Clear container before re-appending in correct order
for (const noteEl of notesInOrder) {
notesContainer.appendChild(noteEl);
}
this.setLastSelectedNotePath(lastSelectedNotePathRef.current);
}
/**
* Callback function passed to NoteItemRenderer to handle UI updates after selection changes.
*/
private handleSelectionChange(container: HTMLElement): void {
this.updateSelectionClasses(container);
this.updateBulkActionButtonsVisibility(container);
}
/**
* Callback function passed to NoteItemRenderer for actions (like postpone, remove)
* that require a broader UI update (potentially a full refresh or targeted updates).
*/
private async handleNoteAction(): Promise<void> {
await this.refreshSidebarView();
}
/**
* Updates the 'selected' class on note items based on the selectedNotes array.
* (Called by handleSelectionChange)
*/
private updateSelectionClasses(container: HTMLElement): void {
const selectedNotes = this.getSelectedNotes();
const allNoteElements = container.querySelectorAll('.review-note-item[data-note-path]') as NodeListOf<HTMLElement>;
allNoteElements.forEach(el => {
const path = el.dataset.notePath;
if (path && selectedNotes.includes(path)) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
/**
* Updates the visibility of the bulk action buttons based on selection count.
* (Called by handleSelectionChange and render)
*/
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';
// Handle visibility/disabled state of "Advance Selected" button
const advanceSelectedBtn = bulkActionsContainer.querySelector('.review-bulk-advance') as HTMLButtonElement | null;
if (advanceSelectedBtn) {
if (selectedNotesPaths.length > 1) {
const todayStart = DateUtils.startOfDay(new Date()); // Returns timestamp
const hasEligibleFutureNote = selectedNotesPaths.some(path => {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return schedule && DateUtils.startOfDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.style.display = ''; // Always show if bulk actions are visible, rely on disabled state
} else {
advanceSelectedBtn.disabled = true;
// advanceSelectedBtn.style.display = 'none'; // Or hide if no selection
}
}
}
}
/**
* Updates the main header statistics display.
*/
private async updateHeaderStats(container: HTMLElement): Promise<void> {
const headerStatsEl = container.querySelector('.review-stats-list-view .review-stats-count');
if (!headerStatsEl) return;
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfDay());
let totalTime = 0;
for (const note of dueNotesForStats) {
totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
const statsText = `${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`;
headerStatsEl.textContent = statsText;
const reviewButtonsContainer = container.querySelector('.review-buttons-container');
if (reviewButtonsContainer) {
(reviewButtonsContainer as HTMLElement).style.display = dueNotesForStats.length > 0 ? '' : 'none';
}
this.updateBulkActionButtonsVisibility(container);
const allCaughtUpEl = container.querySelector('.review-all-caught-up');
// Use notesForDisplay (which reflects the current date context) for "All caught up" message
const notesInCurrentContext = this.plugin.reviewController.getTodayNotes();
if (allCaughtUpEl) {
(allCaughtUpEl as HTMLElement).style.display = notesInCurrentContext.length === 0 ? '' : 'none';
if (notesInCurrentContext.length === 0) {
allCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
}
} else if (notesInCurrentContext.length === 0) {
const buttonsContainer = container.querySelector('.review-buttons-container');
const newCaughtUpEl = container.createDiv("review-all-caught-up");
newCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
// 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
if (anchor && anchor.nextSibling) {
container.insertBefore(newCaughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(newCaughtUpEl);
} else {
container.prepend(newCaughtUpEl); // Fallback
}
}
}
/**
* Updates the count and estimated time for a specific date section header, or removes the section if empty.
*/
private async updateSectionCounts(sectionEl: HTMLElement, container: HTMLElement): Promise<void> {
if (!sectionEl || !container || !sectionEl.parentElement) return;
const notesInSection = Array.from(sectionEl.querySelectorAll('.review-note-item[data-note-path]'));
const count = notesInSection.length;
if (count === 0) {
sectionEl.remove();
} else {
const headerTextEl = sectionEl.querySelector('.review-date-header-container h3');
const timeEl = sectionEl.querySelector('.review-date-time');
const dateStr = sectionEl.dataset.dateKey;
if (headerTextEl && timeEl && dateStr) {
let sectionTime = 0;
for (const noteEl of notesInSection) {
const path = (noteEl as HTMLElement).dataset.notePath;
if (path) {
sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(path);
}
}
timeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
let displayHeader = dateStr;
const noteCountText = `${count} ${count === 1 ? 'note' : 'notes'}`;
const firstNotePath = (notesInSection[0] as HTMLElement).dataset.notePath;
const schedule = firstNotePath ? this.plugin.reviewScheduleService.schedules[firstNotePath] : null;
if (dateStr !== "Due notes" && schedule) {
const actualGroupSampleDate = new Date(schedule.nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
headerTextEl.textContent = displayHeader;
let overdueBadge = headerTextEl.querySelector(".review-overdue-badge") as HTMLElement | null;
if (dateStr === "Due notes") {
const daysDiff = await Promise.all(notesInSection.map(async 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)`;
if (!overdueBadge) {
overdueBadge = headerTextEl.createSpan("review-overdue-badge");
overdueBadge.style.fontSize = "0.8em";
overdueBadge.style.fontWeight = "normal";
overdueBadge.style.color = "var(--text-muted)";
}
overdueBadge.setText(badgeText);
} else if (overdueBadge) {
overdueBadge.remove();
}
} else if (overdueBadge) {
overdueBadge.remove();
}
}
}
await this.updateHeaderStats(container);
}
/**
* Updates counts for all date sections currently in the DOM.
*/
private async updateAllSectionCounts(container: HTMLElement): Promise<void> {
const sections = container.querySelectorAll('.review-date-section');
for (const section of Array.from(sections)) {
await this.updateSectionCounts(section as HTMLElement, container);
}
await this.updateHeaderStats(container);
}
/**
* Group notes by their review date, considering activeListBaseDate.
*/
async groupNotesByDate(notes: ReviewSchedule[], includeFuture: boolean = false): Promise<Record<string, ReviewSchedule[]>> {
const grouped: Record<string, ReviewSchedule[]> = {};
const actualTodayStart = DateUtils.startOfDay(new Date());
const activeListBaseDate = this.getActiveListBaseDate();
const refDateForFilteringStart = activeListBaseDate ? DateUtils.startOfDay(new Date(activeListBaseDate)) : actualTodayStart;
for (const note of notes) {
const noteDate = new Date(note.nextReviewDate);
const noteDateStart = DateUtils.startOfDay(noteDate); // Timestamp for note's due day midnight
if (activeListBaseDate) {
// When a specific date is selected from the calendar (activeListBaseDate is set),
// only include notes whose due day (UTC midnight) matches the selected day (UTC midnight).
if (noteDateStart !== refDateForFilteringStart) {
continue;
}
}
// For the default view (activeListBaseDate is null), notesToGroup (derived from controller's todayNotes)
// already contains all notes due up to and including today.
// No further date-based filtering is needed here for the default view.
// 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);
// 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".
if (!grouped[dateStr]) {
grouped[dateStr] = [];
}
grouped[dateStr].push(note);
}
return grouped;
}
/**
* Get sorted date keys in the preferred display order: Due notes, Today, Tomorrow, future dates.
*/
getSortedDateKeys(groupedNotes: Record<string, ReviewSchedule[]>): string[] {
const keys = Object.keys(groupedNotes);
const dateOrder: Record<string, number> = { 'Due notes': 0, 'Today': 1, 'Tomorrow': 2 };
return keys.sort((a, b) => {
const aIsSpecial = a in dateOrder;
const bIsSpecial = b in dateOrder;
if (aIsSpecial && bIsSpecial) return dateOrder[a as keyof typeof dateOrder] - dateOrder[b as keyof typeof dateOrder];
if (aIsSpecial) return -1;
if (bIsSpecial) return 1;
const numAMatch = a.match(/^In (\d+) days$/);
const numBMatch = b.match(/^In (\d+) days$/);
if (numAMatch && numBMatch) return parseInt(numAMatch[1]) - parseInt(numBMatch[1]);
if (numAMatch) return -1;
if (numBMatch) return 1;
return a.localeCompare(b);
});
}
}

View file

@ -0,0 +1,361 @@
import { Menu, Notice, TFile, setIcon } from "obsidian";
import SpaceforgePlugin from "../../main";
import { ReviewSchedule } from "../../models/review-schedule";
import { DateUtils } from "../../utils/dates";
import { EstimationUtils } from "../../utils/estimation";
export class NoteItemRenderer {
private plugin: SpaceforgePlugin;
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
}
private async _populateNoteItemDetails(
noteEl: HTMLElement,
note: ReviewSchedule,
dateStr: string,
selectedNotesArray: string[]
): Promise<void> {
noteEl.dataset.notePath = note.path; // Ensure path is set/updated
// Overdue status
noteEl.removeClass("overdue-note");
noteEl.removeAttribute("title");
if (dateStr === "Due notes") {
noteEl.addClass("overdue-note");
const daysOverdue = Math.abs(Math.floor((note.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1000)));
const originalDueDate = new Date(note.nextReviewDate).toLocaleDateString();
noteEl.setAttribute("title", `Originally due: ${originalDueDate} (${daysOverdue} ${daysOverdue === 1 ? 'day' : 'days'} overdue)`);
}
// Selected status
if (selectedNotesArray.includes(note.path)) {
noteEl.addClass("selected");
} else {
noteEl.removeClass("selected");
}
// Title
const titleEl = noteEl.querySelector(".review-note-title") as HTMLElement;
if (titleEl) {
const file = this.plugin.app.vault.getAbstractFileByPath(note.path);
titleEl.setText(file instanceof TFile ? file.basename : note.path);
}
const estimatedTime = await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
const formattedTime = EstimationUtils.formatTime(estimatedTime);
// Phase and Time
const phaseEl = noteEl.querySelector(".review-note-phase") as HTMLElement;
const timeElOld = noteEl.querySelector(".review-note-time") as HTMLElement; // For non-initial
if (timeElOld) timeElOld.remove(); // Remove old time element if it exists from a previous state
if (phaseEl) {
phaseEl.empty(); // Clear previous phase content
phaseEl.removeClass("review-phase-initial", "review-phase-graduated", "review-phase-spaced");
if (note.scheduleCategory === 'initial') {
const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length;
const currentStepDisplay = note.reviewCount < totalInitialSteps ? note.reviewCount + 1 : totalInitialSteps;
phaseEl.innerHTML = `
<div title="Initial">Initial</div>
<div title="${currentStepDisplay}/${totalInitialSteps}">${currentStepDisplay}/${totalInitialSteps}</div>
<div class="phase-time" title="${formattedTime}">${formattedTime}</div>
`;
phaseEl.addClass("review-phase-initial");
} else {
phaseEl.setText(note.scheduleCategory === 'graduated' ? "Graduated" : "Spaced");
phaseEl.addClass(note.scheduleCategory === 'graduated' ? "review-phase-graduated" : "review-phase-spaced");
// Create and append new time element for non-initial
const timeElNew = noteEl.createDiv("review-note-time"); // Create it next to phaseEl or in a specific spot
noteEl.insertBefore(timeElNew, phaseEl.nextSibling); // Example: insert after phaseEl
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;
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.
} else if (!isDraggable) {
noteEl.removeAttribute("draggable"); // Ensure main element is not draggable
}
}
// Advance button state
const advanceBtn = noteEl.querySelector(".review-note-advance") as HTMLButtonElement | null;
if (advanceBtn) {
const todayStartTs = DateUtils.startOfDay(new Date()); // Returns timestamp
const noteReviewDayStartTs = DateUtils.startOfDay(new Date(note.nextReviewDate)); // Returns timestamp
const isEligibleForAdvance = noteReviewDayStartTs > todayStartTs;
advanceBtn.disabled = !isEligibleForAdvance;
advanceBtn.style.display = isEligibleForAdvance ? '' : 'none';
}
}
public async updateNoteItem(
noteEl: HTMLElement,
note: ReviewSchedule,
dateStr: string,
selectedNotesArray: string[]
): Promise<void> {
// _populateNoteItemDetails will now also handle the advance button state
await this._populateNoteItemDetails(noteEl, note, dateStr, selectedNotesArray);
}
public async renderNoteItem(
notesContainer: HTMLElement,
noteToRender: ReviewSchedule, // Renamed to avoid conflict with 'note' in event handlers
dateStr: string,
parentContainerForBulkActions: HTMLElement,
selectedNotesArray: string[],
lastSelectedNotePathRef: { current: string | null },
onSelectionChange: () => void,
onNoteAction: () => Promise<void>
): Promise<HTMLElement> {
if (!parentContainerForBulkActions) {
console.error("Spaceforge: parentContainerForBulkActions is null in renderNoteItem. This should not happen.");
parentContainerForBulkActions = document.body; // Fallback
}
const noteEl = notesContainer.createDiv("review-note-item");
// Basic structure is created here.
// Data attributes and dynamic content will be set by _populateNoteItemDetails.
// --- Create Static Structure ---
const titleEl = noteEl.createDiv("review-note-title");
titleEl.style.cursor = "pointer";
noteEl.createDiv("review-note-phase"); // Placeholder, content filled by _populate...
const buttonsEl = noteEl.createDiv("review-note-buttons");
const actionBtnsEl = buttonsEl.createDiv("review-note-actions");
// Using icons for buttons
const reviewBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-review" });
setIcon(reviewBtn, "play");
reviewBtn.title = "Review";
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";
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++) {
dragHandleEl.createDiv("drag-handle-line");
}
// --- 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);
});
reviewBtn.addEventListener("click", async (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();
}
});
advanceBtn.addEventListener("click", async (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();
}
});
postponeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
try {
await this.plugin.reviewController.postponeNote(path);
await this.plugin.savePluginData();
new Notice(`Note postponed`);
await onNoteAction();
} catch (error) {
console.error("Error postponing note:", error);
new Notice("Failed to postpone note.");
await onNoteAction();
}
}
});
removeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const path = noteEl.dataset.notePath;
if (path) {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof 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) {
console.error("Error removing note:", error);
new Notice("Failed to remove note from schedule.");
await onNoteAction();
}
}
});
dragHandleEl.addEventListener("mousedown", (e) => {
e.stopPropagation();
// Only enable dragging if not disabled (checked by _populateNoteItemDetails via class)
if (!dragHandleEl.classList.contains("is-disabled")) {
noteEl.setAttribute("draggable", "true");
}
});
noteEl.addEventListener("dragstart", (e) => {
const path = noteEl.dataset.notePath;
if (path && noteEl.getAttribute("draggable") === "true") { // Check if draggable
e.dataTransfer?.setData("text/plain", path);
} else {
e.preventDefault(); // Prevent drag if not supposed to be draggable
}
});
noteEl.addEventListener("dragend", () => {
noteEl.removeAttribute("draggable");
});
noteEl.addEventListener("click", (e: MouseEvent) => {
e.stopPropagation();
const currentPath = noteEl.dataset.notePath;
if (!currentPath) return;
// Logic for selection (ctrl/meta/shift click)
// This part uses selectedNotesArray and lastSelectedNotePathRef directly, which are passed in.
// This is fine as they are managed by the calling ListViewRenderer.
const allVisibleNoteElements = Array.from(parentContainerForBulkActions.querySelectorAll('.review-note-item[data-note-path]')) as HTMLElement[];
const allVisibleNotePaths = allVisibleNoteElements.map(el => el.dataset.notePath).filter(p => p) as string[];
const currentIndex = allVisibleNotePaths.indexOf(currentPath);
if (e.shiftKey && lastSelectedNotePathRef.current && lastSelectedNotePathRef.current !== currentPath) {
const lastClickedIndexInVisible = allVisibleNotePaths.indexOf(lastSelectedNotePathRef.current);
if (lastClickedIndexInVisible !== -1 && currentIndex !== -1) {
const start = Math.min(lastClickedIndexInVisible, currentIndex);
const end = Math.max(lastClickedIndexInVisible, currentIndex);
const notesToSelectInRange = allVisibleNotePaths.slice(start, end + 1);
if (e.ctrlKey || e.metaKey) {
notesToSelectInRange.forEach(p => { if (p && !selectedNotesArray.includes(p)) selectedNotesArray.push(p); });
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(...notesToSelectInRange.filter(p => p) as string[]);
}
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(currentPath);
}
} else if (e.ctrlKey || e.metaKey) {
const indexInSelection = selectedNotesArray.indexOf(currentPath);
if (indexInSelection > -1) {
selectedNotesArray.splice(indexInSelection, 1);
} else {
selectedNotesArray.push(currentPath);
}
} else {
selectedNotesArray.length = 0;
selectedNotesArray.push(currentPath);
}
lastSelectedNotePathRef.current = currentPath;
onSelectionChange();
});
noteEl.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const path = noteEl.dataset.notePath; // Get current path
if (!path) return;
const menu = new Menu();
menu.addItem((item) => item
.setTitle("Open note")
.setIcon("file-text")
.onClick(() => this.plugin.reviewController.openNoteWithoutReview(path)));
menu.addItem((item) => item
.setTitle("Review note")
.setIcon("play-circle")
.onClick(async () => {
this.plugin.reviewController.reviewNote(path);
await onNoteAction();
}));
menu.addItem((item) => item
.setTitle("Postpone by 1 day")
.setIcon("skip-forward")
.onClick(async () => {
await this.plugin.reviewController.postponeNote(path, 1);
// savePluginData is handled by postponeNote in controller
// Notice is handled by postponeNote in service/controller
await onNoteAction();
}));
// Conditional "Advance note" context menu item
const schedule = this.plugin.reviewScheduleService.schedules[path];
if (schedule) {
const todayStart = DateUtils.startOfDay(new Date()); // Returns timestamp
const noteReviewDayStart = DateUtils.startOfDay(new Date(schedule.nextReviewDate)); // Returns timestamp
if (noteReviewDayStart > todayStart) {
menu.addItem((item) => item
.setTitle("Advance note")
.setIcon("arrow-left-circle") // Match button icon
.onClick(async () => {
await this.plugin.reviewController.advanceNote(path);
// savePluginData and Notice are handled by advanceNote in controller
await onNoteAction();
}));
}
}
menu.addItem((item) => item
.setTitle("Remove from review")
.setIcon("trash")
.onClick(async () => {
const file = this.plugin.app.vault.getAbstractFileByPath(path);
const confirmed = confirm(`Remove "${file instanceof 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();
}
}));
menu.showAtMouseEvent(e);
});
// Populate initial details, which now includes advance button state logic
await this._populateNoteItemDetails(noteEl, noteToRender, dateStr, selectedNotesArray);
return noteEl;
}
}

View file

@ -0,0 +1,491 @@
import { Notice, setIcon } from "obsidian";
import SpaceforgePlugin from "../../main";
import { EstimationUtils } from "../../utils/estimation";
export class PomodoroUIManager {
private plugin: SpaceforgePlugin;
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;
public pomodoroRootEl: HTMLElement | null = null; // Container for the actual timer content
private pomodoroTimerDisplayEl: HTMLElement | null = null;
private pomodoroStartBtn: HTMLButtonElement | null = null;
private pomodoroStopBtn: HTMLButtonElement | null = null;
private pomodoroSkipBtn: HTMLButtonElement | null = null;
private pomodoroQuickSettingsPanelEl: HTMLElement | null = null;
// private pomodoroQuickSettingsToggleBtn: HTMLElement | null = null; // Removed
private pomodoroQuickWorkInput: HTMLInputElement | null = null;
private pomodoroQuickShortInput: HTMLInputElement | null = null;
private pomodoroQuickLongInput: HTMLInputElement | null = null;
private pomodoroQuickSessionsInput: HTMLInputElement | null = null;
private pomodoroCalculationResultEl: HTMLElement | 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 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;
/**
* Saves the current values from the Pomodoro quick settings input fields.
* @returns true if settings were valid and saved, false otherwise.
*/
private _savePomodoroSettings(): boolean {
const work = parseInt(this.pomodoroQuickWorkInput?.value || "0");
const short = parseInt(this.pomodoroQuickShortInput?.value || "0");
const long = parseInt(this.pomodoroQuickLongInput?.value || "0");
const sessions = parseInt(this.pomodoroQuickSessionsInput?.value || "0");
if (work > 0 && short > 0 && long > 0 && sessions > 0) {
this.plugin.pomodoroService.updateDurations(work, short, long, sessions);
// new Notice("Pomodoro durations updated."); // Removed notification
return true;
} else {
new Notice("Invalid Pomodoro durations. Settings not saved. Please enter positive numbers.");
// 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);
return false;
}
}
constructor(plugin: SpaceforgePlugin) {
this.plugin = plugin;
// Elements will be created in attachAndRender
}
/**
* Attaches the Pomodoro UI to a given container and renders its initial state.
* Creates necessary sub-containers if they don't exist.
* @param container The parent element where the Pomodoro UI should be placed.
*/
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;
if (!this.pomodoroRootEl || this.pomodoroRootEl.parentElement !== this.attachedContainer) {
this.pomodoroRootEl?.remove();
this.pomodoroRootEl = this.attachedContainer.createDiv("pomodoro-section-content");
rootElWasCreated = true;
}
// Pomodoro section is always "open" now, so pomodoroRootEl should always be visible.
if (this.pomodoroRootEl) {
this.pomodoroRootEl.style.display = '';
}
// If pomodoroRootEl was just created or its children are missing, render its internal timer structure
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();
}
// getIsPomodoroSectionOpen, setIsPomodoroSectionOpen, setupPomodoroVisibilityToggleButton, updatePomodoroVisibility are no longer needed
// as the section is always visible and the toggle button is removed.
/** Controls the visibility of the entire attached Pomodoro UI section (e.g. for global plugin enable/disable) */
public showPomodoroSection(show: boolean): void {
if (this.attachedContainer) {
this.attachedContainer.style.display = show ? '' : 'none';
// If the section is hidden externally, we might want to ensure controls are visible when it's re-shown.
// For now, we'll let areMainControlsVisible persist.
}
}
/**
* Renders or updates the Pomodoro Timer UI elements into pomodoroRootEl.
* @param container The parent element to render into (this.pomodoroRootEl).
*/
private renderPomodoroTimer(container: HTMLElement): void {
// container.empty(); // REMOVED - We will find or create elements
let mainControlsRow = container.querySelector(".pomodoro-main-controls") as HTMLElement;
if (!mainControlsRow) {
mainControlsRow = container.createDiv("pomodoro-main-controls");
}
// Start Button
if (!this.pomodoroStartBtn || this.pomodoroStartBtn.parentElement !== mainControlsRow) {
this.pomodoroStartBtn?.remove();
this.pomodoroStartBtn = mainControlsRow.createEl("button", { cls: "pomodoro-start-btn" });
setIcon(this.pomodoroStartBtn, "play");
this.pomodoroStartBtn.addEventListener("click", () => this.plugin.pomodoroService.start());
}
// Stop Button
if (!this.pomodoroStopBtn || this.pomodoroStopBtn.parentElement !== mainControlsRow) {
this.pomodoroStopBtn?.remove();
this.pomodoroStopBtn = mainControlsRow.createEl("button", { cls: "pomodoro-stop-btn" });
setIcon(this.pomodoroStopBtn, "pause");
this.pomodoroStopBtn.addEventListener("click", () => this.plugin.pomodoroService.stop());
}
this.pomodoroStopBtn.hide(); // Initial state, updatePomodoroUI will manage visibility
// 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.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();
this.didLongPress = false;
this.didVeryLongPress = false;
this.longPressTimer = window.setTimeout(() => {
this.didLongPress = true; // Mark that the first threshold was met
// Action for long press (toggle buttons) will be decided on mouseup/touchend
// unless veryLongPressTimer also fires.
}, this.LONG_PRESS_DURATION);
this.veryLongPressTimer = window.setTimeout(() => {
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.updateButtonVisibility();
}, this.VERY_LONG_PRESS_DURATION);
});
const handlePressEnd = () => {
if (this.veryLongPressTimer) clearTimeout(this.veryLongPressTimer);
if (this.longPressTimer) clearTimeout(this.longPressTimer);
this.veryLongPressTimer = null;
this.longPressTimer = null;
if (this.didVeryLongPress) {
// Action for very long press (timer text + buttons) already taken by its timeout.
} else if (this.didLongPress) {
if (!this.isTimerTextVisible) { // If timer text was hidden
this.isTimerTextVisible = true; // Make text visible
this.areButtonsVisible = true; // Make buttons visible
} else { // Timer text was already visible
this.areButtonsVisible = !this.areButtonsVisible; // Just toggle buttons
}
this.updateTimerTextDisplay();
this.updateButtonVisibility();
} else { // Short click
if (this.isTimerTextVisible) { // Only toggle settings if timer text is visible
this.toggleSettingsPanel();
}
}
this.didLongPress = false;
this.didVeryLongPress = false;
};
this.pomodoroTimerDisplayEl.addEventListener("mouseup", handlePressEnd);
this.pomodoroTimerDisplayEl.addEventListener("touchend", handlePressEnd);
const cancelPress = () => {
if (this.veryLongPressTimer) clearTimeout(this.veryLongPressTimer);
if (this.longPressTimer) clearTimeout(this.longPressTimer);
this.veryLongPressTimer = null;
this.longPressTimer = null;
this.didLongPress = false;
this.didVeryLongPress = false;
};
this.pomodoroTimerDisplayEl.addEventListener("mouseleave", cancelPress);
this.pomodoroTimerDisplayEl.addEventListener("touchmove", cancelPress);
// Touchstart needs to mirror mousedown logic for setting up timers
this.pomodoroTimerDisplayEl.addEventListener("touchstart", (e) => {
e.preventDefault();
this.didLongPress = false;
this.didVeryLongPress = false;
this.longPressTimer = window.setTimeout(() => {
this.didLongPress = true;
}, this.LONG_PRESS_DURATION);
this.veryLongPressTimer = window.setTimeout(() => {
this.didVeryLongPress = true;
this.isTimerTextVisible = !this.isTimerTextVisible;
this.areButtonsVisible = this.isTimerTextVisible;
this.updateTimerTextDisplay();
this.updateButtonVisibility();
}, this.VERY_LONG_PRESS_DURATION);
}, { passive: false });
}
// Skip Button
if (!this.pomodoroSkipBtn || this.pomodoroSkipBtn.parentElement !== mainControlsRow) {
this.pomodoroSkipBtn?.remove();
this.pomodoroSkipBtn = mainControlsRow.createEl("button", { cls: "pomodoro-skip-btn" });
setIcon(this.pomodoroSkipBtn, "skip-forward");
this.pomodoroSkipBtn.addEventListener("click", () => this.plugin.pomodoroService.skipSession());
}
// Quick Settings Toggle Button - REMOVED
// if (!this.pomodoroQuickSettingsToggleBtn || this.pomodoroQuickSettingsToggleBtn.parentElement !== mainControlsRow) {
// this.pomodoroQuickSettingsToggleBtn?.remove();
// this.pomodoroQuickSettingsToggleBtn = mainControlsRow.createDiv("pomodoro-quick-settings-toggle");
// setIcon(this.pomodoroQuickSettingsToggleBtn, "settings");
// this.pomodoroQuickSettingsToggleBtn.setAttribute("aria-label", "Pomodoro Settings");
// 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';
// 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);
// }
// });
// }
// 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.style.display = 'none'; // 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");
settingDiv.createEl("label", { text: labelText });
const input = settingDiv.createEl("input", { type: inputType });
input.setAttr("min", "1");
return input;
};
this.pomodoroQuickWorkInput = createQuickSetting("Work (min):");
this.pomodoroQuickShortInput = createQuickSetting("Short Break (min):");
this.pomodoroQuickLongInput = createQuickSetting("Long Break (min):");
this.pomodoroQuickSessionsInput = createQuickSetting("Sessions/Long Break:");
const buttonsContainer = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-quick-settings-buttons" });
// const saveBtn = buttonsContainer.createEl("button", { text: "Save", cls: "pomodoro-quick-save-btn" });
// saveBtn.addEventListener("click", () => {
// const work = parseInt(this.pomodoroQuickWorkInput?.value || "0");
// const short = parseInt(this.pomodoroQuickShortInput?.value || "0");
// const long = parseInt(this.pomodoroQuickLongInput?.value || "0");
// const sessions = parseInt(this.pomodoroQuickSessionsInput?.value || "0");
// if (work > 0 && short > 0 && long > 0 && sessions > 0) {
// this.plugin.pomodoroService.updateDurations(work, short, long, sessions);
// this.pomodoroQuickSettingsPanelEl?.hide();
// new Notice("Pomodoro durations updated.");
// } else {
// new Notice("Please enter valid positive numbers for durations.");
// }
// });
const calculateBtn = buttonsContainer.createEl("button", { text: "Calculate Reading Time", cls: "pomodoro-quick-calculate-btn" });
calculateBtn.addEventListener("click", async () => {
const settingsSaved = this._savePomodoroSettings();
if (settingsSaved) {
await this.calculateAndDisplayPomodoroEstimate();
}
});
this.pomodoroCalculationResultEl = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-calculation-result" });
this.pomodoroCalculationResultEl.style.display = 'none';
}
this.updatePomodoroUI(); // Ensure UI reflects current state after potential recreation
}
/**
* Calculates the estimated Pomodoro cycles for today's notes and displays it.
*/
private async calculateAndDisplayPomodoroEstimate(): Promise<void> {
if (!this.plugin || !this.pomodoroCalculationResultEl) return;
// Use notes from the review controller, which are context-aware (selected date or actual today)
const notesForEstimate = this.plugin.reviewController.getTodayNotes();
if (notesForEstimate.length === 0) {
const activeDate = this.plugin.reviewController.getCurrentReviewDateOverride();
const message = activeDate
? `No notes scheduled for ${new Date(activeDate).toLocaleDateString()} to calculate.`
: "No notes currently due to calculate.";
this.pomodoroCalculationResultEl.setText(message);
this.pomodoroCalculationResultEl.style.display = 'block';
return;
}
let totalReadingTimeInSeconds = 0;
for (const note of notesForEstimate) {
totalReadingTimeInSeconds += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
const totalReadingTimeInMinutes = totalReadingTimeInSeconds / 60;
const settings = this.plugin.settings;
const workDuration = settings.pomodoroWorkDuration;
const shortBreakDuration = settings.pomodoroShortBreakDuration;
const longBreakDuration = settings.pomodoroLongBreakDuration;
const sessionsUntilLongBreak = settings.pomodoroSessionsUntilLongBreak;
let pomodorosNeeded = 0;
let sessionsCompletedInCycle = 0;
let remainingReadingTimeMinutes = totalReadingTimeInMinutes;
let totalBreakTimeInMinutes = 0;
if (totalReadingTimeInMinutes === 0) {
this.pomodoroCalculationResultEl.setText("Estimated reading time is 0 minutes.");
this.pomodoroCalculationResultEl.style.display = 'block';
return;
}
while (remainingReadingTimeMinutes > 0) {
pomodorosNeeded++;
remainingReadingTimeMinutes -= workDuration;
sessionsCompletedInCycle++;
if (remainingReadingTimeMinutes <= 0) break;
if (sessionsCompletedInCycle >= sessionsUntilLongBreak) {
totalBreakTimeInMinutes += longBreakDuration;
sessionsCompletedInCycle = 0;
} else {
totalBreakTimeInMinutes += shortBreakDuration;
}
}
const totalTimeWithBreaksMinutes = (pomodorosNeeded * workDuration) + totalBreakTimeInMinutes;
const formattedTotalReadingTime = EstimationUtils.formatTime(totalReadingTimeInSeconds);
const formattedTotalTimeWithBreaks = EstimationUtils.formatTime(Math.ceil(totalTimeWithBreaksMinutes * 60));
this.pomodoroCalculationResultEl.empty();
this.pomodoroCalculationResultEl.createEl("p", { text: `Estimated reading time for ${notesForEstimate.length} note(s) in current view: ${formattedTotalReadingTime}.` });
this.pomodoroCalculationResultEl.createEl("p", { text: `Requires ~${pomodorosNeeded} Pomodoro work session(s).` });
this.pomodoroCalculationResultEl.createEl("p", { text: `Total time with breaks: ~${formattedTotalTimeWithBreaks}.` });
this.pomodoroCalculationResultEl.style.display = 'block';
}
/**
* Updates the Pomodoro UI based on the current state from PomodoroService.
*/
private toggleSettingsPanel(): void {
const panel = this.pomodoroQuickSettingsPanelEl;
if (!panel) return;
const isCurrentlyHidden = panel.style.display === 'none' || !panel.style.display;
panel.style.display = isCurrentlyHidden ? 'flex' : 'none';
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);
} else {
// Panel is being closed, so save the settings
this._savePomodoroSettings();
}
}
private updateTimerTextDisplay(): void {
if (this.pomodoroTimerDisplayEl) {
// This makes the text invisible but keeps the element in layout
this.pomodoroTimerDisplayEl.style.opacity = this.isTimerTextVisible ? '1' : '0';
// Or, to truly remove text content:
// this.pomodoroTimerDisplayEl.setText(this.isTimerTextVisible ? this.plugin.pomodoroService.getFormattedTimeLeft() : '');
// 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;
}
/**
* 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
if (!this.attachedContainer || !this.pomodoroRootEl) {
return;
}
// Ensure the root container is visible (it should always be, managed by attachAndRender)
this.pomodoroRootEl.style.display = '';
const state = this.plugin.pluginState;
const service = this.plugin.pomodoroService;
if (this.pomodoroTimerDisplayEl) {
this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft());
this.pomodoroTimerDisplayEl.className = 'pomodoro-timer-display pomodoro-timer-fade'; // Reset classes
if (state.pomodoroCurrentMode !== 'idle') {
this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`);
} else {
this.pomodoroTimerDisplayEl.addClass('mode-idle');
}
if (state.pomodoroIsRunning) {
this.pomodoroTimerDisplayEl.addClass('timer-visible');
} else {
this.pomodoroTimerDisplayEl.removeClass('timer-visible');
}
}
// This 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');
}
// '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.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';
}
}
}

177
utils/dates.ts Normal file
View file

@ -0,0 +1,177 @@
/**
* Utility functions for date operations
*/
export class DateUtils {
/**
* Get start of day timestamp for a given date
*
* @param date Date to get start of day for
* @returns Timestamp for start of day
*/
static startOfDay(date: Date = new Date()): number {
const newDate = new Date(date);
newDate.setHours(0, 0, 0, 0);
return newDate.getTime();
}
/**
* Add days to a timestamp
*
* @param timestamp Base timestamp
* @param days Number of days to add
* @returns New timestamp
*/
static addDays(timestamp: number, days: number): number {
return timestamp + (days * 24 * 60 * 60 * 1000);
}
/**
* Format a timestamp as a readable date string
*
* @param timestamp Timestamp to format
* @param format Format type ('short', 'medium', 'long', 'relative')
* @param baseDateParam Optional base date for relative formatting
* @returns Formatted date string
*/
static formatDate(timestamp: number, format: 'short' | 'medium' | 'long' | 'relative' = 'medium', baseDateParam?: Date | null): string {
const noteEventDate = new Date(timestamp); // The date of the note/event
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
if (normalizedNoteEventDate < normalizedActualCurrentDate) {
return 'Due notes';
}
// If baseDateParam is set, we are in a calendar view for a specific day.
// The label for the group of notes (which are all for baseDateParam's day)
// should reflect how baseDateParam's day relates to the actual current day.
if (baseDateParam) {
const normalizedBaseDate = this.startOfDay(new Date(baseDateParam)); // The day being viewed in the calendar
if (normalizedBaseDate === normalizedActualCurrentDate) {
return 'Today'; // e.g., Calendar view is set to actual today
} else if (normalizedBaseDate === this.startOfDay(new Date(this.addDays(normalizedActualCurrentDate, 1)))) {
return 'Tomorrow'; // e.g., Calendar view is set to actual tomorrow
} else {
// Calendar view is set to a specific day that is not actual today or actual tomorrow.
// This includes future days beyond tomorrow, or past days (which aren't "Due notes" but >= actualCurrentDate).
// Display the specific date of the calendar view.
return new Date(normalizedBaseDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
} else {
// No baseDateParam, so this is the default dashboard view.
// Comparisons are relative to the actual current date.
// We already know normalizedNoteEventDate >= normalizedActualCurrentDate from the "Due notes" check.
const diffInDays = Math.floor((normalizedNoteEventDate - normalizedActualCurrentDate) / (24 * 60 * 60 * 1000));
if (diffInDays === 0) {
return 'Today'; // Note is scheduled for actual today
} else if (diffInDays === 1) {
return 'Tomorrow'; // Note is scheduled for actual tomorrow
} else { // diffInDays > 1
return `In ${diffInDays} days`; // Note is scheduled further in the future
}
}
} else if (format === 'short') {
return noteEventDate.toLocaleDateString();
} else if (format === 'long') {
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
} else {
// Medium format (default)
return noteEventDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
}
/**
* Get the day difference between two timestamps
*
* @param timestamp1 First timestamp
* @param timestamp2 Second timestamp
* @returns Difference in days
*/
static dayDifference(timestamp1: 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;
}
/**
* Get start of UTC day timestamp for a given date
*
* @param date Date to get start of UTC day for
* @returns Timestamp for start of UTC day (00:00:00.000Z)
*/
static startOfUTCDay(date: Date = new Date()): number {
const newDate = new Date(date.getTime()); // Create a new Date object from the timestamp
newDate.setUTCHours(0, 0, 0, 0);
return newDate.getTime();
}
/**
* Get end of UTC day timestamp for a given date
*
* @param date Date to get end of UTC day for
* @returns Timestamp for end of UTC day (23:59:59.999Z)
*/
static endOfUTCDay(date: Date = new Date()): number {
const newDate = new Date(date.getTime()); // Create a new Date object from the timestamp
newDate.setUTCHours(23, 59, 59, 999);
return newDate.getTime();
}
/**
* Get the day difference between two timestamps based on UTC days
*
* @param timestamp1 First timestamp
* @param timestamp2 Second timestamp
* @returns Difference in UTC days
*/
static dayDifferenceUTC(timestamp1: 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;
}
/**
* Check if two dates are the same day, ignoring time.
* @param date1 The first date.
* @param date2 The second date.
* @returns True if both dates fall on the same day, false otherwise.
*/
static isSameDay(date1: Date, date2: Date): boolean {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
}
}

189
utils/estimation.ts Normal file
View file

@ -0,0 +1,189 @@
import { TFile } from 'obsidian';
import SpaceforgePlugin from '../main';
/**
* Utility functions for time estimation
*/
export class EstimationUtils {
/**
* Reading speeds for different content types (words per minute)
* Used as fallback and for content-specific adjustments
*/
private static readonly READING_SPEEDS = {
notes: 200, // General notes
technical: 100, // Technical content
fiction: 250, // Fiction/prose
simple: 300 // Simple content
};
/**
* Average English word length in characters (including spaces)
*/
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
*
* @param plugin Reference to the main plugin
*/
static setPlugin(plugin: SpaceforgePlugin): void {
this.plugin = plugin;
}
/**
* Get the user's reading speed from settings
*
* @param contentType Optional content type for adjustment
* @returns Reading speed in words per minute
*/
static getReadingSpeed(contentType?: 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
*
* @param file The file to estimate review time for
* @param fileContent Optional file content (to avoid reading file again)
* @param contentType Type of content for reading speed adjustment
* @returns Estimated review time in seconds
*/
static async estimateReviewTime(
file: TFile,
fileContent?: string,
contentType: keyof typeof EstimationUtils.READING_SPEEDS = 'notes'
): Promise<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
*
* @param paths Array of note paths
* @returns Total estimated review time in seconds
*/
static async calculateTotalReviewTime(paths: string[]): Promise<number> {
if (!this.plugin) {
return paths.length * this.MIN_REVIEW_TIME;
}
let totalTime = 0;
for (const path of paths) {
totalTime += await this.plugin.dataStorage.estimateReviewTime(path);
}
return totalTime;
}
/**
* Count words in text
*
* @param text Text to count words in
* @returns Number of words
*/
static countWords(text: string): number {
// Remove Markdown formatting
const cleanText = text
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/\[.*?\]\(.*?\)/g, '') // Remove links
.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
*
* @param seconds Time in seconds
* @returns Formatted time string (e.g., "5 min" or "1 hr 30 min")
*/
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 {
return `${hours} hr ${remainingMinutes} min`;
}
}
}
/**
* Format a time estimate with color coding based on duration
*
* @param seconds Time in seconds
* @param element HTML element to update
* @returns Formatted HTML time string with color coding
*/
static formatTimeWithColor(seconds: 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");
element.removeClass("review-time-medium");
element.removeClass("review-time-long");
} else if (seconds < 15 * 60) { // 5-15 minutes
element.addClass("review-time-medium");
element.removeClass("review-time-short");
element.removeClass("review-time-long");
} else { // More than 15 minutes
element.addClass("review-time-long");
element.removeClass("review-time-short");
element.removeClass("review-time-medium");
}
}
}

66
utils/event-emitter.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Simple event emitter implementation
*/
export class EventEmitter {
/**
* Event listeners by event name
*/
private listeners: Record<string, Function[]> = {};
/**
* 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 {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
/**
* Emit an event
*
* @param event Event name
* @param args Arguments to pass to listeners
*/
emit(event: string, ...args: any[]): void {
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 {
if (!this.listeners[event]) {
return;
}
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
}
/**
* Remove all listeners for an event
*
* @param event Event name
*/
removeAllListeners(event?: string): void {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
}

549
utils/link-analyzer.ts Normal file
View file

@ -0,0 +1,549 @@
import { TFile, TFolder, Vault } from 'obsidian';
/**
* Represents a link to another note
*/
export interface NoteLink {
/**
* The text content of the link
*/
text: string;
/**
* Whether this is an embed link (![[...]]) or a regular link ([[...]])
*/
isEmbed: boolean;
}
/**
* Represents a node in the file link graph
*/
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)
*/
incomingLinkCount: number;
}
/**
* Represents a review hierarchy for a group of files
*/
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
*/
traversalOrder: string[];
}
/**
* Utility class for analyzing links between files
*/
export class LinkAnalyzer {
/**
* Regular expression for finding wikilinks of both forms: [[filename]] and ![[filename]]
* First capture group matches the exclamation mark (if present)
* Second capture group matches the content inside the brackets
*/
private static readonly LINK_REGEX = /(!?)(?:\[\[(.*?)\]\])/g;
/**
* Analyze links in a folder and build a review hierarchy
*
* @param vault Obsidian vault
* @param folder Folder to analyze
* @param includeSubfolders Whether to include subfolders
* @returns Review hierarchy for the folder
*/
static async analyzeFolder(
vault: Vault,
folder: TFolder,
includeSubfolders: boolean
): Promise<ReviewHierarchy> {
// Get all markdown files in the folder
const files = vault.getMarkdownFiles().filter(file => {
if (includeSubfolders) {
return file.path.startsWith(folder.path);
} else {
const parentPath = file.parent ? file.parent.path : "";
return parentPath === folder.path;
}
});
// Initialize nodes
const nodes: Record<string, FileNode> = {};
for (const file of files) {
nodes[file.path] = {
path: file.path,
outgoingLinks: [],
regularLinks: [],
embedLinks: [],
incomingLinks: [],
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
const resolvedPath = this.resolveLink(noteLink.text, file.path, files);
if (resolvedPath && nodes[resolvedPath]) {
// 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)) {
node.embedLinks.push(resolvedPath);
}
} else {
if (!node.regularLinks.includes(resolvedPath)) {
node.regularLinks.push(resolvedPath);
}
}
}
// Update incoming links for the target
const targetNode = nodes[resolvedPath];
if (!targetNode.incomingLinks.includes(file.path)) {
targetNode.incomingLinks.push(file.path);
targetNode.incomingLinkCount++;
}
}
}
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
}
}
// 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
*
* @param content Markdown content
* @returns Array of note links with information about whether they are embeds
*/
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({
text: match[2], // The link text is now in the second capture group
isEmbed: match[1] === '!' // True if it has an exclamation mark
});
}
return links;
}
/**
* Resolve a link to a full file path
*
* @param link Link text
* @param sourcePath Path of the source file
* @param allFiles All available files
* @returns Resolved file path or null if not found
*/
static resolveLink(link: string, sourcePath: string, allFiles: TFile[]): string | null {
// Handle links with .md extension explicitly
if (link.endsWith('.md')) {
// Try to find the exact file
const exactFile = allFiles.find(f => f.path.endsWith('/' + link) || f.path === link);
if (exactFile) {
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
*
* @param nodes All file nodes
* @returns Array with the path of the best starting node
*/
private static findStartingNode(nodes: Record<string, FileNode>): string[] {
console.log("LinkAnalyzer: Starting node selection process...");
// If we have no nodes, return an empty array
if (Object.keys(nodes).length === 0) {
console.log("LinkAnalyzer: No nodes found, returning empty array.");
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]) {
folderNodes[folderPath] = [];
}
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
console.log(`LinkAnalyzer: Checking for exact match with folder name "${folderName}" in folder "${folderPath}"...`);
for (const path of filesInFolder) {
const fileName = path.split('/').pop()?.toLowerCase() || '';
const fileNameWithoutExt = fileName.replace(/\.md$/, '');
if (fileNameWithoutExt === folderName) {
console.log(`LinkAnalyzer: Selected main file with exact folder name match: ${path}`);
return [path];
}
}
// SECOND PRIORITY: Partial match with folder name
console.log(`LinkAnalyzer: Checking for partial match with folder name "${folderName}"...`);
for (const path of filesInFolder) {
const fileName = path.split('/').pop()?.toLowerCase() || '';
const fileNameWithoutExt = fileName.replace(/\.md$/, '');
if (fileNameWithoutExt.includes(folderName) || folderName.includes(fileNameWithoutExt)) {
console.log(`LinkAnalyzer: Selected main file with partial folder name match: ${path}`);
return [path];
}
}
// THIRD PRIORITY: Common index or main file patterns
console.log("LinkAnalyzer: Checking for common index or main file patterns...");
for (const path of filesInFolder) {
const fileName = path.split('/').pop()?.toLowerCase() || '';
const fileNameWithoutExt = fileName.replace(/\.md$/, '');
if (fileNameWithoutExt === 'index' ||
fileNameWithoutExt === 'main' ||
fileNameWithoutExt.includes('index') ||
fileNameWithoutExt.includes('readme') ||
fileNameWithoutExt.includes('main')) {
console.log(`LinkAnalyzer: Selected main file by standard name pattern: ${path}`);
return [path];
}
}
}
// FOURTH PRIORITY: Look for files with the most outgoing links
// Prefer regular links over embeds
console.log("LinkAnalyzer: Checking for files with the most outgoing links...");
const sortedNodes = Object.values(nodes)
.sort((a, b) => {
// First, prioritize by regular link count (non-embed links)
const regularLinkDiff = b.regularLinks.length - a.regularLinks.length;
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 &&
(sortedNodes[0].regularLinks.length > 0 || sortedNodes[0].outgoingLinks.length > 0)) {
// Return the node with the most links
console.log(`LinkAnalyzer: Selected main file by link count: ${sortedNodes[0].path} with ${sortedNodes[0].regularLinks.length} regular links`);
return [sortedNodes[0].path];
}
// FALLBACK: Use all files as root nodes if none of the above criteria match
console.log(`LinkAnalyzer: No clear main file identified, using first file as root node`);
return Object.keys(nodes).length > 0 ? [Object.keys(nodes)[0]] : [];
}
/**
* Find root nodes (files with the most incoming links)
* For backward compatibility, retained but replaced by findStartingNode
*
* @param nodes All file nodes
* @returns Array of root node paths
*/
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
*
* @param nodes All file nodes
* @param startNodePath Path to the starting node
* @returns Array of file paths in traversal order
*/
private static createOrderedTraversal(
nodes: Record<string, FileNode>,
startNodePath: string
): 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) {
// Extract folder path from file path
const folderPath = path.substring(0, path.lastIndexOf('/'));
fileFolders.set(path, folderPath);
}
const mainFolder = fileFolders.get(startNodePath) || "";
console.log(`LinkAnalyzer: Starting ordered traversal from: ${startNodePath} in main folder: ${mainFolder || "vault root"}`);
// Helper function for depth-first traversal, respecting link order and folder constraints
const traverse = (currentNodePath: string, currentDepth: number = 0, currentMainFolder: string) => {
if (visited.has(currentNodePath)) {
console.log(`${" ".repeat(currentDepth)}LinkAnalyzer: Already visited: ${currentNodePath}`);
return;
}
const node = nodes[currentNodePath];
if (!node) {
console.warn(`${" ".repeat(currentDepth)}LinkAnalyzer: Node not found for path: ${currentNodePath}`);
return;
}
console.log(`${" ".repeat(currentDepth)}LinkAnalyzer: Traversing: ${currentNodePath}`);
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
console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Skipping unknown linked path: ${linkedPath}`);
continue;
}
const linkedNodeFolder = fileFolders.get(linkedPath) || "";
// MODIFIED RULE: Traverse if the linked note is within the scope of the main analysis folder.
if (nodes[linkedPath] && linkedNodeFolder.startsWith(currentMainFolder)) { // Ensure it's a known node and within the main hierarchy
if (!visited.has(linkedPath)) { // Avoid re-traversing already processed branches
console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Following link within main hierarchy to: ${linkedPath}`);
traverse(linkedPath, currentDepth + 1, currentMainFolder);
} else {
console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Link to already visited node in main hierarchy: ${linkedPath}`);
}
} else {
// This link goes outside the main folder being analyzed or is not a known node.
console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Ignoring link outside main hierarchy or unknown node: ${linkedPath} (from ${currentNodeFolder} to ${linkedNodeFolder})`);
}
}
};
// Start traversal from the designated startNodePath, if it exists
if (nodes[startNodePath]) {
traverse(startNodePath, 0, mainFolder);
} else {
console.warn(`LinkAnalyzer: Start node ${startNodePath} not found in nodes. Traversal order might be empty or incomplete.`);
}
// 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
console.log(`LinkAnalyzer: Final traversal order from ${startNodePath} (in folder '${mainFolder || "vault root"}') has ${traversalOrder.length} files: ${traversalOrder.join(' -> ')}`);
return traversalOrder;
}
/**
* Create a traversal order for reviewing files
*
* @param nodes All file nodes
* @param rootNodes Root node paths
* @returns Array of file paths in traversal order
*/
private static createTraversalOrder(
nodes: Record<string, FileNode>,
rootNodes: string[]
): string[] {
// If there's a single root node, use the ordered traversal
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)) {
traversalOrder.push(nodePath);
visited.add(nodePath);
}
}
return traversalOrder;
}
/**
* Analyze links in a single note
*
* @param vault Obsidian vault
* @param filePath Path to the note file
* @param regularOnly Whether to include only regular wiki links (not embeds)
* @returns Array of resolved link paths in the order they appear
*/
static async analyzeNoteLinks(
vault: Vault,
filePath: string,
regularOnly: boolean = 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)
: noteLinks;
// Process links in the exact order they appear in the document
for (const link of filteredLinks) {
const resolvedPath = this.resolveLink(
link.text,
filePath,
vault.getMarkdownFiles()
);
if (resolvedPath && !seenLinks.has(resolvedPath)) {
resolvedLinks.push(resolvedPath);
seenLinks.add(resolvedPath);
}
}
console.log(`LinkAnalyzer: Analyzed links in ${filePath}, found ${resolvedLinks.length} unique links in order`);
return resolvedLinks;
} catch (error) {
console.error(`LinkAnalyzer: Error analyzing links in ${filePath}:`, error);
return [];
}
}
}

20
version-bump.mjs Normal file
View file

@ -0,0 +1,20 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.argv[2];
const minAppVersion = process.argv[3];
// Read minAppVersion from existing manifest.json if not provided
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion: currentMinAppVersion } = manifest;
// Write updated manifest.json
manifest.version = targetVersion;
manifest.minAppVersion = minAppVersion ?? currentMinAppVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, 2));
// Update versions.json with the new version
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion ?? currentMinAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, 2));
console.log(`Updated to version ${targetVersion}`);

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "Initial release"
}