Browse Source

Advanced metadata

imwald
Nuša Pukšič 2 months ago
parent
commit
8c83bfabcd
  1. 345
      assets/controllers/advanced_metadata_controller.js
  2. 285
      assets/controllers/nostr-utils.ts
  3. 617
      assets/controllers/nostr_publish_controller.js
  4. 4
      assets/styles/04-pages/highlights.css
  5. 150
      assets/styles/advanced-metadata.css
  6. 1
      composer.json
  7. 103
      composer.lock
  8. 3
      importmap.php
  9. 27
      migrations/Version20251105000000.php
  10. 43
      src/Controller/ArticleController.php
  11. 67
      src/Dto/AdvancedMetadata.php
  12. 51
      src/Dto/ZapSplit.php
  13. 14
      src/Entity/Article.php
  14. 106
      src/Form/AdvancedMetadataType.php
  15. 7
      src/Form/EditorType.php
  16. 66
      src/Form/ZapSplitType.php
  17. 192
      src/Service/Nostr/NostrEventBuilder.php
  18. 188
      src/Service/Nostr/NostrEventParser.php
  19. 12
      symfony.lock
  20. 115
      templates/pages/_advanced_metadata.html.twig
  21. 7
      templates/pages/editor.html.twig
  22. 2
      templates/pages/highlights.html.twig

345
assets/controllers/advanced_metadata_controller.js

@ -0,0 +1,345 @@ @@ -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 = '<small>Share: <span class="share-percent">0</span>%</small>';
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();
}
}

285
assets/controllers/nostr-utils.ts

@ -0,0 +1,285 @@ @@ -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
};
}

617
assets/controllers/nostr_publish_controller.js

@ -1,273 +1,418 @@ @@ -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[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert formatting
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert links
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Convert images (handle src/alt in any order)
markdown = markdown.replace(/<img\b[^>]*>/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[^>]*>(.*?)<\/ul>/gis, '$1\n');
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n');
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
// Convert paragraphs
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
markdown = markdown.replace(/<br[^>]*>/gi, '\n');
// Convert blockquotes
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n');
// Convert code blocks
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n');
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
// Clean up HTML entities and remaining tags
markdown = markdown.replace(/&nbsp;/g, ' ');
markdown = markdown.replace(/&amp;/g, '&');
markdown = markdown.replace(/&lt;/g, '<');
markdown = markdown.replace(/&gt;/g, '>');
markdown = markdown.replace(/&quot;/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 = `<div class="alert alert-info">${message}</div>`;
}
// Add advanced metadata tags
if (formData.advancedMetadata) {
const advancedTags = buildAdvancedTags(formData.advancedMetadata);
tags.push(...advancedTags);
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
// 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 = `<div class="alert alert-danger">${message}</div>`;
}
return await response.json();
}
htmlToMarkdown(html) {
// Basic HTML to Markdown conversion
let markdown = html;
// Convert headers
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert formatting
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert links
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Convert images
markdown = markdown.replace(/<img\b[^>]*>/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[^>]*>(.*?)<\/ul>/gis, '$1\n');
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n');
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
// Convert paragraphs
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
markdown = markdown.replace(/<br[^>]*>/gi, '\n');
// Convert blockquotes
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n');
// Convert code blocks
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n');
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
// Clean up HTML entities and remaining tags
markdown = markdown.replace(/&nbsp;/g, ' ');
markdown = markdown.replace(/&amp;/g, '&');
markdown = markdown.replace(/&lt;/g, '<');
markdown = markdown.replace(/&gt;/g, '>');
markdown = markdown.replace(/&quot;/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 = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}
}

4
assets/styles/04-pages/highlights.css

@ -100,6 +100,10 @@ @@ -100,6 +100,10 @@
}
@media (max-width: 768px) {
.highlights-grid {
column-count: 1;
}
.highlight-card {
margin-bottom: 1.5rem;
}

150
assets/styles/advanced-metadata.css

@ -0,0 +1,150 @@ @@ -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;
}

1
composer.json

@ -52,6 +52,7 @@ @@ -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": "*",

103
composer.lock generated

@ -4,7 +4,7 @@ @@ -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 @@ @@ -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",

3
importmap.php

@ -112,4 +112,7 @@ return [ @@ -112,4 +112,7 @@ return [
'version' => '0.16.25',
'type' => 'css',
],
'katex/dist/katex.min.js' => [
'version' => '0.16.25',
],
];

27
migrations/Version20251105000000.php

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251105000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add advanced_metadata field to article table for storing Nostr advanced tags';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE article ADD advanced_metadata JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE article DROP advanced_metadata');
}
}

43
src/Controller/ArticleController.php

@ -2,10 +2,13 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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();

67
src/Dto/AdvancedMetadata.php

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class AdvancedMetadata
{
public bool $doNotRepublish = false;
#[Assert\Choice(choices: [
'',
'CC0-1.0',
'CC-BY-4.0',
'CC-BY-SA-4.0',
'CC-BY-NC-4.0',
'CC-BY-NC-SA-4.0',
'CC-BY-ND-4.0',
'CC-BY-NC-ND-4.0',
'MIT',
'Apache-2.0',
'GPL-3.0',
'AGPL-3.0',
'All rights reserved',
'custom'
])]
public string $license = '';
public ?string $customLicense = null;
/** @var ZapSplit[] */
#[Assert\Valid]
public array $zapSplits = [];
public ?string $contentWarning = null;
public ?int $expirationTimestamp = null;
public bool $isProtected = false;
/** @var array<string, mixed> 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;
}
}

51
src/Dto/ZapSplit.php

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class ZapSplit
{
#[Assert\NotBlank(message: 'Recipient is required')]
public string $recipient = '';
#[Assert\Regex(
pattern: '/^wss:\/\/.+/',
message: 'Relay must be a valid WebSocket URL starting with wss://'
)]
public ?string $relay = null;
#[Assert\PositiveOrZero(message: 'Weight must be 0 or greater')]
public ?int $weight = null;
/** Computed share percentage (0-100) */
public ?float $sharePercent = null;
public function __construct(
string $recipient = '',
?string $relay = null,
?int $weight = null
) {
$this->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');
}
}

14
src/Entity/Article.php

@ -79,6 +79,9 @@ class Article @@ -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 @@ -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;
}
}

106
src/Form/AdvancedMetadataType.php

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Dto\AdvancedMetadata;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AdvancedMetadataType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}

7
src/Form/EditorType.php

@ -4,6 +4,7 @@ declare(strict_types=1); @@ -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 @@ -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

66
src/Form/ZapSplitType.php

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Dto\ZapSplit;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ZapSplitType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}

192
src/Service/Nostr/NostrEventBuilder.php

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Service\Nostr;
use App\Dto\AdvancedMetadata;
use App\Dto\ZapSplit;
use App\Entity\Article;
use nostriphant\NIP19\Bech32;
use swentel\nostr\Key\Key;
class NostrEventBuilder
{
private Key $key;
public function __construct()
{
$this->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;
}
}

188
src/Service/Nostr/NostrEventParser.php

@ -0,0 +1,188 @@ @@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Service\Nostr;
use App\Dto\AdvancedMetadata;
use App\Dto\ZapSplit;
use swentel\nostr\Key\Key;
class NostrEventParser
{
private Key $key;
public function __construct()
{
$this->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", <hex-pubkey>, <relay>, <weight>]
* 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;
}
}

12
symfony.lock

@ -282,6 +282,18 @@ @@ -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": {

115
templates/pages/_advanced_metadata.html.twig

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
{% block _editor_advancedMetadata_widget %}
<div class="advanced-metadata-section" {{ stimulus_controller('advanced-metadata') }}>
<details class="mb-4">
<summary class="h5 cursor-pointer">Zap splits and content warning</summary>
<div class="mt-2 p-3 border rounded">
<div class="row hidden">
<div class="col-md-6">
{{ form_row(form.doNotRepublish) }}
</div>
<div class="col-md-6">
{{ form_row(form.license, {
'attr': {
'data-advanced-metadata-target': 'licenseSelect',
'data-action': 'change->advanced-metadata#licenseChanged'
}
}) }}
</div>
</div>
<div class="row hidden">
<div class="col-md-12">
{{ form_row(form.customLicense, {
'attr': {
'data-advanced-metadata-target': 'customLicenseInput'
}
}) }}
</div>
</div>
<h6>Zap Splits</h6>
<p class="text-muted small">Configure multiple recipients for zaps (tips). Weights determine the split ratio.</p>
<div data-advanced-metadata-target="zapSplitsContainer"
data-prototype="{{ form_widget(form.zapSplits.vars.prototype)|e('html_attr') }}"
data-index="{{ form.zapSplits|length }}">
{% for zapSplit in form.zapSplits %}
<div class="zap-split-item mb-3 p-3 border rounded" data-index="{{ loop.index0 }}">
<div class="row">
<div class="col-md-5">
{{ form_row(zapSplit.recipient) }}
</div>
<div class="col-md-4">
{{ form_row(zapSplit.relay) }}
</div>
<div class="col-md-3">
{{ form_row(zapSplit.weight) }}
</div>
</div>
<button type="button"
class="btn btn-sm btn-danger mt-2"
data-action="click->advanced-metadata#removeZapSplit"
data-index="{{ loop.index0 }}">
Remove
</button>
<div class="zap-share-display mt-2 text-muted">
<small>Share: <span class="share-percent">0</span>%</small>
</div>
</div>
{% endfor %}
</div>
<div class="mb-3">
<button type="button"
class="btn btn-sm btn-secondary"
data-advanced-metadata-target="addZapButton"
data-action="click->advanced-metadata#addZapSplit">
Add Zap Recipient
</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-advanced-metadata-target="distributeEquallyButton"
data-action="click->advanced-metadata#distributeEqually">
Distribute Equally
</button>
</div>
<hr class="my-4">
<div class="row">
<div class="col-md-6">
{{ form_row(form.contentWarning) }}
</div>
</div>
<div class="row hidden">
<div class="col-md-6">
{{ form_row(form.expirationTimestamp, {
'attr': {
'data-advanced-metadata-target': 'expirationInput',
'data-action': 'change->advanced-metadata#expirationChanged'
}
}) }}
</div>
</div>
<div class="row hidden">
<div class="col-md-12">
{{ form_row(form.isProtected, {
'attr': {
'data-advanced-metadata-target': 'protectedCheckbox',
'data-action': 'change->advanced-metadata#protectedChanged'
}
}) }}
<div class="alert alert-warning mt-2"
style="display: none;"
data-advanced-metadata-target="protectedWarning">
<small><strong>Warning:</strong> Some relays may reject protected events. Use with caution.</small>
</div>
</div>
</div>
</div>
</details>
</div>
{% endblock %}

7
templates/pages/editor.html.twig

@ -1,14 +1,15 @@ @@ -1,14 +1,15 @@
{% extends 'layout.html.twig' %}
{% form_theme form _self %}
{% form_theme form _self 'pages/_advanced_metadata.html.twig' %}
{% block quill_widget %}
<div {{ stimulus_controller('quill') }} class="quill" data-id="{{ id }}" >
<div id="editor">
{{ value|raw }}
</div>
<input type="hidden" name="editor[content_md]" data-quill-target="markdown">
<input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" />
</div>
<input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" />
{% endblock %}
{% block body %}
@ -83,6 +84,8 @@ @@ -83,6 +84,8 @@
'attr': {'class': 'form-check-input'}
}) }}
{{ form_row(form.advancedMetadata) }}
<div class="actions">
<button type="button"
class="btn btn-primary"

2
templates/pages/highlights.html.twig

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
{% block body %}
<twig:Atoms:PageHeading
heading="Article Highlights"
heading="Highlights"
tagline="Noteworthy passages highlighted by the community"
/>

Loading…
Cancel
Save