diff --git a/assets/controllers/advanced_metadata_controller.js b/assets/controllers/advanced_metadata_controller.js new file mode 100644 index 0000000..808cbc5 --- /dev/null +++ b/assets/controllers/advanced_metadata_controller.js @@ -0,0 +1,345 @@ +import { Controller } from '@hotwired/stimulus'; + +// Inline utility functions +function calculateShares(splits) { + if (splits.length === 0) { + return []; + } + + // Check if any weights are specified + const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0); + + if (!hasWeights) { + // Equal distribution + const equalShare = 100 / splits.length; + return splits.map(() => equalShare); + } + + // Calculate total weight + const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0); + + if (totalWeight === 0) { + return splits.map(() => 0); + } + + // Calculate weighted shares + return splits.map(s => { + const weight = s.weight || 0; + return (weight / totalWeight) * 100; + }); +} + +function isValidPubkey(pubkey) { + if (!pubkey) return false; + + // Check if hex (64 chars) + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + return true; + } + + // Check if npub (basic check) + if (pubkey.startsWith('npub1') && pubkey.length > 60) { + return true; + } + + return false; +} + +function isValidRelay(relay) { + if (!relay) return true; // Empty is valid (optional) + + try { + const url = new URL(relay); + return url.protocol === 'wss:'; + } catch { + return false; + } +} + +export default class extends Controller { + static targets = [ + 'zapSplitsContainer', + 'addZapButton', + 'distributeEquallyButton', + 'licenseSelect', + 'customLicenseInput', + 'protectedCheckbox', + 'protectedWarning', + 'expirationInput' + ]; + + connect() { + console.log('Advanced metadata controller connected'); + this.updateLicenseVisibility(); + this.updateProtectedWarning(); + this.updateZapShares(); + } + + /** + * Add a new zap split row + */ + addZapSplit(event) { + event.preventDefault(); + + const container = this.zapSplitsContainerTarget; + const prototype = container.dataset.prototype; + + if (!prototype) { + console.error('No prototype found for zap splits'); + return; + } + + // Get the current index + const index = parseInt(container.dataset.index || '0', 10); + + // Replace __name__ with the index + const newForm = prototype.replace(/__name__/g, index.toString()); + + // Create wrapper div + const wrapper = document.createElement('div'); + wrapper.classList.add('zap-split-item', 'mb-3', 'p-3', 'border', 'rounded'); + wrapper.dataset.index = index.toString(); + wrapper.innerHTML = newForm; + + // Add delete button + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.classList.add('btn', 'btn-sm', 'btn-danger', 'mt-2'); + deleteBtn.textContent = 'Remove'; + deleteBtn.addEventListener('click', () => this.removeZapSplit(wrapper)); + wrapper.appendChild(deleteBtn); + + // Add share percentage display + const shareDisplay = document.createElement('div'); + shareDisplay.classList.add('zap-share-display', 'mt-2', 'text-muted'); + shareDisplay.innerHTML = 'Share: 0%'; + wrapper.appendChild(shareDisplay); + + container.appendChild(wrapper); + + // Update index + container.dataset.index = (index + 1).toString(); + + // Add event listeners for live validation and share calculation + this.attachZapSplitListeners(wrapper); + + this.updateZapShares(); + } + + /** + * Remove a zap split row + */ + removeZapSplit(wrapper) { + wrapper.remove(); + this.updateZapShares(); + } + + /** + * Distribute weights equally among all splits + */ + distributeEqually(event) { + event.preventDefault(); + + const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); + + splits.forEach((split) => { + const weightInput = split.querySelector('.zap-weight'); + if (weightInput) { + weightInput.value = '1'; + } + }); + + this.updateZapShares(); + } + + /** + * Update share percentages for all zap splits + */ + updateZapShares() { + const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); + const zapSplits = []; + + splits.forEach((split) => { + const weightInput = split.querySelector('.zap-weight'); + const weight = weightInput?.value ? parseInt(weightInput.value, 10) : undefined; + + zapSplits.push({ + recipient: '', + weight: weight + }); + }); + + const shares = calculateShares(zapSplits); + + splits.forEach((split, index) => { + const shareDisplay = split.querySelector('.share-percent'); + if (shareDisplay) { + shareDisplay.textContent = shares[index].toFixed(1); + } + }); + } + + /** + * Attach event listeners to a zap split row + */ + attachZapSplitListeners(wrapper) { + const recipientInput = wrapper.querySelector('.zap-recipient'); + const relayInput = wrapper.querySelector('.zap-relay'); + const weightInput = wrapper.querySelector('.zap-weight'); + + if (recipientInput) { + recipientInput.addEventListener('blur', (e) => this.validateRecipient(e.target)); + } + + if (relayInput) { + relayInput.addEventListener('blur', (e) => this.validateRelay(e.target)); + } + + if (weightInput) { + weightInput.addEventListener('input', () => this.updateZapShares()); + } + } + + /** + * Validate recipient pubkey (npub or hex) + */ + validateRecipient(input) { + const value = input.value.trim(); + + if (!value) { + this.setInputValid(input, true); + return; + } + + const isValid = isValidPubkey(value); + this.setInputValid(input, isValid, isValid ? '' : 'Invalid pubkey. Must be npub or 64-character hex.'); + } + + /** + * Validate relay URL + */ + validateRelay(input) { + const value = input.value.trim(); + + if (!value) { + this.setInputValid(input, true); + return; + } + + const isValid = isValidRelay(value); + this.setInputValid(input, isValid, isValid ? '' : 'Invalid relay URL. Must start with wss://'); + } + + /** + * Set input validation state + */ + setInputValid(input, isValid, message = '') { + if (isValid) { + input.classList.remove('is-invalid'); + input.classList.add('is-valid'); + + // Remove error message + const feedback = input.parentElement?.querySelector('.invalid-feedback'); + if (feedback) { + feedback.remove(); + } + } else { + input.classList.remove('is-valid'); + input.classList.add('is-invalid'); + + // Add/update error message + let feedback = input.parentElement?.querySelector('.invalid-feedback'); + if (!feedback) { + feedback = document.createElement('div'); + feedback.classList.add('invalid-feedback'); + input.parentElement?.appendChild(feedback); + } + feedback.textContent = message; + } + } + + /** + * Update license field visibility based on selection + */ + updateLicenseVisibility() { + if (!this.hasLicenseSelectTarget || !this.hasCustomLicenseInputTarget) { + return; + } + + const isCustom = this.licenseSelectTarget.value === 'custom'; + const customWrapper = this.customLicenseInputTarget.closest('.mb-3'); + + if (customWrapper) { + if (isCustom) { + customWrapper.style.display = 'block'; + this.customLicenseInputTarget.required = true; + } else { + customWrapper.style.display = 'none'; + this.customLicenseInputTarget.required = false; + this.customLicenseInputTarget.value = ''; + } + } + } + + /** + * Show/hide protected event warning + */ + updateProtectedWarning() { + if (!this.hasProtectedCheckboxTarget || !this.hasProtectedWarningTarget) { + return; + } + + if (this.protectedCheckboxTarget.checked) { + this.protectedWarningTarget.style.display = 'block'; + } else { + this.protectedWarningTarget.style.display = 'none'; + } + } + + /** + * Validate expiration is in the future + */ + validateExpiration() { + if (!this.hasExpirationInputTarget) { + return; + } + + const value = this.expirationInputTarget.value; + if (!value) { + this.setInputValid(this.expirationInputTarget, true); + return; + } + + const expirationDate = new Date(value); + const now = new Date(); + + const isValid = expirationDate > now; + this.setInputValid( + this.expirationInputTarget, + isValid, + isValid ? '' : 'Expiration date must be in the future' + ); + } + + /** + * Event handler for license select change + */ + licenseChanged() { + this.updateLicenseVisibility(); + } + + /** + * Event handler for protected checkbox change + */ + protectedChanged() { + this.updateProtectedWarning(); + } + + /** + * Event handler for expiration input change + */ + expirationChanged() { + this.validateExpiration(); + } +} + diff --git a/assets/controllers/nostr-utils.ts b/assets/controllers/nostr-utils.ts new file mode 100644 index 0000000..0537415 --- /dev/null +++ b/assets/controllers/nostr-utils.ts @@ -0,0 +1,285 @@ +/** + * Nostr utilities for handling pubkeys, relays, and tag building + */ + +// Bech32 character set +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +/** + * Decode bech32 string to hex + * Simplified implementation for npub decoding + */ +function bech32Decode(str: string): { prefix: string; data: Uint8Array } | null { + const lowered = str.toLowerCase(); + + // Find the separator + let sepIndex = lowered.lastIndexOf('1'); + if (sepIndex < 1) return null; + + const prefix = lowered.substring(0, sepIndex); + const dataStr = lowered.substring(sepIndex + 1); + + if (dataStr.length < 6) return null; + + // Decode the data + const values: number[] = []; + for (let i = 0; i < dataStr.length; i++) { + const c = dataStr[i]; + const v = BECH32_CHARSET.indexOf(c); + if (v === -1) return null; + values.push(v); + } + + // Remove checksum (last 6 chars) + const data = values.slice(0, -6); + + // Convert from 5-bit to 8-bit + const bytes: number[] = []; + let acc = 0; + let bits = 0; + + for (const value of data) { + acc = (acc << 5) | value; + bits += 5; + + if (bits >= 8) { + bits -= 8; + bytes.push((acc >> bits) & 0xff); + } + } + + return { + prefix, + data: new Uint8Array(bytes) + }; +} + +/** + * Convert Uint8Array to hex string + */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Convert npub to hex pubkey + */ +export function npubToHex(npub: string): string | null { + if (!npub.startsWith('npub1')) { + // Check if it's already hex + if (/^[0-9a-f]{64}$/i.test(npub)) { + return npub.toLowerCase(); + } + return null; + } + + try { + const decoded = bech32Decode(npub); + if (!decoded || decoded.prefix !== 'npub') { + return null; + } + + return bytesToHex(decoded.data); + } catch (e) { + console.error('Error decoding npub:', e); + return null; + } +} + +/** + * Validate if a string is a valid npub or hex pubkey + */ +export function isValidPubkey(pubkey: string): boolean { + if (!pubkey) return false; + + // Check if hex (64 chars) + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + return true; + } + + // Check if npub + if (pubkey.startsWith('npub1')) { + return npubToHex(pubkey) !== null; + } + + return false; +} + +/** + * Validate relay URL + */ +export function isValidRelay(relay: string): boolean { + if (!relay) return true; // Empty is valid (optional) + + try { + const url = new URL(relay); + return url.protocol === 'wss:'; + } catch { + return false; + } +} + +/** + * ZapSplit interface + */ +export interface ZapSplit { + recipient: string; + relay?: string; + weight?: number; + sharePercent?: number; +} + +/** + * Calculate share percentages for zap splits + */ +export function calculateShares(splits: ZapSplit[]): number[] { + if (splits.length === 0) { + return []; + } + + // Check if any weights are specified + const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0); + + if (!hasWeights) { + // Equal distribution + const equalShare = 100 / splits.length; + return splits.map(() => equalShare); + } + + // Calculate total weight + const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0); + + if (totalWeight === 0) { + return splits.map(() => 0); + } + + // Calculate weighted shares + return splits.map(s => { + const weight = s.weight || 0; + return (weight / totalWeight) * 100; + }); +} + +/** + * Build a zap tag for Nostr event + */ +export function buildZapTag(split: ZapSplit): (string | number)[] { + const hexPubkey = npubToHex(split.recipient); + if (!hexPubkey) { + throw new Error(`Invalid recipient pubkey: ${split.recipient}`); + } + + const tag: (string | number)[] = ['zap', hexPubkey]; + + // Add relay (even if empty, to maintain position) + tag.push(split.relay || ''); + + // Add weight if specified + if (split.weight !== undefined && split.weight !== null) { + tag.push(split.weight); + } + + return tag; +} + +/** + * Advanced metadata interface + */ +export interface AdvancedMetadata { + doNotRepublish: boolean; + license: string; + customLicense?: string; + zapSplits: ZapSplit[]; + contentWarning?: string; + expirationTimestamp?: number; + isProtected: boolean; +} + +/** + * Build advanced metadata tags for Nostr event + */ +export function buildAdvancedTags(metadata: AdvancedMetadata): any[][] { + const tags: any[][] = []; + + // Policy: Do not republish + if (metadata.doNotRepublish) { + tags.push(['L', 'rights.decent.newsroom']); + tags.push(['l', 'no-republish', 'rights.decent.newsroom']); + } + + // License + const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; + if (license && license !== 'All rights reserved') { + tags.push(['L', 'spdx.org/licenses']); + tags.push(['l', license, 'spdx.org/licenses']); + } else if (license === 'All rights reserved') { + tags.push(['L', 'rights.decent.newsroom']); + tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); + } + + // Zap splits + for (const split of metadata.zapSplits) { + try { + tags.push(buildZapTag(split)); + } catch (e) { + console.error('Error building zap tag:', e); + } + } + + // Content warning + if (metadata.contentWarning) { + tags.push(['content-warning', metadata.contentWarning]); + } + + // Expiration + if (metadata.expirationTimestamp) { + tags.push(['expiration', metadata.expirationTimestamp.toString()]); + } + + // Protected event + if (metadata.isProtected) { + tags.push(['-']); + } + + return tags; +} + +/** + * Validate advanced metadata before publishing + */ +export function validateAdvancedMetadata(metadata: AdvancedMetadata): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Validate zap splits + for (let i = 0; i < metadata.zapSplits.length; i++) { + const split = metadata.zapSplits[i]; + + if (!isValidPubkey(split.recipient)) { + errors.push(`Zap split ${i + 1}: Invalid recipient pubkey`); + } + + if (split.relay && !isValidRelay(split.relay)) { + errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); + } + + if (split.weight !== undefined && split.weight !== null && split.weight < 0) { + errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); + } + } + + // Validate expiration is in the future + if (metadata.expirationTimestamp) { + const now = Math.floor(Date.now() / 1000); + if (metadata.expirationTimestamp <= now) { + errors.push('Expiration date must be in the future'); + } + } + + return { + valid: errors.length === 0, + errors + }; +} + diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index 623caf6..52caffc 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -1,273 +1,418 @@ import { Controller } from '@hotwired/stimulus'; +// Inline utility functions (simplified versions) +function buildAdvancedTags(metadata) { + const tags = []; + + // Policy: Do not republish + if (metadata.doNotRepublish) { + tags.push(['L', 'rights.decent.newsroom']); + tags.push(['l', 'no-republish', 'rights.decent.newsroom']); + } + + // License + const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; + if (license && license !== 'All rights reserved') { + tags.push(['L', 'spdx.org/licenses']); + tags.push(['l', license, 'spdx.org/licenses']); + } else if (license === 'All rights reserved') { + tags.push(['L', 'rights.decent.newsroom']); + tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); + } + + // Zap splits + for (const split of metadata.zapSplits) { + const zapTag = ['zap', split.recipient, split.relay || '']; + if (split.weight !== undefined && split.weight !== null) { + zapTag.push(split.weight.toString()); + } + tags.push(zapTag); + } + + // Content warning + if (metadata.contentWarning) { + tags.push(['content-warning', metadata.contentWarning]); + } + + // Expiration + if (metadata.expirationTimestamp) { + tags.push(['expiration', metadata.expirationTimestamp.toString()]); + } + + // Protected event + if (metadata.isProtected) { + tags.push(['-']); + } + + return tags; +} + +function validateAdvancedMetadata(metadata) { + const errors = []; + + // Basic validation + for (let i = 0; i < metadata.zapSplits.length; i++) { + const split = metadata.zapSplits[i]; + + if (!split.recipient) { + errors.push(`Zap split ${i + 1}: Recipient is required`); + } + + if (split.relay && !split.relay.startsWith('wss://')) { + errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); + } + + if (split.weight !== undefined && split.weight !== null && split.weight < 0) { + errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); + } + } + + // Validate expiration is in the future + if (metadata.expirationTimestamp) { + const now = Math.floor(Date.now() / 1000); + if (metadata.expirationTimestamp <= now) { + errors.push('Expiration date must be in the future'); + } + } + + return { + valid: errors.length === 0, + errors + }; +} + export default class extends Controller { - static targets = ['form', 'publishButton', 'status']; - static values = { - publishUrl: String, - csrfToken: String - }; + static targets = ['form', 'publishButton', 'status']; + static values = { + publishUrl: String, + csrfToken: String + }; + + connect() { + console.log('Nostr publish controller connected'); + try { + console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); + console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); + console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); + } catch (_) {} + } + + async publish(event) { + event.preventDefault(); + + if (!this.publishUrlValue) { + this.showError('Publish URL is not configured'); + return; + } + if (!this.csrfTokenValue) { + this.showError('Missing CSRF token'); + return; + } - connect() { - console.log('Nostr publish controller connected'); - try { - console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); - console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); - console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); - } catch (_) {} + if (!window.nostr) { + this.showError('Nostr extension not found'); + return; } - async publish(event) { - event.preventDefault(); + this.publishButtonTarget.disabled = true; + this.showStatus('Preparing article for signing...'); + + try { + // Collect form data + const formData = this.collectFormData(); - if (!this.publishUrlValue) { - this.showError('Publish URL is not configured'); - return; - } - if (!this.csrfTokenValue) { - this.showError('Missing CSRF token'); - return; - } + // Validate required fields + if (!formData.title || !formData.content) { + throw new Error('Title and content are required'); + } - if (!window.nostr) { - this.showError('Nostr extension not found'); - return; - } + // Create Nostr event + const nostrEvent = await this.createNostrEvent(formData); - this.publishButtonTarget.disabled = true; - this.showStatus('Preparing article for signing...'); + this.showStatus('Requesting signature from Nostr extension...'); - try { - // Collect form data - const formData = this.collectFormData(); + // Sign the event with Nostr extension + const signedEvent = await window.nostr.signEvent(nostrEvent); - // Validate required fields - if (!formData.title || !formData.content) { - throw new Error('Title and content are required'); - } + this.showStatus('Publishing article...'); - // Create Nostr event - const nostrEvent = await this.createNostrEvent(formData); + // Send to backend + await this.sendToBackend(signedEvent, formData); - this.showStatus('Requesting signature from Nostr extension...'); + this.showSuccess('Article published successfully!'); - // Sign the event with Nostr extension - const signedEvent = await window.nostr.signEvent(nostrEvent); + // Optionally redirect after successful publish + setTimeout(() => { + window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; + }, 2000); + + } catch (error) { + console.error('Publishing error:', error); + this.showError(`Publishing failed: ${error.message}`); + } finally { + this.publishButtonTarget.disabled = false; + } + } + + collectFormData() { + // Find the actual form element within our target + const form = this.formTarget.querySelector('form'); + if (!form) throw new Error('Form element not found'); + + const fd = new FormData(form); + + // Prefer the Markdown field populated by the Quill controller + const md = fd.get('editor[content_md]'); + let html = fd.get('editor[content]') || fd.get('content') || ''; + + // Final content: use MD if present, otherwise convert HTML -> MD + const content = (typeof md === 'string' && md.length > 0) + ? md + : this.htmlToMarkdown(String(html)); + + const title = fd.get('editor[title]') || ''; + const summary = fd.get('editor[summary]') || ''; + const image = fd.get('editor[image]') || ''; + const topicsString = fd.get('editor[topics]') || ''; + const isDraft = fd.get('editor[isDraft]') === '1'; + const addClientTag = fd.get('editor[clientTag]') === '1'; + + // Collect advanced metadata + const advancedMetadata = this.collectAdvancedMetadata(fd); + + // Parse topics + const topics = String(topicsString).split(',') + .map(s => s.trim()) + .filter(Boolean) + .map(t => t.startsWith('#') ? t : `#${t}`); + + // Reuse existing slug if provided on the container (editing), else generate from title + const existingSlug = (this.element.dataset.slug || '').trim(); + const slug = existingSlug || this.generateSlug(String(title)); + + return { + title: String(title), + summary: String(summary), + content, + image: String(image), + topics, + slug, + isDraft, + addClientTag, + advancedMetadata, + }; + } + + collectAdvancedMetadata(fd) { + const metadata = { + doNotRepublish: fd.get('editor[advancedMetadata][doNotRepublish]') === '1', + license: fd.get('editor[advancedMetadata][license]') || '', + customLicense: fd.get('editor[advancedMetadata][customLicense]') || '', + contentWarning: fd.get('editor[advancedMetadata][contentWarning]') || '', + isProtected: fd.get('editor[advancedMetadata][isProtected]') === '1', + zapSplits: [], + }; - this.showStatus('Publishing article...'); + // Parse expiration timestamp + const expirationDate = fd.get('editor[advancedMetadata][expirationTimestamp]'); + if (expirationDate) { + try { + metadata.expirationTimestamp = Math.floor(new Date(expirationDate).getTime() / 1000); + } catch (e) { + console.warn('Invalid expiration date:', e); + } + } - // Send to backend - await this.sendToBackend(signedEvent, formData); + // Collect zap splits + let index = 0; + while (true) { + const recipient = fd.get(`editor[advancedMetadata][zapSplits][${index}][recipient]`); + if (!recipient) break; - this.showSuccess('Article published successfully!'); + const relay = fd.get(`editor[advancedMetadata][zapSplits][${index}][relay]`) || ''; + const weightStr = fd.get(`editor[advancedMetadata][zapSplits][${index}][weight]`); + const weight = weightStr ? parseInt(weightStr, 10) : undefined; - // Optionally redirect after successful publish - setTimeout(() => { - window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; - }, 2000); + metadata.zapSplits.push({ + recipient: String(recipient), + relay: relay ? String(relay) : undefined, + weight: weight !== undefined && !isNaN(weight) ? weight : undefined, + }); - } catch (error) { - console.error('Publishing error:', error); - this.showError(`Publishing failed: ${error.message}`); - } finally { - this.publishButtonTarget.disabled = false; - } + index++; } - collectFormData() { - // Find the actual form element within our target - const form = this.formTarget.querySelector('form'); - if (!form) { - throw new Error('Form element not found'); - } - - const formData = new FormData(form); - - let content = formData.get('editor[content]') || ''; - content = this.htmlToMarkdown(content); - - const title = formData.get('editor[title]') || ''; - const summary = formData.get('editor[summary]') || ''; - const image = formData.get('editor[image]') || ''; - const topicsString = formData.get('editor[topics]') || ''; - const isDraft = formData.get('editor[isDraft]') === '1'; - const addClientTag = formData.get('editor[clientTag]') === '1'; - - // Parse topics - const topics = topicsString.split(',') - .map(topic => topic.trim()) - .filter(topic => topic.length > 0) - .map(topic => topic.startsWith('#') ? topic : `#${topic}`); - - // Reuse existing slug if provided on the container (editing), else generate from title - const existingSlug = (this.element.dataset.slug || '').trim(); - const slug = existingSlug || this.generateSlug(title); - - return { - title, - summary, - content, - image, - topics, - slug, - isDraft, - addClientTag - }; + return metadata; + } + + async createNostrEvent(formData) { + // Get user's public key + const pubkey = await window.nostr.getPublicKey(); + + // Validate advanced metadata if present + if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) { + const validation = validateAdvancedMetadata(formData.advancedMetadata); + if (!validation.valid) { + throw new Error('Invalid advanced metadata: ' + validation.errors.join(', ')); + } } - async createNostrEvent(formData) { - // Get user's public key - const pubkey = await window.nostr.getPublicKey(); - - // Create tags array - const tags = [ - ['d', formData.slug], - ['title', formData.title], - ['published_at', Math.floor(Date.now() / 1000).toString()], - ]; - - let kind = 30023; // Default kind for long-form content - if (formData.isDraft) { - kind = 30024; // Draft kind - } - - if (formData.summary) { - tags.push(['summary', formData.summary]); - } - - if (formData.image) { - tags.push(['image', formData.image]); - } - - // Add topic tags - formData.topics.forEach(topic => { - tags.push(['t', topic.replace('#', '')]); - }); - - if (formData.addClientTag) { - tags.push(['client', 'Decent Newsroom']); - } - - // Create the Nostr event (NIP-23 long-form content) - const event = { - kind: kind, // Long-form content kind - created_at: Math.floor(Date.now() / 1000), - tags: tags, - content: formData.content, - pubkey: pubkey - }; - - return event; + // Create tags array + const tags = [ + ['d', formData.slug], + ['title', formData.title], + ['published_at', Math.floor(Date.now() / 1000).toString()], + ]; + + let kind = 30023; // Default kind for long-form content + if (formData.isDraft) { + kind = 30024; // Draft kind } - async sendToBackend(signedEvent, formData) { - const response = await fetch(this.publishUrlValue, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': this.csrfTokenValue - }, - body: JSON.stringify({ - event: signedEvent, - formData: formData - }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + if (formData.summary) { + tags.push(['summary', formData.summary]); } - htmlToMarkdown(html) { - // Basic HTML to Markdown conversion - // This is a simplified version - you might want to use a proper library - let markdown = html; - - // Convert headers - markdown = markdown.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); - - // Convert formatting - markdown = markdown.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); - markdown = markdown.replace(/]*>(.*?)<\/b>/gi, '**$1**'); - markdown = markdown.replace(/]*>(.*?)<\/em>/gi, '*$1*'); - markdown = markdown.replace(/]*>(.*?)<\/i>/gi, '*$1*'); - - // Convert links - markdown = markdown.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); - - // Convert images (handle src/alt in any order) - markdown = markdown.replace(/]*>/gi, (imgTag) => { - const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); - const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); - const src = srcMatch ? srcMatch[1] : ''; - const alt = altMatch ? altMatch[1] : ''; - return src ? `![${alt}](${src})` : ''; - }); - - // Convert lists - markdown = markdown.replace(/]*>(.*?)<\/ul>/gis, '$1\n'); - markdown = markdown.replace(/]*>(.*?)<\/ol>/gis, '$1\n'); - markdown = markdown.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); - - // Convert paragraphs - markdown = markdown.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); - - // Convert line breaks - markdown = markdown.replace(/]*>/gi, '\n'); - - // Convert blockquotes - markdown = markdown.replace(/]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); - - // Convert code blocks - markdown = markdown.replace(/]*>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/code>/gi, '`$1`'); - - // Clean up HTML entities and remaining tags - markdown = markdown.replace(/ /g, ' '); - markdown = markdown.replace(/&/g, '&'); - markdown = markdown.replace(/</g, '<'); - markdown = markdown.replace(/>/g, '>'); - markdown = markdown.replace(/"/g, '"'); - markdown = markdown.replace(/<[^>]*>/g, ''); // Remove any remaining HTML tags - - // Clean up extra whitespace - markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); - - return markdown; + if (formData.image) { + tags.push(['image', formData.image]); } - generateSlug(title) { - // add a random seed at the end of the title to avoid collisions - const randomSeed = Math.random().toString(36).substring(2, 8); - title = `${title} ${randomSeed}`; - return title - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') // Remove special characters - .replace(/\s+/g, '-') // Replace spaces with hyphens - .replace(/-+/g, '-') // Replace multiple hyphens with single - .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens + // Add topic tags + formData.topics.forEach(topic => { + tags.push(['t', topic.replace('#', '')]); + }); + + if (formData.addClientTag) { + tags.push(['client', 'Decent Newsroom']); } - showStatus(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } + // Add advanced metadata tags + if (formData.advancedMetadata) { + const advancedTags = buildAdvancedTags(formData.advancedMetadata); + tags.push(...advancedTags); } - showSuccess(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } + // Create the Nostr event (NIP-23 long-form content) + const event = { + kind: kind, + created_at: Math.floor(Date.now() / 1000), + tags: tags, + content: formData.content, + pubkey: pubkey + }; + + return event; + } + + async sendToBackend(signedEvent, formData) { + const response = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': this.csrfTokenValue + }, + body: JSON.stringify({ + event: signedEvent, + formData: formData + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); } - showError(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } + return await response.json(); + } + + htmlToMarkdown(html) { + // Basic HTML to Markdown conversion + let markdown = html; + + // Convert headers + markdown = markdown.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); + + // Convert formatting + markdown = markdown.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); + markdown = markdown.replace(/]*>(.*?)<\/b>/gi, '**$1**'); + markdown = markdown.replace(/]*>(.*?)<\/em>/gi, '*$1*'); + markdown = markdown.replace(/]*>(.*?)<\/i>/gi, '*$1*'); + + // Convert links + markdown = markdown.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + + // Convert images + markdown = markdown.replace(/]*>/gi, (imgTag) => { + const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); + const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); + const src = srcMatch ? srcMatch[1] : ''; + const alt = altMatch ? altMatch[1] : ''; + return src ? `![${alt}](${src})` : ''; + }); + + // Convert lists + markdown = markdown.replace(/]*>(.*?)<\/ul>/gis, '$1\n'); + markdown = markdown.replace(/]*>(.*?)<\/ol>/gis, '$1\n'); + markdown = markdown.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); + + // Convert paragraphs + markdown = markdown.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); + + // Convert line breaks + markdown = markdown.replace(/]*>/gi, '\n'); + + // Convert blockquotes + markdown = markdown.replace(/]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); + + // Convert code blocks + markdown = markdown.replace(/]*>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/code>/gi, '`$1`'); + + // Clean up HTML entities and remaining tags + markdown = markdown.replace(/ /g, ' '); + markdown = markdown.replace(/&/g, '&'); + markdown = markdown.replace(/</g, '<'); + markdown = markdown.replace(/>/g, '>'); + markdown = markdown.replace(/"/g, '"'); + markdown = markdown.replace(/<[^>]*>/g, ''); + + // Clean up extra whitespace + markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); + + return markdown; + } + + generateSlug(title) { + // add a random seed at the end of the title to avoid collisions + const randomSeed = Math.random().toString(36).substring(2, 8); + title = `${title} ${randomSeed}`; + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + } + + showStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; } + } + + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } } + diff --git a/assets/styles/04-pages/highlights.css b/assets/styles/04-pages/highlights.css index 512960d..ef45338 100644 --- a/assets/styles/04-pages/highlights.css +++ b/assets/styles/04-pages/highlights.css @@ -100,6 +100,10 @@ } @media (max-width: 768px) { + .highlights-grid { + column-count: 1; + } + .highlight-card { margin-bottom: 1.5rem; } diff --git a/assets/styles/advanced-metadata.css b/assets/styles/advanced-metadata.css new file mode 100644 index 0000000..0e9e3ca --- /dev/null +++ b/assets/styles/advanced-metadata.css @@ -0,0 +1,150 @@ +/* Advanced Metadata Styles */ + +.advanced-metadata-section details { + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 1rem; + background-color: #f8f9fa; +} + +.advanced-metadata-section summary { + cursor: pointer; + user-select: none; + font-weight: 600; + margin: -1rem -1rem 0 -1rem; + padding: 1rem; +} + +.advanced-metadata-section summary:hover { + background-color: #e9ecef; +} + +.advanced-metadata-section details[open] summary { + border-bottom: 1px solid #dee2e6; + margin-bottom: 1rem; +} + +.zap-split-item { + background-color: #ffffff; + transition: box-shadow 0.2s; +} + +.zap-split-item:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.zap-share-display { + font-size: 0.875rem; + font-weight: 600; + color: #6c757d; +} + +.upload-area { + border: 2px dashed #dee2e6; + border-radius: 0.375rem; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; + margin: 1rem 0; +} + +.upload-area:hover { + border-color: #0d6efd; + background-color: #f8f9fa; +} + +.upload-area input[type="file"] { + display: none; +} + +.iu-dialog { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1050; +} + +.iu-dialog.active { + display: block; +} + +.iu-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); +} + +.iu-modal { + position: relative; + max-width: 500px; + margin: 3rem auto; + background-color: white; + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + z-index: 1051; +} + +.modal-header { + padding: 1rem; + border-bottom: 1px solid #dee2e6; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header .close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6c757d; +} + +.modal-body { + padding: 1rem; +} + +.upload-progress, +.upload-error { + margin-top: 1rem; + padding: 0.75rem; + border-radius: 0.375rem; +} + +.upload-progress { + background-color: #d1ecf1; + color: #0c5460; +} + +.upload-error { + background-color: #f8d7da; + color: #721c24; +} + +/* Form validation styles */ +.form-control.is-invalid { + border-color: #dc3545; +} + +.form-control.is-valid { + border-color: #28a745; +} + +.invalid-feedback { + display: block; + margin-top: 0.25rem; + font-size: 0.875em; + color: #dc3545; +} + +.cursor-pointer { + cursor: pointer; +} + diff --git a/composer.json b/composer.json index 694648a..4a38538 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,7 @@ "symfony/twig-bundle": "7.2.*", "symfony/ux-icons": "^2.22", "symfony/ux-live-component": "^2.21", + "symfony/validator": "^7.2", "symfony/workflow": "7.2.*", "symfony/yaml": "7.2.*", "tkijewski/php-lnurl": "*", diff --git a/composer.lock b/composer.lock index b22e53f..8cdb447 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e294409f4f3b8647d655053fddae6168", + "content-hash": "24e3ddfdfbf97ad8ae4e802565a80d28", "packages": [ { "name": "bacon/bacon-qr-code", @@ -10284,6 +10284,107 @@ ], "time": "2025-10-17T06:14:35+00:00" }, + { + "name": "symfony/validator", + "version": "v7.2.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "b5410782f69892acf828e0eec7943a2caca46ed6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/b5410782f69892acf828e0eec7943a2caca46ed6", + "reference": "b5410782f69892acf828e0eec7943a2caca46ed6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/translation": "^6.4.3|^7.0.3", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.2.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-29T19:57:35+00:00" + }, { "name": "symfony/var-dumper", "version": "v7.2.9", diff --git a/importmap.php b/importmap.php index fa72f15..83da80a 100644 --- a/importmap.php +++ b/importmap.php @@ -112,4 +112,7 @@ return [ 'version' => '0.16.25', 'type' => 'css', ], + 'katex/dist/katex.min.js' => [ + 'version' => '0.16.25', + ], ]; diff --git a/migrations/Version20251105000000.php b/migrations/Version20251105000000.php new file mode 100644 index 0000000..a419c2a --- /dev/null +++ b/migrations/Version20251105000000.php @@ -0,0 +1,27 @@ +addSql('ALTER TABLE article ADD advanced_metadata JSON DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE article DROP advanced_metadata'); + } +} + diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index e360eeb..8415603 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -2,10 +2,13 @@ namespace App\Controller; +use App\Dto\AdvancedMetadata; use App\Entity\Article; use App\Enum\KindsEnum; use App\Form\EditorType; use App\Service\NostrClient; +use App\Service\Nostr\NostrEventBuilder; +use App\Service\Nostr\NostrEventParser; use App\Service\RedisCacheService; use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; @@ -139,8 +142,16 @@ class ArticleController extends AbstractController */ #[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/edit/{slug}', name: 'editor-edit-slug')] - public function newArticle(Request $request, NostrClient $nostrClient, EntityManagerInterface $entityManager, $slug = null): Response + public function newArticle( + Request $request, + NostrClient $nostrClient, + EntityManagerInterface $entityManager, + NostrEventParser $eventParser, + $slug = null + ): Response { + $advancedMetadata = null; + if (!$slug) { $article = new Article(); $article->setKind(KindsEnum::LONGFORM); @@ -159,6 +170,11 @@ class ArticleController extends AbstractController return $b->getCreatedAt() <=> $a->getCreatedAt(); }); $article = array_shift($articles); + // Parse advanced metadata from the raw event if available + if ($article->getRaw()) { + $tags = $article->getRaw()['tags'] ?? []; + $advancedMetadata = $eventParser->parseAdvancedMetadata($tags); + } } $recentArticles = []; @@ -193,6 +209,11 @@ class ArticleController extends AbstractController } $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); + // Populate advanced metadata form data + if ($advancedMetadata) { + $form->get('advancedMetadata')->setData($advancedMetadata); + } + $form->handleRequest($request); // load template with content editor @@ -215,7 +236,8 @@ class ArticleController extends AbstractController NostrClient $nostrClient, CacheItemPoolInterface $articlesCache, CsrfTokenManagerInterface $csrfTokenManager, - LoggerInterface $logger + LoggerInterface $logger, + NostrEventParser $eventParser ): JsonResponse { try { // Verify CSRF token @@ -281,6 +303,23 @@ class ArticleController extends AbstractController $article->setCreatedAt(new \DateTimeImmutable('@' . $signedEvent['created_at'])); $article->setPublishedAt(new \DateTimeImmutable()); + // Parse and store advanced metadata + $advancedMetadata = $eventParser->parseAdvancedMetadata($signedEvent['tags']); + $article->setAdvancedMetadata([ + 'doNotRepublish' => $advancedMetadata->doNotRepublish, + 'license' => $advancedMetadata->getLicenseValue(), + 'zapSplits' => array_map(function($split) { + return [ + 'recipient' => $split->recipient, + 'relay' => $split->relay, + 'weight' => $split->weight, + ]; + }, $advancedMetadata->zapSplits), + 'contentWarning' => $advancedMetadata->contentWarning, + 'expirationTimestamp' => $advancedMetadata->expirationTimestamp, + 'isProtected' => $advancedMetadata->isProtected, + ]); + // Save to database $entityManager->persist($article); $entityManager->flush(); diff --git a/src/Dto/AdvancedMetadata.php b/src/Dto/AdvancedMetadata.php new file mode 100644 index 0000000..75380dd --- /dev/null +++ b/src/Dto/AdvancedMetadata.php @@ -0,0 +1,67 @@ + Additional tags to preserve on re-publish */ + public array $extraTags = []; + + public function addZapSplit(ZapSplit $split): void + { + $this->zapSplits[] = $split; + } + + public function removeZapSplit(int $index): void + { + if (isset($this->zapSplits[$index])) { + unset($this->zapSplits[$index]); + $this->zapSplits = array_values($this->zapSplits); + } + } + + public function getLicenseValue(): ?string + { + if ($this->license === 'custom') { + return $this->customLicense; + } + return $this->license !== '' ? $this->license : null; + } +} + diff --git a/src/Dto/ZapSplit.php b/src/Dto/ZapSplit.php new file mode 100644 index 0000000..f060dbd --- /dev/null +++ b/src/Dto/ZapSplit.php @@ -0,0 +1,51 @@ +recipient = $recipient; + $this->relay = $relay; + $this->weight = $weight; + } + + /** + * Returns the recipient as hex pubkey. + * If it's an npub, it gets converted to hex. + */ + public function getRecipientHex(): string + { + // This will be handled by the service/utility + return $this->recipient; + } + + public function isNpub(): bool + { + return str_starts_with($this->recipient, 'npub1'); + } +} + diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 27e2a4a..2feaf55 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -79,6 +79,9 @@ class Article #[ORM\Column(type: Types::INTEGER, nullable: true)] private ?int $ratingPositive = null; + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $advancedMetadata = null; + public function getId(): null|int|string { return $this->id; @@ -337,4 +340,15 @@ class Article { $this->raw = $raw; } + + public function getAdvancedMetadata(): ?array + { + return $this->advancedMetadata; + } + + public function setAdvancedMetadata(?array $advancedMetadata): static + { + $this->advancedMetadata = $advancedMetadata; + return $this; + } } diff --git a/src/Form/AdvancedMetadataType.php b/src/Form/AdvancedMetadataType.php new file mode 100644 index 0000000..7dc4153 --- /dev/null +++ b/src/Form/AdvancedMetadataType.php @@ -0,0 +1,106 @@ +add('doNotRepublish', CheckboxType::class, [ + 'label' => 'Do not republish', + 'required' => false, + 'help' => 'Mark this article with a policy label indicating it should not be republished', + 'row_attr' => ['class' => 'form-check'], + 'label_attr' => ['class' => 'form-check-label'], + 'attr' => ['class' => 'form-check-input'], + ]) + ->add('license', ChoiceType::class, [ + 'label' => 'License', + 'required' => false, + 'choices' => [ + 'No license' => '', + 'Public Domain (CC0)' => 'CC0-1.0', + 'Attribution (CC-BY)' => 'CC-BY-4.0', + 'Attribution-ShareAlike (CC-BY-SA)' => 'CC-BY-SA-4.0', + 'Attribution-NonCommercial (CC-BY-NC)' => 'CC-BY-NC-4.0', + 'Attribution-NonCommercial-ShareAlike (CC-BY-NC-SA)' => 'CC-BY-NC-SA-4.0', + 'Attribution-NoDerivs (CC-BY-ND)' => 'CC-BY-ND-4.0', + 'Attribution-NonCommercial-NoDerivs (CC-BY-NC-ND)' => 'CC-BY-NC-ND-4.0', + 'MIT License' => 'MIT', + 'Apache License 2.0' => 'Apache-2.0', + 'GNU GPL v3' => 'GPL-3.0', + 'GNU AGPL v3' => 'AGPL-3.0', + 'All rights reserved' => 'All rights reserved', + 'Custom license' => 'custom', + ], + 'attr' => ['class' => 'form-select'], + ]) + ->add('customLicense', TextType::class, [ + 'label' => 'Custom license identifier', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'e.g., My-Custom-License-1.0' + ], + 'help' => 'Specify a custom SPDX identifier or license name', + ]) + ->add('zapSplits', CollectionType::class, [ + 'entry_type' => ZapSplitType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'label' => 'Zap splits', + 'required' => false, + 'attr' => [ + 'class' => 'zap-splits-collection', + ], + 'help' => 'Configure multiple recipients for zaps (tips). Leave weights empty for equal distribution.', + ]) + ->add('contentWarning', TextType::class, [ + 'label' => 'Content warning', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'e.g., graphic content, spoilers, etc.' + ], + 'help' => 'Optional warning about sensitive content', + ]) + ->add('expirationTimestamp', DateTimeType::class, [ + 'label' => 'Expiration date', + 'required' => false, + 'widget' => 'single_text', + 'input' => 'timestamp', + 'attr' => ['class' => 'form-control'], + 'help' => 'When this article should expire (optional)', + ]) + ->add('isProtected', CheckboxType::class, [ + 'label' => 'Protected event', + 'required' => false, + 'help' => 'Mark this event as protected. Warning: Some relays may reject protected events.', + 'row_attr' => ['class' => 'form-check'], + 'label_attr' => ['class' => 'form-check-label'], + 'attr' => ['class' => 'form-check-input'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AdvancedMetadata::class, + ]); + } +} + diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index 3cc7a56..07f4786 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Form; +use App\Dto\AdvancedMetadata; use App\Entity\Article; use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; use App\Form\DataTransformer\HtmlToMdTransformer; @@ -55,7 +56,11 @@ class EditorType extends AbstractType ->add('isDraft', CheckboxType::class, [ 'label' => 'Save as draft', 'required' => false, - 'mapped' => false, + ]) + ->add('advancedMetadata', AdvancedMetadataType::class, [ + 'label' => 'Advanced metadata', + 'required' => false, + 'mapped' => false, ]); // Apply the custom transformer diff --git a/src/Form/ZapSplitType.php b/src/Form/ZapSplitType.php new file mode 100644 index 0000000..dc64ae9 --- /dev/null +++ b/src/Form/ZapSplitType.php @@ -0,0 +1,66 @@ +add('recipient', TextType::class, [ + 'label' => 'Recipient', + 'required' => true, + 'attr' => [ + 'class' => 'form-control zap-recipient', + 'placeholder' => 'npub1... or hex pubkey' + ], + 'constraints' => [ + new Assert\NotBlank(['message' => 'Recipient is required']), + ], + ]) + ->add('relay', TextType::class, [ + 'label' => 'Relay hint', + 'required' => false, + 'attr' => [ + 'class' => 'form-control zap-relay', + 'placeholder' => 'wss://relay.example.com' + ], + 'constraints' => [ + new Assert\Regex([ + 'pattern' => '/^wss:\/\/.+/', + 'message' => 'Relay must be a valid WebSocket URL starting with wss://' + ]), + ], + ]) + ->add('weight', IntegerType::class, [ + 'label' => 'Weight', + 'required' => false, + 'attr' => [ + 'class' => 'form-control zap-weight', + 'placeholder' => '1', + 'min' => 0 + ], + 'constraints' => [ + new Assert\PositiveOrZero(['message' => 'Weight must be 0 or greater']), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ZapSplit::class, + ]); + } +} + diff --git a/src/Service/Nostr/NostrEventBuilder.php b/src/Service/Nostr/NostrEventBuilder.php new file mode 100644 index 0000000..d773712 --- /dev/null +++ b/src/Service/Nostr/NostrEventBuilder.php @@ -0,0 +1,192 @@ +key = new Key(); + } + + /** + * Build tags array from Article and AdvancedMetadata + * + * @param Article $article + * @param AdvancedMetadata|null $metadata + * @param array $formData Additional form data (isDraft, addClientTag, etc.) + * @return array + */ + public function buildTags(Article $article, ?AdvancedMetadata $metadata, array $formData = []): array + { + $tags = []; + + // Base NIP-23 tags + $tags[] = ['d', $article->getSlug() ?? '']; + $tags[] = ['title', $article->getTitle() ?? '']; + $tags[] = ['published_at', (string)($article->getPublishedAt()?->getTimestamp() ?? time())]; + + if ($article->getSummary()) { + $tags[] = ['summary', $article->getSummary()]; + } + + if ($article->getImage()) { + $tags[] = ['image', $article->getImage()]; + } + + // Topic tags + if ($article->getTopics()) { + foreach ($article->getTopics() as $topic) { + $cleanTopic = str_replace('#', '', $topic); + $tags[] = ['t', $cleanTopic]; + } + } + + // Client tag + if ($formData['addClientTag'] ?? false) { + $tags[] = ['client', 'Decent Newsroom']; + } + + // Advanced metadata tags + if ($metadata) { + $tags = array_merge($tags, $this->buildAdvancedTags($metadata)); + } + + return $tags; + } + + /** + * Build advanced metadata tags + */ + private function buildAdvancedTags(AdvancedMetadata $metadata): array + { + $tags = []; + + // Policy: Do not republish + if ($metadata->doNotRepublish) { + $tags[] = ['L', 'rights.decent.newsroom']; + $tags[] = ['l', 'no-republish', 'rights.decent.newsroom']; + } + + // License + $license = $metadata->getLicenseValue(); + if ($license && $license !== 'All rights reserved') { + $tags[] = ['L', 'spdx.org/licenses']; + $tags[] = ['l', $license, 'spdx.org/licenses']; + } elseif ($license === 'All rights reserved') { + $tags[] = ['L', 'rights.decent.newsroom']; + $tags[] = ['l', 'all-rights-reserved', 'rights.decent.newsroom']; + } + + // Zap splits + foreach ($metadata->zapSplits as $split) { + $zapTag = ['zap', $this->convertToHex($split->recipient)]; + + if ($split->relay) { + $zapTag[] = $split->relay; + } else { + $zapTag[] = ''; + } + + if ($split->weight !== null) { + $zapTag[] = (string)$split->weight; + } + + $tags[] = $zapTag; + } + + // Content warning + if ($metadata->contentWarning) { + $tags[] = ['content-warning', $metadata->contentWarning]; + } + + // Expiration + if ($metadata->expirationTimestamp) { + $tags[] = ['expiration', (string)$metadata->expirationTimestamp]; + } + + // Protected event + if ($metadata->isProtected) { + $tags[] = ['-']; + } + + // Extra tags (passthrough) + foreach ($metadata->extraTags as $tag) { + if (is_array($tag)) { + $tags[] = $tag; + } + } + + return $tags; + } + + /** + * Convert npub or hex to hex pubkey + */ + public function convertToHex(string $pubkey): string + { + if (str_starts_with($pubkey, 'npub1')) { + try { + return $this->key->convertToHex($pubkey); + } catch (\Exception $e) { + throw new \InvalidArgumentException('Invalid npub format: ' . $e->getMessage()); + } + } + + // Validate hex format + if (!preg_match('/^[0-9a-f]{64}$/i', $pubkey)) { + throw new \InvalidArgumentException('Invalid pubkey format. Must be hex (64 chars) or npub'); + } + + return strtolower($pubkey); + } + + /** + * Calculate share percentages for zap splits + * + * @param ZapSplit[] $splits + * @return array Array of percentages indexed by split position + */ + public function calculateShares(array $splits): array + { + if (empty($splits)) { + return []; + } + + $hasWeights = false; + $totalWeight = 0; + + foreach ($splits as $split) { + if ($split->weight !== null && $split->weight > 0) { + $hasWeights = true; + $totalWeight += $split->weight; + } + } + + // If no weights specified, equal distribution + if (!$hasWeights) { + $equalShare = 100.0 / count($splits); + return array_fill(0, count($splits), $equalShare); + } + + // Calculate weighted shares + $shares = []; + foreach ($splits as $split) { + $weight = $split->weight ?? 0; + $shares[] = $totalWeight > 0 ? ($weight / $totalWeight) * 100 : 0; + } + + return $shares; + } +} + diff --git a/src/Service/Nostr/NostrEventParser.php b/src/Service/Nostr/NostrEventParser.php new file mode 100644 index 0000000..03582e9 --- /dev/null +++ b/src/Service/Nostr/NostrEventParser.php @@ -0,0 +1,188 @@ +key = new Key(); + } + + /** + * Parse tags from a Nostr event into AdvancedMetadata DTO + * + * @param array $tags The tags array from the Nostr event + * @return AdvancedMetadata + */ + public function parseAdvancedMetadata(array $tags): AdvancedMetadata + { + $metadata = new AdvancedMetadata(); + $knownTags = ['d', 'title', 'summary', 'image', 'published_at', 't', 'client']; + $processedAdvancedTags = []; + + foreach ($tags as $tag) { + if (!is_array($tag) || empty($tag)) { + continue; + } + + $tagName = $tag[0] ?? ''; + + switch ($tagName) { + case 'L': + // Label namespace - just track it, process with 'l' tags + break; + + case 'l': + $this->parseLabel($tag, $metadata); + $processedAdvancedTags[] = $tag; + break; + + case 'zap': + $split = $this->parseZapTag($tag); + if ($split) { + $metadata->addZapSplit($split); + $processedAdvancedTags[] = $tag; + } + break; + + case 'content-warning': + $metadata->contentWarning = $tag[1] ?? ''; + $processedAdvancedTags[] = $tag; + break; + + case 'expiration': + $timestamp = (int)($tag[1] ?? 0); + if ($timestamp > 0) { + $metadata->expirationTimestamp = $timestamp; + } + $processedAdvancedTags[] = $tag; + break; + + case '-': + $metadata->isProtected = true; + $processedAdvancedTags[] = $tag; + break; + + default: + // Preserve unknown tags for passthrough + if (!in_array($tagName, $knownTags, true)) { + $metadata->extraTags[] = $tag; + } + break; + } + } + + return $metadata; + } + + /** + * Parse a label tag (NIP-32) + */ + private function parseLabel(array $tag, AdvancedMetadata $metadata): void + { + $label = $tag[1] ?? ''; + $namespace = $tag[2] ?? ''; + + if ($namespace === 'rights.decent.newsroom') { + if ($label === 'no-republish') { + $metadata->doNotRepublish = true; + } elseif ($label === 'all-rights-reserved') { + $metadata->license = 'All rights reserved'; + } + } elseif ($namespace === 'spdx.org/licenses') { + $metadata->license = $label; + } + } + + /** + * Parse a zap split tag + * + * Format: ["zap", , , ] + * Relay and weight are optional + */ + private function parseZapTag(array $tag): ?ZapSplit + { + if (count($tag) < 2) { + return null; + } + + $pubkeyHex = $tag[1] ?? ''; + if (empty($pubkeyHex)) { + return null; + } + + // Convert hex to npub for display (more user-friendly) + try { + $npub = $this->key->convertPublicKeyToBech32($pubkeyHex); + } catch (\Exception $e) { + // If conversion fails, use hex + $npub = $pubkeyHex; + } + + $relay = $tag[2] ?? null; + if ($relay === '') { + $relay = null; + } + + $weight = isset($tag[3]) && $tag[3] !== '' ? (int)$tag[3] : null; + + return new ZapSplit($npub, $relay, $weight); + } + + /** + * Extract basic article data from tags + */ + public function extractArticleData(array $tags): array + { + $data = [ + 'slug' => '', + 'title' => '', + 'summary' => '', + 'image' => '', + 'topics' => [], + 'publishedAt' => null, + ]; + + foreach ($tags as $tag) { + if (!is_array($tag) || count($tag) < 2) { + continue; + } + + switch ($tag[0]) { + case 'd': + $data['slug'] = $tag[1]; + break; + case 'title': + $data['title'] = $tag[1]; + break; + case 'summary': + $data['summary'] = $tag[1]; + break; + case 'image': + $data['image'] = $tag[1]; + break; + case 't': + $data['topics'][] = $tag[1]; + break; + case 'published_at': + $timestamp = (int)$tag[1]; + if ($timestamp > 0) { + $data['publishedAt'] = new \DateTimeImmutable('@' . $timestamp); + } + break; + } + } + + return $data; + } +} + diff --git a/symfony.lock b/symfony.lock index b8dd67e..21f84a2 100644 --- a/symfony.lock +++ b/symfony.lock @@ -282,6 +282,18 @@ "config/packages/twig_component.yaml" ] }, + "symfony/validator": { + "version": "7.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" + }, + "files": [ + "config/packages/validator.yaml" + ] + }, "symfony/web-profiler-bundle": { "version": "7.1", "recipe": { diff --git a/templates/pages/_advanced_metadata.html.twig b/templates/pages/_advanced_metadata.html.twig new file mode 100644 index 0000000..b382963 --- /dev/null +++ b/templates/pages/_advanced_metadata.html.twig @@ -0,0 +1,115 @@ +{% block _editor_advancedMetadata_widget %} + +{% endblock %} + diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index 46f59c3..f7461d1 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -1,14 +1,15 @@ {% extends 'layout.html.twig' %} -{% form_theme form _self %} +{% form_theme form _self 'pages/_advanced_metadata.html.twig' %} {% block quill_widget %}
{{ value|raw }}
+ +
- {% endblock %} {% block body %} @@ -83,6 +84,8 @@ 'attr': {'class': 'form-check-input'} }) }} + {{ form_row(form.advancedMetadata) }} +