From 54e2b782ba8e05420cfb81905640b5863f7186db Mon Sep 17 00:00:00 2001 From: Dralk Date: Thu, 31 Jul 2025 07:03:20 +0300 Subject: [PATCH] spaceforge --- LICENSE | 9 + README.md | 144 + api/claude-service.ts | 240 + api/gemini-service.ts | 194 + api/mcq-generation-service.ts | 8 + api/ollama-service.ts | 189 + api/openai-service.ts | 188 + api/openrouter-service.ts | 323 + api/together-service.ts | 218 + controllers/README.md | 24 + controllers/interfaces.ts | 189 + controllers/review-batch-controller.ts | 269 + controllers/review-controller-core.ts | 561 + controllers/review-controller-mcq.ts | 318 + controllers/review-controller.ts | 321 + controllers/review-navigation-controller.ts | 236 + controllers/review-session-controller.ts | 96 + data-storage.ts | 514 + esbuild.config.mjs | 54 + install.js | 91 + main.js | 10515 ++++++++++++++++++ main.ts | 684 ++ manifest.json | 10 + models/mcq.ts | 120 + models/plugin-data.ts | 65 + models/review-schedule.ts | 202 + models/review-session.ts | 113 + models/settings.ts | 383 + package-lock.json | 2473 ++++ package.json | 34 + services/fsrs-schedule-service.ts | 113 + services/mcq-service.ts | 165 + services/pomodoro-service.ts | 316 + services/review-history-service.ts | 46 + services/review-schedule-service.ts | 1104 ++ services/review-session-service.ts | 210 + styles.css | 3165 ++++++ styles/_animations.css | 67 + styles/_buttons.css | 81 + styles/_components.css | 161 + styles/_variables.css | 65 + styles/calendar.css | 514 + styles/mcq.css | 1015 ++ styles/pomodoro.css | 225 + styles/responsive.css | 175 + styles/review-buttons.css | 261 + styles/settings.css | 187 + styles/sidebar.css | 890 ++ styles/styles.css | 14 + tsconfig.json | 25 + ui/batch-review-modal.ts | 374 + ui/calendar-view.ts | 285 + ui/consolidated-mcq-modal.ts | 717 ++ ui/context-menu.ts | 344 + ui/mcq-modal.ts | 503 + ui/review-modal.ts | 176 + ui/settings-tab.ts | 1197 ++ ui/sidebar-view.ts | 420 + ui/sidebar/list-view-renderer.ts | 865 ++ ui/sidebar/note-item-renderer.ts | 361 + ui/sidebar/pomodoro-ui-manager.ts | 491 + utils/dates.ts | 177 + utils/estimation.ts | 189 + utils/event-emitter.ts | 66 + utils/link-analyzer.ts | 549 + version-bump.mjs | 20 + versions.json | 3 + 67 files changed, 34321 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/claude-service.ts create mode 100644 api/gemini-service.ts create mode 100644 api/mcq-generation-service.ts create mode 100644 api/ollama-service.ts create mode 100644 api/openai-service.ts create mode 100644 api/openrouter-service.ts create mode 100644 api/together-service.ts create mode 100644 controllers/README.md create mode 100644 controllers/interfaces.ts create mode 100644 controllers/review-batch-controller.ts create mode 100644 controllers/review-controller-core.ts create mode 100644 controllers/review-controller-mcq.ts create mode 100644 controllers/review-controller.ts create mode 100644 controllers/review-navigation-controller.ts create mode 100644 controllers/review-session-controller.ts create mode 100644 data-storage.ts create mode 100644 esbuild.config.mjs create mode 100644 install.js create mode 100644 main.js create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 models/mcq.ts create mode 100644 models/plugin-data.ts create mode 100644 models/review-schedule.ts create mode 100644 models/review-session.ts create mode 100644 models/settings.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 services/fsrs-schedule-service.ts create mode 100644 services/mcq-service.ts create mode 100644 services/pomodoro-service.ts create mode 100644 services/review-history-service.ts create mode 100644 services/review-schedule-service.ts create mode 100644 services/review-session-service.ts create mode 100644 styles.css create mode 100644 styles/_animations.css create mode 100644 styles/_buttons.css create mode 100644 styles/_components.css create mode 100644 styles/_variables.css create mode 100644 styles/calendar.css create mode 100644 styles/mcq.css create mode 100644 styles/pomodoro.css create mode 100644 styles/responsive.css create mode 100644 styles/review-buttons.css create mode 100644 styles/settings.css create mode 100644 styles/sidebar.css create mode 100644 styles/styles.css create mode 100644 tsconfig.json create mode 100644 ui/batch-review-modal.ts create mode 100644 ui/calendar-view.ts create mode 100644 ui/consolidated-mcq-modal.ts create mode 100644 ui/context-menu.ts create mode 100644 ui/mcq-modal.ts create mode 100644 ui/review-modal.ts create mode 100644 ui/settings-tab.ts create mode 100644 ui/sidebar-view.ts create mode 100644 ui/sidebar/list-view-renderer.ts create mode 100644 ui/sidebar/note-item-renderer.ts create mode 100644 ui/sidebar/pomodoro-ui-manager.ts create mode 100644 utils/dates.ts create mode 100644 utils/estimation.ts create mode 100644 utils/event-emitter.ts create mode 100644 utils/link-analyzer.ts create mode 100644 version-bump.mjs create mode 100644 versions.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91ea89c --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d027a20 --- /dev/null +++ b/README.md @@ -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. diff --git a/api/claude-service.ts b/api/claude-service.ts new file mode 100644 index 0000000..7021e25 --- /dev/null +++ b/api/claude-service.ts @@ -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 { + 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/api/gemini-service.ts b/api/gemini-service.ts new file mode 100644 index 0000000..f0ae305 --- /dev/null +++ b/api/gemini-service.ts @@ -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 { + 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/api/mcq-generation-service.ts b/api/mcq-generation-service.ts new file mode 100644 index 0000000..2c5bbc5 --- /dev/null +++ b/api/mcq-generation-service.ts @@ -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; +} diff --git a/api/ollama-service.ts b/api/ollama-service.ts new file mode 100644 index 0000000..f024892 --- /dev/null +++ b/api/ollama-service.ts @@ -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 { + 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/api/openai-service.ts b/api/openai-service.ts new file mode 100644 index 0000000..3f98584 --- /dev/null +++ b/api/openai-service.ts @@ -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 { + 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/api/openrouter-service.ts b/api/openrouter-service.ts new file mode 100644 index 0000000..2a98817 --- /dev/null +++ b/api/openrouter-service.ts @@ -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 { + // 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/api/together-service.ts b/api/together-service.ts new file mode 100644 index 0000000..79adbce --- /dev/null +++ b/api/together-service.ts @@ -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 { + 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 { + 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 and tags from the question text + questionText = questionText.replace(//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 []; + } + } +} diff --git a/controllers/README.md b/controllers/README.md new file mode 100644 index 0000000..6c71888 --- /dev/null +++ b/controllers/README.md @@ -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. diff --git a/controllers/interfaces.ts b/controllers/interfaces.ts new file mode 100644 index 0000000..6c20bc6 --- /dev/null +++ b/controllers/interfaces.ts @@ -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; + + /** + * Review the current note + */ + reviewCurrentNote(): Promise; + + /** + * Review a note + * + * @param path Path to the note file + */ + reviewNote(path: string): Promise; + + /** + * 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; + + /** + * Skip the review of a note and reschedule for tomorrow with penalty + * + * @param path Path to the note file + */ + skipReview(path: string): Promise; + + /** + * 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; + + /** + * Handle a note being postponed, updating navigation state + * + * @param path Path to the postponed note + */ + handleNotePostponed(path: string): Promise; + + /** + * 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; + + /** + * 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; + + /** + * Navigate to the previous note in the current order + */ + navigateToPreviousNote(): Promise; + + /** + * Navigate to the next note without recording a review + */ + navigateToNextNoteWithoutRating(): Promise; + + /** + * Navigate to the current note without showing review modal + */ + navigateToCurrentNoteWithoutModal(): Promise; + + /** + * Open a note without showing the review modal + * + * @param path Path to the note file + */ + openNoteWithoutReview(path: string): Promise; + + /** + * 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; +} + +/** + * Interface for the batch review controller + */ +export interface IReviewBatchController { + /** + * Start reviewing all of today's notes + */ + reviewAllTodaysNotes(): Promise; + + /** + * 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; + + /** + * Review all notes with MCQs in a batch + * + * @param useMCQ Whether to use MCQs for testing + */ + reviewAllNotesWithMCQ(useMCQ?: boolean): Promise; + + /** + * Regenerate MCQs for all notes due today + */ + regenerateAllMCQs(): Promise; + + /** + * 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; + + /** + * Remove a specific set of notes from the review schedule + * + * @param paths Array of note paths to remove + */ + removeNotes(paths: string[]): Promise; +} + +/** + * 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[]; +} diff --git a/controllers/review-batch-controller.ts b/controllers/review-batch-controller.ts new file mode 100644 index 0000000..66600bd --- /dev/null +++ b/controllers/review-batch-controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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.`); + } +} diff --git a/controllers/review-controller-core.ts b/controllers/review-controller-core.ts new file mode 100644 index 0000000..0a5aaab --- /dev/null +++ b/controllers/review-controller-core.ts @@ -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 = 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 = 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 { + 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 { + // 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/controllers/review-controller-mcq.ts b/controllers/review-controller-mcq.ts new file mode 100644 index 0000000..484387d --- /dev/null +++ b/controllers/review-controller-mcq.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/controllers/review-controller.ts b/controllers/review-controller.ts new file mode 100644 index 0000000..9d29fbe --- /dev/null +++ b/controllers/review-controller.ts @@ -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 { + 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 { + await this.coreController.reviewCurrentNote(); + } + + /** + * Review a specific note + * Delegates to core controller + * + * @param path Path to the note file + */ + async reviewNote(path: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.batchController.reviewAllTodaysNotes(); + } + + /** + * Navigate to the next note + * Delegates to navigation controller + */ + async navigateToNextNote(): Promise { + await this.navigationController.navigateToNextNote(); + } + + /** + * Navigate to the previous note + * Delegates to navigation controller + */ + async navigateToPreviousNote(): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.batchController.reviewAllNotesWithMCQ(useMCQ); + } +} diff --git a/controllers/review-navigation-controller.ts b/controllers/review-navigation-controller.ts new file mode 100644 index 0000000..e135764 --- /dev/null +++ b/controllers/review-navigation-controller.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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}`); + } +} diff --git a/controllers/review-session-controller.ts b/controllers/review-session-controller.ts new file mode 100644 index 0000000..f075889 --- /dev/null +++ b/controllers/review-session-controller.ts @@ -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 = 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 { + 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(); + } + } +} diff --git a/data-storage.ts b/data-storage.ts new file mode 100644 index 0000000..2f0b40e --- /dev/null +++ b/data-storage.ts @@ -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; + + /** + * Review history items + */ + history: ReviewHistoryItem[]; + + /** + * Review sessions + */ + reviewSessions: ReviewSessionStore; + + /** + * Multiple-choice question sets + */ + mcqSets: Record; + + /** + * Multiple-choice question session history + */ + mcqSessions: Record; + + /** + * 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; + 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} True if any cleanup was performed, false otherwise. + */ + public async cleanupNonExistentFiles(): Promise { + 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(); + + // 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 { + await this.reviewScheduleService.scheduleNoteForReview(path, daysFromNow); + // await this.saveData(); // Removed + } + + async recordReview(path: string, response: ReviewResponse, isSkipped: boolean = false): Promise { + 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 { + await this.reviewScheduleService.skipNote(path, response); + // await this.saveData(); // Removed + } + + async postponeNote(path: string, days: number = 1): Promise { + await this.reviewScheduleService.postponeNote(path, days); + // await this.saveData(); // Removed + } + + async removeFromReview(path: string): Promise { + await this.reviewScheduleService.removeFromReview(path); + // await this.saveData(); // Removed + } + + async clearAllSchedules(): Promise { + await this.reviewScheduleService.clearAllSchedules(); + // await this.saveData(); // Removed + } + + async estimateReviewTime(path: string): Promise { + // 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 { + const session = await this.reviewSessionService.createReviewSession(folderPath, name); + // if (session) { // Removed save call + // await this.saveData(); + // } + return session; + } + + async setActiveSession(sessionId: string | null): Promise { + 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 { + const moreFiles = await this.reviewSessionService.advanceActiveSession(); + // await this.saveData(); // Removed + return moreFiles; + } + + async scheduleNotesInOrder(paths: string[], daysFromNow: number = 0): Promise { + const count = await this.reviewScheduleService.scheduleNotesInOrder(paths, daysFromNow); + // if (count > 0) { // Removed save call + // await this.saveData(); + // } + return count; + } + + async scheduleSessionForReview(sessionId: string): Promise { + // 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 { + 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 { + 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 { + 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 +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..66c3e37 --- /dev/null +++ b/esbuild.config.mjs @@ -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 ⚡'); +} diff --git a/install.js b/install.js new file mode 100644 index 0000000..ca17ffc --- /dev/null +++ b/install.js @@ -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); + }); +} \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..34a1d89 --- /dev/null +++ b/main.js @@ -0,0 +1,10515 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ + +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; + +// main.ts +var main_exports = {}; +__export(main_exports, { + default: () => SpaceforgePlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian26 = require("obsidian"); + +// utils/event-emitter.ts +var EventEmitter = class { + constructor() { + /** + * Event listeners by event name + */ + this.listeners = {}; + } + /** + * Register a listener for an event + * + * @param event Event name + * @param callback Function to call when event is emitted + */ + on(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + } + /** + * Emit an event + * + * @param event Event name + * @param args Arguments to pass to listeners + */ + emit(event, ...args) { + if (!this.listeners[event]) { + return; + } + for (const callback of this.listeners[event]) { + callback(...args); + } + } + /** + * Remove a listener for an event + * + * @param event Event name + * @param callback Function to remove + */ + off(event, callback) { + if (!this.listeners[event]) { + return; + } + this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback); + } + /** + * Remove all listeners for an event + * + * @param event Event name + */ + removeAllListeners(event) { + if (event) { + delete this.listeners[event]; + } else { + this.listeners = {}; + } + } +}; + +// data-storage.ts +var import_obsidian = require("obsidian"); + +// models/review-schedule.ts +var FsrsRating = /* @__PURE__ */ ((FsrsRating2) => { + FsrsRating2[FsrsRating2["Again"] = 1] = "Again"; + FsrsRating2[FsrsRating2["Hard"] = 2] = "Hard"; + FsrsRating2[FsrsRating2["Good"] = 3] = "Good"; + FsrsRating2[FsrsRating2["Easy"] = 4] = "Easy"; + return FsrsRating2; +})(FsrsRating || {}); +var ReviewResponse = /* @__PURE__ */ ((ReviewResponse2) => { + ReviewResponse2[ReviewResponse2["CompleteBlackout"] = 0] = "CompleteBlackout"; + ReviewResponse2[ReviewResponse2["IncorrectResponse"] = 1] = "IncorrectResponse"; + ReviewResponse2[ReviewResponse2["IncorrectButFamiliar"] = 2] = "IncorrectButFamiliar"; + ReviewResponse2[ReviewResponse2["CorrectWithDifficulty"] = 3] = "CorrectWithDifficulty"; + ReviewResponse2[ReviewResponse2["CorrectWithHesitation"] = 4] = "CorrectWithHesitation"; + ReviewResponse2[ReviewResponse2["PerfectRecall"] = 5] = "PerfectRecall"; + ReviewResponse2[ReviewResponse2["Hard"] = 1] = "Hard"; + ReviewResponse2[ReviewResponse2["Fair"] = 3] = "Fair"; + ReviewResponse2[ReviewResponse2["Good"] = 4] = "Good"; + ReviewResponse2[ReviewResponse2["Perfect"] = 5] = "Perfect"; + return ReviewResponse2; +})(ReviewResponse || {}); +function toSM2Quality(response) { + if (response >= 0 && response <= 5) { + return response; + } + switch (response) { + case 1 /* Hard */: + return 1 /* IncorrectResponse */; + case 3 /* Fair */: + return 3 /* CorrectWithDifficulty */; + case 4 /* Good */: + return 4 /* CorrectWithHesitation */; + case 5 /* Perfect */: + return 5 /* PerfectRecall */; + default: + return 3 /* CorrectWithDifficulty */; + } +} + +// utils/estimation.ts +var EstimationUtils = class { + /** + * Set the plugin reference + * + * @param plugin Reference to the main plugin + */ + static setPlugin(plugin) { + this.plugin = plugin; + } + /** + * Get the user's reading speed from settings + * + * @param contentType Optional content type for adjustment + * @returns Reading speed in words per minute + */ + static getReadingSpeed(contentType) { + var _a; + const baseSpeed = ((_a = this.plugin) == null ? void 0 : _a.settings.readingSpeed) || 200; + if (contentType) { + const baseContentSpeed = this.READING_SPEEDS.notes; + const contentSpeed = this.READING_SPEEDS[contentType]; + return baseSpeed * (contentSpeed / baseContentSpeed); + } + return baseSpeed; + } + /** + * Estimate review time for a file based on its content + * + * @param file The file to estimate review time for + * @param fileContent Optional file content (to avoid reading file again) + * @param contentType Type of content for reading speed adjustment + * @returns Estimated review time in seconds + */ + static async estimateReviewTime(file, fileContent, contentType = "notes") { + if (!file) { + return this.MIN_REVIEW_TIME; + } + if (!fileContent) { + const sizeEstimate = Math.ceil(file.stat.size / (this.AVG_WORD_LENGTH * 7)) * 60; + return Math.max(this.MIN_REVIEW_TIME, sizeEstimate); + } + const wordCount = this.countWords(fileContent); + const readingSpeed = this.getReadingSpeed(contentType); + const readingTimeMinutes = wordCount / readingSpeed; + const reviewTimeSeconds = Math.ceil(readingTimeMinutes * 60); + return Math.max(this.MIN_REVIEW_TIME, reviewTimeSeconds); + } + /** + * Calculate aggregate review time for multiple notes + * + * @param paths Array of note paths + * @returns Total estimated review time in seconds + */ + static async calculateTotalReviewTime(paths) { + if (!this.plugin) { + return paths.length * this.MIN_REVIEW_TIME; + } + let totalTime = 0; + for (const path of paths) { + totalTime += await this.plugin.dataStorage.estimateReviewTime(path); + } + return totalTime; + } + /** + * Count words in text + * + * @param text Text to count words in + * @returns Number of words + */ + static countWords(text) { + const cleanText = text.replace(/```[\s\S]*?```/g, "").replace(/`.*?`/g, "").replace(/\[.*?\]\(.*?\)/g, "").replace(/\*\*.*?\*\*/g, "$1").replace(/\*.*?\*/g, "$1").replace(/~~.*?~~/g, "$1"); + const words = cleanText.match(/\S+/g) || []; + return words.length; + } + /** + * Format seconds as a readable time string + * + * @param seconds Time in seconds + * @returns Formatted time string (e.g., "5 min" or "1 hr 30 min") + */ + static formatTime(seconds) { + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes} min`; + } else { + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) { + return `${hours} hr`; + } else { + return `${hours} hr ${remainingMinutes} min`; + } + } + } + /** + * Format a time estimate with color coding based on duration + * + * @param seconds Time in seconds + * @param element HTML element to update + * @returns Formatted HTML time string with color coding + */ + static formatTimeWithColor(seconds, element) { + const formattedTime = this.formatTime(seconds); + element.setText(formattedTime); + if (seconds < 5 * 60) { + element.addClass("review-time-short"); + element.removeClass("review-time-medium"); + element.removeClass("review-time-long"); + } else if (seconds < 15 * 60) { + element.addClass("review-time-medium"); + element.removeClass("review-time-short"); + element.removeClass("review-time-long"); + } else { + element.addClass("review-time-long"); + element.removeClass("review-time-short"); + element.removeClass("review-time-medium"); + } + } +}; +/** + * Reading speeds for different content types (words per minute) + * Used as fallback and for content-specific adjustments + */ +EstimationUtils.READING_SPEEDS = { + notes: 200, + // General notes + technical: 100, + // Technical content + fiction: 250, + // Fiction/prose + simple: 300 + // Simple content +}; +/** + * Average English word length in characters (including spaces) + */ +EstimationUtils.AVG_WORD_LENGTH = 5.5; +/** + * Minimum review time in seconds + */ +EstimationUtils.MIN_REVIEW_TIME = 30; + +// data-storage.ts +var DataStorage = class { + /** + * Initialize data storage + * + * @param plugin Reference to the main plugin + * @param reviewScheduleService Instance of ReviewScheduleService + * @param reviewHistoryService Instance of ReviewHistoryService + * @param reviewSessionService Instance of ReviewSessionService + * @param mcqService Instance of MCQService + */ + constructor(plugin, reviewScheduleService, reviewHistoryService, reviewSessionService, mcqService) { + this.plugin = plugin; + this.reviewScheduleService = reviewScheduleService; + this.reviewHistoryService = reviewHistoryService; + this.reviewSessionService = reviewSessionService; + this.mcqService = mcqService; + } + // Removed ensureDataLoaded() method + // Removed loadData() method (and all localStorage logic within it) + // Removed saveData() method (and all localStorage logic within it) + /** + * Initialize default data when no data is available + * This is now primarily for internal use during integrity checks, + * as main.ts handles initial default loading. + */ + initializeDefaultData() { + this.reviewScheduleService.schedules = {}; + this.reviewHistoryService.history = []; + this.reviewSessionService.reviewSessions = { + sessions: {}, + activeSessionId: null + }; + this.mcqService.mcqSets = {}; + this.mcqService.mcqSessions = {}; + this.reviewScheduleService.customNoteOrder = []; + this.reviewScheduleService.lastLinkAnalysisTimestamp = null; + } + /** + * Verify data integrity and fix any issues + * @returns true if data is valid, false if it needed to be fixed + */ + verifyDataIntegrity() { + console.log("Verifying data integrity..."); + let isValid = true; + if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== "object") { + console.warn("Invalid schedules data structure"); + this.reviewScheduleService.schedules = {}; + isValid = false; + } else { + let invalidCount = 0; + for (const path in this.reviewScheduleService.schedules) { + const schedule = this.reviewScheduleService.schedules[path]; + if (!schedule || typeof schedule !== "object") { + delete this.reviewScheduleService.schedules[path]; + invalidCount++; + isValid = false; + continue; + } + const s = schedule; + if (!("path" in s) || typeof s.path !== "string" || !("lastReviewDate" in s) || s.lastReviewDate !== null && typeof s.lastReviewDate !== "number" || !("nextReviewDate" in s) || typeof s.nextReviewDate !== "number" || !("ease" in s) || typeof s.ease !== "number") { + delete this.reviewScheduleService.schedules[path]; + invalidCount++; + isValid = false; + } + } + if (invalidCount > 0) { + console.warn(`Removed ${invalidCount} invalid schedules`); + } + } + if (!Array.isArray(this.reviewHistoryService.history)) { + console.warn("Invalid history data structure"); + this.reviewHistoryService.history = []; + isValid = false; + } + if (!this.reviewSessionService.reviewSessions || typeof this.reviewSessionService.reviewSessions !== "object" || !this.reviewSessionService.reviewSessions.sessions || typeof this.reviewSessionService.reviewSessions.sessions !== "object") { + console.warn("Invalid review sessions data structure"); + this.reviewSessionService.reviewSessions = { + sessions: {}, + activeSessionId: null + }; + isValid = false; + } + if (!this.mcqService.mcqSets || typeof this.mcqService.mcqSets !== "object") { + console.warn("Invalid MCQ sets data structure"); + this.mcqService.mcqSets = {}; + isValid = false; + } + if (!this.mcqService.mcqSessions || typeof this.mcqService.mcqSessions !== "object") { + console.warn("Invalid MCQ sessions data structure"); + this.mcqService.mcqSessions = {}; + isValid = false; + } + if (!Array.isArray(this.reviewScheduleService.customNoteOrder)) { + console.warn("Invalid custom note order data structure"); + this.reviewScheduleService.customNoteOrder = []; + isValid = false; + } + if (this.reviewScheduleService.lastLinkAnalysisTimestamp !== null && typeof this.reviewScheduleService.lastLinkAnalysisTimestamp !== "number") { + console.warn("Invalid last link analysis timestamp data structure"); + this.reviewScheduleService.lastLinkAnalysisTimestamp = null; + isValid = false; + } + const noSchedules = Object.keys(this.reviewScheduleService.schedules).length === 0; + const hasMCQs = Object.keys(this.mcqService.mcqSets).length > 0; + if (noSchedules && hasMCQs) { + console.warn("WARNING: No schedules found but MCQ data exists - possible data inconsistency"); + } + console.log("Data integrity check complete:", { + isValid, + schedules: Object.keys(this.reviewScheduleService.schedules).length, + history: this.reviewHistoryService.history.length, + reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions).length, + mcqSets: Object.keys(this.mcqService.mcqSets).length, + mcqSessions: Object.keys(this.mcqService.mcqSessions).length + }); + return isValid; + } + /** + * Verify data integrity and remove schedules for files that no longer exist + * @returns {Promise} True if any cleanup was performed, false otherwise. + */ + async cleanupNonExistentFiles() { + console.log("Verifying data integrity and cleaning up non-existent files..."); + let changesMade = false; + if (!this.reviewScheduleService.schedules || typeof this.reviewScheduleService.schedules !== "object") { + console.warn("Schedules is not a valid object, resetting it"); + this.reviewScheduleService.schedules = {}; + changesMade = true; + } + if (!Array.isArray(this.reviewHistoryService.history)) { + console.warn("History is not a valid array, resetting it"); + this.reviewHistoryService.history = []; + changesMade = true; + } + if (!this.reviewSessionService.reviewSessions || typeof this.reviewSessionService.reviewSessions !== "object" || !this.reviewSessionService.reviewSessions.sessions) { + console.warn("Review sessions is not a valid object, resetting it"); + this.reviewSessionService.reviewSessions = { + sessions: {}, + activeSessionId: null + }; + changesMade = true; + } + let cleanupCount = 0; + const beforeCount = Object.keys(this.reviewScheduleService.schedules).length; + const safetyCheck = { + totalSchedules: beforeCount, + checkedSchedules: 0, + missingSchedules: 0, + preserved: false + }; + try { + const allSchedules = { ...this.reviewScheduleService.schedules }; + const allFiles = /* @__PURE__ */ new Set(); + try { + const mdFiles = this.plugin.app.vault.getMarkdownFiles(); + mdFiles.forEach((file) => allFiles.add(file.path)); + console.log(`Preloaded ${allFiles.size} markdown files for checking`); + } catch (listError) { + console.error("Error listing markdown files:", listError); + return changesMade; + } + if (allFiles.size === 0 && beforeCount > 0) { + console.warn("No files found in vault but schedules exist - preserving schedules"); + safetyCheck.preserved = true; + return changesMade; + } + for (const path in allSchedules) { + try { + safetyCheck.checkedSchedules++; + if (allFiles.has(path)) + continue; + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file) { + safetyCheck.missingSchedules++; + delete this.reviewScheduleService.schedules[path]; + cleanupCount++; + changesMade = true; + } + } catch (checkError) { + console.warn(`Error checking file at path ${path}:`, checkError); + } + if (safetyCheck.missingSchedules > 0 && safetyCheck.missingSchedules === safetyCheck.checkedSchedules && safetyCheck.checkedSchedules >= 5) { + console.warn(`SAFETY ALERT: Preventing removal of all schedules (${safetyCheck.missingSchedules}/${safetyCheck.totalSchedules} would be removed)`); + this.reviewScheduleService.schedules = allSchedules; + cleanupCount = 0; + changesMade = false; + safetyCheck.preserved = true; + break; + } + } + console.log(`Cleanup complete: ${beforeCount} schedules before, ${Object.keys(this.reviewScheduleService.schedules).length} after (${cleanupCount} removed)`); + console.log(`Safety check: ${safetyCheck.checkedSchedules} checked, ${safetyCheck.missingSchedules} missing, preserved: ${safetyCheck.preserved}`); + if (cleanupCount > 0 && !safetyCheck.preserved) { + console.log(`Spaceforge: Cleaned up ${cleanupCount} non-existent files from schedules. Data needs saving.`); + } + const dataState = { + schedules: Object.keys(this.reviewScheduleService.schedules).length, + history: this.reviewHistoryService.history.length, + reviewSessions: Object.keys(this.reviewSessionService.reviewSessions.sessions || {}).length, + mcqSets: Object.keys(this.mcqService.mcqSets || {}).length, + mcqSessions: Object.keys(this.mcqService.mcqSessions || {}).length + }; + console.log("Final data state after cleanup check:", dataState); + if (dataState.schedules === 0 && (dataState.history > 0 || dataState.reviewSessions > 0 || dataState.mcqSets > 0)) { + console.warn("WARNING: No schedules found but other data exists - possible data inconsistency"); + } + } catch (error) { + console.error("Error during cleanup:", error); + } + return changesMade; + } + // The following methods are now delegated to the respective service classes. + // They are kept here as public methods to maintain the public API of DataStorage, + // but they now simply call the corresponding method on the service instance. + // REMOVED await this.saveData() from all these methods. + async scheduleNoteForReview(path, daysFromNow = 0) { + await this.reviewScheduleService.scheduleNoteForReview(path, daysFromNow); + } + async recordReview(path, response, isSkipped = false) { + return await this.reviewScheduleService.recordReview(path, response, isSkipped); + } + calculateNewSchedule(currentInterval, currentEase, response) { + const result = this.reviewScheduleService.calculateNewSchedule(currentInterval, currentEase, response); + return { interval: result.interval, ease: result.ease }; + } + async skipNote(path, response = 3 /* CorrectWithDifficulty */) { + await this.reviewScheduleService.skipNote(path, response); + } + async postponeNote(path, days = 1) { + await this.reviewScheduleService.postponeNote(path, days); + } + async removeFromReview(path) { + await this.reviewScheduleService.removeFromReview(path); + } + async clearAllSchedules() { + await this.reviewScheduleService.clearAllSchedules(); + } + async estimateReviewTime(path) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!(file instanceof import_obsidian.TFile)) + return 60; + try { + const content = await this.plugin.app.vault.read(file); + return EstimationUtils.estimateReviewTime(file, content); + } catch (error) { + console.error("Error estimating review time:", error); + return 60; + } + } + async createReviewSession(folderPath, name) { + const session = await this.reviewSessionService.createReviewSession(folderPath, name); + return session; + } + async setActiveSession(sessionId) { + const success = await this.reviewSessionService.setActiveSession(sessionId); + return success; + } + getActiveSession() { + return this.reviewSessionService.getActiveSession(); + } + getNextSessionFile() { + return this.reviewSessionService.getNextSessionFile(); + } + async advanceActiveSession() { + const moreFiles = await this.reviewSessionService.advanceActiveSession(); + return moreFiles; + } + async scheduleNotesInOrder(paths, daysFromNow = 0) { + const count = await this.reviewScheduleService.scheduleNotesInOrder(paths, daysFromNow); + return count; + } + async scheduleSessionForReview(sessionId) { + return await this.reviewSessionService.scheduleSessionForReview(sessionId); + } + async saveMCQSet(mcqSet) { + const id = this.mcqService.saveMCQSet(mcqSet); + return id; + } + getMCQSetForNote(notePath) { + return this.mcqService.getMCQSetForNote(notePath); + } + async saveMCQSession(session) { + this.mcqService.saveMCQSession(session); + } + getMCQSessionsForNote(notePath) { + return this.mcqService.getMCQSessionsForNote(notePath); + } + getLatestMCQSessionForNote(notePath) { + return this.mcqService.getLatestMCQSessionForNote(notePath); + } + async updateCustomNoteOrder(order) { + await this.reviewScheduleService.updateCustomNoteOrder(order); + } + getDueNotesWithCustomOrder(date = Date.now(), useCustomOrder = true) { + return this.reviewScheduleService.getDueNotesWithCustomOrder(date, useCustomOrder); + } + // Removed internal helper methods that were moved to services +}; + +// controllers/review-controller-core.ts +var import_obsidian3 = require("obsidian"); + +// ui/review-modal.ts +var import_obsidian2 = require("obsidian"); + +// node_modules/ts-fsrs/dist/index.mjs +var c = ((s) => (s[s.New = 0] = "New", s[s.Learning = 1] = "Learning", s[s.Review = 2] = "Review", s[s.Relearning = 3] = "Relearning", s))(c || {}); +var l = ((s) => (s[s.Manual = 0] = "Manual", s[s.Again = 1] = "Again", s[s.Hard = 2] = "Hard", s[s.Good = 3] = "Good", s[s.Easy = 4] = "Easy", s))(l || {}); +var h = class { + static card(t) { + return { ...t, state: h.state(t.state), due: h.time(t.due), last_review: t.last_review ? h.time(t.last_review) : void 0 }; + } + static rating(t) { + if (typeof t == "string") { + const e = t.charAt(0).toUpperCase(), i = t.slice(1).toLowerCase(), r = l[`${e}${i}`]; + if (r === void 0) + throw new Error(`Invalid rating:[${t}]`); + return r; + } else if (typeof t == "number") + return t; + throw new Error(`Invalid rating:[${t}]`); + } + static state(t) { + if (typeof t == "string") { + const e = t.charAt(0).toUpperCase(), i = t.slice(1).toLowerCase(), r = c[`${e}${i}`]; + if (r === void 0) + throw new Error(`Invalid state:[${t}]`); + return r; + } else if (typeof t == "number") + return t; + throw new Error(`Invalid state:[${t}]`); + } + static time(t) { + if (typeof t == "object" && t instanceof Date) + return t; + if (typeof t == "string") { + const e = Date.parse(t); + if (isNaN(e)) + throw new Error(`Invalid date:[${t}]`); + return new Date(e); + } else if (typeof t == "number") + return new Date(t); + throw new Error(`Invalid date:[${t}]`); + } + static review_log(t) { + return { ...t, due: h.time(t.due), rating: h.rating(t.rating), state: h.state(t.state), review: h.time(t.review) }; + } +}; +var X = "4.7.1"; +Date.prototype.scheduler = function(s, t) { + return F(this, s, t); +}, Date.prototype.diff = function(s, t) { + return L(this, s, t); +}, Date.prototype.format = function() { + return O(this); +}, Date.prototype.dueFormat = function(s, t, e) { + return j(this, s, t, e); +}; +function F(s, t, e) { + return new Date(e ? h.time(s).getTime() + t * 24 * 60 * 60 * 1e3 : h.time(s).getTime() + t * 60 * 1e3); +} +function L(s, t, e) { + if (!s || !t) + throw new Error("Invalid date"); + const i = h.time(s).getTime() - h.time(t).getTime(); + let r = 0; + switch (e) { + case "days": + r = Math.floor(i / (24 * 60 * 60 * 1e3)); + break; + case "minutes": + r = Math.floor(i / (60 * 1e3)); + break; + } + return r; +} +function O(s) { + const t = h.time(s), e = t.getFullYear(), i = t.getMonth() + 1, r = t.getDate(), a = t.getHours(), n = t.getMinutes(), d = t.getSeconds(); + return `${e}-${p(i)}-${p(r)} ${p(a)}:${p(n)}:${p(d)}`; +} +function p(s) { + return s < 10 ? `0${s}` : `${s}`; +} +var S = [60, 60, 24, 31, 12]; +var E = ["second", "min", "hour", "day", "month", "year"]; +function j(s, t, e, i = E) { + s = h.time(s), t = h.time(t), i.length !== E.length && (i = E); + let r = s.getTime() - t.getTime(), a; + for (r /= 1e3, a = 0; a < S.length && !(r < S[a]); a++) + r /= S[a]; + return `${Math.floor(r)}${e ? i[a] : ""}`; +} +var I = Object.freeze([l.Again, l.Hard, l.Good, l.Easy]); +var Z = [{ start: 2.5, end: 7, factor: 0.15 }, { start: 7, end: 20, factor: 0.1 }, { start: 20, end: 1 / 0, factor: 0.05 }]; +function G(s, t, e) { + let i = 1; + for (const n of Z) + i += n.factor * Math.max(Math.min(s, n.end) - n.start, 0); + s = Math.min(s, e); + let r = Math.max(2, Math.round(s - i)); + const a = Math.min(Math.round(s + i), e); + return s > t && (r = Math.max(r, t + 1)), r = Math.min(r, a), { min_ivl: r, max_ivl: a }; +} +function m(s, t, e) { + return Math.min(Math.max(s, t), e); +} +function N(s, t) { + const e = Date.UTC(s.getUTCFullYear(), s.getUTCMonth(), s.getUTCDate()), i = Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()); + return Math.floor((i - e) / 864e5); +} +var k = 0.9; +var C = 36500; +var T = Object.freeze([0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 46e-4, 1.54575, 0.1192, 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621]); +var U = false; +var q = true; +var tt = `v${X} using FSRS-5.0`; +var _ = 0.01; +var b = 100; +var R = Object.freeze([Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([_, b]), Object.freeze([1, 10]), Object.freeze([1e-3, 4]), Object.freeze([1e-3, 4]), Object.freeze([1e-3, 0.75]), Object.freeze([0, 4.5]), Object.freeze([0, 0.8]), Object.freeze([1e-3, 3.5]), Object.freeze([1e-3, 5]), Object.freeze([1e-3, 0.25]), Object.freeze([1e-3, 0.9]), Object.freeze([0, 4]), Object.freeze([0, 1]), Object.freeze([1, 6]), Object.freeze([0, 2]), Object.freeze([0, 2])]); +var z = (s) => { + var _a, _b; + let t = [...T]; + return (s == null ? void 0 : s.w) && (s.w.length === 19 ? t = [...s.w] : s.w.length === 17 && (t = s == null ? void 0 : s.w.concat([0, 0]), t[4] = +(t[5] * 2 + t[4]).toFixed(8), t[5] = +(Math.log(t[5] * 3 + 1) / 3).toFixed(8), t[6] = +(t[6] + 0.5).toFixed(8), console.debug("[FSRS V5]auto fill w to 19 length"))), t = t.map((e, i) => m(e, R[i][0], R[i][1])), { request_retention: (s == null ? void 0 : s.request_retention) || k, maximum_interval: (s == null ? void 0 : s.maximum_interval) || C, w: t, enable_fuzz: (_a = s == null ? void 0 : s.enable_fuzz) != null ? _a : U, enable_short_term: (_b = s == null ? void 0 : s.enable_short_term) != null ? _b : q }; +}; +function v(s, t) { + const e = { due: s ? h.time(s) : /* @__PURE__ */ new Date(), stability: 0, difficulty: 0, elapsed_days: 0, scheduled_days: 0, reps: 0, lapses: 0, state: c.New, last_review: void 0 }; + return t && typeof t == "function" ? t(e) : e; +} +var et = class { + constructor(t) { + __publicField(this, "c"); + __publicField(this, "s0"); + __publicField(this, "s1"); + __publicField(this, "s2"); + const e = it(); + this.c = 1, this.s0 = e(" "), this.s1 = e(" "), this.s2 = e(" "), t == null && (t = +/* @__PURE__ */ new Date()), this.s0 -= e(t), this.s0 < 0 && (this.s0 += 1), this.s1 -= e(t), this.s1 < 0 && (this.s1 += 1), this.s2 -= e(t), this.s2 < 0 && (this.s2 += 1); + } + next() { + const t = 2091639 * this.s0 + this.c * 23283064365386963e-26; + return this.s0 = this.s1, this.s1 = this.s2, this.s2 = t - (this.c = t | 0), this.s2; + } + set state(t) { + this.c = t.c, this.s0 = t.s0, this.s1 = t.s1, this.s2 = t.s2; + } + get state() { + return { c: this.c, s0: this.s0, s1: this.s1, s2: this.s2 }; + } +}; +function it() { + let s = 4022871197; + return function(t) { + t = String(t); + for (let e = 0; e < t.length; e++) { + s += t.charCodeAt(e); + let i = 0.02519603282416938 * s; + s = i >>> 0, i -= s, i *= s, s = i >>> 0, i -= s, s += i * 4294967296; + } + return (s >>> 0) * 23283064365386963e-26; + }; +} +function rt(s) { + const t = new et(s), e = () => t.next(); + return e.int32 = () => t.next() * 4294967296 | 0, e.double = () => e() + (e() * 2097152 | 0) * 11102230246251565e-32, e.state = () => t.state, e.importState = (i) => (t.state = i, e), e; +} +var $ = -0.5; +var D = 19 / 81; +function P(s, t) { + return +Math.pow(1 + D * s / t, $).toFixed(8); +} +var Y = class { + constructor(t) { + __publicField(this, "param"); + __publicField(this, "intervalModifier"); + __publicField(this, "_seed"); + __publicField(this, "forgetting_curve", P); + this.param = new Proxy(z(t), this.params_handler_proxy()), this.intervalModifier = this.calculate_interval_modifier(this.param.request_retention); + } + get interval_modifier() { + return this.intervalModifier; + } + set seed(t) { + this._seed = t; + } + calculate_interval_modifier(t) { + if (t <= 0 || t > 1) + throw new Error("Requested retention rate should be in the range (0,1]"); + return +((Math.pow(t, 1 / $) - 1) / D).toFixed(8); + } + get parameters() { + return this.param; + } + set parameters(t) { + this.update_parameters(t); + } + params_handler_proxy() { + const t = this; + return { set: function(e, i, r) { + return i === "request_retention" && Number.isFinite(r) && (t.intervalModifier = t.calculate_interval_modifier(Number(r))), Reflect.set(e, i, r), true; + } }; + } + update_parameters(t) { + const e = z(t); + for (const i in e) + if (i in this.param) { + const r = i; + this.param[r] = e[r]; + } + } + init_stability(t) { + return Math.max(this.param.w[t - 1], 0.1); + } + init_difficulty(t) { + return this.constrain_difficulty(this.param.w[4] - Math.exp((t - 1) * this.param.w[5]) + 1); + } + apply_fuzz(t, e) { + if (!this.param.enable_fuzz || t < 2.5) + return Math.round(t); + const i = rt(this._seed)(), { min_ivl: r, max_ivl: a } = G(t, e, this.param.maximum_interval); + return Math.floor(i * (a - r + 1) + r); + } + next_interval(t, e) { + const i = Math.min(Math.max(1, Math.round(t * this.intervalModifier)), this.param.maximum_interval); + return this.apply_fuzz(i, e); + } + linear_damping(t, e) { + return +(t * (10 - e) / 9).toFixed(8); + } + next_difficulty(t, e) { + const i = -this.param.w[6] * (e - 3), r = t + this.linear_damping(i, t); + return this.constrain_difficulty(this.mean_reversion(this.init_difficulty(l.Easy), r)); + } + constrain_difficulty(t) { + return Math.min(Math.max(+t.toFixed(8), 1), 10); + } + mean_reversion(t, e) { + return +(this.param.w[7] * t + (1 - this.param.w[7]) * e).toFixed(8); + } + next_recall_stability(t, e, i, r) { + const a = l.Hard === r ? this.param.w[15] : 1, n = l.Easy === r ? this.param.w[16] : 1; + return +m(e * (1 + Math.exp(this.param.w[8]) * (11 - t) * Math.pow(e, -this.param.w[9]) * (Math.exp((1 - i) * this.param.w[10]) - 1) * a * n), _, 36500).toFixed(8); + } + next_forget_stability(t, e, i) { + return +m(this.param.w[11] * Math.pow(t, -this.param.w[12]) * (Math.pow(e + 1, this.param.w[13]) - 1) * Math.exp((1 - i) * this.param.w[14]), _, 36500).toFixed(8); + } + next_short_term_stability(t, e) { + return +m(t * Math.exp(this.param.w[17] * (e - 3 + this.param.w[18])), _, 36500).toFixed(8); + } + next_state(t, e, i) { + const { difficulty: r, stability: a } = t != null ? t : { difficulty: 0, stability: 0 }; + if (e < 0) + throw new Error(`Invalid delta_t "${e}"`); + if (i < 0 || i > 4) + throw new Error(`Invalid grade "${i}"`); + if (r === 0 && a === 0) + return { difficulty: this.init_difficulty(i), stability: this.init_stability(i) }; + if (i === 0) + return { difficulty: r, stability: a }; + if (r < 1 || a < _) + throw new Error(`Invalid memory state { difficulty: ${r}, stability: ${a} }`); + const n = this.forgetting_curve(e, a), d = this.next_recall_stability(r, a, n, i), u = this.next_forget_stability(r, a, n), o = this.next_short_term_stability(a, i); + let f = d; + if (i === 1) { + let [y, w] = [0, 0]; + this.param.enable_short_term && (y = this.param.w[17], w = this.param.w[18]); + const g = a / Math.exp(y * w); + f = m(+g.toFixed(8), _, u); + } + return e === 0 && this.param.enable_short_term && (f = o), { difficulty: this.next_difficulty(r, i), stability: f }; + } +}; +function H() { + const s = this.review_time.getTime(), t = this.current.reps, e = this.current.difficulty * this.current.stability; + return `${s}_${t}_${e}`; +} +var x = ((s) => (s.SCHEDULER = "Scheduler", s.SEED = "Seed", s))(x || {}); +var A = class { + constructor(t, e, i, r = { seed: H }) { + __publicField(this, "last"); + __publicField(this, "current"); + __publicField(this, "review_time"); + __publicField(this, "next", /* @__PURE__ */ new Map()); + __publicField(this, "algorithm"); + __publicField(this, "initSeedStrategy"); + this.algorithm = i, this.initSeedStrategy = r.seed.bind(this), this.last = h.card(t), this.current = h.card(t), this.review_time = h.time(e), this.init(); + } + init() { + const { state: t, last_review: e } = this.current; + let i = 0; + t !== c.New && e && (i = N(e, this.review_time)), this.current.last_review = this.review_time, this.current.elapsed_days = i, this.current.reps += 1, this.algorithm.seed = this.initSeedStrategy(); + } + preview() { + return { [l.Again]: this.review(l.Again), [l.Hard]: this.review(l.Hard), [l.Good]: this.review(l.Good), [l.Easy]: this.review(l.Easy), [Symbol.iterator]: this.previewIterator.bind(this) }; + } + *previewIterator() { + for (const t of I) + yield this.review(t); + } + review(t) { + const { state: e } = this.last; + let i; + switch (e) { + case c.New: + i = this.newState(t); + break; + case c.Learning: + case c.Relearning: + i = this.learningState(t); + break; + case c.Review: + i = this.reviewState(t); + break; + } + if (i) + return i; + throw new Error("Invalid grade"); + } + buildLog(t) { + const { last_review: e, due: i, elapsed_days: r } = this.last; + return { rating: t, state: this.current.state, due: e || i, stability: this.current.stability, difficulty: this.current.difficulty, elapsed_days: this.current.elapsed_days, last_elapsed_days: r, scheduled_days: this.current.scheduled_days, review: this.review_time }; + } +}; +var V = class extends A { + newState(t) { + const e = this.next.get(t); + if (e) + return e; + const i = h.card(this.current); + switch (i.difficulty = this.algorithm.init_difficulty(t), i.stability = this.algorithm.init_stability(t), t) { + case l.Again: + i.scheduled_days = 0, i.due = this.review_time.scheduler(1), i.state = c.Learning; + break; + case l.Hard: + i.scheduled_days = 0, i.due = this.review_time.scheduler(5), i.state = c.Learning; + break; + case l.Good: + i.scheduled_days = 0, i.due = this.review_time.scheduler(10), i.state = c.Learning; + break; + case l.Easy: { + const a = this.algorithm.next_interval(i.stability, this.current.elapsed_days); + i.scheduled_days = a, i.due = this.review_time.scheduler(a, true), i.state = c.Review; + break; + } + default: + throw new Error("Invalid grade"); + } + const r = { card: i, log: this.buildLog(t) }; + return this.next.set(t, r), r; + } + learningState(t) { + const e = this.next.get(t); + if (e) + return e; + const { state: i, difficulty: r, stability: a } = this.last, n = h.card(this.current), d = this.current.elapsed_days; + switch (n.difficulty = this.algorithm.next_difficulty(r, t), n.stability = this.algorithm.next_short_term_stability(a, t), t) { + case l.Again: { + n.scheduled_days = 0, n.due = this.review_time.scheduler(5, false), n.state = i; + break; + } + case l.Hard: { + n.scheduled_days = 0, n.due = this.review_time.scheduler(10), n.state = i; + break; + } + case l.Good: { + const o = this.algorithm.next_interval(n.stability, d); + n.scheduled_days = o, n.due = this.review_time.scheduler(o, true), n.state = c.Review; + break; + } + case l.Easy: { + const o = this.algorithm.next_short_term_stability(a, l.Good), f = this.algorithm.next_interval(o, d), y = Math.max(this.algorithm.next_interval(n.stability, d), f + 1); + n.scheduled_days = y, n.due = this.review_time.scheduler(y, true), n.state = c.Review; + break; + } + default: + throw new Error("Invalid grade"); + } + const u = { card: n, log: this.buildLog(t) }; + return this.next.set(t, u), u; + } + reviewState(t) { + const e = this.next.get(t); + if (e) + return e; + const i = this.current.elapsed_days, { difficulty: r, stability: a } = this.last, n = this.algorithm.forgetting_curve(i, a), d = h.card(this.current), u = h.card(this.current), o = h.card(this.current), f = h.card(this.current); + this.next_ds(d, u, o, f, r, a, n), this.next_interval(d, u, o, f, i), this.next_state(d, u, o, f), d.lapses += 1; + const y = { card: d, log: this.buildLog(l.Again) }, w = { card: u, log: super.buildLog(l.Hard) }, g = { card: o, log: super.buildLog(l.Good) }, M = { card: f, log: super.buildLog(l.Easy) }; + return this.next.set(l.Again, y), this.next.set(l.Hard, w), this.next.set(l.Good, g), this.next.set(l.Easy, M), this.next.get(t); + } + next_ds(t, e, i, r, a, n, d) { + t.difficulty = this.algorithm.next_difficulty(a, l.Again); + const u = n / Math.exp(this.algorithm.parameters.w[17] * this.algorithm.parameters.w[18]), o = this.algorithm.next_forget_stability(a, n, d); + t.stability = m(+u.toFixed(8), _, o), e.difficulty = this.algorithm.next_difficulty(a, l.Hard), e.stability = this.algorithm.next_recall_stability(a, n, d, l.Hard), i.difficulty = this.algorithm.next_difficulty(a, l.Good), i.stability = this.algorithm.next_recall_stability(a, n, d, l.Good), r.difficulty = this.algorithm.next_difficulty(a, l.Easy), r.stability = this.algorithm.next_recall_stability(a, n, d, l.Easy); + } + next_interval(t, e, i, r, a) { + let n, d; + n = this.algorithm.next_interval(e.stability, a), d = this.algorithm.next_interval(i.stability, a), n = Math.min(n, d), d = Math.max(d, n + 1); + const u = Math.max(this.algorithm.next_interval(r.stability, a), d + 1); + t.scheduled_days = 0, t.due = this.review_time.scheduler(5), e.scheduled_days = n, e.due = this.review_time.scheduler(n, true), i.scheduled_days = d, i.due = this.review_time.scheduler(d, true), r.scheduled_days = u, r.due = this.review_time.scheduler(u, true); + } + next_state(t, e, i, r) { + t.state = c.Relearning, e.state = c.Review, i.state = c.Review, r.state = c.Review; + } +}; +var B = class extends A { + newState(t) { + const e = this.next.get(t); + if (e) + return e; + this.current.scheduled_days = 0, this.current.elapsed_days = 0; + const i = h.card(this.current), r = h.card(this.current), a = h.card(this.current), n = h.card(this.current); + return this.init_ds(i, r, a, n), this.next_interval(i, r, a, n, 0), this.next_state(i, r, a, n), this.update_next(i, r, a, n), this.next.get(t); + } + init_ds(t, e, i, r) { + t.difficulty = this.algorithm.init_difficulty(l.Again), t.stability = this.algorithm.init_stability(l.Again), e.difficulty = this.algorithm.init_difficulty(l.Hard), e.stability = this.algorithm.init_stability(l.Hard), i.difficulty = this.algorithm.init_difficulty(l.Good), i.stability = this.algorithm.init_stability(l.Good), r.difficulty = this.algorithm.init_difficulty(l.Easy), r.stability = this.algorithm.init_stability(l.Easy); + } + learningState(t) { + return this.reviewState(t); + } + reviewState(t) { + const e = this.next.get(t); + if (e) + return e; + const i = this.current.elapsed_days, { difficulty: r, stability: a } = this.last, n = this.algorithm.forgetting_curve(i, a), d = h.card(this.current), u = h.card(this.current), o = h.card(this.current), f = h.card(this.current); + return this.next_ds(d, u, o, f, r, a, n), this.next_interval(d, u, o, f, i), this.next_state(d, u, o, f), d.lapses += 1, this.update_next(d, u, o, f), this.next.get(t); + } + next_ds(t, e, i, r, a, n, d) { + t.difficulty = this.algorithm.next_difficulty(a, l.Again); + const u = this.algorithm.next_forget_stability(a, n, d); + t.stability = m(n, _, u), e.difficulty = this.algorithm.next_difficulty(a, l.Hard), e.stability = this.algorithm.next_recall_stability(a, n, d, l.Hard), i.difficulty = this.algorithm.next_difficulty(a, l.Good), i.stability = this.algorithm.next_recall_stability(a, n, d, l.Good), r.difficulty = this.algorithm.next_difficulty(a, l.Easy), r.stability = this.algorithm.next_recall_stability(a, n, d, l.Easy); + } + next_interval(t, e, i, r, a) { + let n, d, u, o; + n = this.algorithm.next_interval(t.stability, a), d = this.algorithm.next_interval(e.stability, a), u = this.algorithm.next_interval(i.stability, a), o = this.algorithm.next_interval(r.stability, a), n = Math.min(n, d), d = Math.max(d, n + 1), u = Math.max(u, d + 1), o = Math.max(o, u + 1), t.scheduled_days = n, t.due = this.review_time.scheduler(n, true), e.scheduled_days = d, e.due = this.review_time.scheduler(d, true), i.scheduled_days = u, i.due = this.review_time.scheduler(u, true), r.scheduled_days = o, r.due = this.review_time.scheduler(o, true); + } + next_state(t, e, i, r) { + t.state = c.Review, e.state = c.Review, i.state = c.Review, r.state = c.Review; + } + update_next(t, e, i, r) { + const a = { card: t, log: this.buildLog(l.Again) }, n = { card: e, log: super.buildLog(l.Hard) }, d = { card: i, log: super.buildLog(l.Good) }, u = { card: r, log: super.buildLog(l.Easy) }; + this.next.set(l.Again, a), this.next.set(l.Hard, n), this.next.set(l.Good, d), this.next.set(l.Easy, u); + } +}; +var st = class { + constructor(t) { + __publicField(this, "fsrs"); + this.fsrs = t; + } + replay(t, e, i) { + return this.fsrs.next(t, e, i); + } + handleManualRating(t, e, i, r, a, n, d) { + if (typeof e > "u") + throw new Error("reschedule: state is required for manual rating"); + let u, o; + if (e === c.New) + u = { rating: l.Manual, state: e, due: d != null ? d : i, stability: t.stability, difficulty: t.difficulty, elapsed_days: r, last_elapsed_days: t.elapsed_days, scheduled_days: t.scheduled_days, review: i }, o = v(i), o.last_review = i; + else { + if (typeof d > "u") + throw new Error("reschedule: due is required for manual rating"); + const f = d.diff(i, "days"); + u = { rating: l.Manual, state: t.state, due: t.last_review || t.due, stability: t.stability, difficulty: t.difficulty, elapsed_days: r, last_elapsed_days: t.elapsed_days, scheduled_days: t.scheduled_days, review: i }, o = { ...t, state: e, due: d, last_review: i, stability: a || t.stability, difficulty: n || t.difficulty, elapsed_days: r, scheduled_days: f, reps: t.reps + 1 }; + } + return { card: o, log: u }; + } + reschedule(t, e) { + const i = []; + let r = v(t.due); + for (const a of e) { + let n; + if (a.review = h.time(a.review), a.rating === l.Manual) { + let d = 0; + r.state !== c.New && r.last_review && (d = a.review.diff(r.last_review, "days")), n = this.handleManualRating(r, a.state, a.review, d, a.stability, a.difficulty, a.due ? h.time(a.due) : void 0); + } else + n = this.replay(r, a.review, a.rating); + i.push(n), r = n.card; + } + return i; + } + calculateManualRecord(t, e, i, r) { + if (!i) + return null; + const { card: a, log: n } = i, d = h.card(t); + return d.due.getTime() === a.due.getTime() ? null : (d.scheduled_days = a.due.diff(d.due, "days"), this.handleManualRating(d, a.state, h.time(e), n.elapsed_days, r ? a.stability : void 0, r ? a.difficulty : void 0, a.due)); + } +}; +var W = class extends Y { + constructor(t) { + super(t); + __publicField(this, "strategyHandler", /* @__PURE__ */ new Map()); + __publicField(this, "Scheduler"); + const { enable_short_term: e } = this.parameters; + this.Scheduler = e ? V : B; + } + params_handler_proxy() { + const t = this; + return { set: function(e, i, r) { + return i === "request_retention" && Number.isFinite(r) ? t.intervalModifier = t.calculate_interval_modifier(Number(r)) : i === "enable_short_term" && (t.Scheduler = r === true ? V : B), Reflect.set(e, i, r), true; + } }; + } + useStrategy(t, e) { + return this.strategyHandler.set(t, e), this; + } + clearStrategy(t) { + return t ? this.strategyHandler.delete(t) : this.strategyHandler.clear(), this; + } + getScheduler(t, e) { + const i = this.strategyHandler.get(x.SEED), r = this.strategyHandler.get(x.SCHEDULER) || this.Scheduler, a = i || H; + return new r(t, e, this, { seed: a }); + } + repeat(t, e, i) { + const r = this.getScheduler(t, e).preview(); + return i && typeof i == "function" ? i(r) : r; + } + next(t, e, i, r) { + const a = this.getScheduler(t, e), n = h.rating(i); + if (n === l.Manual) + throw new Error("Cannot review a manual rating"); + const d = a.review(n); + return r && typeof r == "function" ? r(d) : d; + } + get_retrievability(t, e, i = true) { + const r = h.card(t); + e = e ? h.time(e) : /* @__PURE__ */ new Date(); + const a = r.state !== c.New ? Math.max(e.diff(r.last_review, "days"), 0) : 0, n = r.state !== c.New ? this.forgetting_curve(a, +r.stability.toFixed(8)) : 0; + return i ? `${(n * 100).toFixed(2)}%` : n; + } + rollback(t, e, i) { + const r = h.card(t), a = h.review_log(e); + if (a.rating === l.Manual) + throw new Error("Cannot rollback a manual rating"); + let n, d, u; + switch (a.state) { + case c.New: + n = a.due, d = void 0, u = 0; + break; + case c.Learning: + case c.Relearning: + case c.Review: + n = a.review, d = a.due, u = r.lapses - (a.rating === l.Again && a.state === c.Review ? 1 : 0); + break; + } + const o = { ...r, due: n, stability: a.stability, difficulty: a.difficulty, elapsed_days: a.last_elapsed_days, scheduled_days: a.scheduled_days, reps: Math.max(0, r.reps - 1), lapses: Math.max(0, u), state: a.state, last_review: d }; + return i && typeof i == "function" ? i(o) : o; + } + forget(t, e, i = false, r) { + const a = h.card(t); + e = h.time(e); + const n = a.state === c.New ? 0 : e.diff(a.last_review, "days"), d = { rating: l.Manual, state: a.state, due: a.due, stability: a.stability, difficulty: a.difficulty, elapsed_days: 0, last_elapsed_days: a.elapsed_days, scheduled_days: n, review: e }, u = { card: { ...a, due: e, stability: 0, difficulty: 0, elapsed_days: 0, scheduled_days: 0, reps: i ? 0 : a.reps, lapses: i ? 0 : a.lapses, state: c.New, last_review: a.last_review }, log: d }; + return r && typeof r == "function" ? r(u) : u; + } + reschedule(t, e = [], i = {}) { + const { recordLogHandler: r, reviewsOrderBy: a, skipManual: n = true, now: d = /* @__PURE__ */ new Date(), update_memory_state: u = false } = i; + a && typeof a == "function" && e.sort(a), n && (e = e.filter((M) => M.rating !== l.Manual)); + const o = new st(this), f = o.reschedule(i.first_card || v(), e), y = f.length, w = h.card(t), g = o.calculateManualRecord(w, d, y ? f[y - 1] : void 0, u); + return r && typeof r == "function" ? { collections: f.map(r), reschedule_item: g ? r(g) : null } : { collections: f, reschedule_item: g }; + } +}; + +// ui/review-modal.ts +var ReviewModal = class extends import_obsidian2.Modal { + constructor(app, plugin, path) { + super(app); + this.plugin = plugin; + this.path = path; + } + onOpen() { + const { contentEl } = this; + contentEl.createEl("h2", { text: "Review Note" }); + const buttonsContainer = contentEl.createDiv("review-buttons-container"); + const schedule = this.plugin.reviewScheduleService.schedules[this.path]; + if (schedule && schedule.schedulingAlgorithm === "fsrs") { + const createFsrsButton = (text, clsSuffix, rating) => { + const button = buttonsContainer.createEl("button", { text, cls: `review-button review-button-fsrs-${clsSuffix}` }); + button.addEventListener("click", () => { + this.plugin.reviewController.processReviewResponse(this.path, rating); + this.close(); + }); + }; + createFsrsButton("1: Again", "again", 1 /* Again */); + createFsrsButton("2: Hard", "hard", 2 /* Hard */); + createFsrsButton("3: Good", "good", 3 /* Good */); + createFsrsButton("4: Easy", "easy", 4 /* Easy */); + } else { + const createSm2Button = (text, cls, response) => { + const button = buttonsContainer.createEl("button", { text, cls }); + button.addEventListener("click", () => { + this.plugin.reviewController.processReviewResponse(this.path, response); + this.close(); + }); + }; + createSm2Button("0: Complete Blackout", "review-button review-button-complete-blackout", 0 /* CompleteBlackout */); + createSm2Button("1: Incorrect Response", "review-button review-button-incorrect", 1 /* IncorrectResponse */); + createSm2Button("2: Incorrect but Familiar", "review-button review-button-incorrect-familiar", 2 /* IncorrectButFamiliar */); + createSm2Button("3: Correct with Difficulty", "review-button review-button-correct-difficulty", 3 /* CorrectWithDifficulty */); + createSm2Button("4: Correct with Hesitation", "review-button review-button-correct-hesitation", 4 /* CorrectWithHesitation */); + createSm2Button("5: Perfect Recall", "review-button review-button-perfect-recall", 5 /* PerfectRecall */); + } + buttonsContainer.createEl("div", { cls: "review-button-separator" }); + const postponeButton = buttonsContainer.createEl("button", { text: "Postpone to Tomorrow", cls: "review-button review-button-postpone" }); + postponeButton.addEventListener("click", () => { + this.plugin.reviewController.skipReview(this.path); + this.close(); + }); + const skipButton = buttonsContainer.createEl("button", { text: "Skip/Next", cls: "review-button review-button-skip" }); + skipButton.addEventListener("click", async () => { + this.close(); + if (this.plugin.navigationController) { + await this.plugin.navigationController.navigateToNextNoteWithoutRating(); + } + }); + if (this.plugin.settings.enableMCQ) { + buttonsContainer.createEl("div", { cls: "review-button-separator" }); + const mcqButton = buttonsContainer.createEl("button", { cls: "review-button review-button-mcq" }); + const mcqIconSpan = mcqButton.createSpan("mcq-button-icon"); + (0, import_obsidian2.setIcon)(mcqIconSpan, "mcq-quiz"); + const textSpan = mcqButton.createSpan("mcq-button-text"); + textSpan.setText("Test with MCQs"); + mcqButton.addEventListener("click", () => { + const mcqController2 = this.plugin.mcqController; + if (mcqController2) { + mcqController2.startMCQReview(this.path); + this.close(); + } else { + this.plugin.initializeMCQComponents(); + const initializedMcqController = this.plugin.mcqController; + if (initializedMcqController) { + initializedMcqController.startMCQReview(this.path); + this.close(); + } else { + new import_obsidian2.Notice("MCQ feature could not be initialized. Please check settings."); + } + } + }); + const mcqController = this.plugin.mcqController; + if (mcqController && this.plugin.mcqService.getMCQSetForNote(this.path)) { + const refreshMcqButton = buttonsContainer.createEl("button", { cls: "review-button review-button-mcq-refresh" }); + const refreshIconSpan = refreshMcqButton.createSpan("mcq-button-icon"); + (0, import_obsidian2.setIcon)(refreshIconSpan, "refresh-cw"); + const refreshTextSpan = refreshMcqButton.createSpan("mcq-button-text"); + refreshTextSpan.setText("Generate New MCQs"); + refreshMcqButton.addEventListener("click", async () => { + if (mcqController) { + new import_obsidian2.Notice("Generating new MCQs..."); + const success = await mcqController.generateMCQs(this.path, true); + if (success) { + mcqController.startMCQReview(this.path); + this.close(); + } else { + new import_obsidian2.Notice("Failed to generate new MCQs"); + } + } + }); + } + } + const infoText = contentEl.createDiv("review-info-text"); + if (schedule) { + const file = this.app.vault.getAbstractFileByPath(this.path); + const fileName = file instanceof import_obsidian2.TFile ? file.basename : this.path; + infoText.createEl("p", { text: `Reviewing: ${fileName}` }); + const activeSession = this.plugin.reviewSessionService.getActiveSession(); + if (activeSession) { + const currentIndex = activeSession.currentIndex; + const totalFiles = activeSession.hierarchy.traversalOrder.length; + infoText.createEl("p", { text: `Session: ${activeSession.name} (${currentIndex + 1}/${totalFiles})`, cls: "review-session-info" }); + } + if (schedule.lastReviewDate) + infoText.createEl("p", { text: `Last reviewed: ${new Date(schedule.lastReviewDate).toLocaleDateString()}` }); + if (schedule.schedulingAlgorithm === "fsrs" && schedule.fsrsData) { + infoText.createEl("p", { text: `Algorithm: FSRS`, cls: "review-phase-fsrs" }); + infoText.createEl("p", { text: `Stability: ${schedule.fsrsData.stability.toFixed(2)}` }); + infoText.createEl("p", { text: `Difficulty: ${schedule.fsrsData.difficulty.toFixed(2)}` }); + infoText.createEl("p", { text: `State: ${c[schedule.fsrsData.state]}` }); + infoText.createEl("p", { text: `Interval: ${schedule.fsrsData.scheduled_days} days` }); + } else { + infoText.createEl("p", { text: `Algorithm: SM-2`, cls: "review-phase-sm2" }); + let phaseText; + let phaseClass; + if (schedule.scheduleCategory === "initial") { + const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length; + const currentStepDisplay = (schedule.reviewCount || 0) < totalInitialSteps ? (schedule.reviewCount || 0) + 1 : totalInitialSteps; + phaseText = `Initial phase (${currentStepDisplay}/${totalInitialSteps})`; + phaseClass = "review-phase-initial"; + } else if (schedule.scheduleCategory === "graduated") { + phaseText = "Graduated (Spaced Repetition)"; + phaseClass = "review-phase-graduated"; + } else { + phaseText = "Spaced Repetition"; + phaseClass = "review-phase-spaced"; + } + infoText.createEl("p", { text: phaseText, cls: phaseClass }); + infoText.createEl("p", { text: `Current ease: ${schedule.ease}` }); + infoText.createEl("p", { text: `Current interval: ${schedule.interval} days` }); + } + } + } + onClose() { + const { contentEl } = this; + contentEl.empty(); + console.log("Review modal closed for path: " + this.path); + } +}; + +// controllers/review-controller-core.ts +var ReviewControllerCore = class { + /** + * Initialize review controller + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + /** + * Currently loaded notes due for review + */ + this.todayNotes = []; + /** + * Current index in today's notes + */ + this.currentNoteIndex = 0; + /** + * Cache of linked notes to improve performance + */ + this.linkedNoteCache = /* @__PURE__ */ new Map(); + /** + * Traversal order of notes for hierarchical navigation + */ + this.traversalOrder = []; + /** + * Map of paths to their position in the traversal order + * Used for fast lookups during navigation + */ + this.traversalPositions = /* @__PURE__ */ new Map(); + /** + * Optional override for the current date, for testing or reviewing past/future notes. + * If null, Date.now() is used. + */ + this.currentReviewDateOverride = null; + this.plugin = plugin; + this.updateTodayNotes(); + } + /** + * Sets an override for the current review date. + * @param date Timestamp of the date to simulate, or null to use actual Date.now(). + */ + async setReviewDateOverride(date) { + this.currentReviewDateOverride = date; + console.log(`Review date override set to: ${date ? new Date(date).toISOString() : "null (use current time)"}`); + await this.updateTodayNotes(); + } + /** + * Gets the effective review date (override or actual Date.now()). + * @returns Timestamp for the effective review date. + */ + getEffectiveReviewDate() { + var _a; + return (_a = this.currentReviewDateOverride) != null ? _a : Date.now(); + } + /** + * Gets the current review date override. + * @returns Timestamp of the override, or null if no override is set. + */ + getCurrentReviewDateOverride() { + return this.currentReviewDateOverride; + } + /** + * Get the currently loaded notes due for review + */ + getTodayNotes() { + return this.todayNotes; + } + /** + * Get the current index in today's notes + */ + getCurrentNoteIndex() { + return this.currentNoteIndex; + } + /** + * Set the current index in today's notes + * + * @param index The new index + */ + setCurrentNoteIndex(index) { + if (index >= 0 && index < this.todayNotes.length) { + this.currentNoteIndex = index; + console.log(`Set currentNoteIndex to: ${index}`); + } else { + console.warn(`Attempted to set invalid currentNoteIndex: ${index}. Max index is ${this.todayNotes.length - 1}`); + this.currentNoteIndex = Math.max(0, Math.min(index, this.todayNotes.length - 1)); + } + } + /** + * Update the list of today's due notes + * + * @param preserveCurrentIndex Whether to try to preserve the current note index + */ + async updateTodayNotes(preserveCurrentIndex = false) { + let currentNotePath = null; + if (preserveCurrentIndex && this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) { + currentNotePath = this.todayNotes[this.currentNoteIndex].path; + } + this.linkedNoteCache.clear(); + this.traversalOrder = []; + this.traversalPositions.clear(); + const originalTraversalOrder = [...this.traversalOrder]; + const originalPositions = new Map(this.traversalPositions); + const effectiveDate = this.getEffectiveReviewDate(); + const matchExactDate = this.currentReviewDateOverride !== null; + const newDueNotes = this.plugin.dataStorage.reviewScheduleService.getDueNotesWithCustomOrder(effectiveDate, true, matchExactDate); + this.todayNotes = newDueNotes; + this.traversalOrder = this.todayNotes.map((note) => note.path); + this.traversalPositions = /* @__PURE__ */ new Map(); + this.traversalOrder.forEach((path, index) => { + this.traversalPositions.set(path, index); + }); + if (!preserveCurrentIndex) { + this.currentNoteIndex = 0; + } + this.linkedNoteCache.clear(); + if (this.todayNotes.length === 0) { + return; + } + if (preserveCurrentIndex && currentNotePath) { + const newIndex = this.todayNotes.findIndex((note) => note.path === currentNotePath); + if (newIndex !== -1) { + this.currentNoteIndex = newIndex; + } else { + this.currentNoteIndex = 0; + } + this.currentNoteIndex = Math.min(Math.max(0, newIndex), this.todayNotes.length - 1); + } + console.log(`Updated today's notes list with ${this.todayNotes.length} notes.`); + } + /** + * Review the current note + */ + async reviewCurrentNote() { + if (this.todayNotes.length === 0) { + await this.updateTodayNotes(); + if (this.todayNotes.length === 0) { + new import_obsidian3.Notice("No notes due for review today!"); + return; + } + } + const note = this.todayNotes[this.currentNoteIndex]; + await this.reviewNote(note.path); + } + /** + * Start a review for a note + * + * @param path Path to the note file + */ + async reviewNote(path) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!(file instanceof import_obsidian3.TFile)) { + new import_obsidian3.Notice("Cannot review: file not found"); + return; + } + await this.plugin.app.workspace.getLeaf().openFile(file); + this.showReviewModal(path); + } + /** + * Postpone a note's review + * + * @param path Path to the note file + * @param days Number of days to postpone (default: 1) + */ + async postponeNote(path, days = 1) { + await this.plugin.dataStorage.reviewScheduleService.postponeNote(path, days); + await this.plugin.savePluginData(); + await this.handleNotePostponed(path); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + } + /** + * Advance a note's review by one day, if eligible. + * + * @param path Path to the note file + */ + async advanceNote(path) { + const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path); + if (advanced) { + await this.plugin.savePluginData(); + await this.handleNoteAdvanced(path); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + new import_obsidian3.Notice(`Note advanced.`); + } else { + new import_obsidian3.Notice(`Note is not eligible to be advanced.`); + } + } + /** + * Handle a note being advanced, updating navigation state. + * This primarily involves re-evaluating the todayNotes list. + * + * @param path Path to the advanced note + */ + async handleNoteAdvanced(path) { + var _a; + console.log(`Handling advanced note: ${path}`); + await this.updateTodayNotes(true); + console.log(`After advance - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`); + console.log(`Current note after advance update: ${(_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path}, index: ${this.currentNoteIndex}`); + } + /** + * Handle a note being postponed, updating navigation state + * + * @param path Path to the postponed note + */ + async handleNotePostponed(path) { + var _a; + console.log(`Handling postponed note: ${path}`); + if (this.todayNotes.length === 0) + return; + const postponedIndex = this.todayNotes.findIndex((note) => note.path === path); + if (postponedIndex === -1) { + console.log(`Note ${path} not found in today's notes`); + return; + } + const wasCurrentNote = postponedIndex === this.currentNoteIndex; + const currentNotePath = this.currentNoteIndex < this.todayNotes.length ? this.todayNotes[this.currentNoteIndex].path : null; + console.log(`Current note before update: ${currentNotePath}, index: ${this.currentNoteIndex}`); + this.traversalOrder = this.traversalOrder.filter((p2) => p2 !== path); + this.traversalPositions.delete(path); + this.traversalOrder.forEach((p2, i) => this.traversalPositions.set(p2, i)); + this.todayNotes = this.todayNotes.filter((n) => n.path !== path); + if (wasCurrentNote) { + this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1); + } else if (currentNotePath) { + const newIndex = this.todayNotes.findIndex((n) => n.path === currentNotePath); + this.currentNoteIndex = newIndex !== -1 ? newIndex : Math.min(this.currentNoteIndex, this.todayNotes.length - 1); + } + await this.plugin.dataStorage.reviewScheduleService.updateCustomNoteOrder(this.traversalOrder); + console.log(`After postpone - todayNotes: ${this.todayNotes.length}, traversalOrder: ${this.traversalOrder.length}`); + if (wasCurrentNote) { + console.log(`Postponed note was the current note, selecting new current note`); + this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1); + } else if (currentNotePath) { + const newIndex = this.todayNotes.findIndex((note) => note.path === currentNotePath); + if (newIndex !== -1) { + console.log(`Keeping previously selected note: ${currentNotePath}`); + this.currentNoteIndex = newIndex; + } else { + console.log(`Previously selected note no longer in list, adjusting index`); + this.currentNoteIndex = Math.min(this.currentNoteIndex, this.todayNotes.length - 1); + } + } else { + console.log(`Adjusting index to remain in bounds`); + if (this.currentNoteIndex >= this.todayNotes.length) { + this.currentNoteIndex = Math.max(0, this.todayNotes.length - 1); + } + } + console.log(`Current note after update: ${(_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path}, index: ${this.currentNoteIndex}`); + } + /** + * Show the review modal for a note + * + * @param path Path to the note file + */ + showReviewModal(path) { + const modal = new ReviewModal(this.plugin.app, this.plugin, path); + modal.open(); + } + /** + * Skip the review of a note and reschedule for tomorrow with penalty + * + * @param path Path to the note file + */ + async skipReview(path) { + const effectiveDate = this.getEffectiveReviewDate(); + await this.plugin.dataStorage.reviewScheduleService.skipNote(path, 3 /* CorrectWithDifficulty */, effectiveDate); + await this.plugin.savePluginData(); + new import_obsidian3.Notice("Review postponed to tomorrow. Note will be easier to recover with a small penalty applied."); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + await this.updateTodayNotes(true); + if (this.todayNotes.length > 0) { + this.currentNoteIndex = (this.currentNoteIndex + 1) % this.todayNotes.length; + const currentPath = this.todayNotes[this.currentNoteIndex].path; + if (currentPath === path) { + new import_obsidian3.Notice("All caught up! No more notes due for review."); + return; + } + if (this.plugin.navigationController) { + if (this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) { + const nextNotePath = this.todayNotes[this.currentNoteIndex].path; + await this.plugin.navigationController.openNoteWithoutReview(nextNotePath); + this.showReviewModal(nextNotePath); + } else { + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + } else { + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + } else { + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + } + /** + * Process a review response + * + * @param path Path to the note file + * @param response User's response during review (SM-2 or FSRS) + */ + async processReviewResponse(path, response) { + var _a, _b; + const effectiveDate = this.getEffectiveReviewDate(); + const wasRecorded = await this.plugin.dataStorage.reviewScheduleService.recordReview(path, response, false, effectiveDate); + if (!wasRecorded) { + new import_obsidian3.Notice("Note previewed, not recorded"); + return; + } + const schedule = this.plugin.dataStorage.reviewScheduleService.schedules[path]; + let triggerRegeneration = false; + if (schedule && this.plugin.settings.enableQuestionRegenerationOnRating && this.plugin.mcqService && typeof response === "number") { + if (schedule.schedulingAlgorithm === "fsrs") { + if (response >= this.plugin.settings.minFsrsRatingForQuestionRegeneration) { + triggerRegeneration = true; + console.log(`FSRS Rating ${response} met/exceeded threshold (${this.plugin.settings.minFsrsRatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`); + } + } else { + if (response >= this.plugin.settings.minSm2RatingForQuestionRegeneration) { + triggerRegeneration = true; + console.log(`SM-2 Rating ${response} met/exceeded threshold (${this.plugin.settings.minSm2RatingForQuestionRegeneration}) for MCQ regeneration for note ${path}.`); + } + } + } + if (triggerRegeneration) { + this.plugin.mcqService.flagMCQSetForRegeneration(path); + } + let responseText; + if (schedule && schedule.schedulingAlgorithm === "fsrs") { + switch (response) { + case 1 /* Again */: + responseText = "Again (1)"; + break; + case 2 /* Hard */: + responseText = "Hard (2)"; + break; + case 3 /* Good */: + responseText = "Good (3)"; + break; + case 4 /* Easy */: + responseText = "Easy (4)"; + break; + default: + responseText = "Unknown FSRS Rating"; + } + } else { + switch (response) { + case 0 /* CompleteBlackout */: + responseText = "Complete Blackout (0)"; + break; + case 1 /* IncorrectResponse */: + responseText = "Incorrect Response (1)"; + break; + case 2 /* IncorrectButFamiliar */: + responseText = "Incorrect but Familiar (2)"; + break; + case 3 /* CorrectWithDifficulty */: + responseText = "Correct with Difficulty (3)"; + break; + case 4 /* CorrectWithHesitation */: + responseText = "Correct with Hesitation (4)"; + break; + case 5 /* PerfectRecall */: + responseText = "Perfect Recall (5)"; + break; + default: + responseText = "Unknown SM-2 Rating"; + } + } + new import_obsidian3.Notice(`Note review recorded: ${responseText}`); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + await this.updateTodayNotes(true); + const activeSession = this.plugin.dataStorage.getActiveSession(); + if (activeSession) { + await this.plugin.dataStorage.advanceActiveSession(); + const nextFilePath = this.plugin.dataStorage.getNextSessionFile(); + if (nextFilePath) { + await this.reviewNote(nextFilePath); + } else { + new import_obsidian3.Notice("Hierarchical review session complete!"); + } + } else if (this.todayNotes.length > 0) { + console.log("Before navigation - Current Index:", this.currentNoteIndex); + console.log("Before navigation - Current Note:", (_a = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _a.path); + console.log("Before navigation - Traversal Order:", this.traversalOrder); + this.currentNoteIndex = (this.currentNoteIndex + 1) % this.todayNotes.length; + console.log(`Moving to next note in sidebar order at index ${this.currentNoteIndex}`); + const currentPath = this.todayNotes[this.currentNoteIndex].path; + if (currentPath === path) { + console.log("All notes have been reviewed"); + new import_obsidian3.Notice("All caught up! No more notes due for review."); + return; + } + console.log("After navigation - Current Index:", this.currentNoteIndex); + console.log("After navigation - Current Note:", (_b = this.todayNotes[this.currentNoteIndex]) == null ? void 0 : _b.path); + if (this.todayNotes.length > 0 && this.currentNoteIndex < this.todayNotes.length) { + const nextNotePath = this.todayNotes[this.currentNoteIndex].path; + console.log("Opening review modal for:", nextNotePath); + if (this.plugin.navigationController) { + await this.plugin.navigationController.openNoteWithoutReview(nextNotePath); + this.showReviewModal(nextNotePath); + } else { + console.log("No navigation controller available"); + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + } else { + console.log("No more notes to review - reached the end"); + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + } else { + new import_obsidian3.Notice("All caught up! No more notes due for review."); + } + await this.plugin.savePluginData(); + } +}; + +// controllers/review-navigation-controller.ts +var import_obsidian4 = require("obsidian"); +var ReviewNavigationController = class { + /** + * Initialize navigation controller + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + this.plugin = plugin; + } + /** + * Navigate to the current note without showing review modal + */ + async navigateToCurrentNoteWithoutModal() { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const todayNotes = reviewController.getTodayNotes(); + const currentNoteIndex = reviewController.getCurrentNoteIndex(); + if (todayNotes.length === 0) { + await reviewController.updateTodayNotes(); + if (todayNotes.length === 0) { + new import_obsidian4.Notice("No notes due for review today!"); + return; + } + } + const note = todayNotes[currentNoteIndex]; + await this.openNoteWithoutReview(note.path); + } + /** + * Navigate to the next note following the current order + */ + async navigateToNextNote() { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const todayNotes = reviewController.getTodayNotes(); + let currentNoteIndex = reviewController.getCurrentNoteIndex(); + if (todayNotes.length === 0) { + await reviewController.updateTodayNotes(false); + if (todayNotes.length === 0) { + new import_obsidian4.Notice("No notes due for review today!"); + return; + } + } + if (todayNotes.length === 1) { + await this.navigateToCurrentNoteWithoutModal(); + return; + } + const nextIndex = (currentNoteIndex + 1) % todayNotes.length; + const nextNote = todayNotes[nextIndex]; + const nextPath = nextNote.path; + console.log(`Navigating to next note: ${nextIndex + 1}/${todayNotes.length} (${nextPath})`); + let messageType = "next note"; + const currentFile = this.plugin.app.vault.getAbstractFileByPath(todayNotes[currentNoteIndex].path); + const nextFile = this.plugin.app.vault.getAbstractFileByPath(nextPath); + if (currentFile instanceof import_obsidian4.TFile && nextFile instanceof import_obsidian4.TFile) { + const currentFolder = currentFile.parent ? currentFile.parent.path : null; + const nextFolder = nextFile.parent ? nextFile.parent.path : null; + if (this.plugin.sessionController && this.plugin.sessionController.getDueLinkedNotes(todayNotes[currentNoteIndex].path).includes(nextPath)) { + messageType = "linked note"; + } else if (currentFolder === nextFolder) { + messageType = "next note in folder"; + } else { + messageType = "next note in different folder"; + } + } + if (this.plugin.reviewController) { + this.plugin.reviewController.setCurrentNoteIndex(nextIndex); + } + await this.openNoteWithoutReview(nextPath); + if (this.plugin.settings.showNavigationNotifications) { + new import_obsidian4.Notice(`Navigated to ${messageType} (${nextIndex + 1}/${todayNotes.length})`); + } + } + /** + * Navigate to the next note without recording a review + * Uses the same traversal logic as navigateToNextNote + */ + async navigateToNextNoteWithoutRating() { + await this.navigateToNextNote(); + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const todayNotes = reviewController.getTodayNotes(); + const currentNoteIndex = reviewController.getCurrentNoteIndex(); + if (todayNotes.length > 0) { + const nextNote = todayNotes[currentNoteIndex]; + reviewController.showReviewModal(nextNote.path); + } + } + /** + * Navigate to the previous note in the current order + */ + async navigateToPreviousNote() { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const todayNotes = reviewController.getTodayNotes(); + let currentNoteIndex = reviewController.getCurrentNoteIndex(); + if (todayNotes.length === 0) { + const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0; + await reviewController.updateTodayNotes(hasCustomOrder); + if (todayNotes.length === 0) { + new import_obsidian4.Notice("No notes due for review today!"); + return; + } + } + if (todayNotes.length === 1) { + await this.navigateToCurrentNoteWithoutModal(); + return; + } + const prevIndex = (currentNoteIndex - 1 + todayNotes.length) % todayNotes.length; + const prevNote = todayNotes[prevIndex]; + const prevPath = prevNote.path; + console.log(`Navigating to previous note: ${prevIndex + 1}/${todayNotes.length} (${prevPath})`); + if (this.plugin.reviewController) { + this.plugin.reviewController.setCurrentNoteIndex(prevIndex); + } + await this.openNoteWithoutReview(prevPath); + if (this.plugin.settings.showNavigationNotifications) { + new import_obsidian4.Notice(`Navigated to previous note (${prevIndex + 1}/${todayNotes.length})`); + } + } + /** + * Open a note without showing the review modal + * + * @param path Path to the note file + */ + async openNoteWithoutReview(path) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!(file instanceof import_obsidian4.TFile)) { + new import_obsidian4.Notice("Cannot navigate: file not found"); + return; + } + await this.plugin.app.workspace.getLeaf().openFile(file); + } + /** + * Swap two notes in the traversal order + * + * @param path1 Path to the first note + * @param path2 Path to the second note + */ + async swapNotes(path1, path2) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const todayNotes = reviewController.getTodayNotes(); + let currentNoteIndex = reviewController.getCurrentNoteIndex(); + const index1 = todayNotes.findIndex((n) => n.path === path1); + const index2 = todayNotes.findIndex((n) => n.path === path2); + if (index1 < 0 || index2 < 0) + return; + const newTodayNotes = [...todayNotes]; + const temp = newTodayNotes[index1]; + newTodayNotes[index1] = newTodayNotes[index2]; + newTodayNotes[index2] = temp; + const newOrder = newTodayNotes.map((note) => note.path); + await this.plugin.reviewScheduleService.updateCustomNoteOrder(newOrder); + await this.plugin.savePluginData(); + await reviewController.updateTodayNotes(true); + console.log(`Swapped notes: ${path1} and ${path2}`); + } +}; + +// controllers/review-batch-controller.ts +var import_obsidian7 = require("obsidian"); + +// ui/batch-review-modal.ts +var import_obsidian6 = require("obsidian"); + +// ui/consolidated-mcq-modal.ts +var import_obsidian5 = require("obsidian"); +var ConsolidatedMCQModal = class extends import_obsidian5.Modal { + /** + * Initialize consolidated MCQ modal + * + * @param plugin Reference to the main plugin + * @param mcqSets Collection of all MCQ sets + * @param onComplete Callback for when review is completed + */ + constructor(plugin, mcqSets, onComplete) { + super(plugin.app); + /** + * All questions from all MCQ sets, flattened + */ + this.allQuestions = []; + /** + * Current question index + */ + this.currentQuestionIndex = 0; + /** + * User's answers + */ + this.answers = []; + /** + * Start time for current question + */ + this.questionStartTime = 0; + this.plugin = plugin; + this.mcqSets = mcqSets; + this.onComplete = onComplete; + for (const set of mcqSets) { + set.mcqSet.questions.forEach((question, index) => { + this.allQuestions.push({ + ...question, + notePath: set.path, + fileName: set.fileName, + mcqSetId: `${set.path}_${set.mcqSet.generatedAt}`, + originalIndex: index + }); + }); + } + } + /** + * Called when the modal is opened + */ + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("spaceforge-mcq-modal"); + const headerContainer = contentEl.createDiv("mcq-header-container"); + headerContainer.createEl("h2", { text: "Multiple Choice Review" }); + const progressEl = contentEl.createDiv("mcq-progress"); + const progressPercent = Math.round((this.currentQuestionIndex + 1) / this.allQuestions.length * 100); + contentEl.setAttribute("data-progress", progressPercent.toString()); + progressEl.setText(`Question ${this.currentQuestionIndex + 1} of ${this.allQuestions.length}`); + const progressCounter = contentEl.createDiv(); + 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`); + const noteInfoEl = contentEl.createDiv("mcq-note-info"); + noteInfoEl.setText(`Question from: ${this.allQuestions[this.currentQuestionIndex].fileName}`); + this.questionStartTime = Date.now(); + this.displayCurrentQuestion(contentEl); + this.registerKeyboardShortcuts(); + } + /** + * Register keyboard shortcuts for answering questions + */ + registerKeyboardShortcuts() { + const keyDownHandler = (event) => { + const questionIndex = this.currentQuestionIndex; + if (questionIndex >= this.allQuestions.length) + return; + const question = this.allQuestions[questionIndex]; + if (!question || !question.choices) + return; + const num = parseInt(event.key); + if (!isNaN(num) && num >= 1 && num <= question.choices.length) { + this.handleAnswer(num - 1); + return; + } + if (event.key.length === 1) { + const letterCode = event.key.toUpperCase().charCodeAt(0); + const index = letterCode - 65; + if (index >= 0 && index < question.choices.length) { + this.handleAnswer(index); + return; + } + } + }; + document.addEventListener("keydown", keyDownHandler); + this.onClose = () => { + document.removeEventListener("keydown", keyDownHandler); + const { contentEl } = this; + contentEl.empty(); + }; + } + /** + * Display the current question + * + * @param containerEl Container element + */ + displayCurrentQuestion(containerEl) { + const questionIndex = this.currentQuestionIndex; + if (questionIndex >= this.allQuestions.length) { + this.completeReview(); + return; + } + const question = this.allQuestions[questionIndex]; + if (!question || !question.choices || question.choices.length < 2) { + console.error("Invalid question data:", question); + new import_obsidian5.Notice("Error: Invalid question data. Moving to next question."); + this.currentQuestionIndex++; + if (this.currentQuestionIndex < this.allQuestions.length) { + this.displayCurrentQuestion(containerEl); + } else { + this.completeReview(); + } + return; + } + const questionContainer = containerEl.querySelector(".mcq-question-container"); + if (questionContainer) { + questionContainer.remove(); + } + const noteInfoEl = containerEl.querySelector(".mcq-note-info"); + if (noteInfoEl instanceof HTMLElement) { + noteInfoEl.setText(`Question from: ${question.fileName}`); + } + const newQuestionContainer = containerEl.createDiv("mcq-question-container"); + const questionEl = newQuestionContainer.createDiv("mcq-question-text"); + questionEl.setText(question.question); + const choicesContainer = newQuestionContainer.createDiv("mcq-choices-container"); + question.choices.forEach((choice, index) => { + const choiceEl = choicesContainer.createDiv("mcq-choice"); + const choiceBtn = choiceEl.createEl("button", { + cls: "mcq-choice-btn" + }); + const letterLabel = choiceBtn.createSpan("mcq-choice-letter"); + letterLabel.setText(String.fromCharCode(65 + index) + ") "); + const textSpan = choiceBtn.createSpan("mcq-choice-text"); + textSpan.setText(choice || "(Empty choice)"); + choiceBtn.addEventListener("click", () => { + this.handleAnswer(index); + }); + }); + } + /** + * Handle user's answer selection + * + * @param selectedIndex Index of the selected answer + */ + handleAnswer(selectedIndex) { + const questionIndex = this.currentQuestionIndex; + const question = this.allQuestions[questionIndex]; + const isCorrect = selectedIndex === question.correctAnswerIndex; + const timeToAnswer = (Date.now() - this.questionStartTime) / 1e3; + const existingAnswerIndex = this.answers.findIndex( + (a) => a.questionIndex === questionIndex + ); + let answer; + if (existingAnswerIndex >= 0) { + answer = this.answers[existingAnswerIndex]; + answer.selectedAnswerIndex = selectedIndex; + answer.correct = isCorrect; + answer.timeToAnswer = timeToAnswer; + answer.attempts += 1; + } else { + answer = { + questionIndex, + selectedAnswerIndex: selectedIndex, + // Always record the selected index + correct: isCorrect, + timeToAnswer, + attempts: 1, + notePath: question.notePath, + fileName: question.fileName + }; + this.answers.push(answer); + } + this.highlightAnswer(selectedIndex, isCorrect); + setTimeout(() => { + if (isCorrect) { + this.currentQuestionIndex++; + this.questionStartTime = Date.now(); + const { contentEl } = this; + const progressEl = contentEl.querySelector(".mcq-progress"); + if (progressEl instanceof HTMLElement) { + progressEl.textContent = `Question ${this.currentQuestionIndex + 1} of ${this.allQuestions.length} (${this.allQuestions.length} questions from ${this.mcqSets.length} notes)`; + } + const newProgressPercent = Math.round((this.currentQuestionIndex + 1) / this.allQuestions.length * 100); + contentEl.setAttribute("data-progress", newProgressPercent.toString()); + if (this.currentQuestionIndex < this.allQuestions.length) { + this.displayCurrentQuestion(contentEl); + } else { + this.completeReview(); + } + } else { + new import_obsidian5.Notice("Incorrect answer. Try again to proceed to the next question."); + setTimeout(() => { + const choiceButtons = document.querySelectorAll(".mcq-choice-btn"); + if (choiceButtons.length <= selectedIndex) + return; + const selectedBtn = choiceButtons[selectedIndex]; + selectedBtn.classList.remove("mcq-choice-incorrect"); + }, 500); + } + }, 1e3); + } + /** + * Highlight the selected answer + * + * @param selectedIndex Index of the selected answer + * @param isCorrect Whether the answer is correct + */ + highlightAnswer(selectedIndex, isCorrect) { + const choiceButtons = document.querySelectorAll(".mcq-choice-btn"); + if (choiceButtons.length <= selectedIndex) + return; + const selectedBtn = choiceButtons[selectedIndex]; + if (isCorrect) { + selectedBtn.classList.add("mcq-choice-correct"); + } else { + selectedBtn.classList.add("mcq-choice-incorrect"); + } + } + /** + * Complete the review and show results + */ + completeReview() { + const noteScores = {}; + for (const answer of this.answers) { + if (!noteScores[answer.notePath]) { + noteScores[answer.notePath] = { + totalQuestions: 0, + correctAnswers: 0, + score: 0, + notePath: answer.notePath, + fileName: answer.fileName + }; + } + } + for (const question of this.allQuestions) { + if (!noteScores[question.notePath]) { + noteScores[question.notePath] = { + totalQuestions: 0, + correctAnswers: 0, + score: 0, + notePath: question.notePath, + fileName: question.fileName + // Assuming fileName is consistent for the notePath + }; + } + noteScores[question.notePath].totalQuestions++; + } + const finalAnswersCorrectCount = {}; + for (const question of this.allQuestions) { + finalAnswersCorrectCount[question.notePath] = 0; + } + for (const answer of this.answers) { + if (answer.correct && answer.attempts <= 1) { + if (noteScores[answer.notePath]) { + noteScores[answer.notePath].correctAnswers++; + } + } + } + for (const notePath in noteScores) { + const noteScore = noteScores[notePath]; + if (noteScore.totalQuestions > 0) { + noteScore.score = noteScore.correctAnswers / noteScore.totalQuestions; + } else { + noteScore.score = 0; + } + } + const results = []; + for (const notePath in noteScores) { + const noteScore = noteScores[notePath]; + const score = noteScore.score; + let success = false; + let response = 1 /* Hard */; + if (score >= 0.9) { + success = true; + response = 5 /* Perfect */; + } else if (score >= 0.7) { + success = true; + response = 4 /* Good */; + } else if (score >= 0.5) { + success = true; + response = 3 /* Fair */; + } else { + success = false; + response = 1 /* Hard */; + } + results.push({ + path: notePath, + success, + response, + score + }); + } + this.onComplete(results); + const { contentEl } = this; + contentEl.empty(); + const headerEl = contentEl.createEl("h2", { text: "MCQ Review Complete" }); + headerEl.style.color = "var(--mcq-primary)"; + headerEl.style.textAlign = "center"; + headerEl.style.marginBottom = "24px"; + const totalCorrectOverall = this.answers.filter((a) => a.correct && a.attempts <= 1).length; + const totalQuestionsOverall = this.allQuestions.length; + const overallScore = totalQuestionsOverall > 0 ? totalCorrectOverall / totalQuestionsOverall : 0; + const scorePercentOverall = Math.round(overallScore * 100); + const scoreEl = contentEl.createDiv("mcq-score"); + const scoreTextEl = scoreEl.createDiv("mcq-score-text"); + scoreTextEl.setText(`Overall Score: ${scorePercentOverall}%`); + const performanceIndicator = scoreEl.createDiv(); + performanceIndicator.style.marginTop = "8px"; + performanceIndicator.style.fontSize = "1.1em"; + if (scorePercentOverall >= 90) { + performanceIndicator.setText("\u{1F393} Excellent Performance!"); + performanceIndicator.style.color = "var(--mcq-correct)"; + } else if (scorePercentOverall >= 70) { + performanceIndicator.setText("\u{1F44D} Good Work!"); + performanceIndicator.style.color = "var(--mcq-primary)"; + } else if (scorePercentOverall >= 50) { + performanceIndicator.setText("\u{1F504} Keep Practicing"); + performanceIndicator.style.color = "var(--mcq-warning)"; + } else { + performanceIndicator.setText("\u{1F4DA} More Review Recommended"); + performanceIndicator.style.color = "var(--mcq-text-muted)"; + } + 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`); + const noteScoresEl = contentEl.createDiv("mcq-note-scores"); + const scoreHeading = noteScoresEl.createEl("h3", { text: "Scores by Note" }); + scoreHeading.style.marginTop = "20px"; + const sortedNotes = Object.keys(noteScores).sort((a, b2) => noteScores[b2].score - noteScores[a].score); + for (const notePath of sortedNotes) { + const noteScore = noteScores[notePath]; + if (noteScore.totalQuestions === 0) + continue; + const noteScoreEl = noteScoresEl.createDiv("mcq-note-score"); + noteScoreEl.createEl("div", { + text: noteScore.fileName, + cls: "mcq-note-score-title" + }); + const scorePercent = Math.round(noteScore.score * 100); + const scoreTextValueEl = noteScoreEl.createEl("div", { + text: `Score: ${scorePercent}% (${noteScore.correctAnswers}/${noteScore.totalQuestions})`, + cls: "mcq-note-score-value" + }); + if (noteScore.score >= 0.7) { + scoreTextValueEl.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)"; + } + 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)"; + } + } + 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) { + 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)"; + correctAnswerSpan.style.fontWeight = "bold"; + }); + const closeBtn = contentEl.createEl("button", "mcq-close-btn"); + closeBtn.setText("Close"); + closeBtn.style.marginTop = "24px"; + closeBtn.addEventListener("click", () => { + this.close(); + }); + } + /** + * Shuffle an array + * + * @param array Array to shuffle + * @returns Shuffled array + */ + shuffleArray(array) { + const newArray = [...array]; + for (let i = newArray.length - 1; i > 0; i--) { + const j2 = Math.floor(Math.random() * (i + 1)); + [newArray[i], newArray[j2]] = [newArray[j2], newArray[i]]; + } + return newArray; + } +}; + +// ui/batch-review-modal.ts +var BatchReviewModal = class extends import_obsidian6.Modal { + constructor(app, plugin, notes, useMCQ = false) { + super(app); + this.currentIndex = 0; + this.results = []; + this.started = false; + this.allMCQSets = []; + this.collectingMCQs = false; + this.plugin = plugin; + this.notes = notes; + this.useMCQ = useMCQ; + } + async onOpen() { + const { contentEl } = this; + this.renderStartScreen(contentEl); + } + renderStartScreen(contentEl) { + contentEl.empty(); + contentEl.createEl("h2", { text: "Batch Review" }); + const infoEl = contentEl.createDiv("batch-review-info"); + infoEl.createEl("p", { text: `${this.notes.length} notes scheduled for review` }); + this.estimateAndShowTime(infoEl); + const buttonsEl = contentEl.createDiv("batch-review-buttons"); + const startButton = buttonsEl.createEl("button", { + text: this.useMCQ ? "Start MCQ Review" : "Start Manual Review", + cls: "batch-review-start-button" + }); + startButton.addEventListener("click", () => this.startBatchReview()); + const toggleMCQButton = buttonsEl.createEl("button", { + text: this.useMCQ ? "Switch to Manual Review" : "Switch to MCQ Review", + cls: "batch-review-toggle-button" + }); + toggleMCQButton.addEventListener("click", () => { + this.useMCQ = !this.useMCQ; + this.renderStartScreen(contentEl); + }); + if (this.useMCQ) { + const regenerateButton = buttonsEl.createEl("button", { + text: "Regenerate All MCQs", + cls: "batch-review-regenerate-button" + }); + regenerateButton.addEventListener("click", () => { + this.close(); + if (this.plugin.batchController) { + this.plugin.batchController.regenerateAllMCQs(); + } + }); + } + const cancelButton = buttonsEl.createEl("button", { text: "Cancel", cls: "batch-review-cancel-button" }); + cancelButton.addEventListener("click", () => this.close()); + } + async estimateAndShowTime(containerEl) { + let totalTime = 0; + for (const note of this.notes) { + totalTime += await this.plugin.dataStorage.estimateReviewTime(note.path); + } + if (this.useMCQ) { + totalTime += this.notes.length * 15; + } + containerEl.createEl("p", { text: `Estimated time: ${EstimationUtils.formatTime(totalTime)}`, cls: "batch-review-time" }); + } + async startBatchReview() { + this.started = true; + if (this.useMCQ) { + this.collectingMCQs = true; + await this.collectAllMCQs(); + if (this.allMCQSets.length > 0) { + this.showConsolidatedMCQUI(); + } else { + new import_obsidian6.Notice("Could not generate any MCQs. Falling back to manual review."); + await this.processNextManual(); + } + } else { + await this.processNextManual(); + } + } + async collectAllMCQs() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Collecting MCQs" }); + const progressEl = contentEl.createDiv("batch-review-progress"); + progressEl.createEl("p", { text: `Preparing MCQs for ${this.notes.length} notes...` }); + const progressBar = contentEl.createDiv("mcq-collection-progress"); + progressBar.style.width = "100%"; + progressBar.style.height = "10px"; + progressBar.style.marginTop = "20px"; + progressBar.style.backgroundColor = "var(--background-modifier-border)"; + progressBar.style.borderRadius = "5px"; + progressBar.style.overflow = "hidden"; + const progressFill = progressBar.createDiv(); + progressFill.style.width = "0%"; + progressFill.style.height = "100%"; + progressFill.style.backgroundColor = "var(--interactive-accent)"; + progressFill.style.transition = "width 0.3s ease"; + const statusEl = contentEl.createDiv("batch-review-status"); + statusEl.style.marginTop = "10px"; + statusEl.style.color = "var(--text-muted)"; + this.allMCQSets = []; + for (let i = 0; i < this.notes.length; i++) { + const note = this.notes[i]; + const file = this.plugin.app.vault.getAbstractFileByPath(note.path); + const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path; + progressFill.style.width = `${(i + 1) / this.notes.length * 100}%`; + statusEl.setText(`Processing ${i + 1}/${this.notes.length}: ${fileName}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path); + if (!mcqSet && this.plugin.mcqGenerationService && this.plugin.mcqController) { + statusEl.setText(`Generating MCQs for ${i + 1}/${this.notes.length}: ${fileName}...`); + try { + mcqSet = await this.plugin.mcqController.generateMCQs(note.path); + } catch (error) { + console.error("Error generating MCQs:", error); + statusEl.setText(`Error generating MCQs for ${fileName}`); + await new Promise((resolve) => setTimeout(resolve, 1e3)); + } + } + if (mcqSet) { + this.allMCQSets.push({ path: note.path, mcqSet, fileName }); + } + } + statusEl.setText(`Collected MCQs for ${this.allMCQSets.length}/${this.notes.length} notes`); + await new Promise((resolve) => setTimeout(resolve, 1e3)); + this.collectingMCQs = false; + } + showConsolidatedMCQUI() { + if (this.allMCQSets.length === 0) { + this.showSummary(); + return; + } + if (this.plugin.mcqController) { + try { + this.close(); + const consolidatedModal = new ConsolidatedMCQModal( + this.plugin, + this.allMCQSets, + (results) => { + this.results = results; + 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 import_obsidian6.Notice("Error showing MCQ review. Falling back to manual review."); + this.open(); + this.processNextManual(); + } + } else { + new import_obsidian6.Notice("MCQ controller not available. Falling back to manual review."); + this.open(); + this.processNextManual(); + } + } + async recordAllReviews(results) { + for (const result of results) { + await this.plugin.dataStorage.recordReview(result.path, result.response); + } + } + // This method is likely unused now due to the consolidated modal approach, but kept for reference/potential fallback + async processNextMCQ() { + if (this.currentIndex >= this.notes.length) { + this.showSummary(); + return; + } + const note = this.notes[this.currentIndex]; + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "MCQ Review in Progress" }); + const progressEl = contentEl.createDiv("batch-review-progress"); + progressEl.createEl("p", { text: `Processing note ${this.currentIndex + 1}/${this.notes.length}` }); + const file = this.plugin.app.vault.getAbstractFileByPath(note.path); + const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path; + progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" }); + this.close(); + if (this.plugin.mcqController) { + let mcqSet = this.plugin.dataStorage.getMCQSetForNote(note.path); + if (!mcqSet && this.plugin.mcqGenerationService) { + new import_obsidian6.Notice(`Generating MCQs for ${fileName}...`); + mcqSet = await this.plugin.mcqController.generateMCQs(note.path); + } + if (mcqSet) { + this.plugin.mcqController.startMCQReview( + note.path + /* Removed callback */ + ); + console.warn("processNextMCQ flow is currently inactive due to consolidated modal implementation."); + this.open(); + this.showSummary(); + } else { + new import_obsidian6.Notice(`Couldn't generate MCQs for ${fileName}, falling back to manual review`); + this.open(); + this.processNextManual(); + } + } else { + new import_obsidian6.Notice("MCQ controller not available, falling back to manual review"); + this.open(); + this.processNextManual(); + } + } + getLatestMCQScore(path) { + const session = this.plugin.dataStorage.getLatestMCQSessionForNote(path); + return session == null ? void 0 : session.score; + } + async processNextManual() { + if (this.currentIndex >= this.notes.length) { + this.showSummary(); + return; + } + const note = this.notes[this.currentIndex]; + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Manual Review" }); + const progressEl = contentEl.createDiv("batch-review-progress"); + progressEl.createEl("p", { text: `Note ${this.currentIndex + 1}/${this.notes.length}` }); + const file = this.plugin.app.vault.getAbstractFileByPath(note.path); + const fileName = file instanceof import_obsidian6.TFile ? file.basename : note.path; + progressEl.createEl("p", { text: `Current note: ${fileName}`, cls: "batch-review-current-note" }); + if (this.plugin.navigationController) { + await this.plugin.navigationController.openNoteWithoutReview(note.path); + } + const buttonsContainer = contentEl.createDiv("batch-review-buttons"); + const blackoutButton = buttonsContainer.createEl("button", { text: "0: Complete Blackout", cls: "review-button review-button-complete-blackout" }); + blackoutButton.addEventListener("click", () => this.recordManualResult(note.path, 0 /* CompleteBlackout */)); + const incorrectButton = buttonsContainer.createEl("button", { text: "1: Incorrect Response", cls: "review-button review-button-incorrect" }); + incorrectButton.addEventListener("click", () => this.recordManualResult(note.path, 1 /* IncorrectResponse */)); + const incorrectFamiliarButton = buttonsContainer.createEl("button", { text: "2: Incorrect but Familiar", cls: "review-button review-button-incorrect-familiar" }); + incorrectFamiliarButton.addEventListener("click", () => this.recordManualResult(note.path, 2 /* IncorrectButFamiliar */)); + const correctDifficultyButton = buttonsContainer.createEl("button", { text: "3: Correct with Difficulty", cls: "review-button review-button-correct-difficulty" }); + correctDifficultyButton.addEventListener("click", () => this.recordManualResult(note.path, 3 /* CorrectWithDifficulty */)); + const correctHesitationButton = buttonsContainer.createEl("button", { text: "4: Correct with Hesitation", cls: "review-button review-button-correct-hesitation" }); + correctHesitationButton.addEventListener("click", () => this.recordManualResult(note.path, 4 /* CorrectWithHesitation */)); + const perfectRecallButton = buttonsContainer.createEl("button", { text: "5: Perfect Recall", cls: "review-button review-button-perfect-recall" }); + perfectRecallButton.addEventListener("click", () => this.recordManualResult(note.path, 5 /* PerfectRecall */)); + const skipButton = buttonsContainer.createEl("button", { text: "Skip", cls: "review-button review-button-skip" }); + skipButton.addEventListener("click", () => { + this.currentIndex++; + this.processNextManual(); + }); + } + async recordManualResult(path, response) { + this.results.push({ path, success: response >= 3 /* CorrectWithDifficulty */, response }); + const wasRecorded = await this.plugin.dataStorage.recordReview(path, response); + if (!wasRecorded) { + new import_obsidian6.Notice("Note previewed, not recorded"); + } + this.currentIndex++; + this.processNextManual(); + } + showSummary() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Batch Review Complete" }); + const statsEl = contentEl.createDiv("batch-review-summary-stats"); + const totalNotes = this.results.length; + const successfulNotes = this.results.filter((r) => r.success).length; + const successRate = totalNotes > 0 ? Math.round(successfulNotes / totalNotes * 100) : 0; + statsEl.createEl("p", { text: `Completed: ${totalNotes}/${this.notes.length} notes` }); + statsEl.createEl("p", { text: `Success rate: ${successRate}% (${successfulNotes}/${totalNotes})`, cls: successRate >= 70 ? "batch-review-success" : "batch-review-needs-improvement" }); + const resultsEl = contentEl.createDiv("batch-review-results"); + resultsEl.createEl("h3", { text: "Individual Results" }); + const resultsListEl = resultsEl.createDiv("batch-review-results-list"); + for (const result of this.results) { + const resultItemEl = resultsListEl.createDiv("batch-review-result-item"); + const file = this.plugin.app.vault.getAbstractFileByPath(result.path); + const fileName = file instanceof import_obsidian6.TFile ? file.basename : result.path; + resultItemEl.createEl("div", { text: fileName, cls: "batch-review-result-filename" }); + let responseText; + let responseClass; + switch (result.response) { + case 0 /* CompleteBlackout */: + responseText = "Complete Blackout (0)"; + responseClass = "batch-review-complete-blackout"; + break; + case 1 /* IncorrectResponse */: + responseText = "Incorrect Response (1)"; + responseClass = "batch-review-incorrect"; + break; + case 2 /* IncorrectButFamiliar */: + responseText = "Incorrect but Familiar (2)"; + responseClass = "batch-review-incorrect-familiar"; + break; + case 3 /* CorrectWithDifficulty */: + responseText = "Correct with Difficulty (3)"; + responseClass = "batch-review-correct-difficulty"; + break; + case 4 /* CorrectWithHesitation */: + responseText = "Correct with Hesitation (4)"; + responseClass = "batch-review-correct-hesitation"; + break; + case 5 /* PerfectRecall */: + responseText = "Perfect Recall (5)"; + responseClass = "batch-review-perfect-recall"; + break; + default: + responseText = "Unknown"; + responseClass = ""; + } + resultItemEl.createEl("div", { text: responseText, cls: `batch-review-result-response ${responseClass}` }); + if (result.score !== void 0) { + resultItemEl.createEl("div", { text: `MCQ Score: ${Math.round(result.score * 100)}%`, cls: "batch-review-result-mcq-score" }); + } + } + const closeButton = contentEl.createEl("button", { text: "Close", cls: "batch-review-close-button" }); + closeButton.addEventListener("click", () => { + this.close(); + if (this.plugin.sidebarView) + this.plugin.sidebarView.refresh(); + }); + } + onClose() { + const { contentEl } = this; + contentEl.empty(); + if (this.started && this.currentIndex < this.notes.length && this.results.length > 0) { + new import_obsidian6.Notice(`Batch review interrupted after ${this.results.length} notes`); + } + } +}; + +// controllers/review-batch-controller.ts +var ReviewBatchController = class { + /** + * Initialize batch controller + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + this.plugin = plugin; + } + /** + * Start reviewing all of today's notes + */ + async reviewAllTodaysNotes() { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + await reviewController.updateTodayNotes(true); + const todayNotes = reviewController.getTodayNotes(); + if (todayNotes.length === 0) { + new import_obsidian7.Notice("No notes due for review today!"); + return; + } + console.log("Review All Notes - Today's Notes Order:", todayNotes.map((n) => n.path)); + if (reviewController) { + await reviewController.updateTodayNotes(false); + const note = todayNotes[0]; + console.log(`Review All: Starting with note at index 0: ${note.path}`); + await reviewController.reviewNote(note.path); + } + new import_obsidian7.Notice(`Starting review of all ${todayNotes.length} notes due today`); + } + /** + * Review a specific set of notes + * + * @param paths Array of note paths to review + * @param useMCQ Whether to use MCQs for testing (default: false) + */ + async reviewNotes(paths, useMCQ = false) { + if (paths.length === 0) { + new import_obsidian7.Notice("No notes selected for review."); + return; + } + const notesToReview = this.plugin.dataStorage.getDueNotesWithCustomOrder().filter((note) => paths.includes(note.path)); + if (notesToReview.length === 0) { + new import_obsidian7.Notice("Selected notes are not currently due for review."); + return; + } + if (useMCQ) { + new import_obsidian7.Notice(`Preparing MCQs for ${notesToReview.length} selected notes. This may take a moment...`); + } else { + new import_obsidian7.Notice(`Starting review of ${notesToReview.length} selected notes.`); + } + const modal = new BatchReviewModal(this.plugin.app, this.plugin, notesToReview, useMCQ); + modal.open(); + } + /** + * Review all notes with MCQs in a batch + * + * @param useMCQ Whether to use MCQs for testing + */ + async reviewAllNotesWithMCQ(useMCQ = true) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0; + await reviewController.updateTodayNotes(hasCustomOrder); + const todayNotes = reviewController.getTodayNotes(); + if (todayNotes.length === 0) { + new import_obsidian7.Notice("No notes due for review today!"); + return; + } + if (useMCQ) { + new import_obsidian7.Notice("Preparing all MCQs. This may take a moment..."); + } + const orderedNotes = [...todayNotes]; + console.log("Review All with MCQ: Using sidebar order for consistent navigation"); + console.log("Ordered notes:", orderedNotes.map((n) => n.path)); + const modal = new BatchReviewModal(this.plugin.app, this.plugin, orderedNotes, useMCQ); + modal.open(); + } + /** + * Regenerate MCQs for all notes due today + */ + async regenerateAllMCQs() { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + const hasCustomOrder = this.plugin.reviewScheduleService.customNoteOrder.length > 0; + await reviewController.updateTodayNotes(hasCustomOrder); + const todayNotes = reviewController.getTodayNotes(); + if (todayNotes.length === 0) { + new import_obsidian7.Notice("No notes due for review today!"); + return; + } + if (!this.plugin.mcqController) { + new import_obsidian7.Notice("MCQ controller not initialized. Please check MCQ settings."); + return; + } + new import_obsidian7.Notice(`Regenerating MCQs for ${todayNotes.length} notes...`); + let generatedCount = 0; + for (const note of todayNotes) { + const success = await this.plugin.mcqController.generateMCQs(note.path); + if (success) { + generatedCount++; + } + } + new import_obsidian7.Notice(`Generated MCQs for ${generatedCount} out of ${todayNotes.length} notes`); + this.reviewAllNotesWithMCQ(true); + } + /** + * Postpone a specific set of notes + * + * @param paths Array of note paths to postpone + * @param days Number of days to postpone (default: 1) + */ + async postponeNotes(paths, days = 1) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + if (paths.length === 0) { + new import_obsidian7.Notice("No notes selected to postpone."); + return; + } + new import_obsidian7.Notice(`Postponing ${paths.length} notes by ${days} day(s)...`); + for (const path of paths) { + await this.plugin.dataStorage.postponeNote(path, days); + } + await this.plugin.savePluginData(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + await reviewController.updateTodayNotes(true); + new import_obsidian7.Notice(`Postponed ${paths.length} notes by ${days} day(s).`); + } + /** + * Advance a specific set of notes by one day each, if eligible. + * + * @param paths Array of note paths to advance + */ + async advanceNotes(paths) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + if (paths.length === 0) { + new import_obsidian7.Notice("No notes selected to advance."); + return; + } + let advancedCount = 0; + for (const path of paths) { + const advanced = await this.plugin.dataStorage.reviewScheduleService.advanceNote(path); + if (advanced) { + advancedCount++; + } + } + if (advancedCount > 0) { + await this.plugin.savePluginData(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + await reviewController.updateTodayNotes(true); + new import_obsidian7.Notice(`Advanced ${advancedCount} note(s).`); + } else { + new import_obsidian7.Notice("No eligible notes were advanced."); + } + } + /** + * Remove a specific set of notes from the review schedule + * + * @param paths Array of note paths to remove + */ + async removeNotes(paths) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return; + if (paths.length === 0) { + new import_obsidian7.Notice("No notes selected to remove."); + return; + } + new import_obsidian7.Notice(`Removing ${paths.length} notes from review schedule...`); + for (const path of paths) { + await this.plugin.dataStorage.removeFromReview(path); + } + await this.plugin.savePluginData(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + await reviewController.updateTodayNotes(true); + new import_obsidian7.Notice(`Removed ${paths.length} notes from review schedule.`); + } +}; + +// controllers/review-controller-mcq.ts +var import_obsidian9 = require("obsidian"); + +// ui/mcq-modal.ts +var import_obsidian8 = require("obsidian"); +var MCQModal = class extends import_obsidian8.Modal { + constructor(plugin, notePath, mcqSet, onCompleteCallback = null) { + super(plugin.app); + this.questionStartTime = 0; + this.isFreshGeneration = false; + this.selectedAnswerIndex = -1; + this.plugin = plugin; + this.notePath = notePath; + this.mcqSet = mcqSet; + this.onCompleteCallback = onCompleteCallback; + this.isFreshGeneration = mcqSet.generatedAt > Date.now() - 6e4; + this.session = { + mcqSetId: `${mcqSet.notePath}_${mcqSet.generatedAt}`, + notePath, + answers: [], + score: 0, + currentQuestionIndex: 0, + completed: false, + startedAt: Date.now(), + completedAt: null + }; + } + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("spaceforge-mcq-modal"); + const headerContainer = contentEl.createDiv("mcq-header-container"); + headerContainer.createEl("h2", { text: "Multiple Choice Review" }); + if (!this.isFreshGeneration) { + const refreshBtn = headerContainer.createDiv("mcq-refresh-btn"); + (0, import_obsidian8.setIcon)(refreshBtn, "refresh-cw"); + refreshBtn.setAttribute("aria-label", "Generate new questions"); + refreshBtn.addEventListener("click", async () => { + this.close(); + const mcqGenerationService = this.plugin.mcqGenerationService; + if (mcqGenerationService && this.plugin.mcqController) { + const file = this.plugin.app.vault.getAbstractFileByPath(this.notePath); + if (file instanceof import_obsidian8.TFile) { + const content = await this.plugin.app.vault.read(file); + const newMcqSet = await mcqGenerationService.generateMCQs(this.notePath, content, this.plugin.settings); + if (newMcqSet) { + this.plugin.mcqService.saveMCQSet(newMcqSet); + await this.plugin.savePluginData(); + const newModal = new MCQModal(this.plugin, this.notePath, newMcqSet, this.onCompleteCallback); + newModal.open(); + } else { + new import_obsidian8.Notice("Failed to regenerate MCQs."); + } + } else { + new import_obsidian8.Notice("Could not find note file to regenerate MCQs."); + } + } else { + new import_obsidian8.Notice("MCQ generation service not available."); + } + }); + } + const questionIndex = this.session.currentQuestionIndex; + const existingAnswer = this.session.answers.find((a) => a.questionIndex === questionIndex); + const progressEl = contentEl.createDiv("mcq-progress"); + const progressPercent = Math.round((questionIndex + 1) / this.mcqSet.questions.length * 100); + contentEl.setAttribute("data-progress", progressPercent.toString()); + if (existingAnswer) { + progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length} (Attempt ${existingAnswer.attempts + 1})`); + if (existingAnswer.attempts === 1) { + const warningEl = contentEl.createDiv("mcq-attempt-warning"); + const warningIcon = warningEl.createSpan(); + warningIcon.setText("\u26A0\uFE0F "); + const warningText = warningEl.createSpan(); + warningText.setText("This is your last attempt before scoring 0 points for this question."); + warningEl.addClass("mcq-warning"); + } + } else { + progressEl.setText(`Question ${questionIndex + 1} of ${this.mcqSet.questions.length}`); + } + this.questionStartTime = Date.now(); + this.displayCurrentQuestion(contentEl); + this.registerKeyboardShortcuts(); + } + registerKeyboardShortcuts() { + const keyDownHandler = (event) => { + if (this.session.completed) + return; + const questionIndex = this.session.currentQuestionIndex; + if (questionIndex >= this.mcqSet.questions.length) + return; + const question = this.mcqSet.questions[questionIndex]; + if (!question || !question.choices) + return; + const choiceButtons = this.contentEl.querySelectorAll(".mcq-choice-btn"); + if (choiceButtons.length === 0) + return; + if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) { + choiceButtons[this.selectedAnswerIndex].classList.remove("mcq-choice-selected"); + } + let processAnswer = false; + if (event.key === "ArrowDown") { + event.preventDefault(); + this.selectedAnswerIndex = (this.selectedAnswerIndex + 1) % question.choices.length; + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.selectedAnswerIndex = (this.selectedAnswerIndex - 1 + question.choices.length) % question.choices.length; + } else if (event.key === "ArrowRight" || event.key === "Enter") { + event.preventDefault(); + if (this.selectedAnswerIndex !== -1) + processAnswer = true; + } + const num = parseInt(event.key); + if (!isNaN(num) && num >= 1 && num <= question.choices.length) { + this.selectedAnswerIndex = num - 1; + processAnswer = true; + } else if (event.key.length === 1) { + const letterCode = event.key.toUpperCase().charCodeAt(0); + const index = letterCode - 65; + if (index >= 0 && index < question.choices.length) { + this.selectedAnswerIndex = index; + processAnswer = true; + } + } + if (this.selectedAnswerIndex !== -1 && this.selectedAnswerIndex < choiceButtons.length) { + choiceButtons[this.selectedAnswerIndex].classList.add("mcq-choice-selected"); + } + if (processAnswer && this.selectedAnswerIndex !== -1) { + if (this.selectedAnswerIndex < choiceButtons.length) { + const button = choiceButtons[this.selectedAnswerIndex]; + button.classList.add("mcq-key-pressed"); + setTimeout(() => { + button.classList.remove("mcq-key-pressed"); + this.handleAnswer(this.selectedAnswerIndex); + this.selectedAnswerIndex = -1; + }, 150); + } else { + this.handleAnswer(this.selectedAnswerIndex); + this.selectedAnswerIndex = -1; + } + } + }; + this.scope.register([], "ArrowDown", keyDownHandler); + this.scope.register([], "ArrowUp", keyDownHandler); + this.scope.register([], "ArrowRight", keyDownHandler); + this.scope.register([], "Enter", keyDownHandler); + for (let i = 1; i <= 9; i++) { + this.scope.register([], i.toString(), keyDownHandler); + } + for (let i = 0; i < 9; i++) { + this.scope.register([], String.fromCharCode(65 + i), keyDownHandler); + } + const originalOnClose = this.onClose; + this.onClose = () => { + originalOnClose.call(this); + if (!this.session.completed && this.session.answers.length > 0) { + this.session.completedAt = Date.now(); + this.calculateScore(); + this.plugin.mcqService.saveMCQSession(this.session); + this.plugin.savePluginData(); + if (this.onCompleteCallback) { + this.onCompleteCallback(this.notePath, this.session.score, false); + } + } else if (!this.session.completed && this.onCompleteCallback) { + this.onCompleteCallback(this.notePath, 0, false); + } + }; + } + displayCurrentQuestion(containerEl) { + const questionIndex = this.session.currentQuestionIndex; + if (questionIndex >= this.mcqSet.questions.length) { + this.completeSession(); + return; + } + const question = this.mcqSet.questions[questionIndex]; + if (!question || !question.choices || question.choices.length < 2) { + console.error("Invalid question data:", question); + new import_obsidian8.Notice("Error: Invalid question data. Moving to next question."); + this.session.currentQuestionIndex++; + if (this.session.currentQuestionIndex < this.mcqSet.questions.length) { + this.displayCurrentQuestion(containerEl); + } else { + this.completeSession(); + } + return; + } + const existingQuestionContainer = containerEl.querySelector(".mcq-question-container"); + if (existingQuestionContainer) + existingQuestionContainer.remove(); + const newQuestionContainer = containerEl.createDiv("mcq-question-container"); + const questionEl = newQuestionContainer.createDiv("mcq-question-text"); + questionEl.setText(question.question); + const existingAnswer = this.session.answers.find((a) => a.questionIndex === questionIndex); + if (existingAnswer && existingAnswer.attempts >= 2) { + const skipContainer = newQuestionContainer.createDiv("mcq-skip-container"); + 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) / 1e3, 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 choicesContainer2 = this.contentEl.querySelector(".mcq-choices-container"); + if (choicesContainer2 instanceof HTMLElement) + choicesContainer2.style.display = "none"; + skipButton.style.display = "none"; + }); + } + const choicesContainer = newQuestionContainer.createDiv("mcq-choices-container"); + question.choices.forEach((choice, index) => { + const choiceEl = choicesContainer.createDiv("mcq-choice"); + const choiceBtn = choiceEl.createEl("button", { cls: "mcq-choice-btn" }); + const letterLabel = choiceBtn.createSpan("mcq-choice-letter"); + letterLabel.setText(String.fromCharCode(65 + index) + ") "); + const textSpan = choiceBtn.createSpan("mcq-choice-text"); + textSpan.setText(choice || "(Empty choice)"); + const shortcutHint = choiceBtn.createSpan("mcq-shortcut-hint"); + shortcutHint.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) { + const questionIndex = this.session.currentQuestionIndex; + const question = this.mcqSet.questions[questionIndex]; + const isCorrect = selectedIndex === question.correctAnswerIndex; + const timeToAnswer = (Date.now() - this.questionStartTime) / 1e3; + const existingAnswerIndex = this.session.answers.findIndex((a) => a.questionIndex === questionIndex); + let answer; + if (existingAnswerIndex >= 0) { + answer = this.session.answers[existingAnswerIndex]; + if (isCorrect) { + answer.selectedAnswerIndex = selectedIndex; + answer.correct = true; + } + answer.timeToAnswer = timeToAnswer; + answer.attempts += 1; + if (answer.attempts >= 2 && !answer.correct) { + console.log(`Question ${questionIndex + 1} has ${answer.attempts} attempts, marking as zero points`); + answer.selectedAnswerIndex = -1; + } + } else { + answer = { questionIndex, selectedAnswerIndex: isCorrect ? selectedIndex : -1, correct: isCorrect, timeToAnswer, attempts: 1 }; + this.session.answers.push(answer); + } + this.highlightAnswer(selectedIndex, isCorrect); + setTimeout(() => { + if (isCorrect) { + this.session.currentQuestionIndex++; + this.questionStartTime = Date.now(); + const { contentEl } = this; + const progressEl = contentEl.querySelector(".mcq-progress"); + if (progressEl instanceof HTMLElement) + progressEl.textContent = `Question ${this.session.currentQuestionIndex + 1} of ${this.mcqSet.questions.length}`; + const newProgressPercent = Math.round((this.session.currentQuestionIndex + 1) / this.mcqSet.questions.length * 100); + contentEl.setAttribute("data-progress", newProgressPercent.toString()); + if (this.session.currentQuestionIndex < this.mcqSet.questions.length) { + this.displayCurrentQuestion(contentEl); + } else { + this.completeSession(); + } + } + }, 1e3); + } + highlightAnswer(selectedIndex, isCorrect) { + const choiceButtons = this.contentEl.querySelectorAll(".mcq-choice-btn"); + choiceButtons.forEach((button) => button.classList.remove("mcq-choice-correct", "mcq-choice-incorrect", "mcq-choice-selected")); + if (selectedIndex < choiceButtons.length) { + const selectedBtn = choiceButtons[selectedIndex]; + selectedBtn.classList.add(isCorrect ? "mcq-choice-correct" : "mcq-choice-incorrect"); + } + } + completeSession() { + const { contentEl } = this; + contentEl.empty(); + try { + this.calculateScore(); + this.session.completed = true; + this.session.completedAt = Date.now(); + this.plugin.mcqService.saveMCQSession(this.session); + this.plugin.savePluginData(); + contentEl.createEl("h2").setText("Review Complete"); + const scoreEl = contentEl.createDiv("mcq-score"); + const scoreTextEl = scoreEl.createDiv("mcq-score-text"); + const scorePercentage = this.session.score; + const reviewSchedule = this.plugin.reviewScheduleService.schedules[this.notePath]; + let ratingText = ""; + let ratingDetails = ""; + if ((reviewSchedule == null ? void 0 : reviewSchedule.schedulingAlgorithm) === "fsrs") { + let fsrsRating = 1; + if (scorePercentage === 1) + fsrsRating = 4; + else if (scorePercentage >= 0.75) + fsrsRating = 3; + else if (scorePercentage >= 0.5) + fsrsRating = 2; + ratingText = `FSRS Rating: ${getFsrsRatingText(fsrsRating)} (${fsrsRating}/4)`; + if (reviewSchedule.fsrsData) { + const fsrs = reviewSchedule.fsrsData; + ratingDetails += `Stability: ${fsrs.stability.toFixed(2)}, Difficulty: ${fsrs.difficulty.toFixed(2)}, Interval: ${fsrs.scheduled_days}d, State: ${mapFsrsStateToString(fsrs.state)}, Reps: ${fsrs.reps}, Lapses: ${fsrs.lapses}`; + if (fsrs.last_review) { + const nextDueDate = new Date(fsrs.last_review); + nextDueDate.setDate(nextDueDate.getDate() + fsrs.scheduled_days); + ratingDetails += `, Next Due: ${nextDueDate.toLocaleDateString()}`; + } + } + } else if ((reviewSchedule == null ? void 0 : reviewSchedule.schedulingAlgorithm) === "sm2") { + let sm2Rating = 0; + if (scorePercentage >= 0.9) + sm2Rating = 5; + else if (scorePercentage >= 0.8) + sm2Rating = 4; + else if (scorePercentage >= 0.6) + sm2Rating = 3; + else if (scorePercentage >= 0.4) + sm2Rating = 2; + else if (scorePercentage >= 0.2) + sm2Rating = 1; + ratingText = `SM-2 Rating: ${getSm2RatingText(sm2Rating)} (${sm2Rating}/5)`; + if (reviewSchedule) { + ratingDetails += `Ease: ${(reviewSchedule.ease / 100).toFixed(2)}, Interval: ${reviewSchedule.interval}d, Next Due: ${new Date(reviewSchedule.nextReviewDate).toLocaleDateString()}`; + if (reviewSchedule.repetitionCount !== void 0) + ratingDetails += `, Reps: ${reviewSchedule.repetitionCount}`; + } + } else { + ratingText = `Score: ${Math.round(scorePercentage * 100)}%`; + } + scoreTextEl.setText(ratingText); + if (ratingDetails) { + const detailsEl = scoreEl.createDiv("mcq-score-details"); + detailsEl.setText(ratingDetails); + detailsEl.style.fontSize = "0.9em"; + detailsEl.style.color = "var(--text-muted)"; + detailsEl.style.marginTop = "4px"; + } + 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`); + } 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) { + 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"); + contentEl.createEl("p").setText("There was an error completing the MCQ session. Please try again."); + const errorCloseBtn = contentEl.createEl("button", { cls: "mcq-close-btn" }); + errorCloseBtn.setText("Close"); + errorCloseBtn.addEventListener("click", () => this.close()); + } + } + calculateScore() { + let totalScore = 0; + this.session.answers.forEach((answer) => { + let questionScore = 0; + if (answer.correct && answer.selectedAnswerIndex !== -1) { + if (answer.attempts === 1) + questionScore = 1; + else if (answer.attempts === 2) + questionScore = 0.5; + } + if (questionScore > 0 && answer.timeToAnswer > this.plugin.settings.mcqTimeDeductionSeconds) { + questionScore -= this.plugin.settings.mcqTimeDeductionAmount; + questionScore = Math.max(0, questionScore); + } + totalScore += questionScore; + }); + this.session.score = this.mcqSet.questions.length > 0 ? totalScore / this.mcqSet.questions.length : 0; + } + // onClose is now handled by the keyboard shortcut registration cleanup +}; +function mapFsrsStateToString(state) { + switch (state) { + case 0: + return "New"; + case 1: + return "Learning"; + case 2: + return "Review"; + case 3: + return "Relearning"; + default: + return "Unknown"; + } +} +function getFsrsRatingText(rating) { + switch (rating) { + case 1: + return "Again"; + case 2: + return "Hard"; + case 3: + return "Good"; + case 4: + return "Easy"; + default: + return "Unknown"; + } +} +function getSm2RatingText(rating) { + switch (rating) { + case 0: + return "Complete Blackout"; + case 1: + return "Incorrect Response"; + case 2: + return "Incorrect But Familiar"; + case 3: + return "Correct With Difficulty"; + case 4: + return "Correct With Hesitation"; + case 5: + return "Perfect Recall"; + default: + return "Unknown"; + } +} + +// controllers/review-controller-mcq.ts +var MCQController = class { + /** + * Initialize MCQ controller + * + * @param plugin Reference to the main plugin + * @param mcqService Reference to the MCQ data service + * @param mcqGenerationService Reference to the MCQ generation service + */ + constructor(plugin, mcqService, mcqGenerationService) { + this.plugin = plugin; + this.mcqService = mcqService; + this.mcqGenerationService = mcqGenerationService; + } + /** + * Start an MCQ review session for a note + * + * @param notePath Path to the note + * @param onCompleteCallback Optional callback when the modal closes + */ + // Helper methods to map score to ratings + mapScoreToSm2Response(score) { + if (score >= 0.95) + return 5 /* PerfectRecall */; + if (score >= 0.8) + return 4 /* CorrectWithHesitation */; + if (score >= 0.6) + return 3 /* CorrectWithDifficulty */; + if (score >= 0.4) + return 2 /* IncorrectButFamiliar */; + if (score > 0) + return 1 /* IncorrectResponse */; + return 0 /* CompleteBlackout */; + } + mapScoreToFsrsRating(score) { + if (score >= 0.9) + return 4 /* Easy */; + if (score >= 0.7) + return 3 /* Good */; + if (score >= 0.5) + return 2 /* Hard */; + return 1 /* Again */; + } + async startMCQReview(notePath, externalOnCompleteCallback) { + if (!this.plugin.settings.enableMCQ) { + new import_obsidian9.Notice("MCQ feature is disabled in settings."); + if (externalOnCompleteCallback) + externalOnCompleteCallback(notePath, false); + return; + } + if (!this.mcqGenerationService) { + new import_obsidian9.Notice("MCQ generation service is not available. Check API provider settings."); + return; + } + try { + let mcqSet = this.mcqService.getMCQSetForNote(notePath); + if (mcqSet && mcqSet.needsQuestionRegeneration) { + new import_obsidian9.Notice("Questions for this note are flagged for regeneration. Generating new set..."); + mcqSet = await this.generateMCQs(notePath, true); + if (mcqSet) { + mcqSet.needsQuestionRegeneration = false; + this.mcqService.saveMCQSet(mcqSet); + await this.plugin.savePluginData(); + } else { + new import_obsidian9.Notice("Failed to regenerate MCQs. Using existing set if available."); + mcqSet = this.mcqService.getMCQSetForNote(notePath); + } + } + if (!mcqSet || mcqSet.questions.length === 0) { + new import_obsidian9.Notice("No MCQs found for this note. Generating new set..."); + mcqSet = await this.generateMCQs(notePath); + if (!mcqSet) { + new import_obsidian9.Notice("Failed to generate MCQs for this note."); + return; + } + } + const session = { + notePath, + mcqSetId: `${mcqSet.notePath}_${mcqSet.generatedAt}`, + startedAt: Date.now(), + answers: [], + completed: false, + score: 0, + currentQuestionIndex: 0, + // Initialize required property + completedAt: null + // Initialize required property (removed duplicate) + }; + const internalOnComplete = async (path, score, completed) => { + if (completed) { + const schedule = this.plugin.reviewScheduleService.schedules[path]; + if (schedule) { + let rating; + if (schedule.schedulingAlgorithm === "fsrs") { + rating = this.mapScoreToFsrsRating(score); + new import_obsidian9.Notice(`MCQ complete for FSRS card. Score: ${(score * 100).toFixed(0)}%. Rating: ${FsrsRating[rating]}(${rating}).`); + } else { + rating = this.mapScoreToSm2Response(score); + new import_obsidian9.Notice(`MCQ complete for SM-2 card. Score: ${(score * 100).toFixed(0)}%. Rating: ${ReviewResponse[rating]}(${rating}).`); + } + await this.plugin.reviewController.processReviewResponse(path, rating); + } else { + new import_obsidian9.Notice(`MCQ complete. Score: ${(score * 100).toFixed(0)}%. Could not find schedule to update review status.`); + } + } else { + new import_obsidian9.Notice(`MCQ session for ${path} was not fully completed. Score (partial): ${(score * 100).toFixed(0)}%. Review not recorded.`); + } + if (externalOnCompleteCallback) { + externalOnCompleteCallback(path, completed && score >= 0.7); + } + }; + new MCQModal(this.plugin, notePath, mcqSet, internalOnComplete).open(); + } catch (error) { + console.error("Error starting MCQ review:", error); + new import_obsidian9.Notice("Error starting MCQ review. Please check console for details."); + if (externalOnCompleteCallback) + externalOnCompleteCallback(notePath, false); + } + } + /** + * Generate MCQs for a note + * + * @param notePath Path to the note + * @param forceRegeneration If true, will ignore existing fresh sets and generate new ones. + * @returns Generated MCQ set or null if failed + */ + async generateMCQs(notePath, forceRegeneration = false) { + if (!this.plugin.settings.enableMCQ || !this.mcqGenerationService) { + new import_obsidian9.Notice("MCQ feature is disabled or the generation service is not available. Check API provider settings."); + return null; + } + if (!forceRegeneration) { + const existingSet = this.mcqService.getMCQSetForNote(notePath); + if (existingSet) { + const twentyFourHours = 24 * 60 * 60 * 1e3; + if (Date.now() - existingSet.generatedAt < twentyFourHours && !existingSet.needsQuestionRegeneration) { + new import_obsidian9.Notice("Using recently generated MCQs for this note."); + return existingSet; + } + } + } + const file = this.plugin.app.vault.getAbstractFileByPath(notePath); + if (!(file instanceof import_obsidian9.TFile)) { + new import_obsidian9.Notice("Cannot generate MCQs: file not found"); + return null; + } + const content = await this.plugin.app.vault.read(file); + const mcqSet = await this.mcqGenerationService.generateMCQs(notePath, content, this.plugin.settings); + if (mcqSet) { + this.mcqService.saveMCQSet(mcqSet); + await this.plugin.savePluginData(); + new import_obsidian9.Notice("MCQs generated and saved successfully."); + return mcqSet; + } else { + return null; + } + } + // Methods to access MCQ sessions, delegated to mcqService + getMCQSessionsForNote(notePath) { + return this.mcqService.getMCQSessionsForNote(notePath); + } + getLatestMCQSessionForNote(notePath) { + return this.mcqService.getLatestMCQSessionForNote(notePath); + } + async saveMCQSession(session) { + this.mcqService.saveMCQSession(session); + await this.plugin.savePluginData(); + } + /** + * Starts a consolidated MCQ review session for all notes due on the currently selected review date. + */ + async startConsolidatedMCQReviewForSelectedDate() { + if (!this.plugin.settings.enableMCQ) { + new import_obsidian9.Notice("MCQ feature is disabled in settings."); + return; + } + if (!this.mcqGenerationService) { + new import_obsidian9.Notice("MCQ generation service is not available. Check API provider settings."); + return; + } + const dueNotes = this.plugin.reviewController.getTodayNotes(); + if (dueNotes.length === 0) { + new import_obsidian9.Notice("No notes due for review on the selected date."); + return; + } + const mcqSetsForReview = []; + let notesWithMCQsCount = 0; + new import_obsidian9.Notice(`Fetching MCQs for ${dueNotes.length} due note(s)...`); + for (const noteSchedule of dueNotes) { + const notePath = noteSchedule.path; + let mcqSet = this.mcqService.getMCQSetForNote(notePath); + if (mcqSet && mcqSet.needsQuestionRegeneration) { + new import_obsidian9.Notice(`Regenerating flagged MCQs for ${notePath}...`); + const regeneratedMcqSet = await this.generateMCQs(notePath, true); + if (regeneratedMcqSet) { + mcqSet = regeneratedMcqSet; + mcqSet.needsQuestionRegeneration = false; + this.mcqService.saveMCQSet(mcqSet); + } else { + new import_obsidian9.Notice(`Failed to regenerate MCQs for ${notePath}. Using existing set if available (might be outdated or empty).`); + } + } + if (!mcqSet || mcqSet.questions.length === 0) { + new import_obsidian9.Notice(`No MCQs found or set is empty for ${notePath}. Attempting to generate new set...`); + const newMcqSet = await this.generateMCQs(notePath, false); + if (newMcqSet) { + mcqSet = newMcqSet; + } else { + new import_obsidian9.Notice(`Failed to generate MCQs for ${notePath}. This note will be skipped in MCQ review.`); + } + } + if (mcqSet && mcqSet.questions.length > 0) { + const file = this.plugin.app.vault.getAbstractFileByPath(notePath); + mcqSetsForReview.push({ + path: notePath, + mcqSet, + fileName: file instanceof import_obsidian9.TFile ? file.basename : notePath + }); + notesWithMCQsCount++; + } + } + if (mcqSetsForReview.length === 0) { + new import_obsidian9.Notice("No MCQs available or generated for the due notes."); + return; + } + await this.plugin.savePluginData(); + new import_obsidian9.Notice(`Starting consolidated MCQ review for ${notesWithMCQsCount} note(s) with ${mcqSetsForReview.reduce((sum, s) => sum + s.mcqSet.questions.length, 0)} questions.`); + const onConsolidatedComplete = async (results) => { + let reviewsProcessed = 0; + for (const result of results) { + const schedule = this.plugin.reviewScheduleService.schedules[result.path]; + if (schedule && typeof result.score === "number") { + let rating; + if (schedule.schedulingAlgorithm === "fsrs") { + rating = this.mapScoreToFsrsRating(result.score); + new import_obsidian9.Notice(`MCQ for ${result.path} (FSRS) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${FsrsRating[rating]}(${rating})`); + } else { + rating = this.mapScoreToSm2Response(result.score); + new import_obsidian9.Notice(`MCQ for ${result.path} (SM-2) - Score: ${(result.score * 100).toFixed(0)}%, Rating: ${ReviewResponse[rating]}(${rating})`); + } + await this.plugin.reviewController.processReviewResponse(result.path, rating); + reviewsProcessed++; + } else if (schedule) { + console.warn(`MCQ result for ${result.path} did not have a score. Review not recorded via MCQ.`); + } + } + if (reviewsProcessed > 0) { + new import_obsidian9.Notice(`${reviewsProcessed} note review(s) updated based on consolidated MCQ session.`); + } else { + new import_obsidian9.Notice("No note reviews were updated from the MCQ session."); + } + }; + new ConsolidatedMCQModal(this.plugin, mcqSetsForReview, onConsolidatedComplete).open(); + } +}; + +// controllers/review-controller.ts +var ReviewController = class { + // Add batch controller + /** + * Constructor initializes the review controller + * + * @param plugin Reference to the main plugin + * @param mcqService Reference to the MCQ service + */ + constructor(plugin, mcqService) { + this.plugin = plugin; + this.coreController = new ReviewControllerCore(plugin); + this.navigationController = new ReviewNavigationController(plugin); + if (plugin.mcqGenerationService) { + this.mcqController = new MCQController(plugin, mcqService, plugin.mcqGenerationService); + } else { + console.warn("MCQ Generation Service not available during ReviewController initialization."); + } + this.batchController = new ReviewBatchController(plugin); + } + /** + * Update the list of today's due notes + * Delegates to core controller + * + * @param preserveCurrentIndex Whether to try to preserve the current note index + */ + async updateTodayNotes(preserveCurrentIndex = false) { + await this.coreController.updateTodayNotes(preserveCurrentIndex); + } + /** + * Get the currently loaded notes due for review + * Delegates to core controller + */ + getTodayNotes() { + return this.coreController.getTodayNotes(); + } + /** + * Get the current index in today's notes + * Delegates to core controller + */ + getCurrentNoteIndex() { + return this.coreController.getCurrentNoteIndex(); + } + /** + * Set the current index in today's notes + * Delegates to core controller + * + * @param index The new index + */ + setCurrentNoteIndex(index) { + this.coreController.setCurrentNoteIndex(index); + } + /** + * Review the current note + * Delegates to core controller + */ + async reviewCurrentNote() { + await this.coreController.reviewCurrentNote(); + } + /** + * Review a specific note + * Delegates to core controller + * + * @param path Path to the note file + */ + async reviewNote(path) { + await this.coreController.reviewNote(path); + } + /** + * Postpone a note's review + * Delegates to core controller + * + * @param path Path to the note file + * @param days Number of days to postpone (default: 1) + */ + async postponeNote(path, days = 1) { + await this.coreController.postponeNote(path, days); + } + /** + * Advance a note's review by one day, if eligible. + * Delegates to core controller. + * + * @param path Path to the note file + */ + async advanceNote(path) { + await this.coreController.advanceNote(path); + } + /** + * Handle a note being postponed, updating navigation state + * Delegates to core controller + * + * @param path Path to the postponed note + */ + async handleNotePostponed(path) { + await this.coreController.handleNotePostponed(path); + } + /** + * Show the review modal for a note + * Delegates to core controller + * + * @param path Path to the note file + */ + showReviewModal(path) { + this.coreController.showReviewModal(path); + } + /** + * Skip the review of a note and reschedule for tomorrow with penalty + * Delegates to core controller + * + * @param path Path to the note file + */ + async skipReview(path) { + await this.coreController.skipReview(path); + } + /** + * Process a review response + * Delegates to core controller + * + * @param path Path to the note file + * @param response User's response during review (SM-2 or FSRS) + */ + async processReviewResponse(path, response) { + await this.coreController.processReviewResponse(path, response); + } + /** + * Sets an override for the current review date. + * Delegates to core controller. + * @param date Timestamp of the date to simulate, or null to use actual Date.now(). + */ + async setReviewDateOverride(date) { + await this.coreController.setReviewDateOverride(date); + } + /** + * Gets the current review date override. + * Delegates to core controller. + * @returns Timestamp of the override, or null if no override is set. + */ + getCurrentReviewDateOverride() { + return this.coreController.getCurrentReviewDateOverride(); + } + // Delegate methods to specialized controllers + /** + * Start reviewing all of today's notes + * Delegates to batch controller + */ + async reviewAllTodaysNotes() { + await this.batchController.reviewAllTodaysNotes(); + } + /** + * Navigate to the next note + * Delegates to navigation controller + */ + async navigateToNextNote() { + await this.navigationController.navigateToNextNote(); + } + /** + * Navigate to the previous note + * Delegates to navigation controller + */ + async navigateToPreviousNote() { + await this.navigationController.navigateToPreviousNote(); + } + /** + * Start an MCQ review session for a note + * Delegates to MCQ controller + * + * @param notePath Path to the note + * @param onComplete Optional callback for when MCQ review is completed + */ + async startMCQReview(notePath, onComplete) { + if (this.mcqController) { + await this.mcqController.startMCQReview(notePath, onComplete); + } else { + console.error("MCQ Controller not initialized when calling startMCQReview"); + if (onComplete) { + onComplete(notePath, false); + } + } + } + /** + * Review a specific set of notes + * Delegates to batch controller + * + * @param paths Array of note paths to review + * @param useMCQ Whether to use MCQs for testing (default: false) + */ + async reviewNotes(paths, useMCQ = false) { + await this.batchController.reviewNotes(paths, useMCQ); + } + /** + * Postpone a specific set of notes + * Delegates to batch controller + * + * @param paths Array of note paths to postpone + * @param days Number of days to postpone (default: 1) + */ + async postponeNotes(paths, days = 1) { + await this.batchController.postponeNotes(paths, days); + } + /** + * Advance a specific set of notes by one day each, if eligible. + * Delegates to batch controller. + * + * @param paths Array of note paths to advance + */ + async advanceNotes(paths) { + await this.batchController.advanceNotes(paths); + } + /** + * Remove a specific set of notes from the review schedule + * Delegates to batch controller + * + * @param paths Array of note paths to remove + */ + async removeNotes(paths) { + await this.batchController.removeNotes(paths); + } + /** + * Open a note without showing the review modal + * Delegates to navigation controller + * + * @param path Path to the note file + */ + async openNoteWithoutReview(path) { + await this.navigationController.openNoteWithoutReview(path); + } + /** + * Swap two notes in the traversal order + * Delegates to navigation controller + * + * @param path1 Path to the first note + * @param path2 Path to the second note + */ + async swapNotes(path1, path2) { + await this.navigationController.swapNotes(path1, path2); + } + /** + * Review all notes with MCQs in a batch + * Delegates to batch controller + * + * @param useMCQ Whether to use MCQs for testing + */ + async reviewAllNotesWithMCQ(useMCQ = true) { + await this.batchController.reviewAllNotesWithMCQ(useMCQ); + } +}; + +// utils/link-analyzer.ts +var import_obsidian10 = require("obsidian"); +var LinkAnalyzer = class { + /** + * Analyze links in a folder and build a review hierarchy + * + * @param vault Obsidian vault + * @param folder Folder to analyze + * @param includeSubfolders Whether to include subfolders + * @returns Review hierarchy for the folder + */ + static async analyzeFolder(vault, folder, includeSubfolders) { + const files = vault.getMarkdownFiles().filter((file) => { + if (includeSubfolders) { + return file.path.startsWith(folder.path); + } else { + const parentPath = file.parent ? file.parent.path : ""; + return parentPath === folder.path; + } + }); + const nodes = {}; + for (const file of files) { + nodes[file.path] = { + path: file.path, + outgoingLinks: [], + regularLinks: [], + embedLinks: [], + incomingLinks: [], + incomingLinkCount: 0 + }; + } + for (const file of files) { + try { + const content = await vault.read(file); + const node = nodes[file.path]; + node.content = content; + const noteLinks = this.extractLinks(content); + for (const noteLink of noteLinks) { + const resolvedPath = this.resolveLink(noteLink.text, file.path, files); + if (resolvedPath && nodes[resolvedPath]) { + if (!node.outgoingLinks.includes(resolvedPath)) { + node.outgoingLinks.push(resolvedPath); + if (noteLink.isEmbed) { + if (!node.embedLinks.includes(resolvedPath)) { + node.embedLinks.push(resolvedPath); + } + } else { + if (!node.regularLinks.includes(resolvedPath)) { + node.regularLinks.push(resolvedPath); + } + } + } + const targetNode = nodes[resolvedPath]; + if (!targetNode.incomingLinks.includes(file.path)) { + targetNode.incomingLinks.push(file.path); + targetNode.incomingLinkCount++; + } + } + } + } catch (error) { + console.error(`Error processing file ${file.path}:`, error); + } + } + const startingNode = this.findStartingNode(nodes); + const traversalOrder = this.createTraversalOrder(nodes, startingNode); + return { + rootNodes: startingNode, + nodes, + traversalOrder + }; + } + /** + * Extract links from markdown content in the order they appear + * + * @param content Markdown content + * @returns Array of note links with information about whether they are embeds + */ + static extractLinks(content) { + const links = []; + let match; + this.LINK_REGEX.lastIndex = 0; + while ((match = this.LINK_REGEX.exec(content)) !== null) { + links.push({ + text: match[2], + // The link text is now in the second capture group + isEmbed: match[1] === "!" + // True if it has an exclamation mark + }); + } + return links; + } + /** + * Resolve a link to a full file path + * + * @param link Link text + * @param sourcePath Path of the source file + * @param allFiles All available files + * @returns Resolved file path or null if not found + */ + static resolveLink(link, sourcePath, allFiles) { + if (link.endsWith(".md")) { + const exactFile = allFiles.find((f) => f.path.endsWith("/" + link) || f.path === link); + if (exactFile) { + return exactFile.path; + } + } + const basename = link.split("/").pop(); + if (!basename) + return null; + const matchingFiles = allFiles.filter((f) => f.basename === basename); + if (matchingFiles.length === 0) { + return null; + } + if (matchingFiles.length === 1) { + return matchingFiles[0].path; + } + const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf("/")); + const sameDir = matchingFiles.find((f) => f.path.startsWith(sourceDir + "/")); + return sameDir ? sameDir.path : matchingFiles[0].path; + } + /** + * Find the best starting node based on file naming and link structure + * + * @param nodes All file nodes + * @returns Array with the path of the best starting node + */ + static findStartingNode(nodes) { + var _a, _b, _c, _d; + console.log("LinkAnalyzer: Starting node selection process..."); + if (Object.keys(nodes).length === 0) { + console.log("LinkAnalyzer: No nodes found, returning empty array."); + return []; + } + const folderNodes = {}; + for (const path in nodes) { + const folderPath = path.substring(0, path.lastIndexOf("/")); + if (!folderNodes[folderPath]) { + folderNodes[folderPath] = []; + } + folderNodes[folderPath].push(path); + } + for (const folderPath in folderNodes) { + const folderName = ((_a = folderPath.split("/").pop()) == null ? void 0 : _a.toLowerCase()) || ""; + const filesInFolder = folderNodes[folderPath]; + console.log(`LinkAnalyzer: Checking for exact match with folder name "${folderName}" in folder "${folderPath}"...`); + for (const path of filesInFolder) { + const fileName = ((_b = path.split("/").pop()) == null ? void 0 : _b.toLowerCase()) || ""; + const fileNameWithoutExt = fileName.replace(/\.md$/, ""); + if (fileNameWithoutExt === folderName) { + console.log(`LinkAnalyzer: Selected main file with exact folder name match: ${path}`); + return [path]; + } + } + console.log(`LinkAnalyzer: Checking for partial match with folder name "${folderName}"...`); + for (const path of filesInFolder) { + const fileName = ((_c = path.split("/").pop()) == null ? void 0 : _c.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]; + } + } + console.log("LinkAnalyzer: Checking for common index or main file patterns..."); + for (const path of filesInFolder) { + const fileName = ((_d = path.split("/").pop()) == null ? void 0 : _d.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]; + } + } + } + console.log("LinkAnalyzer: Checking for files with the most outgoing links..."); + const sortedNodes = Object.values(nodes).sort((a, b2) => { + const regularLinkDiff = b2.regularLinks.length - a.regularLinks.length; + if (regularLinkDiff !== 0) { + return regularLinkDiff; + } + return b2.outgoingLinks.length - a.outgoingLinks.length; + }); + if (sortedNodes.length > 0 && (sortedNodes[0].regularLinks.length > 0 || sortedNodes[0].outgoingLinks.length > 0)) { + console.log(`LinkAnalyzer: Selected main file by link count: ${sortedNodes[0].path} with ${sortedNodes[0].regularLinks.length} regular links`); + return [sortedNodes[0].path]; + } + 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 + */ + static findRootNodes(nodes) { + return this.findStartingNode(nodes); + } + /** + * Create a traversal order for reviewing files that respects the exact order of links + * + * @param nodes All file nodes + * @param startNodePath Path to the starting node + * @returns Array of file paths in traversal order + */ + static createOrderedTraversal(nodes, startNodePath) { + const visited = /* @__PURE__ */ new Set(); + const traversalOrder = []; + const fileFolders = /* @__PURE__ */ new Map(); + for (const path in nodes) { + const folderPath = path.substring(0, path.lastIndexOf("/")); + fileFolders.set(path, folderPath); + } + const mainFolder = fileFolders.get(startNodePath) || ""; + console.log(`LinkAnalyzer: Starting ordered traversal from: ${startNodePath} in main folder: ${mainFolder || "vault root"}`); + const traverse = (currentNodePath, currentDepth = 0, currentMainFolder) => { + 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) || ""; + const linksToFollow = node.regularLinks.length > 0 ? node.regularLinks : node.outgoingLinks; + for (const linkedPath of linksToFollow) { + if (!nodes[linkedPath]) { + console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Skipping unknown linked path: ${linkedPath}`); + continue; + } + const linkedNodeFolder = fileFolders.get(linkedPath) || ""; + if (nodes[linkedPath] && linkedNodeFolder.startsWith(currentMainFolder)) { + if (!visited.has(linkedPath)) { + 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 { + console.log(`${" ".repeat(currentDepth + 1)}LinkAnalyzer: Ignoring link outside main hierarchy or unknown node: ${linkedPath} (from ${currentNodeFolder} to ${linkedNodeFolder})`); + } + } + }; + 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.`); + } + 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 + */ + static createTraversalOrder(nodes, rootNodes) { + if (rootNodes.length === 1) { + return this.createOrderedTraversal(nodes, rootNodes[0]); + } + const visited = /* @__PURE__ */ new Set(); + const traversalOrder = []; + const traverse = (nodePath) => { + if (visited.has(nodePath)) + return; + visited.add(nodePath); + traversalOrder.push(nodePath); + const node = nodes[nodePath]; + if (!node) + return; + for (const linkedPath of node.outgoingLinks) { + traverse(linkedPath); + } + }; + for (const rootPath of rootNodes) { + traverse(rootPath); + } + for (const nodePath of Object.keys(nodes)) { + if (!visited.has(nodePath)) { + traversalOrder.push(nodePath); + visited.add(nodePath); + } + } + return traversalOrder; + } + /** + * Analyze links in a single note + * + * @param vault Obsidian vault + * @param filePath Path to the note file + * @param regularOnly Whether to include only regular wiki links (not embeds) + * @returns Array of resolved link paths in the order they appear + */ + static async analyzeNoteLinks(vault, filePath, regularOnly = false) { + const file = vault.getAbstractFileByPath(filePath); + if (!(file instanceof import_obsidian10.TFile)) { + return []; + } + try { + const content = await vault.read(file); + const noteLinks = this.extractLinks(content); + const resolvedLinks = []; + const seenLinks = /* @__PURE__ */ new Set(); + const filteredLinks = regularOnly ? noteLinks.filter((link) => !link.isEmbed) : noteLinks; + for (const link of filteredLinks) { + const resolvedPath = this.resolveLink( + link.text, + filePath, + vault.getMarkdownFiles() + ); + if (resolvedPath && !seenLinks.has(resolvedPath)) { + resolvedLinks.push(resolvedPath); + seenLinks.add(resolvedPath); + } + } + 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 []; + } + } +}; +/** + * Regular expression for finding wikilinks of both forms: [[filename]] and ![[filename]] + * First capture group matches the exclamation mark (if present) + * Second capture group matches the content inside the brackets + */ +LinkAnalyzer.LINK_REGEX = /(!?)(?:\[\[(.*?)\]\])/g; + +// controllers/review-session-controller.ts +var ReviewSessionController = class { + /** + * Initialize session controller + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + /** + * Cache of linked notes to improve performance + */ + this.linkedNoteCache = /* @__PURE__ */ new Map(); + this.plugin = plugin; + } + /** + * Get linked notes that are due today + * + * @param notePath Path of the note to get links from + * @returns Array of paths to linked notes that are due today + */ + getDueLinkedNotes(notePath) { + const reviewController = this.plugin.reviewController; + if (!reviewController) + return []; + const todayNotes = reviewController.getTodayNotes(); + const links = this.linkedNoteCache.get(notePath) || []; + const duePaths = todayNotes.map((n) => n.path); + if (links.length === 0) { + this.analyzeNoteLinks(notePath).then((newLinks) => { + if (newLinks.length > 0) { + this.linkedNoteCache.set(notePath, newLinks); + } + }); + return []; + } + return links.filter((link) => duePaths.includes(link)); + } + /** + * Analyze links in a note and cache the results + * + * @param notePath Path to the note + * @returns Array of linked note paths + */ + async analyzeNoteLinks(notePath) { + try { + const links = await LinkAnalyzer.analyzeNoteLinks( + this.plugin.app.vault, + notePath, + true + // regularOnly = true - only include regular wiki links, not embeds + ); + this.linkedNoteCache.set(notePath, links); + return links; + } catch (error) { + console.error(`Error analyzing links for ${notePath}:`, error); + return []; + } + } + /** + * Clear the link cache for a specific note or all notes + * + * @param notePath Optional path to clear cache for specific note + */ + clearLinkCache(notePath) { + if (notePath) { + this.linkedNoteCache.delete(notePath); + } else { + this.linkedNoteCache.clear(); + } + } +}; + +// ui/context-menu.ts +var import_obsidian11 = require("obsidian"); +var ContextMenuHandler = class { + /** + * Initialize context menu handler + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + this.plugin = plugin; + } + /** + * Register context menu handlers + */ + register() { + this.plugin.registerEvent( + this.plugin.app.workspace.on("file-menu", this.handleFileMenuEvent.bind(this)) + ); + 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, abstractFile) { + if (abstractFile instanceof import_obsidian11.TFolder) { + console.log("Folder right-clicked:", abstractFile.path); + this.addFolderMenuItems(menu, abstractFile); + } else if (abstractFile instanceof import_obsidian11.TFile && abstractFile.extension === "md") { + 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 + */ + addFileMenuItems(menu, file) { + const isScheduled = !!this.plugin.reviewScheduleService.schedules[file.path]; + menu.addItem((item) => { + item.setTitle(isScheduled ? "Update review schedule" : "Add to review schedule").setIcon("calendar-plus").onClick(async () => { + await this.plugin.reviewScheduleService.scheduleNoteForReview(file.path); + await this.plugin.savePluginData(); + }); + }); + if (isScheduled) { + menu.addItem((item) => { + item.setTitle("Review now").setIcon("eye").onClick(() => this.plugin.reviewController.reviewNote(file.path)); + }); + menu.addItem((item) => { + item.setTitle("Remove from review").setIcon("calendar-minus").onClick(async () => { + await this.plugin.reviewScheduleService.removeFromReview(file.path); + await this.plugin.savePluginData(); + }); + }); + } + if (file.parent) { + menu.addItem((item) => { + item.setTitle("Add note's folder to review schedule").setIcon("folder-plus").onClick(async () => { + if (file.parent) { + new import_obsidian11.Notice(`Adding folder "${file.parent.name}" to review schedule...`); + if (file.parent instanceof import_obsidian11.TFolder) { + await this.addFolderToReview(file.parent); + } else { + new import_obsidian11.Notice("Error: Parent is not a folder."); + console.error("Error: file.parent is not an instance of TFolder", file.parent); + } + } + }); + }); + } + } + /** + * Adds context menu items for a TFolder. + * + * @param menu Context menu + * @param folder Target folder + */ + addFolderMenuItems(menu, folder) { + 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) { + console.log("Processing folder:", folder.path); + new import_obsidian11.Notice(`Analyzing folder structure for "${folder.name}"...`); + try { + const allFiles = this.plugin.app.vault.getMarkdownFiles().filter((file) => { + if (this.plugin.settings.includeSubfolders) { + const folderPath = folder.path === "/" ? "/" : `${folder.path}/`; + return file.path.startsWith(folderPath); + } else { + const parentPath = file.parent ? file.parent.path : ""; + return parentPath === folder.path; + } + }); + if (allFiles.length === 0) { + new import_obsidian11.Notice("No markdown files found in folder."); + return; + } + const includeSubfolders = this.plugin.settings.includeSubfolders; + let mainFilePath = null; + for (const file of allFiles) { + const fileName = file.basename.toLowerCase(); + const folderName = folder.name.toLowerCase(); + if (fileName === folderName) { + mainFilePath = file.path; + console.log(`Found main file with exact folder name match: ${mainFilePath}`); + break; + } + } + if (!mainFilePath) { + for (const file of allFiles) { + const fileName = file.basename.toLowerCase(); + const folderName = folder.name.toLowerCase(); + if (fileName.includes(folderName) || folderName.includes(fileName) || fileName === "index" || fileName === "main" || fileName.includes("index") || fileName.includes("main")) { + mainFilePath = file.path; + console.log(`Found main file by partial name match: ${mainFilePath}`); + break; + } + } + } + if (!mainFilePath) { + const activeFile = this.plugin.app.workspace.getActiveFile(); + if (activeFile && activeFile.extension === "md" && allFiles.some((f) => f.path === activeFile.path)) { + mainFilePath = activeFile.path; + console.log(`Using active note as main file: ${mainFilePath}`); + } + } + let traversalOrder = []; + const visited = /* @__PURE__ */ new Set(); + const processLinksRecursively = async (path) => { + if (visited.has(path)) { + return; + } + visited.add(path); + traversalOrder.push(path); + const links = await LinkAnalyzer.analyzeNoteLinks( + this.plugin.app.vault, + path, + false + ); + for (const link of links) { + const linkFile = this.plugin.app.vault.getAbstractFileByPath(link); + if (!(linkFile instanceof import_obsidian11.TFile) || linkFile.extension !== "md") { + continue; + } + if (allFiles.some((f) => f.path === linkFile.path)) { + await processLinksRecursively(linkFile.path); + } else { + if (!visited.has(linkFile.path)) { + visited.add(linkFile.path); + traversalOrder.push(linkFile.path); + } + } + } + }; + if (mainFilePath) { + console.log(`Starting traversal from identified main file: ${mainFilePath}`); + await processLinksRecursively(mainFilePath); + } + const sortedAllFiles = [...allFiles].sort((a, b2) => a.path.localeCompare(b2.path)); + for (const file of sortedAllFiles) { + if (!visited.has(file.path)) { + console.log(`Processing new branch or unlinked file: ${file.path}`); + await processLinksRecursively(file.path); + } + } + console.log(`Final traversal order determined with ${traversalOrder.length} files.`); + const count = await this.plugin.reviewScheduleService.scheduleNotesInOrder(traversalOrder); + if (count > 0) { + await this.plugin.savePluginData(); + } + const startingFileName = traversalOrder.length > 0 ? traversalOrder[0].split("/").pop() : "unknown"; + new import_obsidian11.Notice(`Added ${count} notes from "${folder.name}" to review schedule, starting with ${startingFileName}`); + await this.plugin.reviewController.updateTodayNotes(); + } catch (error) { + console.error("Error adding folder to review:", error); + new import_obsidian11.Notice("Error adding folder to review schedule"); + } + } + /** + * Create a hierarchical review session for a folder + * + * @param folder Target folder + */ + async createHierarchicalSession(folder) { + new import_obsidian11.Notice("Analyzing folder structure and links..."); + const session = await this.plugin.reviewSessionService.createReviewSession( + folder.path, + folder.name + ); + if (session) { + await this.plugin.savePluginData(); + } + if (!session) { + new import_obsidian11.Notice("Failed to create review session"); + return; + } + const fileCount = session.hierarchy.traversalOrder.length; + await this.plugin.reviewSessionService.setActiveSession(session.id); + await this.plugin.savePluginData(); + const firstFilePath = this.plugin.reviewSessionService.getNextSessionFile(); + if (firstFilePath) { + const scheduledCount = await this.plugin.reviewSessionService.scheduleSessionForReview(session.id); + if (scheduledCount > 0) { + await this.plugin.savePluginData(); + } + new import_obsidian11.Notice(`Created hierarchical review session with ${fileCount} files.`); + const file = this.plugin.app.vault.getAbstractFileByPath(firstFilePath); + if (file instanceof import_obsidian11.TFile) { + this.plugin.reviewController.reviewNote(firstFilePath); + } + } else { + new import_obsidian11.Notice("No files found for review in this folder."); + await this.plugin.reviewSessionService.setActiveSession(null); + await this.plugin.savePluginData(); + } + } +}; + +// ui/sidebar-view.ts +var import_obsidian16 = require("obsidian"); + +// ui/sidebar/pomodoro-ui-manager.ts +var import_obsidian12 = require("obsidian"); +var PomodoroUIManager = class { + constructor(plugin) { + this.attachedContainer = null; + // The container provided by ListViewRenderer + // References to Pomodoro UI elements + this.pomodoroVisibilityToggleBtnContainer = null; + this.pomodoroVisibilityToggleBtn = null; + this.pomodoroRootEl = null; + // Container for the actual timer content + this.pomodoroTimerDisplayEl = null; + this.pomodoroStartBtn = null; + this.pomodoroStopBtn = null; + this.pomodoroSkipBtn = null; + this.pomodoroQuickSettingsPanelEl = null; + // private pomodoroQuickSettingsToggleBtn: HTMLElement | null = null; // Removed + this.pomodoroQuickWorkInput = null; + this.pomodoroQuickShortInput = null; + this.pomodoroQuickLongInput = null; + this.pomodoroQuickSessionsInput = null; + this.pomodoroCalculationResultEl = null; + // private isPomodoroSectionOpen: boolean = false; // No longer needed, section is always "open" + this.areButtonsVisible = true; + // For Play/Pause/Skip buttons + this.isTimerTextVisible = true; + // For the timer countdown text + this.longPressTimer = null; + this.veryLongPressTimer = null; + this.LONG_PRESS_DURATION = 500; + // ms + this.VERY_LONG_PRESS_DURATION = 1500; + // ms + this.didLongPress = false; + this.didVeryLongPress = false; + this.plugin = plugin; + } + /** + * Saves the current values from the Pomodoro quick settings input fields. + * @returns true if settings were valid and saved, false otherwise. + */ + _savePomodoroSettings() { + var _a, _b, _c, _d; + const work = parseInt(((_a = this.pomodoroQuickWorkInput) == null ? void 0 : _a.value) || "0"); + const short = parseInt(((_b = this.pomodoroQuickShortInput) == null ? void 0 : _b.value) || "0"); + const long = parseInt(((_c = this.pomodoroQuickLongInput) == null ? void 0 : _c.value) || "0"); + const sessions = parseInt(((_d = this.pomodoroQuickSessionsInput) == null ? void 0 : _d.value) || "0"); + if (work > 0 && short > 0 && long > 0 && sessions > 0) { + this.plugin.pomodoroService.updateDurations(work, short, long, sessions); + return true; + } else { + new import_obsidian12.Notice("Invalid Pomodoro durations. Settings not saved. Please enter positive numbers."); + if (this.pomodoroQuickWorkInput) + this.pomodoroQuickWorkInput.value = String(this.plugin.settings.pomodoroWorkDuration); + if (this.pomodoroQuickShortInput) + this.pomodoroQuickShortInput.value = String(this.plugin.settings.pomodoroShortBreakDuration); + if (this.pomodoroQuickLongInput) + this.pomodoroQuickLongInput.value = String(this.plugin.settings.pomodoroLongBreakDuration); + if (this.pomodoroQuickSessionsInput) + this.pomodoroQuickSessionsInput.value = String(this.plugin.settings.pomodoroSessionsUntilLongBreak); + return false; + } + } + /** + * Attaches the Pomodoro UI to a given container and renders its initial state. + * Creates necessary sub-containers if they don't exist. + * @param container The parent element where the Pomodoro UI should be placed. + */ + attachAndRender(container) { + var _a; + this.attachedContainer = container; + let rootElWasCreated = false; + if (!this.pomodoroRootEl || this.pomodoroRootEl.parentElement !== this.attachedContainer) { + (_a = this.pomodoroRootEl) == null ? void 0 : _a.remove(); + this.pomodoroRootEl = this.attachedContainer.createDiv("pomodoro-section-content"); + rootElWasCreated = true; + } + if (this.pomodoroRootEl) { + this.pomodoroRootEl.style.display = ""; + } + if (rootElWasCreated || this.pomodoroRootEl && this.pomodoroRootEl.children.length === 0) { + this.renderPomodoroTimer(this.pomodoroRootEl); + } + this.updatePomodoroUI(); + } + // getIsPomodoroSectionOpen, setIsPomodoroSectionOpen, setupPomodoroVisibilityToggleButton, updatePomodoroVisibility are no longer needed + // as the section is always visible and the toggle button is removed. + /** Controls the visibility of the entire attached Pomodoro UI section (e.g. for global plugin enable/disable) */ + showPomodoroSection(show) { + if (this.attachedContainer) { + this.attachedContainer.style.display = show ? "" : "none"; + } + } + /** + * Renders or updates the Pomodoro Timer UI elements into pomodoroRootEl. + * @param container The parent element to render into (this.pomodoroRootEl). + */ + renderPomodoroTimer(container) { + var _a, _b, _c, _d, _e; + let mainControlsRow = container.querySelector(".pomodoro-main-controls"); + if (!mainControlsRow) { + mainControlsRow = container.createDiv("pomodoro-main-controls"); + } + if (!this.pomodoroStartBtn || this.pomodoroStartBtn.parentElement !== mainControlsRow) { + (_a = this.pomodoroStartBtn) == null ? void 0 : _a.remove(); + this.pomodoroStartBtn = mainControlsRow.createEl("button", { cls: "pomodoro-start-btn" }); + (0, import_obsidian12.setIcon)(this.pomodoroStartBtn, "play"); + this.pomodoroStartBtn.addEventListener("click", () => this.plugin.pomodoroService.start()); + } + if (!this.pomodoroStopBtn || this.pomodoroStopBtn.parentElement !== mainControlsRow) { + (_b = this.pomodoroStopBtn) == null ? void 0 : _b.remove(); + this.pomodoroStopBtn = mainControlsRow.createEl("button", { cls: "pomodoro-stop-btn" }); + (0, import_obsidian12.setIcon)(this.pomodoroStopBtn, "pause"); + this.pomodoroStopBtn.addEventListener("click", () => this.plugin.pomodoroService.stop()); + } + this.pomodoroStopBtn.hide(); + if (!this.pomodoroTimerDisplayEl || this.pomodoroTimerDisplayEl.parentElement !== mainControlsRow) { + (_c = this.pomodoroTimerDisplayEl) == null ? void 0 : _c.remove(); + this.pomodoroTimerDisplayEl = mainControlsRow.createDiv("pomodoro-timer-display"); + this.pomodoroTimerDisplayEl.addClass("pomodoro-timer-fade"); + } + this.pomodoroTimerDisplayEl.setText(this.plugin.pomodoroService.getFormattedTimeLeft()); + if (this.pomodoroTimerDisplayEl) { + this.pomodoroTimerDisplayEl.addEventListener("mousedown", (e) => { + e.preventDefault(); + this.didLongPress = false; + this.didVeryLongPress = false; + this.longPressTimer = window.setTimeout(() => { + this.didLongPress = true; + }, this.LONG_PRESS_DURATION); + this.veryLongPressTimer = window.setTimeout(() => { + this.didVeryLongPress = true; + this.isTimerTextVisible = !this.isTimerTextVisible; + this.areButtonsVisible = this.isTimerTextVisible; + this.updateTimerTextDisplay(); + this.updateButtonVisibility(); + }, this.VERY_LONG_PRESS_DURATION); + }); + const handlePressEnd = () => { + if (this.veryLongPressTimer) + clearTimeout(this.veryLongPressTimer); + if (this.longPressTimer) + clearTimeout(this.longPressTimer); + this.veryLongPressTimer = null; + this.longPressTimer = null; + if (this.didVeryLongPress) { + } else if (this.didLongPress) { + if (!this.isTimerTextVisible) { + this.isTimerTextVisible = true; + this.areButtonsVisible = true; + } else { + this.areButtonsVisible = !this.areButtonsVisible; + } + this.updateTimerTextDisplay(); + this.updateButtonVisibility(); + } else { + if (this.isTimerTextVisible) { + this.toggleSettingsPanel(); + } + } + this.didLongPress = false; + this.didVeryLongPress = false; + }; + this.pomodoroTimerDisplayEl.addEventListener("mouseup", handlePressEnd); + this.pomodoroTimerDisplayEl.addEventListener("touchend", handlePressEnd); + const cancelPress = () => { + if (this.veryLongPressTimer) + clearTimeout(this.veryLongPressTimer); + if (this.longPressTimer) + clearTimeout(this.longPressTimer); + this.veryLongPressTimer = null; + this.longPressTimer = null; + this.didLongPress = false; + this.didVeryLongPress = false; + }; + this.pomodoroTimerDisplayEl.addEventListener("mouseleave", cancelPress); + this.pomodoroTimerDisplayEl.addEventListener("touchmove", cancelPress); + this.pomodoroTimerDisplayEl.addEventListener("touchstart", (e) => { + e.preventDefault(); + this.didLongPress = false; + this.didVeryLongPress = false; + this.longPressTimer = window.setTimeout(() => { + this.didLongPress = true; + }, this.LONG_PRESS_DURATION); + this.veryLongPressTimer = window.setTimeout(() => { + this.didVeryLongPress = true; + this.isTimerTextVisible = !this.isTimerTextVisible; + this.areButtonsVisible = this.isTimerTextVisible; + this.updateTimerTextDisplay(); + this.updateButtonVisibility(); + }, this.VERY_LONG_PRESS_DURATION); + }, { passive: false }); + } + if (!this.pomodoroSkipBtn || this.pomodoroSkipBtn.parentElement !== mainControlsRow) { + (_d = this.pomodoroSkipBtn) == null ? void 0 : _d.remove(); + this.pomodoroSkipBtn = mainControlsRow.createEl("button", { cls: "pomodoro-skip-btn" }); + (0, import_obsidian12.setIcon)(this.pomodoroSkipBtn, "skip-forward"); + this.pomodoroSkipBtn.addEventListener("click", () => this.plugin.pomodoroService.skipSession()); + } + let settingsPanelContainer = container.querySelector(".pomodoro-settings-panel-container"); + if (!settingsPanelContainer) { + settingsPanelContainer = container.createDiv("pomodoro-settings-panel-container"); + } + if (!this.pomodoroQuickSettingsPanelEl || this.pomodoroQuickSettingsPanelEl.parentElement !== settingsPanelContainer) { + (_e = this.pomodoroQuickSettingsPanelEl) == null ? void 0 : _e.remove(); + this.pomodoroQuickSettingsPanelEl = settingsPanelContainer.createDiv("pomodoro-quick-settings-panel"); + this.pomodoroQuickSettingsPanelEl.style.display = "none"; + const createQuickSetting = (labelText, inputType = "number") => { + const settingDiv = this.pomodoroQuickSettingsPanelEl.createDiv("pomodoro-quick-setting"); + settingDiv.createEl("label", { text: labelText }); + const input = settingDiv.createEl("input", { type: inputType }); + input.setAttr("min", "1"); + return input; + }; + this.pomodoroQuickWorkInput = createQuickSetting("Work (min):"); + this.pomodoroQuickShortInput = createQuickSetting("Short Break (min):"); + this.pomodoroQuickLongInput = createQuickSetting("Long Break (min):"); + this.pomodoroQuickSessionsInput = createQuickSetting("Sessions/Long Break:"); + const buttonsContainer = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-quick-settings-buttons" }); + const calculateBtn = buttonsContainer.createEl("button", { text: "Calculate Reading Time", cls: "pomodoro-quick-calculate-btn" }); + calculateBtn.addEventListener("click", async () => { + const settingsSaved = this._savePomodoroSettings(); + if (settingsSaved) { + await this.calculateAndDisplayPomodoroEstimate(); + } + }); + this.pomodoroCalculationResultEl = this.pomodoroQuickSettingsPanelEl.createDiv({ cls: "pomodoro-calculation-result" }); + this.pomodoroCalculationResultEl.style.display = "none"; + } + this.updatePomodoroUI(); + } + /** + * Calculates the estimated Pomodoro cycles for today's notes and displays it. + */ + async calculateAndDisplayPomodoroEstimate() { + if (!this.plugin || !this.pomodoroCalculationResultEl) + return; + const notesForEstimate = this.plugin.reviewController.getTodayNotes(); + if (notesForEstimate.length === 0) { + const activeDate = this.plugin.reviewController.getCurrentReviewDateOverride(); + const message = activeDate ? `No notes scheduled for ${new Date(activeDate).toLocaleDateString()} to calculate.` : "No notes currently due to calculate."; + this.pomodoroCalculationResultEl.setText(message); + this.pomodoroCalculationResultEl.style.display = "block"; + return; + } + let totalReadingTimeInSeconds = 0; + for (const note of notesForEstimate) { + totalReadingTimeInSeconds += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); + } + const totalReadingTimeInMinutes = totalReadingTimeInSeconds / 60; + const settings = this.plugin.settings; + const workDuration = settings.pomodoroWorkDuration; + const shortBreakDuration = settings.pomodoroShortBreakDuration; + const longBreakDuration = settings.pomodoroLongBreakDuration; + const sessionsUntilLongBreak = settings.pomodoroSessionsUntilLongBreak; + let pomodorosNeeded = 0; + let sessionsCompletedInCycle = 0; + let remainingReadingTimeMinutes = totalReadingTimeInMinutes; + let totalBreakTimeInMinutes = 0; + if (totalReadingTimeInMinutes === 0) { + this.pomodoroCalculationResultEl.setText("Estimated reading time is 0 minutes."); + this.pomodoroCalculationResultEl.style.display = "block"; + return; + } + while (remainingReadingTimeMinutes > 0) { + pomodorosNeeded++; + remainingReadingTimeMinutes -= workDuration; + sessionsCompletedInCycle++; + if (remainingReadingTimeMinutes <= 0) + break; + if (sessionsCompletedInCycle >= sessionsUntilLongBreak) { + totalBreakTimeInMinutes += longBreakDuration; + sessionsCompletedInCycle = 0; + } else { + totalBreakTimeInMinutes += shortBreakDuration; + } + } + const totalTimeWithBreaksMinutes = pomodorosNeeded * workDuration + totalBreakTimeInMinutes; + const formattedTotalReadingTime = EstimationUtils.formatTime(totalReadingTimeInSeconds); + const formattedTotalTimeWithBreaks = EstimationUtils.formatTime(Math.ceil(totalTimeWithBreaksMinutes * 60)); + this.pomodoroCalculationResultEl.empty(); + this.pomodoroCalculationResultEl.createEl("p", { text: `Estimated reading time for ${notesForEstimate.length} note(s) in current view: ${formattedTotalReadingTime}.` }); + this.pomodoroCalculationResultEl.createEl("p", { text: `Requires ~${pomodorosNeeded} Pomodoro work session(s).` }); + this.pomodoroCalculationResultEl.createEl("p", { text: `Total time with breaks: ~${formattedTotalTimeWithBreaks}.` }); + this.pomodoroCalculationResultEl.style.display = "block"; + } + /** + * Updates the Pomodoro UI based on the current state from PomodoroService. + */ + toggleSettingsPanel() { + const panel = this.pomodoroQuickSettingsPanelEl; + if (!panel) + return; + const isCurrentlyHidden = panel.style.display === "none" || !panel.style.display; + panel.style.display = isCurrentlyHidden ? "flex" : "none"; + if (isCurrentlyHidden) { + if (this.pomodoroQuickWorkInput) + this.pomodoroQuickWorkInput.value = String(this.plugin.settings.pomodoroWorkDuration); + if (this.pomodoroQuickShortInput) + this.pomodoroQuickShortInput.value = String(this.plugin.settings.pomodoroShortBreakDuration); + if (this.pomodoroQuickLongInput) + this.pomodoroQuickLongInput.value = String(this.plugin.settings.pomodoroLongBreakDuration); + if (this.pomodoroQuickSessionsInput) + this.pomodoroQuickSessionsInput.value = String(this.plugin.settings.pomodoroSessionsUntilLongBreak); + } else { + this._savePomodoroSettings(); + } + } + updateTimerTextDisplay() { + if (this.pomodoroTimerDisplayEl) { + this.pomodoroTimerDisplayEl.style.opacity = this.isTimerTextVisible ? "1" : "0"; + } + } + updateButtonVisibility() { + const buttonsVisibility = this.areButtonsVisible ? "" : "none"; + const isRunning = this.plugin.pluginState.pomodoroIsRunning; + if (this.pomodoroStartBtn) + this.pomodoroStartBtn.style.display = isRunning ? "none" : buttonsVisibility; + if (this.pomodoroStopBtn) + this.pomodoroStopBtn.style.display = isRunning ? buttonsVisibility : "none"; + if (this.pomodoroSkipBtn) + this.pomodoroSkipBtn.style.display = buttonsVisibility; + } + /** + * Updates the Pomodoro UI based on the current state from PomodoroService. + */ + updatePomodoroUI() { + var _a; + if (!this.attachedContainer || !this.pomodoroRootEl) { + return; + } + this.pomodoroRootEl.style.display = ""; + const state = this.plugin.pluginState; + const service = this.plugin.pomodoroService; + if (this.pomodoroTimerDisplayEl) { + this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft()); + this.pomodoroTimerDisplayEl.className = "pomodoro-timer-display pomodoro-timer-fade"; + if (state.pomodoroCurrentMode !== "idle") { + this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`); + } else { + this.pomodoroTimerDisplayEl.addClass("mode-idle"); + } + if (state.pomodoroIsRunning) { + this.pomodoroTimerDisplayEl.addClass("timer-visible"); + } else { + this.pomodoroTimerDisplayEl.removeClass("timer-visible"); + } + } + if (this.pomodoroTimerDisplayEl) { + this.pomodoroTimerDisplayEl.setText(service.getFormattedTimeLeft()); + this.updateTimerTextDisplay(); + this.pomodoroTimerDisplayEl.className = "pomodoro-timer-display pomodoro-timer-fade"; + if (state.pomodoroCurrentMode !== "idle") { + this.pomodoroTimerDisplayEl.addClass(`mode-${state.pomodoroCurrentMode}`); + } else { + this.pomodoroTimerDisplayEl.addClass("mode-idle"); + } + if (state.pomodoroIsRunning) { + this.pomodoroTimerDisplayEl.addClass("timer-visible"); + } else { + this.pomodoroTimerDisplayEl.removeClass("timer-visible"); + } + } + this.updateButtonVisibility(); + this.pomodoroRootEl.toggleClass("is-running", state.pomodoroIsRunning); + this.pomodoroRootEl.toggleClass("is-paused", !state.pomodoroIsRunning && state.pomodoroCurrentMode !== "idle"); + this.pomodoroRootEl.toggleClass("is-idle", state.pomodoroCurrentMode === "idle"); + if (this.pomodoroCalculationResultEl && ((_a = this.pomodoroQuickSettingsPanelEl) == null ? void 0 : _a.style.display) === "none") { + this.pomodoroCalculationResultEl.style.display = "none"; + } + } +}; + +// ui/sidebar/note-item-renderer.ts +var import_obsidian13 = require("obsidian"); + +// utils/dates.ts +var DateUtils = class { + /** + * Get start of day timestamp for a given date + * + * @param date Date to get start of day for + * @returns Timestamp for start of day + */ + static startOfDay(date = /* @__PURE__ */ new Date()) { + const newDate = new Date(date); + newDate.setHours(0, 0, 0, 0); + return newDate.getTime(); + } + /** + * Add days to a timestamp + * + * @param timestamp Base timestamp + * @param days Number of days to add + * @returns New timestamp + */ + static addDays(timestamp, days) { + return timestamp + days * 24 * 60 * 60 * 1e3; + } + /** + * Format a timestamp as a readable date string + * + * @param timestamp Timestamp to format + * @param format Format type ('short', 'medium', 'long', 'relative') + * @param baseDateParam Optional base date for relative formatting + * @returns Formatted date string + */ + static formatDate(timestamp, format = "medium", baseDateParam) { + const noteEventDate = new Date(timestamp); + if (format === "relative") { + const referenceDateForCalc = baseDateParam ? new Date(baseDateParam) : /* @__PURE__ */ new Date(); + const normalizedNoteEventDate = this.startOfDay(noteEventDate); + const normalizedReferenceDate = this.startOfDay(referenceDateForCalc); + const normalizedActualCurrentDate = this.startOfDay(/* @__PURE__ */ new Date()); + if (normalizedNoteEventDate < normalizedActualCurrentDate) { + return "Due notes"; + } + if (baseDateParam) { + const normalizedBaseDate = this.startOfDay(new Date(baseDateParam)); + if (normalizedBaseDate === normalizedActualCurrentDate) { + return "Today"; + } else if (normalizedBaseDate === this.startOfDay(new Date(this.addDays(normalizedActualCurrentDate, 1)))) { + return "Tomorrow"; + } else { + return new Date(normalizedBaseDate).toLocaleDateString(void 0, { month: "short", day: "numeric" }); + } + } else { + const diffInDays = Math.floor((normalizedNoteEventDate - normalizedActualCurrentDate) / (24 * 60 * 60 * 1e3)); + if (diffInDays === 0) { + return "Today"; + } else if (diffInDays === 1) { + return "Tomorrow"; + } else { + return `In ${diffInDays} days`; + } + } + } else if (format === "short") { + return noteEventDate.toLocaleDateString(); + } else if (format === "long") { + return noteEventDate.toLocaleDateString(void 0, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }); + } else { + return noteEventDate.toLocaleDateString(void 0, { + weekday: "short", + month: "short", + day: "numeric" + }); + } + } + /** + * Get the day difference between two timestamps + * + * @param timestamp1 First timestamp + * @param timestamp2 Second timestamp + * @returns Difference in days + */ + static dayDifference(timestamp1, timestamp2) { + const date1 = new Date(timestamp1); + const date2 = new Date(timestamp2); + date1.setHours(0, 0, 0, 0); + date2.setHours(0, 0, 0, 0); + const diffTime = Math.abs(date2.getTime() - date1.getTime()); + const diffDays = Math.floor(diffTime / (1e3 * 60 * 60 * 24)); + return diffDays; + } + /** + * Get start of UTC day timestamp for a given date + * + * @param date Date to get start of UTC day for + * @returns Timestamp for start of UTC day (00:00:00.000Z) + */ + static startOfUTCDay(date = /* @__PURE__ */ new Date()) { + const newDate = new Date(date.getTime()); + newDate.setUTCHours(0, 0, 0, 0); + return newDate.getTime(); + } + /** + * Get end of UTC day timestamp for a given date + * + * @param date Date to get end of UTC day for + * @returns Timestamp for end of UTC day (23:59:59.999Z) + */ + static endOfUTCDay(date = /* @__PURE__ */ new Date()) { + const newDate = new Date(date.getTime()); + newDate.setUTCHours(23, 59, 59, 999); + return newDate.getTime(); + } + /** + * Get the day difference between two timestamps based on UTC days + * + * @param timestamp1 First timestamp + * @param timestamp2 Second timestamp + * @returns Difference in UTC days + */ + static dayDifferenceUTC(timestamp1, timestamp2) { + const date1UTCMidnight = this.startOfUTCDay(new Date(timestamp1)); + const date2UTCMidnight = this.startOfUTCDay(new Date(timestamp2)); + const diffTime = Math.abs(date2UTCMidnight - date1UTCMidnight); + const diffDays = Math.floor(diffTime / (1e3 * 60 * 60 * 24)); + return diffDays; + } + /** + * Check if two dates are the same day, ignoring time. + * @param date1 The first date. + * @param date2 The second date. + * @returns True if both dates fall on the same day, false otherwise. + */ + static isSameDay(date1, date2) { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate(); + } +}; + +// ui/sidebar/note-item-renderer.ts +var NoteItemRenderer = class { + constructor(plugin) { + this.plugin = plugin; + } + async _populateNoteItemDetails(noteEl, note, dateStr, selectedNotesArray) { + noteEl.dataset.notePath = note.path; + noteEl.removeClass("overdue-note"); + noteEl.removeAttribute("title"); + if (dateStr === "Due notes") { + noteEl.addClass("overdue-note"); + const daysOverdue = Math.abs(Math.floor((note.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1e3))); + const originalDueDate = new Date(note.nextReviewDate).toLocaleDateString(); + noteEl.setAttribute("title", `Originally due: ${originalDueDate} (${daysOverdue} ${daysOverdue === 1 ? "day" : "days"} overdue)`); + } + if (selectedNotesArray.includes(note.path)) { + noteEl.addClass("selected"); + } else { + noteEl.removeClass("selected"); + } + const titleEl = noteEl.querySelector(".review-note-title"); + if (titleEl) { + const file = this.plugin.app.vault.getAbstractFileByPath(note.path); + titleEl.setText(file instanceof import_obsidian13.TFile ? file.basename : note.path); + } + const estimatedTime = await this.plugin.reviewScheduleService.estimateReviewTime(note.path); + const formattedTime = EstimationUtils.formatTime(estimatedTime); + const phaseEl = noteEl.querySelector(".review-note-phase"); + const timeElOld = noteEl.querySelector(".review-note-time"); + if (timeElOld) + timeElOld.remove(); + if (phaseEl) { + phaseEl.empty(); + phaseEl.removeClass("review-phase-initial", "review-phase-graduated", "review-phase-spaced"); + if (note.scheduleCategory === "initial") { + const totalInitialSteps = this.plugin.settings.initialScheduleCustomIntervals.length; + const currentStepDisplay = note.reviewCount < totalInitialSteps ? note.reviewCount + 1 : totalInitialSteps; + phaseEl.innerHTML = ` +
Initial
+
${currentStepDisplay}/${totalInitialSteps}
+
${formattedTime}
+ `; + phaseEl.addClass("review-phase-initial"); + } else { + phaseEl.setText(note.scheduleCategory === "graduated" ? "Graduated" : "Spaced"); + phaseEl.addClass(note.scheduleCategory === "graduated" ? "review-phase-graduated" : "review-phase-spaced"); + const timeElNew = noteEl.createDiv("review-note-time"); + noteEl.insertBefore(timeElNew, phaseEl.nextSibling); + EstimationUtils.formatTimeWithColor(estimatedTime, timeElNew); + } + } + const buttonsEl = noteEl.querySelector(".review-note-buttons"); + let dragHandleEl = buttonsEl == null ? void 0 : buttonsEl.querySelector(".review-note-drag-handle"); + if (dragHandleEl) { + const isDraggable = dateStr === "Due notes" || dateStr === "Today"; + dragHandleEl.classList.toggle("is-disabled", !isDraggable); + if (isDraggable && !dragHandleEl.hasAttribute("draggable")) { + } else if (!isDraggable) { + noteEl.removeAttribute("draggable"); + } + } + const advanceBtn = noteEl.querySelector(".review-note-advance"); + if (advanceBtn) { + const todayStartTs = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const noteReviewDayStartTs = DateUtils.startOfDay(new Date(note.nextReviewDate)); + const isEligibleForAdvance = noteReviewDayStartTs > todayStartTs; + advanceBtn.disabled = !isEligibleForAdvance; + advanceBtn.style.display = isEligibleForAdvance ? "" : "none"; + } + } + async updateNoteItem(noteEl, note, dateStr, selectedNotesArray) { + await this._populateNoteItemDetails(noteEl, note, dateStr, selectedNotesArray); + } + async renderNoteItem(notesContainer, noteToRender, dateStr, parentContainerForBulkActions, selectedNotesArray, lastSelectedNotePathRef, onSelectionChange, onNoteAction) { + if (!parentContainerForBulkActions) { + console.error("Spaceforge: parentContainerForBulkActions is null in renderNoteItem. This should not happen."); + parentContainerForBulkActions = document.body; + } + const noteEl = notesContainer.createDiv("review-note-item"); + const titleEl = noteEl.createDiv("review-note-title"); + titleEl.style.cursor = "pointer"; + noteEl.createDiv("review-note-phase"); + const buttonsEl = noteEl.createDiv("review-note-buttons"); + const actionBtnsEl = buttonsEl.createDiv("review-note-actions"); + const reviewBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-review" }); + (0, import_obsidian13.setIcon)(reviewBtn, "play"); + reviewBtn.title = "Review"; + const advanceBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-advance" }); + (0, import_obsidian13.setIcon)(advanceBtn, "arrow-left-circle"); + advanceBtn.title = "Advance"; + const postponeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-postpone" }); + (0, import_obsidian13.setIcon)(postponeBtn, "arrow-right-circle"); + postponeBtn.title = "Postpone"; + const removeBtn = actionBtnsEl.createEl("button", { cls: "review-note-button review-note-remove" }); + (0, import_obsidian13.setIcon)(removeBtn, "trash-2"); + removeBtn.title = "Remove"; + const dragHandleEl = buttonsEl.createDiv("review-note-drag-handle"); + dragHandleEl.setAttribute("aria-label", "Drag to reorder"); + for (let i = 0; i < 3; i++) { + dragHandleEl.createDiv("drag-handle-line"); + } + titleEl.addEventListener("click", (e) => { + e.stopPropagation(); + const path = noteEl.dataset.notePath; + if (path) + this.plugin.reviewController.openNoteWithoutReview(path); + }); + reviewBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const path = noteEl.dataset.notePath; + if (path) { + await this.plugin.reviewController.reviewNote(path); + await onNoteAction(); + } + }); + advanceBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + if (advanceBtn.disabled) + return; + const path = noteEl.dataset.notePath; + if (path) { + await this.plugin.reviewController.advanceNote(path); + await onNoteAction(); + } + }); + postponeBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const path = noteEl.dataset.notePath; + if (path) { + try { + await this.plugin.reviewController.postponeNote(path); + await this.plugin.savePluginData(); + new import_obsidian13.Notice(`Note postponed`); + await onNoteAction(); + } catch (error) { + console.error("Error postponing note:", error); + new import_obsidian13.Notice("Failed to postpone note."); + await onNoteAction(); + } + } + }); + removeBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const path = noteEl.dataset.notePath; + if (path) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + const confirmed = confirm(`Remove "${file instanceof import_obsidian13.TFile ? file.basename : path}" from review schedule?`); + if (!confirmed) + return; + try { + await this.plugin.reviewScheduleService.removeFromReview(path); + await this.plugin.savePluginData(); + new import_obsidian13.Notice(`Note removed from review schedule`); + await onNoteAction(); + } catch (error) { + console.error("Error removing note:", error); + new import_obsidian13.Notice("Failed to remove note from schedule."); + await onNoteAction(); + } + } + }); + dragHandleEl.addEventListener("mousedown", (e) => { + e.stopPropagation(); + if (!dragHandleEl.classList.contains("is-disabled")) { + noteEl.setAttribute("draggable", "true"); + } + }); + noteEl.addEventListener("dragstart", (e) => { + var _a; + const path = noteEl.dataset.notePath; + if (path && noteEl.getAttribute("draggable") === "true") { + (_a = e.dataTransfer) == null ? void 0 : _a.setData("text/plain", path); + } else { + e.preventDefault(); + } + }); + noteEl.addEventListener("dragend", () => { + noteEl.removeAttribute("draggable"); + }); + noteEl.addEventListener("click", (e) => { + e.stopPropagation(); + const currentPath = noteEl.dataset.notePath; + if (!currentPath) + return; + const allVisibleNoteElements = Array.from(parentContainerForBulkActions.querySelectorAll(".review-note-item[data-note-path]")); + const allVisibleNotePaths = allVisibleNoteElements.map((el) => el.dataset.notePath).filter((p2) => p2); + const currentIndex = allVisibleNotePaths.indexOf(currentPath); + if (e.shiftKey && lastSelectedNotePathRef.current && lastSelectedNotePathRef.current !== currentPath) { + const lastClickedIndexInVisible = allVisibleNotePaths.indexOf(lastSelectedNotePathRef.current); + if (lastClickedIndexInVisible !== -1 && currentIndex !== -1) { + const start = Math.min(lastClickedIndexInVisible, currentIndex); + const end = Math.max(lastClickedIndexInVisible, currentIndex); + const notesToSelectInRange = allVisibleNotePaths.slice(start, end + 1); + if (e.ctrlKey || e.metaKey) { + notesToSelectInRange.forEach((p2) => { + if (p2 && !selectedNotesArray.includes(p2)) + selectedNotesArray.push(p2); + }); + } else { + selectedNotesArray.length = 0; + selectedNotesArray.push(...notesToSelectInRange.filter((p2) => p2)); + } + } else { + selectedNotesArray.length = 0; + selectedNotesArray.push(currentPath); + } + } else if (e.ctrlKey || e.metaKey) { + const indexInSelection = selectedNotesArray.indexOf(currentPath); + if (indexInSelection > -1) { + selectedNotesArray.splice(indexInSelection, 1); + } else { + selectedNotesArray.push(currentPath); + } + } else { + selectedNotesArray.length = 0; + selectedNotesArray.push(currentPath); + } + lastSelectedNotePathRef.current = currentPath; + onSelectionChange(); + }); + noteEl.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + const path = noteEl.dataset.notePath; + if (!path) + return; + const menu = new import_obsidian13.Menu(); + menu.addItem((item) => item.setTitle("Open note").setIcon("file-text").onClick(() => this.plugin.reviewController.openNoteWithoutReview(path))); + menu.addItem((item) => item.setTitle("Review note").setIcon("play-circle").onClick(async () => { + this.plugin.reviewController.reviewNote(path); + await onNoteAction(); + })); + menu.addItem((item) => item.setTitle("Postpone by 1 day").setIcon("skip-forward").onClick(async () => { + await this.plugin.reviewController.postponeNote(path, 1); + await onNoteAction(); + })); + const schedule = this.plugin.reviewScheduleService.schedules[path]; + if (schedule) { + const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const noteReviewDayStart = DateUtils.startOfDay(new Date(schedule.nextReviewDate)); + if (noteReviewDayStart > todayStart) { + menu.addItem((item) => item.setTitle("Advance note").setIcon("arrow-left-circle").onClick(async () => { + await this.plugin.reviewController.advanceNote(path); + await onNoteAction(); + })); + } + } + menu.addItem((item) => item.setTitle("Remove from review").setIcon("trash").onClick(async () => { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + const confirmed = confirm(`Remove "${file instanceof import_obsidian13.TFile ? file.basename : path}" from review schedule?`); + if (confirmed) { + await this.plugin.reviewScheduleService.removeFromReview(path); + await this.plugin.savePluginData(); + new import_obsidian13.Notice("Note removed from review schedule."); + await onNoteAction(); + } + })); + menu.showAtMouseEvent(e); + }); + await this._populateNoteItemDetails(noteEl, noteToRender, dateStr, selectedNotesArray); + return noteEl; + } +}; + +// ui/sidebar/list-view-renderer.ts +var import_obsidian14 = require("obsidian"); +var ListViewRenderer = class { + // Callback to trigger full refresh if needed + constructor(plugin, pomodoroUIManager, noteItemRenderer, stateAccessors) { + this.plugin = plugin; + this.pomodoroUIManager = pomodoroUIManager; + this.noteItemRenderer = noteItemRenderer; + this.getActiveListBaseDate = stateAccessors.getActiveListBaseDate; + this.getSelectedNotes = stateAccessors.getSelectedNotes; + this.setSelectedNotes = stateAccessors.setSelectedNotes; + this.getExpandedUpcomingDayKey = stateAccessors.getExpandedUpcomingDayKey; + this.setExpandedUpcomingDayKey = stateAccessors.setExpandedUpcomingDayKey; + this.getLastSelectedNotePath = stateAccessors.getLastSelectedNotePath; + this.setLastSelectedNotePath = stateAccessors.setLastSelectedNotePath; + this.refreshSidebarView = stateAccessors.refreshSidebarView; + } + /** + * Render the list view content into the provided container. + * @param container Container element for list view content + */ + async render(container) { + const activeListBaseDate = this.getActiveListBaseDate(); + const selectedNotes = this.getSelectedNotes(); + const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true); + const notesForPomodoro = this.plugin.reviewController.getTodayNotes(); + await this._ensureAndUpdateReviewButtonsSection(container, notesForPomodoro, selectedNotes); + this._ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate); + let notesToGroup; + let shouldIncludeFutureInGrouping = false; + if (activeListBaseDate) { + notesToGroup = Object.values(this.plugin.reviewScheduleService.schedules); + shouldIncludeFutureInGrouping = true; + } else { + notesToGroup = dueNotesForStats; + } + const groupedNotes = await this.groupNotesByDate(notesToGroup, shouldIncludeFutureInGrouping); + const sortedDateKeys = this.getSortedDateKeys(groupedNotes); + await this._ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes); + this._ensureAndUpdateActiveSessionSection(container); + if (!activeListBaseDate) { + await this._ensureAndUpdateUpcomingReviewsSection(container); + } else { + const existingUpcomingSection = container.querySelector(".review-upcoming-section"); + if (existingUpcomingSection) + existingUpcomingSection.remove(); + } + this.updateBulkActionButtonsVisibility(container); + } + // private async _ensureAndUpdateStatsSection(container: HTMLElement, dueNotesForStats: ReviewSchedule[]): Promise { + // let statsEl = container.querySelector(".review-stats-list-view") as HTMLElement; + // if (!statsEl) { + // statsEl = container.createDiv("review-stats-list-view"); + // } + // let statsCountEl = statsEl.querySelector(".review-stats-count") as HTMLElement; + // if (!statsCountEl) { + // statsCountEl = statsEl.createEl("div", { cls: "review-stats-count" }); + // } + // const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfDay()); + // let totalTime = 0; + // for (const note of dueNotesForStats) { + // totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); + // } + // statsCountEl.setText(`${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`); + // } + async _ensureAndUpdateReviewButtonsSection(container, notesForDisplay, selectedNotes) { + let reviewButtonsContainer = container.querySelector(".review-buttons-container"); + if (notesForDisplay.length > 0) { + if (!reviewButtonsContainer) { + reviewButtonsContainer = container.createDiv("review-buttons-container"); + const navButtonsContainer = reviewButtonsContainer.createDiv("review-nav-buttons"); + const prevNoteBtn = navButtonsContainer.createEl("button", { text: "Previous", title: "Navigate to Previous Note", cls: "review-all-button" }); + prevNoteBtn.addEventListener("click", () => { + this.plugin.reviewController.navigateToPreviousNote(); + }); + const nextNoteBtn = navButtonsContainer.createEl("button", { text: "Next", title: "Navigate to Next Note", cls: "review-all-button" }); + nextNoteBtn.addEventListener("click", () => { + this.plugin.reviewController.navigateToNextNote(); + }); + reviewButtonsContainer.createDiv("sidebar-pomodoro-button-container"); + const reviewCurrentBtn = reviewButtonsContainer.createEl("button", { text: "Review Current Note", title: "Review the currently open note if it's due", cls: "review-all-button" }); + reviewCurrentBtn.addEventListener("click", () => { + this.plugin.reviewController.reviewCurrentNote(); + }); + const reviewAllBtn = reviewButtonsContainer.createEl("button", { text: "Review All", title: "Start Reviewing All Due Notes", cls: "review-all-button" }); + reviewAllBtn.addEventListener("click", () => { + this.plugin.reviewController.reviewAllTodaysNotes(); + }); + if (this.plugin.settings.enableMCQ) { + const reviewAllMCQBtn = reviewButtonsContainer.createEl("button", { text: "Review All with MCQs", cls: "review-all-mcq-button" }); + reviewAllMCQBtn.addEventListener("click", () => { + this.plugin.reviewController.reviewAllNotesWithMCQ(true); + }); + } + } + reviewButtonsContainer.style.display = ""; + const pomodoroSectionContainerEl = reviewButtonsContainer.querySelector(".sidebar-pomodoro-button-container"); + if (this.pomodoroUIManager && pomodoroSectionContainerEl) { + this.pomodoroUIManager.attachAndRender(pomodoroSectionContainerEl); + if (this.plugin.settings.pomodoroEnabled) { + this.pomodoroUIManager.showPomodoroSection(true); + this.pomodoroUIManager.updatePomodoroUI(); + } else { + this.pomodoroUIManager.showPomodoroSection(false); + } + } + let bulkActionButtons = container.querySelector(".review-bulk-actions"); + if (!bulkActionButtons) { + bulkActionButtons = container.createDiv("review-bulk-actions"); + const reviewSelectedBtn = bulkActionButtons.createEl("button", { text: "Review Selected", cls: "review-bulk-button" }); + reviewSelectedBtn.addEventListener("click", async () => { + await this.plugin.reviewController.reviewNotes(this.getSelectedNotes(), false); + this.setSelectedNotes([]); + await this.refreshSidebarView(); + }); + const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance Selected", cls: "review-bulk-button review-bulk-advance" }); + advanceSelectedBtn.addEventListener("click", async () => { + const pathsToAdvance = [...this.getSelectedNotes()]; + if (pathsToAdvance.length === 0) { + new import_obsidian14.Notice("No notes selected to advance."); + return; + } + await this.plugin.reviewController.advanceNotes(pathsToAdvance); + this.setSelectedNotes([]); + await this.refreshSidebarView(); + }); + const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone Selected", cls: "review-bulk-button review-bulk-postpone" }); + postponeSelectedBtn.addEventListener("click", async () => { + const pathsToPostpone = [...this.getSelectedNotes()]; + if (pathsToPostpone.length === 0) { + new import_obsidian14.Notice("No notes selected to postpone."); + return; + } + this.setSelectedNotes([]); + await this.plugin.reviewController.postponeNotes(pathsToPostpone); + await this.refreshSidebarView(); + }); + const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove Selected", cls: "review-bulk-button review-bulk-remove" }); + removeSelectedBtn.addEventListener("click", async () => { + const pathsToRemove = [...this.getSelectedNotes()]; + const confirmed = confirm(`Remove ${pathsToRemove.length} selected notes from review schedule?`); + if (!confirmed) + return; + this.setSelectedNotes([]); + await this.plugin.reviewController.removeNotes(pathsToRemove); + await this.plugin.savePluginData(); + await this.refreshSidebarView(); + new import_obsidian14.Notice(`Removed ${pathsToRemove.length} selected notes.`); + }); + } + this.updateBulkActionButtonsVisibility(container); + } else if (reviewButtonsContainer) { + reviewButtonsContainer.style.display = "none"; + const bulkActionButtons = container.querySelector(".review-bulk-actions"); + if (bulkActionButtons) + bulkActionButtons.style.display = "none"; + } + } + _ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate) { + let caughtUpEl = container.querySelector(".review-all-caught-up"); + if (dueNotesForStats.length === 0 && !activeListBaseDate) { + if (!caughtUpEl) { + caughtUpEl = container.createDiv("review-all-caught-up"); + const statsEl = container.querySelector(".review-stats-list-view"); + const buttonsContainer = container.querySelector(".review-buttons-container"); + const anchor = buttonsContainer || statsEl; + if (anchor && anchor.nextSibling) { + container.insertBefore(caughtUpEl, anchor.nextSibling); + } else if (anchor) { + container.appendChild(caughtUpEl); + } else { + container.prepend(caughtUpEl); + } + } + caughtUpEl.setText("All caught up! No notes due for review."); + caughtUpEl.style.display = ""; + } else if (caughtUpEl) { + caughtUpEl.style.display = "none"; + } + } + async _ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes) { + const existingSectionElements = Array.from(container.querySelectorAll(".review-date-section")); + const dataKeysInDom = new Set(existingSectionElements.map((el) => el.dataset.dateKey).filter(Boolean)); + const dataKeysFromData = new Set(sortedDateKeys); + let notesDisplayed = false; + for (const sectionEl of existingSectionElements) { + if (!dataKeysFromData.has(sectionEl.dataset.dateKey)) { + sectionEl.remove(); + } + } + for (const dateStr of sortedDateKeys) { + const notesForSection = groupedNotes[dateStr]; + if (!notesForSection || notesForSection.length === 0) + continue; + notesDisplayed = true; + let dateSectionEl = container.querySelector(`.review-date-section[data-date-key="${dateStr}"]`); + let notesContainerEl; + if (!dateSectionEl) { + dateSectionEl = container.createDiv("review-date-section"); + dateSectionEl.dataset.dateKey = dateStr; + const headerRow = dateSectionEl.createDiv("review-date-header"); + const headerContainer2 = headerRow.createDiv("review-date-header-container"); + headerContainer2.createEl("h3"); + const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const sectionDateKeyIsFuture = !["Due notes", "Today"].includes(dateStr) && (notesForSection[0] && DateUtils.startOfDay(new Date(notesForSection[0].nextReviewDate)) > todayStart); + if (sectionDateKeyIsFuture) { + const advanceAllBtn = headerContainer2.createEl("button", { text: "Advance All", cls: "review-date-action-button review-date-advance-all" }); + advanceAllBtn.title = `Advance all notes in this section by 1 day`; + advanceAllBtn.addEventListener("click", async () => { + const currentNotesForSection = groupedNotes[dateStr] || []; + if (currentNotesForSection.length === 0) + return; + const confirmed = confirm(`Advance all ${currentNotesForSection.length} notes from "${dateStr}" by 1 day? (Only future notes will be affected)`); + if (!confirmed) + return; + const paths = currentNotesForSection.map((note) => note.path); + await this.plugin.reviewController.advanceNotes(paths); + }); + } + const postponeAllBtn = headerContainer2.createEl("button", { text: "Postpone All", cls: "review-date-action-button review-date-postpone-all" }); + postponeAllBtn.title = `Postpone all notes in this section by 1 day`; + postponeAllBtn.addEventListener("click", async () => { + const currentNotesForSection = groupedNotes[dateStr] || []; + if (currentNotesForSection.length === 0) + return; + const daysToPostpone = 1; + const confirmed = confirm(`Postpone all ${currentNotesForSection.length} notes from "${dateStr}" by ${daysToPostpone} day(s)?`); + if (!confirmed) + return; + const paths = currentNotesForSection.map((note) => note.path); + await this.plugin.reviewController.postponeNotes(paths, daysToPostpone); + }); + headerRow.createSpan("review-date-time"); + notesContainerEl = dateSectionEl.createDiv("review-notes-container"); + } else { + notesContainerEl = dateSectionEl.querySelector(".review-notes-container"); + if (!notesContainerEl) { + notesContainerEl = dateSectionEl.createDiv("review-notes-container"); + } + } + dateSectionEl.removeClass("review-date-section-overdue"); + const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const isDefaultTodayView = !this.getActiveListBaseDate(); + if (dateStr === "Due notes") { + dateSectionEl.addClass("review-date-section-overdue"); + } + const headerContainer = dateSectionEl.querySelector(".review-date-header-container"); + const dateHeading = headerContainer.querySelector("h3"); + const reviewTimeEl = dateSectionEl.querySelector(".review-date-time"); + let displayHeader = dateStr; + const noteCountText = `${notesForSection.length} ${notesForSection.length === 1 ? "note" : "notes"}`; + if (dateStr !== "Due notes" && notesForSection.length > 0) { + const actualGroupSampleDate = new Date(notesForSection[0].nextReviewDate); + const formattedActualDate = actualGroupSampleDate.toLocaleDateString(void 0, { month: "short", day: "numeric" }); + if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) { + displayHeader = `${dateStr} (${formattedActualDate})`; + } + displayHeader += ` - ${noteCountText}`; + } else { + displayHeader = `${dateStr} - ${noteCountText}`; + } + dateHeading.setText(displayHeader); + let overdueBadge = dateHeading.querySelector(".review-overdue-badge"); + const todayActualStart = DateUtils.startOfDay(); + const shouldShowOverdueBadge = dateStr === "Due notes"; + if (shouldShowOverdueBadge) { + const overdueNotesInThisSection = notesForSection; + if (overdueNotesInThisSection.length > 0) { + const daysDiff = overdueNotesInThisSection.map((note) => { + const referenceDateForDiff = isDefaultTodayView ? todayActualStart : DateUtils.startOfDay(this.getActiveListBaseDate()); + return Math.floor((referenceDateForDiff - new Date(note.nextReviewDate).getTime()) / (24 * 60 * 60 * 1e3)); + }); + const maxDays = Math.max(0, ...daysDiff.filter((d) => d >= 0 && !isNaN(d))); + if (maxDays > 0) { + if (!overdueBadge) { + overdueBadge = dateHeading.createSpan("review-overdue-badge"); + overdueBadge.style.fontSize = "0.8em"; + overdueBadge.style.fontWeight = "normal"; + overdueBadge.style.color = "var(--text-muted)"; + } + overdueBadge.setText(` (${maxDays} ${maxDays === 1 ? "day" : "days"} overdue)`); + overdueBadge.style.display = ""; + } else if (overdueBadge) { + overdueBadge.style.display = "none"; + } + } else if (overdueBadge) { + overdueBadge.style.display = "none"; + } + } else if (overdueBadge) { + overdueBadge.style.display = "none"; + } + let sectionTime = 0; + for (const note of notesForSection) { + sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); + } + reviewTimeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`); + await this._updateOrRenderNoteList(notesContainerEl, notesForSection, dateStr, container); + } + let noNotesForDateMsg = container.querySelector(".review-no-notes-for-date"); + const activeListBaseDate = this.getActiveListBaseDate(); + if (activeListBaseDate && !notesDisplayed) { + if (!noNotesForDateMsg) { + noNotesForDateMsg = container.createDiv("review-no-notes-for-date"); + } + noNotesForDateMsg.setText(`No notes scheduled on or after ${activeListBaseDate.toLocaleDateString()}.`); + noNotesForDateMsg.style.display = ""; + } else if (noNotesForDateMsg) { + noNotesForDateMsg.style.display = "none"; + } + } + _ensureAndUpdateActiveSessionSection(container) { + const activeSession = this.plugin.reviewSessionService.getActiveSession(); + let sessionSection = container.querySelector(".review-session-section"); + if (activeSession) { + if (!sessionSection) { + sessionSection = container.createDiv("review-session-section"); + sessionSection.createEl("h3", { text: "Active Review Session" }); + const sessionInfo = sessionSection.createDiv("review-session-info"); + sessionInfo.createDiv({ cls: "review-session-name" }); + sessionInfo.createDiv({ cls: "review-session-progress" }); + const progressBarContainer = sessionInfo.createDiv("review-session-progress-bar-container"); + progressBarContainer.createDiv("review-session-progress-bar"); + const continueBtn = sessionSection.createEl("button", { text: "Continue Session", cls: "review-session-continue" }); + continueBtn.addEventListener("click", () => { + }); + const endBtn = sessionSection.createEl("button", { text: "End Session", cls: "review-session-end" }); + endBtn.addEventListener("click", () => { + this.plugin.reviewSessionService.setActiveSession(null); + this.refreshSidebarView(); + }); + } + sessionSection.style.display = ""; + sessionSection.querySelector(".review-session-name").setText(activeSession.name); + sessionSection.querySelector(".review-session-progress").setText(`Progress: ${activeSession.currentIndex}/${activeSession.hierarchy.traversalOrder.length}`); + const progressBar = sessionSection.querySelector(".review-session-progress-bar"); + const progressPercent = Math.min(100, Math.round(activeSession.currentIndex / activeSession.hierarchy.traversalOrder.length * 100)); + progressBar.style.width = `${progressPercent}%`; + } else if (sessionSection) { + sessionSection.style.display = "none"; + } + } + async _ensureAndUpdateUpcomingReviewsSection(container) { + const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules); + const upcomingGroupedNotes = await this.groupNotesByDate(allSchedules, true); + const upcomingKeys = this.getSortedDateKeys(upcomingGroupedNotes).filter((key) => { + if (key === "Due notes") + return false; + const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + if (key === DateUtils.formatDate(actualTodayStart, "relative", null)) + return false; + if (key === DateUtils.formatDate(DateUtils.addDays(actualTodayStart, 1), "relative", null)) + return false; + return true; + }); + let upcomingSection = container.querySelector(".review-upcoming-section"); + if (upcomingKeys.length > 0) { + if (!upcomingSection) { + upcomingSection = container.createDiv("review-upcoming-section"); + upcomingSection.createEl("h3", { text: "Upcoming Reviews" }); + upcomingSection.createDiv("review-upcoming-list"); + } + upcomingSection.style.display = ""; + const upcomingListEl = upcomingSection.querySelector(".review-upcoming-list"); + if (!upcomingListEl) + return; + const existingDayItemElements = Array.from(upcomingListEl.querySelectorAll(".review-upcoming-day")); + const dayKeysInDom = new Set(existingDayItemElements.map((el) => el.dataset.dayKey).filter(Boolean)); + for (const dayItemEl of existingDayItemElements) { + if (!upcomingKeys.includes(dayItemEl.dataset.dayKey)) { + dayItemEl.remove(); + } + } + for (const dayKey of upcomingKeys) { + const notesForDay = upcomingGroupedNotes[dayKey]; + if (!notesForDay || notesForDay.length === 0) { + const staleEmptyDayItem = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`); + if (staleEmptyDayItem) + staleEmptyDayItem.remove(); + continue; + } + let dayItemEl = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`); + if (!dayItemEl) { + dayItemEl = upcomingListEl.createDiv("review-upcoming-day"); + dayItemEl.addClass("clickable"); + dayItemEl.dataset.dayKey = dayKey; + const daySummary = dayItemEl.createDiv("review-upcoming-day-summary"); + daySummary.createEl("span", { cls: "review-upcoming-day-name" }); + dayItemEl.addEventListener("click", async () => { + const currentDayKey = dayItemEl.dataset.dayKey; + if (!currentDayKey) + return; + const expandedUpcomingDayKey = this.getExpandedUpcomingDayKey(); + const isCurrentlyExpanded = expandedUpcomingDayKey === currentDayKey; + this.setExpandedUpcomingDayKey(isCurrentlyExpanded ? null : currentDayKey); + await this.refreshSidebarView(); + }); + } + const daySummaryNameEl = dayItemEl.querySelector(".review-upcoming-day-summary .review-upcoming-day-name"); + let upcomingDisplayHeader = dayKey; + if (notesForDay.length > 0) { + const sampleUpcomingDate = new Date(notesForDay[0].nextReviewDate); + const formattedUpcomingDate = DateUtils.formatDate(sampleUpcomingDate.getTime(), "medium"); + if (["Today", "Tomorrow"].includes(dayKey) || dayKey.startsWith("In ")) { + upcomingDisplayHeader = `${dayKey} (${formattedUpcomingDate})`; + } else { + upcomingDisplayHeader = formattedUpcomingDate; + } + } + if (daySummaryNameEl) + daySummaryNameEl.setText(`${upcomingDisplayHeader}: ${notesForDay.length} ${notesForDay.length === 1 ? "note" : "notes"}`); + const isExpanded = this.getExpandedUpcomingDayKey() === dayKey; + dayItemEl.classList.toggle("is-expanded", isExpanded); + let notesContainerEl = dayItemEl.querySelector(".review-upcoming-notes-container"); + if (isExpanded) { + if (!notesContainerEl) { + notesContainerEl = dayItemEl.createDiv("review-upcoming-notes-container"); + } + notesContainerEl.style.display = ""; + await this._updateOrRenderNoteList(notesContainerEl, notesForDay, dayKey, container); + } else if (notesContainerEl) { + notesContainerEl.style.display = "none"; + } + } + } else if (upcomingSection) { + upcomingSection.style.display = "none"; + } + } + /** + * Renders or updates a list of notes within a given container. + */ + async _updateOrRenderNoteList(notesContainer, notes, dateStr, parentContainerForBulkActions) { + const existingNoteElements = Array.from(notesContainer.querySelectorAll(".review-note-item[data-note-path]")); + const existingNotesMap = new Map(existingNoteElements.map((el) => [el.dataset.notePath, el])); + const notesInOrder = []; + const lastSelectedNotePath = this.getLastSelectedNotePath(); + const lastSelectedNotePathRef = { current: lastSelectedNotePath }; + for (const note of notes) { + let noteEl = existingNotesMap.get(note.path); + if (noteEl) { + await this.noteItemRenderer.updateNoteItem( + noteEl, + note, + dateStr, + this.getSelectedNotes() + ); + existingNotesMap.delete(note.path); + } else { + noteEl = await this.noteItemRenderer.renderNoteItem( + notesContainer, + // Temporarily append here, will be reordered + note, + dateStr, + parentContainerForBulkActions, + this.getSelectedNotes(), + lastSelectedNotePathRef, + () => this.handleSelectionChange(parentContainerForBulkActions), + this.handleNoteAction.bind(this) + ); + } + if (noteEl) + notesInOrder.push(noteEl); + } + for (const staleNoteEl of existingNotesMap.values()) { + staleNoteEl.remove(); + } + notesContainer.empty(); + for (const noteEl of notesInOrder) { + notesContainer.appendChild(noteEl); + } + this.setLastSelectedNotePath(lastSelectedNotePathRef.current); + } + /** + * Callback function passed to NoteItemRenderer to handle UI updates after selection changes. + */ + handleSelectionChange(container) { + this.updateSelectionClasses(container); + this.updateBulkActionButtonsVisibility(container); + } + /** + * Callback function passed to NoteItemRenderer for actions (like postpone, remove) + * that require a broader UI update (potentially a full refresh or targeted updates). + */ + async handleNoteAction() { + await this.refreshSidebarView(); + } + /** + * Updates the 'selected' class on note items based on the selectedNotes array. + * (Called by handleSelectionChange) + */ + updateSelectionClasses(container) { + const selectedNotes = this.getSelectedNotes(); + const allNoteElements = container.querySelectorAll(".review-note-item[data-note-path]"); + allNoteElements.forEach((el) => { + const path = el.dataset.notePath; + if (path && selectedNotes.includes(path)) { + el.classList.add("selected"); + } else { + el.classList.remove("selected"); + } + }); + } + /** + * Updates the visibility of the bulk action buttons based on selection count. + * (Called by handleSelectionChange and render) + */ + updateBulkActionButtonsVisibility(container) { + const selectedNotesPaths = this.getSelectedNotes(); + const bulkActionsContainer = container.querySelector(".review-bulk-actions"); + if (bulkActionsContainer) { + bulkActionsContainer.style.display = selectedNotesPaths.length > 1 ? "flex" : "none"; + const advanceSelectedBtn = bulkActionsContainer.querySelector(".review-bulk-advance"); + if (advanceSelectedBtn) { + if (selectedNotesPaths.length > 1) { + const todayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const hasEligibleFutureNote = selectedNotesPaths.some((path) => { + const schedule = this.plugin.reviewScheduleService.schedules[path]; + return schedule && DateUtils.startOfDay(new Date(schedule.nextReviewDate)) > todayStart; + }); + advanceSelectedBtn.disabled = !hasEligibleFutureNote; + advanceSelectedBtn.style.display = ""; + } else { + advanceSelectedBtn.disabled = true; + } + } + } + } + /** + * Updates the main header statistics display. + */ + async updateHeaderStats(container) { + const headerStatsEl = container.querySelector(".review-stats-list-view .review-stats-count"); + if (!headerStatsEl) + return; + const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true); + const overdueNotes = dueNotesForStats.filter((note) => note.nextReviewDate < DateUtils.startOfDay()); + let totalTime = 0; + for (const note of dueNotesForStats) { + totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); + } + const statsText = `${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ""}`; + headerStatsEl.textContent = statsText; + const reviewButtonsContainer = container.querySelector(".review-buttons-container"); + if (reviewButtonsContainer) { + reviewButtonsContainer.style.display = dueNotesForStats.length > 0 ? "" : "none"; + } + this.updateBulkActionButtonsVisibility(container); + const allCaughtUpEl = container.querySelector(".review-all-caught-up"); + const notesInCurrentContext = this.plugin.reviewController.getTodayNotes(); + if (allCaughtUpEl) { + allCaughtUpEl.style.display = notesInCurrentContext.length === 0 ? "" : "none"; + if (notesInCurrentContext.length === 0) { + allCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review."); + } + } else if (notesInCurrentContext.length === 0) { + const buttonsContainer = container.querySelector(".review-buttons-container"); + const newCaughtUpEl = container.createDiv("review-all-caught-up"); + newCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review."); + const anchor = buttonsContainer; + if (anchor && anchor.nextSibling) { + container.insertBefore(newCaughtUpEl, anchor.nextSibling); + } else if (anchor) { + container.appendChild(newCaughtUpEl); + } else { + container.prepend(newCaughtUpEl); + } + } + } + /** + * Updates the count and estimated time for a specific date section header, or removes the section if empty. + */ + async updateSectionCounts(sectionEl, container) { + if (!sectionEl || !container || !sectionEl.parentElement) + return; + const notesInSection = Array.from(sectionEl.querySelectorAll(".review-note-item[data-note-path]")); + const count = notesInSection.length; + if (count === 0) { + sectionEl.remove(); + } else { + const headerTextEl = sectionEl.querySelector(".review-date-header-container h3"); + const timeEl = sectionEl.querySelector(".review-date-time"); + const dateStr = sectionEl.dataset.dateKey; + if (headerTextEl && timeEl && dateStr) { + let sectionTime = 0; + for (const noteEl of notesInSection) { + const path = noteEl.dataset.notePath; + if (path) { + sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(path); + } + } + timeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`); + let displayHeader = dateStr; + const noteCountText = `${count} ${count === 1 ? "note" : "notes"}`; + const firstNotePath = notesInSection[0].dataset.notePath; + const schedule = firstNotePath ? this.plugin.reviewScheduleService.schedules[firstNotePath] : null; + if (dateStr !== "Due notes" && schedule) { + const actualGroupSampleDate = new Date(schedule.nextReviewDate); + const formattedActualDate = actualGroupSampleDate.toLocaleDateString(void 0, { month: "short", day: "numeric" }); + if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) { + displayHeader = `${dateStr} (${formattedActualDate})`; + } + displayHeader += ` - ${noteCountText}`; + } else { + displayHeader = `${dateStr} - ${noteCountText}`; + } + headerTextEl.textContent = displayHeader; + let overdueBadge = headerTextEl.querySelector(".review-overdue-badge"); + if (dateStr === "Due notes") { + const daysDiff = await Promise.all(notesInSection.map(async (noteEl) => { + const path = noteEl.dataset.notePath; + const noteSchedule = path ? this.plugin.reviewScheduleService.schedules[path] : null; + return noteSchedule ? Math.abs(Math.floor((noteSchedule.nextReviewDate - DateUtils.startOfDay()) / (24 * 60 * 60 * 1e3))) : 0; + })); + const maxDays = Math.max(0, ...daysDiff.filter((d) => !isNaN(d))); + if (maxDays > 0) { + const badgeText = ` (${maxDays} ${maxDays === 1 ? "day" : "days"} overdue)`; + if (!overdueBadge) { + overdueBadge = headerTextEl.createSpan("review-overdue-badge"); + overdueBadge.style.fontSize = "0.8em"; + overdueBadge.style.fontWeight = "normal"; + overdueBadge.style.color = "var(--text-muted)"; + } + overdueBadge.setText(badgeText); + } else if (overdueBadge) { + overdueBadge.remove(); + } + } else if (overdueBadge) { + overdueBadge.remove(); + } + } + } + await this.updateHeaderStats(container); + } + /** + * Updates counts for all date sections currently in the DOM. + */ + async updateAllSectionCounts(container) { + const sections = container.querySelectorAll(".review-date-section"); + for (const section of Array.from(sections)) { + await this.updateSectionCounts(section, container); + } + await this.updateHeaderStats(container); + } + /** + * Group notes by their review date, considering activeListBaseDate. + */ + async groupNotesByDate(notes, includeFuture = false) { + const grouped = {}; + const actualTodayStart = DateUtils.startOfDay(/* @__PURE__ */ new Date()); + const activeListBaseDate = this.getActiveListBaseDate(); + const refDateForFilteringStart = activeListBaseDate ? DateUtils.startOfDay(new Date(activeListBaseDate)) : actualTodayStart; + for (const note of notes) { + const noteDate = new Date(note.nextReviewDate); + const noteDateStart = DateUtils.startOfDay(noteDate); + if (activeListBaseDate) { + if (noteDateStart !== refDateForFilteringStart) { + continue; + } + } + let dateStr = DateUtils.formatDate(note.nextReviewDate, "relative", activeListBaseDate); + if (!grouped[dateStr]) { + grouped[dateStr] = []; + } + grouped[dateStr].push(note); + } + return grouped; + } + /** + * Get sorted date keys in the preferred display order: Due notes, Today, Tomorrow, future dates. + */ + getSortedDateKeys(groupedNotes) { + const keys = Object.keys(groupedNotes); + const dateOrder = { "Due notes": 0, "Today": 1, "Tomorrow": 2 }; + return keys.sort((a, b2) => { + const aIsSpecial = a in dateOrder; + const bIsSpecial = b2 in dateOrder; + if (aIsSpecial && bIsSpecial) + return dateOrder[a] - dateOrder[b2]; + if (aIsSpecial) + return -1; + if (bIsSpecial) + return 1; + const numAMatch = a.match(/^In (\d+) days$/); + const numBMatch = b2.match(/^In (\d+) days$/); + if (numAMatch && numBMatch) + return parseInt(numAMatch[1]) - parseInt(numBMatch[1]); + if (numAMatch) + return -1; + if (numBMatch) + return 1; + return a.localeCompare(b2); + }); + } +}; + +// ui/calendar-view.ts +var import_obsidian15 = require("obsidian"); +var CalendarView = class { + /** + * Initialize calendar view + * + * @param containerEl Container element + * @param plugin Reference to the main plugin + */ + constructor(containerEl, plugin) { + /** + * Reviews grouped by date + */ + this.reviewsByDate = /* @__PURE__ */ new Map(); + // Persistent UI elements + this.calendarHeaderEl = null; + this.monthTitleEl = null; + this.calendarGridEl = null; + this.containerEl = containerEl; + this.plugin = plugin; + this.currentDate = /* @__PURE__ */ new Date(); + } + /** + * Render the calendar view + */ + async render() { + this.ensureCalendarBaseStructure(); + this.updateCalendarHeader(); + await this.loadReviewsData(); + if (this.calendarGridEl) { + this.renderCalendarGridContent(this.calendarGridEl); + } + } + ensureCalendarBaseStructure() { + var _a, _b; + if (!this.containerEl) + return; + let calendarContainer = this.containerEl.querySelector(".calendar-container"); + if (!calendarContainer) { + calendarContainer = this.containerEl.createDiv("calendar-container"); + } + if (!this.calendarHeaderEl || !calendarContainer.contains(this.calendarHeaderEl)) { + (_a = this.calendarHeaderEl) == null ? void 0 : _a.remove(); + this.calendarHeaderEl = calendarContainer.createDiv("calendar-header"); + const prevMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn"); + (0, import_obsidian15.setIcon)(prevMonthBtn, "chevron-left"); + prevMonthBtn.addEventListener("click", () => { + this.currentDate.setMonth(this.currentDate.getMonth() - 1); + this.render(); + }); + this.monthTitleEl = this.calendarHeaderEl.createDiv("calendar-month-title"); + const nextMonthBtn = this.calendarHeaderEl.createDiv("calendar-nav-btn"); + (0, import_obsidian15.setIcon)(nextMonthBtn, "chevron-right"); + nextMonthBtn.addEventListener("click", () => { + this.currentDate.setMonth(this.currentDate.getMonth() + 1); + this.render(); + }); + const todayBtn = this.calendarHeaderEl.createDiv("calendar-today-btn"); + todayBtn.setText("Today"); + todayBtn.addEventListener("click", () => { + this.currentDate = /* @__PURE__ */ new Date(); + this.render(); + }); + } + if (!this.calendarGridEl || !calendarContainer.contains(this.calendarGridEl)) { + (_b = this.calendarGridEl) == null ? void 0 : _b.remove(); + this.calendarGridEl = calendarContainer.createDiv("calendar-grid"); + } + } + /** + * Update calendar header (month title) + */ + updateCalendarHeader() { + if (this.monthTitleEl) { + this.monthTitleEl.setText( + this.currentDate.toLocaleString("default", { + month: "long", + year: "numeric" + }) + ); + } + } + /** + * Load and organize review data by date + */ + async loadReviewsData() { + this.reviewsByDate = /* @__PURE__ */ new Map(); + const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules); + for (const schedule of allSchedules) { + const scheduleDueDayStart = DateUtils.startOfDay(new Date(schedule.nextReviewDate)); + const dateKey = scheduleDueDayStart.toString(); + if (!this.reviewsByDate.has(dateKey)) { + this.reviewsByDate.set(dateKey, { + timestamp: scheduleDueDayStart, + // Store the start of day timestamp + notes: [], + totalTime: 0 + }); + } + const dateReviews = this.reviewsByDate.get(dateKey); + if (dateReviews) { + dateReviews.notes.push(schedule); + dateReviews.totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(schedule.path); + } + } + } + /** + * Render or update the calendar grid content + * + * @param gridEl The calendar grid element to populate + */ + renderCalendarGridContent(gridEl) { + if (!gridEl.querySelector(".calendar-weekday")) { + const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + weekdays.forEach((day) => { + const dayHeader = gridEl.createDiv("calendar-weekday"); + dayHeader.setText(day); + }); + } + const { year, month, firstDay, daysInMonth } = this.getCalendarData(); + const totalCells = 42; + let dayCells = Array.from(gridEl.querySelectorAll(".calendar-day")); + if (dayCells.length < totalCells) { + for (let i = dayCells.length; i < totalCells; i++) { + dayCells.push(gridEl.createDiv("calendar-day")); + } + } else if (dayCells.length > totalCells) { + for (let i = totalCells; i < dayCells.length; i++) { + dayCells[i].remove(); + } + dayCells = dayCells.slice(0, totalCells); + } + let dayOfMonth = 1; + for (let i = 0; i < totalCells; i++) { + const dayCell = dayCells[i]; + dayCell.empty(); + dayCell.className = "calendar-day"; + dayCell.removeAttribute("data-date-key"); + dayCell.onclick = null; + if (i >= firstDay && dayOfMonth <= daysInMonth) { + const currentDateObj = new Date(year, month, dayOfMonth); + const cellDayStart = DateUtils.startOfDay(currentDateObj); + const dateKey = cellDayStart.toString(); + dayCell.dataset.dateKey = dateKey; + const dayNumber = dayCell.createDiv("calendar-day-number"); + dayNumber.setText(dayOfMonth.toString()); + if (this.isToday(year, month, dayOfMonth)) { + dayCell.addClass("today"); + } + const dateReviews = this.reviewsByDate.get(dateKey); + if (dateReviews && dateReviews.notes.length > 0) { + dayCell.addClass("has-reviews"); + const reviewCount = dayCell.createDiv("calendar-review-count"); + reviewCount.setText(dateReviews.notes.length.toString()); + const timeEstimate = dayCell.createDiv("calendar-time-estimate"); + timeEstimate.setText(EstimationUtils.formatTime(dateReviews.totalTime)); + dayCell.addEventListener("click", async () => { + const today = /* @__PURE__ */ new Date(); + const isClickedDateToday = DateUtils.isSameDay(currentDateObj, today); + this.plugin.settings.sidebarViewType = "list"; + if (isClickedDateToday) { + this.plugin.clickedDateFromCalendar = null; + } else { + this.plugin.clickedDateFromCalendar = currentDateObj; + } + await this.plugin.savePluginData(); + if (this.plugin.sidebarView && typeof this.plugin.sidebarView.refresh === "function") { + await this.plugin.sidebarView.refresh(); + } else { + this.plugin.app.workspace.requestSaveLayout(); + new import_obsidian15.Notice("Switched to list view. Sidebar will update."); + } + }); + if (dateReviews.notes.length > 10) + dayCell.addClass("heavy-load"); + else if (dateReviews.notes.length > 5) + dayCell.addClass("medium-load"); + else + dayCell.addClass("light-load"); + } + dayOfMonth++; + } else { + dayCell.addClass("empty"); + } + } + } + /** + * Get calendar data for the current month + * + * @returns Calendar data object + */ + getCalendarData() { + const year = this.currentDate.getFullYear(); + const month = this.currentDate.getMonth(); + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + return { year, month, firstDay, daysInMonth }; + } + /** + * Check if a date is today + * + * @param year Year + * @param month Month + * @param day Day + * @returns True if the date is today + */ + isToday(year, month, day) { + const today = /* @__PURE__ */ new Date(); + return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; + } +}; + +// ui/sidebar-view.ts +var ReviewSidebarView = class extends import_obsidian16.ItemView { + constructor(leaf, plugin) { + super(leaf); + this.activeListBaseDate = null; + this.selectedNotes = []; + this.lastSelectedNotePath = null; + this.lastScrollPosition = 0; + this.expandedUpcomingDayKey = null; + this.listViewRenderer = null; + // Persistent UI elements + this.mainContainer = null; + this.persistentHeaderEl = null; + this.listViewContentEl = null; + this.calendarViewContentEl = null; + this.plugin = plugin; + this.noteItemRenderer = new NoteItemRenderer(this.plugin); + } + getViewType() { + return "spaceforge-review-schedule"; + } + getDisplayText() { + return "Spaceforge Review"; + } + getIcon() { + return "calendar-clock"; + } + async onOpen() { + this.plugin.events.on("sidebar-update", this.refresh.bind(this)); + this.plugin.events.on("pomodoro-update", () => { + if (this.pomodoroUIManager) { + this.pomodoroUIManager.updatePomodoroUI(); + } + }); + await this.refresh(); + } + async onClose() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + this.plugin.events.off("sidebar-update", this.refresh.bind(this)); + } + ensureBaseStructure() { + const contentEl = this.containerEl || this.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"); + } + if (!this.pomodoroUIManager) { + this.pomodoroUIManager = new PomodoroUIManager(this.plugin); + } + if (!this.listViewRenderer) { + this.listViewRenderer = new ListViewRenderer(this.plugin, this.pomodoroUIManager, this.noteItemRenderer, { + getActiveListBaseDate: () => this.activeListBaseDate, + getSelectedNotes: () => this.selectedNotes, + setSelectedNotes: (notes) => { + this.selectedNotes = notes; + }, + getExpandedUpcomingDayKey: () => this.expandedUpcomingDayKey, + setExpandedUpcomingDayKey: (key) => { + this.expandedUpcomingDayKey = key; + }, + getLastSelectedNotePath: () => this.lastSelectedNotePath, + setLastSelectedNotePath: (path) => { + this.lastSelectedNotePath = path; + }, + refreshSidebarView: this.refresh.bind(this) + }); + } + if (!this.calendarViewContentEl) { + this.calendarViewContentEl = this.mainContainer.createDiv("calendar-view-content"); + } + if (!this.calendarView && this.calendarViewContentEl) { + this.calendarView = new CalendarView(this.calendarViewContentEl, this.plugin); + } + } + updateViewToggleButtonsState() { + if (!this.persistentHeaderEl) + return; + const viewToggle = this.persistentHeaderEl.querySelector(".review-view-toggle"); + if (!viewToggle) + return; + const listViewBtn = viewToggle.children[0]; + const calendarViewBtn = viewToggle.children[1]; + if (listViewBtn) + listViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === "list"); + if (calendarViewBtn) + calendarViewBtn.classList.toggle("active", this.plugin.settings.sidebarViewType === "calendar"); + } + async refresh() { + const previousActiveListBaseDateEpoch = this.activeListBaseDate ? DateUtils.startOfDay(this.activeListBaseDate) : null; + let newTargetDate = this.activeListBaseDate; + if (this.plugin.clickedDateFromCalendar) { + newTargetDate = this.plugin.clickedDateFromCalendar; + this.plugin.clickedDateFromCalendar = null; + if (this.plugin.settings.sidebarViewType !== "list") { + this.plugin.settings.sidebarViewType = "list"; + await this.plugin.savePluginData(); + } + } + const newTargetDateEpoch = newTargetDate ? DateUtils.startOfDay(newTargetDate) : null; + let reviewDateChanged = false; + if (newTargetDateEpoch !== previousActiveListBaseDateEpoch) { + this.activeListBaseDate = newTargetDate; + reviewDateChanged = true; + } + const currentControllerOverrideEpoch = this.plugin.reviewController.getCurrentReviewDateOverride(); + const targetControllerOverrideValue = this.activeListBaseDate ? DateUtils.startOfDay(this.activeListBaseDate) : null; + if (targetControllerOverrideValue !== currentControllerOverrideEpoch) { + await this.plugin.reviewController.setReviewDateOverride(targetControllerOverrideValue); + if (!reviewDateChanged) { + reviewDateChanged = true; + } + } + this.ensureBaseStructure(); + let storedScrollPosition = this.lastScrollPosition; + if (this.mainContainer) { + storedScrollPosition = this.mainContainer.scrollTop; + } + this.updateViewToggleButtonsState(); + await this.showCorrectViewPane(); + if (this.mainContainer) { + requestAnimationFrame(() => { + if (this.mainContainer) + this.mainContainer.scrollTop = storedScrollPosition; + this.lastScrollPosition = storedScrollPosition; + }); + } + } + async showCorrectViewPane() { + if (this.plugin.settings.sidebarViewType === "calendar") { + if (this.listViewContentEl) + this.listViewContentEl.hide(); + if (this.calendarViewContentEl) { + this.calendarViewContentEl.show(); + await this.renderCalendarViewContent(this.calendarViewContentEl); + } + } else { + if (this.calendarViewContentEl) + this.calendarViewContentEl.hide(); + if (this.listViewContentEl) { + this.listViewContentEl.show(); + await this.renderListViewContent(this.listViewContentEl); + } + } + } + /** + * Render the list view content by delegating to ListViewRenderer. + */ + async renderListViewContent(container) { + if (this.listViewRenderer) { + await this.listViewRenderer.render(container); + } else { + console.error("ListViewRenderer not initialized during renderListViewContent call!"); + container.setText("Error: Could not render list view. Renderer not ready."); + } + } + /** + * Render the calendar view content + */ + async renderCalendarViewContent(container) { + if (this.calendarView) { + await this.calendarView.render(); + } else { + console.error("CalendarView not initialized during renderCalendarViewContent call!"); + container.setText("Error: Could not render calendar view. CalendarView not ready."); + return; + } + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target === this.containerEl) { + this.updateCalendarContainerClass(); + } + } + }); + if (this.containerEl) { + resizeObserver.observe(this.containerEl); + this.resizeObserver = resizeObserver; + this.updateCalendarContainerClass(); + } + } + updateCalendarContainerClass() { + if (!this.containerEl || !this.calendarViewContentEl) + return; + const calendarContainer = this.calendarViewContentEl.querySelector(".calendar-container"); + if (calendarContainer) { + const sidebarWidth = this.containerEl.clientWidth; + const collapsedThreshold = 300; + if (sidebarWidth < collapsedThreshold) { + calendarContainer.classList.add("is-collapsed"); + } else { + calendarContainer.classList.remove("is-collapsed"); + } + } + } + // --- Methods moved out --- + // --- Existing methods kept for view lifecycle / state --- + /** + * Move a note up in the list + * (Existing Method - Consider moving to controller) + */ + async moveNoteUp(dateStr, note) { + if (!this.listViewRenderer) { + console.error("Cannot move note up: ListViewRenderer not initialized."); + return; + } + const notes = await this.listViewRenderer.groupNotesByDate( + this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true), + false + ); + const dateNotes = notes[dateStr]; + if (!dateNotes) + return; + const index = dateNotes.findIndex((n) => n.path === note.path); + if (index <= 0) + return; + const path1 = dateNotes[index].path; + const path2 = dateNotes[index - 1].path; + await this.plugin.reviewController.swapNotes(path1, path2); + await this.refresh(); + new import_obsidian16.Notice(`Moved note up`); + } + /** + * Move a note down in the list + * (Existing Method - Consider moving to controller) + */ + async moveNoteDown(dateStr, note) { + if (!this.listViewRenderer) { + console.error("Cannot move note down: ListViewRenderer not initialized."); + return; + } + const notes = await this.listViewRenderer.groupNotesByDate( + this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true), + false + ); + const dateNotes = notes[dateStr]; + if (!dateNotes) + return; + const index = dateNotes.findIndex((n) => n.path === note.path); + if (index < 0 || index >= dateNotes.length - 1) + return; + const path1 = dateNotes[index].path; + const path2 = dateNotes[index + 1].path; + await this.plugin.reviewController.swapNotes(path1, path2); + await this.refresh(); + new import_obsidian16.Notice(`Moved note down`); + } + /** + * Group notes by their folder + * (Existing Method - Consider moving to utility/service) + */ + async groupNotesByFolder(notes) { + var _a; + const grouped = {}; + for (const note of notes) { + const file = this.plugin.app.vault.getAbstractFileByPath(note.path); + const folderPath = ((_a = file == null ? void 0 : file.parent) == null ? void 0 : _a.path) || "/"; + if (!grouped[folderPath]) { + grouped[folderPath] = []; + } + grouped[folderPath].push(note); + } + return grouped; + } + // --- State Management for Obsidian View Lifecycle --- + getViewState() { + if (this.mainContainer) { + this.lastScrollPosition = this.mainContainer.scrollTop; + } + return { + activeListBaseDateISO: this.activeListBaseDate ? this.activeListBaseDate.toISOString() : null, + // isPomodoroSectionOpen: isPomodoroOpen, // Removed + expandedUpcomingDayKey: this.expandedUpcomingDayKey, + selectedNotes: this.selectedNotes, + lastScrollPosition: this.lastScrollPosition, + sidebarViewType: this.plugin.settings.sidebarViewType + }; + } + async setViewState(state, e) { + var _a, _b, _c; + if (!state) + return; + this.activeListBaseDate = state.activeListBaseDateISO ? new Date(state.activeListBaseDateISO) : null; + this.expandedUpcomingDayKey = (_a = state.expandedUpcomingDayKey) != null ? _a : null; + this.selectedNotes = (_b = state.selectedNotes) != null ? _b : []; + this.lastScrollPosition = (_c = state.lastScrollPosition) != null ? _c : 0; + if (state.sidebarViewType) { + this.plugin.settings.sidebarViewType = state.sidebarViewType; + } + await this.refresh(); + } +}; + +// ui/settings-tab.ts +var import_obsidian17 = require("obsidian"); + +// models/settings.ts +var DEFAULT_SETTINGS = { + showNavigationNotifications: true, + baseEase: 250, + // 2.5 in SM-2 format (recommended default from the original algorithm) + loadBalance: false, + // Disable load balancing by default for pure SM-2 compliance + maximumInterval: 365, + // Cap at 1 year (extension to original SM-2) + useCustomDataPath: false, + customDataPath: "", + // autoNextNote property removed, now always true by default + notifyBeforeDue: 120, + includeSubfolders: true, + readingSpeed: 100, + // Default WPM set to 100 + sidebarViewType: "calendar", + useInitialSchedule: true, + initialScheduleCustomIntervals: [0, 3, 7, 14, 30], + // MCQ settings + enableMCQ: true, + mcqApiProvider: "ollama" /* Ollama */, + // Default API provider set to Ollama + openRouterApiKey: "", + openRouterModel: "openai/gpt-4.1-mini", + openaiApiKey: "", + openaiModel: "gpt-3.5-turbo", + ollamaApiUrl: "", + ollamaModel: "", + geminiApiKey: "", + geminiModel: "gemini-pro", + claudeApiKey: "", + claudeModel: "claude-3-sonnet-20240229", + togetherApiKey: "", + togetherModel: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + mcqPromptType: "detailed", + mcqQuestionsPerNote: 4, + mcqChoicesPerQuestion: 5, + mcqQuestionAmountMode: "fixed" /* Fixed */, + // Default to fixed number + mcqWordsPerQuestion: 100, + // Default words per question if mode is switched + mcqTimeDeductionAmount: 0.5, + mcqTimeDeductionSeconds: 90, + mcqDifficulty: "advanced" /* Advanced */, + mcqBasicSystemPrompt: "You are a tutor who creates clear, straightforward multiple-choice questions to test basic understanding of the given content. Focus on key concepts and important facts. Make questions simple and direct, with one clearly correct answer. Always mark the correct answer with [CORRECT] at the end of the line.", + mcqAdvancedSystemPrompt: "You are an expert tutor who creates challenging but fair multiple-choice questions to test deep understanding of the given content. Generate questions that assess comprehension, application, and analysis, not just memorization. Make incorrect choices plausible to encourage critical thinking. Always mark the correct answer with [CORRECT] at the end of the line.", + // Pomodoro Timer Defaults + pomodoroEnabled: true, + pomodoroSoundEnabled: true, + pomodoroWorkDuration: 25, + pomodoroShortBreakDuration: 5, + pomodoroLongBreakDuration: 15, + pomodoroSessionsUntilLongBreak: 4, + // MCQ Question Regeneration Settings + enableQuestionRegenerationOnRating: false, + minSm2RatingForQuestionRegeneration: 4, + // SM-2: 0 (Blackout) to 5 (Perfect Recall) - Defaulting to 4 (Correct with Hesitation) + minFsrsRatingForQuestionRegeneration: 3, + // FSRS: 1 (Again) to 4 (Easy) - Defaulting to 3 (Good) + // Algorithm Defaults + defaultSchedulingAlgorithm: "fsrs", + fsrsParameters: { + request_retention: 0.9, + maximum_interval: 36500, + enable_fuzz: true, + // Default FSRS weights from FSRS-4.5-Anki + w: [ + 0.4, + 0.6, + 2.4, + 5.8, + 4.93, + 0.94, + 0.86, + 0.01, + 1.49, + 0.14, + 0.94, + 2.18, + 0.05, + 0.34, + 1.26, + 0.29, + 2.61 + ], + learning_steps: [1, 10], + // 1 minute, 10 minutes + enable_short_term: false + // Default for enable_short_term + } +}; + +// models/plugin-data.ts +var DEFAULT_PLUGIN_STATE_DATA = { + schedules: {}, + history: [], + reviewSessions: { sessions: {}, activeSessionId: null }, + mcqSets: {}, + mcqSessions: {}, + customNoteOrder: [], + lastLinkAnalysisTimestamp: null, + version: "0.0.0", + // This will be updated from plugin.manifest.version on save + // Pomodoro Timer State Defaults + pomodoroCurrentMode: "idle", + pomodoroTimeLeftInSeconds: 25 * 60, + // Default to work duration + pomodoroSessionsCompletedInCycle: 0, + pomodoroIsRunning: false, + pomodoroEndTimeMs: null +}; +var DEFAULT_APP_DATA = { + settings: DEFAULT_SETTINGS, + pluginState: DEFAULT_PLUGIN_STATE_DATA +}; + +// ui/settings-tab.ts +var SpaceforgeSettingTab = class extends import_obsidian17.PluginSettingTab { + /** + * Initialize settings tab + * + * @param app Obsidian app + * @param plugin Reference to the main plugin + */ + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + /** + * Display settings + */ + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "Spaceforge Settings" }); + const createCollapsible = (title, iconName, defaultOpen = true) => { + const sectionContainer = containerEl.createEl("div", { cls: "sf-settings-section" }); + const header = sectionContainer.createEl("div", { cls: "sf-settings-section-header" }); + if (iconName) { + const iconEl = header.createEl("span", { cls: "sf-settings-icon" }); + (0, import_obsidian17.setIcon)(iconEl, iconName); + } + header.createEl("h3", { text: title }); + const collapseIndicator = header.createEl("span", { + cls: "sf-settings-collapse-indicator", + text: defaultOpen ? "\u25BE" : "\u25B8" + }); + const contentContainer = sectionContainer.createEl("div", { + cls: "sf-settings-section-content" + }); + if (!defaultOpen) { + contentContainer.style.display = "none"; + } + header.addEventListener("click", () => { + const isVisible = contentContainer.style.display !== "none"; + contentContainer.style.display = isVisible ? "none" : "block"; + collapseIndicator.textContent = isVisible ? "\u25B8" : "\u25BE"; + }); + return contentContainer; + }; + const createActionButtons = () => { + const actionsContainer = containerEl.createEl("div", { cls: "sf-settings-actions" }); + const exportBtn = actionsContainer.createEl("button", { text: "Export All Data" }); + exportBtn.addEventListener("click", () => { + var _a; + const pluginStateToExport = { + schedules: this.plugin.reviewScheduleService.schedules, + history: this.plugin.reviewHistoryService.history, + reviewSessions: this.plugin.reviewSessionService.reviewSessions, + mcqSets: this.plugin.mcqService.mcqSets, + mcqSessions: this.plugin.mcqService.mcqSessions, + customNoteOrder: this.plugin.reviewScheduleService.customNoteOrder, + lastLinkAnalysisTimestamp: this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp, + version: this.plugin.manifest.version, + pomodoroCurrentMode: this.plugin.pluginState.pomodoroCurrentMode, + pomodoroTimeLeftInSeconds: this.plugin.pluginState.pomodoroTimeLeftInSeconds, + pomodoroSessionsCompletedInCycle: this.plugin.pluginState.pomodoroSessionsCompletedInCycle, + pomodoroIsRunning: this.plugin.pluginState.pomodoroIsRunning, + pomodoroEndTimeMs: (_a = this.plugin.pluginState.pomodoroEndTimeMs) != null ? _a : null + // Add the missing field + }; + const dataToExport = { + settings: this.plugin.settings, + pluginState: pluginStateToExport + }; + const dataJson = JSON.stringify(dataToExport, null, 2); + const blob = new Blob([dataJson], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "spaceforge-data.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + new import_obsidian17.Notice("All plugin data exported successfully"); + }); + const importBtn = actionsContainer.createEl("button", { text: "Import All Data" }); + importBtn.addEventListener("click", () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json"; + input.onchange = async (e) => { + var _a, _b, _c; + const file = (_a = e.target.files) == null ? void 0 : _a[0]; + if (file) { + try { + const text = await file.text(); + const importedFullData = JSON.parse(text); + if (!importedFullData || typeof importedFullData.settings !== "object" || typeof importedFullData.pluginState !== "object") { + throw new Error("Invalid data file format. Expected settings and pluginState properties."); + } + this.plugin.settings = { ...DEFAULT_SETTINGS, ...importedFullData.settings }; + this.plugin.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA, ...importedFullData.pluginState }; + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules || {}; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history || []; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions || { sessions: {}, activeSessionId: null }; + this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets || {}; + this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions || {}; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder || []; + this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.plugin.pluginState.lastLinkAnalysisTimestamp === "number" ? this.plugin.pluginState.lastLinkAnalysisTimestamp : null; + (_b = this.plugin.pomodoroService) == null ? void 0 : _b.onSettingsChanged(); + (_c = this.plugin.pomodoroService) == null ? void 0 : _c.reinitializeTimerFromState(); + this.plugin.initializeMCQComponents(); + await this.plugin.savePluginData(); + this.display(); + new import_obsidian17.Notice("All plugin data imported successfully. Plugin may require a reload for all changes to take effect."); + } catch (error) { + console.error("Failed to import data:", error); + new import_obsidian17.Notice(`Failed to import data: ${error.message}`); + } + } + }; + input.click(); + }); + const resetBtn = actionsContainer.createEl("button", { text: "Reset to Defaults" }); + resetBtn.addEventListener("click", async () => { + var _a, _b, _c; + const confirmed = confirm("Are you sure you want to reset all plugin data (settings and state) to defaults? This cannot be undone."); + if (confirmed) { + this.plugin.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + this.plugin.pluginState = JSON.parse(JSON.stringify(DEFAULT_PLUGIN_STATE_DATA)); + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions; + this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets; + this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder; + this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = (_a = this.plugin.pluginState.lastLinkAnalysisTimestamp) != null ? _a : null; + (_b = this.plugin.pomodoroService) == null ? void 0 : _b.onSettingsChanged(); + (_c = this.plugin.pomodoroService) == null ? void 0 : _c.reinitializeTimerFromState(); + this.plugin.initializeMCQComponents(); + await this.plugin.savePluginData(); + this.display(); + new import_obsidian17.Notice("All plugin data reset to defaults."); + } + }); + const clearScheduleBtn = actionsContainer.createEl("button", { + text: "Clear All Schedule Data", + cls: "sf-button-danger" + // Optional: Add a class for dangerous actions + }); + clearScheduleBtn.addEventListener("click", async () => { + const confirmed = confirm("Are you sure you want to clear all review schedules, history, and session data? This action cannot be undone."); + if (confirmed) { + try { + this.plugin.pluginState.schedules = { ...DEFAULT_PLUGIN_STATE_DATA.schedules }; + this.plugin.pluginState.history = [...DEFAULT_PLUGIN_STATE_DATA.history]; + this.plugin.pluginState.reviewSessions = { ...DEFAULT_PLUGIN_STATE_DATA.reviewSessions }; + this.plugin.pluginState.customNoteOrder = [...DEFAULT_PLUGIN_STATE_DATA.customNoteOrder]; + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder; + if (this.plugin.reviewController) { + await this.plugin.reviewController.updateTodayNotes(); + } + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + await this.plugin.savePluginData(); + new import_obsidian17.Notice("All schedule data cleared successfully."); + this.display(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + } catch (error) { + console.error("Failed to clear schedule data:", error); + new import_obsidian17.Notice("Failed to clear schedule data. Check console for details."); + } + } + }); + return actionsContainer; + }; + const spacedRepSection = createCollapsible("Spaced Repetition", "calendar-clock", true); + spacedRepSection.createEl("h3", { text: "Algorithm Configuration" }); + const algoSelectionSetting = new import_obsidian17.Setting(spacedRepSection).setName("Default scheduling algorithm").setDesc("Choose the default algorithm for newly created notes.").addDropdown((dropdown) => dropdown.addOption("sm2", "SM-2").addOption("fsrs", "FSRS").setValue(this.plugin.settings.defaultSchedulingAlgorithm).onChange(async (value) => { + this.plugin.settings.defaultSchedulingAlgorithm = value; + await this.plugin.savePluginData(); + this.display(); + })); + const aboutAlgoContainer = spacedRepSection.createEl("div", { cls: "sf-info-box sf-algo-about-box" }); + this.renderAboutAlgorithmSection(aboutAlgoContainer, this.plugin.settings.defaultSchedulingAlgorithm); + const sm2ParamsContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" }); + const sm2Summary = sm2ParamsContainer.createEl("summary"); + sm2Summary.createEl("h3", { text: "SM-2 Parameters" }); + sm2ParamsContainer.open = false; + new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Base ease factor").setDesc("Initial ease factor for new SM-2 notes (2.5 is SM-2 default). Higher ease increases interval growth. Value shown is internal format (250 = 2.5).").addSlider((slider) => slider.setLimits(130, 500, 10).setValue(this.plugin.settings.baseEase).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.baseEase = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Use initial learning schedule").setDesc("For new SM-2 notes, use a fixed set of initial intervals before applying the full algorithm.").addToggle((toggle) => toggle.setValue(this.plugin.settings.useInitialSchedule).onChange(async (value) => { + this.plugin.settings.useInitialSchedule = value; + await this.plugin.savePluginData(); + this.display(); + })); + if (this.plugin.settings.useInitialSchedule) { + new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Custom initial intervals (days)").setDesc("Comma-separated list for initial SM-2 reviews (e.g., 0,1,3,7). Must start with 0.").addText((text) => text.setValue(this.plugin.settings.initialScheduleCustomIntervals.join(", ")).onChange(async (value) => { + const intervals = value.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n >= 0); + if (intervals.length > 0 && intervals[0] === 0) { + this.plugin.settings.initialScheduleCustomIntervals = intervals; + await this.plugin.savePluginData(); + } else { + new import_obsidian17.Notice("Custom initial SM-2 intervals must start with 0 and be valid numbers.", 5e3); + text.setValue(this.plugin.settings.initialScheduleCustomIntervals.join(", ")); + } + })); + } + new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Maximum interval (days)").setDesc("Longest possible interval between SM-2 reviews.").addText((text) => text.setValue(this.plugin.settings.maximumInterval.toString()).onChange(async (value) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.maximumInterval = numValue; + await this.plugin.savePluginData(); + } + })); + new import_obsidian17.Setting(sm2ParamsContainer).setName("SM-2: Load balancing").setDesc("Add slight randomness to SM-2 intervals to prevent reviews clumping on the same day.").addToggle((toggle) => toggle.setValue(this.plugin.settings.loadBalance).onChange(async (value) => { + this.plugin.settings.loadBalance = value; + await this.plugin.savePluginData(); + })); + const fsrsParamsContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" }); + const fsrsSummary = fsrsParamsContainer.createEl("summary"); + fsrsSummary.createEl("h3", { text: "FSRS Parameters" }); + fsrsParamsContainer.open = false; + new import_obsidian17.Setting(fsrsParamsContainer).setName("Request Retention").setDesc("Desired recall probability (0.7-0.99, default: 0.9). Higher values mean more frequent reviews.").addText((text) => { + var _a, _b, _c; + return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.request_retention) == null ? void 0 : _b.toString()) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.request_retention.toString()).onChange(async (value) => { + const numValue = parseFloat(value); + if (!isNaN(numValue) && numValue >= 0.7 && numValue <= 0.99) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, request_retention: numValue }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new import_obsidian17.Notice("FSRS Request Retention must be between 0.7 and 0.99."); + } + }); + }); + new import_obsidian17.Setting(fsrsParamsContainer).setName("Maximum Interval (days)").setDesc("Longest possible interval FSRS will schedule.").addText((text) => { + var _a, _b, _c; + return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.maximum_interval) == null ? void 0 : _b.toString()) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.maximum_interval.toString()).onChange(async (value) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, maximum_interval: numValue }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new import_obsidian17.Notice("FSRS Maximum Interval must be a positive number."); + } + }); + }); + new import_obsidian17.Setting(fsrsParamsContainer).setName("Learning Steps (minutes)").setDesc("Comma-separated initial learning intervals in minutes (e.g., 1,10 for 1m, 10m).").addText((text) => { + var _a, _b, _c; + return text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.learning_steps) == null ? void 0 : _b.join(",")) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.learning_steps.join(",")).onChange(async (value) => { + const steps = value.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0); + if (steps.length > 0) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: steps }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else if (value.trim() === "") { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: [] }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new import_obsidian17.Notice("FSRS Learning Steps must be valid comma-separated numbers > 0, or empty."); + } + }); + }); + new import_obsidian17.Setting(fsrsParamsContainer).setName("Enable Fuzz").setDesc("Add slight randomness to FSRS intervals (recommended).").addToggle((toggle) => { + var _a, _b; + return toggle.setValue((_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.enable_fuzz) != null ? _b : DEFAULT_SETTINGS.fsrsParameters.enable_fuzz).onChange(async (value) => { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_fuzz: value }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + }); + }); + new import_obsidian17.Setting(fsrsParamsContainer).setName("Enable Short Term Scheduling").setDesc("Use FSRS short-term memory model (affects initial learning steps).").addToggle((toggle) => { + var _a, _b; + return toggle.setValue((_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.enable_short_term) != null ? _b : DEFAULT_SETTINGS.fsrsParameters.enable_short_term).onChange(async (value) => { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_short_term: value }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + }); + }); + new import_obsidian17.Setting(fsrsParamsContainer).setName("Weights (W)").setDesc("FSRS algorithm parameters (17 numbers). Edit with caution. Default weights are generally good.").addTextArea((text) => { + var _a, _b, _c; + text.inputEl.rows = 3; + text.inputEl.style.width = "100%"; + text.setValue((_c = (_b = (_a = this.plugin.settings.fsrsParameters) == null ? void 0 : _a.w) == null ? void 0 : _b.join(",")) != null ? _c : DEFAULT_SETTINGS.fsrsParameters.w.join(",")).onChange(async (value) => { + const weights = value.split(",").map((s) => parseFloat(s.trim())); + if (weights.length === 17 && weights.every((n) => !isNaN(n))) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, w: weights }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new import_obsidian17.Notice("FSRS Weights must be a comma-separated list of 17 valid numbers."); + } + }); + }); + const conversionContainer = spacedRepSection.createEl("details", { cls: "sf-settings-collapsible-subsection" }); + const conversionSummary = conversionContainer.createEl("summary"); + conversionSummary.createEl("h3", { text: "Card Conversion Utilities" }); + conversionContainer.open = false; + new import_obsidian17.Setting(conversionContainer).setName("Convert all SM-2 cards to FSRS").setDesc("Migrate all existing SM-2 cards to use the FSRS algorithm. This will reset their learning state for FSRS.").addButton((button) => button.setButtonText("Convert SM-2 to FSRS").setCta().onClick(async () => { + const confirmed = confirm("Are you sure you want to convert ALL SM-2 cards to FSRS? Their FSRS learning state will be reset. This action cannot be easily undone."); + if (confirmed) { + new import_obsidian17.Notice("Converting SM-2 cards to FSRS... This may take a moment."); + await this.plugin.reviewScheduleService.convertAllSm2ToFsrs(); + await this.plugin.savePluginData(); + new import_obsidian17.Notice("All SM-2 cards have been converted to FSRS."); + this.display(); + } + })); + new import_obsidian17.Setting(conversionContainer).setName("Convert all FSRS cards to SM-2").setDesc("Migrate all existing FSRS cards to use the SM-2 algorithm. Their SM-2 learning state will be initialized with defaults.").addButton((button) => button.setButtonText("Convert FSRS to SM-2").setCta().onClick(async () => { + const confirmed = confirm("Are you sure you want to convert ALL FSRS cards to SM-2? Their SM-2 learning state will be reset to defaults. This action cannot be easily undone."); + if (confirmed) { + new import_obsidian17.Notice("Converting FSRS cards to SM-2... This may take a moment."); + await this.plugin.reviewScheduleService.convertAllFsrsToSm2(); + await this.plugin.savePluginData(); + new import_obsidian17.Notice("All FSRS cards have been converted to SM-2."); + this.display(); + } + })); + const interfaceSection = createCollapsible("Interface & Behavior", "settings", false); + new import_obsidian17.Setting(interfaceSection).setName("Display Settings").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(interfaceSection).setName("Default view type").setDesc("Choose between list or calendar for the review sidebar").addDropdown((dropdown) => dropdown.addOption("list", "List view").addOption("calendar", "Calendar view").setValue(this.plugin.settings.sidebarViewType).onChange(async (value) => { + this.plugin.settings.sidebarViewType = value; + await this.plugin.savePluginData(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + })); + new import_obsidian17.Setting(interfaceSection).setName("Show navigation notifications").setDesc("Display notifications when moving between notes").addToggle((toggle) => toggle.setValue(this.plugin.settings.showNavigationNotifications).onChange(async (value) => { + this.plugin.settings.showNavigationNotifications = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(interfaceSection).setName("Review Behavior").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(interfaceSection).setName("Include subfolders").setDesc("When adding a folder to review, include all notes in subfolders").addToggle((toggle) => toggle.setValue(this.plugin.settings.includeSubfolders).onChange(async (value) => { + this.plugin.settings.includeSubfolders = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(interfaceSection).setName("Notification time").setDesc("Minutes before due time to notify about upcoming reviews (0 to disable)").addText((text) => text.setValue(this.plugin.settings.notifyBeforeDue.toString()).onChange(async (value) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue >= 0) { + this.plugin.settings.notifyBeforeDue = numValue; + await this.plugin.savePluginData(); + } + })); + new import_obsidian17.Setting(interfaceSection).setName("Reading speed (WPM)").setDesc("Words per minute for estimating review time").addSlider((slider) => slider.setLimits(100, 500, 10).setValue(this.plugin.settings.readingSpeed).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.readingSpeed = value; + await this.plugin.savePluginData(); + })); + interfaceSection.createEl("div", { + cls: "sf-setting-explain", + text: "Average adults read 200-250 WPM for regular content, 100-150 WPM for technical content" + }); + const mcqSection = createCollapsible("Multiple Choice Questions", "newspaper", false); + new import_obsidian17.Setting(mcqSection).setName("Enable MCQ feature").setDesc("Use AI-generated multiple-choice questions to test your knowledge").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableMCQ).onChange(async (value) => { + this.plugin.settings.enableMCQ = value; + await this.plugin.savePluginData(); + this.plugin.initializeMCQComponents(); + try { + const apiSettings = { + openRouterApiKey: this.plugin.settings.openRouterApiKey, + enableMCQ: value, + openRouterModel: this.plugin.settings.openRouterModel + }; + window.localStorage.setItem("spaceforge-api-settings", JSON.stringify(apiSettings)); + console.log("Updated API settings backup with MCQ state"); + } catch (e) { + console.error("Error updating API settings backup:", e); + } + this.display(); + })); + if (this.plugin.settings.enableMCQ) { + new import_obsidian17.Setting(mcqSection).setName("API Configuration").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(mcqSection).setName("API Provider").setDesc("Select the API provider for generating MCQs.").addDropdown((dropdown) => { + dropdown.addOption("openrouter" /* OpenRouter */, "OpenRouter").addOption("openai" /* OpenAI */, "OpenAI").addOption("ollama" /* Ollama */, "Ollama").addOption("gemini" /* Gemini */, "Gemini").addOption("claude" /* Claude */, "Claude").addOption("together" /* Together */, "Together AI").setValue(this.plugin.settings.mcqApiProvider).onChange(async (value) => { + this.plugin.settings.mcqApiProvider = value; + await this.plugin.savePluginData(); + this.plugin.initializeMCQComponents(); + this.display(); + }); + }); + const provider = this.plugin.settings.mcqApiProvider; + if (provider === "openrouter" /* OpenRouter */) { + new import_obsidian17.Setting(mcqSection).setName("OpenRouter Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + const apiKeyContainer = mcqSection.createEl("div", { cls: "sf-setting-highlight" }); + new import_obsidian17.Setting(apiKeyContainer).setName("OpenRouter API Key").setDesc("Required for generating MCQs via OpenRouter.").addText((text) => text.setPlaceholder("Enter your OpenRouter API key").setValue(this.plugin.settings.openRouterApiKey).onChange(async (value) => { + this.plugin.settings.openRouterApiKey = value; + await this.plugin.savePluginData(); + })); + apiKeyContainer.createEl("div", { text: "Get your API key at https://openrouter.ai/keys" }); + new import_obsidian17.Setting(mcqSection).setName("OpenRouter Model").setDesc("Model identifier from OpenRouter (e.g., openai/gpt-4.1-mini)").addText((text) => text.setPlaceholder("Enter OpenRouter model identifier").setValue(this.plugin.settings.openRouterModel).onChange(async (value) => { + this.plugin.settings.openRouterModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === "openai" /* OpenAI */) { + new import_obsidian17.Setting(mcqSection).setName("OpenAI Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + new import_obsidian17.Setting(mcqSection).setName("OpenAI API Key").setDesc("Your OpenAI API key.").addText((text) => text.setPlaceholder("Enter your OpenAI API key (sk-...)").setValue(this.plugin.settings.openaiApiKey).onChange(async (value) => { + this.plugin.settings.openaiApiKey = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("OpenAI Model").setDesc("Model name (e.g., gpt-3.5-turbo, gpt-4)").addText((text) => text.setPlaceholder("Enter OpenAI model name").setValue(this.plugin.settings.openaiModel).onChange(async (value) => { + this.plugin.settings.openaiModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === "ollama" /* Ollama */) { + new import_obsidian17.Setting(mcqSection).setName("Ollama Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + new import_obsidian17.Setting(mcqSection).setName("Ollama API URL").setDesc("URL of your running Ollama instance (e.g., http://localhost:11434)").addText((text) => text.setPlaceholder("http://localhost:11434").setValue(this.plugin.settings.ollamaApiUrl).onChange(async (value) => { + this.plugin.settings.ollamaApiUrl = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Ollama Model").setDesc("Name of the Ollama model to use (e.g., llama3, mistral)").addText((text) => text.setPlaceholder("Enter Ollama model name").setValue(this.plugin.settings.ollamaModel).onChange(async (value) => { + this.plugin.settings.ollamaModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === "gemini" /* Gemini */) { + new import_obsidian17.Setting(mcqSection).setName("Gemini Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + new import_obsidian17.Setting(mcqSection).setName("Gemini API Key").setDesc("Your Google AI Gemini API key.").addText((text) => text.setPlaceholder("Enter your Gemini API key").setValue(this.plugin.settings.geminiApiKey).onChange(async (value) => { + this.plugin.settings.geminiApiKey = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Gemini Model").setDesc("Model name (e.g., gemini-pro)").addText((text) => text.setPlaceholder("Enter Gemini model name").setValue(this.plugin.settings.geminiModel).onChange(async (value) => { + this.plugin.settings.geminiModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === "claude" /* Claude */) { + new import_obsidian17.Setting(mcqSection).setName("Claude Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + new import_obsidian17.Setting(mcqSection).setName("Claude API Key").setDesc("Your Anthropic Claude API key.").addText((text) => text.setPlaceholder("Enter your Claude API key").setValue(this.plugin.settings.claudeApiKey).onChange(async (value) => { + this.plugin.settings.claudeApiKey = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Claude Model").setDesc("Model name (e.g., claude-3-opus-20240229, claude-3-sonnet-20240229)").addText((text) => text.setPlaceholder("Enter Claude model name").setValue(this.plugin.settings.claudeModel).onChange(async (value) => { + this.plugin.settings.claudeModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === "together" /* Together */) { + new import_obsidian17.Setting(mcqSection).setName("Together AI Configuration").setHeading().setClass("sf-settings-subsection-provider-header"); + new import_obsidian17.Setting(mcqSection).setName("Together AI API Key").setDesc("Your Together AI API key.").addText((text) => text.setPlaceholder("Enter your Together AI API key").setValue(this.plugin.settings.togetherApiKey).onChange(async (value) => { + this.plugin.settings.togetherApiKey = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Together AI Model").setDesc("Model identifier from Together AI (e.g., meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8)").addText((text) => text.setPlaceholder("Enter Together AI model identifier").setValue(this.plugin.settings.togetherModel).onChange(async (value) => { + this.plugin.settings.togetherModel = value; + await this.plugin.savePluginData(); + })); + } + new import_obsidian17.Setting(mcqSection).setName("Question Generation (Common)").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(mcqSection).setName("Question amount mode").setDesc("How to determine the number of questions per note.").addDropdown((dropdown) => dropdown.addOption("fixed" /* Fixed */, "Fixed Number").addOption("wordsPerQuestion" /* WordsPerQuestion */, "Per X Words").setValue(this.plugin.settings.mcqQuestionAmountMode).onChange(async (value) => { + this.plugin.settings.mcqQuestionAmountMode = value; + await this.plugin.savePluginData(); + this.display(); + })); + if (this.plugin.settings.mcqQuestionAmountMode === "fixed" /* Fixed */) { + new import_obsidian17.Setting(mcqSection).setName("Questions per note (Fixed)").setDesc("Number of questions to generate for each note.").addSlider((slider) => slider.setLimits(1, 10, 1).setValue(this.plugin.settings.mcqQuestionsPerNote).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.mcqQuestionsPerNote = value; + await this.plugin.savePluginData(); + })); + } else if (this.plugin.settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + new import_obsidian17.Setting(mcqSection).setName("Words per question target").setDesc("Generate approximately 1 question for every X words in the note.").addText((text) => text.setPlaceholder("100").setValue(this.plugin.settings.mcqWordsPerQuestion.toString()).onChange(async (value) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.mcqWordsPerQuestion = numValue; + await this.plugin.savePluginData(); + } else { + new import_obsidian17.Notice("Words per question must be a positive number."); + text.setValue(this.plugin.settings.mcqWordsPerQuestion.toString()); + } + })); + } + new import_obsidian17.Setting(mcqSection).setName("Choices per question").setDesc("Number of answer choices for each question").addSlider((slider) => slider.setLimits(2, 6, 1).setValue(this.plugin.settings.mcqChoicesPerQuestion).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.mcqChoicesPerQuestion = value; + await this.plugin.savePluginData(); + })); + const mcqFormattingGrid = mcqSection.createEl("div", { cls: "sf-setting-grid" }); + const promptTypeContainer = mcqFormattingGrid.createEl("div"); + new import_obsidian17.Setting(promptTypeContainer).setName("Prompt type").setDesc("Format for MCQ generation").addDropdown((dropdown) => dropdown.addOption("basic", "Basic").addOption("detailed", "Detailed").setValue(this.plugin.settings.mcqPromptType).onChange(async (value) => { + this.plugin.settings.mcqPromptType = value; + await this.plugin.savePluginData(); + })); + const difficultyContainer = mcqFormattingGrid.createEl("div"); + new import_obsidian17.Setting(difficultyContainer).setName("MCQ difficulty").setDesc("Complexity level").addDropdown((dropdown) => dropdown.addOption("basic", "Basic recall").addOption("advanced", "Advanced understanding").setValue(this.plugin.settings.mcqDifficulty).onChange(async (value) => { + this.plugin.settings.mcqDifficulty = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Scoring Settings").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(mcqSection).setName("Time deduction amount").setDesc("Score penalty for slow answers (0-1)").addSlider((slider) => slider.setLimits(0, 1, 0.1).setValue(this.plugin.settings.mcqTimeDeductionAmount).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.mcqTimeDeductionAmount = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Time deduction threshold").setDesc("Apply penalty after this many seconds").addSlider((slider) => slider.setLimits(10, 120, 5).setValue(this.plugin.settings.mcqTimeDeductionSeconds).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.mcqTimeDeductionSeconds = value; + await this.plugin.savePluginData(); + })); + const systemPromptsContainer = mcqSection.createEl("details", { cls: "sf-system-prompts-container" }); + systemPromptsContainer.createEl("summary", { text: "System Prompts (Advanced)", cls: "sf-settings-subsection" }); + systemPromptsContainer.createEl("div", { text: "Basic Difficulty Prompt", cls: "sf-prompt-label" }); + const basicTextarea = systemPromptsContainer.createEl("textarea", { + attr: { + placeholder: "Enter system prompt for basic difficulty", + rows: "6" + }, + cls: "prompt-textarea" + }); + basicTextarea.value = this.plugin.settings.mcqBasicSystemPrompt; + basicTextarea.addEventListener("change", async () => { + this.plugin.settings.mcqBasicSystemPrompt = basicTextarea.value; + await this.plugin.savePluginData(); + }); + systemPromptsContainer.createEl("div", { text: "Advanced Difficulty Prompt", cls: "sf-prompt-label" }); + const advancedTextarea = systemPromptsContainer.createEl("textarea", { + attr: { + placeholder: "Enter system prompt for advanced difficulty", + rows: "6" + }, + cls: "prompt-textarea" + }); + advancedTextarea.value = this.plugin.settings.mcqAdvancedSystemPrompt; + advancedTextarea.addEventListener("change", async () => { + this.plugin.settings.mcqAdvancedSystemPrompt = advancedTextarea.value; + await this.plugin.savePluginData(); + }); + new import_obsidian17.Setting(mcqSection).setName("Advanced Question Behavior").setHeading().setClass("sf-settings-subsection-header"); + const regenerationSetting = new import_obsidian17.Setting(mcqSection).setName("Enable question regeneration on rating").setDesc("Automatically regenerate questions for a note if its review rating meets or exceeds a specified value.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableQuestionRegenerationOnRating).onChange(async (value) => { + this.plugin.settings.enableQuestionRegenerationOnRating = value; + await this.plugin.savePluginData(); + this.display(); + })); + if (this.plugin.settings.enableQuestionRegenerationOnRating) { + new import_obsidian17.Setting(mcqSection).setName("Min SM-2 rating for MCQ regeneration").setDesc("For SM-2: Regenerate MCQs if review rating (0-5) is this value or higher. (0:Blackout, 5:Perfect)").addSlider((slider) => slider.setLimits(0, 5, 1).setValue(this.plugin.settings.minSm2RatingForQuestionRegeneration).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.minSm2RatingForQuestionRegeneration = value; + await this.plugin.savePluginData(); + })); + new import_obsidian17.Setting(mcqSection).setName("Min FSRS rating for MCQ regeneration").setDesc("For FSRS: Regenerate MCQs if review rating (1-4) is this value or higher. (1:Again, 4:Easy)").addSlider((slider) => slider.setLimits(1, 4, 1).setValue(this.plugin.settings.minFsrsRatingForQuestionRegeneration).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.minFsrsRatingForQuestionRegeneration = value; + await this.plugin.savePluginData(); + })); + } + } else { + const mcqDisabledMessage = mcqSection.createEl("div", { cls: "sf-info-box" }); + mcqDisabledMessage.createEl("p", { + text: "Multiple Choice Questions are currently disabled. Enable them to generate AI-powered quizzes that test your understanding of notes." + }); + } + const pomodoroSection = createCollapsible("Pomodoro Timer", "timer", false); + new import_obsidian17.Setting(pomodoroSection).setName("Enable Pomodoro Timer").setDesc("Show the Pomodoro timer in the sidebar.").addToggle((toggle) => toggle.setValue(this.plugin.settings.pomodoroEnabled).onChange(async (value) => { + var _a, _b; + this.plugin.settings.pomodoroEnabled = value; + await this.plugin.savePluginData(); + (_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged(); + (_b = this.plugin.sidebarView) == null ? void 0 : _b.refresh(); + this.display(); + })); + if (this.plugin.settings.pomodoroEnabled) { + new import_obsidian17.Setting(pomodoroSection).setName("Timer Durations (minutes)").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(pomodoroSection).setName("Work Duration").setDesc("Length of a work session.").addText((text) => text.setPlaceholder("25").setValue(this.plugin.settings.pomodoroWorkDuration.toString()).onChange(async (value) => { + var _a; + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroWorkDuration = numValue; + await this.plugin.savePluginData(); + (_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged(); + } + })); + new import_obsidian17.Setting(pomodoroSection).setName("Short Break Duration").setDesc("Length of a short break.").addText((text) => text.setPlaceholder("5").setValue(this.plugin.settings.pomodoroShortBreakDuration.toString()).onChange(async (value) => { + var _a; + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroShortBreakDuration = numValue; + await this.plugin.savePluginData(); + (_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged(); + } + })); + new import_obsidian17.Setting(pomodoroSection).setName("Long Break Duration").setDesc("Length of a long break.").addText((text) => text.setPlaceholder("15").setValue(this.plugin.settings.pomodoroLongBreakDuration.toString()).onChange(async (value) => { + var _a; + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroLongBreakDuration = numValue; + await this.plugin.savePluginData(); + (_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged(); + } + })); + new import_obsidian17.Setting(pomodoroSection).setName("Sessions Until Long Break").setDesc("Number of work sessions before a long break starts.").addText((text) => text.setPlaceholder("4").setValue(this.plugin.settings.pomodoroSessionsUntilLongBreak.toString()).onChange(async (value) => { + var _a; + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroSessionsUntilLongBreak = numValue; + await this.plugin.savePluginData(); + (_a = this.plugin.pomodoroService) == null ? void 0 : _a.onSettingsChanged(); + } + })); + new import_obsidian17.Setting(pomodoroSection).setName("Notifications").setHeading().setClass("sf-settings-subsection-header"); + new import_obsidian17.Setting(pomodoroSection).setName("Enable Sound Notifications").setDesc("Play a sound at the end of each work/break session.").addToggle((toggle) => toggle.setValue(this.plugin.settings.pomodoroSoundEnabled).onChange(async (value) => { + this.plugin.settings.pomodoroSoundEnabled = value; + await this.plugin.savePluginData(); + })); + } else { + const pomodoroDisabledMessage = pomodoroSection.createEl("div", { cls: "sf-info-box" }); + pomodoroDisabledMessage.createEl("p", { + text: "Pomodoro Timer is currently disabled. Enable it to configure durations and notifications." + }); + } + containerEl.createEl("h2", { text: "Manage Plugin Data" }); + createActionButtons(); + } + renderAboutAlgorithmSection(container, algorithm) { + container.empty(); + if (algorithm === "sm2") { + container.createEl("h4", { text: "About the Modified SM-2 Algorithm" }); + container.createEl("p", { + text: "Spaceforge uses a modified version of the SuperMemo SM-2 algorithm (1991) which schedules reviews based on how well you recall information. When you rate your recall quality from 0-5, the algorithm adjusts the interval and difficulty (ease factor) accordingly." + }); + container.createEl("p", { + text: "Our implementation includes specific handling for overdue or skipped items to prevent them from accumulating in a backlog:" + }); + const sm2List = container.createEl("ul"); + sm2List.createEl("li", { text: "Overdue items: Automatically set to review tomorrow with a quality rating of 0." }); + sm2List.createEl("li", { text: "Postponed items: Explicitly moved to tomorrow with a one-step quality penalty." }); + sm2List.createEl("li", { text: "Both cases reset the repetition count to 1 and update the ease factor." }); + const ratingsTable = container.createEl("table", { cls: "sf-ratings-table" }); + ratingsTable.innerHTML = ` + Rating (0-5)DescriptionEffect on Interval + + 0-2Incorrect / struggledResets, shortest interval + 3Correct with difficultySmall increase + 4Correct with hesitationModerate increase + 5Perfect recallLargest increase + + `; + } else if (algorithm === "fsrs") { + container.createEl("h4", { text: "About the FSRS Algorithm" }); + container.createEl("p", { + text: "FSRS (Free Spaced Repetition Scheduler) is a modern, evidence-based algorithm that models memory retention to optimize review schedules. It calculates card difficulty and stability dynamically based on your review history and aims for a target retention rate." + }); + container.createEl("p", { + text: "Key concepts in FSRS:" + }); + const fsrsList = container.createEl("ul"); + fsrsList.createEl("li", { text: "Difficulty: How hard a card is to remember." }); + fsrsList.createEl("li", { text: "Stability: How long a card is likely to be remembered." }); + fsrsList.createEl("li", { text: "Retention: The probability of recalling a card at the time of review." }); + fsrsList.createEl("li", { text: "Learning Steps: Initial short intervals for new cards (configurable)." }); + const ratingsTable = container.createEl("table", { cls: "sf-ratings-table" }); + ratingsTable.innerHTML = ` + Rating (1-4)DescriptionEffect on Stability/Difficulty + + 1 (Again)Forgot the cardDecreases stability, may increase difficulty + 2 (Hard)Recalled with significant difficultySmaller increase in stability + 3 (Good)Recalled correctlyStandard increase in stability + 4 (Easy)Recalled very easilyLargest increase in stability, may decrease difficulty + + `; + container.createEl("p", { text: "FSRS parameters (weights, retention, etc.) can be tuned, but the defaults are generally effective." }); + } + } +}; + +// api/openrouter-service.ts +var import_obsidian18 = require("obsidian"); +var OpenRouterService = class { + /** + * Initialize OpenRouter service + * + * @param plugin Reference to the main plugin + */ + constructor(plugin) { + this.plugin = plugin; + } + /** + * Generate MCQs for a note + * + * @param notePath Path to the note + * @param noteContent Content of the note + * @param settings Current plugin settings + * @returns Generated MCQ set or null if failed + */ + async generateMCQs(notePath, noteContent, settings) { + if (!settings.openRouterApiKey) { + new import_obsidian18.Notice("OpenRouter API key is not set. Please add it in the settings."); + return null; + } + try { + new import_obsidian18.Notice("Generating MCQs using OpenRouter..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`OpenRouter: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`OpenRouter: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian18.Notice("Failed to generate valid MCQs from OpenRouter. Please try again."); + return null; + } + const mcqSet = { + notePath, + questions, + generatedAt: Date.now() + }; + return mcqSet; + } catch (error) { + console.error("Error generating MCQs with OpenRouter:", error); + new import_obsidian18.Notice("Failed to generate MCQs with OpenRouter. Please check console for details."); + return null; + } + } + /** + * Generate prompt for the AI + * + * @param noteContent Content of the note + * @param settings Current plugin settings + * @param numQuestionsToGenerate The target number of questions to ask for + * @returns Prompt for the AI + */ + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + if (promptType === "basic") { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + if (difficulty === "basic") { + basePrompt += ` + +Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += ` + +Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + /** + * Make API request to OpenRouter + * + * @param prompt Prompt for the AI + * @param settings Current plugin settings + * @returns AI response text + */ + async makeApiRequest(prompt, settings) { + const apiKey = settings.openRouterApiKey; + const model = settings.openRouterModel; + const difficulty = settings.mcqDifficulty; + try { + console.log(`Making API request to OpenRouter using model: ${model} with difficulty: ${difficulty}`); + const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + const response = await 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, + 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 import_obsidian18.Notice(`OpenRouter API error: ${error.message}`); + throw error; + } + } + /** + * Parse the AI response to extract MCQs + * + * @param response AI response text + * @param settings Current plugin settings + * @param numQuestionsToGenerate The target number of questions expected + * @returns Array of parsed MCQ questions + */ + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from OpenRouter:", response); + let questionBlocks = []; + questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length === 0) { + const lines = response.split("\n"); + let currentQuestion = ""; + for (const line of lines) { + if (/^\d+\./.test(line.trim())) { + if (currentQuestion) { + questionBlocks.push(currentQuestion); + } + currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n"; + } else if (currentQuestion) { + currentQuestion += line + "\n"; + } + } + if (currentQuestion) { + questionBlocks.push(currentQuestion); + } + } + console.log(`Found ${questionBlocks.length} question blocks from OpenRouter`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) { + console.log("Skipping block with insufficient lines (OpenRouter):", block); + continue; + } + let questionText = lines[0].trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + console.log("Processing question (OpenRouter):", questionText); + console.log("Found choices (OpenRouter):", lines.slice(1)); + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim(); + choices.push(cleanedLine); + if (isCorrect) { + correctAnswerIndex = choices.length - 1; + console.log(`Found correct answer at index ${correctAnswerIndex} (OpenRouter): ${cleanedLine}`); + } + } + if (correctAnswerIndex === -1) { + for (let i = 0; i < choices.length; i++) { + if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) { + correctAnswerIndex = i; + console.log(`Found correct answer with alternative marker at index ${i} (OpenRouter): ${choices[i]}`); + break; + } + } + } + if (correctAnswerIndex === -1 && choices.length > 0) { + correctAnswerIndex = 0; + console.log(`No correct answer marker found, defaulting to first choice (OpenRouter): ${choices[0]}`); + } + if (questionText && choices.length >= 2) { + questions.push({ + question: questionText, + choices, + correctAnswerIndex + }); + } else { + console.log("Skipping invalid question (OpenRouter):", { questionText, choicesLength: choices.length }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from OpenRouter`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from OpenRouter:", error); + new import_obsidian18.Notice("Error parsing MCQ response from OpenRouter. Please try again."); + return []; + } + } +}; + +// api/openai-service.ts +var import_obsidian19 = require("obsidian"); +var OpenAIService = class { + constructor(plugin) { + this.plugin = plugin; + } + async generateMCQs(notePath, noteContent, settings) { + if (!settings.openaiApiKey) { + new import_obsidian19.Notice("OpenAI API key is not set. Please add it in the Spaceforge settings."); + return null; + } + if (!settings.openaiModel) { + new import_obsidian19.Notice("OpenAI Model is not set. Please add it in the Spaceforge settings."); + return null; + } + try { + new import_obsidian19.Notice("Generating MCQs using OpenAI..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`OpenAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`OpenAI: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian19.Notice("Failed to generate valid MCQs from OpenAI. Please try again."); + return null; + } + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch (error) { + console.error("Error generating MCQs with OpenAI:", error); + new import_obsidian19.Notice("Failed to generate MCQs with OpenAI. Please check console for details."); + return null; + } + } + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + if (promptType === "basic") { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + if (difficulty === "basic") { + basePrompt += ` + +Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += ` + +Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + async makeApiRequest(prompt, settings) { + var _a; + const apiKey = settings.openaiApiKey; + const model = settings.openaiModel; + const difficulty = settings.mcqDifficulty; + const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + console.log(`Making API request to OpenAI using model: ${model} with difficulty: ${difficulty}`); + try { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt } + ] + }) + }); + if (!response.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}): ${((_a = errorData.error) == null ? void 0 : _a.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 import_obsidian19.Notice(`OpenAI API error: ${error.message}`); + throw error; + } + } + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from OpenAI:", response); + let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length === 0) { + const lines = response.split("\n"); + let currentQuestion = ""; + for (const line of lines) { + if (/^\d+\./.test(line.trim())) { + if (currentQuestion) + questionBlocks.push(currentQuestion); + currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n"; + } else if (currentQuestion) { + currentQuestion += line + "\n"; + } + } + if (currentQuestion) + questionBlocks.push(currentQuestion); + } + console.log(`Found ${questionBlocks.length} question blocks from OpenAI`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) + continue; + let questionText = lines[0].trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim(); + choices.push(cleanedLine); + if (isCorrect) + correctAnswerIndex = choices.length - 1; + } + if (correctAnswerIndex === -1) { + for (let i = 0; i < choices.length; i++) { + if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) { + correctAnswerIndex = i; + break; + } + } + } + if (correctAnswerIndex === -1 && choices.length > 0) + correctAnswerIndex = 0; + if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from OpenAI`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from OpenAI:", error); + new import_obsidian19.Notice("Error parsing MCQ response from OpenAI. Please try again."); + return []; + } + } +}; + +// api/ollama-service.ts +var import_obsidian20 = require("obsidian"); +var OllamaService = class { + constructor(plugin) { + this.plugin = plugin; + } + async generateMCQs(notePath, noteContent, settings) { + if (!settings.ollamaApiUrl) { + new import_obsidian20.Notice("Ollama API URL is not set. Please add it in the Spaceforge settings."); + return null; + } + if (!settings.ollamaModel) { + new import_obsidian20.Notice("Ollama Model is not set. Please add it in the Spaceforge settings."); + return null; + } + try { + new import_obsidian20.Notice("Generating MCQs using Ollama..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`Ollama: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`Ollama: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian20.Notice("Failed to generate valid MCQs from Ollama. Please try again."); + return null; + } + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch (error) { + console.error("Error generating MCQs with Ollama:", error); + new import_obsidian20.Notice("Failed to generate MCQs with Ollama. Please check console for details."); + return null; + } + } + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + if (promptType === "basic") { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + if (difficulty === "basic") { + basePrompt += ` + +Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += ` + +Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + async makeApiRequest(prompt, settings) { + const apiUrl = settings.ollamaApiUrl.endsWith("/") ? settings.ollamaApiUrl.slice(0, -1) : settings.ollamaApiUrl; + const model = settings.ollamaModel; + const difficulty = settings.mcqDifficulty; + const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + console.log(`Making API request to Ollama at ${apiUrl} using model: ${model} with difficulty: ${difficulty}`); + try { + const response = await fetch(`${apiUrl}/api/chat`, { + // Common Ollama chat endpoint + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt } + ], + stream: false + // Ensure we get the full response, not a stream + }) + }); + if (!response.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(); + if (!data.message || !data.message.content) { + console.error("Invalid API response format from Ollama:", data); + throw new Error("Invalid API response format from Ollama - missing message content"); + } + return data.message.content; + } catch (error) { + console.error("Error in Ollama API request:", error); + new import_obsidian20.Notice(`Ollama API error: ${error.message}`); + throw error; + } + } + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from Ollama:", response); + let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length === 0) { + const lines = response.split("\n"); + let currentQuestion = ""; + for (const line of lines) { + if (/^\d+\./.test(line.trim())) { + if (currentQuestion) + questionBlocks.push(currentQuestion); + currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n"; + } else if (currentQuestion) { + currentQuestion += line + "\n"; + } + } + if (currentQuestion) + questionBlocks.push(currentQuestion); + } + console.log(`Found ${questionBlocks.length} question blocks from Ollama`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) + continue; + let questionText = lines[0].trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim(); + choices.push(cleanedLine); + if (isCorrect) + correctAnswerIndex = choices.length - 1; + } + if (correctAnswerIndex === -1) { + for (let i = 0; i < choices.length; i++) { + if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) { + correctAnswerIndex = i; + break; + } + } + } + if (correctAnswerIndex === -1 && choices.length > 0) + correctAnswerIndex = 0; + if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from Ollama`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from Ollama:", error); + new import_obsidian20.Notice("Error parsing MCQ response from Ollama. Please try again."); + return []; + } + } +}; + +// api/gemini-service.ts +var import_obsidian21 = require("obsidian"); +var GeminiService = class { + constructor(plugin) { + this.plugin = plugin; + } + async generateMCQs(notePath, noteContent, settings) { + if (!settings.geminiApiKey) { + new import_obsidian21.Notice("Gemini API key is not set. Please add it in the Spaceforge settings."); + return null; + } + if (!settings.geminiModel) { + new import_obsidian21.Notice("Gemini Model is not set. Please add it in the Spaceforge settings."); + return null; + } + try { + new import_obsidian21.Notice("Generating MCQs using Gemini..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`Gemini: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`Gemini: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian21.Notice("Failed to generate valid MCQs from Gemini. Please try again."); + return null; + } + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch (error) { + console.error("Error generating MCQs with Gemini:", error); + new import_obsidian21.Notice("Failed to generate MCQs with Gemini. Please check console for details."); + return null; + } + } + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + const systemInstruction = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + if (promptType === "basic") { + basePrompt = `${systemInstruction} + +Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `${systemInstruction} + +Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + async makeApiRequest(prompt, settings) { + var _a; + const apiKey = settings.geminiApiKey; + const model = settings.geminiModel; + console.log(`Making API request to Gemini using model: ${model}`); + const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; + try { + const response = await 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 = ((_a = errorData == null ? void 0 : errorData.error) == null ? void 0 : _a.message) || (errorData == null ? void 0 : errorData.message) || "Unknown error"; + throw new Error(`API request failed (${response.status}): ${errorMessage}`); + } + const data = await response.json(); + if (!data.candidates || !data.candidates.length || !data.candidates[0].content || !data.candidates[0].content.parts || !data.candidates[0].content.parts.length || !data.candidates[0].content.parts[0].text) { + console.error("Invalid API response format from Gemini:", data); + throw new Error("Invalid API response format from Gemini - missing content"); + } + return data.candidates[0].content.parts[0].text; + } catch (error) { + console.error("Error in Gemini API request:", error); + new import_obsidian21.Notice(`Gemini API error: ${error.message}`); + throw error; + } + } + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from Gemini:", response); + let questionBlocks = response.split(/\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length === 0) { + const lines = response.split("\n"); + let currentQuestion = ""; + for (const line of lines) { + if (/^\d+\./.test(line.trim())) { + if (currentQuestion) + questionBlocks.push(currentQuestion); + currentQuestion = line.replace(/^\d+\.\s*/, "") + "\n"; + } else if (currentQuestion) { + currentQuestion += line + "\n"; + } + } + if (currentQuestion) + questionBlocks.push(currentQuestion); + } + console.log(`Found ${questionBlocks.length} question blocks from Gemini`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) + continue; + let questionText = lines[0].trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/g, "").replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, "").trim(); + choices.push(cleanedLine); + if (isCorrect) + correctAnswerIndex = choices.length - 1; + } + if (correctAnswerIndex === -1) { + for (let i = 0; i < choices.length; i++) { + if (lines[i + 1] && (lines[i + 1].toLowerCase().includes("correct") || lines[i + 1].includes("\u2713") || lines[i + 1].includes("\u2714\uFE0F"))) { + correctAnswerIndex = i; + break; + } + } + } + if (correctAnswerIndex === -1 && choices.length > 0) + correctAnswerIndex = 0; + if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from Gemini`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from Gemini:", error); + new import_obsidian21.Notice("Error parsing MCQ response from Gemini. Please try again."); + return []; + } + } +}; + +// api/claude-service.ts +var import_obsidian22 = require("obsidian"); +var ClaudeService = class { + constructor(plugin) { + this.plugin = plugin; + } + async generateMCQs(notePath, noteContent, settings) { + if (!settings.claudeApiKey) { + new import_obsidian22.Notice("Claude API key is not set. Please add it in the Spaceforge settings."); + return null; + } + if (!settings.claudeModel) { + new import_obsidian22.Notice("Claude Model is not set. Please add it in the Spaceforge settings."); + return null; + } + try { + new import_obsidian22.Notice("Generating MCQs using Claude..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`Claude: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`Claude: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian22.Notice("Failed to generate valid MCQs from Claude. Please try again."); + return null; + } + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch (error) { + console.error("Error generating MCQs with Claude:", error); + new import_obsidian22.Notice("Failed to generate MCQs with Claude. Please check console for details."); + return null; + } + } + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + if (promptType === "basic") { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + if (difficulty === "basic") { + basePrompt += ` + +Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += ` + +Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + async makeApiRequest(prompt, settings) { + var _a; + const apiKey = settings.claudeApiKey; + const model = settings.claudeModel; + const difficulty = settings.mcqDifficulty; + const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + console.log(`Making API request to Claude using model: ${model} with difficulty: ${difficulty}`); + try { + const response = await 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, + 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}): ${((_a = errorData.error) == null ? void 0 : _a.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 import_obsidian22.Notice(`Claude API error: ${error.message}`); + throw error; + } + } + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from Claude:", response); + let questionBlocks = response.split(/\n\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) { + if (!/^\d+\.\s+/.test(questionBlocks[0])) { + if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) { + questionBlocks = response.split(/\n(?=\d+\.\s)/); + if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) { + const potentialBlocks = response.split(/\n\n+/); + if (potentialBlocks.some((pb) => /^\d+\.\s/.test(pb.trimStart()))) { + questionBlocks = potentialBlocks; + } else { + } + } + } + } + } + if (questionBlocks.length === 0 || questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1.")) { + const lines = response.split("\n"); + let currentQuestionBlock = ""; + const tempBlocks = []; + for (const line of lines) { + if (/^\d+\.\s+/.test(line.trim())) { + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + currentQuestionBlock = line + "\n"; + } else if (currentQuestionBlock.length > 0) { + currentQuestionBlock += line + "\n"; + } else if (tempBlocks.length === 0 && line.trim().length > 0) { + currentQuestionBlock = line + "\n"; + } + } + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + if (tempBlocks.length > 0) + questionBlocks = tempBlocks; + } + console.log(`Found ${questionBlocks.length} question blocks from Claude`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) + continue; + let questionText = lines[0].replace(/^\d+\.\s*/, "").trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/gi, "").replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, "").trim(); + if (cleanedLine.length > 0) { + choices.push(cleanedLine); + if (isCorrect) + correctAnswerIndex = choices.length - 1; + } + } + if (correctAnswerIndex === -1 && choices.length > 0) { + for (let i = 0; i < choices.length; i++) { + if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) { + choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim(); + correctAnswerIndex = i; + break; + } + } + if (correctAnswerIndex === -1) + correctAnswerIndex = 0; + } + if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } else if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from Claude`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from Claude:", error); + new import_obsidian22.Notice("Error parsing MCQ response from Claude. Please try again."); + return []; + } + } +}; + +// api/together-service.ts +var import_obsidian23 = require("obsidian"); +var TogetherService = class { + constructor(plugin) { + this.plugin = plugin; + } + async generateMCQs(notePath, noteContent, settings) { + if (!settings.togetherApiKey) { + new import_obsidian23.Notice("Together AI API key is not set. Please add it in the Spaceforge settings."); + return null; + } + if (!settings.togetherModel) { + new import_obsidian23.Notice("Together AI Model is not set. Please add it in the Spaceforge settings."); + return null; + } + try { + new import_obsidian23.Notice("Generating MCQs using Together AI..."); + let numQuestionsToGenerate; + if (settings.mcqQuestionAmountMode === "wordsPerQuestion" /* WordsPerQuestion */) { + const wordCount = noteContent.split(/\s+/).filter(Boolean).length; + numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); + console.log(`TogetherAI: Calculated ${numQuestionsToGenerate} questions based on ${wordCount} words and ${settings.mcqWordsPerQuestion} words/question setting.`); + } else { + numQuestionsToGenerate = settings.mcqQuestionsPerNote; + console.log(`TogetherAI: Using fixed number of questions: ${numQuestionsToGenerate}`); + } + const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); + const response = await this.makeApiRequest(prompt, settings); + const questions = this.parseResponse(response, settings, numQuestionsToGenerate); + if (questions.length === 0) { + new import_obsidian23.Notice("Failed to generate valid MCQs from Together AI. Please try again."); + return null; + } + return { + notePath, + questions, + generatedAt: Date.now() + }; + } catch (error) { + console.error("Error generating MCQs with Together AI:", error); + new import_obsidian23.Notice("Failed to generate MCQs with Together AI. Please check console for details."); + return null; + } + } + generatePrompt(noteContent, settings, numQuestionsToGenerate) { + const questionCount = numQuestionsToGenerate; + const choiceCount = settings.mcqChoicesPerQuestion; + const promptType = settings.mcqPromptType; + const difficulty = settings.mcqDifficulty; + let basePrompt = ""; + if (promptType === "basic") { + basePrompt = `Generate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; + } else { + basePrompt = `Generate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line. + +For example: +1. What is the capital of France? + A) London + B) Berlin + C) Paris [CORRECT] + D) Madrid + E) Rome`; + } + if (difficulty === "basic") { + basePrompt += ` + +Create straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`; + } else { + basePrompt += ` + +Create challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`; + } + return `${basePrompt} + +Note Content: +${noteContent}`; + } + async makeApiRequest(prompt, settings) { + var _a; + const apiKey = settings.togetherApiKey; + const model = settings.togetherModel; + const difficulty = settings.mcqDifficulty; + const systemPrompt = difficulty === "basic" ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; + console.log(`Making API request to Together AI using model: ${model} with difficulty: ${difficulty}`); + try { + const response = await fetch("https://api.together.xyz/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model, + max_tokens: 2048, + // Adjust as needed + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt } + ] + }) + }); + if (!response.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}): ${((_a = errorData.error) == null ? void 0 : _a.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 import_obsidian23.Notice(`Together AI API error: ${error.message}`); + throw error; + } + } + parseResponse(response, settings, numQuestionsToGenerate) { + const questions = []; + try { + console.log("Raw AI response from Together AI:", response); + let questionBlocks = response.split(/\n\d+\.\s+/).filter((block) => block.trim().length > 0); + if (questionBlocks.length > 0 && !/^\d+\.\s+/.test(response.trimStart())) { + if (!/^\d+\.\s+/.test(questionBlocks[0])) { + if (response.trimStart().length > 0 && questionBlocks.length === 1 && !response.includes("\n1.")) { + questionBlocks = response.split(/\n(?=\d+\.\s)/); + if (questionBlocks.length === 1 && !/^\d+\.\s/.test(questionBlocks[0])) { + const potentialBlocks = response.split(/\n\n+/); + if (potentialBlocks.some((pb) => /^\d+\.\s/.test(pb.trimStart()))) { + questionBlocks = potentialBlocks; + } + } + } + } + } + if (questionBlocks.length === 0 || questionBlocks.length < settings.mcqQuestionsPerNote / 2 && response.includes("1.")) { + const lines = response.split("\n"); + let currentQuestionBlock = ""; + const tempBlocks = []; + for (const line of lines) { + if (/^\d+\.\s+/.test(line.trim())) { + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + currentQuestionBlock = line + "\n"; + } else if (currentQuestionBlock.length > 0) { + currentQuestionBlock += line + "\n"; + } else if (tempBlocks.length === 0 && line.trim().length > 0) { + currentQuestionBlock = line + "\n"; + } + } + if (currentQuestionBlock.trim().length > 0) { + tempBlocks.push(currentQuestionBlock.trim()); + } + if (tempBlocks.length > 0) + questionBlocks = tempBlocks; + } + console.log(`Found ${questionBlocks.length} question blocks from Together AI`); + for (const block of questionBlocks) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + if (lines.length < 2) + continue; + let questionText = lines[0].replace(/^\d+\.\s*/, "").trim(); + questionText = questionText.replace(//g, "").replace(/<\/think>/g, ""); + const choices = []; + let correctAnswerIndex = -1; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + const isCorrect = line.includes("[CORRECT]"); + const cleanedLine = line.replace(/\[CORRECT\]/gi, "").replace(/^([A-Z]\.|[A-Z]\)|\d+\.|\d+\)|-\s*|\*\s*)/, "").trim(); + if (cleanedLine.length > 0) { + choices.push(cleanedLine); + if (isCorrect) + correctAnswerIndex = choices.length - 1; + } + } + if (correctAnswerIndex === -1 && choices.length > 0) { + for (let i = 0; i < choices.length; i++) { + if (choices[i].toLowerCase().includes("(correct answer)") || choices[i].toLowerCase().includes(" - correct")) { + choices[i] = choices[i].replace(/\(correct answer\)/gi, "").replace(/ - correct/gi, "").trim(); + correctAnswerIndex = i; + break; + } + } + if (correctAnswerIndex === -1) + correctAnswerIndex = 0; + } + if (questionText && choices.length >= settings.mcqChoicesPerQuestion - 1 && choices.length > 0) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } else if (questionText && choices.length >= 2) { + questions.push({ question: questionText, choices, correctAnswerIndex }); + } + } + console.log(`Successfully parsed ${questions.length} MCQ questions from Together AI`); + return questions.slice(0, numQuestionsToGenerate); + } catch (error) { + console.error("Error parsing MCQ response from Together AI:", error); + new import_obsidian23.Notice("Error parsing MCQ response from Together AI. Please try again."); + return []; + } + } +}; + +// services/review-schedule-service.ts +var import_obsidian24 = require("obsidian"); + +// services/fsrs-schedule-service.ts +var FsrsScheduleService = class { + constructor(settings) { + this.pluginSettings = settings; + this.fsrsInstance = new W(this.mapSettingsToFsrsParams(settings.fsrsParameters)); + } + mapSettingsToFsrsParams(params) { + var _a, _b, _c, _d; + const defaultWeights = [0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61]; + const mappedParams = { + request_retention: (_a = params.request_retention) != null ? _a : 0.9, + maximum_interval: (_b = params.maximum_interval) != null ? _b : 36500, + w: params.w && params.w.length > 0 ? params.w : defaultWeights, + enable_fuzz: (_c = params.enable_fuzz) != null ? _c : true, + learning_steps: params.learning_steps && params.learning_steps.length > 0 ? params.learning_steps : [1, 10], + enable_short_term: (_d = params.enable_short_term) != null ? _d : false + // Added enable_short_term + }; + return mappedParams; + } + updateFSRSInstance(settings) { + this.pluginSettings = settings; + this.fsrsInstance = new W(this.mapSettingsToFsrsParams(settings.fsrsParameters)); + } + createNewFsrsCardData(creationDate = /* @__PURE__ */ new Date()) { + const emptyCard = v(creationDate); + return { + stability: emptyCard.stability, + difficulty: emptyCard.difficulty, + elapsed_days: emptyCard.elapsed_days, + scheduled_days: emptyCard.scheduled_days, + reps: emptyCard.reps, + lapses: emptyCard.lapses, + state: emptyCard.state, + // Cast to number; FsrsState is an enum + last_review: emptyCard.last_review ? emptyCard.last_review.getTime() : void 0 + }; + } + mapReviewScheduleFsrsDataToFsrsLibCard(fsrsData, now) { + return { + ...fsrsData, + due: now, + // This will be the review date for the repeat() call + state: fsrsData.state, + last_review: fsrsData.last_review ? new Date(fsrsData.last_review) : void 0 + }; + } + mapFsrsLibRatingToTsFsrsRating(rating) { + switch (rating) { + case 1 /* Again */: + return l.Again; + case 2 /* Hard */: + return l.Hard; + case 3 /* Good */: + return l.Good; + case 4 /* Easy */: + return l.Easy; + default: + throw new Error(`Unknown FsrsRating: ${rating}`); + } + } + recordReview(currentFsrsData, rating, reviewDateTime) { + const fsrsLibCardToReview = this.mapReviewScheduleFsrsDataToFsrsLibCard(currentFsrsData, reviewDateTime); + const tsFsrsRating = this.mapFsrsLibRatingToTsFsrsRating(rating); + const schedulingResult = this.fsrsInstance.repeat(fsrsLibCardToReview, reviewDateTime); + const validRatingKey = tsFsrsRating; + const resultCard = schedulingResult[validRatingKey].card; + const resultLog = schedulingResult[validRatingKey].log; + const updatedData = { + stability: resultCard.stability, + difficulty: resultCard.difficulty, + elapsed_days: resultCard.elapsed_days, + scheduled_days: resultCard.scheduled_days, + reps: resultCard.reps, + lapses: resultCard.lapses, + state: resultCard.state, + last_review: resultCard.last_review ? resultCard.last_review.getTime() : void 0 + }; + return { + updatedData, + nextReviewDate: resultCard.due.getTime(), + log: resultLog + }; + } + skipReview(currentFsrsData, reviewDateTime) { + return this.recordReview(currentFsrsData, 1 /* Again */, reviewDateTime); + } +}; + +// services/review-schedule-service.ts +var ReviewScheduleService = class { + /** + * Initialize Review Schedule Service + * + * @param plugin Reference to the main plugin + * @param schedules Initial schedules data + * @param customNoteOrder Initial custom note order data + * @param lastLinkAnalysisTimestamp Initial last link analysis timestamp + * @param history Reference to the history array in DataStorage + */ + constructor(plugin, schedules, customNoteOrder, lastLinkAnalysisTimestamp, history) { + // Added FsrsScheduleService instance + /** + * Note schedules indexed by path + */ + this.schedules = {}; + /** + * Custom order for notes (user-defined ordering) + */ + this.customNoteOrder = []; + /** + * Timestamp of the last time link analysis was performed for ordering + */ + this.lastLinkAnalysisTimestamp = null; + this.plugin = plugin; + this.schedules = schedules; + this.customNoteOrder = customNoteOrder; + this.lastLinkAnalysisTimestamp = lastLinkAnalysisTimestamp; + this.history = history; + this.fsrsService = new FsrsScheduleService(this.plugin.settings); + } + updateAlgorithmServicesForSettingsChange() { + this.fsrsService.updateFSRSInstance(this.plugin.settings); + } + /** + * Schedule a note for review + * + * @param path Path to the note file + * @param daysFromNow Days until first review (default: 0, same day) + */ + async scheduleNoteForReview(path, daysFromNow = 0) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file || !(file instanceof import_obsidian24.TFile) || file.extension !== "md") { + new import_obsidian24.Notice("Only markdown files can be added to the review schedule"); + return; + } + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm; + let newSchedule; + if (defaultAlgorithm === "fsrs") { + const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now)); + newSchedule = { + path, + lastReviewDate: null, + // Will be UTC midnight when set + nextReviewDate: now, + // FSRS cards are due immediately (exact timestamp) + reviewCount: 0, + schedulingAlgorithm: "fsrs", + fsrsData, + // SM-2 fields can be undefined or default + ease: this.plugin.settings.baseEase, + // Keep a base for potential conversion + interval: 0, + consecutive: 0, + repetitionCount: 0, + scheduleCategory: void 0 + // Not applicable to FSRS + }; + } else { + newSchedule = { + path, + lastReviewDate: null, + // Will be UTC midnight when set + nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), + ease: this.plugin.settings.baseEase, + interval: daysFromNow, + consecutive: 0, + reviewCount: 0, + repetitionCount: 0, + scheduleCategory: this.plugin.settings.useInitialSchedule ? "initial" : "spaced", + schedulingAlgorithm: "sm2", + fsrsData: void 0 + }; + if (newSchedule.scheduleCategory === "initial") { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals; + if (initialIntervals && initialIntervals.length > 0) { + newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0]; + } + if (daysFromNow === 0) { + newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval); + } + } + } + this.schedules[path] = newSchedule; + if (!this.customNoteOrder.includes(path)) { + this.customNoteOrder.push(path); + } + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + new import_obsidian24.Notice(`Note added to review schedule`); + } + /** + * Check if a note is due for review on or before the specified date + * + * @param schedule The review schedule for the note + * @param effectiveReviewDate The date to check against + * @returns true if the note is due, false otherwise + */ + isNoteDue(schedule, effectiveReviewDate) { + const reviewDateObj = new Date(effectiveReviewDate); + const effectiveUTCDayEnd = DateUtils.endOfUTCDay(reviewDateObj); + return schedule.nextReviewDate <= effectiveUTCDayEnd; + } + /** + * Record a review for a note + * + * @param path Path to the note file + * @param response User's response during review (can be SM-2 or FSRS rating) + * @param isSkipped Whether this review was explicitly skipped (default: false) + * @param currentReviewDate Optional timestamp for the current review date (simulated or actual) + * @returns true if the review was recorded, false if it was just a preview + */ + async recordReview(path, response, isSkipped = false, currentReviewDate) { + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + const schedule = this.schedules[path]; + if (!schedule) + return false; + const effectiveReviewDate = currentReviewDate || Date.now(); + const isDue = this.isNoteDue(schedule, effectiveReviewDate); + if (!isDue) { + return false; + } + const reviewDateObj = new Date(effectiveReviewDate); + const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj); + let historyResponseValue; + if (Object.values(FsrsRating).includes(response) && typeof response === "number") { + switch (response) { + case 1 /* Again */: + historyResponseValue = 1 /* IncorrectResponse */; + break; + case 2 /* Hard */: + historyResponseValue = 2 /* IncorrectButFamiliar */; + break; + case 3 /* Good */: + historyResponseValue = 3 /* CorrectWithDifficulty */; + break; + case 4 /* Easy */: + historyResponseValue = 4 /* CorrectWithHesitation */; + break; + default: + historyResponseValue = 3 /* CorrectWithDifficulty */; + } + } else { + historyResponseValue = toSM2Quality(response); + } + if (!isSkipped) { + schedule.reviewCount = (schedule.reviewCount || 0) + 1; + } + schedule.lastReviewDate = effectiveUTCDayStart; + this.history.push({ + path, + timestamp: effectiveReviewDate, + response: historyResponseValue, + interval: (_c = (_b = schedule.interval) != null ? _b : (_a = schedule.fsrsData) == null ? void 0 : _a.scheduled_days) != null ? _c : 0, + ease: (_e = schedule.ease) != null ? _e : ((_d = schedule.fsrsData) == null ? void 0 : _d.difficulty) ? Math.round(schedule.fsrsData.difficulty * 10) : this.plugin.settings.baseEase, + isSkipped + }); + if (this.history.length > 1e3) + this.history.splice(0, this.history.length - 1e3); + if (schedule.schedulingAlgorithm === "fsrs") { + if (!schedule.fsrsData) { + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj); + } + let actualFsrsRating; + if (response === 5 /* Perfect */) { + actualFsrsRating = 4 /* Easy */; + } else if (response === 4 /* Good */) { + actualFsrsRating = 3 /* Good */; + } else if (response === 3 /* Fair */) { + actualFsrsRating = 2 /* Hard */; + } else if (response === 1 /* Hard */) { + actualFsrsRating = 1 /* Again */; + } else if (Object.values(FsrsRating).includes(response)) { + actualFsrsRating = response; + } else { + const quality = toSM2Quality(response); + if (quality >= 4) + actualFsrsRating = 4 /* Easy */; + else if (quality === 3) + actualFsrsRating = 3 /* Good */; + else if (quality === 2) + actualFsrsRating = 2 /* Hard */; + else + actualFsrsRating = 1 /* Again */; + } + const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.recordReview( + schedule.fsrsData, + actualFsrsRating, + // Pass the correctly determined FsrsRating + reviewDateObj + // Pass the exact moment of review + ); + schedule.fsrsData = updatedData; + schedule.nextReviewDate = newNextReviewDateFsrs; + schedule.interval = updatedData.scheduled_days; + schedule.ease = Math.round(updatedData.difficulty * 10); + } else { + let qualityRating = toSM2Quality(response); + schedule.ease = (_f = schedule.ease) != null ? _f : this.plugin.settings.baseEase; + schedule.interval = (_g = schedule.interval) != null ? _g : 0; + schedule.repetitionCount = (_h = schedule.repetitionCount) != null ? _h : 0; + schedule.consecutive = (_i = schedule.consecutive) != null ? _i : 0; + schedule.scheduleCategory = (_j = schedule.scheduleCategory) != null ? _j : this.plugin.settings.useInitialSchedule ? "initial" : "spaced"; + if (schedule.scheduleCategory === "initial") { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals || []; + if (schedule.repetitionCount < initialIntervals.length) { + schedule.interval = initialIntervals[schedule.repetitionCount]; + } else { + schedule.scheduleCategory = "graduated"; + const daysLateForGraduation = 0; + const { interval, ease, repetitionCount: newRepCount } = this.calculateSM2Schedule( + schedule.interval, + // previous interval (last of initial steps) + schedule.ease, + qualityRating, + 0, + // Reset repetition count for SM-2 calculation after graduation + daysLateForGraduation, + isSkipped + ); + schedule.interval = interval; + schedule.ease = ease; + schedule.repetitionCount = newRepCount; + } + const q2 = qualityRating; + let newEase = schedule.ease / 100; + newEase = newEase + (0.1 - (5 - q2) * (0.08 + (5 - q2) * 0.02)); + newEase = Math.max(1.3, newEase); + schedule.ease = Math.round(newEase * 100); + if (qualityRating >= 3 /* CorrectWithDifficulty */) { + schedule.consecutive += 1; + if (qualityRating >= 3) { + schedule.repetitionCount = (schedule.repetitionCount || 0) + 1; + } else { + schedule.repetitionCount = 0; + } + } else { + schedule.consecutive = 0; + schedule.repetitionCount = 0; + } + } else { + const daysLate = schedule.nextReviewDate < effectiveUTCDayStart ? ( + // Compare with UTC day start + DateUtils.dayDifferenceUTC(schedule.nextReviewDate, effectiveUTCDayStart) + ) : 0; + const { interval, ease, repetitionCount } = this.calculateSM2Schedule( + schedule.interval, + schedule.ease, + qualityRating, + schedule.repetitionCount || 0, + daysLate, + isSkipped + ); + schedule.interval = interval; + schedule.ease = ease; + schedule.repetitionCount = repetitionCount; + if (qualityRating >= 3 /* CorrectWithDifficulty */) { + schedule.consecutive += 1; + } else { + schedule.consecutive = 0; + } + } + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, schedule.interval); + } + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + return true; + } + /** + * Calculate new schedule parameters based on review response using the SM-2 algorithm + * (This method is likely redundant now that recordReview uses calculateSM2Schedule directly, + * but keeping for potential external use or backward compatibility if needed) + * + * @param currentInterval Current interval in days + * @param currentEase Current ease factor + * @param response User's response during review + * @param repetitionCount Current repetition count (n) + * @param daysLate How many days late the review is (0 if on time or early) + * @param isSkipped Whether the item was explicitly skipped by the user + * @returns New interval, ease, and repetition count + */ + calculateNewSchedule(currentInterval, currentEase, response, repetitionCount = 0, daysLate = 0, isSkipped = false) { + let qualityRating = toSM2Quality(response); + if (isSkipped || daysLate > 0) { + const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0; + let ease2 = currentEase / 100; + ease2 = ease2 + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02)); + ease2 = Math.max(1.3, ease2); + return { + interval: 1, + // Force next review to be tomorrow + ease: Math.round(ease2 * 100), + // Convert back to internal format + repetitionCount: 1 + // Reset repetition count to 1 + }; + } + let ease = currentEase / 100; + let newRepetitionCount = repetitionCount; + let interval; + ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02)); + ease = Math.max(1.3, ease); + if (qualityRating < 3) { + newRepetitionCount = 0; + interval = 1; + } else { + newRepetitionCount += 1; + if (newRepetitionCount === 1) { + interval = 1; + } else if (newRepetitionCount === 2) { + interval = 6; + } else { + interval = Math.round(currentInterval * ease); + } + } + if (this.plugin.settings.loadBalance) { + const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0; + interval = interval + Math.random() * fuzz * 2 - fuzz; + } + interval = Math.max(1, interval); + interval = Math.min(interval, this.plugin.settings.maximumInterval); + return { + interval: Math.round(interval), + // SM-2 uses whole days + ease: Math.round(ease * 100), + repetitionCount: newRepetitionCount + }; + } + /** + * Calculate new schedule parameters using the enhanced SM-2 algorithm with lateness penalty + * (This is the core calculation logic used internally by recordReview and skipNote) + * + * @param currentInterval Current interval in days + * @param currentEase Current ease factor (expressed as a number where 2.5 = 250) + * @param qualityRating User's response during review, as a numeric quality rating (0-5) + * @param repetitionCount Current repetition count (n) + * @param daysLate How many days late the review is (0 if on time or early) + * @param isSkipped Whether the item was explicitly skipped by the user + * @returns New interval, ease, and repetition count + */ + calculateSM2Schedule(currentInterval, currentEase, qualityRating, repetitionCount = 0, daysLate = 0, isSkipped = false) { + if (isSkipped || daysLate > 0) { + console.log(`[Modified SM-2] Processing ${isSkipped ? "skipped" : "overdue"} item: + - Days late: ${daysLate} + - Original quality: ${qualityRating} + - Current interval: ${currentInterval} + - Current ease: ${currentEase / 100}`); + const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0; + console.log(`[Modified SM-2] Applied penalty: + - Effective quality: ${q_eff}`); + let ease2 = currentEase / 100; + ease2 = ease2 + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02)); + ease2 = Math.max(1.3, ease2); + const result = { + interval: 1, + // Force next review to be tomorrow + ease: Math.round(ease2 * 100), + // Convert back to internal format + repetitionCount: 1 + // Reset repetition count to 1 + }; + console.log(`[Modified SM-2] Result for ${isSkipped ? "skipped" : "overdue"} item: + - New interval: ${result.interval} day(s) + - New ease: ${result.ease / 100} + - New repetition count: ${result.repetitionCount}`); + return result; + } + let ease = currentEase / 100; + let newRepetitionCount = repetitionCount; + let interval; + ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02)); + ease = Math.max(1.3, ease); + if (qualityRating < 3) { + newRepetitionCount = 0; + interval = 1; + } else { + newRepetitionCount += 1; + if (newRepetitionCount === 1) { + interval = 1; + } else if (newRepetitionCount === 2) { + interval = 6; + } else { + interval = Math.round(currentInterval * ease); + } + } + if (this.plugin.settings.loadBalance) { + const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0; + interval = interval + Math.random() * fuzz * 2 - fuzz; + } + interval = Math.max(1, interval); + interval = Math.min(interval, this.plugin.settings.maximumInterval); + return { + interval: Math.round(interval), + // SM-2 uses whole days + ease: Math.round(ease * 100), + repetitionCount: newRepetitionCount + }; + } + /** + * Get notes due for review + * + * @param date Optional target date (default: now) + * @param matchExactDate If true, only return notes due exactly on this date (ignoring time). Otherwise, notes due on or before this date. + * @returns Array of due note schedules sorted by due date + */ + getDueNotes(date = Date.now(), matchExactDate = false) { + const targetUTCDayStartForSM2 = DateUtils.startOfUTCDay(new Date(date)); + const targetUTCDayEndForFSRS = DateUtils.endOfUTCDay(new Date(date)); + return Object.values(this.schedules).filter((schedule) => { + if (schedule.schedulingAlgorithm === "fsrs") { + if (matchExactDate) { + return schedule.nextReviewDate >= targetUTCDayStartForSM2 && schedule.nextReviewDate <= targetUTCDayEndForFSRS; + } else { + return schedule.nextReviewDate <= targetUTCDayEndForFSRS; + } + } else { + if (matchExactDate) { + return schedule.nextReviewDate === targetUTCDayStartForSM2; + } + return schedule.nextReviewDate <= targetUTCDayStartForSM2; + } + }).sort((a, b2) => a.nextReviewDate - b2.nextReviewDate); + } + /** + * Get upcoming reviews within a specified timeframe + * + * @param days Number of days to look ahead + * @returns Array of upcoming review schedules sorted by due date + */ + getUpcomingReviews(days = 7) { + const now = Date.now(); + const futureDate = DateUtils.addDays(now, days); + return Object.values(this.schedules).filter( + (schedule) => schedule.nextReviewDate > now && schedule.nextReviewDate <= futureDate + ).sort((a, b2) => a.nextReviewDate - b2.nextReviewDate); + } + /** + * Skip a note's review and reschedule for tomorrow with penalized quality + * + * This implements the "Postpone to Tomorrow" functionality from the modified SM-2 algorithm. + * It applies a one-step quality penalty (reduce by 1 but not below 0) and forces the next + * review to be tomorrow, regardless of what the normal interval would be. This keeps items + * in rotation rather than letting them disappear into an ever-growing backlog. + * + * @param path Path to the note file + * @param response Optional user's response to use for penalty calculation + * @param currentReviewDate Optional timestamp for the current review date (simulated or actual) + */ + async skipNote(path, response = 3 /* CorrectWithDifficulty */, currentReviewDate) { + const schedule = this.schedules[path]; + if (!schedule) + return; + console.log(`Skipping note ${path} with algorithm ${schedule.schedulingAlgorithm}`); + const effectiveReviewDate = currentReviewDate || Date.now(); + const reviewDateObj = new Date(effectiveReviewDate); + if (schedule.schedulingAlgorithm === "fsrs") { + if (!schedule.fsrsData) { + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj); + } + const { updatedData, nextReviewDate: newNextReviewDateFsrs, log } = this.fsrsService.skipReview( + schedule.fsrsData, + reviewDateObj + // Pass exact moment for FSRS skip + ); + schedule.fsrsData = updatedData; + schedule.nextReviewDate = newNextReviewDateFsrs; + schedule.lastReviewDate = DateUtils.startOfUTCDay(reviewDateObj); + this.history.push({ + // Log FSRS skip + path, + timestamp: effectiveReviewDate, + response: 1 /* IncorrectResponse */, + // Approx. for log + interval: schedule.fsrsData.scheduled_days, + ease: Math.round(schedule.fsrsData.difficulty * 10), + isSkipped: true + }); + } else { + let qualityRating = toSM2Quality(response); + qualityRating = Math.max(0, qualityRating - 1); + this.history.push({ + path, + timestamp: effectiveReviewDate, + response: qualityRating, + interval: schedule.interval || 0, + ease: schedule.ease || 0, + isSkipped: true + }); + const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj); + schedule.lastReviewDate = effectiveUTCDayStart; + if (schedule.scheduleCategory === "initial") { + schedule.interval = 1; + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, 1); + } else { + const { interval, ease, repetitionCount } = this.calculateSM2Schedule( + schedule.interval || 0, + schedule.ease || this.plugin.settings.baseEase, + qualityRating, + schedule.repetitionCount || 0, + 0, + true + // daysLate = 0 for a skip, isSkipped = true + ); + schedule.interval = interval; + schedule.ease = ease; + schedule.repetitionCount = repetitionCount; + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, interval); + } + schedule.consecutive = 0; + } + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + /** + * Postpone a note's review + * + * @param path Path to the note file + * @param days Number of days to postpone (default: 1) + */ + async postponeNote(path, days = 1) { + const schedule = this.schedules[path]; + if (!schedule) + return; + schedule.nextReviewDate = DateUtils.addDays(schedule.nextReviewDate, days); + if (this.plugin.events) { + setTimeout(() => { + this.plugin.events.emit("sidebar-update"); + }, 50); + } + new import_obsidian24.Notice(`Review postponed for ${days} day${days !== 1 ? "s" : ""}`); + } + /** + * Advance a note's review by one day, if eligible. + * + * @param path Path to the note file + * @returns True if the note was advanced, false otherwise. + */ + async advanceNote(path) { + const schedule = this.schedules[path]; + if (!schedule) { + console.warn(`[ReviewScheduleService] Advance: Schedule not found for path ${path}`); + return false; + } + const todayUTCMidnight = DateUtils.startOfUTCDay(/* @__PURE__ */ new Date()); + const noteReviewUTCDayStart = DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate)); + if (noteReviewUTCDayStart <= todayUTCMidnight) { + return false; + } + const newPotentialNextReviewTimestamp = DateUtils.addDays(schedule.nextReviewDate, -1); + if (schedule.schedulingAlgorithm === "sm2") { + schedule.nextReviewDate = Math.max(todayUTCMidnight, DateUtils.startOfUTCDay(new Date(newPotentialNextReviewTimestamp))); + } else { + schedule.nextReviewDate = Math.max(todayUTCMidnight, newPotentialNextReviewTimestamp); + } + if (this.plugin.events) { + setTimeout(() => { + this.plugin.events.emit("sidebar-update"); + }, 50); + } + return true; + } + /** + * Remove a note from the review schedule + * + * @param path Path to the note file + */ + async removeFromReview(path) { + if (this.schedules[path]) { + delete this.schedules[path]; + this.customNoteOrder = this.customNoteOrder.filter((p2) => p2 !== path); + new import_obsidian24.Notice("Note removed from review schedule"); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + } + /** + * Clear all review schedules + */ + async clearAllSchedules() { + this.schedules = {}; + this.customNoteOrder = []; + new import_obsidian24.Notice("All review schedules have been cleared"); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + if (this.plugin.reviewController) { + await this.plugin.reviewController.updateTodayNotes(); + } + } + /** + * Estimate review time for a note + * + * @param path Path to the note file + * @returns Estimated review time in seconds + */ + async estimateReviewTime(path) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!(file instanceof import_obsidian24.TFile)) + return 60; + try { + const content = await this.plugin.app.vault.read(file); + return EstimationUtils.estimateReviewTime(file, content); + } catch (error) { + console.error("Error estimating review time:", error); + return 60; + } + } + /** + * Schedule multiple notes for review in a specific order + * + * @param paths Array of note paths in the order they should be processed + * @param daysFromNow Days until first review (default: 0, same day) + * @returns Number of notes scheduled + */ + async scheduleNotesInOrder(paths, daysFromNow = 0) { + let count = 0; + for (const path of paths) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file || !(file instanceof import_obsidian24.TFile) || file.extension !== "md" || this.schedules[path]) { + continue; + } + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm; + let newSchedule; + if (defaultAlgorithm === "fsrs") { + const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now)); + newSchedule = { + path, + lastReviewDate: null, + nextReviewDate: now, + reviewCount: 0, + // FSRS due now + schedulingAlgorithm: "fsrs", + fsrsData, + ease: this.plugin.settings.baseEase, + interval: 0, + consecutive: 0, + repetitionCount: 0, + scheduleCategory: void 0 + }; + } else { + newSchedule = { + path, + lastReviewDate: null, + nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), + // UTC midnight + ease: this.plugin.settings.baseEase, + interval: daysFromNow, + consecutive: 0, + reviewCount: 0, + repetitionCount: 0, + scheduleCategory: this.plugin.settings.useInitialSchedule ? "initial" : "spaced", + schedulingAlgorithm: "sm2", + fsrsData: void 0 + }; + if (newSchedule.scheduleCategory === "initial") { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals; + if (initialIntervals && initialIntervals.length > 0) { + newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0]; + } + if (daysFromNow === 0) { + newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval); + } + } + } + this.schedules[path] = newSchedule; + if (!this.customNoteOrder.includes(path)) { + this.customNoteOrder.push(path); + } + count++; + } + if (count > 0) { + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + return count; + } + /** + * Update the custom note order - used to maintain user-defined ordering + * + * @param order Array of note paths in desired order + */ + async updateCustomNoteOrder(order) { + const uniqueValidPaths = Array.from(new Set(order)).filter((path) => this.schedules[path] !== void 0); + this.customNoteOrder = uniqueValidPaths; + console.log(`Updated custom note order with ${this.customNoteOrder.length} unique entries`); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + /** + * Get due notes ordered by custom order if available + * + * @param date Optional target date (default: now) + * @param useCustomOrder Whether to apply custom ordering (default: true) + * @param matchExactDate Passed to getDueNotes to filter by exact date if true. + * @returns Array of due note schedules sorted appropriately + */ + getDueNotesWithCustomOrder(date = Date.now(), useCustomOrder = true, matchExactDate = false) { + const dueNotes = this.getDueNotes(date, matchExactDate); + if (!useCustomOrder || this.customNoteOrder.length === 0) { + return dueNotes; + } + const notesByPath = {}; + dueNotes.forEach((note) => { + notesByPath[note.path] = note; + }); + const notesInOrder = []; + const orderedPaths = /* @__PURE__ */ new Set(); + for (const path of this.customNoteOrder) { + if (notesByPath[path]) { + notesInOrder.push(notesByPath[path]); + orderedPaths.add(path); + } + } + for (const note of dueNotes) { + if (!orderedPaths.has(note.path)) { + notesInOrder.push(note); + } + } + return notesInOrder; + } + /** + * Handles the renaming of a note file. + * Updates the schedule and custom order if the note was scheduled. + * + * @param oldPath The original path of the note. + * @param newPath The new path of the note. + */ + handleNoteRename(oldPath, newPath) { + if (this.schedules[oldPath]) { + const schedule = this.schedules[oldPath]; + delete this.schedules[oldPath]; + schedule.path = newPath; + this.schedules[newPath] = schedule; + const oldPathIndex = this.customNoteOrder.indexOf(oldPath); + if (oldPathIndex > -1) { + this.customNoteOrder[oldPathIndex] = newPath; + } else { + if (!this.customNoteOrder.includes(newPath)) { + this.customNoteOrder.push(newPath); + } + } + console.log(`Handled rename from ${oldPath} to ${newPath}. Schedule updated.`); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + } + // Helper method for backward compatibility (moved from DataStorage) + getRepetitionCount(interval) { + if (interval <= 1) + return 0; + if (interval <= 6) + return 1; + return 2; + } + async convertAllSm2ToFsrs() { + let convertedCount = 0; + for (const path in this.schedules) { + if (Object.prototype.hasOwnProperty.call(this.schedules, path)) { + const schedule = this.schedules[path]; + if (schedule.schedulingAlgorithm === "sm2") { + schedule.schedulingAlgorithm = "fsrs"; + const baseDate = schedule.lastReviewDate ? new Date(schedule.lastReviewDate) : /* @__PURE__ */ new Date(); + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(baseDate); + schedule.nextReviewDate = baseDate.getTime(); + schedule.ease = this.plugin.settings.baseEase; + schedule.interval = 0; + schedule.repetitionCount = 0; + schedule.consecutive = 0; + schedule.scheduleCategory = void 0; + convertedCount++; + } + } + } + console.log(`Converted ${convertedCount} SM-2 cards to FSRS.`); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } + async convertAllFsrsToSm2() { + let convertedCount = 0; + for (const path in this.schedules) { + if (Object.prototype.hasOwnProperty.call(this.schedules, path)) { + const schedule = this.schedules[path]; + if (schedule.schedulingAlgorithm === "fsrs") { + schedule.schedulingAlgorithm = "sm2"; + schedule.ease = this.plugin.settings.baseEase; + schedule.interval = 0; + schedule.repetitionCount = 0; + schedule.consecutive = 0; + schedule.scheduleCategory = this.plugin.settings.useInitialSchedule ? "initial" : "spaced"; + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + let nextReview = DateUtils.addDays(todayUTCStart, 0); + if (schedule.scheduleCategory === "initial" && this.plugin.settings.initialScheduleCustomIntervals.length > 0) { + schedule.interval = this.plugin.settings.initialScheduleCustomIntervals[0]; + nextReview = DateUtils.addDays(todayUTCStart, schedule.interval); + } + schedule.nextReviewDate = nextReview; + schedule.fsrsData = void 0; + convertedCount++; + } + } + } + console.log(`Converted ${convertedCount} FSRS cards to SM-2.`); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + } +}; + +// services/review-history-service.ts +var ReviewHistoryService = class { + /** + * Initialize Review History Service + * + * @param history Reference to the history array in DataStorage + */ + constructor(history) { + this.history = history; + } + /** + * Get review history for a specific note + * + * @param path Path to the note file + * @returns Array of review history items for the note + */ + getNoteHistory(path) { + return this.history.filter((item) => item.path === path).sort((a, b2) => b2.timestamp - a.timestamp); + } + /** + * Add a history item (used by ReviewScheduleService) + * This method is here to centralize history management, even if called from another service. + * + * @param item The history item to add + */ + addHistoryItem(item) { + this.history.push(item); + if (this.history.length > 1e3) { + this.history = this.history.slice(-1e3); + } + } +}; + +// services/review-session-service.ts +var import_obsidian25 = require("obsidian"); + +// models/review-session.ts +function generateSessionId(prefix = "session") { + return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} +function getNextFileInSession(session) { + if (!session.hierarchy.traversalOrder.length) { + return null; + } + if (session.currentIndex >= session.hierarchy.traversalOrder.length) { + return null; + } + return session.hierarchy.traversalOrder[session.currentIndex]; +} +function advanceSession(session) { + return { + ...session, + currentIndex: session.currentIndex + 1, + updatedAt: Date.now() + }; +} +function isSessionComplete(session) { + return session.currentIndex >= session.hierarchy.traversalOrder.length; +} + +// services/review-session-service.ts +var ReviewSessionService = class { + /** + * Initialize Review Session Service + * + * @param plugin Reference to the main plugin + * @param reviewSessions Reference to the reviewSessions object in DataStorage + */ + constructor(plugin, reviewSessions) { + this.plugin = plugin; + this.reviewSessions = reviewSessions; + } + /** + * Create a new review session for a folder + * + * @param folderPath Path to the folder + * @param name Name for the session + * @returns Created review session or null if failed + */ + async createReviewSession(folderPath, name) { + const folder = this.plugin.app.vault.getAbstractFileByPath(folderPath); + if (!folder || !(folder instanceof import_obsidian25.TFolder)) { + new import_obsidian25.Notice("Invalid folder for review session"); + return null; + } + try { + const includeSubfolders = this.plugin.settings.includeSubfolders; + const hierarchy = await LinkAnalyzer.analyzeFolder( + this.plugin.app.vault, + folder, + includeSubfolders + ); + const id = generateSessionId(folder.name); + const session = { + id, + name: name || folder.name, + path: folderPath, + hierarchy, + currentIndex: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + isActive: false + }; + this.reviewSessions.sessions[id] = session; + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + return session; + } catch (error) { + console.error("Error creating review session:", error); + new import_obsidian25.Notice("Failed to create review session"); + return null; + } + } + /** + * Set the active review session + * + * @param sessionId ID of the session to activate + * @returns Whether the session was activated + */ + async setActiveSession(sessionId) { + if (sessionId === null) { + this.reviewSessions.activeSessionId = null; + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + return true; + } + const session = this.reviewSessions.sessions[sessionId]; + if (!session) { + return false; + } + this.reviewSessions.activeSessionId = sessionId; + session.isActive = true; + session.updatedAt = Date.now(); + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + return true; + } + /** + * Get the active review session + * + * @returns Active review session or null if none + */ + getActiveSession() { + const id = this.reviewSessions.activeSessionId; + if (!id) { + return null; + } + return this.reviewSessions.sessions[id] || null; + } + /** + * Get the next file to review in the active session + * + * @returns Path to the next file or null if done + */ + getNextSessionFile() { + const session = this.getActiveSession(); + if (!session) { + return null; + } + return getNextFileInSession(session); + } + /** + * Advance to the next file in the active session + * + * @returns Whether there are more files to review + */ + async advanceActiveSession() { + const session = this.getActiveSession(); + if (!session) { + return false; + } + const updatedSession = advanceSession(session); + this.reviewSessions.sessions[session.id] = updatedSession; + if (isSessionComplete(updatedSession)) { + updatedSession.isActive = false; + this.reviewSessions.activeSessionId = null; + new import_obsidian25.Notice(`Completed review session: ${updatedSession.name}`); + } + if (this.plugin.events) { + this.plugin.events.emit("sidebar-update"); + } + return !isSessionComplete(updatedSession); + } + /** + * Schedule all files in a session for review + * (This method depends on ReviewScheduleService, will need to pass it in or access via plugin) + * + * @param sessionId ID of the session + * @returns Number of files scheduled + */ + async scheduleSessionForReview(sessionId) { + const session = this.reviewSessions.sessions[sessionId]; + if (!session) { + return 0; + } + if (!this.plugin.reviewScheduleService) { + console.error("ReviewScheduleService not available on plugin instance."); + return 0; + } + return await this.plugin.reviewScheduleService.scheduleNotesInOrder(session.hierarchy.traversalOrder); + } +}; + +// services/mcq-service.ts +var MCQService = class { + /** + * Initialize MCQ Service + * + * @param mcqSets Reference to the mcqSets object in DataStorage + * @param mcqSessions Reference to the mcqSessions object in DataStorage + */ + constructor(mcqSets, mcqSessions) { + this.mcqSets = mcqSets; + this.mcqSessions = mcqSessions; + } + /** + * Save an MCQ set + * + * @param mcqSet MCQ set to save + * @returns The ID of the saved MCQ set + */ + saveMCQSet(mcqSet) { + const id = `${mcqSet.notePath}_${mcqSet.generatedAt}`; + this.mcqSets[id] = mcqSet; + return id; + } + /** + * Get the latest MCQ set for a note + * + * @param notePath Path to the note + * @returns MCQ set or null if none exists + */ + getMCQSetForNote(notePath) { + try { + if (!notePath) { + console.error("Invalid notePath provided to getMCQSetForNote"); + return null; + } + if (!this.mcqSets) { + console.warn("mcqSets not initialized"); + this.mcqSets = {}; + return null; + } + const sets = Object.values(this.mcqSets).filter((set) => set && set.notePath === notePath).sort((a, b2) => b2.generatedAt - a.generatedAt); + 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) { + try { + if (!session || !session.notePath || !session.mcqSetId) { + console.error("Invalid MCQ session data:", session); + return; + } + if (!this.mcqSessions) { + this.mcqSessions = {}; + } + if (!this.mcqSessions[session.notePath]) { + this.mcqSessions[session.notePath] = []; + } + const existingIndex = this.mcqSessions[session.notePath].findIndex( + (s) => s && s.mcqSetId === session.mcqSetId && s.startedAt === session.startedAt + ); + if (existingIndex >= 0) { + this.mcqSessions[session.notePath][existingIndex] = session; + } else { + this.mcqSessions[session.notePath].push(session); + } + if (this.mcqSessions[session.notePath].length > 10) { + this.mcqSessions[session.notePath].sort((a, b2) => b2.startedAt - a.startedAt); + this.mcqSessions[session.notePath] = this.mcqSessions[session.notePath].slice(0, 10); + } + } catch (error) { + console.error("Error saving MCQ session:", error); + } + } + /** + * Get all MCQ sessions for a note + * + * @param notePath Path to the note + * @returns Array of MCQ sessions + */ + getMCQSessionsForNote(notePath) { + return this.mcqSessions[notePath] || []; + } + /** + * Get the latest MCQ session for a note + * + * @param notePath Path to the note + * @returns Latest MCQ session or null + */ + getLatestMCQSessionForNote(notePath) { + const sessions = this.getMCQSessionsForNote(notePath).sort((a, b2) => b2.startedAt - a.startedAt); + return sessions.length > 0 ? sessions[0] : null; + } + /** + * Flags an MCQ set for regeneration. + * This is typically called when a note's review rating meets certain criteria. + * + * @param notePath Path to the note whose MCQ set should be flagged. + */ + flagMCQSetForRegeneration(notePath) { + const mcqSet = this.getMCQSetForNote(notePath); + if (mcqSet) { + mcqSet.needsQuestionRegeneration = true; + this.saveMCQSet(mcqSet); + console.log(`MCQSet for ${notePath} flagged for regeneration.`); + } else { + console.log(`No MCQSet found for ${notePath} to flag for regeneration.`); + } + } +}; + +// services/pomodoro-service.ts +var PomodoroService = class { + constructor(plugin) { + this.timerInterval = null; + this.plugin = plugin; + if (this.plugin.settings.pomodoroEnabled && this.plugin.pluginState.pomodoroIsRunning) { + this.recalculateTimeLeftFromEndTime(); + this.startTimerInterval(); + } + } + get settings() { + return this.plugin.settings; + } + get state() { + return this.plugin.pluginState; + } + // --- Public API --- + start() { + if (this.state.pomodoroIsRunning) + return; + this.state.pomodoroIsRunning = true; + if (this.state.pomodoroCurrentMode === "idle") { + this.switchToMode("work"); + } else if (this.state.pomodoroTimeLeftInSeconds <= 0) { + this.handleTimerEnd(); + } + this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1e3; + this.startTimerInterval(); + this.notifyUpdate(); + this.plugin.savePluginData(); + } + stop() { + if (!this.state.pomodoroIsRunning) + return; + this.stopTimerInterval(); + this.state.pomodoroIsRunning = false; + if (this.state.pomodoroEndTimeMs) { + const remainingMs = this.state.pomodoroEndTimeMs - Date.now(); + this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1e3)); + } + this.state.pomodoroEndTimeMs = null; + this.notifyUpdate(); + this.plugin.savePluginData(); + } + resetCurrentSession() { + this.stopTimerInterval(); + this.state.pomodoroIsRunning = false; + this.state.pomodoroEndTimeMs = null; + this.resetTimeForMode(this.state.pomodoroCurrentMode === "idle" ? "work" : this.state.pomodoroCurrentMode); + this.notifyUpdate(); + this.plugin.savePluginData(); + } + skipSession() { + this.stopTimerInterval(); + this.state.pomodoroIsRunning = false; + this.handleTimerEnd(true); + if (this.state.pomodoroIsRunning) { + this.startTimerInterval(); + } + this.notifyUpdate(); + this.plugin.savePluginData(); + } + getFormattedTimeLeft() { + const minutes = Math.floor(this.state.pomodoroTimeLeftInSeconds / 60); + const seconds = this.state.pomodoroTimeLeftInSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + updateDurations(work, short, long, sessions) { + this.settings.pomodoroWorkDuration = work; + this.settings.pomodoroShortBreakDuration = short; + this.settings.pomodoroLongBreakDuration = long; + this.settings.pomodoroSessionsUntilLongBreak = sessions; + const activeModesForDurationUpdate = ["work", "shortBreak", "longBreak"]; + if (!this.state.pomodoroIsRunning && activeModesForDurationUpdate.includes(this.state.pomodoroCurrentMode)) { + this.resetTimeForMode(this.state.pomodoroCurrentMode); + } + this.plugin.savePluginData(); + this.notifyUpdate(); + } + // --- Internal Logic --- + startTimerInterval() { + if (this.timerInterval !== null) { + window.clearInterval(this.timerInterval); + } + if (!this.settings.pomodoroEnabled || !this.state.pomodoroIsRunning) + return; + this.timerInterval = window.setInterval(() => { + this.tick(); + }, 1e3); + } + stopTimerInterval() { + if (this.timerInterval !== null) { + window.clearInterval(this.timerInterval); + this.timerInterval = null; + } + } + tick() { + if (!this.state.pomodoroIsRunning || !this.state.pomodoroEndTimeMs) { + this.stop(); + return; + } + const now = Date.now(); + const remainingMs = this.state.pomodoroEndTimeMs - now; + const remainingSeconds = Math.max(0, Math.round(remainingMs / 1e3)); + this.state.pomodoroTimeLeftInSeconds = remainingSeconds; + if (remainingSeconds <= 0) { + this.handleTimerEnd(); + } else { + this.notifyUpdate(); + } + } + handleTimerEnd(skipped = false) { + this.stopTimerInterval(); + if (this.settings.pomodoroSoundEnabled && !skipped) { + this.playSoundNotification(); + } + const currentMode = this.state.pomodoroCurrentMode; + let nextMode = "idle"; + if (currentMode === "work") { + this.state.pomodoroSessionsCompletedInCycle++; + if (this.state.pomodoroSessionsCompletedInCycle >= this.settings.pomodoroSessionsUntilLongBreak) { + nextMode = "longBreak"; + } else { + nextMode = "shortBreak"; + } + } else if (currentMode === "shortBreak" || currentMode === "longBreak") { + nextMode = "work"; + if (currentMode === "longBreak") { + this.state.pomodoroSessionsCompletedInCycle = 0; + } + } + const wasRunning = this.state.pomodoroIsRunning; + this.switchToMode(nextMode); + if (nextMode !== "idle") { + this.state.pomodoroIsRunning = true; + this.state.pomodoroEndTimeMs = Date.now() + this.state.pomodoroTimeLeftInSeconds * 1e3; + if (!wasRunning) { + this.startTimerInterval(); + } else if (!this.timerInterval) { + this.startTimerInterval(); + } + } else { + this.state.pomodoroIsRunning = false; + this.state.pomodoroEndTimeMs = null; + this.stopTimerInterval(); + } + this.plugin.savePluginData(); + this.notifyUpdate(); + } + switchToMode(mode) { + this.state.pomodoroCurrentMode = mode; + this.resetTimeForMode(mode); + } + resetTimeForMode(mode) { + switch (mode) { + case "work": + this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60; + break; + case "shortBreak": + this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroShortBreakDuration * 60; + break; + case "longBreak": + this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroLongBreakDuration * 60; + break; + case "idle": + this.state.pomodoroTimeLeftInSeconds = this.settings.pomodoroWorkDuration * 60; + break; + } + } + playSoundNotification() { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + if (!audioContext) + return; + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(440, audioContext.currentTime); + gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.5); + } catch (e) { + console.error("Could not play sound notification:", e); + } + } + notifyUpdate() { + this.plugin.events.emit("pomodoro-update"); + } + // Call this when global settings change from the settings tab + onSettingsChanged() { + if (!this.settings.pomodoroEnabled) { + this.stop(); + this.state.pomodoroCurrentMode = "idle"; + this.resetTimeForMode("idle"); + } else { + if (!this.state.pomodoroIsRunning) { + const currentMode = this.state.pomodoroCurrentMode; + const activeModes = ["work", "shortBreak", "longBreak"]; + if (activeModes.includes(currentMode)) { + this.resetTimeForMode(currentMode); + } + } + } + this.notifyUpdate(); + } + destroy() { + this.stopTimerInterval(); + } + // Recalculate time left based on stored end time, useful on load/reinit + recalculateTimeLeftFromEndTime() { + if (this.state.pomodoroIsRunning && this.state.pomodoroEndTimeMs) { + const now = Date.now(); + const remainingMs = this.state.pomodoroEndTimeMs - now; + this.state.pomodoroTimeLeftInSeconds = Math.max(0, Math.round(remainingMs / 1e3)); + if (remainingMs <= 0) { + console.log("Pomodoro timer ended while inactive. Handling potential transition."); + this.handleTimerEnd(true); + } + } else if (!this.state.pomodoroIsRunning) { + this.state.pomodoroEndTimeMs = null; + } + } + // Call this after pluginState has been externally modified (e.g., by data import) + reinitializeTimerFromState() { + this.stopTimerInterval(); + if (this.settings.pomodoroEnabled) { + this.recalculateTimeLeftFromEndTime(); + if (this.state.pomodoroIsRunning) { + this.startTimerInterval(); + } + } else { + this.stop(); + this.state.pomodoroCurrentMode = "idle"; + this.resetTimeForMode("idle"); + this.state.pomodoroEndTimeMs = null; + } + this.notifyUpdate(); + } +}; + +// main.ts +var SpaceforgePlugin = class extends import_obsidian26.Plugin { + constructor() { + super(...arguments); + this.stylesheetPath = "styles.css"; + this.stylesheetId = "spaceforge-styles"; + this.lastStylesModTime = null; + this.cssHotReloadIntervalId = null; + this.clickedDateFromCalendar = null; + } + async onload() { + var _a; + console.log("Loading Spaceforge plugin (version " + this.manifest.version + ")"); + this.events = new EventEmitter(); + this.settings = { ...DEFAULT_SETTINGS }; + this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA }; + this.reviewScheduleService = new ReviewScheduleService( + this, + this.pluginState.schedules, + this.pluginState.customNoteOrder, + (_a = this.pluginState.lastLinkAnalysisTimestamp) != null ? _a : null, + // Coalesce undefined to null + this.pluginState.history + ); + this.reviewHistoryService = new ReviewHistoryService(this.pluginState.history); + this.reviewSessionService = new ReviewSessionService(this, this.pluginState.reviewSessions); + this.mcqService = new MCQService(this.pluginState.mcqSets, this.pluginState.mcqSessions); + await this.loadPluginData(); + this.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + this.pomodoroService = new PomodoroService(this); + this.registerView( + "spaceforge-review-schedule", + (leaf) => this.sidebarView = new ReviewSidebarView(leaf, this) + ); + this.dataStorage = new DataStorage( + this, + this.reviewScheduleService, + this.reviewHistoryService, + this.reviewSessionService, + this.mcqService + ); + this.reviewController = new ReviewController(this, this.mcqService); + this.navigationController = new ReviewNavigationController(this); + this.sessionController = new ReviewSessionController(this); + this.batchController = new ReviewBatchController(this); + this.contextMenuHandler = new ContextMenuHandler(this); + this.initializeMCQComponents(); + EstimationUtils.setPlugin(this); + this.addIcons(); + this.contextMenuHandler.register(); + this.addRibbonIcon("calendar-clock", "Spaceforge Review", async () => { + await this.activateSidebarView(); + }); + this.addSettingTab(new SpaceforgeSettingTab(this.app, this)); + this.addCommands(); + this.addCommand({ + id: "add-selected-file-to-review", + name: "Add Selected File to Review Schedule (File Explorer)", + hotkeys: [{ modifiers: ["Alt", "Shift"], key: "s" }], + callback: () => { + const fileExplorerLeaf = this.app.workspace.getLeavesOfType("file-explorer")[0]; + if (fileExplorerLeaf && fileExplorerLeaf.view && fileExplorerLeaf.view.file) { + const selectedFile = fileExplorerLeaf.view.file; + if (selectedFile instanceof import_obsidian26.TFile && selectedFile.extension === "md") { + this.reviewScheduleService.scheduleNoteForReview(selectedFile.path).then(() => this.savePluginData()); + new import_obsidian26.Notice(`Added "${selectedFile.path}" to review schedule.`); + } else { + new import_obsidian26.Notice("Selected item is not a markdown file."); + } + } else { + new import_obsidian26.Notice("No file selected in file explorer."); + } + } + }); + this.registerEvent(this.app.workspace.on("file-open", (file) => { + })); + this.registerEvent(this.app.vault.on("delete", async (file) => { + if (file instanceof import_obsidian26.TFile && file.extension === "md") { + await this.reviewScheduleService.removeFromReview(file.path); + await this.savePluginData(); + } + if (this.sidebarView) + this.sidebarView.refresh(); + })); + this.registerEvent(this.app.vault.on("rename", async (file, oldPath) => { + if (file instanceof import_obsidian26.TFile && file.extension === "md") { + this.reviewScheduleService.handleNoteRename(oldPath, file.path); + await this.savePluginData(); + } + if (this.sidebarView) + this.sidebarView.refresh(); + })); + this.registerInterval(window.setInterval(() => { + if (this.sidebarView) + this.sidebarView.refresh(); + }, 60 * 1e3)); + this.app.workspace.onLayoutReady(() => this.activateSidebarView()); + this.addStylesheet(); + if (this.app.vault.adapter.stat && typeof this.app.vault.adapter.stat === "function") { + this.cssHotReloadIntervalId = window.setInterval(async () => { + try { + const stats = await this.app.vault.adapter.stat(this.stylesheetPath); + if (stats && (this.lastStylesModTime === null || this.lastStylesModTime < stats.mtime)) { + this.lastStylesModTime = stats.mtime; + console.log("Detected styles.css change, reloading stylesheet..."); + this.addStylesheet(); + } + } catch (error) { + } + }, 1e3); + this.registerInterval(this.cssHotReloadIntervalId); + } + if (this.settings.notifyBeforeDue > 0) { + this.registerInterval(window.setInterval(() => this.checkForDueNotes(), 5 * 60 * 1e3)); + } + this.registerInterval(window.setInterval(async () => { + console.log("Auto-saving data..."); + await this.savePluginData(); + }, 5 * 60 * 1e3)); + window.addEventListener("beforeunload", (event) => { + console.log("Window closing, saving data immediately..."); + let existingData = {}; + try { + const loadedData = this.loadData(); + if (loadedData && !(loadedData instanceof Promise)) { + existingData = loadedData; + } else if (loadedData instanceof Promise) { + console.warn("Synchronous loadData not available in beforeunload."); + } + } catch (loadError) { + console.warn("Could not load existing data during unload:", loadError); + } + try { + const reviewData = { + schedules: this.reviewScheduleService.schedules, + history: this.reviewHistoryService.history, + reviewSessions: this.reviewSessionService.reviewSessions, + mcqSets: this.mcqService.mcqSets, + mcqSessions: this.mcqService.mcqSessions, + customNoteOrder: this.reviewScheduleService.customNoteOrder, + lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp, + version: this.manifest.version + }; + const combinedData = { ...existingData, reviewData }; + let backupStr = JSON.stringify(combinedData); + window.localStorage.setItem("spaceforge-backup", backupStr); + console.log(`Saved emergency backup to localStorage (${Math.round(backupStr.length / 1024)}KB)`); + this.savePluginData().catch((e) => console.error("Error saving to Obsidian storage during unload:", e)); + } catch (error) { + console.error("Emergency data backup failed:", error); + try { + const minimalBackup = JSON.stringify({ settings: this.settings, reviewData: { schedules: this.reviewScheduleService.schedules || {}, customNoteOrder: this.reviewScheduleService.customNoteOrder || [], lastLinkAnalysisTimestamp: this.reviewScheduleService.lastLinkAnalysisTimestamp, version: this.manifest.version } }); + window.localStorage.setItem("spaceforge-minimal-backup", minimalBackup); + } catch (minimalError) { + console.error("Even minimal backup failed:", minimalError); + } + } + }); + } + async onunload() { + console.log("Unloading Spaceforge plugin"); + if (this.cssHotReloadIntervalId !== null) { + window.clearInterval(this.cssHotReloadIntervalId); + this.cssHotReloadIntervalId = null; + } + const styleEl = document.getElementById(this.stylesheetId); + if (styleEl) + styleEl.remove(); + let existingData = {}; + try { + 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 { + // const customPath = await this.app.loadLocalStorage('spaceforgeCustomDataPath'); + // return (customPath && typeof customPath === 'string' && customPath.trim() !== '') ? customPath.trim() : null; + // } + async _getEffectiveDataPath() { + var _a, _b; + if ((_a = this.settings) == null ? void 0 : _a.useCustomDataPath) { + const relativePath = (_b = this.settings.customDataPath) == null ? void 0 : _b.trim(); + if (relativePath && relativePath !== "") { + let pathForJson = relativePath; + if (!pathForJson.endsWith("/")) { + pathForJson += "/"; + } + pathForJson += "data.json"; + const vaultBasePath = this.app.vault.getRoot().path; + let absolutePath = (vaultBasePath ? vaultBasePath + "/" : "") + pathForJson; + absolutePath = (0, import_obsidian26.normalizePath)(absolutePath); + return absolutePath; + } else { + return null; + } + } + return null; + } + async loadPluginData() { + var _a, _b, _c, _d, _e, _f; + const lsUseCustomPath = await this.app.loadLocalStorage("spaceforge_useCustomDataPath"); + const lsCustomPathRelative = await this.app.loadLocalStorage("spaceforge_customDataPathRelative"); + this.settings = { ...DEFAULT_SETTINGS }; + if (typeof lsUseCustomPath === "boolean") { + this.settings.useCustomDataPath = lsUseCustomPath; + } + if (typeof lsCustomPathRelative === "string") { + this.settings.customDataPath = lsCustomPathRelative; + } + let rawLoadedData; + const effectivePath = await this._getEffectiveDataPath(); + const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`; + try { + if (effectivePath) { + if (await this.app.vault.adapter.exists(effectivePath)) { + const jsonData = await this.app.vault.adapter.read(effectivePath); + if (jsonData) + rawLoadedData = JSON.parse(jsonData); + new import_obsidian26.Notice(`Spaceforge: Loaded data from custom path: ${effectivePath}`, 3e3); + } else { + if (await this.app.vault.adapter.exists(defaultPluginDataPath)) { + new import_obsidian26.Notice(`Spaceforge: Custom data file not found at ${effectivePath}. Attempting to migrate from default location.`, 5e3); + try { + const oldJsonData = await this.app.vault.adapter.read(defaultPluginDataPath); + if (oldJsonData) { + rawLoadedData = JSON.parse(oldJsonData); + console.log(`Spaceforge: Data from default location will be migrated to ${effectivePath} on next save.`); + } + } catch (migrationReadError) { + console.error(`Spaceforge: Error reading data from default location for migration:`, migrationReadError); + } + } + if (!rawLoadedData) { + new import_obsidian26.Notice(`Spaceforge: No data file found at custom path ${effectivePath}. New data file will be created on save.`, 3e3); + } + } + } else { + rawLoadedData = await this.loadData(); + } + let loadedSettings = {}; + if ((rawLoadedData == null ? void 0 : rawLoadedData.settings) && typeof rawLoadedData.settings === "object") { + loadedSettings = rawLoadedData.settings; + } + this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings }; + if (typeof lsUseCustomPath === "boolean") { + this.settings.useCustomDataPath = lsUseCustomPath; + } + if (typeof lsCustomPathRelative === "string") { + this.settings.customDataPath = lsCustomPathRelative; + } + this.pluginState = { ...DEFAULT_APP_DATA.pluginState }; + if (rawLoadedData == null ? void 0 : rawLoadedData.pluginState) { + this.pluginState = { ...this.pluginState, ...rawLoadedData.pluginState }; + } else if (rawLoadedData && !rawLoadedData.pluginState && rawLoadedData.schedules) { + this.pluginState = { ...this.pluginState, ...rawLoadedData }; + } + this.pluginState.pomodoroCurrentMode = this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode; + this.pluginState.pomodoroTimeLeftInSeconds = this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds; + this.pluginState.pomodoroSessionsCompletedInCycle = this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle; + this.pluginState.pomodoroIsRunning = typeof this.pluginState.pomodoroIsRunning === "boolean" ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning; + if (this.pluginState.schedules) { + for (const path in this.pluginState.schedules) { + if (Object.prototype.hasOwnProperty.call(this.pluginState.schedules, path)) { + const schedule = this.pluginState.schedules[path]; + if (!schedule.schedulingAlgorithm) { + schedule.schedulingAlgorithm = "sm2"; + schedule.fsrsData = void 0; + if (!schedule.scheduleCategory) { + schedule.scheduleCategory = this.settings.useInitialSchedule ? "initial" : "spaced"; + } + } + if (schedule.schedulingAlgorithm === "sm2") { + schedule.ease = (_a = schedule.ease) != null ? _a : this.settings.baseEase; + schedule.interval = (_b = schedule.interval) != null ? _b : 0; + schedule.repetitionCount = (_c = schedule.repetitionCount) != null ? _c : 0; + schedule.consecutive = (_d = schedule.consecutive) != null ? _d : 0; + schedule.scheduleCategory = (_e = schedule.scheduleCategory) != null ? _e : this.settings.useInitialSchedule ? "initial" : "spaced"; + } + } + } + } + this.reviewScheduleService.schedules = this.pluginState.schedules || {}; + this.reviewHistoryService.history = this.pluginState.history || []; + this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null }; + this.mcqService.mcqSets = this.pluginState.mcqSets || {}; + this.mcqService.mcqSessions = this.pluginState.mcqSessions || {}; + this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || []; + this.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.pluginState.lastLinkAnalysisTimestamp === "number" ? this.pluginState.lastLinkAnalysisTimestamp : null; + } catch (error) { + console.error("Error loading plugin data:", error); + console.warn("Spaceforge: loadPluginData caught an error. Re-initializing settings and pluginState to defaults."); + this.settings = { ...DEFAULT_SETTINGS }; + this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA }; + this.reviewScheduleService.schedules = this.pluginState.schedules || {}; + this.reviewHistoryService.history = this.pluginState.history || []; + this.reviewSessionService.reviewSessions = this.pluginState.reviewSessions || { sessions: {}, activeSessionId: null }; + this.mcqService.mcqSets = this.pluginState.mcqSets || {}; + this.mcqService.mcqSessions = this.pluginState.mcqSessions || {}; + this.reviewScheduleService.customNoteOrder = this.pluginState.customNoteOrder || []; + this.reviewScheduleService.lastLinkAnalysisTimestamp = (_f = this.pluginState.lastLinkAnalysisTimestamp) != null ? _f : null; + if (this.reviewScheduleService) { + this.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } + new import_obsidian26.Notice("Spaceforge: Error loading data, initialized with defaults.", 5e3); + } + } + async savePluginData() { + var _a, _b; + try { + if (!this.settings || typeof this.settings !== "object") { + console.warn("Spaceforge: Settings object was invalid, resetting to defaults before save."); + this.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + } else { + this.settings = { ...DEFAULT_SETTINGS, ...this.settings }; + } + if (!this.pluginState) { + console.warn("Spaceforge: pluginState was undefined, initializing to default before save."); + this.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA }; + } + const currentPluginState = { + schedules: this.reviewScheduleService.schedules || {}, + history: this.reviewHistoryService.history || [], + reviewSessions: this.reviewSessionService.reviewSessions || { sessions: {}, activeSessionId: null }, + mcqSets: this.mcqService.mcqSets || {}, + mcqSessions: this.mcqService.mcqSessions || {}, + customNoteOrder: this.reviewScheduleService.customNoteOrder || [], + lastLinkAnalysisTimestamp: (_a = this.reviewScheduleService.lastLinkAnalysisTimestamp) != null ? _a : null, + pomodoroCurrentMode: this.pluginState.pomodoroCurrentMode || DEFAULT_PLUGIN_STATE_DATA.pomodoroCurrentMode, + pomodoroTimeLeftInSeconds: this.pluginState.pomodoroTimeLeftInSeconds || DEFAULT_PLUGIN_STATE_DATA.pomodoroTimeLeftInSeconds, + pomodoroSessionsCompletedInCycle: this.pluginState.pomodoroSessionsCompletedInCycle || DEFAULT_PLUGIN_STATE_DATA.pomodoroSessionsCompletedInCycle, + pomodoroIsRunning: typeof this.pluginState.pomodoroIsRunning === "boolean" ? this.pluginState.pomodoroIsRunning : DEFAULT_PLUGIN_STATE_DATA.pomodoroIsRunning, + pomodoroEndTimeMs: (_b = this.pluginState.pomodoroEndTimeMs) != null ? _b : null, + // Add the missing field + version: this.manifest.version + }; + this.pluginState = currentPluginState; + const dataToSave = { + settings: this.settings, + // Use the already prepared this.settings + pluginState: this.pluginState + }; + const effectiveSavePath = await this._getEffectiveDataPath(); + const defaultPluginDataPath = this.app.vault.configDir + `/plugins/${this.manifest.id}/data.json`; + if (effectiveSavePath) { + try { + const dirPathOnly = effectiveSavePath.substring(0, effectiveSavePath.lastIndexOf("/")); + if (dirPathOnly && !await this.app.vault.adapter.exists(dirPathOnly)) { + await this.app.vault.adapter.mkdir(dirPathOnly); + new import_obsidian26.Notice(`Spaceforge: Created directory for custom data: ${dirPathOnly}`, 3e3); + } + await this.app.vault.adapter.write(effectiveSavePath, JSON.stringify(dataToSave, null, 2)); + if (await this.app.vault.adapter.exists(defaultPluginDataPath)) { + await this.app.vault.adapter.remove(defaultPluginDataPath); + new import_obsidian26.Notice(`Spaceforge: Removed old data file from default plugin folder as custom path is active.`, 5e3); + console.log(`Spaceforge: Removed old data file at ${defaultPluginDataPath}`); + } + } catch (writeError) { + console.error(`Error saving data to custom path ${effectiveSavePath}:`, writeError); + new import_obsidian26.Notice(`Error saving data to custom path ${effectiveSavePath}: ${writeError.message}. Falling back to default path for this save.`, 1e4); + try { + await this.saveData(dataToSave); + new import_obsidian26.Notice(`Spaceforge: Data saved to default plugin folder due to error with custom path.`, 5e3); + } catch (fallbackError) { + console.error(`Error saving data to default location after custom path failed:`, fallbackError); + new import_obsidian26.Notice(`CRITICAL: Spaceforge failed to save data to both custom and default locations.`, 1e4); + } + } + } else { + await this.saveData(dataToSave); + } + } catch (error) { + console.error("General error in savePluginData:", error); + new import_obsidian26.Notice("Error saving Spaceforge data. Check console for details.", 5e3); + } + } + async activateSidebarView() { + const existingLeaves = this.app.workspace.getLeavesOfType("spaceforge-review-schedule"); + if (existingLeaves.length > 0) { + this.app.workspace.revealLeaf(existingLeaves[0]); + } else { + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ + type: "spaceforge-review-schedule", + active: true + }); + this.app.workspace.revealLeaf(leaf); + } else { + console.error("Spaceforge: Could not get a leaf to activate the sidebar view."); + new import_obsidian26.Notice("Spaceforge: Could not open sidebar view."); + } + } + } + addCommands() { + this.addCommand({ + id: "spaceforge-next-review-note", + name: "Next Review Note", + 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 import_obsidian26.TFile && activeFile.extension === "md") { + this.reviewController.reviewNote(activeFile.path); + } else { + new import_obsidian26.Notice("No active markdown file to review."); + } + } + }); + this.addCommand({ + id: "spaceforge-add-current-note-to-review", + name: "Add Current Note to Review Schedule", + hotkeys: [{ modifiers: ["Alt", "Shift"], key: "\\\\" }], + callback: () => { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && activeFile instanceof import_obsidian26.TFile && activeFile.extension === "md") { + this.reviewScheduleService.scheduleNoteForReview(activeFile.path).then(() => this.savePluginData()); + new import_obsidian26.Notice(`Added "${activeFile.path}" to review schedule.`); + } else { + new import_obsidian26.Notice("No active markdown file to add to review."); + } + } + }); + this.addCommand({ + id: "spaceforge-add-current-folder-to-review", + name: "Add Current Note's Folder to Review Schedule", + hotkeys: [{ modifiers: ["Alt", "Shift"], key: "'" }], + callback: async () => { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && activeFile.parent && activeFile.parent instanceof import_obsidian26.TFolder) { + const folder = activeFile.parent; + await this.contextMenuHandler.addFolderToReview(folder); + } else { + new import_obsidian26.Notice("Could not determine the current note's folder, no active file, or parent is not a folder."); + } + } + }); + } + addIcons() { + } + initializeMCQComponents() { + this.mcqGenerationService = void 0; + this.mcqController = void 0; + if (this.settings.enableMCQ) { + console.log("Initializing MCQ components for provider:", this.settings.mcqApiProvider); + this.mcqGenerationService = this.createMcqGenerationService(); + if (this.mcqGenerationService) { + this.mcqController = new MCQController(this, this.mcqService, this.mcqGenerationService); + } else { + new import_obsidian26.Notice("MCQ Generation Service could not be initialized. Check API provider settings in Spaceforge settings."); + console.warn(`Failed to create MCQ generation service for provider: ${this.settings.mcqApiProvider}`); + } + } + } + createMcqGenerationService() { + switch (this.settings.mcqApiProvider) { + case "openrouter" /* OpenRouter */: + if (!this.settings.openRouterApiKey) { + new import_obsidian26.Notice("OpenRouter API key is not set in Spaceforge settings."); + return void 0; + } + return new OpenRouterService(this); + case "openai" /* OpenAI */: + if (!this.settings.openaiApiKey) { + new import_obsidian26.Notice("OpenAI API key is not set in Spaceforge settings."); + return void 0; + } + return new OpenAIService(this); + case "ollama" /* Ollama */: + if (!this.settings.ollamaApiUrl || !this.settings.ollamaModel) { + new import_obsidian26.Notice("Ollama API URL or Model is not set in Spaceforge settings."); + return void 0; + } + return new OllamaService(this); + case "gemini" /* Gemini */: + if (!this.settings.geminiApiKey) { + new import_obsidian26.Notice("Gemini API key is not set in Spaceforge settings."); + return void 0; + } + return new GeminiService(this); + case "claude" /* Claude */: + if (!this.settings.claudeApiKey || !this.settings.claudeModel) { + new import_obsidian26.Notice("Claude API key or Model is not set in Spaceforge settings."); + return void 0; + } + return new ClaudeService(this); + case "together" /* Together */: + if (!this.settings.togetherApiKey || !this.settings.togetherModel) { + new import_obsidian26.Notice("Together AI API key or Model is not set in Spaceforge settings."); + return void 0; + } + return new TogetherService(this); + default: + new import_obsidian26.Notice(`Unsupported MCQ API provider selected: ${this.settings.mcqApiProvider}`); + console.error(`Unsupported MCQ API provider: ${this.settings.mcqApiProvider}`); + return void 0; + } + } + async checkForDueNotes() { + } + addStylesheet() { + } + async exportPluginData() { + } + async importPluginData(fileContent) { + } +}; diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..a4b2146 --- /dev/null +++ b/main.ts @@ -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 { + 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 { + 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 { + // const customPath = await this.app.loadLocalStorage('spaceforgeCustomDataPath'); + // return (customPath && typeof customPath === 'string' && customPath.trim() !== '') ? customPath.trim() : null; + // } + + private async _getEffectiveDataPath(): Promise { + 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 { + // 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 | 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 { + 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 { + 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 { /* ... */ } + private addStylesheet(): void { /* ... */ } + async exportPluginData(): Promise { /* ... */ } + async importPluginData(fileContent: string): Promise { /* ... */ } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c101be3 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/models/mcq.ts b/models/mcq.ts new file mode 100644 index 0000000..a0275f7 --- /dev/null +++ b/models/mcq.ts @@ -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; +} diff --git a/models/plugin-data.ts b/models/plugin-data.ts new file mode 100644 index 0000000..f5affcd --- /dev/null +++ b/models/plugin-data.ts @@ -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; + history: ReviewHistoryItem[]; + reviewSessions: ReviewSessionStore; + mcqSets: Record; + mcqSessions: Record; + 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, +}; diff --git a/models/review-schedule.ts b/models/review-schedule.ts new file mode 100644 index 0000000..6ab3d30 --- /dev/null +++ b/models/review-schedule.ts @@ -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; +} diff --git a/models/review-session.ts b/models/review-session.ts new file mode 100644 index 0000000..07fe6bd --- /dev/null +++ b/models/review-session.ts @@ -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; + + /** + * 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; +} diff --git a/models/settings.ts b/models/settings.ts new file mode 100644 index 0000000..4d75702 --- /dev/null +++ b/models/settings.ts @@ -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 + }, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f64f70d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2473 @@ +{ + "name": "spaceforge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spaceforge", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fs-extra": "^11.1.1", + "ts-fsrs": "^4.7.1" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^16.11.6", + "@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" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.36.5", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.5.tgz", + "integrity": "sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", + "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.29.0.tgz", + "integrity": "sha512-kgTsISt9pM53yRFQmLZ4npj99yGl3x3Pl7z4eA66OuTzAGC4bQB5H5fuLwPnqTKU3yyrrg4MIhjF17UYnL4c0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "5.29.0", + "@typescript-eslint/type-utils": "5.29.0", + "@typescript-eslint/utils": "5.29.0", + "debug": "^4.3.4", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz", + "integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.29.0", + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/typescript-estree": "5.29.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.29.0.tgz", + "integrity": "sha512-etbXUT0FygFi2ihcxDZjz21LtC+Eps9V2xVx09zFoN44RRHPrkMflidGMI+2dUs821zR1tDS6Oc9IXxIjOUZwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/visitor-keys": "5.29.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.29.0.tgz", + "integrity": "sha512-JK6bAaaiJozbox3K220VRfCzLa9n0ib/J+FHIwnaV3Enw/TO267qe0pM1b1QrrEuy6xun374XEAsRlA86JJnyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "5.29.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.29.0.tgz", + "integrity": "sha512-X99VbqvAXOMdVyfFmksMy3u8p8yoRGITgU1joBJPzeYa0rhdf5ok9S56/itRoUSh99fiDoMtarSIJXo7H/SnOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.29.0.tgz", + "integrity": "sha512-mQvSUJ/JjGBdvo+1LwC+GY2XmSYjK1nAaVw2emp/E61wEVYEyibRHCqm1I1vEKbXCpUKuW4G7u9ZCaZhJbLoNQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/visitor-keys": "5.29.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.29.0.tgz", + "integrity": "sha512-3Eos6uP1nyLOBayc/VUdKZikV90HahXE5Dx9L5YlSd/7ylQPXhLk1BYb29SDgnBnTp+jmSZUU0QxUiyHgW4p7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.29.0", + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/typescript-estree": "5.29.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.29.0.tgz", + "integrity": "sha512-Hpb/mCWsjILvikMQoZIE3voc9wtQcS0A9FUw3h8bhr9UxBdtI/tw1ZDZUOXHXLOVMedKCH5NxyzATwnU78bWCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.29.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "peer": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/obsidian": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", + "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-fsrs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-4.7.1.tgz", + "integrity": "sha512-uYqKbSCNWLRPT0PfqFFy2rn0YuYz2fKmtgpgHOBd5/DP14oj6diG7P271AwJ8iGrKnmIspzXCF+nTKxict+Lcg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f481ae9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/fsrs-schedule-service.ts b/services/fsrs-schedule-service.ts new file mode 100644 index 0000000..6d3d50c --- /dev/null +++ b/services/fsrs-schedule-service.ts @@ -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['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['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['fsrsData'], + rating: FsrsRating, + reviewDateTime: Date + ): { updatedData: Required['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['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['fsrsData'], + reviewDateTime: Date + ): { updatedData: Required['fsrsData']; nextReviewDate: number; log: FsrsReviewLog } { + // Skipping in FSRS is typically handled by rating "Again" + return this.recordReview(currentFsrsData, FsrsRating.Again, reviewDateTime); + } +} diff --git a/services/mcq-service.ts b/services/mcq-service.ts new file mode 100644 index 0000000..e8cf84b --- /dev/null +++ b/services/mcq-service.ts @@ -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; + + /** + * MCQ sessions by note path (This will be a reference to the mcqSessions object in DataStorage) + */ + mcqSessions: Record; + + /** + * Initialize MCQ Service + * + * @param mcqSets Reference to the mcqSets object in DataStorage + * @param mcqSessions Reference to the mcqSessions object in DataStorage + */ + constructor(mcqSets: Record, mcqSessions: Record) { + 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.`); + } + } +} diff --git a/services/pomodoro-service.ts b/services/pomodoro-service.ts new file mode 100644 index 0000000..2b165ea --- /dev/null +++ b/services/pomodoro-service.ts @@ -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 + } +} diff --git a/services/review-history-service.ts b/services/review-history-service.ts new file mode 100644 index 0000000..19853fb --- /dev/null +++ b/services/review-history-service.ts @@ -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); + } + } +} diff --git a/services/review-schedule-service.ts b/services/review-schedule-service.ts new file mode 100644 index 0000000..b862990 --- /dev/null +++ b/services/review-schedule-service.ts @@ -0,0 +1,1104 @@ +import { Notice, TFile, TFolder } from 'obsidian'; +import { ReviewSchedule, ReviewResponse, toSM2Quality, FsrsRating } from '../models/review-schedule'; // Added FsrsRating +import SpaceforgePlugin from '../main'; +import { DateUtils } from '../utils/dates'; +import { FsrsScheduleService } from './fsrs-schedule-service'; // Added FsrsScheduleService +import { EstimationUtils } from '../utils/estimation'; +import { ReviewHistoryItem } from '../models/review-schedule'; // Need this for history recording + +/** + * Handles management of review schedules and SM-2 calculations + */ +export class ReviewScheduleService { + /** + * Reference to the main plugin + */ + private plugin: SpaceforgePlugin; + private fsrsService: FsrsScheduleService; // Added FsrsScheduleService instance + + /** + * Note schedules indexed by path + */ + schedules: Record = {}; + + /** + * Custom order for notes (user-defined ordering) + */ + customNoteOrder: string[] = []; + + /** + * Timestamp of the last time link analysis was performed for ordering + */ + lastLinkAnalysisTimestamp: number | null = null; + + /** + * Review history (will be managed by ReviewHistoryService, but needed for recordReview) + * This will be a reference to the history array in DataStorage + */ + history: ReviewHistoryItem[]; + + /** + * Initialize Review Schedule Service + * + * @param plugin Reference to the main plugin + * @param schedules Initial schedules data + * @param customNoteOrder Initial custom note order data + * @param lastLinkAnalysisTimestamp Initial last link analysis timestamp + * @param history Reference to the history array in DataStorage + */ + constructor( + plugin: SpaceforgePlugin, + schedules: Record, + customNoteOrder: string[], + lastLinkAnalysisTimestamp: number | null, + history: ReviewHistoryItem[] + ) { + this.plugin = plugin; + this.schedules = schedules; + this.customNoteOrder = customNoteOrder; + this.lastLinkAnalysisTimestamp = lastLinkAnalysisTimestamp; + this.history = history; + this.fsrsService = new FsrsScheduleService(this.plugin.settings); + } + + public updateAlgorithmServicesForSettingsChange(): void { + // Call this if settings change, especially FSRS parameters + this.fsrsService.updateFSRSInstance(this.plugin.settings); + } + + /** + * Schedule a note for review + * + * @param path Path to the note file + * @param daysFromNow Days until first review (default: 0, same day) + */ + async scheduleNoteForReview(path: string, daysFromNow: number = 0): Promise { + // Check if file exists and is a markdown file + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file || !(file instanceof TFile) || file.extension !== "md") { + new Notice("Only markdown files can be added to the review schedule"); + return; + } + + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm; + + let newSchedule: ReviewSchedule; + + if (defaultAlgorithm === 'fsrs') { + // FSRS card creation date is the exact moment. Its nextReviewDate is also an exact moment. + const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now)); + newSchedule = { + path, + lastReviewDate: null, // Will be UTC midnight when set + nextReviewDate: now, // FSRS cards are due immediately (exact timestamp) + reviewCount: 0, + schedulingAlgorithm: 'fsrs', + fsrsData: fsrsData, + // SM-2 fields can be undefined or default + ease: this.plugin.settings.baseEase, // Keep a base for potential conversion + interval: 0, + consecutive: 0, + repetitionCount: 0, + scheduleCategory: undefined, // Not applicable to FSRS + }; + // daysFromNow is ignored for FSRS initial scheduling, it follows its learning steps. + } else { // sm2 + newSchedule = { + path, + lastReviewDate: null, // Will be UTC midnight when set + nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), + ease: this.plugin.settings.baseEase, + interval: daysFromNow, + consecutive: 0, + reviewCount: 0, + repetitionCount: 0, + scheduleCategory: this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced', + schedulingAlgorithm: 'sm2', + fsrsData: undefined, + }; + + if (newSchedule.scheduleCategory === 'initial') { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals; + if (initialIntervals && initialIntervals.length > 0) { + newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0]; + } + if (daysFromNow === 0) { + newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval); + } + } + } + this.schedules[path] = newSchedule; + + // Add to custom order if not already present + if (!this.customNoteOrder.includes(path)) { + this.customNoteOrder.push(path); + } + + // Data saving is now handled by main.ts after this method returns + + // Notify any listeners (for UI updates) + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + + new Notice(`Note added to review schedule`); + } + + /** + * Check if a note is due for review on or before the specified date + * + * @param schedule The review schedule for the note + * @param effectiveReviewDate The date to check against + * @returns true if the note is due, false otherwise + */ + private isNoteDue(schedule: ReviewSchedule, effectiveReviewDate: number): boolean { + const reviewDateObj = new Date(effectiveReviewDate); + const effectiveUTCDayEnd = DateUtils.endOfUTCDay(reviewDateObj); + + // Note is due if its nextReviewDate is on or before the effective date + // Allow notes due today or earlier to be reviewed + return schedule.nextReviewDate <= effectiveUTCDayEnd; + } + + /** + * Record a review for a note + * + * @param path Path to the note file + * @param response User's response during review (can be SM-2 or FSRS rating) + * @param isSkipped Whether this review was explicitly skipped (default: false) + * @param currentReviewDate Optional timestamp for the current review date (simulated or actual) + * @returns true if the review was recorded, false if it was just a preview + */ + async recordReview(path: string, response: ReviewResponse | FsrsRating, isSkipped: boolean = false, currentReviewDate?: number): Promise { + const schedule = this.schedules[path]; + if (!schedule) return false; + + const effectiveReviewDate = currentReviewDate || Date.now(); + + // Check if the note is actually due for review + const isDue = this.isNoteDue(schedule, effectiveReviewDate); + + if (!isDue) { + // Note is not due, this is just a preview - don't record anything + return false; + } + + const reviewDateObj = new Date(effectiveReviewDate); + const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj); + + // Determine historyResponse for logging + let historyResponseValue: ReviewResponse; + if (Object.values(FsrsRating).includes(response as FsrsRating) && typeof response === "number") { + switch (response as FsrsRating) { + case FsrsRating.Again: historyResponseValue = ReviewResponse.IncorrectResponse; break; + case FsrsRating.Hard: historyResponseValue = ReviewResponse.IncorrectButFamiliar; break; + case FsrsRating.Good: historyResponseValue = ReviewResponse.CorrectWithDifficulty; break; + case FsrsRating.Easy: historyResponseValue = ReviewResponse.CorrectWithHesitation; break; + default: historyResponseValue = ReviewResponse.CorrectWithDifficulty; + } + } else { + historyResponseValue = toSM2Quality(response as ReviewResponse); + } + + // Record the actual review + if (!isSkipped) { + schedule.reviewCount = (schedule.reviewCount || 0) + 1; + } + schedule.lastReviewDate = effectiveUTCDayStart; + + // Log to history + this.history.push({ + path, + timestamp: effectiveReviewDate, + response: historyResponseValue, + interval: schedule.interval ?? schedule.fsrsData?.scheduled_days ?? 0, + ease: schedule.ease ?? (schedule.fsrsData?.difficulty ? Math.round(schedule.fsrsData.difficulty * 10) : this.plugin.settings.baseEase), + isSkipped: isSkipped + }); + if (this.history.length > 1000) this.history.splice(0, this.history.length - 1000); + + if (schedule.schedulingAlgorithm === 'fsrs') { + if (!schedule.fsrsData) { // Should not happen if card is properly initialized + // FSRS service expects a Date object for the review time. + // reviewDateObj (created from effectiveReviewDate) is correct here. + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj); + } + + // --- START FIX: Determine correct FsrsRating --- + let actualFsrsRating: FsrsRating; + // Check if the response is one of the legacy ReviewResponse values from MCQ modal + if (response === ReviewResponse.Perfect) { // Value 5 + actualFsrsRating = FsrsRating.Easy; // Map to 4 + } else if (response === ReviewResponse.Good) { // Value 4 + actualFsrsRating = FsrsRating.Good; // Map to 3 + } else if (response === ReviewResponse.Fair) { // Value 3 + actualFsrsRating = FsrsRating.Hard; // Map to 2 + } else if (response === ReviewResponse.Hard) { // Value 1 (legacy) + actualFsrsRating = FsrsRating.Again; // Map to 1 + } else if (Object.values(FsrsRating).includes(response as FsrsRating)) { + // If it's already a valid FsrsRating (1, 2, 3, 4), use it directly + actualFsrsRating = response as FsrsRating; + } else { + // Fallback for unexpected values (e.g., SM-2 specific 0, 2, potentially) + // Map based on general difficulty + const quality = toSM2Quality(response as ReviewResponse); + if (quality >= 4) actualFsrsRating = FsrsRating.Easy; // 4, 5 -> Easy + else if (quality === 3) actualFsrsRating = FsrsRating.Good; // 3 -> Good + else if (quality === 2) actualFsrsRating = FsrsRating.Hard; // 2 -> Hard + else actualFsrsRating = FsrsRating.Again; // 0, 1 -> Again + } + // --- END FIX --- + + const { updatedData, nextReviewDate: newNextReviewDateFsrs } = this.fsrsService.recordReview( + schedule.fsrsData, + actualFsrsRating, // Pass the correctly determined FsrsRating + reviewDateObj // Pass the exact moment of review + ); + schedule.fsrsData = updatedData; + schedule.nextReviewDate = newNextReviewDateFsrs; // This is already a UTC timestamp from FSRS + // SM-2 specific fields are not updated for FSRS cards + schedule.interval = updatedData.scheduled_days; // For display consistency if needed + schedule.ease = Math.round(updatedData.difficulty * 10); // Approximate for display + + } else { // SM-2 + let qualityRating = toSM2Quality(response as ReviewResponse); + + // Initialize SM-2 fields if they are somehow missing (should not happen for SM-2 cards) + schedule.ease = schedule.ease ?? this.plugin.settings.baseEase; + schedule.interval = schedule.interval ?? 0; + schedule.repetitionCount = schedule.repetitionCount ?? 0; + schedule.consecutive = schedule.consecutive ?? 0; + schedule.scheduleCategory = schedule.scheduleCategory ?? (this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced'); + + + if (schedule.scheduleCategory === 'initial') { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals || []; + // reviewCount is 0-indexed for history, 1-indexed for human counting of reviews. + // If reviewCount is 0 (first review), initialIntervals[0] is the interval *after* this review. + // If reviewCount is 1 (second review), initialIntervals[1] is the interval *after* this review. + // The schedule.reviewCount was already incremented if not skipped. + // So, if reviewCount is now 1 (meaning 1st review just happened), use initialIntervals[0] for next interval. + // This seems to be what the original logic intended with `initialIntervals[schedule.reviewCount]` + // if reviewCount was considered 0-indexed for *which review this is*. + // Let's assume schedule.reviewCount (already incremented) is the number of reviews *completed*. + // If 1 review completed, next interval is initialIntervals[0] if length allows. + // If `schedule.reviewCount -1` is the index for the interval *just completed*, then `schedule.reviewCount` + // would be the index for the *next* interval. + // The original `schedule.reviewCount < initialIntervals.length` and `initialIntervals[schedule.reviewCount]` + // implies that if `reviewCount` is (e.g.) 0 after incrementing (meaning it was -1, which is impossible), + // it would take `initialIntervals[0]`. If `reviewCount` is 1, it takes `initialIntervals[1]`. + // This needs to be robust. Let's use `schedule.repetitionCount` for initial steps as it's clearer. + // For initial phase, repetitionCount tracks progression through initial steps. + + if (schedule.repetitionCount < initialIntervals.length) { + schedule.interval = initialIntervals[schedule.repetitionCount]; + } else { // Graduated from initial steps + schedule.scheduleCategory = 'graduated'; + // For the first SM-2 calculation after graduation, treat as if n=0 for interval calc. + // daysLate should be 0 as we are just graduating. + const daysLateForGraduation = 0; + const { interval, ease, repetitionCount: newRepCount } = this.calculateSM2Schedule( + schedule.interval, // previous interval (last of initial steps) + schedule.ease, + qualityRating, + 0, // Reset repetition count for SM-2 calculation after graduation + daysLateForGraduation, + isSkipped + ); + schedule.interval = interval; + schedule.ease = ease; + schedule.repetitionCount = newRepCount; // This will be 1 if q >= 3 + } + + // Update ease factor regardless of graduation + const q = qualityRating; + let newEase = schedule.ease / 100; + newEase = newEase + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)); + newEase = Math.max(1.3, newEase); + schedule.ease = Math.round(newEase * 100); + + if (qualityRating >= ReviewResponse.CorrectWithDifficulty) { + schedule.consecutive += 1; + if (qualityRating >= 3) { + // repetitionCount for initial phase should increment if successful, reset if not. + // This is distinct from the SM-2 n. + schedule.repetitionCount = (schedule.repetitionCount || 0) + 1; + } else { // q < 3 + schedule.repetitionCount = 0; // Reset progress in initial steps + } + } else { // q < 3 + schedule.consecutive = 0; + schedule.repetitionCount = 0; // Reset progress in initial steps + } + } else { // 'spaced' or 'graduated' (already graduated or started as spaced) + const daysLate = schedule.nextReviewDate < effectiveUTCDayStart ? // Compare with UTC day start + DateUtils.dayDifferenceUTC(schedule.nextReviewDate, effectiveUTCDayStart) : 0; + const { interval, ease, repetitionCount } = this.calculateSM2Schedule( + schedule.interval, schedule.ease, qualityRating, schedule.repetitionCount || 0, daysLate, isSkipped + ); + schedule.interval = interval; + schedule.ease = ease; + schedule.repetitionCount = repetitionCount; + + if (qualityRating >= ReviewResponse.CorrectWithDifficulty) { + schedule.consecutive += 1; + } else { + schedule.consecutive = 0; + } + } + // Base the next review date on the UTC start of the current review day + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, schedule.interval); + } + + // Data saving is now handled by main.ts after this method returns + + // Notify any listeners (for UI updates) + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + + return true; // Review was successfully recorded + } + + /** + * Calculate new schedule parameters based on review response using the SM-2 algorithm + * (This method is likely redundant now that recordReview uses calculateSM2Schedule directly, + * but keeping for potential external use or backward compatibility if needed) + * + * @param currentInterval Current interval in days + * @param currentEase Current ease factor + * @param response User's response during review + * @param repetitionCount Current repetition count (n) + * @param daysLate How many days late the review is (0 if on time or early) + * @param isSkipped Whether the item was explicitly skipped by the user + * @returns New interval, ease, and repetition count + */ + calculateNewSchedule( + currentInterval: number, + currentEase: number, + response: ReviewResponse, + repetitionCount: number = 0, + daysLate: number = 0, + isSkipped: boolean = false + ): { interval: number, ease: number, repetitionCount: number } { + // Ensure response is in the valid SM-2 range (0-5) + let qualityRating = toSM2Quality(response); + + // Handle overdue or skipped items according to modified SM-2 algorithm + if (isSkipped || daysLate > 0) { + // Determine effective quality rating: + // - If explicitly skipped, reduce quality by 1 (but not below 0) + // - If overdue, set quality to 0 + // - If both overdue and explicitly skipped, prioritize the skip logic (user choice) + const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0; + + // Convert ease from internal format (250 = 2.5) to SM-2 format (2.5) + let ease = currentEase / 100; + + // Calculate new ease factor using SM-2 formula with the effective quality + ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02)); + + // Apply minimum ease factor (SM-2 specifies 1.3 as the minimum) + ease = Math.max(1.3, ease); + + // Force next review to be tomorrow (interval = 1) regardless of computed interval + // and reset repetition count to 1 + return { + interval: 1, // Force next review to be tomorrow + ease: Math.round(ease * 100), // Convert back to internal format + repetitionCount: 1 // Reset repetition count to 1 + }; + } + + // For normal reviews, use the regular SM-2 implementation + // Convert ease from internal format (250 = 2.5) to SM-2 format (2.5) + let ease = currentEase / 100; + let newRepetitionCount = repetitionCount; + let interval: number; + + // Calculate new ease factor using SM-2 formula + // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) + ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02)); + + // Apply minimum ease factor (SM-2 specifies 1.3 as the minimum) + ease = Math.max(1.3, ease); + + // If response is less than 3, reset repetition count to 0 (per strict SM-2) + if (qualityRating < 3) { + newRepetitionCount = 0; + interval = 1; // Next interval is 1 day for failed items + } else { + // Increment repetition count for correct responses + newRepetitionCount += 1; + + // Calculate new interval based on SM-2 rules + if (newRepetitionCount === 1) { + interval = 1; + } else if (newRepetitionCount === 2) { + interval = 6; + } else { + // For n > 2, use the formula I_n = I_(n-1) * EF + interval = Math.round(currentInterval * ease); + } + } + + // Apply load balancing if enabled (this is an extension to the algorithm) + if (this.plugin.settings.loadBalance) { + const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0; + interval = interval + Math.random() * fuzz * 2 - fuzz; + } + + // Ensure interval is at least 1 day + interval = Math.max(1, interval); + + // Enforce maximum interval (this is an extension to the algorithm) + interval = Math.min(interval, this.plugin.settings.maximumInterval); + + // Convert ease back to internal format before returning + return { + interval: Math.round(interval), // SM-2 uses whole days + ease: Math.round(ease * 100), + repetitionCount: newRepetitionCount + }; + } + + /** + * Calculate new schedule parameters using the enhanced SM-2 algorithm with lateness penalty + * (This is the core calculation logic used internally by recordReview and skipNote) + * + * @param currentInterval Current interval in days + * @param currentEase Current ease factor (expressed as a number where 2.5 = 250) + * @param qualityRating User's response during review, as a numeric quality rating (0-5) + * @param repetitionCount Current repetition count (n) + * @param daysLate How many days late the review is (0 if on time or early) + * @param isSkipped Whether the item was explicitly skipped by the user + * @returns New interval, ease, and repetition count + */ + private calculateSM2Schedule( + currentInterval: number, + currentEase: number, + qualityRating: number, // Changed parameter type from ReviewResponse to number + repetitionCount: number = 0, + daysLate: number = 0, + isSkipped: boolean = false + ): { interval: number, ease: number, repetitionCount: number } { + // qualityRating is now expected to be a number (0-5) directly. + // The call to toSM2Quality(response) is removed. + + // Handle overdue or skipped items according to modified SM-2 algorithm + if (isSkipped || daysLate > 0) { + // Debug log + console.log(`[Modified SM-2] Processing ${isSkipped ? 'skipped' : 'overdue'} item: + - Days late: ${daysLate} + - Original quality: ${qualityRating} + - Current interval: ${currentInterval} + - Current ease: ${currentEase/100}`); + + // Determine effective quality rating: + // - If explicitly skipped, reduce quality by 1 (but not below 0) + // - If overdue, set quality to 0 + // - If both overdue and explicitly skipped, prioritize the skip logic (user choice) + const q_eff = isSkipped ? Math.max(0, qualityRating - 1) : 0; + + console.log(`[Modified SM-2] Applied penalty: + - Effective quality: ${q_eff}`); + + // Convert ease from internal format (250 = 2.5) to SM-2 format (2.5) + let ease = currentEase / 100; + + // Calculate new ease factor using SM-2 formula with the effective quality + ease = ease + (0.1 - (5 - q_eff) * (0.08 + (5 - q_eff) * 0.02)); + + // Apply minimum ease factor (SM-2 specifies 1.3 as the minimum) + ease = Math.max(1.3, ease); + + // Force next review to be tomorrow (interval = 1) regardless of computed interval + // and reset repetition count to 1 + const result = { + interval: 1, // Force next review to be tomorrow + ease: Math.round(ease * 100), // Convert back to internal format + repetitionCount: 1 // Reset repetition count to 1 + }; + + console.log(`[Modified SM-2] Result for ${isSkipped ? 'skipped' : 'overdue'} item: + - New interval: ${result.interval} day(s) + - New ease: ${result.ease/100} + - New repetition count: ${result.repetitionCount}`); + + return result; + } + + // For normal reviews, use the regular SM-2 implementation + // Convert ease from internal format (250 = 2.5) to SM-2 format (2.5) + let ease = currentEase / 100; + let newRepetitionCount = repetitionCount; + let interval: number; + + // Calculate new ease factor using SM-2 formula + // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) + ease = ease + (0.1 - (5 - qualityRating) * (0.08 + (5 - qualityRating) * 0.02)); + + // Apply minimum ease factor (SM-2 specifies 1.3 as the minimum) + ease = Math.max(1.3, ease); + + // If response is less than 3, reset repetition count to 0 (per strict SM-2) + if (qualityRating < 3) { + newRepetitionCount = 0; + interval = 1; // Next interval is 1 day for failed items + } else { + // Increment repetition count for correct responses + newRepetitionCount += 1; + + // Calculate new interval based on SM-2 rules + if (newRepetitionCount === 1) { + interval = 1; + } else if (newRepetitionCount === 2) { + interval = 6; + } else { + // For n > 2, use the formula I_n = I_(n-1) * EF + interval = Math.round(currentInterval * ease); + } + } + + // Apply load balancing if enabled (this is an extension to the algorithm) + if (this.plugin.settings.loadBalance) { + const fuzz = interval > 7 ? Math.min(3, Math.floor(interval * 0.05)) : 0; + interval = interval + Math.random() * fuzz * 2 - fuzz; + } + + // Ensure interval is at least 1 day + interval = Math.max(1, interval); + + // Enforce maximum interval (this is an extension to the algorithm) + interval = Math.min(interval, this.plugin.settings.maximumInterval); + + // Convert ease back to internal format before returning + return { + interval: Math.round(interval), // SM-2 uses whole days + ease: Math.round(ease * 100), + repetitionCount: newRepetitionCount + }; + } + + + /** + * Get notes due for review + * + * @param date Optional target date (default: now) + * @param matchExactDate If true, only return notes due exactly on this date (ignoring time). Otherwise, notes due on or before this date. + * @returns Array of due note schedules sorted by due date + */ + getDueNotes(date: number = Date.now(), matchExactDate: boolean = false): ReviewSchedule[] { + // 'date' is the reference point (e.g., effectiveReviewDate from controller, which is a UTC timestamp) + // For FSRS, nextReviewDate is an exact UTC timestamp. + // For SM-2, nextReviewDate is a UTC midnight timestamp. + const targetUTCDayStartForSM2 = DateUtils.startOfUTCDay(new Date(date)); + const targetUTCDayEndForFSRS = DateUtils.endOfUTCDay(new Date(date)); // Added for FSRS date coincidence + + return Object.values(this.schedules) + .filter(schedule => { + if (schedule.schedulingAlgorithm === 'fsrs') { + if (matchExactDate) { + // FSRS notes are due if their nextReviewDate falls within the same UTC day as 'date' + // This considers date coincidence rather than exact time + return schedule.nextReviewDate >= targetUTCDayStartForSM2 && + schedule.nextReviewDate <= targetUTCDayEndForFSRS; + } else { + // FSRS notes are due if their nextReviewDate is on or before the end of the UTC day of 'date' + return schedule.nextReviewDate <= targetUTCDayEndForFSRS; + } + } else { // SM-2 + // SM-2 notes are due if their nextReviewDate (UTC midnight) is on or before the UTC midnight of 'date'. + if (matchExactDate) { + // Due if the note's UTC midnight due date IS the target UTC midnight. + return schedule.nextReviewDate === targetUTCDayStartForSM2; + } + // Due if the note's UTC midnight due date is on or before the target UTC midnight. + return schedule.nextReviewDate <= targetUTCDayStartForSM2; + } + }) + .sort((a, b) => a.nextReviewDate - b.nextReviewDate); + } + + /** + * Get upcoming reviews within a specified timeframe + * + * @param days Number of days to look ahead + * @returns Array of upcoming review schedules sorted by due date + */ + getUpcomingReviews(days: number = 7): ReviewSchedule[] { + const now = Date.now(); + const futureDate = DateUtils.addDays(now, days); + + return Object.values(this.schedules) + .filter(schedule => + schedule.nextReviewDate > now && + schedule.nextReviewDate <= futureDate + ) + .sort((a, b) => a.nextReviewDate - b.nextReviewDate); + } + + /** + * Skip a note's review and reschedule for tomorrow with penalized quality + * + * This implements the "Postpone to Tomorrow" functionality from the modified SM-2 algorithm. + * It applies a one-step quality penalty (reduce by 1 but not below 0) and forces the next + * review to be tomorrow, regardless of what the normal interval would be. This keeps items + * in rotation rather than letting them disappear into an ever-growing backlog. + * + * @param path Path to the note file + * @param response Optional user's response to use for penalty calculation + * @param currentReviewDate Optional timestamp for the current review date (simulated or actual) + */ + async skipNote(path: string, response: ReviewResponse | FsrsRating = ReviewResponse.CorrectWithDifficulty, currentReviewDate?: number): Promise { + const schedule = this.schedules[path]; + if (!schedule) return; + + console.log(`Skipping note ${path} with algorithm ${schedule.schedulingAlgorithm}`); + const effectiveReviewDate = currentReviewDate || Date.now(); + const reviewDateObj = new Date(effectiveReviewDate); + + // Record as a skipped review (isSkipped = true) + // The 'response' for a skip is less critical but can be used for penalty in SM-2. + // For FSRS, skipReview in FsrsService handles it as 'Again'. + if (schedule.schedulingAlgorithm === 'fsrs') { + if (!schedule.fsrsData) { // Should not happen + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(reviewDateObj); + } + const { updatedData, nextReviewDate: newNextReviewDateFsrs, log } = this.fsrsService.skipReview( + schedule.fsrsData, + reviewDateObj // Pass exact moment for FSRS skip + ); + schedule.fsrsData = updatedData; + schedule.nextReviewDate = newNextReviewDateFsrs; // Already a UTC timestamp + schedule.lastReviewDate = DateUtils.startOfUTCDay(reviewDateObj); // FSRS skip is a review, set last review to UTC midnight + + this.history.push({ // Log FSRS skip + path, timestamp: effectiveReviewDate, response: ReviewResponse.IncorrectResponse, // Approx. for log + interval: schedule.fsrsData.scheduled_days, ease: Math.round(schedule.fsrsData.difficulty * 10), isSkipped: true + }); + + } else { // SM-2 + let qualityRating = toSM2Quality(response as ReviewResponse); + qualityRating = Math.max(0, qualityRating - 1); // Apply skip penalty for SM-2 + + this.history.push({ + path, timestamp: effectiveReviewDate, response: qualityRating, + interval: schedule.interval || 0, ease: schedule.ease || 0, isSkipped: true + }); + + const effectiveUTCDayStart = DateUtils.startOfUTCDay(reviewDateObj); + schedule.lastReviewDate = effectiveUTCDayStart; // Set last review to UTC midnight + + if (schedule.scheduleCategory === 'initial') { + schedule.interval = 1; // Skip in initial phase often means try again soon + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, 1); + // For initial phase skips, repetitionCount might not advance or could reset. + // The current calculateSM2Schedule with isSkipped=true will set repCount=1. + } else { + const { interval, ease, repetitionCount } = this.calculateSM2Schedule( + schedule.interval || 0, schedule.ease || this.plugin.settings.baseEase, qualityRating, + schedule.repetitionCount || 0, 0, true // daysLate = 0 for a skip, isSkipped = true + ); + schedule.interval = interval; // Will be 1 due to isSkipped=true + schedule.ease = ease; + schedule.repetitionCount = repetitionCount; // Will be 1 + schedule.nextReviewDate = DateUtils.addDays(effectiveUTCDayStart, interval); + } + schedule.consecutive = 0; + } + + // Data saving is now handled by main.ts after this method returns + + // Notify any listeners (for UI updates) + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + } + + /** + * Postpone a note's review + * + * @param path Path to the note file + * @param days Number of days to postpone (default: 1) + */ + async postponeNote(path: string, days: number = 1): Promise { + const schedule = this.schedules[path]; + if (!schedule) return; + + // Update the review date, preserving the current phase + // regardless of which initial phase the note is in + schedule.nextReviewDate = DateUtils.addDays(schedule.nextReviewDate, days); + // Data saving is now handled by main.ts after this method returns + + // Note: We're now handling the UI update through the controller's postponeNote method + // directly, so we don't need to call handleNotePostponed here to avoid duplicate calls + + // Refresh the sidebar view if available with a slight delay to allow data to settle + if (this.plugin.events) { + setTimeout(() => { + this.plugin.events.emit('sidebar-update'); + }, 50); // Small delay (e.g., 50ms) + } + + new Notice(`Review postponed for ${days} day${days !== 1 ? 's' : ''}`); + } + + /** + * Advance a note's review by one day, if eligible. + * + * @param path Path to the note file + * @returns True if the note was advanced, false otherwise. + */ + async advanceNote(path: string): Promise { + const schedule = this.schedules[path]; + if (!schedule) { + console.warn(`[ReviewScheduleService] Advance: Schedule not found for path ${path}`); + return false; + } + + const todayUTCMidnight = DateUtils.startOfUTCDay(new Date()); + // For SM-2, nextReviewDate is already UTC midnight. For FSRS, it's an exact time. + // To compare consistently for "advancing a day", we should compare UTC day starts. + const noteReviewUTCDayStart = DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate)); + + // Only advance future notes, and not past today (based on UTC days) + if (noteReviewUTCDayStart <= todayUTCMidnight) { + return false; + } + + // New potential next review date is one day earlier. + // If FSRS, it's one day earlier from its exact time. If SM-2, one day earlier from its UTC midnight. + const newPotentialNextReviewTimestamp = DateUtils.addDays(schedule.nextReviewDate, -1); + + // Ensure the new date (if SM-2, its UTC day start) is not before todayUTCMidnight + if (schedule.schedulingAlgorithm === 'sm2') { + schedule.nextReviewDate = Math.max(todayUTCMidnight, DateUtils.startOfUTCDay(new Date(newPotentialNextReviewTimestamp))); + } else { // FSRS + // For FSRS, ensure the new exact time is not earlier than the start of today UTC. + // Or, more practically, not earlier than the current exact time if that's preferred. + // Let's keep it simple: advance by one day, but not before current UTC midnight. + schedule.nextReviewDate = Math.max(todayUTCMidnight, newPotentialNextReviewTimestamp); + } + + // Data saving is handled by the calling controller (e.g., ReviewControllerCore) + + if (this.plugin.events) { + // Use a timeout to allow other operations to complete before UI refresh + setTimeout(() => { + this.plugin.events.emit('sidebar-update'); + }, 50); + } + // Notice is handled by the calling controller for better context. + return true; + } + + /** + * Remove a note from the review schedule + * + * @param path Path to the note file + */ + async removeFromReview(path: string): Promise { + if (this.schedules[path]) { + delete this.schedules[path]; + + // Remove from custom order if present + this.customNoteOrder = this.customNoteOrder.filter(p => p !== path); + + // Data saving is now handled by main.ts after this method returns + new Notice("Note removed from review schedule"); + + // Note: The controller will be notified separately to update its state + // This prevents immediate reordering based on link analysis after removal. + // The sidebar or other components should trigger an update if needed. + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); // Notify UI to refresh + } + } + } + + /** + * Clear all review schedules + */ + async clearAllSchedules(): Promise { + this.schedules = {}; + this.customNoteOrder = []; // Also clear custom order + // Data saving is now handled by main.ts after this method returns + new Notice("All review schedules have been cleared"); + + // Notify any listeners (for UI updates) + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + + // Explicitly update the review controller's state + // This dependency might need to be managed differently, e.g., via events + if (this.plugin.reviewController) { + await this.plugin.reviewController.updateTodayNotes(); + } + } + + /** + * Estimate review time for a note + * + * @param path Path to the note file + * @returns Estimated review time in seconds + */ + async estimateReviewTime(path: string): Promise { + 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 + } + } + + /** + * Schedule multiple notes for review in a specific order + * + * @param paths Array of note paths in the order they should be processed + * @param daysFromNow Days until first review (default: 0, same day) + * @returns Number of notes scheduled + */ + async scheduleNotesInOrder(paths: string[], daysFromNow: number = 0): Promise { + let count = 0; + + // Schedule each note in the provided order + for (const path of paths) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file || !(file instanceof TFile) || file.extension !== "md" || this.schedules[path]) { + continue; + } + + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + const defaultAlgorithm = this.plugin.settings.defaultSchedulingAlgorithm; + let newSchedule: ReviewSchedule; + + if (defaultAlgorithm === 'fsrs') { + const fsrsData = this.fsrsService.createNewFsrsCardData(new Date(now)); // Exact moment + newSchedule = { + path, lastReviewDate: null, nextReviewDate: now, reviewCount: 0, // FSRS due now + schedulingAlgorithm: 'fsrs', fsrsData: fsrsData, + ease: this.plugin.settings.baseEase, interval: 0, consecutive: 0, repetitionCount: 0, scheduleCategory: undefined, + }; + } else { // sm2 + newSchedule = { + path, lastReviewDate: null, + nextReviewDate: DateUtils.addDays(todayUTCStart, daysFromNow), // UTC midnight + ease: this.plugin.settings.baseEase, interval: daysFromNow, + consecutive: 0, reviewCount: 0, repetitionCount: 0, + scheduleCategory: this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced', + schedulingAlgorithm: 'sm2', fsrsData: undefined, + }; + if (newSchedule.scheduleCategory === 'initial') { + const initialIntervals = this.plugin.settings.initialScheduleCustomIntervals; + if (initialIntervals && initialIntervals.length > 0) { + newSchedule.interval = daysFromNow > 0 ? daysFromNow : initialIntervals[0]; + } + if (daysFromNow === 0) { + newSchedule.nextReviewDate = DateUtils.addDays(todayUTCStart, newSchedule.interval); + } + } + } + this.schedules[path] = newSchedule; + + // Add to custom order if not already present + if (!this.customNoteOrder.includes(path)) { + this.customNoteOrder.push(path); + } + + count++; + } + + if (count > 0) { + // Data saving is now handled by main.ts after this method returns + + // Notify any listeners (for UI updates) + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + } + + return count; + } + + /** + * Update the custom note order - used to maintain user-defined ordering + * + * @param order Array of note paths in desired order + */ + async updateCustomNoteOrder(order: string[]): Promise { + // Filter out duplicate paths and ensure we only store paths that exist in our schedules + const uniqueValidPaths = Array.from(new Set(order)).filter(path => this.schedules[path] !== undefined); + this.customNoteOrder = uniqueValidPaths; + console.log(`Updated custom note order with ${this.customNoteOrder.length} unique entries`); + + // Data saving is now handled by main.ts after this method returns + + // Notify sidebar to update + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + } + + /** + * Get due notes ordered by custom order if available + * + * @param date Optional target date (default: now) + * @param useCustomOrder Whether to apply custom ordering (default: true) + * @param matchExactDate Passed to getDueNotes to filter by exact date if true. + * @returns Array of due note schedules sorted appropriately + */ + getDueNotesWithCustomOrder(date: number = Date.now(), useCustomOrder: boolean = true, matchExactDate: boolean = false): ReviewSchedule[] { + // First, get all due notes using the modified method + const dueNotes = this.getDueNotes(date, matchExactDate); + + // If we have no custom order or are instructed not to use it, return regular order + if (!useCustomOrder || this.customNoteOrder.length === 0) { + return dueNotes; + } + + // Create a map for faster lookups + const notesByPath: Record = {}; + dueNotes.forEach(note => { + notesByPath[note.path] = note; + }); + + // Apply custom order for notes that have an order defined + const notesInOrder: ReviewSchedule[] = []; + const orderedPaths: Set = new Set(); + + // First, add notes that have a defined order + for (const path of this.customNoteOrder) { + if (notesByPath[path]) { + notesInOrder.push(notesByPath[path]); + orderedPaths.add(path); + } + } + + // Then add any remaining notes that don't have a defined order + for (const note of dueNotes) { + if (!orderedPaths.has(note.path)) { + notesInOrder.push(note); + } + } + + return notesInOrder; + } + + /** + * Handles the renaming of a note file. + * Updates the schedule and custom order if the note was scheduled. + * + * @param oldPath The original path of the note. + * @param newPath The new path of the note. + */ + handleNoteRename(oldPath: string, newPath: string): void { + if (this.schedules[oldPath]) { + const schedule = this.schedules[oldPath]; + delete this.schedules[oldPath]; + + schedule.path = newPath; + this.schedules[newPath] = schedule; + + // Update customNoteOrder + const oldPathIndex = this.customNoteOrder.indexOf(oldPath); + if (oldPathIndex > -1) { + this.customNoteOrder[oldPathIndex] = newPath; + } else { + // If oldPath wasn't in custom order for some reason, + // ensure newPath is added if it's not there already. + // This typically shouldn't happen if data is consistent. + if (!this.customNoteOrder.includes(newPath)) { + this.customNoteOrder.push(newPath); + } + } + + console.log(`Handled rename from ${oldPath} to ${newPath}. Schedule updated.`); + + // Notify any listeners (for UI updates) + // Data saving will be handled by the caller in main.ts + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + } + } + + // Helper method for backward compatibility (moved from DataStorage) + private getRepetitionCount(interval: number): number { + // For legacy data migration: estimate repetition count based on interval + if (interval <= 1) return 0; + if (interval <= 6) return 1; + return 2; + } + + public async convertAllSm2ToFsrs(): Promise { + let convertedCount = 0; + for (const path in this.schedules) { + if (Object.prototype.hasOwnProperty.call(this.schedules, path)) { + const schedule = this.schedules[path]; + if (schedule.schedulingAlgorithm === 'sm2') { + schedule.schedulingAlgorithm = 'fsrs'; + // Use last review date or now for FSRS card creation to get a sensible start. + const baseDate = schedule.lastReviewDate ? new Date(schedule.lastReviewDate) : new Date(); + schedule.fsrsData = this.fsrsService.createNewFsrsCardData(baseDate); + // New FSRS cards are typically due 'now' relative to their creation/conversion. + schedule.nextReviewDate = baseDate.getTime(); + + // Clear or nullify SM-2 specific fields + schedule.ease = this.plugin.settings.baseEase; // Keep a base ease for potential future conversion back + schedule.interval = 0; // Reset SM-2 interval + schedule.repetitionCount = 0; + schedule.consecutive = 0; + schedule.scheduleCategory = undefined; + convertedCount++; + } + } + } + console.log(`Converted ${convertedCount} SM-2 cards to FSRS.`); + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); // Notify UI to refresh + } + } + + public async convertAllFsrsToSm2(): Promise { + let convertedCount = 0; + for (const path in this.schedules) { + if (Object.prototype.hasOwnProperty.call(this.schedules, path)) { + const schedule = this.schedules[path]; + if (schedule.schedulingAlgorithm === 'fsrs') { + schedule.schedulingAlgorithm = 'sm2'; + schedule.ease = this.plugin.settings.baseEase; + schedule.interval = 0; // Start with a 0-day interval, due immediately for SM-2 re-evaluation + schedule.repetitionCount = 0; + schedule.consecutive = 0; + schedule.scheduleCategory = this.plugin.settings.useInitialSchedule ? 'initial' : 'spaced'; + + // Set next review date based on SM-2 initial logic, using UTC days + const now = Date.now(); + const todayUTCStart = DateUtils.startOfUTCDay(new Date(now)); + let nextReview = DateUtils.addDays(todayUTCStart, 0); // Due today (UTC midnight) + if (schedule.scheduleCategory === 'initial' && this.plugin.settings.initialScheduleCustomIntervals.length > 0) { + schedule.interval = this.plugin.settings.initialScheduleCustomIntervals[0]; + nextReview = DateUtils.addDays(todayUTCStart, schedule.interval); + } + schedule.nextReviewDate = nextReview; + + schedule.fsrsData = undefined; // Clear FSRS data + convertedCount++; + } + } + } + console.log(`Converted ${convertedCount} FSRS cards to SM-2.`); + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); // Notify UI to refresh + } + } +} diff --git a/services/review-session-service.ts b/services/review-session-service.ts new file mode 100644 index 0000000..b1494d2 --- /dev/null +++ b/services/review-session-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9669bf2 --- /dev/null +++ b/styles.css @@ -0,0 +1,3165 @@ +/* styles/_variables.css */ +:root { + --sf-primary: var(--interactive-accent, #5e81ac); + --sf-primary-light: color-mix(in srgb, var(--sf-primary), white 75%); + --sf-primary-dark: color-mix(in srgb, var(--sf-primary), black 20%); + --sf-secondary: var(--text-accent, #7ba1df); + --sf-success: #4caf50; + --sf-success-light: rgba(76, 175, 80, 0.15); + --sf-warning: #ff9800; + --sf-warning-light: rgba(255, 152, 0, 0.15); + --sf-danger: #f44336; + --sf-danger-light: rgba(244, 67, 54, 0.15); + --sf-info: #2196f3; + --sf-info-light: rgba(33, 150, 243, 0.15); + --sf-text: var(--text-normal, #333); + --sf-text-muted: var(--text-muted, #888); + --sf-text-on-primary: var(--text-on-accent, white); + --sf-bg-primary: var(--background-primary, white); + --sf-bg-secondary: var(--background-secondary, #f5f5f5); + --sf-bg-modifier: var(--background-modifier-hover); + --sf-space-xs: 4px; + --sf-space-sm: 8px; + --sf-space-md: 16px; + --sf-space-lg: 24px; + --sf-space-xl: 32px; + --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); + --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); +} +.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%); + --sf-success-light: rgba(76, 175, 80, 0.2); + --sf-warning-light: rgba(255, 152, 0, 0.2); + --sf-danger-light: rgba(244, 67, 54, 0.2); + --sf-info-light: rgba(33, 150, 243, 0.2); + --sf-bg-primary: var(--background-primary-alt); + --sf-bg-secondary: var(--background-secondary-alt); +} +.theme-dark .mcq-question-text { + color: var(--text-normal); +} + +/* styles/_animations.css */ +@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); + } +} +.sf-animate-fade-in { + animation: fadeIn 0.5s ease-out forwards; +} +.sf-animate-pulse { + animation: pulse 2s infinite; +} + +/* styles/_buttons.css */ +.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); +} + +/* styles/_components.css */ +.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; +} +.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); +} +.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); +} +.review-time-short { + color: #38a169; + 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; + 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; + 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); + 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); + 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; +} + +/* styles/review-buttons.css */ +.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; +} +.review-button-complete-blackout { + background-color: #e53e3e; + color: white; + box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2); +} +.review-button-incorrect { + background-color: #e53e3e; + color: white; + box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2); +} +.review-button-incorrect-familiar { + background-color: #feb2b2; + color: #c53030; + box-shadow: 0 1px 3px rgba(229, 62, 62, 0.1); +} +.review-button-correct-difficulty { + background-color: #fbd38d; + color: #c05621; + box-shadow: 0 1px 3px rgba(192, 86, 33, 0.1); +} +.review-button-correct-hesitation { + background-color: #c6f6d5; + color: #2f855a; + box-shadow: 0 1px 3px rgba(47, 133, 90, 0.1); +} +.review-button-perfect-recall { + background-color: #38a169; + color: white; + box-shadow: 0 1px 3px rgba(56, 161, 105, 0.2); +} +.review-button-hard { + background-color: #e53e3e; + color: white; + box-shadow: 0 1px 3px rgba(229, 62, 62, 0.2); +} +.review-button-fair { + background-color: #fbd38d; + color: #c05621; + box-shadow: 0 1px 3px rgba(192, 86, 33, 0.1); +} +.review-button-good { + background-color: #c6f6d5; + color: #2f855a; + box-shadow: 0 1px 3px rgba(47, 133, 90, 0.1); +} +.review-button-perfect { + background-color: #38a169; + color: white; + box-shadow: 0 1px 3px rgba(56, 161, 105, 0.2); +} +.review-button-postpone { + background-color: #ed8936; + color: white; + box-shadow: 0 1px 3px rgba(237, 137, 54, 0.2); +} +.review-button-skip { + background-color: #f7fafc; + color: #4a5568; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} +.review-button-mcq { + background-color: #4299e1; + color: white; + box-shadow: 0 1px 3px rgba(66, 153, 225, 0.2); +} +.review-button-mcq-refresh { + background-color: #667eea; + color: white; + box-shadow: 0 1px 3px rgba(102, 126, 234, 0.2); +} +.review-button-advance, +.review-note-advance, +.review-bulk-advance, +.review-date-advance-all { + background-color: #38B2AC; + color: white; + box-shadow: 0 1px 3px rgba(56, 178, 172, 0.2); +} +.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); +} +.review-note-button:disabled, +.review-bulk-button:disabled, +.review-date-action-button:disabled, +button.disabled { + opacity: 0.5; + cursor: not-allowed !important; + 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; + filter: grayscale(60%); + 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); +} + +/* styles/settings.css */ +.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; +} +.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; +} +.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; +} + +/* styles/sidebar.css */ +.spaceforge-container { + padding: var(--sf-space-md); + overflow-y: auto; + height: 100%; + background: var(--background-primary); + color: var(--text-normal); + box-shadow: var(--sf-shadow-md); +} +.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; +} +.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); +} +.review-upcoming-day.is-expanded { + background-color: var(--background-secondary); + margin-bottom: 5px; +} +.review-upcoming-notes-container { + margin-top: 8px; + padding-left: 10px; + border-left: 2px solid var(--background-modifier-border-hover); + display: flex; + flex-direction: column; + gap: 5px; +} +.review-upcoming-notes-container .review-note-item { + padding: 5px 0; +} +.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); + font-weight: 600; + letter-spacing: -0.01em; +} +.review-stats { + margin-top: var(--sf-space-xs); + font-size: 14px; + color: var(--text-muted); + display: flex; + gap: var(--sf-space-md); + flex-wrap: wrap; + align-items: center; + justify-content: center; +} +.review-stats-count { + font-weight: 500; + display: flex; + align-items: center; + gap: 5px; + text-align: center; +} +.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); +} +.review-stats-custom-order { + color: var(--interactive-accent) !important; + font-style: italic; + font-size: 0.85em; + display: flex; + align-items: center; + gap: 5px; +} +.review-stats-custom-order::before { + content: "\2b50"; + font-style: normal; +} +.review-buttons-container { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: var(--sf-space-md); +} +.review-buttons-container > * { + margin: 0 !important; +} +.review-all-button, +.review-all-mcq-button { + width: 100%; + padding: var(--sf-space-sm) var(--sf-space-md); + margin: 0 !important; + border-radius: var(--sf-radius-md); + cursor: pointer; + font-weight: 500; + text-align: center; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + transition: var(--sf-transition); + box-shadow: var(--sf-shadow-sm); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.review-all-mcq-button { + background-color: var(--text-accent); +} +.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); + 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); + 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; + flex-shrink: 1; + overflow: hidden; + margin-right: var(--sf-space-sm); +} +.review-date-header h3 { + margin: 0; + font-size: 16px; + color: var(--text-normal); + font-weight: 600; + white-space: normal; + overflow-wrap: break-word; + flex-grow: 1; + min-width: 0; +} +.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; + flex-shrink: 0; + cursor: pointer; + background-color: #edf2f7; + color: #4a5568; + border: none; + transition: var(--sf-transition); +} +.review-date-postpone-all:hover { + background-color: var(--sf-warning); + color: white; +} +.review-date-time { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + padding: 3px 8px; + background-color: var(--background-primary); + border-radius: 100px; + border: 1px solid var(--background-modifier-border); +} +.review-notes-container { + padding: var(--sf-space-sm); + background-color: var(--sf-bg-primary); +} +.review-note-item { + display: flex; + align-items: flex-start; + 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); + 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; + overflow: visible; + text-overflow: clip; + white-space: normal; + 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); +} +@media (max-width: 400px) { + .review-note-actions { + flex-direction: column; + width: 100%; + } + .review-note-button, + .review-note-postpone, + .review-note-remove { + width: 100%; + margin-bottom: 5px; + } + .review-note-remove { + order: -1; + } +} +.review-note-button { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); + padding: 6px 12px; + font-size: 13px; +} +.review-note-postpone { + background-color: var(--background-modifier-border); + color: var(--text-normal); + padding: 6px 12px; + font-size: 13px; +} +.review-note-remove { + background-color: var(--text-error); + color: white; + border-color: var(--text-error); + padding: 6px 12px; + font-size: 13px; +} +.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; + 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); + border: 1px solid var(--background-modifier-border); + position: relative; + overflow: hidden; +} +.review-all-caught-up::before { + content: "\1f389"; + 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); + 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); +} +.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); + 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; +} +.review-buttons-container .pomodoro-toggle-container { + width: 100%; + margin: 0 !important; +} +.review-buttons-container .pomodoro-visibility-toggle-btn { + width: 100%; + padding: calc(var(--sf-space-xs) / 1.5) var(--sf-space-sm); + margin: 0 !important; + 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%; + margin: 0 !important; +} +.pomodoro-container { + padding: 2px var(--sf-space-xs); + margin: 0 !important; + background-color: var(--background-secondary); + border-radius: var(--radius-m); + border: 1px solid var(--background-modifier-border); + display: flex; + flex-direction: column; + gap: var(--sf-space-xs); +} +.pomodoro-main-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 4px; +} +.pomodoro-timer-display { + font-size: 22px; + font-weight: 600; + font-family: monospace; + color: var(--text-normal); + padding: calc(var(--sf-space-xs) / 2) 8px; + border-radius: var(--radius-s); + background-color: var(--background-primary); + min-width: 65px; + text-align: center; + order: 2; + flex-grow: 1; + border: none; + transition: border-color 0.3s ease; +} +.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; +} +.pomodoro-timer-display { + border-left: 3px solid transparent; + border-right: 3px solid transparent; + background-color: var(--background-primary); + color: var(--text-normal); +} +.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); +} +.pomodoro-quick-settings-toggle { + cursor: pointer; + padding: 5px; + order: 4; + border-radius: var(--radius-s); + display: flex; + 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 { + width: 18px; + height: 18px; +} +.pomodoro-main-controls .pomodoro-start-btn, +.pomodoro-main-controls .pomodoro-stop-btn, +.pomodoro-main-controls .pomodoro-skip-btn { + flex-grow: 0; + flex-shrink: 0; + padding: 4px 8px; + 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; +} +.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 { + width: 16px; + height: 16px; +} +.pomodoro-quick-settings-panel { + padding: var(--sf-space-xs); + border-top: 1px solid var(--background-modifier-border); + margin-top: var(--sf-space-xs); + display: flex; + flex-direction: column; + gap: calc(var(--sf-space-xs) / 2); + background-color: var(--background-primary); + 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; + 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; +} +.pomodoro-quick-save-btn:hover { + background-color: var(--interactive-accent-hover); +} +.pomodoro-container.is-paused .pomodoro-timer-display { + opacity: 0.7; +} +.pomodoro-container.is-idle .pomodoro-timer-display { + opacity: 0.5; +} + +/* styles/calendar.css */ +.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); +} +.is-collapsed .calendar-container-wrapper { +} +.is-collapsed .calendar-container { + padding: var(--sf-space-sm); +} +.is-collapsed .calendar-header { + margin-bottom: var(--sf-space-sm); +} +.is-collapsed .calendar-month-title { + font-size: 16px; +} +.is-collapsed .calendar-nav-btn { + padding: 4px 8px; + font-size: 13px; +} +.is-collapsed .calendar-today-btn { + margin-left: 5px; + padding: 4px 8px; + font-size: 12px; +} +.is-collapsed .calendar-grid { + gap: 1px; + grid-auto-rows: minmax(40px, auto); +} +.is-collapsed .calendar-weekday { + font-size: 10px; + padding: 2px 0; +} +.is-collapsed .calendar-day { + min-height: 40px; + padding: 2px; + font-size: 10px; + border-radius: 3px; +} +.is-collapsed .calendar-day-number { + font-size: 11px; + margin-bottom: 1px; +} +.is-collapsed .calendar-day.today .calendar-day-number::after { + width: 10px; +} +.is-collapsed .calendar-review-count { + width: 16px; + height: 16px; + font-size: 8px; + top: 2px; + right: 2px; +} +.is-collapsed .calendar-time-estimate { + font-size: 8px; + padding: 0 2px; +} +.is-collapsed .calendar-day { + min-height: 40px; + padding: 4px; + font-size: 12px; + border-radius: 4px; +} +.is-collapsed .calendar-day-number { + font-size: 13px; + margin-bottom: 2px; +} +.is-collapsed .calendar-day.today .calendar-day-number::after { + width: 12px; +} +.is-collapsed .calendar-review-count { + width: 20px; + height: 20px; + font-size: 10px; + top: 4px; + right: 4px; +} +.is-collapsed .calendar-time-estimate { + font-size: 10px; + padding: 1px 3px; +} +@media (max-width: 768px) { + .calendar-grid { + gap: 2px; + grid-template-columns: repeat(7, 1fr); + } + .calendar-day { + min-height: 30px; + padding: 3px; + font-size: 11px; + border-radius: 4px; + aspect-ratio: auto; + } + .calendar-weekday { + font-size: 10px; + padding: 3px 0; + } + .calendar-day-number { + font-size: 12px; + margin-bottom: 3px; + } + .calendar-review-count { + width: 18px; + height: 18px; + font-size: 9px; + top: 3px; + right: 3px; + } + .calendar-time-estimate { + font-size: 9px; + padding: 1px 3px; + } +} +.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; + color: var(--text-normal); +} +.calendar-nav-btn { + cursor: pointer; + padding: 6px 12px; + border-radius: var(--sf-radius-sm); + background-color: transparent; + transition: var(--sf-transition); + color: var(--text-muted); + font-size: 15px; +} +.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; + 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 { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: minmax(50px, auto); + gap: 4px; + margin-top: var(--sf-space-md); +} +.calendar-weekday { + text-align: center; + font-weight: 600; + font-size: 14px; + padding: 8px 0; + color: var(--text-muted); +} +.calendar-day { + position: relative; + height: auto; + min-height: 50px; + width: 100%; + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + 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; +} +.calendar-day:hover { + border-color: var(--interactive-accent); + box-shadow: var(--sf-shadow-lg); + 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); +} +.calendar-day.has-reviews { + background-color: rgba(var(--interactive-accent-rgb), 0.1); + border-color: var(--interactive-accent); + box-shadow: var(--sf-shadow-sm); +} +.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; +} +.calendar-day.light-load .calendar-review-count { + background-color: var(--sf-success); + animation: fadeScale 0.3s ease-out forwards; +} +.calendar-day.medium-load .calendar-review-count { + background-color: var(--sf-warning); + animation: fadeScale 0.3s ease-out forwards; +} +.calendar-day.heavy-load .calendar-review-count { + background-color: var(--sf-danger); + animation: fadeScale 0.3s ease-out forwards; +} +.calendar-review-count { + position: absolute; + top: 8px; + right: 8px; + 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); + transition: transform 0.2s ease; +} +.calendar-day:hover .calendar-review-count { + transform: scale(1.1); +} +.calendar-time-estimate { + margin-top: auto; + align-self: flex-end; + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + padding: 2px 5px; + background-color: var(--background-modifier-hover); + 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); +} +@media (max-width: 768px) { + .calendar-grid { + gap: 2px; + grid-template-columns: repeat(7, 1fr); + } + .calendar-day { + min-height: 30px; + padding: 3px; + font-size: 11px; + border-radius: 4px; + aspect-ratio: auto; + } + .calendar-weekday { + font-size: 10px; + padding: 3px 0; + } + .calendar-day-number { + font-size: 12px; + margin-bottom: 3px; + } + .calendar-review-count { + width: 18px; + height: 18px; + font-size: 9px; + top: 3px; + right: 3px; + } + .calendar-time-estimate { + font-size: 9px; + padding: 1px 3px; + } +} + +/* styles/mcq.css */ +.mcq-header-container { + 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); + position: relative; + flex-shrink: 0; +} +.mcq-header-container h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: var(--sf-primary); + letter-spacing: -0.02em; +} +.mcq-refresh-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 6px; + color: var(--sf-text-muted); + background-color: transparent; + border-radius: 50%; + cursor: pointer; + transition: var(--sf-transition-bounce); +} +.mcq-refresh-btn:hover { + color: var(--sf-primary); + background-color: var(--sf-primary-light); + transform: rotate(15deg); +} +.mcq-progress { + position: relative; + margin-bottom: 24px; + font-weight: 600; + color: var(--text-normal); + display: flex; + align-items: center; + gap: 12px; + font-size: 1em; + height: 32px; + flex-shrink: 0; +} +.mcq-progress span { + white-space: nowrap; +} +.mcq-progress::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 6px; + background: var(--background-modifier-border); + border-radius: 6px; + overflow: hidden; +} +.mcq-progress::before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + height: 6px; + background: var(--interactive-accent); + border-radius: 6px; + z-index: 2; + width: 0%; + transition: width 0.5s ease; + box-shadow: 0 0 8px rgba(var(--interactive-accent-rgb), 0.4); +} +.mcq-progress-info { + font-size: 14px; + color: var(--text-muted); + margin-top: -12px; + margin-bottom: 16px; + padding-left: 2px; +} +.mcq-question-container { + margin-bottom: 20px; + background-color: var(--background-primary); + border-radius: var(--radius-m, 8px); + padding: 20px; + border: 1px solid var(--background-modifier-border); + animation: fadeIn 0.5s ease-out; + min-height: 300px; + max-height: 900px; + overflow-y: auto; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + flex-grow: 1; + overflow-y: auto; +} +.mcq-question-text { + font-size: 1em; + margin-bottom: 10px; + font-weight: 600; + line-height: 1.4; + color: var(--text-normal); + padding-bottom: 10px; + border-bottom: 1px solid var(--background-modifier-border); +} +.spaceforge-mcq-modal.modal-content { + min-width: 500px; + max-width: 90vw; + min-height: 300px; + padding: 28px !important; + display: flex; + flex-direction: column; +} +[data-progress="0"] .mcq-progress::before { + width: 0% !important; +} +[data-progress="5"] .mcq-progress::before { + width: 5% !important; +} +[data-progress="10"] .mcq-progress::before { + width: 10% !important; +} +[data-progress="15"] .mcq-progress::before { + width: 15% !important; +} +[data-progress="20"] .mcq-progress::before { + width: 20% !important; +} +[data-progress="25"] .mcq-progress::before { + width: 25% !important; +} +[data-progress="30"] .mcq-progress::before { + width: 30% !important; +} +[data-progress="35"] .mcq-progress::before { + width: 35% !important; +} +[data-progress="40"] .mcq-progress::before { + width: 40% !important; +} +[data-progress="45"] .mcq-progress::before { + width: 45% !important; +} +[data-progress="50"] .mcq-progress::before { + width: 50% !important; +} +[data-progress="55"] .mcq-progress::before { + width: 55% !important; +} +[data-progress="60"] .mcq-progress::before { + width: 60% !important; +} +[data-progress="65"] .mcq-progress::before { + width: 65% !important; +} +[data-progress="70"] .mcq-progress::before { + width: 70% !important; +} +[data-progress="75"] .mcq-progress::before { + width: 75% !important; +} +[data-progress="80"] .mcq-progress::before { + width: 80% !important; +} +[data-progress="85"] .mcq-progress::before { + width: 85% !important; +} +[data-progress="90"] .mcq-progress::before { + width: 90% !important; +} +[data-progress="95"] .mcq-progress::before { + width: 95% !important; +} +[data-progress="100"] .mcq-progress::before { + width: 100% !important; +} +.mcq-attempt-warning { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md) var(--sf-space-md); + background-color: var(--sf-warning-light); + border-left: 4px solid var(--sf-warning); + color: var(--sf-warning); + font-weight: 500; + border-radius: var(--sf-radius-md); + display: flex; + align-items: center; + gap: var(--sf-space-md); + box-shadow: var(--sf-shadow); + animation: pulse 2s infinite; + flex-shrink: 0; +} +.mcq-choices-container { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; + padding-right: 8px; + overflow-y: auto; + max-height: 450px; +} +.mcq-choice { + width: 100%; +} +.mcq-choice-btn { + width: 100%; + text-align: left; + padding: 8px 10px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: flex-start; + font-weight: 500; + line-height: 1.4; + color: var(--text-normal); + position: relative; + min-height: auto; + height: auto; + margin-bottom: 6px; + box-sizing: border-box; + font-size: 14px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.mcq-choice-btn:hover { + background-color: var(--background-modifier-hover); + border-color: var(--interactive-accent); + transform: translateX(4px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} +.mcq-choice-btn:active { + transform: translateX(2px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} +.mcq-choice-btn::before { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 4px; + background-color: transparent; + transition: background-color 0.2s ease; +} +.mcq-choice-btn:hover::before { + background-color: var(--sf-primary-light); +} +.mcq-choice-letter { + font-weight: 700; + margin-right: 8px; + color: var(--interactive-accent); + font-size: 0.9em; + min-width: 25px; + text-align: center; + background-color: rgba(var(--interactive-accent-rgb), 0.1); + height: 30px; + width: 30px; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 3px rgba(var(--interactive-accent-rgb), 0.2); +} +.mcq-choice-text { + flex: 1; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + padding-right: 60px; + font-size: 15px; + line-height: 1.5; +} +.mcq-shortcut-hint { + position: absolute; + top: 8px; + right: 10px; + font-size: 12px; + color: var(--text-muted); + background-color: var(--background-secondary-alt); + padding: 3px 8px; + border-radius: 12px; + opacity: 0.7; + transition: opacity 0.2s ease; +} +.mcq-choice-btn:hover .mcq-shortcut-hint { + opacity: 1; +} +.spaceforge-mcq-modal.modal { + max-width: 95vw; + max-height: 95vh; + display: flex; + justify-content: center; + align-items: center; +} +.mcq-choice-correct { + background-color: var(--sf-success-light) !important; + border-color: var(--sf-success) !important; + color: var(--text-normal) !important; + box-shadow: 0 2px 8px rgba(56, 161, 105, 0.1) !important; + animation: correctPulse 0.5s ease-out; + transform: none !important; +} +.mcq-choice-correct::before { + background-color: var(--sf-success) !important; + width: 6px; +} +.mcq-choice-correct .mcq-choice-letter { + color: var(--sf-success); +} +.mcq-choice-incorrect { + background-color: var(--sf-danger-light) !important; + border-color: var(--sf-danger) !important; + color: var(--text-normal) !important; + animation: none; + box-shadow: 0 2px 8px rgba(229, 62, 62, 0.1) !important; +} +.mcq-choice-incorrect::before { + background-color: var(--sf-danger) !important; + width: 6px; +} +.mcq-choice-incorrect .mcq-choice-letter { + color: var(--sf-danger); +} +.mcq-skip-container { + margin-bottom: var(--sf-space-md); + text-align: right; +} +.mcq-skip-button, +.mcq-continue-button, +.mcq-postpone-button { + padding: var(--sf-space-sm) var(--sf-space-md); + border-radius: var(--sf-radius-md); + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease; + cursor: pointer; + border: none; + background-color: var(--background-modifier-border); + color: var(--text-normal); + min-height: 40px; +} +.mcq-postpone-button { + background-color: var(--sf-warning); + color: white; + margin-right: var(--sf-space-sm); +} +.mcq-skip-button:hover, +.mcq-continue-button:hover { + filter: brightness(1.1); +} +.mcq-continue-button { + margin-top: var(--sf-space-md); + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + width: 100%; + padding: var(--sf-space-md); + font-size: 1.05em; + min-height: 48px; +} +.mcq-correct-answer-display { + background-color: var(--background-secondary); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + margin-bottom: var(--sf-space-md); + border: 1px solid var(--background-modifier-border); + animation: fadeIn 0.5s ease-out; + color: var(--text-normal); + box-shadow: var(--sf-shadow); +} +.mcq-score { + margin: var(--sf-space-lg) 0; + text-align: center; + padding: var(--sf-space-lg); + background-color: var(--background-modifier-hover); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow); + position: relative; +} +.mcq-score-text { + font-size: 2em; + font-weight: 600; + color: var(--text-normal); + margin-bottom: var(--sf-space-sm); + text-shadow: 0 0 8px rgba(var(--text-normal-rgb), 0.3); +} +.mcq-score::after { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 300%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(var(--interactive-accent-rgb), 0.1) 50%, transparent 100%); + animation: shimmer 2s infinite; +} +.mcq-results { + margin-top: var(--sf-space-lg); +} +.mcq-results h3 { + margin-bottom: var(--sf-space-md); + padding-bottom: var(--sf-space-md); + border-bottom: 1px solid var(--background-modifier-border); + font-size: 1.4em; + color: var(--text-normal); + font-weight: 600; +} +.mcq-result-item { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--background-secondary); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow); + border-left: 4px solid var(--background-modifier-border); + transition: var(--sf-transition); +} +.mcq-result-item:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow-lg); +} +.mcq-result-question { + font-weight: 600; + margin-bottom: var(--sf-space-md); + font-size: 1.1em; + color: var(--text-normal); + padding-bottom: var(--sf-space-sm); + border-bottom: 1px solid var(--background-modifier-border); +} +.mcq-result-your-answer, +.mcq-result-correct-answer, +.mcq-result-attempts, +.mcq-result-time { + margin-top: var(--sf-space-sm); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--sf-space-sm); +} +.mcq-result-label { + font-weight: 600; + color: var(--text-normal); +} +.mcq-result-correct { + color: var(--sf-success); + font-weight: 500; +} +.mcq-result-incorrect { + color: var(--sf-danger); + font-weight: 500; +} +.mcq-result-attempts, +.mcq-result-time { + font-size: 0.9em; + color: var(--text-normal); +} +.mcq-close-btn { + width: 100%; + padding: var(--sf-space-md); + margin-top: var(--sf-space-lg); + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + border: none; + border-radius: var(--sf-radius-md); + cursor: pointer; + font-weight: 600; + font-size: 1.1em; + transition: var(--sf-transition); + box-shadow: var(--sf-shadow); +} +.mcq-close-btn:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow-lg); +} +.mcq-close-btn:active { + transform: translateY(0); +} +.mcq-note-info { + font-style: italic; + margin-bottom: var(--sf-space-md); + padding: var(--sf-space-md) var(--sf-space-md); + background-color: var(--background-secondary); + border-radius: var(--sf-radius-md); + border-left: 4px solid var(--interactive-accent); + display: flex; + align-items: center; + gap: 10px; + box-shadow: var(--sf-shadow); + color: var(--text-normal); +} +.mcq-note-info::before { + content: "\1f4c4"; + font-style: normal; +} +.mcq-collection-progress { + width: 100%; + height: 6px; + margin-top: var(--sf-space-lg); + background-color: var(--background-modifier-border); + border-radius: 10px; + overflow: hidden; + position: relative; +} +.mcq-collection-progress::after { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: var(--sf-primary); + transition: width 0.5s ease; +} +.batch-review-status { + margin-top: var(--sf-space-md); + color: var(--sf-text-muted); + font-style: italic; + text-align: center; +} +.mcq-note-scores { + margin-top: var(--sf-space-xl); + max-height: 350px; + overflow-y: auto; + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); + padding: var(--sf-space-lg); + box-shadow: var(--sf-shadow); + scrollbar-width: thin; +} +.mcq-note-scores::-webkit-scrollbar { + width: 6px; +} +.mcq-note-scores::-webkit-scrollbar-track { + background: var(--sf-bg-primary); +} +.mcq-note-scores::-webkit-scrollbar-thumb { + background-color: var(--background-modifier-border); + border-radius: 10px; +} +.mcq-note-score { + margin-bottom: var(--sf-space-md); + padding-bottom: var(--sf-space-md); + border-bottom: 1px solid var(--background-modifier-border); + transition: var(--sf-transition); +} +.mcq-note-score:hover { + background-color: var(--sf-bg-modifier); + padding: 10px; + margin: -10px -10px 6px -10px; + border-radius: var(--sf-radius-md); +} +.mcq-note-score:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} +.mcq-note-score-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--text-normal); + display: flex; + align-items: center; + gap: var(--sf-space-sm); +} +.mcq-note-score-value { + font-weight: 500; + padding: 4px 10px; + border-radius: 100px; + display: inline-block; + margin-top: 4px; + font-size: 0.95em; +} +.batch-review-info { + margin-bottom: var(--sf-space-lg); + background-color: var(--sf-info-light); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + border-left: 4px solid var(--sf-info); +} +.batch-review-time { + font-style: italic; + color: var(--sf-text-muted); + margin-top: var(--sf-space-xs); +} +.batch-review-buttons { + display: flex; + flex-direction: column; + gap: var(--sf-space-md); + margin: var(--sf-space-lg) 0; +} +.batch-review-buttons button { + padding: var(--sf-space-md); + font-size: 14px; + font-weight: 500; + border-radius: var(--sf-radius-md); + cursor: pointer; + transition: var(--sf-transition); + border: none; +} +.batch-review-start-button { + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); +} +.batch-review-toggle-button { + background-color: var(--sf-secondary); + color: var(--sf-text-on-primary); +} +.batch-review-regenerate-button { + background-color: var(--sf-info); + color: white; +} +.batch-review-cancel-button { + background-color: var(--sf-bg-secondary); + color: var(--sf-text); + border: 1px solid var(--background-modifier-border) !important; +} +.batch-review-progress { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); +} +.batch-review-current-note { + font-weight: bold; + color: var(--sf-primary); + margin-bottom: var(--sf-space-xs); +} +.batch-review-summary-stats { + background-color: var(--sf-bg-secondary); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + margin: var(--sf-space-lg) 0; + box-shadow: var(--sf-shadow); +} +.batch-review-success { + color: var(--sf-success); + font-weight: bold; +} +.batch-review-needs-improvement { + color: var(--sf-danger); + font-weight: bold; +} +.batch-review-results { + margin-top: var(--sf-space-lg); +} +.batch-review-result-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sf-space-md); + margin-bottom: var(--sf-space-xs); + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); + transition: var(--sf-transition); +} +.batch-review-result-item:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow); +} +.batch-review-result-filename { + font-weight: 500; + flex: 1; +} +.batch-review-result-mcq-score { + font-style: italic; + margin-left: 10px; +} +.batch-review-complete-blackout, +.batch-review-incorrect, +.batch-review-incorrect-familiar, +.batch-review-correct-difficulty, +.batch-review-correct-hesitation, +.batch-review-perfect-recall, +.batch-review-hard, +.batch-review-fair, +.batch-review-good, +.batch-review-perfect { + padding: 5px 10px; + border-radius: 100px; + min-width: 100px; + text-align: center; + font-weight: 500; + font-size: 13px; +} +.batch-review-complete-blackout { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} +.batch-review-incorrect { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} +.batch-review-incorrect-familiar { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} +.batch-review-correct-hesitation { + background-color: var(--sf-success-light); + color: var(--sf-success); +} +.batch-review-hard { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} +.batch-review-fair { + background-color: var(--sf-warning-light); + color: var(--sf-warning); +} +.batch-review-good { + background-color: var(--sf-success-light); + color: var(--sf-success); +} +.batch-review-perfect { + background-color: var(--sf-success-light); + color: var(--sf-success); +} +.batch-review-good { + background-color: var(--sf-success-light); + color: var(--sf-success); +} +.batch-review-perfect { + background-color: var(--sf-success-light); + color: var(--sf-success); +} +.batch-review-close-button { + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + padding: var(--sf-space-md); + font-size: 14px; + font-weight: 500; + border-radius: var(--sf-radius-md); + cursor: pointer; + transition: var(--sf-transition); + margin-top: var(--sf-space-lg); + width: 100%; + border: none; +} +.batch-review-close-button:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow); +} +.mcq-choice-btn { + position: relative; + overflow: hidden; + z-index: 1; +} +.mcq-choice-btn::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 5px; + height: 5px; + background: rgba(var(--sf-primary), 0.3); + opacity: 0; + border-radius: 100%; + transform: scale(1, 1) translate(-50%, -50%); + transform-origin: 50% 50%; + z-index: -1; +} +.mcq-choice-btn:focus { + outline: none; +} +.mcq-choice-btn:focus::after { + animation: ripple 0.8s ease-out; +} +.mcq-celebration { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; + z-index: 10; +} +.mcq-celebration-item { + position: absolute; + width: 10px; + height: 10px; + background: var(--sf-primary); + border-radius: 2px; + animation: celebrationConfetti 3s ease-out forwards; +} +[class*=" or "], +[class^="or "] { + background-color: var(--sf-bg-primary) !important; + color: var(--sf-text) !important; + border: 1px solid var(--background-modifier-border) !important; + padding: 2px 6px !important; + border-radius: 4px !important; +} +.review-time-short, +.review-time-medium, +.review-time-long, +.sm2-info, +.sm2-ratings-title, +.sm2-ratings-desc, +.sm2-ratings-table, +.sm2-ratings-table th, +.sm2-ratings-table td, +.prompt-setting, +.prompt-textarea, +.sf-settings-section, +.sf-settings-section-header, +.sf-settings-icon, +.sf-settings-section-header h3, +.sf-settings-collapse-indicator, +.sf-settings-section-content, +.sf-settings-subsection, +.sf-setting-explain, +.sf-setting-highlight, +.sf-setting-grid, +.sf-settings-actions, +.sf-info-box, +.sf-prompt-label, +.sf-system-prompts-container, +.sf-danger-zone, +.mcq-shortcut-hint, +.hidden { +} +.mcq-choice-selected { + border-color: var(--interactive-accent) !important; + box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.5), 0 1px 3px rgba(0, 0, 0, 0.1); + transform: translateX(4px); +} +.mcq-key-pressed { + transform: scale(0.98); + opacity: 0.9; +} +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn { + color: var(--text-normal) !important; +} +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn .mcq-choice-text { + color: var(--text-normal) !important; +} +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn .mcq-shortcut-hint { + color: var(--text-muted) !important; +} +.mcq-result-algorithm-details { + margin-top: var(--sf-space-sm); + padding-top: var(--sf-space-sm); + border-top: 1px dashed var(--background-modifier-border); + font-size: 0.9em; +} +.mcq-result-algorithm-details .mcq-result-label { + font-weight: 600; + color: var(--text-normal); + margin-right: var(--sf-space-xs); +} +.mcq-result-algorithm-value { + color: var(--text-muted); +} +.mcq-result-algorithm-item { + margin-bottom: var(--sf-space-xs); + display: flex; + align-items: baseline; +} +.mcq-result-algorithm-details h4.mcq-result-label { + margin-bottom: var(--sf-space-sm); + font-size: 1.1em; + color: var(--text-normal); +} +:root { + --mcq-correct-answer-text: var(--sf-success-dark, #276749); +} +.mcq-detailed-breakdown { + margin-top: var(--sf-space-xl); + padding-top: var(--sf-space-lg); + border-top: 1px solid var(--background-modifier-border); +} +.mcq-detailed-breakdown h3 { + margin-bottom: var(--sf-space-lg); + font-size: 1.3em; + color: var(--text-normal); + font-weight: 600; +} +.mcq-breakdown-item { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow-sm); +} +.mcq-breakdown-q-header { + font-weight: 600; + color: var(--text-muted); +} +.mcq-breakdown-item div { + margin-bottom: var(--sf-space-xs); + line-height: 1.5; +} +.mcq-breakdown-item span { + display: inline; +} +.mcq-breakdown-item div > span:first-child { + font-weight: 500; + color: var(--text-faint); +} +.mcq-breakdown-item .user-answer-text { +} +.mcq-breakdown-item .correctness-indicator { + font-weight: bold; +} +.mcq-breakdown-item .correct-answer-text { + color: var(--mcq-correct-answer-text); + font-weight: bold; +} + +/* styles/pomodoro.css */ +.pomodoro-visibility-toggle-container { + display: none !important; +} +.sidebar-pomodoro-section-container { + width: 100%; + margin-top: 8px; + margin-bottom: 8px; + padding: 8px; + background-color: var(--background-secondary); + border-radius: var(--radius-m); + border: 1px solid var(--background-modifier-border); + box-sizing: border-box; +} +.pomodoro-visibility-toggle-container { + margin-bottom: 8px; +} +.pomodoro-visibility-toggle-container .pomodoro-visibility-toggle-btn { + width: 100%; + padding: 6px 12px; + background-color: var(--background-modifier-border); + 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-main-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} +.pomodoro-main-controls .pomodoro-start-btn, +.pomodoro-main-controls .pomodoro-stop-btn, +.pomodoro-main-controls .pomodoro-skip-btn { + padding: 6px; + 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); +} +.pomodoro-main-controls .pomodoro-start-btn svg, +.pomodoro-main-controls .pomodoro-stop-btn svg, +.pomodoro-main-controls .pomodoro-skip-btn svg { + width: 18px; + 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; + transition: background-color 0.2s ease-in-out; +} +.pomodoro-timer-display:hover { + background-color: var(--background-secondary-alt); +} +.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(--sf-success); + border-right-color: var(--sf-success); +} +.pomodoro-timer-display.mode-longBreak { + border-left-color: var(--sf-info); + 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); +} +.pomodoro-settings-panel-container { + position: relative; + width: 100%; + z-index: 10; +} +.pomodoro-quick-settings-panel { + position: absolute; + top: 0; + left: 0; + right: 0; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-top: none; + border-radius: 0 0 var(--radius-m) var(--radius-m); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0; + flex-direction: column; + gap: 8px; + overflow: hidden; + max-height: 0; + opacity: 0; + transition: + max-height 0.35s cubic-bezier(0.25, 0.1, 0.25, 1), + opacity 0.3s ease-out, + padding 0.35s cubic-bezier(0.25, 0.1, 0.25, 1); +} +.pomodoro-quick-settings-panel[style*="display: flex"] { + padding: 12px; + max-height: 500px; + opacity: 1; + border-top: 1px solid var(--background-modifier-border); +} +.pomodoro-quick-setting { + display: flex; + justify-content: space-between; + align-items: center; +} +.pomodoro-quick-setting label { + font-size: 13px; + color: var(--text-muted); +} +.pomodoro-quick-setting input[type=number] { + width: 50px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: 4px 6px; + text-align: right; +} +.pomodoro-quick-settings-buttons { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + 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; + border-radius: var(--radius-s); + cursor: pointer; + transition: var(--sf-transition); + border: none; + box-sizing: border-box; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + text-align: center; +} +.pomodoro-quick-save-btn { + display: none; +} +.pomodoro-quick-save-btn:hover { +} +.pomodoro-quick-calculate-btn { + background-color: var(--background-modifier-border); + color: var(--text-normal); +} +.pomodoro-quick-calculate-btn:hover { + background-color: var(--background-modifier-hover); +} +.pomodoro-calculation-result { + font-size: 0.9em; + margin-top: 8px; + padding: 8px; + background-color: var(--background-secondary-alt); + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); +} +.pomodoro-calculation-result p { + margin: 4px 0; +} + +/* styles/responsive.css */ +@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%; + } +} +@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; + } +} +@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; + } +} +@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; + } +} + +/* styles/styles.css */ diff --git a/styles/_animations.css b/styles/_animations.css new file mode 100644 index 0000000..b547196 --- /dev/null +++ b/styles/_animations.css @@ -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; +} diff --git a/styles/_buttons.css b/styles/_buttons.css new file mode 100644 index 0000000..3087717 --- /dev/null +++ b/styles/_buttons.css @@ -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); +} diff --git a/styles/_components.css b/styles/_components.css new file mode 100644 index 0000000..2a1c058 --- /dev/null +++ b/styles/_components.css @@ -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; +} diff --git a/styles/_variables.css b/styles/_variables.css new file mode 100644 index 0000000..338cc41 --- /dev/null +++ b/styles/_variables.css @@ -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 */ +} diff --git a/styles/calendar.css b/styles/calendar.css new file mode 100644 index 0000000..603f8df --- /dev/null +++ b/styles/calendar.css @@ -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 */ + } +} diff --git a/styles/mcq.css b/styles/mcq.css new file mode 100644 index 0000000..9255a0d --- /dev/null +++ b/styles/mcq.css @@ -0,0 +1,1015 @@ +/* Spaceforge - MCQ Styles */ + +/* ====== MCQ Modal Base Styles ====== */ +.mcq-header-container { + 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); + position: relative; + flex-shrink: 0; /* Prevent shrinking */ +} + +.mcq-header-container h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: var(--sf-primary); + letter-spacing: -0.02em; +} + +.mcq-refresh-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 6px; + color: var(--sf-text-muted); + background-color: transparent; + border-radius: 50%; + cursor: pointer; + transition: var(--sf-transition-bounce); +} + +.mcq-refresh-btn:hover { + color: var(--sf-primary); + background-color: var(--sf-primary-light); + transform: rotate(15deg); +} + +/* ====== Improved MCQ Progress Indicator ====== */ +.mcq-progress { + position: relative; + margin-bottom: 24px; + font-weight: 600; + color: var(--text-normal); + display: flex; + align-items: center; + gap: 12px; + font-size: 1em; + height: 32px; /* Taller for better visibility */ + flex-shrink: 0; /* Prevent shrinking */ +} + +/* Question counter text styling */ +.mcq-progress span { + white-space: nowrap; +} + +/* Clean, modern progress bar */ +.mcq-progress::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 6px; /* Thicker bar */ + background: var(--background-modifier-border); + border-radius: 6px; + overflow: hidden; +} + +.mcq-progress::before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + height: 6px; /* Thicker bar */ + background: var(--interactive-accent); + border-radius: 6px; + z-index: 2; + width: 0%; /* Default width, overridden by data-progress */ + transition: width 0.5s ease; + box-shadow: 0 0 8px rgba(var(--interactive-accent-rgb), 0.4); +} + +/* Secondary information below progress */ +.mcq-progress-info { + font-size: 14px; + color: var(--text-muted); + margin-top: -12px; + margin-bottom: 16px; + padding-left: 2px; +} + +/* MCQ Question Container - Much larger dimensions */ +.mcq-question-container { + margin-bottom: 20px; + background-color: var(--background-primary); + border-radius: var(--radius-m, 8px); + padding: 20px; + border: 1px solid var(--background-modifier-border); + animation: fadeIn 0.5s ease-out; + min-height: 300px; /* Much greater minimum height */ + max-height: 900px; /* Much greater maximum height */ + overflow-y: auto; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + flex-grow: 1; /* Allow to grow and take available space */ + overflow-y: auto; /* Add scrolling if content overflows */ +} + +/* Improve question text visibility */ +.mcq-question-text { + font-size: 1em; + margin-bottom: 10px; + font-weight: 600; + line-height: 1.4; + color: var(--text-normal); + padding-bottom: 10px; + border-bottom: 1px solid var(--background-modifier-border); +} + +/* Ensure the MCQ modal takes more space */ +.spaceforge-mcq-modal.modal-content { + min-width: 500px; /* Ensure modal has minimum width */ + max-width: 90vw; /* Allow up to 90% of viewport width */ + min-height: 300px; /* Ensure minimum height */ + padding: 28px !important; + display: flex; /* Make modal content a flex container */ + flex-direction: column; /* Stack children vertically */ +} + +/* Enhanced progress indicator with more granular steps */ +[data-progress="0"] .mcq-progress::before { width: 0% !important; } +[data-progress="5"] .mcq-progress::before { width: 5% !important; } +[data-progress="10"] .mcq-progress::before { width: 10% !important; } +[data-progress="15"] .mcq-progress::before { width: 15% !important; } +[data-progress="20"] .mcq-progress::before { width: 20% !important; } +[data-progress="25"] .mcq-progress::before { width: 25% !important; } +[data-progress="30"] .mcq-progress::before { width: 30% !important; } +[data-progress="35"] .mcq-progress::before { width: 35% !important; } +[data-progress="40"] .mcq-progress::before { width: 40% !important; } +[data-progress="45"] .mcq-progress::before { width: 45% !important; } +[data-progress="50"] .mcq-progress::before { width: 50% !important; } +[data-progress="55"] .mcq-progress::before { width: 55% !important; } +[data-progress="60"] .mcq-progress::before { width: 60% !important; } +[data-progress="65"] .mcq-progress::before { width: 65% !important; } +[data-progress="70"] .mcq-progress::before { width: 70% !important; } +[data-progress="75"] .mcq-progress::before { width: 75% !important; } +[data-progress="80"] .mcq-progress::before { width: 80% !important; } +[data-progress="85"] .mcq-progress::before { width: 85% !important; } +[data-progress="90"] .mcq-progress::before { width: 90% !important; } +[data-progress="95"] .mcq-progress::before { width: 95% !important; } +[data-progress="100"] .mcq-progress::before { width: 100% !important; } + +/* ====== MCQ Warning Message ====== */ +.mcq-attempt-warning { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md) var(--sf-space-md); + background-color: var(--sf-warning-light); + border-left: 4px solid var(--sf-warning); + color: var(--sf-warning); + font-weight: 500; + border-radius: var(--sf-radius-md); + display: flex; + align-items: center; + gap: var(--sf-space-md); + box-shadow: var(--sf-shadow); + animation: pulse 2s infinite; + flex-shrink: 0; /* Prevent shrinking */ +} + +/* ====== MCQ Answer Choices ====== */ +.mcq-choices-container { + display: flex; + flex-direction: column; + gap: 10px; /* Increased gap between choices */ + margin-top: 20px; /* Increased margin top */ + padding-right: 8px; /* Room for scrollbar */ + overflow-y: auto; /* Add scrolling for choices if needed */ + max-height: 450px; /* Increased max height */ +} + +.mcq-choice { + width: 100%; +} + +/* Large, prominent MCQ choice buttons */ +.mcq-choice-btn { + width: 100%; + text-align: left; + padding: 8px 10px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: flex-start; /* Align items to the start for multi-line text */ + font-weight: 500; + line-height: 1.4; + color: var(--text-normal); + position: relative; + /* overflow: hidden; Removed to prevent clipping */ + min-height: auto; /* Allow button to grow with content */ + height: auto; /* Allow button to grow with content */ + margin-bottom: 6px; + box-sizing: border-box; + font-size: 14px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Improved hover and active states */ +.mcq-choice-btn:hover { + background-color: var(--background-modifier-hover); + border-color: var(--interactive-accent); + transform: translateX(4px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +.mcq-choice-btn:active { + transform: translateX(2px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.mcq-choice-btn::before { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 4px; + background-color: transparent; + transition: background-color 0.2s ease; +} + +.mcq-choice-btn:hover::before { + background-color: var(--sf-primary-light); +} + +/* Prominent letter indicator */ +.mcq-choice-letter { + font-weight: 700; + margin-right: 8px; + color: var(--interactive-accent); + font-size: 0.9em; + min-width: 25px; + text-align: center; + background-color: rgba(var(--interactive-accent-rgb), 0.1); + height: 30px; + width: 30px; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 3px rgba(var(--interactive-accent-rgb), 0.2); +} + +/* Enhanced answer text */ +.mcq-choice-text { + flex: 1; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; /* Ensure text wraps */ + padding-right: 60px; /* Increased padding to ensure more space for hint */ + font-size: 15px; /* Slightly increased font size */ + line-height: 1.5; /* Increased line height */ +} + +/* Key indicator on the right of choices */ +.mcq-shortcut-hint { + position: absolute; + top: 8px; /* Position hint at the top right */ + right: 10px; /* Position hint at the top right */ + font-size: 12px; + color: var(--text-muted); + background-color: var(--background-secondary-alt); + padding: 3px 8px; + border-radius: 12px; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.mcq-choice-btn:hover .mcq-shortcut-hint { + opacity: 1; +} + +/* Make the MCQ modal significantly larger */ +.spaceforge-mcq-modal.modal { + max-width: 95vw; + max-height: 95vh; + display: flex; + justify-content: center; + align-items: center; +} + +/* MCQ Choice Correct/Incorrect Styling - fixed dimensions with modern colors */ +.mcq-choice-correct { + background-color: var(--sf-success-light) !important; /* Modern green with lower opacity */ + border-color: var(--sf-success) !important; /* Modern green */ + color: var(--text-normal) !important; + box-shadow: 0 2px 8px rgba(56, 161, 105, 0.1) !important; + animation: correctPulse 0.5s ease-out; + transform: none !important; /* Prevent layout shifts */ +} + +.mcq-choice-correct::before { + background-color: var(--sf-success) !important; /* Modern green */ + width: 6px; +} + +.mcq-choice-correct .mcq-choice-letter { + color: var(--sf-success); /* Modern green */ +} + +.mcq-choice-incorrect { + background-color: var(--sf-danger-light) !important; /* Modern red with lower opacity */ + border-color: var(--sf-danger) !important; /* Modern red */ + color: var(--text-normal) !important; + animation: none; /* Remove the shake animation to prevent layout shifts */ + box-shadow: 0 2px 8px rgba(229, 62, 62, 0.1) !important; +} + +.mcq-choice-incorrect::before { + background-color: var(--sf-danger) !important; /* Modern red */ + width: 6px; +} + +.mcq-choice-incorrect .mcq-choice-letter { + color: var(--sf-danger); /* Modern red */ +} + +/* Skip/Continue Button Styling */ +.mcq-skip-container { + margin-bottom: var(--sf-space-md); + text-align: right; +} + +.mcq-skip-button, +.mcq-continue-button, +.mcq-postpone-button { + padding: var(--sf-space-sm) var(--sf-space-md); + border-radius: var(--sf-radius-md); + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease; + cursor: pointer; + border: none; + background-color: var(--background-modifier-border); + color: var(--text-normal); + min-height: 40px; /* Consistent height */ +} + +.mcq-postpone-button { + background-color: var(--sf-warning); + color: white; + margin-right: var(--sf-space-sm); +} + +.mcq-skip-button:hover, +.mcq-continue-button:hover { + filter: brightness(1.1); +} + +.mcq-continue-button { + margin-top: var(--sf-space-md); + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + width: 100%; + padding: var(--sf-space-md); + font-size: 1.05em; + min-height: 48px; /* Slightly taller for emphasis */ +} + +/* Correct Answer Display */ +.mcq-correct-answer-display { + background-color: var(--background-secondary); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + margin-bottom: var(--sf-space-md); + border: 1px solid var(--background-modifier-border); + animation: fadeIn 0.5s ease-out; + color: var(--text-normal); + box-shadow: var(--sf-shadow); +} + +/* ====== MCQ Results Styling ====== */ +.mcq-score { + margin: var(--sf-space-lg) 0; + text-align: center; + padding: var(--sf-space-lg); + background-color: var(--background-modifier-hover); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow); + position: relative; +} + +.mcq-score-text { + font-size: 2em; + font-weight: 600; + color: var(--text-normal); + margin-bottom: var(--sf-space-sm); + text-shadow: 0 0 8px rgba(var(--text-normal-rgb), 0.3); +} + +.mcq-score::after { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 300%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(var(--interactive-accent-rgb), 0.1) 50%, + transparent 100% + ); + animation: shimmer 2s infinite; +} + +.mcq-results { + margin-top: var(--sf-space-lg); +} + +.mcq-results h3 { + margin-bottom: var(--sf-space-md); + padding-bottom: var(--sf-space-md); + border-bottom: 1px solid var(--background-modifier-border); + font-size: 1.4em; + color: var(--text-normal); + font-weight: 600; +} + +.mcq-result-item { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--background-secondary); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow); + border-left: 4px solid var(--background-modifier-border); + transition: var(--sf-transition); +} + +.mcq-result-item:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow-lg); +} + +.mcq-result-question { + font-weight: 600; + margin-bottom: var(--sf-space-md); + font-size: 1.1em; + color: var(--text-normal); + padding-bottom: var(--sf-space-sm); + border-bottom: 1px solid var(--background-modifier-border); +} + +.mcq-result-your-answer, +.mcq-result-correct-answer, +.mcq-result-attempts, +.mcq-result-time { + margin-top: var(--sf-space-sm); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--sf-space-sm); +} + +.mcq-result-label { + font-weight: 600; + color: var(--text-normal); +} + +.mcq-result-correct { + color: var(--sf-success); + font-weight: 500; +} + +.mcq-result-incorrect { + color: var(--sf-danger); + font-weight: 500; +} + +.mcq-result-attempts, +.mcq-result-time { + font-size: 0.9em; + color: var(--text-normal); +} + +.mcq-close-btn { + width: 100%; + padding: var(--sf-space-md); + margin-top: var(--sf-space-lg); + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + border: none; + border-radius: var(--sf-radius-md); + cursor: pointer; + font-weight: 600; + font-size: 1.1em; + transition: var(--sf-transition); + box-shadow: var(--sf-shadow); +} + +.mcq-close-btn:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow-lg); +} + +.mcq-close-btn:active { + transform: translateY(0); +} + +/* ====== Consolidated MCQ Modal Styles ====== */ +.mcq-note-info { + font-style: italic; + margin-bottom: var(--sf-space-md); + padding: var(--sf-space-md) var(--sf-space-md); + background-color: var(--background-secondary); + border-radius: var(--sf-radius-md); + border-left: 4px solid var(--interactive-accent); + display: flex; + align-items: center; + gap: 10px; + box-shadow: var(--sf-shadow); + color: var(--text-normal); +} + +.mcq-note-info::before { + content: "📄"; + font-style: normal; +} + +.mcq-collection-progress { + width: 100%; + height: 6px; + margin-top: var(--sf-space-lg); + background-color: var(--background-modifier-border); + border-radius: 10px; + overflow: hidden; + position: relative; +} + +.mcq-collection-progress::after { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: var(--sf-primary); + transition: width 0.5s ease; +} + +.batch-review-status { + margin-top: var(--sf-space-md); + color: var(--sf-text-muted); + font-style: italic; + text-align: center; +} + +.mcq-note-scores { + margin-top: var(--sf-space-xl); + max-height: 350px; + overflow-y: auto; + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); + padding: var(--sf-space-lg); + box-shadow: var(--sf-shadow); + scrollbar-width: thin; +} + +.mcq-note-scores::-webkit-scrollbar { + width: 6px; +} + +.mcq-note-scores::-webkit-scrollbar-track { + background: var(--sf-bg-primary); +} + +.mcq-note-scores::-webkit-scrollbar-thumb { + background-color: var(--background-modifier-border); + border-radius: 10px; +} + +.mcq-note-score { + margin-bottom: var(--sf-space-md); + padding-bottom: var(--sf-space-md); + border-bottom: 1px solid var(--background-modifier-border); + transition: var(--sf-transition); +} + +.mcq-note-score:hover { + background-color: var(--sf-bg-modifier); + padding: 10px; + margin: -10px -10px 6px -10px; + border-radius: var(--sf-radius-md); +} + +.mcq-note-score:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.mcq-note-score-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--text-normal); + display: flex; + align-items: center; + gap: var(--sf-space-sm); +} + +.mcq-note-score-value { + font-weight: 500; + padding: 4px 10px; + border-radius: 100px; + display: inline-block; + margin-top: 4px; + font-size: 0.95em; +} + +/* ====== Batch Review Modal Styles ====== */ +.batch-review-info { + margin-bottom: var(--sf-space-lg); + background-color: var(--sf-info-light); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + border-left: 4px solid var(--sf-info); +} + +.batch-review-time { + font-style: italic; + color: var(--sf-text-muted); + margin-top: var(--sf-space-xs); +} + +.batch-review-buttons { + display: flex; + flex-direction: column; + gap: var(--sf-space-md); + margin: var(--sf-space-lg) 0; +} + +.batch-review-buttons button { + padding: var(--sf-space-md); + font-size: 14px; + font-weight: 500; + border-radius: var(--sf-radius-md); + cursor: pointer; + transition: var(--sf-transition); + border: none; +} + +.batch-review-start-button { + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); +} + +.batch-review-toggle-button { + background-color: var(--sf-secondary); + color: var(--sf-text-on-primary); +} + +.batch-review-regenerate-button { + background-color: var(--sf-info); + color: white; +} + +.batch-review-cancel-button { + background-color: var(--sf-bg-secondary); + color: var(--sf-text); + border: 1px solid var(--background-modifier-border) !important; +} + +.batch-review-progress { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); +} + +.batch-review-current-note { + font-weight: bold; + color: var(--sf-primary); + margin-bottom: var(--sf-space-xs); +} + +.batch-review-summary-stats { + background-color: var(--sf-bg-secondary); + padding: var(--sf-space-md); + border-radius: var(--sf-radius-md); + margin: var(--sf-space-lg) 0; + box-shadow: var(--sf-shadow); +} + +.batch-review-success { + color: var(--sf-success); + font-weight: bold; +} + +.batch-review-needs-improvement { + color: var(--sf-danger); + font-weight: bold; +} + +.batch-review-results { + margin-top: var(--sf-space-lg); +} + +.batch-review-result-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sf-space-md); + margin-bottom: var(--sf-space-xs); + background-color: var(--sf-bg-secondary); + border-radius: var(--sf-radius-md); + transition: var(--sf-transition); +} + +.batch-review-result-item:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow); +} + +.batch-review-result-filename { + font-weight: 500; + flex: 1; +} + +.batch-review-result-mcq-score { + font-style: italic; + margin-left: 10px; +} + +.batch-review-complete-blackout, +.batch-review-incorrect, +.batch-review-incorrect-familiar, +.batch-review-correct-difficulty, +.batch-review-correct-hesitation, +.batch-review-perfect-recall, +.batch-review-hard, +.batch-review-fair, +.batch-review-good, +.batch-review-perfect { + padding: 5px 10px; + border-radius: 100px; + min-width: 100px; + text-align: center; + font-weight: 500; + font-size: 13px; +} + +.batch-review-complete-blackout { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} + +.batch-review-incorrect { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} + +.batch-review-incorrect-familiar { + background-color: var(--sf-danger-light); /* Using light danger variable */ + color: var(--sf-danger); /* Using danger variable */ +} + +.batch-review-correct-hesitation { + background-color: var(--sf-success-light); /* Using light success variable */ + color: var(--sf-success); /* Using success variable */ +} + +/* Legacy result styles */ +.batch-review-hard { + background-color: var(--sf-danger-light); + color: var(--sf-danger); +} + +.batch-review-fair { + background-color: var(--sf-warning-light); + color: var(--sf-warning); +} + +.batch-review-good { + background-color: var(--sf-success-light); /* Using light success variable */ + color: var(--sf-success); /* Using success variable */ +} + +.batch-review-perfect { + background-color: var(--sf-success-light); + color: var(--sf-success); +} + +.batch-review-good { + background-color: var(--sf-success-light); + color: var(--sf-success); +} + +.batch-review-perfect { + background-color: var(--sf-success-light); + color: var(--sf-success); +} + +.batch-review-close-button { + background-color: var(--sf-primary); + color: var(--sf-text-on-primary); + padding: var(--sf-space-md); + font-size: 14px; + font-weight: 500; + border-radius: var(--sf-radius-md); + cursor: pointer; + transition: var(--sf-transition); + margin-top: var(--sf-space-lg); + width: 100%; + border: none; +} + +.batch-review-close-button:hover { + transform: translateY(-2px); + box-shadow: var(--sf-shadow); +} + +/* Additional MCQ enhancements */ +.mcq-choice-btn { + position: relative; + overflow: hidden; + z-index: 1; +} + +.mcq-choice-btn::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 5px; + height: 5px; + background: rgba(var(--sf-primary), 0.3); + opacity: 0; + border-radius: 100%; + transform: scale(1, 1) translate(-50%, -50%); + transform-origin: 50% 50%; + z-index: -1; +} + +.mcq-choice-btn:focus { + outline: none; +} + +.mcq-choice-btn:focus::after { + animation: ripple 0.8s ease-out; +} + +/* MCQ celebration styles */ +.mcq-celebration { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; + z-index: 10; +} + +.mcq-celebration-item { + position: absolute; + width: 10px; + height: 10px; + background: var(--sf-primary); + border-radius: 2px; + animation: celebrationConfetti 3s ease-out forwards; +} + +/* Fix for letter indicators in MCQ UI */ +[class*=" or "], [class^="or "] { + background-color: var(--sf-bg-primary) !important; + color: var(--sf-text) !important; + border: 1px solid var(--background-modifier-border) !important; + padding: 2px 6px !important; + border-radius: 4px !important; +} + +/* Preserve legacy class function while improving appearance */ +.review-time-short, .review-time-medium, .review-time-long, +.sm2-info, .sm2-ratings-title, .sm2-ratings-desc, .sm2-ratings-table, .sm2-ratings-table th, .sm2-ratings-table td, +.prompt-setting, .prompt-textarea, .sf-settings-section, .sf-settings-section-header, .sf-settings-icon, +.sf-settings-section-header h3, .sf-settings-collapse-indicator, .sf-settings-section-content, .sf-settings-subsection, +.sf-setting-explain, .sf-setting-highlight, .sf-setting-grid, .sf-settings-actions, .sf-info-box, .sf-prompt-label, +.sf-system-prompts-container, .sf-danger-zone, .mcq-shortcut-hint, .hidden { + /* Preserve the classes for compatibility */ +} + +/* Style for the selected answer using keyboard navigation */ +.mcq-choice-selected { + border-color: var(--interactive-accent) !important; + box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.5), 0 1px 3px rgba(0, 0, 0, 0.1); + transform: translateX(4px); /* Consistent with hover */ +} + +/* Style for the button when a key is pressed */ +.mcq-key-pressed { + transform: scale(0.98); + opacity: 0.9; +} + +/* Dark theme specific overrides for text visibility - MORE SPECIFIC */ +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn { + color: var(--text-normal) !important; /* Attempt with !important if necessary */ +} +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn .mcq-choice-text { + color: var(--text-normal) !important; /* Attempt with !important if necessary */ +} +.theme-dark .spaceforge-mcq-modal .mcq-choice-btn .mcq-shortcut-hint { + color: var(--text-muted) !important; /* Attempt with !important if necessary */ + /* Consider a lighter background for the hint if still problematic */ + /* background-color: var(--background-modifier-hover) !important; */ +} + +/* Styles for Algorithm Details in Results */ +.mcq-result-algorithm-details { + margin-top: var(--sf-space-sm); + padding-top: var(--sf-space-sm); + border-top: 1px dashed var(--background-modifier-border); + font-size: 0.9em; +} +.mcq-result-algorithm-details .mcq-result-label { /* Re-uses general label styling if available, or define specific */ + font-weight: 600; + color: var(--text-normal); /* Ensure label is clearly visible */ + margin-right: var(--sf-space-xs); +} +.mcq-result-algorithm-value { + color: var(--text-muted); /* Or a more prominent color like var(--sf-primary) */ +} +.mcq-result-algorithm-item { /* For multiple lines of FSRS/SM-2 info */ + margin-bottom: var(--sf-space-xs); + display: flex; /* Align label and value nicely */ + align-items: baseline; +} + +.mcq-result-algorithm-details h4.mcq-result-label { /* Specific styling for the sub-header */ + margin-bottom: var(--sf-space-sm); + font-size: 1.1em; /* Slightly larger for a sub-header */ + color: var(--text-normal); +} + +/* ====== Detailed Question Breakdown Styles ====== */ +:root { + --mcq-correct-answer-text: var(--sf-success-dark, #276749); /* Darker green for text, fallback if sf-success-dark not defined */ +} + +.mcq-detailed-breakdown { + margin-top: var(--sf-space-xl); + padding-top: var(--sf-space-lg); + border-top: 1px solid var(--background-modifier-border); +} + +.mcq-detailed-breakdown h3 { + margin-bottom: var(--sf-space-lg); + font-size: 1.3em; + color: var(--text-normal); + font-weight: 600; +} + +.mcq-breakdown-item { + margin-bottom: var(--sf-space-lg); + padding: var(--sf-space-md); + background-color: var(--background-secondary); /* Use secondary background for items */ + border: 1px solid var(--background-modifier-border); + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow-sm); /* Softer shadow for individual items */ +} + +.mcq-breakdown-q-header { + font-weight: 600; + color: var(--text-muted); /* Muted color for "Q1 (from NoteX):" part */ +} + +.mcq-breakdown-item div { /* General styling for text lines within an item */ + margin-bottom: var(--sf-space-xs); + line-height: 1.5; +} + +.mcq-breakdown-item span { /* Ensure spans behave as expected */ + display: inline; /* Default span behavior */ +} + +/* Styling for "Your answer: " and "Correct answer: " labels */ +.mcq-breakdown-item div > span:first-child { + font-weight: 500; + color: var(--text-faint); /* Slightly fainter for the label part */ +} + +/* Specific styling for the user's answer text and correctness indicator */ +.mcq-breakdown-item .user-answer-text { /* Class to be added in JS if needed for more specific targeting */ + /* (color is set dynamically in JS) */ +} + +.mcq-breakdown-item .correctness-indicator { /* Class to be added in JS */ + font-weight: bold; + /* (color is set dynamically in JS) */ +} + +/* Styling for the correct answer text */ +.mcq-breakdown-item .correct-answer-text { /* Class to be added in JS */ + color: var(--mcq-correct-answer-text); + font-weight: bold; +} diff --git a/styles/pomodoro.css b/styles/pomodoro.css new file mode 100644 index 0000000..5f472f9 --- /dev/null +++ b/styles/pomodoro.css @@ -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 */ +} diff --git a/styles/responsive.css b/styles/responsive.css new file mode 100644 index 0000000..4950522 --- /dev/null +++ b/styles/responsive.css @@ -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; + } +} diff --git a/styles/review-buttons.css b/styles/review-buttons.css new file mode 100644 index 0000000..f17071f --- /dev/null +++ b/styles/review-buttons.css @@ -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); +} diff --git a/styles/settings.css b/styles/settings.css new file mode 100644 index 0000000..decf6da --- /dev/null +++ b/styles/settings.css @@ -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; +} diff --git a/styles/sidebar.css b/styles/sidebar.css new file mode 100644 index 0000000..e8c19f5 --- /dev/null +++ b/styles/sidebar.css @@ -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 */ +} diff --git a/styles/styles.css b/styles/styles.css new file mode 100644 index 0000000..24a2d77 --- /dev/null +++ b/styles/styles.css @@ -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'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..62ad08d --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/ui/batch-review-modal.ts b/ui/batch-review-modal.ts new file mode 100644 index 0000000..9e84bba --- /dev/null +++ b/ui/batch-review-modal.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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`); + } + } +} diff --git a/ui/calendar-view.ts b/ui/calendar-view.ts new file mode 100644 index 0000000..5456bb2 --- /dev/null +++ b/ui/calendar-view.ts @@ -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 = 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 { + 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 { + 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 + ); + } +} diff --git a/ui/consolidated-mcq-modal.ts b/ui/consolidated-mcq-modal.ts new file mode 100644 index 0000000..f45c70c --- /dev/null +++ b/ui/consolidated-mcq-modal.ts @@ -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 = {}; + + // 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 = {}; + 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(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; + } +} diff --git a/ui/context-menu.ts b/ui/context-menu.ts new file mode 100644 index 0000000..98dcad5 --- /dev/null +++ b/ui/context-menu.ts @@ -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 { + 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(); // 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 { + 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 + } + } +} diff --git a/ui/mcq-modal.ts b/ui/mcq-modal.ts new file mode 100644 index 0000000..646f289 --- /dev/null +++ b/ui/mcq-modal.ts @@ -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"; + } +} diff --git a/ui/review-modal.ts b/ui/review-modal.ts new file mode 100644 index 0000000..c646f59 --- /dev/null +++ b/ui/review-modal.ts @@ -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); + } +} diff --git a/ui/settings-tab.ts b/ui/settings-tab.ts new file mode 100644 index 0000000..9c8a05b --- /dev/null +++ b/ui/settings-tab.ts @@ -0,0 +1,1197 @@ +import { App, Notice, PluginSettingTab, Setting, setIcon, TextAreaComponent } from 'obsidian'; // Added TextAreaComponent +import SpaceforgePlugin from '../main'; +// Import ApiProvider, DEFAULT_SETTINGS, SpaceforgeSettings, and MCQQuestionAmountMode +import { ApiProvider, DEFAULT_SETTINGS, SpaceforgeSettings, MCQQuestionAmountMode } from '../models/settings'; +import { SpaceforgePluginData, DEFAULT_APP_DATA, DEFAULT_PLUGIN_STATE_DATA } from '../models/plugin-data'; // Import data structures + +/** + * Settings tab for the plugin + */ +export class SpaceforgeSettingTab extends PluginSettingTab { + /** + * Reference to the main plugin + */ + plugin: SpaceforgePlugin; + + /** + * Initialize settings tab + * + * @param app Obsidian app + * @param plugin Reference to the main plugin + */ + constructor(app: App, plugin: SpaceforgePlugin) { + super(app, plugin); + this.plugin = plugin; + } + + /** + * Display settings + */ + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Spaceforge Settings' }); + + // Add a utility for creating collapsible sections + const createCollapsible = (title: string, iconName: string, defaultOpen = true) => { + // Create section container + const sectionContainer = containerEl.createEl('div', { cls: 'sf-settings-section' }); + + // Create header + const header = sectionContainer.createEl('div', { cls: 'sf-settings-section-header' }); + + // Add icon + if (iconName) { + const iconEl = header.createEl('span', { cls: 'sf-settings-icon' }); + setIcon(iconEl, iconName); + } + + // Add title and collapse indicator + header.createEl('h3', { text: title }); + const collapseIndicator = header.createEl('span', { + cls: 'sf-settings-collapse-indicator', + text: defaultOpen ? '▾' : '▸' + }); + + // Create content container + const contentContainer = sectionContainer.createEl('div', { + cls: 'sf-settings-section-content' + }); + + // Initially hide if not defaultOpen + if (!defaultOpen) { + contentContainer.style.display = 'none'; + } + + // Add click handler for toggling + header.addEventListener('click', () => { + const isVisible = contentContainer.style.display !== 'none'; + contentContainer.style.display = isVisible ? 'none' : 'block'; + collapseIndicator.textContent = isVisible ? '▸' : '▾'; + }); + + return contentContainer; + }; + + // We'll rely on the CSS in the styles.css file instead of inline styles + + // Create action buttons for global data operations + const createActionButtons = () => { + const actionsContainer = containerEl.createEl('div', { cls: 'sf-settings-actions' }); + + // Export all data button + const exportBtn = actionsContainer.createEl('button', { text: 'Export All Data' }); + exportBtn.addEventListener('click', () => { + // Construct the full plugin data for export + const pluginStateToExport = { + schedules: this.plugin.reviewScheduleService.schedules, + history: this.plugin.reviewHistoryService.history, + reviewSessions: this.plugin.reviewSessionService.reviewSessions, + mcqSets: this.plugin.mcqService.mcqSets, + mcqSessions: this.plugin.mcqService.mcqSessions, + customNoteOrder: this.plugin.reviewScheduleService.customNoteOrder, + lastLinkAnalysisTimestamp: this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp, + version: this.plugin.manifest.version, + pomodoroCurrentMode: this.plugin.pluginState.pomodoroCurrentMode, + pomodoroTimeLeftInSeconds: this.plugin.pluginState.pomodoroTimeLeftInSeconds, + pomodoroSessionsCompletedInCycle: this.plugin.pluginState.pomodoroSessionsCompletedInCycle, + pomodoroIsRunning: this.plugin.pluginState.pomodoroIsRunning, + pomodoroEndTimeMs: this.plugin.pluginState.pomodoroEndTimeMs ?? null, // Add the missing field + }; + + const dataToExport: SpaceforgePluginData = { + settings: this.plugin.settings, + pluginState: pluginStateToExport + }; + const dataJson = JSON.stringify(dataToExport, null, 2); + const blob = new Blob([dataJson], { type: 'application/json' }); + + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'spaceforge-data.json'; // Changed filename + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + new Notice('All plugin data exported successfully'); + }); + + // Import all data button + const importBtn = actionsContainer.createEl('button', { text: 'Import All Data' }); + importBtn.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + try { + const text = await file.text(); + const importedFullData = JSON.parse(text) as SpaceforgePluginData; + + if (!importedFullData || typeof importedFullData.settings !== 'object' || typeof importedFullData.pluginState !== 'object') { + throw new Error('Invalid data file format. Expected settings and pluginState properties.'); + } + + // Apply imported settings, ensuring all defaults are present + this.plugin.settings = { ...DEFAULT_SETTINGS, ...importedFullData.settings }; + + // Apply imported plugin state, ensuring all defaults are present + this.plugin.pluginState = { ...DEFAULT_PLUGIN_STATE_DATA, ...importedFullData.pluginState }; + + // Repopulate services from the newly loaded pluginState + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules || {}; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history || []; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions || { sessions: {}, activeSessionId: null }; + this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets || {}; + this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions || {}; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder || []; + this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = typeof this.plugin.pluginState.lastLinkAnalysisTimestamp === 'number' ? this.plugin.pluginState.lastLinkAnalysisTimestamp : null; + + // Pomodoro state update + this.plugin.pomodoroService?.onSettingsChanged(); // To re-evaluate durations if they changed from settings + this.plugin.pomodoroService?.reinitializeTimerFromState(); // To correctly start/stop timer based on imported state + + + // Re-initialize MCQ components if settings related to them changed + this.plugin.initializeMCQComponents(); + + await this.plugin.savePluginData(); // Save the fully imported data + + this.display(); // Refresh settings display + + new Notice('All plugin data imported successfully. Plugin may require a reload for all changes to take effect.'); + } catch (error) { + console.error('Failed to import data:', error); + new Notice(`Failed to import data: ${error.message}`); + } + } + }; + input.click(); + }); + + // Reset to defaults button + const resetBtn = actionsContainer.createEl('button', { text: 'Reset to Defaults' }); + resetBtn.addEventListener('click', async () => { + const confirmed = confirm('Are you sure you want to reset all plugin data (settings and state) to defaults? This cannot be undone.'); + + if (confirmed) { + // Apply default settings and default plugin state + this.plugin.settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + this.plugin.pluginState = JSON.parse(JSON.stringify(DEFAULT_PLUGIN_STATE_DATA)); + + // Repopulate services with defaults + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions; + this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets; + this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder; + this.plugin.reviewScheduleService.lastLinkAnalysisTimestamp = this.plugin.pluginState.lastLinkAnalysisTimestamp ?? null; + + this.plugin.pomodoroService?.onSettingsChanged(); // Reset pomodoro service state based on default settings + this.plugin.pomodoroService?.reinitializeTimerFromState(); // Reset pomodoro service timer based on default state + + + // Re-initialize MCQ components based on default settings + this.plugin.initializeMCQComponents(); + + await this.plugin.savePluginData(); // Save the complete default data + + this.display(); // Refresh settings display + + new Notice('All plugin data reset to defaults.'); + } + }); + + // Clear Schedule Data button + const clearScheduleBtn = actionsContainer.createEl('button', { + text: 'Clear All Schedule Data', + cls: 'sf-button-danger' // Optional: Add a class for dangerous actions + }); + clearScheduleBtn.addEventListener('click', async () => { + const confirmed = confirm('Are you sure you want to clear all review schedules, history, and session data? This action cannot be undone.'); + if (confirmed) { + try { + // Reset schedule-related parts of pluginState + this.plugin.pluginState.schedules = { ...DEFAULT_PLUGIN_STATE_DATA.schedules }; + this.plugin.pluginState.history = [...DEFAULT_PLUGIN_STATE_DATA.history]; + this.plugin.pluginState.reviewSessions = { ...DEFAULT_PLUGIN_STATE_DATA.reviewSessions }; + this.plugin.pluginState.customNoteOrder = [...DEFAULT_PLUGIN_STATE_DATA.customNoteOrder]; + // Potentially mcqSets and mcqSessions if they are tied to notes that might be unscheduled + // For now, focusing on explicit schedule data as requested. + + // Update services + this.plugin.reviewScheduleService.schedules = this.plugin.pluginState.schedules; + this.plugin.reviewHistoryService.history = this.plugin.pluginState.history; + this.plugin.reviewSessionService.reviewSessions = this.plugin.pluginState.reviewSessions; + this.plugin.reviewScheduleService.customNoteOrder = this.plugin.pluginState.customNoteOrder; + + // Explicitly update the review controller's state + if (this.plugin.reviewController) { + await this.plugin.reviewController.updateTodayNotes(); + } + + // Emit an event that the sidebar might be listening to + if (this.plugin.events) { + this.plugin.events.emit('sidebar-update'); + } + + // If mcq data should also be cleared when schedules are cleared: + // this.plugin.pluginState.mcqSets = { ...DEFAULT_PLUGIN_STATE_DATA.mcqSets }; + // this.plugin.pluginState.mcqSessions = { ...DEFAULT_PLUGIN_STATE_DATA.mcqSessions }; + // this.plugin.mcqService.mcqSets = this.plugin.pluginState.mcqSets; + // this.plugin.mcqService.mcqSessions = this.plugin.pluginState.mcqSessions; + + // Save the cleared data first + await this.plugin.savePluginData(); + + new Notice('All schedule data cleared successfully.'); + + // Then refresh UI elements + this.display(); // Refresh settings UI + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + + } catch (error) { + console.error('Failed to clear schedule data:', error); + new Notice('Failed to clear schedule data. Check console for details.'); + } + } + }); + + return actionsContainer; + }; + + // ========= SPACED REPETITION SECTION ========= + const spacedRepSection = createCollapsible('Spaced Repetition', 'calendar-clock', true); + + // --- Algorithm Selection --- + // Changed to h3 and removed sf-settings-subsection class for potentially better contrast + spacedRepSection.createEl('h3', { text: 'Algorithm Configuration' }); + + const algoSelectionSetting = new Setting(spacedRepSection) + .setName('Default scheduling algorithm') + .setDesc('Choose the default algorithm for newly created notes.') + .addDropdown(dropdown => dropdown + .addOption('sm2', 'SM-2') + .addOption('fsrs', 'FSRS') + .setValue(this.plugin.settings.defaultSchedulingAlgorithm) + .onChange(async (value: 'sm2' | 'fsrs') => { + this.plugin.settings.defaultSchedulingAlgorithm = value; + await this.plugin.savePluginData(); + this.display(); // Re-render to update the "About" section and parameter visibility + })); + + // --- Dynamic "About Algorithm" Section --- + const aboutAlgoContainer = spacedRepSection.createEl('div', { cls: 'sf-info-box sf-algo-about-box' }); + this.renderAboutAlgorithmSection(aboutAlgoContainer, this.plugin.settings.defaultSchedulingAlgorithm); + + // --- SM-2 Parameters --- + const sm2ParamsContainer = spacedRepSection.createEl('details', { cls: 'sf-settings-collapsible-subsection' }); + const sm2Summary = sm2ParamsContainer.createEl('summary'); + // Changed to h3 + sm2Summary.createEl('h3', { text: 'SM-2 Parameters' }); + sm2ParamsContainer.open = false; // Initially closed + + + new Setting(sm2ParamsContainer) + .setName('SM-2: Base ease factor') + .setDesc('Initial ease factor for new SM-2 notes (2.5 is SM-2 default). Higher ease increases interval growth. Value shown is internal format (250 = 2.5).') + .addSlider(slider => slider + .setLimits(130, 500, 10) + .setValue(this.plugin.settings.baseEase) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.baseEase = value; + await this.plugin.savePluginData(); + })); + + new Setting(sm2ParamsContainer) + .setName('SM-2: Use initial learning schedule') + .setDesc('For new SM-2 notes, use a fixed set of initial intervals before applying the full algorithm.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.useInitialSchedule) + .onChange(async (value: boolean) => { + this.plugin.settings.useInitialSchedule = value; + await this.plugin.savePluginData(); + this.display(); + })); + + if (this.plugin.settings.useInitialSchedule) { + new Setting(sm2ParamsContainer) + .setName('SM-2: Custom initial intervals (days)') + .setDesc('Comma-separated list for initial SM-2 reviews (e.g., 0,1,3,7). Must start with 0.') + .addText(text => text + .setValue(this.plugin.settings.initialScheduleCustomIntervals.join(', ')) + .onChange(async (value: string) => { + const intervals = value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n >= 0); + if (intervals.length > 0 && intervals[0] === 0) { + this.plugin.settings.initialScheduleCustomIntervals = intervals; + await this.plugin.savePluginData(); + } else { + new Notice("Custom initial SM-2 intervals must start with 0 and be valid numbers.", 5000); + text.setValue(this.plugin.settings.initialScheduleCustomIntervals.join(', ')); + } + })); + } + + new Setting(sm2ParamsContainer) + .setName('SM-2: Maximum interval (days)') + .setDesc('Longest possible interval between SM-2 reviews.') + .addText(text => text + .setValue(this.plugin.settings.maximumInterval.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.maximumInterval = numValue; + await this.plugin.savePluginData(); + } + })); + + new Setting(sm2ParamsContainer) + .setName('SM-2: Load balancing') + .setDesc('Add slight randomness to SM-2 intervals to prevent reviews clumping on the same day.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.loadBalance) + .onChange(async (value: boolean) => { + this.plugin.settings.loadBalance = value; + await this.plugin.savePluginData(); + })); + + // --- FSRS Parameters --- + const fsrsParamsContainer = spacedRepSection.createEl('details', { cls: 'sf-settings-collapsible-subsection' }); + const fsrsSummary = fsrsParamsContainer.createEl('summary'); + // Changed to h3 + fsrsSummary.createEl('h3', { text: 'FSRS Parameters' }); + fsrsParamsContainer.open = false; // Initially closed + + new Setting(fsrsParamsContainer) + .setName('Request Retention') + .setDesc('Desired recall probability (0.7-0.99, default: 0.9). Higher values mean more frequent reviews.') + .addText(text => text + .setValue(this.plugin.settings.fsrsParameters?.request_retention?.toString() ?? DEFAULT_SETTINGS.fsrsParameters.request_retention!.toString()) + .onChange(async (value) => { + const numValue = parseFloat(value); + if (!isNaN(numValue) && numValue >= 0.7 && numValue <= 0.99) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, request_retention: numValue }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new Notice("FSRS Request Retention must be between 0.7 and 0.99."); + } + })); + + new Setting(fsrsParamsContainer) + .setName('Maximum Interval (days)') + .setDesc('Longest possible interval FSRS will schedule.') + .addText(text => text + .setValue(this.plugin.settings.fsrsParameters?.maximum_interval?.toString() ?? DEFAULT_SETTINGS.fsrsParameters.maximum_interval!.toString()) + .onChange(async (value) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, maximum_interval: numValue }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new Notice("FSRS Maximum Interval must be a positive number."); + } + })); + + new Setting(fsrsParamsContainer) + .setName('Learning Steps (minutes)') + .setDesc('Comma-separated initial learning intervals in minutes (e.g., 1,10 for 1m, 10m).') + .addText(text => text + .setValue(this.plugin.settings.fsrsParameters?.learning_steps?.join(',') ?? DEFAULT_SETTINGS.fsrsParameters.learning_steps!.join(',')) + .onChange(async (value) => { + const steps = value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0); + if (steps.length > 0) { // Allow empty to use FSRS internal defaults if any + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: steps }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else if (value.trim() === "") { // Allow clearing to use FSRS defaults + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, learning_steps: [] }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + }else { + new Notice("FSRS Learning Steps must be valid comma-separated numbers > 0, or empty."); + } + })); + + new Setting(fsrsParamsContainer) + .setName('Enable Fuzz') + .setDesc('Add slight randomness to FSRS intervals (recommended).') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.fsrsParameters?.enable_fuzz ?? DEFAULT_SETTINGS.fsrsParameters.enable_fuzz!) + .onChange(async (value) => { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_fuzz: value }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + })); + + new Setting(fsrsParamsContainer) + .setName('Enable Short Term Scheduling') + .setDesc('Use FSRS short-term memory model (affects initial learning steps).') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.fsrsParameters?.enable_short_term ?? DEFAULT_SETTINGS.fsrsParameters.enable_short_term!) + .onChange(async (value) => { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, enable_short_term: value }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + })); + + new Setting(fsrsParamsContainer) + .setName('Weights (W)') + .setDesc('FSRS algorithm parameters (17 numbers). Edit with caution. Default weights are generally good.') + .addTextArea(text => { + text.inputEl.rows = 3; + text.inputEl.style.width = "100%"; // Ensure it takes full width + text.setValue(this.plugin.settings.fsrsParameters?.w?.join(',') ?? DEFAULT_SETTINGS.fsrsParameters.w!.join(',')) + .onChange(async (value) => { + const weights = value.split(',').map(s => parseFloat(s.trim())); + if (weights.length === 17 && weights.every(n => !isNaN(n))) { + this.plugin.settings.fsrsParameters = { ...this.plugin.settings.fsrsParameters, w: weights }; + await this.plugin.savePluginData(); + this.plugin.reviewScheduleService.updateAlgorithmServicesForSettingsChange(); + } else { + new Notice("FSRS Weights must be a comma-separated list of 17 valid numbers."); + } + }); + }); + + // --- Card Conversion Utilities --- + const conversionContainer = spacedRepSection.createEl('details', { cls: 'sf-settings-collapsible-subsection' }); + const conversionSummary = conversionContainer.createEl('summary'); + // Changed to h3 + conversionSummary.createEl('h3', { text: 'Card Conversion Utilities' }); + conversionContainer.open = false; // Initially closed + + new Setting(conversionContainer) + .setName('Convert all SM-2 cards to FSRS') + .setDesc('Migrate all existing SM-2 cards to use the FSRS algorithm. This will reset their learning state for FSRS.') + .addButton(button => button + .setButtonText('Convert SM-2 to FSRS') + .setCta() // Call to action style + .onClick(async () => { + const confirmed = confirm('Are you sure you want to convert ALL SM-2 cards to FSRS? Their FSRS learning state will be reset. This action cannot be easily undone.'); + if (confirmed) { + new Notice('Converting SM-2 cards to FSRS... This may take a moment.'); + await this.plugin.reviewScheduleService.convertAllSm2ToFsrs(); + await this.plugin.savePluginData(); + new Notice('All SM-2 cards have been converted to FSRS.'); + this.display(); // Refresh settings tab + } + })); + + new Setting(conversionContainer) + .setName('Convert all FSRS cards to SM-2') + .setDesc('Migrate all existing FSRS cards to use the SM-2 algorithm. Their SM-2 learning state will be initialized with defaults.') + .addButton(button => button + .setButtonText('Convert FSRS to SM-2') + .setCta() + .onClick(async () => { + const confirmed = confirm('Are you sure you want to convert ALL FSRS cards to SM-2? Their SM-2 learning state will be reset to defaults. This action cannot be easily undone.'); + if (confirmed) { + new Notice('Converting FSRS cards to SM-2... This may take a moment.'); + await this.plugin.reviewScheduleService.convertAllFsrsToSm2(); + await this.plugin.savePluginData(); + new Notice('All FSRS cards have been converted to SM-2.'); + this.display(); // Refresh settings tab + } + })); + + // ========= INTERFACE SECTION ========= + const interfaceSection = createCollapsible('Interface & Behavior', 'settings', false); // Closed by default + + new Setting(interfaceSection) + .setName("Display Settings") + .setHeading() + .setClass("sf-settings-subsection-header"); // Add a class for potential specific styling + + new Setting(interfaceSection) + .setName('Default view type') + .setDesc('Choose between list or calendar for the review sidebar') + .addDropdown(dropdown => dropdown + .addOption('list', 'List view') + .addOption('calendar', 'Calendar view') + .setValue(this.plugin.settings.sidebarViewType) + .onChange(async (value: 'list' | 'calendar') => { + this.plugin.settings.sidebarViewType = value; + await this.plugin.savePluginData(); + if (this.plugin.sidebarView) { + this.plugin.sidebarView.refresh(); + } + })); + + new Setting(interfaceSection) + .setName('Show navigation notifications') + .setDesc('Display notifications when moving between notes') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showNavigationNotifications) + .onChange(async (value: boolean) => { + this.plugin.settings.showNavigationNotifications = value; + await this.plugin.savePluginData(); + })); + + new Setting(interfaceSection) + .setName("Review Behavior") + .setHeading() + .setClass("sf-settings-subsection-header"); + + new Setting(interfaceSection) + .setName('Include subfolders') + .setDesc('When adding a folder to review, include all notes in subfolders') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.includeSubfolders) + .onChange(async (value: boolean) => { + this.plugin.settings.includeSubfolders = value; + await this.plugin.savePluginData(); + })); + + new Setting(interfaceSection) + .setName('Notification time') + .setDesc('Minutes before due time to notify about upcoming reviews (0 to disable)') + .addText(text => text + .setValue(this.plugin.settings.notifyBeforeDue.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue >= 0) { + this.plugin.settings.notifyBeforeDue = numValue; + await this.plugin.savePluginData(); + } + })); + + new Setting(interfaceSection) + .setName('Reading speed (WPM)') + .setDesc('Words per minute for estimating review time') + .addSlider(slider => slider + .setLimits(100, 500, 10) + .setValue(this.plugin.settings.readingSpeed) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.readingSpeed = value; + await this.plugin.savePluginData(); + })); + + interfaceSection.createEl('div', { cls: 'sf-setting-explain', + text: 'Average adults read 200-250 WPM for regular content, 100-150 WPM for technical content' + }); + + // ========= MCQ SECTION ========= + const mcqSection = createCollapsible('Multiple Choice Questions', 'newspaper', false); // Closed by default + + new Setting(mcqSection) + .setName('Enable MCQ feature') + .setDesc('Use AI-generated multiple-choice questions to test your knowledge') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableMCQ) + .onChange(async (value: boolean) => { + this.plugin.settings.enableMCQ = value; + await this.plugin.savePluginData(); + + // Reinitialize MCQ components + this.plugin.initializeMCQComponents(); + + // Update the API settings backup when enabling/disabling MCQ + try { + const apiSettings = { + openRouterApiKey: this.plugin.settings.openRouterApiKey, + enableMCQ: value, + openRouterModel: this.plugin.settings.openRouterModel + }; + window.localStorage.setItem('spaceforge-api-settings', JSON.stringify(apiSettings)); + console.log("Updated API settings backup with MCQ state"); + } catch (e) { + console.error("Error updating API settings backup:", e); + } + + // Refresh the MCQ settings visibility + this.display(); + })); + + // Only show other MCQ settings if the feature is enabled + if (this.plugin.settings.enableMCQ) { + new Setting(mcqSection) + .setName("API Configuration") + .setHeading() + .setClass("sf-settings-subsection-header"); + + // API Provider Dropdown + new Setting(mcqSection) + .setName('API Provider') + .setDesc('Select the API provider for generating MCQs.') + .addDropdown(dropdown => { + dropdown + .addOption(ApiProvider.OpenRouter, 'OpenRouter') + .addOption(ApiProvider.OpenAI, 'OpenAI') + .addOption(ApiProvider.Ollama, 'Ollama') + .addOption(ApiProvider.Gemini, 'Gemini') + .addOption(ApiProvider.Claude, 'Claude') + .addOption(ApiProvider.Together, 'Together AI') + .setValue(this.plugin.settings.mcqApiProvider) + .onChange(async (value: ApiProvider) => { + this.plugin.settings.mcqApiProvider = value; + await this.plugin.savePluginData(); + this.plugin.initializeMCQComponents(); // Re-initialize service + this.display(); // Refresh settings tab to show/hide relevant fields + }); + }); + + // Conditional Settings based on Provider + const provider = this.plugin.settings.mcqApiProvider; + + if (provider === ApiProvider.OpenRouter) { + new Setting(mcqSection) + .setName("OpenRouter Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + const apiKeyContainer = mcqSection.createEl('div', { cls: 'sf-setting-highlight' }); + new Setting(apiKeyContainer) + .setName('OpenRouter API Key') + .setDesc('Required for generating MCQs via OpenRouter.') + .addText(text => text + .setPlaceholder('Enter your OpenRouter API key') + .setValue(this.plugin.settings.openRouterApiKey) + .onChange(async (value: string) => { + this.plugin.settings.openRouterApiKey = value; + await this.plugin.savePluginData(); + })); + // Removed sf-setting-explain class + apiKeyContainer.createEl('div', { text: 'Get your API key at https://openrouter.ai/keys' }); + + new Setting(mcqSection) + .setName('OpenRouter Model') + .setDesc('Model identifier from OpenRouter (e.g., openai/gpt-4.1-mini)') + .addText(text => text + .setPlaceholder('Enter OpenRouter model identifier') + .setValue(this.plugin.settings.openRouterModel) + .onChange(async (value: string) => { + this.plugin.settings.openRouterModel = value; + await this.plugin.savePluginData(); + // Removed specific localStorage backup here + })); + } else if (provider === ApiProvider.OpenAI) { + new Setting(mcqSection) + .setName("OpenAI Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('OpenAI API Key') + .setDesc('Your OpenAI API key.') + .addText(text => text + .setPlaceholder('Enter your OpenAI API key (sk-...)') + .setValue(this.plugin.settings.openaiApiKey) + .onChange(async (value: string) => { + this.plugin.settings.openaiApiKey = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('OpenAI Model') + .setDesc('Model name (e.g., gpt-3.5-turbo, gpt-4)') + .addText(text => text + .setPlaceholder('Enter OpenAI model name') + .setValue(this.plugin.settings.openaiModel) + .onChange(async (value: string) => { + this.plugin.settings.openaiModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === ApiProvider.Ollama) { + new Setting(mcqSection) + .setName("Ollama Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('Ollama API URL') + .setDesc('URL of your running Ollama instance (e.g., http://localhost:11434)') + .addText(text => text + .setPlaceholder('http://localhost:11434') + .setValue(this.plugin.settings.ollamaApiUrl) + .onChange(async (value: string) => { + this.plugin.settings.ollamaApiUrl = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('Ollama Model') + .setDesc('Name of the Ollama model to use (e.g., llama3, mistral)') + .addText(text => text + .setPlaceholder('Enter Ollama model name') + .setValue(this.plugin.settings.ollamaModel) + .onChange(async (value: string) => { + this.plugin.settings.ollamaModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === ApiProvider.Gemini) { + new Setting(mcqSection) + .setName("Gemini Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('Gemini API Key') + .setDesc('Your Google AI Gemini API key.') + .addText(text => text + .setPlaceholder('Enter your Gemini API key') + .setValue(this.plugin.settings.geminiApiKey) + .onChange(async (value: string) => { + this.plugin.settings.geminiApiKey = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('Gemini Model') + .setDesc('Model name (e.g., gemini-pro)') + .addText(text => text + .setPlaceholder('Enter Gemini model name') + .setValue(this.plugin.settings.geminiModel) + .onChange(async (value: string) => { + this.plugin.settings.geminiModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === ApiProvider.Claude) { + new Setting(mcqSection) + .setName("Claude Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('Claude API Key') + .setDesc('Your Anthropic Claude API key.') + .addText(text => text + .setPlaceholder('Enter your Claude API key') + .setValue(this.plugin.settings.claudeApiKey) + .onChange(async (value: string) => { + this.plugin.settings.claudeApiKey = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('Claude Model') + .setDesc('Model name (e.g., claude-3-opus-20240229, claude-3-sonnet-20240229)') + .addText(text => text + .setPlaceholder('Enter Claude model name') + .setValue(this.plugin.settings.claudeModel) + .onChange(async (value: string) => { + this.plugin.settings.claudeModel = value; + await this.plugin.savePluginData(); + })); + } else if (provider === ApiProvider.Together) { + new Setting(mcqSection) + .setName("Together AI Configuration") + .setHeading() + .setClass("sf-settings-subsection-provider-header"); + new Setting(mcqSection) + .setName('Together AI API Key') + .setDesc('Your Together AI API key.') + .addText(text => text + .setPlaceholder('Enter your Together AI API key') + .setValue(this.plugin.settings.togetherApiKey) + .onChange(async (value: string) => { + this.plugin.settings.togetherApiKey = value; + await this.plugin.savePluginData(); + })); + new Setting(mcqSection) + .setName('Together AI Model') + .setDesc('Model identifier from Together AI (e.g., meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8)') + .addText(text => text + .setPlaceholder('Enter Together AI model identifier') + .setValue(this.plugin.settings.togetherModel) + .onChange(async (value: string) => { + this.plugin.settings.togetherModel = value; + await this.plugin.savePluginData(); + })); + } + + // Question generation settings (common to all providers) + new Setting(mcqSection) + .setName("Question Generation (Common)") + .setHeading() + .setClass("sf-settings-subsection-header"); + + // Setting for Question Amount Mode + new Setting(mcqSection) + .setName('Question amount mode') + .setDesc('How to determine the number of questions per note.') + .addDropdown(dropdown => dropdown + .addOption(MCQQuestionAmountMode.Fixed, 'Fixed Number') + .addOption(MCQQuestionAmountMode.WordsPerQuestion, 'Per X Words') + .setValue(this.plugin.settings.mcqQuestionAmountMode) + .onChange(async (value: MCQQuestionAmountMode) => { + this.plugin.settings.mcqQuestionAmountMode = value; + await this.plugin.savePluginData(); + this.display(); // Re-render the settings tab to show/hide relevant fields + })); + + // Conditionally display settings based on the mode + if (this.plugin.settings.mcqQuestionAmountMode === MCQQuestionAmountMode.Fixed) { + new Setting(mcqSection) + .setName('Questions per note (Fixed)') + .setDesc('Number of questions to generate for each note.') + .addSlider(slider => slider + .setLimits(1, 10, 1) + .setValue(this.plugin.settings.mcqQuestionsPerNote) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.mcqQuestionsPerNote = value; + await this.plugin.savePluginData(); + })); + } else if (this.plugin.settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) { + new Setting(mcqSection) + .setName('Words per question target') + .setDesc('Generate approximately 1 question for every X words in the note.') + .addText(text => text + .setPlaceholder('100') + .setValue(this.plugin.settings.mcqWordsPerQuestion.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.mcqWordsPerQuestion = numValue; + await this.plugin.savePluginData(); + } else { + new Notice("Words per question must be a positive number."); + // Optionally reset to previous value or default + text.setValue(this.plugin.settings.mcqWordsPerQuestion.toString()); + } + })); + } + + // This setting is now conditional above + // new Setting(mcqSection) + // .setName('Questions per note') + // .setDesc('Number of questions to generate for each note') + // .addSlider(slider => slider + // .setLimits(1, 10, 1) + // .setValue(this.plugin.settings.mcqQuestionsPerNote) + // .setDynamicTooltip() + // .onChange(async (value: number) => { + // this.plugin.settings.mcqQuestionsPerNote = value; + // await this.plugin.savePluginData(); + // })); + + new Setting(mcqSection) + .setName('Choices per question') + .setDesc('Number of answer choices for each question') + .addSlider(slider => slider + .setLimits(2, 6, 1) + .setValue(this.plugin.settings.mcqChoicesPerQuestion) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.mcqChoicesPerQuestion = value; + await this.plugin.savePluginData(); + })); + + // Use CSS grid for the two dropdowns side by side + const mcqFormattingGrid = mcqSection.createEl('div', { cls: 'sf-setting-grid' }); + + // First item in grid + const promptTypeContainer = mcqFormattingGrid.createEl('div'); + new Setting(promptTypeContainer) + .setName('Prompt type') + .setDesc('Format for MCQ generation') + .addDropdown(dropdown => dropdown + .addOption('basic', 'Basic') + .addOption('detailed', 'Detailed') + .setValue(this.plugin.settings.mcqPromptType) + .onChange(async (value: 'basic' | 'detailed') => { + this.plugin.settings.mcqPromptType = value; + await this.plugin.savePluginData(); + })); + + // Second item in grid + const difficultyContainer = mcqFormattingGrid.createEl('div'); + new Setting(difficultyContainer) + .setName('MCQ difficulty') + .setDesc('Complexity level') + .addDropdown(dropdown => dropdown + .addOption('basic', 'Basic recall') + .addOption('advanced', 'Advanced understanding') + .setValue(this.plugin.settings.mcqDifficulty) + .onChange(async (value: string) => { + this.plugin.settings.mcqDifficulty = value as any; + await this.plugin.savePluginData(); + })); + + // Scoring settings + new Setting(mcqSection) + .setName("Scoring Settings") + .setHeading() + .setClass("sf-settings-subsection-header"); + + new Setting(mcqSection) + .setName('Time deduction amount') + .setDesc('Score penalty for slow answers (0-1)') + .addSlider(slider => slider + .setLimits(0, 1, 0.1) + .setValue(this.plugin.settings.mcqTimeDeductionAmount) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.mcqTimeDeductionAmount = value; + await this.plugin.savePluginData(); + })); + + new Setting(mcqSection) + .setName('Time deduction threshold') + .setDesc('Apply penalty after this many seconds') + .addSlider(slider => slider + .setLimits(10, 120, 5) + .setValue(this.plugin.settings.mcqTimeDeductionSeconds) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.mcqTimeDeductionSeconds = value; + await this.plugin.savePluginData(); + })); + + // Add collapsible section for system prompts + const systemPromptsContainer = mcqSection.createEl('details', { cls: 'sf-system-prompts-container' }); + systemPromptsContainer.createEl('summary', { text: 'System Prompts (Advanced)', cls: 'sf-settings-subsection' }); + + // Basic prompt textarea + systemPromptsContainer.createEl('div', { text: 'Basic Difficulty Prompt', cls: 'sf-prompt-label' }); + const basicTextarea = systemPromptsContainer.createEl('textarea', { + attr: { + placeholder: 'Enter system prompt for basic difficulty', + rows: '6' + }, + cls: 'prompt-textarea' + }); + + // Set value and add change handler + basicTextarea.value = this.plugin.settings.mcqBasicSystemPrompt; + basicTextarea.addEventListener('change', async () => { + this.plugin.settings.mcqBasicSystemPrompt = basicTextarea.value; + await this.plugin.savePluginData(); + }); + + // Advanced prompt textarea + systemPromptsContainer.createEl('div', { text: 'Advanced Difficulty Prompt', cls: 'sf-prompt-label' }); + const advancedTextarea = systemPromptsContainer.createEl('textarea', { + attr: { + placeholder: 'Enter system prompt for advanced difficulty', + rows: '6' + }, + cls: 'prompt-textarea' + }); + + // Set value and add change handler + advancedTextarea.value = this.plugin.settings.mcqAdvancedSystemPrompt; + advancedTextarea.addEventListener('change', async () => { + this.plugin.settings.mcqAdvancedSystemPrompt = advancedTextarea.value; + await this.plugin.savePluginData(); + }); + + // Advanced Question Behavior subsection + new Setting(mcqSection) + .setName("Advanced Question Behavior") + .setHeading() + .setClass("sf-settings-subsection-header"); + + const regenerationSetting = new Setting(mcqSection) + .setName('Enable question regeneration on rating') + .setDesc('Automatically regenerate questions for a note if its review rating meets or exceeds a specified value.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableQuestionRegenerationOnRating) + .onChange(async (value: boolean) => { + this.plugin.settings.enableQuestionRegenerationOnRating = value; + await this.plugin.savePluginData(); + // Refresh display to show/hide dependent setting + this.display(); + })); + + if (this.plugin.settings.enableQuestionRegenerationOnRating) { + new Setting(mcqSection) + .setName('Min SM-2 rating for MCQ regeneration') + .setDesc('For SM-2: Regenerate MCQs if review rating (0-5) is this value or higher. (0:Blackout, 5:Perfect)') + .addSlider(slider => slider + .setLimits(0, 5, 1) + .setValue(this.plugin.settings.minSm2RatingForQuestionRegeneration) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.minSm2RatingForQuestionRegeneration = value; + await this.plugin.savePluginData(); + })); + + new Setting(mcqSection) + .setName('Min FSRS rating for MCQ regeneration') + .setDesc('For FSRS: Regenerate MCQs if review rating (1-4) is this value or higher. (1:Again, 4:Easy)') + .addSlider(slider => slider + .setLimits(1, 4, 1) // FSRS ratings are 1-4 + .setValue(this.plugin.settings.minFsrsRatingForQuestionRegeneration) + .setDynamicTooltip() + .onChange(async (value: number) => { + this.plugin.settings.minFsrsRatingForQuestionRegeneration = value; + await this.plugin.savePluginData(); + })); + } + + } else { + // If MCQ is disabled, show a message about enabling it + const mcqDisabledMessage = mcqSection.createEl('div', { cls: 'sf-info-box' }); + mcqDisabledMessage.createEl('p', { + text: 'Multiple Choice Questions are currently disabled. Enable them to generate AI-powered quizzes that test your understanding of notes.' + }); + } + + // ========= POMODORO TIMER SECTION ========= + const pomodoroSection = createCollapsible('Pomodoro Timer', 'timer', false); // Added timer icon + + new Setting(pomodoroSection) + .setName('Enable Pomodoro Timer') + .setDesc('Show the Pomodoro timer in the sidebar.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.pomodoroEnabled) + .onChange(async (value: boolean) => { + this.plugin.settings.pomodoroEnabled = value; + await this.plugin.savePluginData(); + // Notify the service and refresh UI elements + this.plugin.pomodoroService?.onSettingsChanged(); + this.plugin.sidebarView?.refresh(); // Refresh sidebar to show/hide timer + this.display(); // Refresh settings tab to show/hide dependent settings + })); + + // Only show other Pomodoro settings if the feature is enabled + if (this.plugin.settings.pomodoroEnabled) { + new Setting(pomodoroSection) + .setName("Timer Durations (minutes)") + .setHeading() + .setClass("sf-settings-subsection-header"); + + new Setting(pomodoroSection) + .setName('Work Duration') + .setDesc('Length of a work session.') + .addText(text => text + .setPlaceholder('25') + .setValue(this.plugin.settings.pomodoroWorkDuration.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroWorkDuration = numValue; + await this.plugin.savePluginData(); + this.plugin.pomodoroService?.onSettingsChanged(); // Notify service of potential duration change + } + })); + + new Setting(pomodoroSection) + .setName('Short Break Duration') + .setDesc('Length of a short break.') + .addText(text => text + .setPlaceholder('5') + .setValue(this.plugin.settings.pomodoroShortBreakDuration.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroShortBreakDuration = numValue; + await this.plugin.savePluginData(); + this.plugin.pomodoroService?.onSettingsChanged(); + } + })); + + new Setting(pomodoroSection) + .setName('Long Break Duration') + .setDesc('Length of a long break.') + .addText(text => text + .setPlaceholder('15') + .setValue(this.plugin.settings.pomodoroLongBreakDuration.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroLongBreakDuration = numValue; + await this.plugin.savePluginData(); + this.plugin.pomodoroService?.onSettingsChanged(); + } + })); + + new Setting(pomodoroSection) + .setName('Sessions Until Long Break') + .setDesc('Number of work sessions before a long break starts.') + .addText(text => text + .setPlaceholder('4') + .setValue(this.plugin.settings.pomodoroSessionsUntilLongBreak.toString()) + .onChange(async (value: string) => { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.pomodoroSessionsUntilLongBreak = numValue; + await this.plugin.savePluginData(); + this.plugin.pomodoroService?.onSettingsChanged(); + } + })); + + new Setting(pomodoroSection) + .setName("Notifications") + .setHeading() + .setClass("sf-settings-subsection-header"); + + new Setting(pomodoroSection) + .setName('Enable Sound Notifications') + .setDesc('Play a sound at the end of each work/break session.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.pomodoroSoundEnabled) + .onChange(async (value: boolean) => { + this.plugin.settings.pomodoroSoundEnabled = value; + await this.plugin.savePluginData(); + })); + } else { + const pomodoroDisabledMessage = pomodoroSection.createEl('div', { cls: 'sf-info-box' }); + pomodoroDisabledMessage.createEl('p', { + text: 'Pomodoro Timer is currently disabled. Enable it to configure durations and notifications.' + }); + } + + // Add global action buttons at the bottom + containerEl.createEl('h2', { text: 'Manage Plugin Data' }); + createActionButtons(); + } + + private renderAboutAlgorithmSection(container: HTMLElement, algorithm: 'sm2' | 'fsrs'): void { + container.empty(); // Clear previous content + + if (algorithm === 'sm2') { + container.createEl('h4', { text: 'About the Modified SM-2 Algorithm' }); + container.createEl('p', { + text: 'Spaceforge uses a modified version of the SuperMemo SM-2 algorithm (1991) which schedules reviews based on how well you recall information. ' + + 'When you rate your recall quality from 0-5, the algorithm adjusts the interval and difficulty (ease factor) accordingly.' + }); + container.createEl('p', { + text: 'Our implementation includes specific handling for overdue or skipped items to prevent them from accumulating in a backlog:' + }); + const sm2List = container.createEl('ul'); + sm2List.createEl('li', { text: 'Overdue items: Automatically set to review tomorrow with a quality rating of 0.' }); + sm2List.createEl('li', { text: 'Postponed items: Explicitly moved to tomorrow with a one-step quality penalty.' }); + sm2List.createEl('li', { text: 'Both cases reset the repetition count to 1 and update the ease factor.' }); + + const ratingsTable = container.createEl('table', { cls: 'sf-ratings-table' }); // Added a class for potential styling + ratingsTable.innerHTML = ` + Rating (0-5)DescriptionEffect on Interval + + 0-2Incorrect / struggledResets, shortest interval + 3Correct with difficultySmall increase + 4Correct with hesitationModerate increase + 5Perfect recallLargest increase + + `; + } else if (algorithm === 'fsrs') { + container.createEl('h4', { text: 'About the FSRS Algorithm' }); + container.createEl('p', { + text: 'FSRS (Free Spaced Repetition Scheduler) is a modern, evidence-based algorithm that models memory retention to optimize review schedules. ' + + 'It calculates card difficulty and stability dynamically based on your review history and aims for a target retention rate.' + }); + container.createEl('p', { + text: 'Key concepts in FSRS:' + }); + const fsrsList = container.createEl('ul'); + fsrsList.createEl('li', { text: 'Difficulty: How hard a card is to remember.' }); + fsrsList.createEl('li', { text: 'Stability: How long a card is likely to be remembered.' }); + fsrsList.createEl('li', { text: 'Retention: The probability of recalling a card at the time of review.' }); + fsrsList.createEl('li', { text: 'Learning Steps: Initial short intervals for new cards (configurable).' }); + + const ratingsTable = container.createEl('table', { cls: 'sf-ratings-table' }); + ratingsTable.innerHTML = ` + Rating (1-4)DescriptionEffect on Stability/Difficulty + + 1 (Again)Forgot the cardDecreases stability, may increase difficulty + 2 (Hard)Recalled with significant difficultySmaller increase in stability + 3 (Good)Recalled correctlyStandard increase in stability + 4 (Easy)Recalled very easilyLargest increase in stability, may decrease difficulty + + `; + container.createEl('p', {text: 'FSRS parameters (weights, retention, etc.) can be tuned, but the defaults are generally effective.'}); + } + } +} diff --git a/ui/sidebar-view.ts b/ui/sidebar-view.ts new file mode 100644 index 0000000..db35324 --- /dev/null +++ b/ui/sidebar-view.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + // 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 { + 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 { + 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> { + const grouped: Record = {}; + 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 { + 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 + } +} diff --git a/ui/sidebar/list-view-renderer.ts b/ui/sidebar/list-view-renderer.ts new file mode 100644 index 0000000..8244a48 --- /dev/null +++ b/ui/sidebar/list-view-renderer.ts @@ -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; // 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; + } + ) { + 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 { + // 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 { + // 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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; + 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 { + 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 { + 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 { + 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> { + const grouped: Record = {}; + 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[] { + const keys = Object.keys(groupedNotes); + const dateOrder: Record = { '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); + }); + } +} diff --git a/ui/sidebar/note-item-renderer.ts b/ui/sidebar/note-item-renderer.ts new file mode 100644 index 0000000..e1e57c7 --- /dev/null +++ b/ui/sidebar/note-item-renderer.ts @@ -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 { + 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 = ` +
Initial
+
${currentStepDisplay}/${totalInitialSteps}
+
${formattedTime}
+ `; + phaseEl.addClass("review-phase-initial"); + } else { + phaseEl.setText(note.scheduleCategory === 'graduated' ? "Graduated" : "Spaced"); + phaseEl.addClass(note.scheduleCategory === 'graduated' ? "review-phase-graduated" : "review-phase-spaced"); + // 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 { + // _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 + ): Promise { + 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; + } +} diff --git a/ui/sidebar/pomodoro-ui-manager.ts b/ui/sidebar/pomodoro-ui-manager.ts new file mode 100644 index 0000000..59ec909 --- /dev/null +++ b/ui/sidebar/pomodoro-ui-manager.ts @@ -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 { + 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'; + } + } +} diff --git a/utils/dates.ts b/utils/dates.ts new file mode 100644 index 0000000..68b6910 --- /dev/null +++ b/utils/dates.ts @@ -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(); + } +} diff --git a/utils/estimation.ts b/utils/estimation.ts new file mode 100644 index 0000000..1822579 --- /dev/null +++ b/utils/estimation.ts @@ -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 { + 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 { + 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"); + } + } +} diff --git a/utils/event-emitter.ts b/utils/event-emitter.ts new file mode 100644 index 0000000..df64873 --- /dev/null +++ b/utils/event-emitter.ts @@ -0,0 +1,66 @@ +/** + * Simple event emitter implementation + */ +export class EventEmitter { + /** + * Event listeners by event name + */ + private listeners: Record = {}; + + /** + * 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 = {}; + } + } +} diff --git a/utils/link-analyzer.ts b/utils/link-analyzer.ts new file mode 100644 index 0000000..14c4cae --- /dev/null +++ b/utils/link-analyzer.ts @@ -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; + + /** + * 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 { + // 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 = {}; + 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[] { + 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 = {}; + + 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[] { + 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, + startNodePath: string + ): string[] { + const visited = new Set(); + const traversalOrder: string[] = []; + + // Track which folder each file belongs to + const fileFolders = new Map(); + 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, + 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(); + 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 { + 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(); + + // 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 []; + } + } +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..82e4efa --- /dev/null +++ b/version-bump.mjs @@ -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}`); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..48468a7 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "Initial release" +}