MAJOR BREAKING CHANGE: gender and subheading -> tags
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s

This commit is contained in:
ryan 2025-10-02 23:24:39 -07:00
parent 58e67fede3
commit b350b58dfe
15 changed files with 699 additions and 101 deletions

View File

@ -5,6 +5,7 @@ import react from "@astrojs/react";
import node from "@astrojs/node"; import node from "@astrojs/node";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import { eq } from "drizzle-orm";
import { tags } from "./src/lib/db/schema.ts"; import { tags } from "./src/lib/db/schema.ts";
import "dotenv/config"; import "dotenv/config";
@ -17,7 +18,8 @@ try {
if (connectionString) { if (connectionString) {
const client = postgres(connectionString); const client = postgres(connectionString);
const db = drizzle(client, { schema: { tags } }); const db = drizzle(client, { schema: { tags } });
tagsList = await db.select().from(tags); // Only get page-level tags for the sidebar
tagsList = await db.select().from(tags).where(eq(tags.level, 'page'));
await client.end(); await client.end();
} else { } else {
console.warn( console.warn(

View File

@ -0,0 +1,49 @@
-- Step 1: Add columns (level temporarily nullable to allow data migration)
ALTER TABLE "tags" ADD COLUMN "level" text;--> statement-breakpoint
ALTER TABLE "tags" ADD COLUMN "parent_page_id" integer;--> statement-breakpoint
-- Step 2: Set level for existing page tags
UPDATE "tags" SET "level" = 'page' WHERE "name" IN ('general', 'repetition', 'nationalities', 'vocations', 'hobbies', 'food-and-drink', 'social', 'home', 'body');--> statement-breakpoint
-- Step 3: Set level and parentPageId for existing subheading tag (none exist yet, will be created manually)
-- UPDATE "tags" SET "level" = 'subheading', "parent_page_id" = (SELECT id FROM "tags" WHERE "name" = 'body') WHERE "name" = 'parts-of-the-body';--> statement-breakpoint
-- Step 4: Insert grammar tags
INSERT INTO "tags" ("name", "description", "level") VALUES
('Noun', 'Noun', 'grammar'),
('Verb', 'Verb', 'grammar'),
('Adjective', 'Adjective', 'grammar'),
('Adverb', 'Adverb', 'grammar'),
('Preposition', 'Preposition', 'grammar'),
('Conjunction', 'Conjunction', 'grammar'),
('Interjection', 'Interjection', 'grammar'),
('Pronoun', 'Pronoun', 'grammar');--> statement-breakpoint
-- Step 5: Insert gender tags
INSERT INTO "tags" ("name", "description", "level") VALUES
('Masculine', 'Masculine gender', 'gender'),
('Feminine', 'Feminine gender', 'gender');--> statement-breakpoint
-- Step 6: Migrate word.type to word_tags (creating grammar tag associations)
INSERT INTO "word_tags" ("word_id", "tag_id")
SELECT w.id, t.id
FROM "words" w
JOIN "tags" t ON LOWER(w.type) = LOWER(t.name)
WHERE t.level = 'grammar';--> statement-breakpoint
-- Step 7: Migrate word.gender to word_tags (creating gender tag associations)
INSERT INTO "word_tags" ("word_id", "tag_id")
SELECT w.id, t.id
FROM "words" w
JOIN "tags" t ON (w.gender = 'm' AND t.name = 'Masculine') OR (w.gender = 'f' AND t.name = 'Feminine')
WHERE w.gender IS NOT NULL;--> statement-breakpoint
-- Step 8: Make level NOT NULL now that all rows have values
ALTER TABLE "tags" ALTER COLUMN "level" SET NOT NULL;--> statement-breakpoint
-- Step 9: Add foreign key constraint for self-reference
ALTER TABLE "tags" ADD CONSTRAINT "tags_parent_page_id_fkey" FOREIGN KEY ("parent_page_id") REFERENCES "tags"("id");--> statement-breakpoint
-- Step 10: Drop old columns from words table
ALTER TABLE "words" DROP COLUMN "type";--> statement-breakpoint
ALTER TABLE "words" DROP COLUMN "gender";

View File

@ -0,0 +1,299 @@
{
"id": "ce79efb4-3263-4bd2-bf58-1f5ea26ef701",
"prevId": "318667f9-2cec-43d4-9e1c-9c6ddedb8664",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.examples": {
"name": "examples",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"word_id": {
"name": "word_id",
"type": "serial",
"primaryKey": false,
"notNull": true
},
"hindi": {
"name": "hindi",
"type": "text",
"primaryKey": false,
"notNull": true
},
"english": {
"name": "english",
"type": "text",
"primaryKey": false,
"notNull": true
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"examples_word_id_words_id_fk": {
"name": "examples_word_id_words_id_fk",
"tableFrom": "examples",
"tableTo": "words",
"columnsFrom": [
"word_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.see_also": {
"name": "see_also",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"word_id": {
"name": "word_id",
"type": "serial",
"primaryKey": false,
"notNull": true
},
"reference": {
"name": "reference",
"type": "text",
"primaryKey": false,
"notNull": true
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"see_also_word_id_words_id_fk": {
"name": "see_also_word_id_words_id_fk",
"tableFrom": "see_also",
"tableTo": "words",
"columnsFrom": [
"word_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"level": {
"name": "level",
"type": "text",
"primaryKey": false,
"notNull": true
},
"parent_page_id": {
"name": "parent_page_id",
"type": "serial",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.word_tags": {
"name": "word_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"word_id": {
"name": "word_id",
"type": "serial",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "serial",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"word_tags_word_id_words_id_fk": {
"name": "word_tags_word_id_words_id_fk",
"tableFrom": "word_tags",
"tableTo": "words",
"columnsFrom": [
"word_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"word_tags_tag_id_tags_id_fk": {
"name": "word_tags_tag_id_tags_id_fk",
"tableFrom": "word_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.words": {
"name": "words",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"hindi": {
"name": "hindi",
"type": "text",
"primaryKey": false,
"notNull": true
},
"english": {
"name": "english",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"gender": {
"name": "gender",
"type": "text",
"primaryKey": false,
"notNull": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1759452792993, "when": 1759452792993,
"tag": "0001_luxuriant_iceman", "tag": "0001_luxuriant_iceman",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1759469327046,
"tag": "0002_late_leo",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,82 @@
import { readFileSync } from 'fs';
import { parse } from 'yaml';
import { db } from '../src/lib/db';
import { words, tags, wordTags } from '../src/lib/db/schema';
import { eq, and } from 'drizzle-orm';
interface YAMLWord {
hindi: string;
english: string;
}
interface YAMLSection {
slug: string;
about: string;
words: YAMLWord[];
}
async function restorePageTags() {
const yamlContent = readFileSync('./src/vocab_list.yaml', 'utf-8');
const sections: YAMLSection[] = parse(yamlContent);
// Get all tag IDs
const allTags = await db.select().from(tags);
const tagMap = new Map(allTags.map(t => [t.name.toLowerCase(), t.id]));
let restored = 0;
let notFound = 0;
for (const section of sections) {
console.log(`Processing section: ${section.slug} with ${section.words.length} words`);
const pageTagId = tagMap.get(section.slug.toLowerCase());
if (!pageTagId) {
console.log(`Page tag not found: ${section.slug}`);
continue;
}
console.log(`Found page tag ID: ${pageTagId} for ${section.slug}`);
for (const yamlWord of section.words) {
// Find the word in DB by hindi text
const [dbWord] = await db
.select()
.from(words)
.where(eq(words.hindi, yamlWord.hindi))
.limit(1);
if (!dbWord) {
console.log(`Word not found: ${yamlWord.hindi} (${yamlWord.english})`);
notFound++;
continue;
}
// Check if page tag association already exists
const existing = await db
.select()
.from(wordTags)
.where(and(eq(wordTags.wordId, dbWord.id), eq(wordTags.tagId, pageTagId)))
.limit(1);
if (existing.length === 0) {
await db.insert(wordTags).values({
wordId: dbWord.id,
tagId: pageTagId,
});
restored++;
console.log(`Added ${section.slug} tag to: ${yamlWord.hindi}`);
}
}
}
console.log(`\nRestoration complete!`);
console.log(`Page tags added: ${restored}`);
console.log(`Words not found: ${notFound}`);
}
restorePageTags()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,99 @@
import { readFileSync } from 'fs';
import { parse } from 'yaml';
import { db } from '../src/lib/db';
import { words, tags, wordTags } from '../src/lib/db/schema';
import { eq, and } from 'drizzle-orm';
interface YAMLWord {
hindi: string;
english: string;
type?: string;
gender?: 'm' | 'f';
}
interface YAMLSection {
slug: string;
about: string;
words: YAMLWord[];
}
async function restoreTypeAndGender() {
const yamlContent = readFileSync('./src/vocab_list.yaml', 'utf-8');
const sections: YAMLSection[] = parse(yamlContent);
// Get all tag IDs
const allTags = await db.select().from(tags);
const tagMap = new Map(allTags.map(t => [t.name.toLowerCase(), t.id]));
let restored = 0;
let notFound = 0;
for (const section of sections) {
for (const yamlWord of section.words) {
if (!yamlWord.type && !yamlWord.gender) continue;
// Find the word in DB by hindi text
const [dbWord] = await db
.select()
.from(words)
.where(eq(words.hindi, yamlWord.hindi))
.limit(1);
if (!dbWord) {
console.log(`Word not found: ${yamlWord.hindi} (${yamlWord.english})`);
notFound++;
continue;
}
const tagIdsToAdd: number[] = [];
// Add grammar tag
if (yamlWord.type) {
const grammarTagId = tagMap.get(yamlWord.type.toLowerCase());
if (grammarTagId) {
tagIdsToAdd.push(grammarTagId);
} else {
console.log(`Grammar tag not found: ${yamlWord.type}`);
}
}
// Add gender tag
if (yamlWord.gender) {
const genderName = yamlWord.gender === 'm' ? 'masculine' : 'feminine';
const genderTagId = tagMap.get(genderName);
if (genderTagId) {
tagIdsToAdd.push(genderTagId);
}
}
// Insert word_tags associations
for (const tagId of tagIdsToAdd) {
// Check if already exists
const existing = await db
.select()
.from(wordTags)
.where(and(eq(wordTags.wordId, dbWord.id), eq(wordTags.tagId, tagId)))
.limit(1);
if (existing.length === 0) {
await db.insert(wordTags).values({
wordId: dbWord.id,
tagId: tagId,
});
restored++;
}
}
}
}
console.log(`\nRestoration complete!`);
console.log(`Tags added: ${restored}`);
console.log(`Words not found: ${notFound}`);
}
restoreTypeAndGender()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -81,36 +81,6 @@ export default function FlashCardActivity() {
return ( return (
<div className="flash-card-activity"> <div className="flash-card-activity">
<div className="filters">
<div className="filter-group">
<h3>Tags</h3>
{tags.map(tag => (
<label key={tag.id} className="filter-checkbox">
<input
type="checkbox"
checked={selectedTagIds.includes(tag.id)}
onChange={() => toggleTag(tag.id)}
/>
{tag.name}
</label>
))}
</div>
<div className="filter-group">
<h3>Word Types</h3>
{['noun', 'verb', 'adjective', 'adverb', 'pronoun', 'conjunction', 'preposition', 'interjection'].map(type => (
<label key={type} className="filter-checkbox">
<input
type="checkbox"
checked={selectedTypes.includes(type)}
onChange={() => toggleType(type)}
/>
{type.charAt(0).toUpperCase() + type.slice(1)}
</label>
))}
</div>
</div>
{currentWord && ( {currentWord && (
<FlashCard word={currentWord} /> <FlashCard word={currentWord} />
)} )}

View File

@ -6,8 +6,9 @@ import MobileTableOfContents from "@astrojs/starlight/components/MobileTableOfCo
const isLearningPage = Astro.locals.starlightRoute.id === "learn"; const isLearningPage = Astro.locals.starlightRoute.id === "learn";
import { storage } from "@/lib/storage"; import { storage } from "@/lib/storage";
import { titlecase, typeToTitle } from "@/lib/utils";
const categories = await storage.getAllTags(); const categories = await storage.getPageTags();
const types = await storage.getAllTypes(); const types = await storage.getAllTypes();
const words = await storage.getAllWords(); const words = await storage.getAllWords();
--- ---
@ -20,7 +21,7 @@ const words = await storage.getAllWords();
<div class="sl-container"> <div class="sl-container">
<div class="filters"> <div class="filters">
<div class="category-filters"> <div class="category-filters">
<h2>Categoriezs</h2> <h2>Categories</h2>
{/* Check boxes for each category */} {/* Check boxes for each category */}
{categories.map((category) => ( {categories.map((category) => (
<div class="filter"> <div class="filter">
@ -32,7 +33,7 @@ const words = await storage.getAllWords();
value={category.id} value={category.id}
checked checked
/> />
<label for={category.name}>{category.name}</label> <label for={category.name}>{titlecase(category.name)}</label>
</div> </div>
))} ))}
</div> </div>
@ -49,7 +50,7 @@ const words = await storage.getAllWords();
value={type.toString()} value={type.toString()}
checked checked
/> />
<label for={type}>{type}</label> <label for={type}>{typeToTitle(type)}</label>
</div> </div>
))} ))}
</div> </div>

View File

@ -7,7 +7,7 @@ import MobileMenuFooter from "virtual:starlight/components/MobileMenuFooter";
import { storage } from "../lib/storage"; import { storage } from "../lib/storage";
import { titlecase } from "@/lib/utils"; import { titlecase } from "@/lib/utils";
const tags = await storage.getAllTags(); const tags = await storage.getPageTags();
const { sidebar: astroSidebar } = Astro.locals.starlightRoute; const { sidebar: astroSidebar } = Astro.locals.starlightRoute;
const tagsFromApi = { const tagsFromApi = {

View File

@ -7,10 +7,14 @@ import type { Tag, VocabWord } from "@/types/types";
const { word } = Astro.props as { word: VocabWord }; const { word } = Astro.props as { word: VocabWord };
const activeTag = Astro.params.tag; const activeTag = Astro.params.tag;
const gender_lookup: Record<"m" | "f", ["note" | "tip", string]> = { const gender_lookup: Record<string, ["note" | "tip", string]> = {
m: ["note", "masculine"], "Masculine": ["note", "masculine"],
f: ["tip", "feminine"], "Feminine": ["tip", "feminine"],
}; };
// Extract tags by level
const genderTag = word.tags?.find(t => t.level === 'gender');
const pageTags = word.tags?.filter(t => t.level === 'page' && t.name !== activeTag && t.name !== 'general') || [];
--- ---
<li id={word.hindi} class="word-entry"> <li id={word.hindi} class="word-entry">
@ -25,26 +29,17 @@ const gender_lookup: Record<"m" | "f", ["note" | "tip", string]> = {
>{word.english}</span >{word.english}</span
> >
{ {
word.gender && ( genderTag && (
<Badge <Badge
variant={gender_lookup[word.gender][0]} variant={gender_lookup[genderTag.name][0]}
text={gender_lookup[word.gender][1]} text={gender_lookup[genderTag.name][1]}
class="gender-badge" class="gender-badge"
/> />
) )
} }
{ {
word.tags && pageTags.map((tag: Tag) => (
word.tags <a class="badge-link" href={`/vocabulary/${tag.name}`}>
.filter((tag: Tag) => {
return !(
activeTag ||
tag.name == activeTag ||
tag.name == "general"
);
})
.map((tag: Tag) => (
<a class="badge-link" href="/vocabulary/{tag.name}">
<Badge text={tag.name} class="tag-badge" /> <Badge text={tag.name} class="tag-badge" />
</a> </a>
)) ))

View File

@ -1,12 +1,10 @@
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
export const words = pgTable('words', { export const words = pgTable('words', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
hindi: text('hindi').notNull(), hindi: text('hindi').notNull(),
english: text('english').notNull(), english: text('english').notNull(),
type: text('type').notNull(), // noun, verb, adjective, etc.
gender: text('gender'), // m, f, or null
note: text('note'), note: text('note'),
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(),
@ -24,6 +22,8 @@ export const tags = pgTable('tags', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
description: text('description'), description: text('description'),
level: text('level').notNull(), // 'page', 'subheading', 'grammar', 'gender'
parentPageId: integer('parent_page_id'), // For subheadings, references the parent page tag (FK constraint in migration)
}); });
export const wordTags = pgTable('word_tags', { export const wordTags = pgTable('word_tags', {

View File

@ -1,7 +1,7 @@
import { db } from "./db"; import { db } from "./db";
import { words, examples, tags, wordTags, seeAlso } from "./db/schema"; import { words, examples, tags, wordTags, seeAlso } from "./db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Word, Tag, NewWord } from "@/types/types"; import type { Word, Tag, NewWord, TagLevel } from "@/types/types";
class PGStorage { class PGStorage {
async getAllWords(): Promise<Word[]> { async getAllWords(): Promise<Word[]> {
@ -21,11 +21,15 @@ class PGStorage {
id: word.id, id: word.id,
hindi: word.hindi, hindi: word.hindi,
english: word.english, english: word.english,
type: word.type,
gender: word.gender as "m" | "f" | null,
note: word.note, note: word.note,
examples: word.examples.length > 0 ? word.examples : undefined, examples: word.examples.length > 0 ? word.examples : undefined,
tags: word.tags.map((wt) => wt.tag), tags: word.tags.map((wt) => ({
id: wt.tag.id,
name: wt.tag.name,
description: wt.tag.description,
level: wt.tag.level as TagLevel,
parentPageId: wt.tag.parentPageId,
})),
seeAlso: word.seeAlso.length > 0 ? word.seeAlso : undefined, seeAlso: word.seeAlso.length > 0 ? word.seeAlso : undefined,
})); }));
} }
@ -50,11 +54,15 @@ class PGStorage {
id: word.id, id: word.id,
hindi: word.hindi, hindi: word.hindi,
english: word.english, english: word.english,
type: word.type,
gender: word.gender as "m" | "f" | null,
note: word.note, note: word.note,
examples: word.examples.length > 0 ? word.examples : undefined, examples: word.examples.length > 0 ? word.examples : undefined,
tags: word.tags.map((wt) => wt.tag), tags: word.tags.map((wt) => ({
id: wt.tag.id,
name: wt.tag.name,
description: wt.tag.description,
level: wt.tag.level as TagLevel,
parentPageId: wt.tag.parentPageId,
})),
seeAlso: word.seeAlso.length > 0 ? word.seeAlso : undefined, seeAlso: word.seeAlso.length > 0 ? word.seeAlso : undefined,
}; };
} }
@ -87,11 +95,15 @@ class PGStorage {
id: wt.word.id, id: wt.word.id,
hindi: wt.word.hindi, hindi: wt.word.hindi,
english: wt.word.english, english: wt.word.english,
type: wt.word.type,
gender: wt.word.gender as "m" | "f" | null,
note: wt.word.note, note: wt.word.note,
examples: wt.word.examples.length > 0 ? wt.word.examples : undefined, examples: wt.word.examples.length > 0 ? wt.word.examples : undefined,
tags: wt.word.tags.map((t) => t.tag), tags: wt.word.tags.map((t) => ({
id: t.tag.id,
name: t.tag.name,
description: t.tag.description,
level: t.tag.level as TagLevel,
parentPageId: t.tag.parentPageId,
})),
seeAlso: wt.word.seeAlso.length > 0 ? wt.word.seeAlso.map(sa => ({ seeAlso: wt.word.seeAlso.length > 0 ? wt.word.seeAlso.map(sa => ({
...sa, ...sa,
reference: sa.reference || '', reference: sa.reference || '',
@ -100,16 +112,58 @@ class PGStorage {
} }
async getAllTags(): Promise<Tag[]> { async getAllTags(): Promise<Tag[]> {
return await db.query.tags.findMany(); const dbTags = await db.query.tags.findMany();
return dbTags.map(tag => ({
id: tag.id,
name: tag.name,
description: tag.description,
level: tag.level as TagLevel,
parentPageId: tag.parentPageId,
}));
}
async getPageTags(): Promise<Tag[]> {
const dbTags = await db.query.tags.findMany({
where: eq(tags.level, 'page'),
});
return dbTags.map(tag => ({
id: tag.id,
name: tag.name,
description: tag.description,
level: tag.level as TagLevel,
parentPageId: tag.parentPageId,
}));
}
async getGrammarTags(): Promise<Tag[]> {
const dbTags = await db.query.tags.findMany({
where: eq(tags.level, 'grammar'),
});
return dbTags.map(tag => ({
id: tag.id,
name: tag.name,
description: tag.description,
level: tag.level as TagLevel,
parentPageId: tag.parentPageId,
}));
}
async getGenderTags(): Promise<Tag[]> {
const dbTags = await db.query.tags.findMany({
where: eq(tags.level, 'gender'),
});
return dbTags.map(tag => ({
id: tag.id,
name: tag.name,
description: tag.description,
level: tag.level as TagLevel,
parentPageId: tag.parentPageId,
}));
} }
async getAllTypes(): Promise<string[]> { async getAllTypes(): Promise<string[]> {
const types = new Set<string>(); const grammarTags = await this.getGrammarTags();
const allWords = await this.getAllWords(); return grammarTags.map(t => t.name).sort();
allWords.forEach((word) => {
if (word.type) types.add(word.type);
});
return Array.from(types).sort();
} }
async createWord(newWord: NewWord): Promise<Word> { async createWord(newWord: NewWord): Promise<Word> {
@ -118,8 +172,6 @@ class PGStorage {
.values({ .values({
hindi: newWord.hindi, hindi: newWord.hindi,
english: newWord.english, english: newWord.english,
type: newWord.type,
gender: newWord.gender || null,
note: newWord.note || null, note: newWord.note || null,
}) })
.returning(); .returning();
@ -162,16 +214,24 @@ class PGStorage {
return createdWord!; return createdWord!;
} }
async createTag(name: string, description?: string): Promise<Tag> { async createTag(name: string, level: TagLevel, description?: string, parentPageId?: number): Promise<Tag> {
const [tag] = await db const [tag] = await db
.insert(tags) .insert(tags)
.values({ .values({
name, name,
description: description || null, description: description || null,
level,
parentPageId: parentPageId || undefined,
}) })
.returning(); .returning();
return tag; return {
id: tag.id,
name: tag.name,
description: tag.description,
level: tag.level as TagLevel,
parentPageId: tag.parentPageId,
};
} }
async deleteWord(id: number): Promise<void> { async deleteWord(id: number): Promise<void> {

View File

@ -5,3 +5,10 @@ export function titlecase(str: string) {
return word.charAt(0).toUpperCase() + word.slice(1); return word.charAt(0).toUpperCase() + word.slice(1);
}).join(" "); }).join(" ");
} }
export function typeToTitle(type: string | null | undefined) {
if (!type) return "";
const partsOfSpeech = ["noun", "verb", "adjective", "adverb"];
const maybePlural = partsOfSpeech.includes(type.toLowerCase()) ? "s" : "";
return titlecase(type!) + maybePlural;
}

View File

@ -5,14 +5,7 @@ import VocabWord from "@/components/VocabWord.astro";
import markdownit from "markdown-it"; import markdownit from "markdown-it";
import markdownItMark from "markdown-it-mark"; import markdownItMark from "markdown-it-mark";
import { storage } from "@/lib/storage"; import { storage } from "@/lib/storage";
import { titlecase } from "@/lib/utils"; import { titlecase, typeToTitle } from "@/lib/utils";
function typeToTitle(type: string | null | undefined) {
if (!type) return "";
const partsOfSpeech = ["noun", "verb", "adjective", "adverb"];
const maybePlural = partsOfSpeech.includes(type.toLowerCase()) ? "s" : "";
return titlecase(type!) + maybePlural;
}
const md = markdownit().use(markdownItMark); const md = markdownit().use(markdownItMark);
@ -33,11 +26,43 @@ if (!currentTag) {
const words = await storage.getWordsByTag(tag); const words = await storage.getWordsByTag(tag);
const wordtypes = [...new Set(words.map((word) => word.type).filter(Boolean))]; // Check if this page has subheading tags
const wordsByType = wordtypes.map((type) => ({ const subheadingTags = allTags.filter(t => t.level === 'subheading' && t.parentPageId === currentTag.id);
type: typeToTitle(type),
words: words.filter((word) => word.type === type), let wordsByType;
if (subheadingTags.length > 0) {
// Organize by subheadings (e.g., "Parts of the Body")
wordsByType = subheadingTags.map((subheading) => ({
type: subheading.name,
words: words.filter((word) => word.tags?.some(t => t.id === subheading.id)),
})); }));
} else {
// Organize by grammar tags (part of speech)
const grammarTypes = [...new Set(words.flatMap((word) =>
word.tags?.filter(t => t.level === 'grammar').map(t => t.name) || []
))];
// Include words with grammar tags
const wordsWithGrammar = grammarTypes.map((grammarTagName) => ({
type: typeToTitle(grammarTagName),
words: words.filter((word) => word.tags?.some(t => t.level === 'grammar' && t.name === grammarTagName)),
}));
// Find words WITHOUT any grammar tag
const wordsWithoutGrammar = words.filter((word) =>
!word.tags?.some(t => t.level === 'grammar')
);
// Combine: grammar-organized words + uncategorized words
wordsByType = wordsWithGrammar;
if (wordsWithoutGrammar.length > 0) {
wordsByType.push({
type: "Other",
words: wordsWithoutGrammar,
});
}
}
const headings = wordsByType.flatMap(({ type, words }) => { const headings = wordsByType.flatMap(({ type, words }) => {
return [ return [

View File

@ -6,8 +6,6 @@ export interface Word {
id: number; id: number;
english: string; english: string;
hindi: string; hindi: string;
type: string;
gender?: "m" | "f" | null;
note?: string | null; note?: string | null;
examples?: Example[]; examples?: Example[];
tags?: Tag[]; tags?: Tag[];
@ -21,10 +19,14 @@ export interface Example {
note?: string | null; note?: string | null;
} }
export type TagLevel = 'page' | 'subheading' | 'grammar' | 'gender';
export interface Tag { export interface Tag {
id: number; id: number;
name: string; name: string;
description?: string | null; description?: string | null;
level: TagLevel;
parentPageId?: number | null;
} }
export interface SeeAlso { export interface SeeAlso {
@ -38,11 +40,9 @@ export interface SeeAlso {
export interface NewWord { export interface NewWord {
hindi: string; hindi: string;
english: string; english: string;
type: string;
gender?: "m" | "f";
note?: string; note?: string;
examples?: NewExample[]; examples?: NewExample[];
tagIds?: number[]; tagIds?: number[]; // Now includes grammar and gender tags
seeAlso?: NewSeeAlso[]; seeAlso?: NewSeeAlso[];
} }
@ -55,6 +55,8 @@ export interface NewExample {
export interface NewTag { export interface NewTag {
name: string; name: string;
description?: string; description?: string;
level: TagLevel;
parentPageId?: number;
} }
export interface NewSeeAlso { export interface NewSeeAlso {
@ -75,6 +77,8 @@ export const tagSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
level: z.enum(['page', 'subheading', 'grammar', 'gender']),
parentPageId: z.number().optional().nullable(),
}); });
export const seeAlsoSchema = z.object({ export const seeAlsoSchema = z.object({
@ -87,8 +91,6 @@ export const wordSchema = z.object({
id: z.number(), id: z.number(),
english: z.string(), english: z.string(),
hindi: z.string(), hindi: z.string(),
type: z.string(),
gender: z.enum(["m", "f"]).optional().nullable(),
note: z.string().optional().nullable(), note: z.string().optional().nullable(),
examples: z.array(exampleSchema).optional(), examples: z.array(exampleSchema).optional(),
tags: z.array(tagSchema).optional(), tags: z.array(tagSchema).optional(),