mirror of
https://github.com/ebullient/obsidian-deck-notes.git
synced 2026-07-22 06:40:43 +00:00
✨ Initial pass at simple flashcards
This commit is contained in:
commit
2d5695e5b1
17 changed files with 2050 additions and 0 deletions
23
.editorconfig
Normal file
23
.editorconfig
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
|
||||
# Change these settings to your own preference
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# We recommend you to keep these unchanged
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,html,rb,css,xml,scss,json}]
|
||||
indent_size = 2
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
.env
|
||||
|
||||
# build
|
||||
build
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
references
|
||||
*.out
|
||||
.claude/settings.local.json
|
||||
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
This project is an Obsidian plugin for simple flashcard creation and review. **Read README.md for full feature details and usage instructions.**
|
||||
|
||||
## Your Role
|
||||
|
||||
You are a senior development peer working alongside a Senior Software Engineer (25+ years, primarily Java background) on this hobby TypeScript project. Act as a collaborative partner for:
|
||||
- **Code review and feedback** when requested - focus on patterns, maintainability, and TypeScript/JS idioms
|
||||
- **Implementation assistance** when explicitly asked - suggest approaches, don't implement unless requested
|
||||
- **Technical discussion** and problem-solving - challenge assumptions, ask probing questions, offer alternatives
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
**Core Principles:**
|
||||
- **Follow existing patterns** - Before writing new code:
|
||||
1. Search for similar functions in the same module (use `Grep` tool)
|
||||
2. Check method chaining, line breaks, and error handling patterns
|
||||
3. Emulate the style exactly, especially for method chains and async/await
|
||||
- **Understand before acting** - Read project structure, but defer extensive file reading until user specifies what to work on
|
||||
- **Ask for clarification** when implementation choices or requirements are unclear
|
||||
- **Be direct and concise** - Assume high technical competence, reference specific files/line numbers
|
||||
- **Never speculate** - Don't make up code unless asked
|
||||
- **Point out issues proactively** but wait for explicit requests to fix them
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run build` - Build the plugin
|
||||
- `npm run dev` - Build and watch for changes
|
||||
- `npm run lint` - Lint TypeScript files
|
||||
- `npm run fix` - Auto-fix linting issues
|
||||
- `npm run format` - Format code
|
||||
|
||||
## Architecture
|
||||
|
||||
**Core files:**
|
||||
- `main.ts` - Main plugin class
|
||||
- Plugin structure TBD based on flashcard implementation
|
||||
|
||||
**Key features:**
|
||||
- Simple flashcard syntax
|
||||
- Spaced repetition algorithm
|
||||
- Review interface
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **Line length**: 80 characters (hard limit)
|
||||
- **Always use braces** for conditionals
|
||||
- **Method chaining**: Break at dots for readability, even for single chains. This keeps lines under 80 chars and prevents Biome from wrapping unpredictably.
|
||||
```typescript
|
||||
// GOOD - break at dots
|
||||
const patterns = this.settings.excludeLinkPatterns
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
|
||||
// BAD - all on one line
|
||||
const patterns = this.settings.excludeLinkPatterns.split("\n").map((p) => p.trim());
|
||||
|
||||
// GOOD - even single chains if they approach 80 chars
|
||||
const models = data.models
|
||||
?.map((model) => model.name) || [];
|
||||
```
|
||||
- **Error handling**: `try/catch` with user-friendly `Notice` messages
|
||||
- **Async**: Use `async/await` consistently
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
- Run `npm run build` after significant changes (includes linting via prebuild)
|
||||
- Use `npm run fix` to auto-correct linting issues
|
||||
- Reference specific line numbers when discussing issues (format: `file.ts:123`)
|
||||
200
README.md
Normal file
200
README.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Simple Flashcards
|
||||
|
||||
A simple activity card plugin for Obsidian. Display random cards from your markdown files for meditations, prompts, exercises, or any repeatable activities.
|
||||
|
||||
## Features
|
||||
|
||||
- **Random or Least-Recent Selection** - View cards randomly or ensure you see all cards evenly
|
||||
- **Deck Organization** - Organize cards into different folders/paths
|
||||
- **View Tracking** - Optional tracking to prioritize cards you haven't seen recently
|
||||
- **Simple Markdown Format** - Use H2 headings to define cards
|
||||
- **Switch Decks on the Fly** - Change between different card collections within the modal
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the [Releases page](https://github.com/ebullient/obsidian-flashcards/releases)
|
||||
2. Extract the files into your vault's plugins folder: `<vault>/.obsidian/plugins/simple-flashcards/`
|
||||
3. Reload Obsidian
|
||||
4. Enable the plugin in Settings → Community Plugins
|
||||
|
||||
### Development Installation
|
||||
|
||||
1. Clone this repository into your vault's plugins folder:
|
||||
```bash
|
||||
cd <vault>/.obsidian/plugins
|
||||
git clone https://github.com/ebullient/obsidian-flashcards.git simple-flashcards
|
||||
cd simple-flashcards
|
||||
```
|
||||
|
||||
2. Install dependencies and build:
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Reload Obsidian and enable the plugin
|
||||
|
||||
## Card Format
|
||||
|
||||
Cards are created from markdown files using H2 headings (`##`). Each heading becomes one card.
|
||||
|
||||
### Example
|
||||
|
||||
```markdown
|
||||
---
|
||||
tags:
|
||||
- flashcards/activities
|
||||
---
|
||||
|
||||
#flashcards/activities/morning
|
||||
## CARD 1: Morning Stretching Routine
|
||||
|
||||
Start your day with gentle movement:
|
||||
|
||||
1. Neck rolls - 5 each direction
|
||||
2. Shoulder shrugs - 10 reps
|
||||
3. Side stretches - hold 15 seconds each side
|
||||
4. Forward fold - hold 30 seconds
|
||||
|
||||
Take your time and breathe deeply.
|
||||
|
||||
---
|
||||
|
||||
#flashcards/activities/creative
|
||||
## CARD 2: Creative Writing Prompt
|
||||
|
||||
**Setting:** A library that only opens at midnight
|
||||
|
||||
**Challenge:** Write for 10 minutes about what books are found there and who visits.
|
||||
|
||||
---
|
||||
|
||||
#flashcards/activities/mindfulness
|
||||
## CARD 3: Breathing Exercise
|
||||
|
||||
**Box Breathing:**
|
||||
|
||||
- Inhale for 4 counts
|
||||
- Hold for 4 counts
|
||||
- Exhale for 4 counts
|
||||
- Hold for 4 counts
|
||||
|
||||
Repeat 4 times.
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Format Rules
|
||||
|
||||
- **H2 headings** (`##`) mark the start of each card
|
||||
- **Horizontal rules** (`---`) mark the end of card content (optional)
|
||||
- **Tag lines** starting with `#flashcards` are automatically stripped from display
|
||||
- Content after `---` is ignored (use for notes, metadata, etc.)
|
||||
|
||||
## Configuration
|
||||
|
||||
Open Settings → Simple Flashcards to configure:
|
||||
|
||||
### Card Paths
|
||||
List of folders or files to scan for cards (one per line, relative to vault root).
|
||||
|
||||
Example:
|
||||
```
|
||||
Activities/Exercises
|
||||
Daily/Prompts
|
||||
Resources/Meditations.md
|
||||
```
|
||||
|
||||
### Default Deck Path
|
||||
The default path used by "Show Random Activity Card" command. Leave empty to select from all configured paths.
|
||||
|
||||
### Track Views
|
||||
Enable tracking of when cards were last viewed. Required for "Least Recently Viewed" selection mode.
|
||||
|
||||
### Selection Mode
|
||||
- **Random** - Pure random selection
|
||||
- **Least Recently Viewed** - Prioritizes cards you haven't seen recently
|
||||
|
||||
## Commands
|
||||
|
||||
### Show Random Activity Card
|
||||
Opens a modal displaying a card from your default deck (or all cards if no default is set).
|
||||
|
||||
**Modal Controls:**
|
||||
- **Switch Deck** - Cycle through configured card paths
|
||||
- **Next Card** - View another card (records the current card as viewed)
|
||||
- **Done** - Close the modal
|
||||
|
||||
### Refresh Activity Cards
|
||||
Rescans all configured paths to pick up new or modified cards.
|
||||
|
||||
## Usage Tips
|
||||
|
||||
### Organizing Cards
|
||||
|
||||
**Multiple files in folders:**
|
||||
```
|
||||
Activities/
|
||||
├── stretches.md
|
||||
├── writing-prompts.md
|
||||
└── meditations.md
|
||||
```
|
||||
|
||||
**Single file per deck:**
|
||||
```
|
||||
Daily/
|
||||
└── morning-routine.md
|
||||
```
|
||||
|
||||
**Mixed approach:**
|
||||
Configure both folders and individual files in Card Paths.
|
||||
|
||||
### View Tracking
|
||||
|
||||
With "Track Views" enabled and "Least Recently Viewed" selection mode, the plugin ensures you see all cards before repeating. Perfect for:
|
||||
- Daily meditation rotations
|
||||
- Exercise variety
|
||||
- Prompt cycling
|
||||
|
||||
### Tags and Metadata
|
||||
|
||||
While `#flashcards` tag lines are stripped for display, you can still:
|
||||
- Use frontmatter tags for vault organization
|
||||
- Add inline tags for sub-categorization
|
||||
- Include any markdown formatting (bold, lists, links, etc.)
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Production build
|
||||
npm run dev # Watch mode
|
||||
npm run lint # Check for issues
|
||||
npm run fix # Auto-fix linting issues
|
||||
npm run format # Format code
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── @types/
|
||||
│ └── settings.d.ts # TypeScript interfaces
|
||||
├── flashcards-Plugin.ts # Main plugin class
|
||||
├── flashcards-CardParser.ts # Parse files into cards
|
||||
├── flashcards-Modal.ts # Card display modal
|
||||
├── flashcards-SettingsTab.ts # Settings UI
|
||||
├── flashcards-Constants.ts # Default settings
|
||||
└── main.ts # Entry point
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Author
|
||||
|
||||
[ebullient](https://github.com/ebullient)
|
||||
47
biome.json
Normal file
47
biome.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["src/**"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 4,
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
esbuild.config.mjs
Normal file
57
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 dir = prod || !process.env.OUTDIR ? "./build" : process.env.OUTDIR;
|
||||
|
||||
const parameters = {
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['src/main.ts'],
|
||||
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',
|
||||
...builtins
|
||||
],
|
||||
format: 'cjs',
|
||||
logLevel: 'info',
|
||||
target: 'es2020',
|
||||
treeShaking: true,
|
||||
sourcemap: prod ? false : 'inline',
|
||||
minify: prod,
|
||||
outdir: dir,
|
||||
};
|
||||
|
||||
if (prod) {
|
||||
await esbuild.build(parameters).catch((x) => {
|
||||
if (x.errors) {
|
||||
console.error(x.errors);
|
||||
} else {
|
||||
console.error(x);
|
||||
}
|
||||
process.exit(1)
|
||||
});
|
||||
} else {
|
||||
const ctx = await esbuild.context(parameters);
|
||||
await ctx.watch()
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "simple-flashcards",
|
||||
"name": "Simple Flashcards",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.13.10",
|
||||
"description": "A simple flashcard plugin for Obsidian",
|
||||
"author": "ebullient",
|
||||
"authorUrl": "https://github.com/ebullient",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
1039
package-lock.json
generated
Normal file
1039
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
package.json
Normal file
43
package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "simple-flashcards",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Simple flashcard plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"fix": "npx @biomejs/biome check --write ./src",
|
||||
"format": "npx @biomejs/biome format ./src",
|
||||
"lint": "npx @biomejs/biome lint ./src",
|
||||
"prebuild": "npx @biomejs/biome check ./src",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"postbuild": "cp -v manifest.json README.md build",
|
||||
"preversion": "npm run build",
|
||||
"version": "auto-changelog -p"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"obsidian-md",
|
||||
"obsidian-plugin",
|
||||
"obsidian-md-plugin",
|
||||
"flashcards"
|
||||
],
|
||||
"author": "ebullient",
|
||||
"repository": "github.com:ebullient/obsidian-flashcards",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.3",
|
||||
"@types/node": "^24.3.1",
|
||||
"auto-changelog": "^2.5.0",
|
||||
"builtin-modules": "^5.0.0",
|
||||
"esbuild": "^0.25.9",
|
||||
"obsidian": "^1.8.7",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"auto-changelog": {
|
||||
"backfillLimit": false,
|
||||
"commitLimit": false,
|
||||
"ignoreCommitPattern": "(🔖|🔨|🧹|changelog|release|Update README).*"
|
||||
}
|
||||
}
|
||||
17
src/@types/settings.d.ts
vendored
Normal file
17
src/@types/settings.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface Card {
|
||||
filePath: string;
|
||||
heading: string;
|
||||
content: string;
|
||||
key: string; // "filepath#heading" for tracking
|
||||
}
|
||||
|
||||
export interface SimpleFlashcardsSettings {
|
||||
cardPaths: string[]; // ["Journal/Coping", "Activities"]
|
||||
defaultDeckPath: string; // Default path for quick command
|
||||
trackViews: boolean; // Enable view tracking
|
||||
selectionMode: "random" | "least-recent";
|
||||
}
|
||||
|
||||
export interface SimpleFlashcardsData {
|
||||
cardViews: Record<string, number>; // "file.md#heading" → timestamp
|
||||
}
|
||||
68
src/flashcards-CardParser.ts
Normal file
68
src/flashcards-CardParser.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { App, TFile } from "obsidian";
|
||||
import type { Card } from "./@types/settings";
|
||||
|
||||
export class CardParser {
|
||||
app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
async parseFile(file: TFile): Promise<Card[]> {
|
||||
const content = await this.app.vault.read(file);
|
||||
const cards: Card[] = [];
|
||||
const headingRegex = /^## (.+)$/gm;
|
||||
|
||||
// Find all H2 headings
|
||||
const headings: { text: string; index: number }[] = [];
|
||||
let match = headingRegex.exec(content);
|
||||
while (match !== null) {
|
||||
headings.push({ text: match[1], index: match.index });
|
||||
match = headingRegex.exec(content);
|
||||
}
|
||||
|
||||
if (headings.length === 0) {
|
||||
console.warn(`Skipping ${file.path}: No H2 headings found`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract content for each heading
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i];
|
||||
const nextIndex =
|
||||
i + 1 < headings.length
|
||||
? headings[i + 1].index
|
||||
: content.length;
|
||||
|
||||
let cardContent = content.substring(heading.index, nextIndex);
|
||||
|
||||
// Remove the H2 heading line itself
|
||||
const headingLineEnd = cardContent.indexOf("\n");
|
||||
if (headingLineEnd !== -1) {
|
||||
cardContent = cardContent.substring(headingLineEnd + 1);
|
||||
}
|
||||
|
||||
// Truncate at first ---
|
||||
const dividerIndex = cardContent.indexOf("\n---");
|
||||
if (dividerIndex !== -1) {
|
||||
cardContent = cardContent.substring(0, dividerIndex);
|
||||
}
|
||||
|
||||
// Strip lines that are ONLY #flashcards tags
|
||||
cardContent = cardContent
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("#flashcards"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
cards.push({
|
||||
filePath: file.path,
|
||||
heading: heading.text,
|
||||
content: cardContent,
|
||||
key: `${file.path}#${heading.text}`,
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
8
src/flashcards-Constants.ts
Normal file
8
src/flashcards-Constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { SimpleFlashcardsSettings } from "./@types/settings";
|
||||
|
||||
export const DEFAULT_SETTINGS: SimpleFlashcardsSettings = {
|
||||
cardPaths: [],
|
||||
defaultDeckPath: "",
|
||||
trackViews: true,
|
||||
selectionMode: "least-recent",
|
||||
};
|
||||
134
src/flashcards-Modal.ts
Normal file
134
src/flashcards-Modal.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { type App, ButtonComponent, MarkdownRenderer, Modal } from "obsidian";
|
||||
import type { Card } from "./@types/settings";
|
||||
import type SimpleFlashcardsPlugin from "./flashcards-Plugin";
|
||||
|
||||
export class FlashcardModal extends Modal {
|
||||
plugin: SimpleFlashcardsPlugin;
|
||||
card: Card | null;
|
||||
deckPath: string | undefined;
|
||||
contentEl: HTMLElement;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: SimpleFlashcardsPlugin,
|
||||
card: Card | null,
|
||||
deckPath?: string,
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.card = card;
|
||||
this.deckPath = deckPath;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.display();
|
||||
}
|
||||
|
||||
display() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
if (!this.card) {
|
||||
contentEl.createEl("p", {
|
||||
text: "No cards available. Check your card paths in settings.",
|
||||
});
|
||||
this.addCloseButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Render card heading
|
||||
contentEl.createEl("h2", { text: this.card.heading });
|
||||
|
||||
// Render card content as markdown
|
||||
const contentDiv = contentEl.createEl("div", {
|
||||
cls: "flashcard-content",
|
||||
});
|
||||
|
||||
MarkdownRenderer.render(
|
||||
this.app,
|
||||
this.card.content,
|
||||
contentDiv,
|
||||
this.card.filePath,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Add button controls
|
||||
this.addControls();
|
||||
}
|
||||
|
||||
private addControls() {
|
||||
const { contentEl } = this;
|
||||
const buttonContainer = contentEl.createEl("div", {
|
||||
cls: "modal-button-container",
|
||||
});
|
||||
|
||||
// Switch Deck button (only if multiple decks configured)
|
||||
if (this.plugin.settings.cardPaths.length > 1) {
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText("Switch Deck")
|
||||
.onClick(() => {
|
||||
this.showDeckSwitcher();
|
||||
});
|
||||
}
|
||||
|
||||
// Next Card button
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText("Next Card")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.showNextCard();
|
||||
});
|
||||
|
||||
// Done button
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText("Done")
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private addCloseButton() {
|
||||
const { contentEl } = this;
|
||||
const buttonContainer = contentEl.createEl("div", {
|
||||
cls: "modal-button-container",
|
||||
});
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText("Close")
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private showDeckSwitcher() {
|
||||
const availableDecks = this.plugin.settings.cardPaths;
|
||||
if (availableDecks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple approach: cycle through available decks
|
||||
const currentIndex = this.deckPath
|
||||
? availableDecks.indexOf(this.deckPath)
|
||||
: -1;
|
||||
const nextIndex = (currentIndex + 1) % availableDecks.length;
|
||||
this.deckPath = availableDecks[nextIndex];
|
||||
|
||||
this.showNextCard();
|
||||
}
|
||||
|
||||
private showNextCard() {
|
||||
// Record view for current card
|
||||
if (this.card && this.plugin.settings.trackViews) {
|
||||
this.plugin.recordView(this.card.key);
|
||||
}
|
||||
|
||||
// Select next card
|
||||
this.card = this.plugin.selectCard(this.deckPath);
|
||||
this.display();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
144
src/flashcards-Plugin.ts
Normal file
144
src/flashcards-Plugin.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Notice, Plugin, TFile, TFolder } from "obsidian";
|
||||
import type {
|
||||
Card,
|
||||
SimpleFlashcardsData,
|
||||
SimpleFlashcardsSettings,
|
||||
} from "./@types/settings";
|
||||
import { CardParser } from "./flashcards-CardParser";
|
||||
import { DEFAULT_SETTINGS } from "./flashcards-Constants";
|
||||
import { FlashcardModal } from "./flashcards-Modal";
|
||||
import { SimpleFlashcardsSettingsTab } from "./flashcards-SettingsTab";
|
||||
|
||||
export default class SimpleFlashcardsPlugin extends Plugin {
|
||||
settings!: SimpleFlashcardsSettings;
|
||||
data!: SimpleFlashcardsData;
|
||||
cachedCards: Card[] = [];
|
||||
cardParser!: CardParser;
|
||||
|
||||
async onload() {
|
||||
console.log("Loading Simple Flashcards plugin");
|
||||
|
||||
await this.loadSettings();
|
||||
this.cardParser = new CardParser(this.app);
|
||||
|
||||
this.addSettingTab(new SimpleFlashcardsSettingsTab(this.app, this));
|
||||
|
||||
// Command: Show Random Activity Card
|
||||
this.addCommand({
|
||||
id: "show-random-card",
|
||||
name: "Show Random Activity Card",
|
||||
callback: () => {
|
||||
this.showRandomCard();
|
||||
},
|
||||
});
|
||||
|
||||
// Command: Refresh Activity Cards
|
||||
this.addCommand({
|
||||
id: "refresh-cards",
|
||||
name: "Refresh Activity Cards",
|
||||
callback: async () => {
|
||||
await this.scanCards();
|
||||
new Notice("Activity cards refreshed");
|
||||
},
|
||||
});
|
||||
|
||||
// Initial card scan
|
||||
await this.scanCards();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log("Unloading Simple Flashcards plugin");
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
);
|
||||
this.data = (await this.loadData()) || { cardViews: {} };
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData({
|
||||
...this.settings,
|
||||
cardViews: this.data.cardViews,
|
||||
});
|
||||
await this.scanCards();
|
||||
}
|
||||
|
||||
async scanCards() {
|
||||
this.cachedCards = [];
|
||||
|
||||
for (const cardPath of this.settings.cardPaths) {
|
||||
const abstractFile = this.app.vault.getAbstractFileByPath(cardPath);
|
||||
|
||||
if (abstractFile instanceof TFolder) {
|
||||
await this.scanFolder(abstractFile);
|
||||
} else if (abstractFile instanceof TFile) {
|
||||
const cards = await this.cardParser.parseFile(abstractFile);
|
||||
this.cachedCards.push(...cards);
|
||||
} else {
|
||||
console.warn(`Card path not found: ${cardPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Scanned ${this.cachedCards.length} cards`);
|
||||
}
|
||||
|
||||
private async scanFolder(folder: TFolder) {
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFile && child.extension === "md") {
|
||||
const cards = await this.cardParser.parseFile(child);
|
||||
this.cachedCards.push(...cards);
|
||||
} else if (child instanceof TFolder) {
|
||||
await this.scanFolder(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectCard(deckPath?: string): Card | null {
|
||||
let pool = this.cachedCards;
|
||||
|
||||
if (deckPath) {
|
||||
pool = pool.filter((c) => c.filePath.startsWith(deckPath));
|
||||
}
|
||||
|
||||
if (pool.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.settings.selectionMode === "random") {
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
// least-recent: sort by lastSeen timestamp
|
||||
const sorted = pool.sort((a, b) => {
|
||||
const aTime = this.data.cardViews[a.key] || 0;
|
||||
const bTime = this.data.cardViews[b.key] || 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
recordView(cardKey: string) {
|
||||
this.data.cardViews[cardKey] = Date.now();
|
||||
this.saveData({
|
||||
...this.settings,
|
||||
cardViews: this.data.cardViews,
|
||||
});
|
||||
}
|
||||
|
||||
showRandomCard() {
|
||||
const deckPath = this.settings.defaultDeckPath || undefined;
|
||||
const card = this.selectCard(deckPath);
|
||||
|
||||
if (!card) {
|
||||
new Notice("No cards available. Check your settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
new FlashcardModal(this.app, this, card, deckPath).open();
|
||||
}
|
||||
}
|
||||
144
src/flashcards-SettingsTab.ts
Normal file
144
src/flashcards-SettingsTab.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { type App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type { SimpleFlashcardsSettings } from "./@types/settings";
|
||||
import type SimpleFlashcardsPlugin from "./flashcards-Plugin";
|
||||
|
||||
export class SimpleFlashcardsSettingsTab extends PluginSettingTab {
|
||||
plugin: SimpleFlashcardsPlugin;
|
||||
newSettings!: SimpleFlashcardsSettings;
|
||||
|
||||
constructor(app: App, plugin: SimpleFlashcardsPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.plugin.settings = this.newSettings;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private cloneSettings(): SimpleFlashcardsSettings {
|
||||
return JSON.parse(JSON.stringify(this.plugin.settings));
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.newSettings = this.cloneSettings();
|
||||
this.display();
|
||||
}
|
||||
|
||||
display(): void {
|
||||
if (!this.newSettings) {
|
||||
this.newSettings = this.cloneSettings();
|
||||
}
|
||||
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h2", { text: "Simple Flashcards Settings" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Save Settings")
|
||||
.setClass("flashcards-save-reset")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Reset")
|
||||
.setTooltip("Reset to current saved settings")
|
||||
.onClick(() => {
|
||||
this.reset();
|
||||
}),
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Save")
|
||||
.setCta()
|
||||
.setTooltip("Save all changes")
|
||||
.onClick(async () => {
|
||||
await this.save();
|
||||
}),
|
||||
);
|
||||
|
||||
containerEl.createEl("p", {
|
||||
text: "Configure activity card decks for random selection.",
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Card Paths")
|
||||
.setDesc(
|
||||
"Paths to folders containing card files (one per line, relative to vault root)",
|
||||
)
|
||||
.addTextArea((text) =>
|
||||
text
|
||||
.setPlaceholder("Journal/Coping\nActivities/Morning")
|
||||
.setValue(this.newSettings.cardPaths.join("\n"))
|
||||
.onChange((value) => {
|
||||
this.newSettings.cardPaths = value
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
}),
|
||||
)
|
||||
.then((setting) => {
|
||||
setting.controlEl
|
||||
.querySelector("textarea")
|
||||
?.setAttribute("rows", "4");
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default Deck Path")
|
||||
.setDesc(
|
||||
"Default path for 'Show Random Activity Card' command (leave empty for all cards)",
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Journal/Coping")
|
||||
.setValue(this.newSettings.defaultDeckPath)
|
||||
.onChange((value) => {
|
||||
this.newSettings.defaultDeckPath = value.trim();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Track Views")
|
||||
.setDesc(
|
||||
"Track when cards were last viewed (enables least-recent selection)",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.newSettings.trackViews)
|
||||
.onChange((value) => {
|
||||
this.newSettings.trackViews = value;
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Selection Mode")
|
||||
.setDesc("How to select cards to display")
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("random", "Random")
|
||||
.addOption("least-recent", "Least Recently Viewed")
|
||||
.setValue(this.newSettings.selectionMode)
|
||||
.onChange((value) => {
|
||||
this.newSettings.selectionMode = value as
|
||||
| "random"
|
||||
| "least-recent";
|
||||
}),
|
||||
);
|
||||
|
||||
containerEl.createEl("h3", { text: "Usage" });
|
||||
const usage = containerEl.createEl("div");
|
||||
usage.createEl("p", {
|
||||
text: "Cards are created from markdown files with H2 headings (##). Each heading becomes one card.",
|
||||
});
|
||||
usage.createEl("p", {
|
||||
text: "Use --- (horizontal rule) to mark the end of card content. Anything after --- will be ignored.",
|
||||
});
|
||||
usage.createEl("p", {
|
||||
text: "Lines starting with #flashcards will be stripped from card display.",
|
||||
});
|
||||
}
|
||||
|
||||
/** Save on exit */
|
||||
hide(): void {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
3
src/main.ts
Normal file
3
src/main.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import SimpleFlashcardsPlugin from "./flashcards-Plugin";
|
||||
|
||||
export default SimpleFlashcardsPlugin;
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2020"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue