diff --git a/src/@types/settings.d.ts b/src/@types/settings.d.ts index 28a9bfd..df51466 100644 --- a/src/@types/settings.d.ts +++ b/src/@types/settings.d.ts @@ -1,14 +1,15 @@ export interface Card { - hash: number; + hash: string; filePath: string; heading: string; content: string; key: string; // "filepath#heading" for tracking + tags: string[]; // Normalized deck tags (without #flashcards/ prefix) } export interface SimpleFlashcardsSettings { cardPaths: string[]; // ["Journal/Coping", "Activities"] - defaultDeckPath: string; // Default path for quick command + defaultDeckTag: string; // Default tag for deck selection (e.g., "activities") trackViews: boolean; // Enable view tracking selectionMode: "random" | "least-recent"; calloutType: string; // Callout type for embedded cards (e.g., "readaloud") diff --git a/src/flashcards-Api.ts b/src/flashcards-Api.ts index 32e8017..8cc7cb3 100644 --- a/src/flashcards-Api.ts +++ b/src/flashcards-Api.ts @@ -1,3 +1,4 @@ +import type { Card } from "./@types/settings"; import type SimpleFlashcardsPlugin from "./flashcards-Plugin"; export class SimpleFlashcardsApi { @@ -9,9 +10,51 @@ export class SimpleFlashcardsApi { /** * Get a random card embed as formatted text + * @param deck Tag for deck selection (e.g., "activities") * @returns The card embed text, or null if no cards available */ - embedCard(): string | null { - return this.plugin.createEmbedText(); + embedCard(deck: string | undefined = undefined): string | null { + return this.plugin.createEmbedText(deck); + } + + /** + * Get all unique deck tags discovered in cards + * @returns Array of normalized tag strings (e.g., ["activities", "activities/morning"]) + */ + getTags(): string[] { + const tagsSet = new Set(); + + for (const card of this.plugin.cachedCards) { + for (const tag of card.tags) { + tagsSet.add(tag); + } + } + + return Array.from(tagsSet).sort(); + } + + /** + * Select cards by their hash identifiers + * @param hashes Array of card hashes to select + * @returns Array of matching cards + */ + selectCardsByHash(hashes: string[]): Card[] { + const hashSet = new Set(hashes); + return this.plugin.cachedCards.filter((card) => hashSet.has(card.hash)); + } + + /** + * Get a random card from a list of hashes + * @param hashes Array of card hashes to select from + * @returns A random card from the list, or null if no matches + */ + selectCardByHash(hashes: string[]): Card | null { + const cards = this.selectCardsByHash(hashes); + + if (cards.length === 0) { + return null; + } + + return cards[Math.floor(Math.random() * cards.length)]; } } diff --git a/src/flashcards-CardParser.ts b/src/flashcards-CardParser.ts index 447e703..e5f475e 100644 --- a/src/flashcards-CardParser.ts +++ b/src/flashcards-CardParser.ts @@ -1,4 +1,4 @@ -import type { App, TFile } from "obsidian"; +import type { App, CachedMetadata, TFile } from "obsidian"; import type { Card } from "./@types/settings"; export class CardParser { @@ -20,6 +20,9 @@ export class CardParser { const content = await this.app.vault.cachedRead(file); const cards: Card[] = []; + // Extract file-level tags from frontmatter + const fileLevelTags = this.extractFileLevelTags(fileCache); + // Extract content for each H2 heading for (let i = 0; i < h2Headings.length; i++) { const heading = h2Headings[i]; @@ -29,6 +32,17 @@ export class CardParser { ? nextHeading.position.start.offset : content.length; + // Extract section before heading for inline tags + const prevHeading = h2Headings[i - 1]; + const sectionStart = prevHeading + ? prevHeading.position.end.offset + : 0; + const preHeadingContent = content.substring( + sectionStart, + heading.position.start.offset, + ); + const cardLevelTags = this.extractInlineTags(preHeadingContent); + let cardContent = content.substring(startOffset, endOffset); // Truncate at first --- @@ -44,19 +58,99 @@ export class CardParser { .join("\n") .trim(); + // Combine file-level and card-level tags + const allTags = [...fileLevelTags, ...cardLevelTags]; + const uniqueTags = [...new Set(allTags)]; + cards.push({ + hash: this.lowerKebab(heading.heading), filePath: file.path, heading: heading.heading, content: cardContent, key: `${file.path}#${heading.heading}`, - hash: this.hash(Math.random()), + tags: uniqueTags, }); } return cards; } - hash(input: number): number { - return 31 * input; + /** + * Extract file-level tags from frontmatter + */ + extractFileLevelTags(fileCache: CachedMetadata | null): string[] { + const tags: string[] = []; + const frontmatterTags = fileCache?.frontmatter?.tags || []; + + for (const tag of frontmatterTags) { + const normalized = this.normalizeTag(tag); + if (normalized) { + tags.push(normalized); + } + } + + return tags; } + + /** + * Extract inline #flashcards tags from text + */ + extractInlineTags(text: string): string[] { + const tags: string[] = []; + const lines = text.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + // Match lines with #flashcards tags + const tagMatches = trimmed.match(/#flashcards[/\w-]*/g); + if (tagMatches) { + for (const tag of tagMatches) { + const normalized = this.normalizeTag(tag); + if (normalized) { + tags.push(normalized); + } + } + } + } + + return tags; + } + + /** + * Normalize tag by stripping #flashcards/ prefix + * Returns null if tag is just "#flashcards" with no subdeck + */ + normalizeTag(tag: string | null | undefined): string | null { + // Guard against null/undefined + if (!tag) { + return null; + } + + // Remove leading # if present + const cleaned = tag.startsWith("#") ? tag.substring(1) : tag; + + // Must start with flashcards + if (!cleaned.startsWith("flashcards")) { + return null; + } + + // Strip "flashcards/" prefix + if (cleaned === "flashcards") { + return null; // Just #flashcards, no subdeck + } + + if (cleaned.startsWith("flashcards/")) { + return cleaned.substring("flashcards/".length); + } + + return null; + } + + lowerKebab = (name: string): string => { + return (name || "") + .replace(/[\s_]+/g, "-") // replace all spaces and low dash + .replace(/[^0-9a-zA-Z_-]/g, "") // strip other things + .replace(/([a-z])([A-Z])/g, "$1-$2") // separate on camelCase + .toLowerCase(); // convert to lower case + }; } diff --git a/src/flashcards-Constants.ts b/src/flashcards-Constants.ts index 0106dda..942e8b1 100644 --- a/src/flashcards-Constants.ts +++ b/src/flashcards-Constants.ts @@ -2,7 +2,7 @@ import type { SimpleFlashcardsSettings } from "./@types/settings"; export const DEFAULT_SETTINGS: SimpleFlashcardsSettings = { cardPaths: [], - defaultDeckPath: "", + defaultDeckTag: "", trackViews: true, selectionMode: "least-recent", calloutType: "tip", diff --git a/src/flashcards-Modal.ts b/src/flashcards-Modal.ts index 1a0f4df..4fd0a3c 100644 --- a/src/flashcards-Modal.ts +++ b/src/flashcards-Modal.ts @@ -5,18 +5,18 @@ import type SimpleFlashcardsPlugin from "./flashcards-Plugin"; export class FlashcardModal extends Modal { plugin: SimpleFlashcardsPlugin; card: Card | null; - deckPath: string | undefined; + deckTag: string | undefined; constructor( app: App, plugin: SimpleFlashcardsPlugin, card: Card | null, - deckPath?: string, + deckTag?: string, ) { super(app); this.plugin = plugin; this.card = card; - this.deckPath = deckPath; + this.deckTag = deckTag; } onOpen() { @@ -61,8 +61,9 @@ export class FlashcardModal extends Modal { cls: "modal-button-container", }); - // Switch Deck button (only if multiple decks configured) - if (this.plugin.settings.cardPaths.length > 1) { + // Switch Deck button (only if multiple tags available) + const availableTags = this.plugin.api.getTags(); + if (availableTags.length > 1) { new ButtonComponent(buttonContainer) .setButtonText("Switch Deck") .onClick(() => { @@ -100,17 +101,17 @@ export class FlashcardModal extends Modal { } private showDeckSwitcher() { - const availableDecks = this.plugin.settings.cardPaths; - if (availableDecks.length === 0) { + const availableTags = this.plugin.api.getTags(); + if (availableTags.length === 0) { return; } - // Simple approach: cycle through available decks - const currentIndex = this.deckPath - ? availableDecks.indexOf(this.deckPath) + // Simple approach: cycle through available tags + const currentIndex = this.deckTag + ? availableTags.indexOf(this.deckTag) : -1; - const nextIndex = (currentIndex + 1) % availableDecks.length; - this.deckPath = availableDecks[nextIndex]; + const nextIndex = (currentIndex + 1) % availableTags.length; + this.deckTag = availableTags[nextIndex]; this.showNextCard(); } @@ -122,7 +123,7 @@ export class FlashcardModal extends Modal { } // Select next card - this.card = this.plugin.selectCard(this.deckPath); + this.card = this.plugin.selectCard(this.deckTag); this.display(); } diff --git a/src/flashcards-Plugin.ts b/src/flashcards-Plugin.ts index 81fb351..e65a2fd 100644 --- a/src/flashcards-Plugin.ts +++ b/src/flashcards-Plugin.ts @@ -119,11 +119,16 @@ export default class SimpleFlashcardsPlugin extends Plugin { } } - selectCard(deckPath?: string): Card | null { + selectCard(deckTag?: string): Card | null { let pool = this.cachedCards; - if (deckPath) { - pool = pool.filter((c) => c.filePath.startsWith(deckPath)); + if (deckTag) { + // Hierarchical matching: "activities" matches "activities/morning" + pool = pool.filter((c) => + c.tags.some( + (tag) => tag === deckTag || tag.startsWith(`${deckTag}/`), + ), + ); } if (pool.length === 0) { @@ -136,7 +141,7 @@ export default class SimpleFlashcardsPlugin extends Plugin { const bTime = this.data.cardViews[b.key] || 0; if (aTime === bTime) { // consistent, pseudo-randomizing sort - return a.hash - b.hash; + return a.hash.localeCompare(b.hash); } return aTime - bTime; }); @@ -155,20 +160,20 @@ export default class SimpleFlashcardsPlugin extends Plugin { } showFlashCard() { - const deckPath = this.settings.defaultDeckPath || undefined; - const card = this.selectCard(deckPath); + const deckTag = this.settings.defaultDeckTag || undefined; + const card = this.selectCard(deckTag); if (!card) { new Notice("No cards available. Check your settings."); return; } - new FlashcardModal(this.app, this, card, deckPath).open(); + new FlashcardModal(this.app, this, card, deckTag).open(); } - createEmbedText(): string | null { - const deckPath = this.settings.defaultDeckPath || undefined; - const card = this.selectCard(deckPath); + createEmbedText(deck: string | undefined): string | null { + const deckTag = deck || this.settings.defaultDeckTag; + const card = this.selectCard(deckTag); if (!card) { return null; @@ -192,8 +197,8 @@ export default class SimpleFlashcardsPlugin extends Plugin { return embedText; } - embedCard(editor: Editor) { - const embedText = this.createEmbedText(); + embedCard(editor: Editor, deck: string | undefined = undefined) { + const embedText = this.createEmbedText(deck); if (!embedText) { new Notice("No cards available. Check your settings."); diff --git a/src/flashcards-SettingsTab.ts b/src/flashcards-SettingsTab.ts index df04fbb..6c01fcc 100644 --- a/src/flashcards-SettingsTab.ts +++ b/src/flashcards-SettingsTab.ts @@ -83,16 +83,16 @@ export class SimpleFlashcardsSettingsTab extends PluginSettingTab { }); new Setting(containerEl) - .setName("Default Deck Path") + .setName("Default Deck Tag") .setDesc( - "Default path for 'Show Random Activity Card' command (leave empty for all cards)", + "Default tag for 'Show Random Activity Card' command (e.g., 'activities' or 'activities/morning'). Leave empty for all cards.", ) .addText((text) => text - .setPlaceholder("Journal/Coping") - .setValue(this.newSettings.defaultDeckPath) + .setPlaceholder("activities") + .setValue(this.newSettings.defaultDeckTag) .onChange((value) => { - this.newSettings.defaultDeckPath = value.trim(); + this.newSettings.defaultDeckTag = value.trim(); }), ); @@ -147,7 +147,7 @@ export class SimpleFlashcardsSettingsTab extends PluginSettingTab { text: "Use --- (horizontal rule) to mark the end of card content. Anything after --- will be ignored.", }); containerEl.createEl("p", { - text: "Lines starting with #flashcards will be stripped from card display.", + text: "Tag cards with #flashcards/deck-name (e.g., #flashcards/activities/morning). Tags can be in frontmatter or inline before each H2. Lines starting with #flashcards are stripped from display.", }); }