Files
awesome-app/lib/db.ts
2025-10-26 15:39:50 +01:00

336 lines
7.6 KiB
TypeScript

import Database from 'better-sqlite3'
import { join } from 'path'
import { existsSync } from 'fs'
// Database path - using user's .awesome directory
const DB_PATH = process.env.AWESOME_DB_PATH || join(process.env.HOME || '', '.awesome', 'awesome.db')
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
// Check if database file exists before trying to open it
if (!existsSync(DB_PATH)) {
throw new Error(`Database file not found at ${DB_PATH}`)
}
db = new Database(DB_PATH, { readonly: true })
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL')
}
return db
}
export interface AwesomeList {
id: number
name: string
url: string
description: string | null
category: string | null
stars: number | null
forks: number | null
last_commit: string | null
level: number | null
parent_id: number | null
added_at: string | null
last_updated: string | null
}
export interface Repository {
id: number
awesome_list_id: number
name: string
url: string
description: string | null
stars: number | null
forks: number | null
watchers: number | null
language: string | null
topics: string | null
last_commit: string | null
created_at: string | null
added_at: string | null
}
export interface Readme {
id: number
repository_id: number
content: string | null
raw_content: string | null
version_hash: string | null
indexed_at: string | null
}
export interface SearchResult {
repository_id: number
repository_name: string
repository_url: string
description: string | null
stars: number | null
language: string | null
topics: string | null
awesome_list_name: string | null
awesome_list_category: string | null
rank: number
snippet: string | null
}
export interface SearchOptions {
query: string
limit?: number
offset?: number
language?: string
minStars?: number
category?: string
sortBy?: 'relevance' | 'stars' | 'recent'
}
export interface PaginatedResults<T> {
results: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/**
* Full-text search using FTS5
*/
export function searchRepositories(options: SearchOptions): PaginatedResults<SearchResult> {
const db = getDb()
const {
query,
limit = 20,
offset = 0,
language,
minStars,
category,
sortBy = 'relevance'
} = options
// Build FTS query
const ftsQuery = query
.trim()
.split(/\s+/)
.map(term => `"${term}"*`)
.join(' OR ')
let sql = `
SELECT
r.id as repository_id,
r.name as repository_name,
r.url as repository_url,
r.description,
r.stars,
r.language,
r.topics,
al.name as awesome_list_name,
al.category as awesome_list_category,
fts.rank,
snippet(readmes_fts, 2, '<mark>', '</mark>', '...', 32) as snippet
FROM readmes_fts fts
JOIN repositories r ON fts.rowid = r.id
LEFT JOIN awesome_lists al ON r.awesome_list_id = al.id
WHERE readmes_fts MATCH ?
`
const params: any[] = [ftsQuery]
// Add filters
if (language) {
sql += ` AND r.language = ?`
params.push(language)
}
if (minStars !== undefined) {
sql += ` AND r.stars >= ?`
params.push(minStars)
}
if (category) {
sql += ` AND al.category = ?`
params.push(category)
}
// Add sorting
switch (sortBy) {
case 'stars':
sql += ` ORDER BY r.stars DESC, fts.rank`
break
case 'recent':
sql += ` ORDER BY r.last_commit DESC, fts.rank`
break
default:
sql += ` ORDER BY fts.rank`
}
// Count total results (use [\s\S]*? for multiline matching)
const countSql = sql.replace(/SELECT[\s\S]*?FROM/, 'SELECT COUNT(*) as total FROM')
.replace(/ORDER BY[\s\S]*$/, '')
const totalResult = db.prepare(countSql).get(...params) as { total: number }
const total = totalResult.total
// Add pagination
sql += ` LIMIT ? OFFSET ?`
params.push(limit, offset)
const results = db.prepare(sql).all(...params) as SearchResult[]
const page = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(total / limit)
return {
results,
total,
page,
pageSize: limit,
totalPages
}
}
/**
* Get all awesome lists with optional category filter
*/
export function getAwesomeLists(category?: string): AwesomeList[] {
const db = getDb()
let sql = `
SELECT * FROM awesome_lists
WHERE 1=1
`
const params: any[] = []
if (category) {
sql += ` AND category = ?`
params.push(category)
}
sql += ` ORDER BY stars DESC, name ASC`
return db.prepare(sql).all(...params) as AwesomeList[]
}
/**
* Get repositories by awesome list ID
*/
export function getRepositoriesByList(listId: number, limit = 50, offset = 0): PaginatedResults<Repository> {
const db = getDb()
const countSql = `SELECT COUNT(*) as total FROM repositories WHERE awesome_list_id = ?`
const totalResult = db.prepare(countSql).get(listId) as { total: number }
const total = totalResult.total
const sql = `
SELECT * FROM repositories
WHERE awesome_list_id = ?
ORDER BY stars DESC, name ASC
LIMIT ? OFFSET ?
`
const results = db.prepare(sql).all(listId, limit, offset) as Repository[]
const page = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(total / limit)
return {
results,
total,
page,
pageSize: limit,
totalPages
}
}
/**
* Get repository by ID with README content
*/
export function getRepositoryWithReadme(repositoryId: number): (Repository & { readme: Readme | null }) | null {
const db = getDb()
const repo = db.prepare(`
SELECT * FROM repositories WHERE id = ?
`).get(repositoryId) as Repository | undefined
if (!repo) {
return null
}
const readme = db.prepare(`
SELECT raw_content as content FROM readmes WHERE repository_id = ?
`).get(repositoryId) as Readme | undefined
return {
...repo,
readme: readme || null
}
}
/**
* Get all unique categories
*/
export function getCategories(): { name: string; count: number }[] {
const db = getDb()
return db.prepare(`
SELECT
category as name,
COUNT(*) as count
FROM awesome_lists
WHERE category IS NOT NULL
GROUP BY category
ORDER BY count DESC, category ASC
`).all() as { name: string; count: number }[]
}
/**
* Get all unique languages
*/
export function getLanguages(): { name: string; count: number }[] {
const db = getDb()
return db.prepare(`
SELECT
language as name,
COUNT(*) as count
FROM repositories
WHERE language IS NOT NULL
GROUP BY language
ORDER BY count DESC, language ASC
LIMIT 50
`).all() as { name: string; count: number }[]
}
/**
* Get database statistics
*/
export function getStats() {
const db = getDb()
const stats = {
lists: db.prepare('SELECT COUNT(*) as count FROM awesome_lists').get() as { count: number },
repositories: db.prepare('SELECT COUNT(*) as count FROM repositories').get() as { count: number },
readmes: db.prepare('SELECT COUNT(*) as count FROM readmes').get() as { count: number },
lastUpdated: db.prepare('SELECT MAX(last_updated) as date FROM awesome_lists').get() as { date: string | null }
}
return {
totalLists: stats.lists.count,
totalRepositories: stats.repositories.count,
totalReadmes: stats.readmes.count,
lastUpdated: stats.lastUpdated.date
}
}
/**
* Get trending repositories (most stars)
*/
export function getTrendingRepositories(limit = 10): Repository[] {
const db = getDb()
return db.prepare(`
SELECT * FROM repositories
WHERE stars IS NOT NULL
ORDER BY stars DESC
LIMIT ?
`).all(limit) as Repository[]
}