You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

157 lines
5.2 KiB

/**
* Shared Nostr utility functions
* Used across web-app, CLI, and API to ensure consistency
*/
import type { NostrEvent, NostrFilter } from '../types/nostr.js';
import { KIND } from '../types/nostr.js';
/**
* Extract clone URLs from a NIP-34 repo announcement event
*
* This is a shared utility to avoid code duplication across:
* - RepoManager (with URL normalization)
* - Git API endpoint (for performance, without normalization)
* - RepoPollingService
*
* @param event - The Nostr repository announcement event
* @param normalize - Whether to normalize URLs (add .git suffix if needed). Default: false
* @returns Array of clone URLs
*/
export function extractCloneUrls(event: NostrEvent, normalize: boolean = false): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
if (normalize) {
urls.push(normalizeCloneUrl(url));
} else {
urls.push(url);
}
}
}
}
}
return urls;
}
/**
* Normalize a clone URL to ensure it's cloneable
* Adds .git suffix to HTTPS/HTTP URLs that don't have it
* Handles Gitea URLs that might be missing .git extension
*/
export function normalizeCloneUrl(url: string): string {
// Remove trailing slash
url = url.trim().replace(/\/$/, '');
// For HTTPS/HTTP URLs that don't end in .git, check if they're Gitea/GitHub/GitLab style
// Pattern: https://domain.com/owner/repo (without .git)
if ((url.startsWith('https://') || url.startsWith('http://')) && !url.endsWith('.git')) {
// Check if it looks like a git hosting service URL (has at least 2 path segments)
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(p => p);
// If it has 2+ path segments (e.g., /owner/repo), add .git
if (pathParts.length >= 2) {
// Check if it's not already a file or has an extension
const lastPart = pathParts[pathParts.length - 1];
if (!lastPart.includes('.')) {
return `${url}.git`;
}
}
} catch {
// URL parsing failed, return original
}
}
return url;
}
/**
* Fetch repository announcements by author with caching (case-insensitive)
* This helper function provides consistent caching behavior across all endpoints
*
* @param nostrClient - The Nostr client to use for fetching
* @param authorPubkey - The author's pubkey (hex)
* @param eventCache - The event cache instance (optional, will import if not provided)
* @returns Promise resolving to all announcements by the author
*/
export async function fetchRepoAnnouncementsWithCache(
nostrClient: { fetchEvents: (filters: NostrFilter[]) => Promise<NostrEvent[]> },
authorPubkey: string,
eventCache?: { get: (filters: NostrFilter[]) => NostrEvent[] | null; set: (filters: NostrFilter[], events: NostrEvent[]) => void } | null
): Promise<NostrEvent[]> {
const filters: NostrFilter[] = [
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [authorPubkey],
limit: 100 // Fetch more to allow case-insensitive filtering
}
];
// Lazy import eventCache if not provided (for server-side usage)
let cache = eventCache;
if (!cache) {
try {
const cacheModule = await import('../services/nostr/event-cache.js');
cache = cacheModule.eventCache;
} catch {
// Cache not available, skip caching
cache = null;
}
}
// Check cache first
if (cache) {
const cachedEvents = cache.get(filters);
if (cachedEvents && cachedEvents.length > 0) {
// Return cached events immediately, fetch fresh in background
nostrClient.fetchEvents(filters).then(freshEvents => {
// Merge fresh events with cached ones (deduplicate by event ID)
const eventMap = new Map<string, NostrEvent>();
cachedEvents.forEach(e => eventMap.set(e.id, e));
freshEvents.forEach(e => {
const existing = eventMap.get(e.id);
if (!existing || e.created_at > existing.created_at) {
eventMap.set(e.id, e);
}
});
const mergedEvents = Array.from(eventMap.values());
cache!.set(filters, mergedEvents);
}).catch(() => {
// Ignore background fetch errors
});
return cachedEvents;
}
}
// No cache, fetch from relays
const freshEvents = await nostrClient.fetchEvents(filters);
// Cache the results
if (cache && freshEvents.length > 0) {
cache.set(filters, freshEvents);
}
return freshEvents;
}
/**
* Find a repository announcement by repo name (case-insensitive)
*
* @param events - Array of announcement events
* @param repoName - The repository name to find
* @returns The matching announcement event or null
*/
export function findRepoAnnouncement(events: NostrEvent[], repoName: string): NostrEvent | null {
const repoLower = repoName.toLowerCase();
const matching = events.filter(event => {
const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1];
return dTag && dTag.toLowerCase() === repoLower;
});
return matching.length > 0 ? matching[0] : null;
}