diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 941cbab..b33ea56 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -94,3 +94,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772104036,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"830b91f4efe7d208128a008d44fd3b4352c09af0a83b40ea1fab769f9c8563cf","sig":"49a9772580d5ba1b9b9800bdb53f0f4b55661f6062f9968b18cbbd4983d7a042b477281769488d44b4f43c7bdf627d621d83c16659d3d8d226fb32fe0a450756"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772105581,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fdix build"]],"content":"Signed commit: fdix build","id":"aa457cd97e3af5c7e7e6f8938d159f62de2eee27afcf9a9a415192a8b39cd038","sig":"1959bae547fefff3b3fd72e23071e989724ab71f2042bad9cb5a969133045119a068b529df17ded13db96b54372f662760df79a34f1b6072dcabf5d2f003000b"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772106804,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 3"]],"content":"Signed commit: refactor 3","id":"a761c789227ef2368eff89f7062fa7889820c4846701667360978cfdad08c3d2","sig":"9d229200ab66d3f4a0a2a21112c9100ee14d0a5d9f8409a35fef36f195f5f73c8ac2344aa1175cc476f650336a5a10ea6ac0076c8ec2cb229fea7d600c5d4399"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772107667,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"2a8db19aff5126547a397f7daf9121f711a3d61efcced642b496687d9afc48dc","sig":"7e0558fac1764e185b3f52450f5a34805b04342bdb0821b4d459b1627d057d7e2af397b3263a8831e9be2e615556ef09094bce808c22f6049261273004da74bc"} diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 9a9206a..bbc0849 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -47,7 +47,6 @@ // Note: Announcements are now stored in nostr/repo-events.jsonl, not .nostr-announcement import type { NostrEvent } from '$lib/types/nostr.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; - import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; import { createRepoState, type RepoState } from './stores/repo-state.js'; import { usePageDataEffect, @@ -61,6 +60,23 @@ useTabChangeEffect, useBranchChangeEffect } from './hooks/use-repo-effects.js'; + import { + getHighlightLanguage, + supportsPreview, + isImageFileType, + renderCsvAsTable, + escapeHtml, + applySyntaxHighlighting as applySyntaxHighlightingUtil, + renderFileAsHtml as renderFileAsHtmlUtil + } from './utils/file-processing.js'; + import { + parseNostrLinks + } from './utils/nostr-links.js'; + // formatDiscussionTime is defined locally (slightly different format than utility version) + import { + getUserEmail as getUserEmailUtil, + getUserName as getUserNameUtil + } from './utils/user-profile.js'; // Consolidated state - all state variables in one object let state = $state(createRepoState()); @@ -292,34 +308,7 @@ const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); - // Parse nostr: links from content and extract IDs/pubkeys - function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { - const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; - const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; - let match; - - while ((match = nostrLinkRegex.exec(content)) !== null) { - const fullMatch = match[0]; - const prefix = match[1]; - let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; - - if (prefix === 'nevent1') type = 'nevent'; - else if (prefix === 'naddr1') type = 'naddr'; - else if (prefix === 'note1') type = 'note1'; - else if (prefix === 'npub1') type = 'npub'; - else if (prefix === 'profile1') type = 'profile'; - else continue; - - links.push({ - type, - value: fullMatch, - start: match.index, - end: match.index + fullMatch.length - }); - } - - return links; - } + // parseNostrLinks is now imported from utils/nostr-links.ts // Load events/profiles from nostr: links async function loadNostrLinks(content: string) { @@ -398,8 +387,8 @@ } } - // Get event from nostr: link - function getEventFromNostrLink(link: string): NostrEvent | undefined { + // Get event from nostr: link (local version that uses state) + function getEventFromNostrLinkLocal(link: string): NostrEvent | undefined { try { if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { const decoded = nip19.decode(link.replace('nostr:', '')); @@ -426,12 +415,12 @@ return undefined; } - // Get pubkey from nostr: npub/profile link - function getPubkeyFromNostrLink(link: string): string | undefined { + // Get pubkey from nostr: npub/profile link (local version that uses state) + function getPubkeyFromNostrLinkLocal(link: string): string | undefined { return state.discussion.nostrLinkProfiles.get(link); } - // Process content with nostr links into parts for rendering + // Process content with nostr links into parts for rendering (local version that uses state) function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { const links = parseNostrLinks(content); if (links.length === 0) { @@ -451,8 +440,8 @@ } // Add link - const event = getEventFromNostrLink(link.value); - const pubkey = getPubkeyFromNostrLink(link.value); + const event = getEventFromNostrLinkLocal(link.value); + const pubkey = getPubkeyFromNostrLinkLocal(link.value); if (event) { parts.push({ type: 'event', value: link.value, event }); } else if (pubkey) { @@ -863,412 +852,21 @@ } } - // Map file extensions to highlight.js language names - function getHighlightLanguage(ext: string): string { - const langMap: Record = { - 'js': 'javascript', - 'ts': 'typescript', - 'jsx': 'javascript', - 'tsx': 'typescript', - 'json': 'json', - 'css': 'css', - 'html': 'xml', - 'xml': 'xml', - 'yaml': 'yaml', - 'yml': 'yaml', - 'py': 'python', - 'rb': 'ruby', - 'go': 'go', - 'rs': 'rust', - 'java': 'java', - 'c': 'c', - 'cpp': 'cpp', - 'h': 'c', - 'hpp': 'cpp', - 'sh': 'bash', - 'bash': 'bash', - 'zsh': 'bash', - 'sql': 'sql', - 'php': 'php', - 'swift': 'swift', - 'kt': 'kotlin', - 'scala': 'scala', - 'r': 'r', - 'm': 'objectivec', - 'mm': 'objectivec', - 'vue': 'xml', - 'svelte': 'xml', - 'dockerfile': 'dockerfile', - 'toml': 'toml', - 'ini': 'ini', - 'conf': 'ini', - 'log': 'plaintext', - 'txt': 'plaintext', - 'md': 'markdown', - 'markdown': 'markdown', - 'mdown': 'markdown', - 'mkdn': 'markdown', - 'mkd': 'markdown', - 'mdwn': 'markdown', - 'adoc': 'asciidoc', - 'asciidoc': 'asciidoc', - 'ad': 'asciidoc', - }; - return langMap[ext.toLowerCase()] || 'plaintext'; - } - - // Check if file type supports preview mode - function supportsPreview(ext: string): boolean { - const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv']; - return previewExtensions.includes(ext.toLowerCase()); - } - - // Check if a file is an image based on extension - function isImageFileType(ext: string): boolean { - const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'apng', 'avif']; - return imageExtensions.includes(ext.toLowerCase()); - } + // File processing utilities are now imported from utils/file-processing.ts // Render markdown, asciidoc, or HTML files as HTML async function renderFileAsHtml(content: string, ext: string) { - try { - const lowerExt = ext.toLowerCase(); - - if (lowerExt === 'md' || lowerExt === 'markdown') { - // Render markdown - const MarkdownIt = (await import('markdown-it')).default; - const hljsModule = await import('highlight.js'); - const hljs = hljsModule.default || hljsModule; - - const md = new MarkdownIt({ - html: true, - linkify: true, - typographer: true, - breaks: true, - highlight: function (str: string, lang: string): string { - if (lang && hljs.getLanguage(lang)) { - try { - return hljs.highlight(str, { language: lang }).value; - } catch (__) {} - } - try { - return hljs.highlightAuto(str).value; - } catch (__) {} - return ''; - } - }); - - let rendered = md.render(content); - // Rewrite image paths to point to repository API - rendered = rewriteImagePaths(rendered, state.files.currentFile); - state.preview.file.html = rendered; - } else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { - // Render asciidoc - const Asciidoctor = (await import('@asciidoctor/core')).default; - const asciidoctor = Asciidoctor(); - const converted = asciidoctor.convert(content, { - safe: 'safe', - attributes: { - 'source-highlighter': 'highlight.js' - } - }); - let rendered = typeof converted === 'string' ? converted : String(converted); - // Rewrite image paths to point to repository API - rendered = rewriteImagePaths(rendered, state.files.currentFile); - state.preview.file.html = rendered; - } else if (lowerExt === 'html' || lowerExt === 'htm') { - // HTML files - rewrite image paths - let rendered = content; - rendered = rewriteImagePaths(rendered, state.files.currentFile); - state.preview.file.html = rendered; - } else if (lowerExt === 'csv') { - // Parse CSV and render as HTML table - state.preview.file.html = renderCsvAsTable(content); - } - } catch (err) { - console.error('Error rendering file as HTML:', err); - state.preview.file.html = ''; - } - } - - // Parse CSV content and render as HTML table - function renderCsvAsTable(csvContent: string): string { - try { - // Parse CSV - handle quoted fields and escaped quotes - const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== ''); - if (lines.length === 0) { - return '

Empty CSV file

'; - } - - const rows: string[][] = []; - - for (const line of lines) { - const row: string[] = []; - let currentField = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const nextChar = line[i + 1]; - - if (char === '"') { - if (inQuotes && nextChar === '"') { - // Escaped quote - currentField += '"'; - i++; // Skip next quote - } else { - // Toggle quote state - inQuotes = !inQuotes; - } - } else if (char === ',' && !inQuotes) { - // Field separator - row.push(currentField); - currentField = ''; - } else { - currentField += char; - } - } - - // Add the last field - row.push(currentField); - rows.push(row); - } - - if (rows.length === 0) { - return '

No data in CSV file

'; - } - - // Find the maximum number of columns to ensure consistent table structure - const maxColumns = Math.max(...rows.map(row => row.length)); - - // Determine if first row should be treated as header (if it has more than 1 row) - const hasHeader = rows.length > 1; - const headerRow = hasHeader ? rows[0] : null; - const dataRows = hasHeader ? rows.slice(1) : rows; - - // Build HTML table - let html = '
'; - - // Add header row if we have one - if (hasHeader && headerRow) { - html += ''; - for (let i = 0; i < maxColumns; i++) { - const cell = headerRow[i] || ''; - html += ``; - } - html += ''; - } - - // Add data rows - html += ''; - for (const row of dataRows) { - html += ''; - for (let i = 0; i < maxColumns; i++) { - const cell = row[i] || ''; - html += ``; - } - html += ''; - } - html += '
${escapeHtml(cell)}
${escapeHtml(cell)}
'; - - return html; - } catch (err) { - console.error('Error parsing CSV:', err); - return `

Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`; - } + await renderFileAsHtmlUtil(content, ext, state.files.currentFile, (html: string) => { + state.preview.file.html = html; + }); } - // Escape HTML to prevent XSS - function escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return text.replace(/[&<>"']/g, (m) => map[m]); - } + // CSV and HTML utilities are now imported from utils/file-processing.ts async function applySyntaxHighlighting(content: string, ext: string) { - try { - const hljsModule = await import('highlight.js'); - // highlight.js v11+ uses default export - const hljs = hljsModule.default || hljsModule; - const lang = getHighlightLanguage(ext); - - // Register Markdown language if needed (not in highlight.js by default) - if (lang === 'markdown' && !hljs.getLanguage('markdown')) { - hljs.registerLanguage('markdown', function(hljs) { - return { - name: 'Markdown', - aliases: ['md', 'mkdown', 'mkd'], - contains: [ - // Headers - { - className: 'section', - begin: /^#{1,6}\s+/, - relevance: 10 - }, - // Bold - { - className: 'strong', - begin: /\*\*[^*]+\*\*/, - relevance: 0 - }, - { - className: 'strong', - begin: /__[^_]+__/, - relevance: 0 - }, - // Italic - { - className: 'emphasis', - begin: /\*[^*]+\*/, - relevance: 0 - }, - { - className: 'emphasis', - begin: /_[^_]+_/, - relevance: 0 - }, - // Inline code - { - className: 'code', - begin: /`[^`]+`/, - relevance: 0 - }, - // Code blocks - { - className: 'code', - begin: /^```[\w]*/, - end: /^```$/, - contains: [{ begin: /./ }] - }, - // Links - { - className: 'link', - begin: /\[/, - end: /\]/, - contains: [ - { - className: 'string', - begin: /\(/, - end: /\)/ - } - ] - }, - // Images - { - className: 'string', - begin: /!\[/, - end: /\]/ - }, - // Lists - { - className: 'bullet', - begin: /^(\s*)([*+-]|\d+\.)\s+/, - relevance: 0 - }, - // Blockquotes - { - className: 'quote', - begin: /^>\s+/, - relevance: 0 - }, - // Horizontal rules - { - className: 'horizontal_rule', - begin: /^(\*{3,}|-{3,}|_{3,})$/, - relevance: 0 - } - ] - }; - }); - } - - // Register AsciiDoc language if needed (not in highlight.js by default) - if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { - hljs.registerLanguage('asciidoc', function(hljs) { - return { - name: 'AsciiDoc', - aliases: ['adoc', 'asciidoc', 'ad'], - contains: [ - // Headers - { - className: 'section', - begin: /^={1,6}\s+/, - relevance: 10 - }, - // Bold - { - className: 'strong', - begin: /\*\*[^*]+\*\*/, - relevance: 0 - }, - // Italic - { - className: 'emphasis', - begin: /_[^_]+_/, - relevance: 0 - }, - // Inline code - { - className: 'code', - begin: /`[^`]+`/, - relevance: 0 - }, - // Code blocks - { - className: 'code', - begin: /^----+$/, - end: /^----+$/, - contains: [{ begin: /./ }] - }, - // Lists - { - className: 'bullet', - begin: /^(\*+|\.+|-+)\s+/, - relevance: 0 - }, - // Links - { - className: 'link', - begin: /link:/, - end: /\[/, - contains: [{ begin: /\[/, end: /\]/ }] - }, - // Comments - { - className: 'comment', - begin: /^\/\/.*$/, - relevance: 0 - }, - // Attributes - { - className: 'attr', - begin: /^:.*:$/, - relevance: 0 - } - ] - }; - }); - } - - // Apply highlighting - if (lang === 'plaintext') { - state.preview.file.highlightedContent = `
${hljs.highlight(content, { language: 'plaintext' }).value}
`; - } else if (hljs.getLanguage(lang)) { - state.preview.file.highlightedContent = `
${hljs.highlight(content, { language: lang }).value}
`; - } else { - // Fallback to auto-detection - state.preview.file.highlightedContent = `
${hljs.highlightAuto(content).value}
`; - } - } catch (err) { - console.error('Error applying syntax highlighting:', err); - // Fallback to plain text - state.preview.file.highlightedContent = `
${content}
`; - } + await applySyntaxHighlightingUtil(content, ext, (html: string) => { + state.preview.file.highlightedContent = html; + }); } async function loadForkInfo() { @@ -3290,142 +2888,11 @@ let fetchingUserName = false; async function getUserEmail(): Promise { - // Check settings store first - try { - const settings = await settingsStore.getSettings(); - if (settings.userEmail && settings.userEmail.trim()) { - cachedUserEmail = settings.userEmail.trim(); - return cachedUserEmail; - } - } catch (err) { - console.warn('Failed to get userEmail from settings:', err); - } - - // Return cached email if available - if (cachedUserEmail) { - return cachedUserEmail; - } - - // If no user pubkey, can't proceed - if (!state.user.pubkeyHex) { - throw new Error('User not authenticated'); - } - - // Prevent concurrent fetches - if (fetchingUserEmail) { - // Wait a bit and retry (shouldn't happen, but just in case) - await new Promise(resolve => setTimeout(resolve, 100)); - if (cachedUserEmail) { - return cachedUserEmail; - } - } - - fetchingUserEmail = true; - let prefillEmail: string; - - try { - // Fetch from kind 0 event (cache or relays) - prefillEmail = await fetchUserEmail(state.user.pubkeyHex, state.user.pubkey || undefined, DEFAULT_NOSTR_RELAYS); - } catch (err) { - console.warn('Failed to fetch user profile for email:', err); - // Fallback to shortenednpub@gitrepublic.web - const npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown'); - const shortenedNpub = npubFromPubkey.substring(0, 20); - prefillEmail = `${shortenedNpub}@gitrepublic.web`; - } finally { - fetchingUserEmail = false; - } - - // Prompt user for email address - const userEmail = prompt( - 'Please enter your email address for git commits.\n\n' + - 'This will be used as the author email in your commits.\n' + - 'You can use any email address you prefer.', - prefillEmail - ); - - if (userEmail && userEmail.trim()) { - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (emailRegex.test(userEmail.trim())) { - cachedUserEmail = userEmail.trim(); - // Save to settings store - settingsStore.setSetting('userEmail', cachedUserEmail).catch(console.error); - return cachedUserEmail; - } else { - alert('Invalid email format. Using fallback email address.'); - } - } - - // Use fallback if user cancelled or entered invalid email - cachedUserEmail = prefillEmail; - return cachedUserEmail; + return getUserEmailUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName }); } async function getUserName(): Promise { - // Check settings store first - try { - const settings = await settingsStore.getSettings(); - if (settings.userName && settings.userName.trim()) { - cachedUserName = settings.userName.trim(); - return cachedUserName; - } - } catch (err) { - console.warn('Failed to get userName from settings:', err); - } - - // Return cached name if available - if (cachedUserName) { - return cachedUserName; - } - - // If no user pubkey, can't proceed - if (!state.user.pubkeyHex) { - throw new Error('User not authenticated'); - } - - // Prevent concurrent fetches - if (fetchingUserName) { - // Wait a bit and retry (shouldn't happen, but just in case) - await new Promise(resolve => setTimeout(resolve, 100)); - if (cachedUserName) { - return cachedUserName; - } - } - - fetchingUserName = true; - let prefillName: string; - - try { - // Fetch from kind 0 event (cache or relays) - prefillName = await fetchUserName(state.user.pubkeyHex, state.user.pubkey || undefined, DEFAULT_NOSTR_RELAYS); - } catch (err) { - console.warn('Failed to fetch user profile for name:', err); - // Fallback to shortened npub (20 chars) - const npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown'); - prefillName = npubFromPubkey.substring(0, 20); - } finally { - fetchingUserName = false; - } - - // Prompt user for name - const userName = prompt( - 'Please enter your name for git commits.\n\n' + - 'This will be used as the author name in your commits.\n' + - 'You can use any name you prefer.', - prefillName - ); - - if (userName && userName.trim()) { - cachedUserName = userName.trim(); - // Save to settings store - settingsStore.setSetting('userName', cachedUserName).catch(console.error); - return cachedUserName; - } - - // Use fallback if user cancelled - cachedUserName = prefillName; - return cachedUserName; + return getUserNameUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName }); } async function setupAutoSave() { @@ -5478,8 +4945,8 @@ return; } - const authorEmail = await fetchUserEmail(state.user.pubkeyHex || '', state.user.pubkey || undefined); - const authorName = await fetchUserName(state.user.pubkeyHex || '', state.user.pubkey || undefined); + const authorEmail = await getUserEmail(); + const authorName = await getUserName(); const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches/${id}/apply`, { method: 'POST', diff --git a/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts new file mode 100644 index 0000000..334f3cf --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts @@ -0,0 +1,49 @@ +/** + * Discussion utilities + * Handles discussion event processing and formatting + */ + +import type { NostrEvent } from '$lib/types/nostr.js'; +import { KIND } from '$lib/types/nostr.js'; + +/** + * Format discussion timestamp + */ +export function formatDiscussionTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + + return date.toLocaleDateString(); +} + +/** + * Get discussion event by ID + */ +export function getDiscussionEvent(eventId: string, events: Map): NostrEvent | undefined { + return events.get(eventId); +} + +/** + * Get referenced event from discussion + */ +export function getReferencedEventFromDiscussion( + event: NostrEvent, + events: Map +): NostrEvent | undefined { + // Check for 'e' tags (event references) + const eTags = event.tags.filter(t => t[0] === 'e' && t[1]); + if (eTags.length > 0) { + const referencedId = eTags[0][1] as string; + return events.get(referencedId); + } + return undefined; +} diff --git a/src/routes/repos/[npub]/[repo]/utils/file-processing.ts b/src/routes/repos/[npub]/[repo]/utils/file-processing.ts new file mode 100644 index 0000000..74f16c0 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/file-processing.ts @@ -0,0 +1,353 @@ +/** + * File processing utilities + * Handles syntax highlighting, HTML rendering, and file type detection + */ + +// Note: highlight.js, marked, and asciidoctor are imported dynamically in functions + +/** + * Get highlight.js language from file extension + */ +export function getHighlightLanguage(ext: string): string { + const langMap: Record = { + 'js': 'javascript', + 'jsx': 'javascript', + 'ts': 'typescript', + 'tsx': 'typescript', + 'py': 'python', + 'rb': 'ruby', + 'go': 'go', + 'rs': 'rust', + 'java': 'java', + 'c': 'c', + 'cpp': 'cpp', + 'cc': 'cpp', + 'cxx': 'cpp', + 'h': 'c', + 'hpp': 'cpp', + 'hxx': 'cpp', + 'cs': 'csharp', + 'php': 'php', + 'swift': 'swift', + 'kt': 'kotlin', + 'scala': 'scala', + 'clj': 'clojure', + 'sh': 'bash', + 'bash': 'bash', + 'zsh': 'bash', + 'fish': 'bash', + 'ps1': 'powershell', + 'sql': 'sql', + 'html': 'html', + 'htm': 'html', + 'xml': 'xml', + 'css': 'css', + 'scss': 'scss', + 'sass': 'sass', + 'less': 'less', + 'json': 'json', + 'yaml': 'yaml', + 'yml': 'yaml', + 'toml': 'toml', + 'ini': 'ini', + 'conf': 'ini', + 'dockerfile': 'dockerfile', + 'makefile': 'makefile', + 'mk': 'makefile', + 'cmake': 'cmake', + 'r': 'r', + 'R': 'r', + 'm': 'objectivec', + 'mm': 'objectivec', + 'vue': 'xml', + 'svelte': 'xml', + 'graphql': 'graphql', + 'gql': 'graphql', + 'proto': 'protobuf', + 'md': 'markdown', + 'markdown': 'markdown', + 'adoc': 'asciidoc', + 'asciidoc': 'asciidoc', + 'rst': 'restructuredtext', + 'org': 'org', + 'vim': 'vim', + 'lua': 'lua', + 'pl': 'perl', + 'pm': 'perl', + 'tcl': 'tcl', + 'dart': 'dart', + 'elm': 'elm', + 'ex': 'elixir', + 'exs': 'elixir', + 'erl': 'erlang', + 'hrl': 'erlang', + 'fs': 'fsharp', + 'fsx': 'fsharp', + 'fsi': 'fsharp', + 'ml': 'ocaml', + 'mli': 'ocaml', + 'hs': 'haskell', + 'lhs': 'haskell', + 'nim': 'nim', + 'zig': 'zig', + 'cr': 'crystal', + 'jl': 'julia', + 'matlab': 'matlab', + 'tex': 'latex', + 'latex': 'latex', + 'bib': 'bibtex', + 'log': 'plaintext', + 'txt': 'plaintext', + 'diff': 'diff', + 'patch': 'diff' + }; + + return langMap[ext.toLowerCase()] || 'plaintext'; +} + +/** + * Check if file extension supports HTML preview + */ +export function supportsPreview(ext: string): boolean { + const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv']; + return previewExtensions.includes(ext.toLowerCase()); +} + +/** + * Check if file extension is an image type + */ +export function isImageFileType(ext: string): boolean { + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'avif', 'heic', 'heif']; + return imageExtensions.includes(ext.toLowerCase()); +} + +/** + * Rewrite image paths in HTML to be relative to file path + */ +export function rewriteImagePaths(html: string, filePath: string | null): string { + if (!filePath || !html) return html; + + // Get directory path (remove filename) + const dirPath = filePath.split('/').slice(0, -1).join('/'); + const basePath = dirPath ? `/${dirPath}/` : '/'; + + // Rewrite relative image paths + // Match: src="image.png" or src='image.png' or src=image.png + html = html.replace(/src=["']([^"']+)["']/g, (match, path) => { + // Skip absolute URLs and data URLs + if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) { + return match; + } + // Make path relative to file directory + return `src="${basePath}${path}"`; + }); + + return html; +} + +/** + * Render CSV content as HTML table + */ +export function renderCsvAsTable(csvContent: string): string { + const lines = csvContent.split('\n').filter(line => line.trim()); + if (lines.length === 0) return '

Empty CSV file

'; + + // Parse CSV (simple parser - handles basic cases) + const rows: string[][] = []; + for (const line of lines) { + const cells: string[] = []; + let currentCell = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + cells.push(currentCell.trim()); + currentCell = ''; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + rows.push(cells); + } + + if (rows.length === 0) return '

No data in CSV file

'; + + // Generate HTML table + let html = ''; + const headerRow = rows[0]; + for (const cell of headerRow) { + html += ``; + } + html += ''; + + for (let i = 1; i < rows.length; i++) { + html += ''; + for (const cell of rows[i]) { + html += ``; + } + html += ''; + } + + html += '
${escapeHtml(cell)}
${escapeHtml(cell)}
'; + return html; +} + +/** + * Escape HTML special characters + */ +export function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} + +/** + * Apply syntax highlighting to content + */ +export async function applySyntaxHighlighting( + content: string, + ext: string, + setHighlightedContent: (html: string) => void +): Promise { + try { + const hljsModule = await import('highlight.js'); + const hljs = hljsModule.default || hljsModule; + const lang = getHighlightLanguage(ext); + + // Register Markdown language if needed + if (lang === 'markdown' && !hljs.getLanguage('markdown')) { + hljs.registerLanguage('markdown', function(hljs) { + return { + name: 'Markdown', + aliases: ['md', 'mkdown', 'mkd'], + contains: [ + { className: 'section', begin: /^#{1,6}\s+/, relevance: 10 }, + { className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 }, + { className: 'strong', begin: /__[^_]+__/, relevance: 0 }, + { className: 'emphasis', begin: /\*[^*]+\*/, relevance: 0 }, + { className: 'emphasis', begin: /_[^_]+_/, relevance: 0 }, + { className: 'code', begin: /`[^`]+`/, relevance: 0 }, + { className: 'code', begin: /^```[\w]*/, end: /^```$/, contains: [{ begin: /./ }] }, + { className: 'link', begin: /\[/, end: /\]/, contains: [{ className: 'string', begin: /\(/, end: /\)/ }] }, + { className: 'string', begin: /!\[/, end: /\]/ }, + { className: 'bullet', begin: /^(\s*)([*+-]|\d+\.)\s+/, relevance: 0 }, + { className: 'quote', begin: /^>\s+/, relevance: 0 }, + { className: 'horizontal_rule', begin: /^(\*{3,}|-{3,}|_{3,})$/, relevance: 0 } + ] + }; + }); + } + + // Register AsciiDoc language if needed + if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { + hljs.registerLanguage('asciidoc', function(hljs) { + return { + name: 'AsciiDoc', + aliases: ['adoc', 'asciidoc', 'ad'], + contains: [ + { className: 'section', begin: /^={1,6}\s+/, relevance: 10 }, + { className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 }, + { className: 'emphasis', begin: /_[^_]+_/, relevance: 0 }, + { className: 'code', begin: /`[^`]+`/, relevance: 0 }, + { className: 'code', begin: /^----+$/, end: /^----+$/, contains: [{ begin: /./ }] }, + { className: 'bullet', begin: /^(\*+|\.+|-+)\s+/, relevance: 0 }, + { className: 'link', begin: /link:/, end: /\[/, contains: [{ begin: /\[/, end: /\]/ }] }, + { className: 'comment', begin: /^\/\/.*$/, relevance: 0 }, + { className: 'attr', begin: /^:.*:$/, relevance: 0 } + ] + }; + }); + } + + // Apply highlighting + if (lang === 'plaintext') { + setHighlightedContent(`
${hljs.highlight(content, { language: 'plaintext' }).value}
`); + } else if (hljs.getLanguage(lang)) { + setHighlightedContent(`
${hljs.highlight(content, { language: lang }).value}
`); + } else { + setHighlightedContent(`
${hljs.highlightAuto(content).value}
`); + } + } catch (err) { + console.error('Error applying syntax highlighting:', err); + setHighlightedContent(`
${escapeHtml(content)}
`); + } +} + +/** + * Render file content as HTML + */ +export async function renderFileAsHtml( + content: string, + ext: string, + filePath: string | null, + setHtml: (html: string) => void +): Promise { + try { + const lowerExt = ext.toLowerCase(); + + if (lowerExt === 'md' || lowerExt === 'markdown') { + // Render markdown using markdown-it + const MarkdownIt = (await import('markdown-it')).default; + const hljsModule = await import('highlight.js'); + const hljs = hljsModule.default || hljsModule; + + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + breaks: true, + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (__) {} + } + try { + return hljs.highlightAuto(str).value; + } catch (__) {} + return ''; + } + }); + + let rendered = md.render(content); + rendered = rewriteImagePaths(rendered, filePath); + setHtml(rendered); + } else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { + // Render asciidoc + const Asciidoctor = (await import('@asciidoctor/core')).default; + const asciidoctor = Asciidoctor(); + const converted = asciidoctor.convert(content, { + safe: 'safe', + attributes: { + 'source-highlighter': 'highlight.js' + } + }); + let rendered = typeof converted === 'string' ? converted : String(converted); + rendered = rewriteImagePaths(rendered, filePath); + setHtml(rendered); + } else if (lowerExt === 'html' || lowerExt === 'htm') { + // HTML files - rewrite image paths + let rendered = content; + rendered = rewriteImagePaths(rendered, filePath); + setHtml(rendered); + } else if (lowerExt === 'csv') { + // Parse CSV and render as HTML table + const html = renderCsvAsTable(content); + setHtml(html); + } else { + setHtml(''); + } + } catch (err) { + console.error('Error rendering file as HTML:', err); + setHtml(''); + } +} diff --git a/src/routes/repos/[npub]/[repo]/utils/nostr-links.ts b/src/routes/repos/[npub]/[repo]/utils/nostr-links.ts new file mode 100644 index 0000000..17c36ac --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/nostr-links.ts @@ -0,0 +1,202 @@ +/** + * Nostr link processing utilities + * Handles parsing and loading of nostr: links + */ + +import type { NostrEvent } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; + +export interface ParsedNostrLink { + type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; + value: string; + start: number; + end: number; +} + +/** + * Parse nostr: links from content + */ +export function parseNostrLinks(content: string): ParsedNostrLink[] { + const links: ParsedNostrLink[] = []; + const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g; + let match; + + while ((match = nostrLinkRegex.exec(content)) !== null) { + const fullMatch = match[0]; + const prefix = match[1]; + let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; + + if (prefix === 'nevent1') type = 'nevent'; + else if (prefix === 'naddr1') type = 'naddr'; + else if (prefix === 'note1') type = 'note1'; + else if (prefix === 'npub1') type = 'npub'; + else if (prefix === 'nprofile1') type = 'profile'; + else continue; + + links.push({ + type, + value: fullMatch, + start: match.index, + end: match.index + fullMatch.length + }); + } + + return links; +} + +/** + * Get event from nostr link + */ +export function getEventFromNostrLink(link: string): NostrEvent | undefined { + try { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'nevent' || decoded.type === 'note') { + return decoded.data as NostrEvent; + } + } catch { + // Invalid link + } + return undefined; +} + +/** + * Get pubkey from nostr link + */ +export function getPubkeyFromNostrLink(link: string): string | undefined { + try { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'npub' || decoded.type === 'nprofile') { + return decoded.data as string; + } + } catch { + // Invalid link + } + return undefined; +} + +/** + * Process content with nostr links, replacing them with event/profile data + */ +export function processContentWithNostrLinks( + content: string, + events: Map, + profiles: Map +): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { + const links = parseNostrLinks(content); + const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; + let lastIndex = 0; + + for (const link of links) { + // Add text before link + if (link.start > lastIndex) { + const textPart = content.slice(lastIndex, link.start); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + // Add link + const event = getEventFromNostrLink(link.value); + const pubkey = getPubkeyFromNostrLink(link.value); + if (event) { + parts.push({ type: 'event', value: link.value, event }); + } else if (pubkey) { + parts.push({ type: 'profile', value: link.value, pubkey }); + } else { + parts.push({ type: 'placeholder', value: link.value }); + } + + lastIndex = link.end; + } + + // Add remaining text + if (lastIndex < content.length) { + const textPart = content.slice(lastIndex); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + return parts.length > 0 ? parts : [{ type: 'text', value: content }]; +} + +/** + * Load events and profiles from nostr links + */ +export async function loadNostrLinks( + content: string, + setEvents: (events: Map) => void, + setProfiles: (profiles: Map) => void +): Promise { + const links = parseNostrLinks(content); + if (links.length === 0) return; + + const eventIds: string[] = []; + const aTags: string[] = []; + const npubs: string[] = []; + + for (const link of links) { + try { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'nevent') { + const data = decoded.data as { id: string; relays?: string[] }; + if (data.id) eventIds.push(data.id); + } else if (decoded.type === 'naddr') { + const data = decoded.data as { identifier: string; pubkey: string; relays?: string[] }; + if (data.identifier && data.pubkey) { + aTags.push(`${data.pubkey}:${data.identifier}`); + } + } else if (decoded.type === 'note') { + eventIds.push(decoded.data as string); + } else if (decoded.type === 'npub' || decoded.type === 'nprofile') { + npubs.push(decoded.data as string); + } + } catch { + // Invalid link, skip + } + } + + if (eventIds.length === 0 && aTags.length === 0 && npubs.length === 0) return; + + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const eventsMap = new Map(); + const profilesMap = new Map(); + + // Load events + if (eventIds.length > 0) { + try { + const events = await client.fetchEvents([ + { ids: eventIds, limit: eventIds.length } + ]); + for (const event of events) { + eventsMap.set(event.id, event); + } + } catch (err) { + console.warn('Failed to load events from nostr links:', err); + } + } + + // Load profiles + if (npubs.length > 0) { + try { + const profiles = await client.fetchEvents([ + { kinds: [0], authors: npubs, limit: npubs.length } + ]); + for (const profile of profiles) { + try { + const data = JSON.parse(profile.content); + profilesMap.set(profile.pubkey, data); + } catch { + // Invalid JSON + } + } + } catch (err) { + console.warn('Failed to load profiles from nostr links:', err); + } + } + + setEvents(eventsMap); + setProfiles(profilesMap); +} diff --git a/src/routes/repos/[npub]/[repo]/utils/user-profile.ts b/src/routes/repos/[npub]/[repo]/utils/user-profile.ts new file mode 100644 index 0000000..08f4c00 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/user-profile.ts @@ -0,0 +1,164 @@ +/** + * User profile utilities + * Handles fetching and caching user email/name + */ + +import { nip19 } from 'nostr-tools'; +import { settingsStore } from '$lib/services/settings-store.js'; +import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; + +interface CachedUserData { + email: string | null; + name: string | null; +} + +interface FetchingFlags { + email: boolean; + name: boolean; +} + +/** + * Get user email with caching + */ +export async function getUserEmail( + userPubkeyHex: string | null, + userPubkey: string | null, + cachedData: CachedUserData, + fetchingFlags: FetchingFlags +): Promise { + // Check settings store first + try { + const settings = await settingsStore.getSettings(); + if (settings.userEmail && settings.userEmail.trim()) { + cachedData.email = settings.userEmail.trim(); + return cachedData.email; + } + } catch (err) { + console.warn('Failed to get userEmail from settings:', err); + } + + // Return cached email if available + if (cachedData.email) { + return cachedData.email; + } + + // If no user pubkey, can't proceed + if (!userPubkeyHex) { + throw new Error('User not authenticated'); + } + + // Prevent concurrent fetches + if (fetchingFlags.email) { + await new Promise(resolve => setTimeout(resolve, 100)); + if (cachedData.email) { + return cachedData.email; + } + } + + fetchingFlags.email = true; + let prefillEmail: string; + + try { + prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); + } catch (err) { + console.warn('Failed to fetch user profile for email:', err); + const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); + const shortenedNpub = npubFromPubkey.substring(0, 20); + prefillEmail = `${shortenedNpub}@gitrepublic.web`; + } finally { + fetchingFlags.email = false; + } + + // Prompt user for email address + const userEmail = prompt( + 'Please enter your email address for git commits.\n\n' + + 'This will be used as the author email in your commits.\n' + + 'You can use any email address you prefer.', + prefillEmail + ); + + if (userEmail && userEmail.trim()) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (emailRegex.test(userEmail.trim())) { + cachedData.email = userEmail.trim(); + settingsStore.setSetting('userEmail', cachedData.email).catch(console.error); + return cachedData.email; + } else { + alert('Invalid email format. Using fallback email address.'); + } + } + + cachedData.email = prefillEmail; + return cachedData.email; +} + +/** + * Get user name with caching + */ +export async function getUserName( + userPubkeyHex: string | null, + userPubkey: string | null, + cachedData: CachedUserData, + fetchingFlags: FetchingFlags +): Promise { + // Check settings store first + try { + const settings = await settingsStore.getSettings(); + if (settings.userName && settings.userName.trim()) { + cachedData.name = settings.userName.trim(); + return cachedData.name; + } + } catch (err) { + console.warn('Failed to getUserName from settings:', err); + } + + // Return cached name if available + if (cachedData.name) { + return cachedData.name; + } + + // If no user pubkey, can't proceed + if (!userPubkeyHex) { + throw new Error('User not authenticated'); + } + + // Prevent concurrent fetches + if (fetchingFlags.name) { + await new Promise(resolve => setTimeout(resolve, 100)); + if (cachedData.name) { + return cachedData.name; + } + } + + fetchingFlags.name = true; + let prefillName: string; + + try { + prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); + } catch (err) { + console.warn('Failed to fetch user profile for name:', err); + const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); + const shortenedNpub = npubFromPubkey.substring(0, 20); + prefillName = shortenedNpub; + } finally { + fetchingFlags.name = false; + } + + // Prompt user for name + const userName = prompt( + 'Please enter your name for git commits.\n\n' + + 'This will be used as the author name in your commits.\n' + + 'You can use any name you prefer.', + prefillName + ); + + if (userName && userName.trim()) { + cachedData.name = userName.trim(); + settingsStore.setSetting('userName', cachedData.name).catch(console.error); + return cachedData.name; + } + + cachedData.name = prefillName; + return cachedData.name; +}