🎨 Use tag hierarchy to filter decks

This commit is contained in:
Erin Schnabel 2025-11-17 16:12:54 -05:00
parent 803858b2fe
commit 289631b530
No known key found for this signature in database
7 changed files with 184 additions and 40 deletions

View file

@ -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")

View file

@ -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<string>();
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)];
}
}

View file

@ -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
};
}

View file

@ -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",

View file

@ -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();
}

View file

@ -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.");

View file

@ -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.",
});
}