Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules
.env*
!.env.example
.next
certificates
certificates
launchClaude.bat
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,5 @@ We welcome contributions! If you would like to help improve this project, please
5. Open a Pull Request.

# Origins

In May 2025, F3 Nation announced a Hackathon to build a new app to host the Nation's exicon and lexicon. 7 High Impact Men answered that call. Of the submissions made the Nation SLT chose [Roma](https://github.com/victorSauceda) as the winner. Over the following months he worked with the Nation's IT group to incorporate it into the central database and tech stack. Thanks to his inspiration and effort, and contributions of the F3 dev community, the men of F3 Nation now have a living and breathing repository of our culture's language.
24 changes: 16 additions & 8 deletions migrations/1762262413053_add-performance-indexes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ exports.shorthands = undefined;
exports.up = (pgm) => {
console.log("[MIGRATION_LOG] Starting migration: add_performance_indexes UP");

// Add index on target_entry_id for entry_references table (in codex schema)
// This optimizes queries that join on target_entry_id (e.g., finding entries that reference a given entry)
// Add index on target_entry_id for entry_references table (in codex schema, if it exists)
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_entry_references_target_entry_id
ON codex.entry_references(target_entry_id);
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'codex' AND tablename = 'entry_references') THEN
CREATE INDEX IF NOT EXISTS idx_entry_references_target_entry_id
ON codex.entry_references(target_entry_id);
END IF;
END $$;
`);
Comment thread
abereanone marked this conversation as resolved.

// Also create in public schema if it exists there
Expand All @@ -29,11 +33,15 @@ exports.up = (pgm) => {
END $$;
`);

// Add index on tag_id for entry_tags table (in codex schema)
// This optimizes queries that join on tag_id (e.g., finding all entries with a specific tag)
// Add index on tag_id for entry_tags table (in codex schema, if it exists)
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_entry_tags_tag_id
ON codex.entry_tags(tag_id);
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'codex' AND tablename = 'entry_tags') THEN
CREATE INDEX IF NOT EXISTS idx_entry_tags_tag_id
ON codex.entry_tags(tag_id);
END IF;
END $$;
`);

// Also create in public schema if it exists there
Expand Down
3 changes: 2 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const nextConfig: NextConfig = {
headers: [
{
key: "Permissions-Policy",
value: "clipboard-write=(self \"https://f3nation.com\" \"https://www.f3nation.com\")",
value:
'clipboard-write=(self "https://f3nation.com" "https://www.f3nation.com")',
},
],
},
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"db:migrate": "tsx scripts/db-migrate.ts up",
"db:migrate:up": "tsx scripts/db-migrate.ts up",
"db:migrate:down": "tsx scripts/db-migrate.ts down",
"db:migrate:redo": "tsx scripts/db-migrate.ts redo"
"db:migrate:redo": "tsx scripts/db-migrate.ts redo",
"seed": "tsx scripts/import-csv.ts"
},
"dependencies": {
"@genkit-ai/googleai": "^1.8.0",
Expand Down Expand Up @@ -77,6 +78,7 @@
"lucide-react": "^0.475.0",
"next": "^15.2.6",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"patch-package": "^8.0.0",
"pg": "^8.16.3",
"psql": "^0.0.1",
Expand Down
220 changes: 220 additions & 0 deletions scripts/import-csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import pg from "pg";

dotenv.config();

const { Client } = pg;

const KNOWN_TAGS = [
// Multi-word tags first so they match before their component words
"Full Body",
"Warm-Up",
"Arms",
"Legs",
"Core",
"Cardio",
"Partner",
"Coupon",
"Music",
"Mosey",
"Static",
"Strength",
"AMRAP",
"EMOM",
"Reps",
"Timed",
"Distance",
"Routine",
"Run",
"Mary",
];

function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let i = 0;
const n = text.length;

while (i < n) {
const row: string[] = [];

while (i < n && text[i] !== "\n" && text[i] !== "\r") {
let field = "";
if (text[i] === '"') {
i++;
while (i < n) {
if (text[i] === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
} else {
i++;
break;
}
} else {
field += text[i++];
}
}
} else {
while (
i < n &&
text[i] !== "," &&
text[i] !== "\n" &&
text[i] !== "\r"
) {
field += text[i++];
}
}
row.push(field);
if (text[i] === ",") i++;
}

if (text[i] === "\r") i++;
if (text[i] === "\n") i++;

if (row.length > 0 && !(row.length === 1 && row[0] === "")) {
rows.push(row);
}
}

return rows;
}

function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

function stripTitlePrefix(title: string, text: string): string {
if (text.startsWith(title)) {
return text.slice(title.length).trimStart();
}
return text;
}

function extractTagsFromEnd(text: string): {
definition: string;
tags: string[];
} {
const found: string[] = [];
let remaining = text.trimEnd();

let changed = true;
while (changed) {
changed = false;
for (const tag of KNOWN_TAGS) {
if (remaining.endsWith(tag)) {
found.unshift(tag);
remaining = remaining.slice(0, -tag.length).trimEnd();
changed = true;
}
}
}

return { definition: remaining, tags: found };
}

async function main() {
const client = new Client({ connectionString: process.env.DATABASE_URL });
Comment thread
abereanone marked this conversation as resolved.
await client.connect();

try {
const tagsResult = await client.query<{ id: string; name: string }>(
"SELECT id, name FROM tags",
);
const tagMap = new Map<string, string>();
for (const row of tagsResult.rows) {
tagMap.set(row.name, row.id);
}

let inserted = 0;
let failed = 0;

// --- Exicon ---
const exiconText = fs.readFileSync(
path.resolve(process.cwd(), "exicon.csv"),
"utf-8",
);
const exiconRows = parseCSV(exiconText).slice(1);

for (const row of exiconRows) {
const [title, tagsCol, rawText] = row;
if (!title?.trim() || !rawText?.trim()) continue;

const id = slugify(title.trim());
const withoutTitle = stripTitlePrefix(title.trim(), rawText.trim());
const { definition, tags: endTags } = extractTagsFromEnd(withoutTitle);

const csvTags = tagsCol
? tagsCol.split(/[,\s]+/).filter((t) => t.length > 0)
: [];
const allTagNames = [...new Set([...endTags, ...csvTags])];

try {
await client.query(
`INSERT INTO entries (id, title, definition, type, aliases)
VALUES ($1, $2, $3, 'exicon', '[]')
ON CONFLICT (id) DO NOTHING`,
[id, title.trim(), definition],
);

for (const tagName of allTagNames) {
const tagId = tagMap.get(tagName);
if (tagId) {
await client.query(
`INSERT INTO entry_tags (entry_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, tagId],
);
}
}
inserted++;
} catch (err) {
console.error(`Failed to insert exicon "${title}":`, err);
failed++;
}
}

console.log(`Exicon: inserted ${inserted}, failed ${failed}`);
inserted = 0;
failed = 0;

// --- Lexicon ---
const lexiconText = fs.readFileSync(
path.resolve(process.cwd(), "lexicon.csv"),
"utf-8",
);
const lexiconRows = parseCSV(lexiconText).slice(1);

for (const row of lexiconRows) {
const [title, text] = row;
if (!title?.trim() || !text?.trim()) continue;

const id = `lex-${slugify(title.trim())}`;

try {
await client.query(
`INSERT INTO entries (id, title, definition, type, aliases)
VALUES ($1, $2, $3, 'lexicon', '[]')
ON CONFLICT (id) DO NOTHING`,
[id, title.trim(), text.trim()],
);
inserted++;
} catch (err) {
console.error(`Failed to insert lexicon "${title}":`, err);
failed++;
}
}

console.log(`Lexicon: inserted ${inserted}, failed ${failed}`);
} finally {
await client.end();
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading