2025-11-17 21:12:54 +00:00
|
|
|
import type { App, CachedMetadata, TFile } from "obsidian";
|
2025-10-07 12:37:24 +00:00
|
|
|
import type { Card } from "./@types/settings";
|
|
|
|
|
|
|
|
|
|
export class CardParser {
|
|
|
|
|
app: App;
|
|
|
|
|
|
|
|
|
|
constructor(app: App) {
|
|
|
|
|
this.app = app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async parseFile(file: TFile): Promise<Card[]> {
|
2025-10-07 12:54:30 +00:00
|
|
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
|
|
|
|
const h2Headings = fileCache?.headings?.filter((h) => h.level === 2);
|
2025-10-07 12:37:24 +00:00
|
|
|
|
2025-10-07 12:54:30 +00:00
|
|
|
if (!h2Headings || h2Headings.length === 0) {
|
2025-10-07 12:37:24 +00:00
|
|
|
console.warn(`Skipping ${file.path}: No H2 headings found`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 12:54:30 +00:00
|
|
|
const content = await this.app.vault.cachedRead(file);
|
|
|
|
|
const cards: Card[] = [];
|
2025-10-07 12:37:24 +00:00
|
|
|
|
2025-11-17 21:12:54 +00:00
|
|
|
// Extract file-level tags from frontmatter
|
|
|
|
|
const fileLevelTags = this.extractFileLevelTags(fileCache);
|
|
|
|
|
|
2025-10-07 12:54:30 +00:00
|
|
|
// Extract content for each H2 heading
|
|
|
|
|
for (let i = 0; i < h2Headings.length; i++) {
|
|
|
|
|
const heading = h2Headings[i];
|
|
|
|
|
const startOffset = heading.position.end.offset;
|
|
|
|
|
const nextHeading = h2Headings[i + 1];
|
|
|
|
|
const endOffset = nextHeading
|
|
|
|
|
? nextHeading.position.start.offset
|
|
|
|
|
: content.length;
|
2025-10-07 12:37:24 +00:00
|
|
|
|
2025-11-17 21:12:54 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2025-10-07 12:54:30 +00:00
|
|
|
let cardContent = content.substring(startOffset, endOffset);
|
2025-10-07 12:37:24 +00:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2025-11-17 21:12:54 +00:00
|
|
|
// Combine file-level and card-level tags
|
|
|
|
|
const allTags = [...fileLevelTags, ...cardLevelTags];
|
|
|
|
|
const uniqueTags = [...new Set(allTags)];
|
|
|
|
|
|
2025-10-07 12:37:24 +00:00
|
|
|
cards.push({
|
2025-11-17 21:12:54 +00:00
|
|
|
hash: this.lowerKebab(heading.heading),
|
2025-10-07 12:37:24 +00:00
|
|
|
filePath: file.path,
|
2025-10-07 12:54:30 +00:00
|
|
|
heading: heading.heading,
|
2025-10-07 12:37:24 +00:00
|
|
|
content: cardContent,
|
2025-10-07 12:54:30 +00:00
|
|
|
key: `${file.path}#${heading.heading}`,
|
2025-11-17 21:12:54 +00:00
|
|
|
tags: uniqueTags,
|
2025-10-07 12:37:24 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cards;
|
|
|
|
|
}
|
2025-10-30 13:19:23 +00:00
|
|
|
|
2025-11-17 21:12:54 +00:00
|
|
|
/**
|
|
|
|
|
* Extract file-level tags from frontmatter
|
|
|
|
|
*/
|
|
|
|
|
extractFileLevelTags(fileCache: CachedMetadata | null): string[] {
|
|
|
|
|
const tags: string[] = [];
|
2025-11-18 03:09:55 +00:00
|
|
|
const frontmatterTags = fileCache?.frontmatter?.tags as
|
|
|
|
|
| string[]
|
|
|
|
|
| Record<string, unknown>
|
|
|
|
|
| undefined;
|
2025-11-17 21:12:54 +00:00
|
|
|
|
2025-11-18 03:09:55 +00:00
|
|
|
if (!frontmatterTags) {
|
|
|
|
|
return tags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle both array and object formats
|
|
|
|
|
const tagList = Array.isArray(frontmatterTags)
|
|
|
|
|
? frontmatterTags
|
|
|
|
|
: Object.keys(frontmatterTags);
|
|
|
|
|
|
|
|
|
|
for (const tag of tagList) {
|
|
|
|
|
if (typeof tag === "string") {
|
|
|
|
|
const normalized = this.normalizeTag(tag);
|
|
|
|
|
if (normalized) {
|
|
|
|
|
tags.push(normalized);
|
|
|
|
|
}
|
2025-11-17 21:12:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-10-30 13:19:23 +00:00
|
|
|
}
|
2025-11-17 21:12:54 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
};
|
2025-10-07 12:37:24 +00:00
|
|
|
}
|