mirror of
https://github.com/ebullient/obsidian-deck-notes.git
synced 2026-07-22 06:40:43 +00:00
🎨 Use tag hierarchy to filter decks
This commit is contained in:
parent
803858b2fe
commit
289631b530
7 changed files with 184 additions and 40 deletions
5
src/@types/settings.d.ts
vendored
5
src/@types/settings.d.ts
vendored
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue