Browse Source

bug-fix translations

imwald
Silberengel 2 weeks ago
parent
commit
96c80dd311
  1. 52
      src/components/ContentPreview/index.tsx
  2. 39
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 17
      src/components/NoteOptions/useMenuActions.tsx
  4. 6
      src/i18n/locales/en.ts
  5. 11
      src/index.css
  6. 40
      src/lib/advanced-lab-markup-protect.test.ts
  7. 22
      src/lib/advanced-lab-markup-protect.ts
  8. 11
      src/lib/note-translation-display.ts
  9. 35
      src/lib/read-aloud-content-language.test.ts
  10. 69
      src/lib/read-aloud-content-language.ts
  11. 127
      src/lib/read-aloud-nostr-expand.test.ts
  12. 139
      src/lib/read-aloud.ts
  13. 25
      src/lib/translate-client.ts
  14. 85
      src/lib/translate-note-for-menu.test.ts
  15. 228
      src/lib/translate-note-for-menu.ts
  16. 21
      src/services/client-events.service.ts

52
src/components/ContentPreview/index.tsx

@ -14,6 +14,7 @@ import { cn } from '@/lib/utils' @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -85,6 +86,7 @@ export default function ContentPreview({ @@ -85,6 +86,7 @@ export default function ContentPreview({
forParentReplyBlurb?: boolean
}) {
const { t } = useTranslation()
const noteTr = useNoteTranslation(event?.id ?? '')
const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER)
const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
@ -120,11 +122,13 @@ export default function ContentPreview({ @@ -120,11 +122,13 @@ export default function ContentPreview({
)
}
const previewEvent = mergeTranslatedNote(event, noteTr)
const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className)
const withKindRow = (node: React.ReactNode) => (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} event={event} size="small" />
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
<div className={cn('min-w-0', previewBody)}>{node}</div>
</div>
)
@ -139,92 +143,92 @@ export default function ContentPreview({ @@ -139,92 +143,92 @@ export default function ContentPreview({
ExtendedKind.PUBLIC_MESSAGE
].includes(event.kind)
) {
return withKindRow(<NormalContentPreview event={event} />)
return withKindRow(<NormalContentPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.DISCUSSION) {
return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} event={event} size="small" />
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
<div className={cn('min-w-0', previewBody)}>
<DiscussionNote event={event} size="small" />
<DiscussionNote event={previewEvent} size="small" />
</div>
</div>
)
}
if (event.kind === kinds.Highlights) {
return withKindRow(<HighlightPreview event={event} />)
return withKindRow(<HighlightPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(event)
const title = event.tags.find((t) => t[0] === 'title')?.[1]?.trim()
const href = getWebBookmarkArticleUrl(previewEvent)
const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim()
const line = title?.trim() || href?.trim() || t('Web bookmark')
return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>)
}
if (event.kind === ExtendedKind.POLL) {
if (forParentReplyBlurb) {
const snippet = parentReplyPollQuestionBlurb(event.content ?? '')
const snippet = parentReplyPollQuestionBlurb(previewEvent.content ?? '')
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate', previewBody)}>{snippet || t('Poll')}</div>
</div>
)
}
return withKindRow(<PollPreview event={event} />)
return withKindRow(<PollPreview event={previewEvent} />)
}
if (event.kind === kinds.LongFormArticle) {
return withKindRow(<LongFormArticlePreview event={event} />)
return withKindRow(<LongFormArticlePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
return withKindRow(<VideoNotePreview event={event} />)
return withKindRow(<VideoNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.PICTURE) {
return withKindRow(<PictureNotePreview event={event} />)
return withKindRow(<PictureNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
return withKindRow(<GroupMetadataPreview event={event} />)
return withKindRow(<GroupMetadataPreview event={previewEvent} />)
}
if (event.kind === kinds.CommunityDefinition) {
return withKindRow(<CommunityDefinitionPreview event={event} />)
return withKindRow(<CommunityDefinitionPreview event={previewEvent} />)
}
if (event.kind === kinds.LiveEvent) {
return withKindRow(<LiveEventPreview event={event} />)
return withKindRow(<LiveEventPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.ZAP_REQUEST) {
return withKindRow(<ZapPreview event={event} />)
return withKindRow(<ZapPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
if (previewDensity === 'compact') {
return (
<div className={cn('min-w-0', previewOuter)}>
<Zap event={event} variant="compact" omitSenderHeading className={previewBody} />
<Zap event={previewEvent} variant="compact" omitSenderHeading className={previewBody} />
</div>
)
}
return withKindRow(<ZapPreview event={event} />)
return withKindRow(<ZapPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) {
return withKindRow(<ApplicationHandlerInfo event={event} />)
return withKindRow(<ApplicationHandlerInfo event={previewEvent} />)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
return withKindRow(<ApplicationHandlerRecommendation event={event} />)
return withKindRow(<ApplicationHandlerRecommendation event={previewEvent} />)
}
if (event.kind === ExtendedKind.FOLLOW_PACK) {
return withKindRow(<FollowPackPreview event={event} />)
return withKindRow(<FollowPackPreview event={previewEvent} />)
}
if (
@ -232,7 +236,7 @@ export default function ContentPreview({ @@ -232,7 +236,7 @@ export default function ContentPreview({
event.kind === ExtendedKind.GIT_ISSUE ||
event.kind === ExtendedKind.GIT_RELEASE
) {
return withKindRow(<GitRepublicEventCard variant="compact" event={event} />)
return withKindRow(<GitRepublicEventCard variant="compact" event={previewEvent} />)
}
if (isNip25ReactionKind(event.kind)) {
@ -249,7 +253,7 @@ export default function ContentPreview({ @@ -249,7 +253,7 @@ export default function ContentPreview({
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} maxRawLength={24} variant="compact" />
<ReactionEmojiDisplay event={previewEvent} maxRawLength={24} variant="compact" />
)}
{t(notificationReactionSummaryKey(reactionDisplay))}
</div>
@ -270,5 +274,5 @@ export default function ContentPreview({ @@ -270,5 +274,5 @@ export default function ContentPreview({
)
}
return withKindRow(<div>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>)
return withKindRow(<div>[{t('Cannot handle event of kind k', { k: previewEvent.kind })}]</div>)
}

39
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -998,10 +998,7 @@ function parseMarkdownContentLegacy( @@ -998,10 +998,7 @@ function parseMarkdownContentLegacy(
}
// Blockquotes (> text or >) and Greentext (>text with no space)
else if (line.match(/^>\s*/)) {
// Check if this is greentext: >text with no space after >
// Pattern: > followed immediately by non-whitespace, non-> character
const greentextMatch = line.match(/^>([^\s>].*)$/)
const isGreentext = greentextMatch !== null
const isGreentext = isMarkdownGreentextLine(line.trim())
// Collect consecutive blockquote/greentext lines
const blockquoteLines: string[] = []
@ -1012,8 +1009,7 @@ function parseMarkdownContentLegacy( @@ -1012,8 +1009,7 @@ function parseMarkdownContentLegacy(
while (blockquoteLineIdx < lines.length) {
const blockquoteLine = lines[blockquoteLineIdx]
const lineGreentextMatch = blockquoteLine.match(/^>([^\s>].*)$/)
const lineIsGreentext = lineGreentextMatch !== null
const lineIsGreentext = isMarkdownGreentextLine(blockquoteLine.trim())
if (blockquoteLine.match(/^>\s*/)) {
// If we started with greentext, only continue if this line is also greentext
@ -1025,8 +1021,9 @@ function parseMarkdownContentLegacy( @@ -1025,8 +1021,9 @@ function parseMarkdownContentLegacy(
break
}
// Strip the > prefix and optional space
const content = blockquoteLine.replace(/^>\s?/, '')
const content = lineIsGreentext
? stripMarkdownGreentextMarker(blockquoteLine.trim())
: blockquoteLine.replace(/^>\s?/, '')
blockquoteLines.push(content)
blockquoteLineIdx++
tempIndex += blockquoteLine.length + 1 // +1 for newline
@ -2481,7 +2478,7 @@ function parseMarkdownContentLegacy( @@ -2481,7 +2478,7 @@ function parseMarkdownContentLegacy(
parts.push(
<span
key={`greentext-${patternIdx}`}
className="greentext block my-1"
className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]"
>
{greentextContent}
</span>
@ -3113,6 +3110,20 @@ function parseMarkdownContentLegacy( @@ -3113,6 +3110,20 @@ function parseMarkdownContentLegacy(
return { nodes: wrappedParts, hashtagsInContent, footnotes, citations }
}
/**
* Block **quote** (sidebar): `> ` `>` then ASCII space (CommonMark).
* **Greentext** (4chan-style): `>` not followed by that space, e.g. `>implying` vs `> implying`.
*/
function isMarkdownGreentextLine(trimmedLine: string): boolean {
if (!trimmedLine.startsWith('>')) return false
if (trimmedLine.length < 2) return false
return /^>(?! ).+$/.test(trimmedLine)
}
function stripMarkdownGreentextMarker(trimmedLine: string): string {
return trimmedLine.replace(/^>(?! )/, '')
}
/**
* Marked-driven markdown renderer (standard markdown blocks/inline), while keeping
* Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom.
@ -4224,13 +4235,17 @@ function parseMarkdownContentMarked( @@ -4224,13 +4235,17 @@ function parseMarkdownContentMarked(
case 'blockquote': {
const rawLines = String(token.raw ?? '')
.split('\n')
.map((line) => line.replace(/\r$/u, ''))
.filter((line) => line.trim().length > 0)
const isGreentext =
rawLines.length > 0 && rawLines.every((line) => /^>([^\s>].*)$/.test(line.trim()))
rawLines.length > 0 && rawLines.every((line) => isMarkdownGreentextLine(line.trim()))
if (isGreentext) {
const lines = rawLines.map((line) => line.replace(/^>\s?/, ''))
const lines = rawLines.map((line) => stripMarkdownGreentextMarker(line.trim()))
nodes.push(
<div key={`${key}-gt`} className="greentext block my-1">
<div
key={`${key}-gt`}
className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]"
>
{lines.map((line, idx) => (
<React.Fragment key={`${key}-gt-line-${idx}`}>
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-gt-inline-${idx}`)}

17
src/components/NoteOptions/useMenuActions.tsx

@ -15,7 +15,6 @@ import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/ @@ -15,7 +15,6 @@ import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/
import {
clearNoteTranslation,
getNoteTranslation,
setNoteTranslation,
subscribeNoteTranslations
} from '@/lib/note-translation-display'
import { speakNoteReadAloud } from '@/lib/read-aloud'
@ -60,7 +59,7 @@ import { nip19 } from 'nostr-tools' @@ -60,7 +59,7 @@ import { nip19 } from 'nostr-tools'
import {
articleHasTranslatableTitle,
eventHasTranslatableTextBody,
translateNoteForDisplay
translateNoteAndRelatedForDisplay
} from '@/lib/translate-note-for-menu'
import {
fetchTranslateLanguages,
@ -892,14 +891,12 @@ export function useMenuActions({ @@ -892,14 +891,12 @@ export function useMenuActions({
onClick: () => {
closeDrawer()
void toast.promise(
translateNoteForDisplay(event, opt.code).then((out) => {
setNoteTranslation(event.id, {
lang: opt.code,
langLabel: languageSelectSingleLine(opt.code),
content: out.content,
title: out.title
})
}),
translateNoteAndRelatedForDisplay(
event,
opt.code,
languageSelectSingleLine(opt.code),
(id) => client.fetchEvent(id)
),
{
loading: t('Translating note…'),
success: t('Note translated', {

6
src/i18n/locales/en.ts

@ -171,6 +171,12 @@ export default { @@ -171,6 +171,12 @@ export default {
"Read this note aloud": "Read this note aloud",
"Read-aloud is not supported in this browser": "Read-aloud is not supported in this browser",
"Nothing to read aloud": "Nothing to read aloud",
"Read aloud quoted from": "Quoted from {{name}}.",
"Read aloud unknown author": "Unknown author",
"Read aloud embedded note unavailable": "Quoted note not loaded.",
"Read aloud nostr profile unavailable": "Nostr profile reference.",
"Read aloud relay reference": "Nostr relay reference.",
"Read aloud nostr reference unavailable": "Nostr reference.",
"Read-aloud failed": "Read-aloud failed",
"Read aloud": "Read aloud",
"Read-aloud idle": "Idle",

11
src/index.css

@ -596,6 +596,17 @@ @@ -596,6 +596,17 @@
margin: 0.25rem 0;
}
/* Tailwind Typography `prose` sets body/link colors; keep greentext green inside note prose. */
.prose .greentext,
.prose-invert .greentext {
color: #4a7c3a;
}
.dark .prose-invert .greentext,
.dark .prose .greentext {
color: #8fbc8f;
}
/* Lightbox (yet-another-react-lightbox) caption title: wrap on narrow screens instead of truncating */
.yarl__slide_title {
white-space: normal !important;

40
src/lib/advanced-lab-markup-protect.test.ts

@ -10,6 +10,17 @@ vi.mock('@/lib/translate-client', () => ({ @@ -10,6 +10,17 @@ vi.mock('@/lib/translate-client', () => ({
}))
describe('getMarkupProtectRanges', () => {
it('kind-1 editor promo: most of body stays translatable (no runaway freeze before CodeMirror)', () => {
const t =
"I added an advanced editor to #imwald. I'm testing it, locally, and it seems like some sort of miracle. CodeMirror for Markdown and Asciidoc, grammar and spelling checks for all languages with my own instance of https://languagetool.org/, translations with LibreTranslate that can handle markup, citation event helper, Latex helper, custom emoji support, undo button, local autosave and recovery, npub/naddr/nevent mention, etc."
const merged = getMarkupProtectRanges(t, 'markdown')
const cm = t.indexOf('CodeMirror')
expect(cm).toBeGreaterThan(0)
expect(rangeIntersectsMerged(cm, 'CodeMirror'.length, merged)).toBe(false)
const frozen = merged.reduce((acc, [a, b]) => acc + (b - a), 0)
expect(frozen).toBeLessThan(t.length * 0.25)
})
it('freezes ATX heading marker and following whitespace', () => {
const merged = getMarkupProtectRanges('# Hello', 'markdown')
expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true)
@ -240,10 +251,39 @@ describe('translateAdvancedLabMarkup', () => { @@ -240,10 +251,39 @@ describe('translateAdvancedLabMarkup', () => {
expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1', 'Line2'])
})
it('optional preserveEmbeddedNewlinesInTranslatable sends one translatePlainText per translatable segment', async () => {
const { translatePlainText } = await import('@/lib/translate-client')
const spy = vi.mocked(translatePlainText)
spy.mockClear()
spy.mockImplementation(async (s: string) => `<<${s}>>`)
await translateAdvancedLabMarkup('Line1\nLine2', 'de', 'en', 'markdown', {
preserveEmbeddedNewlinesInTranslatable: true
})
expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1\nLine2'])
})
it('preserves blank lines between translated lines', async () => {
const { translatePlainText } = await import('@/lib/translate-client')
vi.mocked(translatePlainText).mockImplementation(async (s: string) => (s === 'A' ? 'Aa' : 'Bb'))
const out = await translateAdvancedLabMarkup('A\n\nB', 'de', 'en', 'markdown')
expect(out).toBe('Aa\n\nBb')
})
it('markdown: every blockquote line body is passed to translate (regression: middle line not frozen)', async () => {
const content =
'Far-right and far-left logic be like:\n\n' +
'> Stephen cheered when John was fired.\n' +
'> We do not like Stephen.\n' +
'> John should not have been fired.\n\n' +
'This is a logical argument the Germans call _Beifall von der falschen Seite._ Where you judge.'
const { translatePlainText } = await import('@/lib/translate-client')
const spy = vi.mocked(translatePlainText)
spy.mockClear()
spy.mockImplementation(async (s: string) => `[${s}]`)
await translateAdvancedLabMarkup(content, 'de', 'en', 'markdown')
const payloads = spy.mock.calls.map((c) => String(c[0]))
expect(payloads.some((p) => p.includes('We do not like Stephen'))).toBe(true)
expect(payloads.some((p) => p.includes('Stephen cheered when John was fired'))).toBe(true)
expect(payloads.some((p) => p.includes('John should not have been fired'))).toBe(true)
})
})

22
src/lib/advanced-lab-markup-protect.ts

@ -13,6 +13,15 @@ import { translatePlainText } from '@/lib/translate-client' @@ -13,6 +13,15 @@ import { translatePlainText } from '@/lib/translate-client'
export type AdvancedLabMarkupMode = 'markdown' | 'asciidoc'
export type TranslateAdvancedLabMarkupOptions = {
/**
* When true, translatable segments are sent in a single `translatePlainText` call per segment,
* preserving embedded newlines. Default splits on newlines so LibreTranslate cannot drop a line;
* coalesced Markdown blockquote runs use this so the model sees full quote context in one `q`.
*/
preserveEmbeddedNewlinesInTranslatable?: boolean
}
function mergeSortedRanges(ranges: [number, number][]): [number, number][] {
if (ranges.length === 0) return []
const s = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1])
@ -953,8 +962,12 @@ function buildSegments(text: string, merged: [number, number][]): Seg[] { @@ -953,8 +962,12 @@ function buildSegments(text: string, merged: [number, number][]): Seg[] {
async function translatePreservingLineBreaks(
text: string,
targetLang: string,
sourceLang: string
sourceLang: string,
opts?: TranslateAdvancedLabMarkupOptions
): Promise<string> {
if (opts?.preserveEmbeddedNewlinesInTranslatable && text.trim() !== '') {
return translatePlainText(text, targetLang, sourceLang)
}
const pieces = text.split(/(\r?\n+)/)
const out: string[] = []
for (const p of pieces) {
@ -976,11 +989,12 @@ export async function translateAdvancedLabMarkup( @@ -976,11 +989,12 @@ export async function translateAdvancedLabMarkup(
text: string,
targetLang: string,
sourceLang: string,
mode: AdvancedLabMarkupMode
mode: AdvancedLabMarkupMode,
options?: TranslateAdvancedLabMarkupOptions
): Promise<string> {
const merged = getMarkupProtectRanges(text, mode)
if (merged.length === 0) {
return translatePreservingLineBreaks(text, targetLang, sourceLang)
return translatePreservingLineBreaks(text, targetLang, sourceLang, options)
}
const segs = buildSegments(text, merged)
const parts = await Promise.all(
@ -988,7 +1002,7 @@ export async function translateAdvancedLabMarkup( @@ -988,7 +1002,7 @@ export async function translateAdvancedLabMarkup(
const chunk = text.slice(s.start, s.end)
if (!s.translatable) return chunk
if (!chunk) return chunk
return translatePreservingLineBreaks(chunk, targetLang, sourceLang)
return translatePreservingLineBreaks(chunk, targetLang, sourceLang, options)
})
)
return parts.join('')

11
src/lib/note-translation-display.ts

@ -9,6 +9,11 @@ export type NoteTranslationEntry = { @@ -9,6 +9,11 @@ export type NoteTranslationEntry = {
content: string
/** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */
title?: string
/**
* Related notes (parent preview, embedded) translated in the same action as this note.
* Cleared together when the user chooses show original on this note.
*/
coTranslatedIds?: string[]
}
const map = new Map<string, NoteTranslationEntry>()
@ -29,6 +34,12 @@ export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry) @@ -29,6 +34,12 @@ export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry)
}
export function clearNoteTranslation(eventId: string): void {
const entry = map.get(eventId)
if (entry?.coTranslatedIds?.length) {
for (const id of entry.coTranslatedIds) {
map.delete(id)
}
}
map.delete(eventId)
emit()
}

35
src/lib/read-aloud-content-language.test.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { detectReadAloudContentLanguage } from '@/lib/read-aloud-content-language'
describe('detectReadAloudContentLanguage', () => {
it('detects German from umlauts', () => {
expect(detectReadAloudContentLanguage('Grüße aus München und Spaß')).toBe('de')
})
it('detects plain ASCII English', () => {
expect(detectReadAloudContentLanguage('Hello this is a test note about nothing special.')).toBe('en')
})
it('prefers British English when UK spellings dominate', () => {
const s =
'The colour of the behaviour was odd; we organised a favourable defence of the centre.'
expect(detectReadAloudContentLanguage(s)).toBe('en-gb')
})
it('prefers US English when American spellings dominate', () => {
const s = 'The color and behavior at the center were organized with a traveling neighbor.'
expect(detectReadAloudContentLanguage(s)).toBe('en')
})
it('detects Russian from Cyrillic', () => {
expect(detectReadAloudContentLanguage('Привет мир это тест')).toBe('ru')
})
it('detects Polish', () => {
expect(detectReadAloudContentLanguage('Cześć, to jest żółć i łódź.')).toBe('pl')
})
it('detects Turkish from distinctive letters (avoid ü so German is not triggered)', () => {
expect(detectReadAloudContentLanguage('Merhaba, dağ ve şeker \u0130stanbul.')).toBe('tr')
})
})

69
src/lib/read-aloud-content-language.ts

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
/**
* Heuristic language guess for read-aloud / Piper when there is no persisted translation `lang`.
* Keep in sync with `services/piper-tts-proxy/server.ts` `detectLanguage` (same script / ratio logic),
* plus `en-gb` hints and a few extra Latin scripts (pl, cs, tr) that have Piper voices in-app.
*/
export function detectReadAloudContentLanguage(text: string): string {
if (!text || text.length === 0) return 'en'
const sample = text.slice(0, Math.min(500, text.length))
const total = sample.length || 1
const germanChars = (sample.match(/[äöüßÄÖÜ]/g) || []).length
const frenchChars = (sample.match(/[éèêëàâäçôùûüÉÈÊËÀÂÄÇÔÙÛÜ]/g) || []).length
const spanishChars = (sample.match(/[ñáéíóúüÑÁÉÍÓÚÜ¿¡]/g) || []).length
const italianChars = (sample.match(/[àèéìòùÀÈÉÌÒÙ]/g) || []).length
const cyrillicChars = (sample.match(/[а-яёА-ЯЁ]/g) || []).length
const hangulChars = (sample.match(/[\uac00-\ud7af]/g) || []).length
const kanaChars = (sample.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length
const hanChars = (sample.match(/[\u4e00-\u9fff]/g) || []).length
const arabicChars = (sample.match(/[\u0600-\u06ff]/g) || []).length
const polishChars = (sample.match(/[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g) || []).length
const czechChars = (sample.match(/[řůŘŮ]/g) || []).length
/** Exclude üöç (shared with German / French); rely on ğ/ı/ş/İ so “Grüße” is not Turkish. */
const turkishChars = (sample.match(/[ğĞıİşŞ]/g) || []).length
const cyrillicRatio = cyrillicChars / total
const hangulRatio = hangulChars / total
const kanaRatio = kanaChars / total
const hanRatio = hanChars / total
const arabicRatio = arabicChars / total
const germanRatio = germanChars / total
const frenchRatio = frenchChars / total
const spanishRatio = spanishChars / total
const italianRatio = italianChars / total
const polishRatio = polishChars / total
const czechRatio = czechChars / total
const turkishRatio = turkishChars / total
if (cyrillicRatio > 0.1) return 'ru'
if (hangulRatio > 0.06 || kanaRatio > 0.02) return 'en'
if (hanRatio > 0.1) return 'zh'
if (arabicRatio > 0.1) return 'ar'
if (germanRatio > 0.02) return 'de'
if (frenchRatio > 0.02) return 'fr'
/** Before Spanish: shared letters like `ó` (Polish) would otherwise count as Spanish. */
if (polishRatio > 0.02) return 'pl'
if (czechRatio > 0.015) return 'cs'
if (spanishRatio > 0.02) return 'es'
if (italianRatio > 0.02) return 'it'
if (turkishRatio > 0.02) return 'tr'
if (preferBritishEnglish(sample)) {
return 'en-gb'
}
return 'en'
}
/** Weak signal: UK spellings vs US spellings when the rest looks like Latin “English”. */
function preferBritishEnglish(sample: string): boolean {
const uk =
/\b(colour|behaviour|realise|realising|centre|defence|favour|favourite|organised|travelling|neighbour|humour|labour)\b/gi
const us =
/\b(color|behavior|realize|realizing|center|defense|favor|favorite|organized|traveling|neighbor|humor|labor)\b/gi
let ukN = 0
let usN = 0
for (const _ of sample.matchAll(uk)) ukN++
for (const _ of sample.matchAll(us)) usN++
return ukN > usN
}

127
src/lib/read-aloud-nostr-expand.test.ts

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import type { Event } from 'nostr-tools'
import client from '@/services/client.service'
import { expandNostrReferencesForReadAloud } from '@/lib/read-aloud'
vi.mock('@/i18n', () => ({
default: {
t: (key: string, opts?: { name?: string }) => {
const table: Record<string, string> = {
'Read aloud unknown author': 'Unknown author',
'Read aloud embedded note unavailable': 'Quoted note not loaded.',
'Read aloud nostr profile unavailable': 'Nostr profile reference.',
'Read aloud relay reference': 'Nostr relay reference.',
'Read aloud nostr reference unavailable': 'Nostr reference.'
}
if (key === 'Read aloud quoted from') {
return `Quoted from ${opts?.name ?? ''}.`
}
return table[key] ?? key
}
},
normalizeToSupportedAppLanguage: (c: string) => c as 'en',
LocalizedLanguageNames: {} as Record<'en', string>
}))
vi.mock('@/services/client.service', () => ({
default: {
peekSessionCachedEvent: vi.fn(),
eventService: {
getSessionMetadataForPubkey: vi.fn()
}
}
}))
const peek = vi.mocked(client.peekSessionCachedEvent)
const getMeta = vi.mocked(client.eventService.getSessionMetadataForPubkey)
describe('expandNostrReferencesForReadAloud', () => {
beforeEach(() => {
peek.mockReset()
getMeta.mockReset()
})
it('leaves plain text unchanged', () => {
expect(expandNostrReferencesForReadAloud('hello world')).toBe('hello world')
})
it('replaces nostr:note with placeholder when event is not cached', () => {
const sk = generateSecretKey()
const ev = finalizeEvent({ kind: 1, content: 'x', tags: [], created_at: 1 }, sk)
const note = nip19.noteEncode(ev.id)
peek.mockReturnValue(undefined)
expect(expandNostrReferencesForReadAloud(`See nostr:${note} please`)).toBe('See Quoted note not loaded. please')
})
it('replaces nostr:note with quoted-from line and body when cached', () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const inner = finalizeEvent({ kind: 1, content: 'Inner **bold**', tags: [], created_at: 2 }, sk)
const note = nip19.noteEncode(inner.id)
peek.mockImplementation((id: string) => {
if (id === note || id === inner.id) return inner as Event
return undefined
})
const profile = finalizeEvent(
{
kind: 0,
pubkey: pk,
content: JSON.stringify({ display_name: 'Pat' }),
tags: [],
created_at: 1
},
sk
) as Event
getMeta.mockImplementation((hex: string) => (hex.toLowerCase() === pk.toLowerCase() ? profile : undefined))
const out = expandNostrReferencesForReadAloud(`Quote: nostr:${note} end`)
expect(out).toContain('Quoted from Pat.')
expect(out).toContain('Inner bold')
expect(out).not.toContain('nostr:')
})
it('uses unknown author when embedded note is cached but profile is not', () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const inner = finalizeEvent({ kind: 1, content: 'Hi', tags: [], created_at: 2 }, sk)
const note = nip19.noteEncode(inner.id)
peek.mockImplementation((id: string) => (id === note || id === inner.id ? (inner as Event) : undefined))
getMeta.mockReturnValue(undefined)
const out = expandNostrReferencesForReadAloud(`nostr:${note}`)
expect(out).toMatch(/^Quoted from Unknown author\. Hi$/)
})
it('replaces nostr:npub with display name when kind 0 is cached', () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const profile = finalizeEvent(
{
kind: 0,
pubkey: pk,
content: JSON.stringify({ name: 'n_name' }),
tags: [],
created_at: 1
},
sk
) as Event
const npub = nip19.npubEncode(pk)
getMeta.mockImplementation((hex: string) => (hex.toLowerCase() === pk.toLowerCase() ? profile : undefined))
expect(expandNostrReferencesForReadAloud(`Hey nostr:${npub}!`)).toBe('Hey n_name!')
})
it('replaces nostr:npub with placeholder when profile is not cached', () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const npub = nip19.npubEncode(pk)
getMeta.mockReturnValue(undefined)
expect(expandNostrReferencesForReadAloud(`x nostr:${npub} y`)).toBe('x Nostr profile reference. y')
})
it('does not match bare npub inside nostr:npub… (no double replacement)', () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const npub = nip19.npubEncode(pk)
getMeta.mockReturnValue(undefined)
expect(expandNostrReferencesForReadAloud(`nostr:${npub}`)).toBe('Nostr profile reference.')
})
})

139
src/lib/read-aloud.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage } from '@/i18n'
import i18n, { LocalizedLanguageNames } from '@/i18n'
import { getNoteTranslation } from '@/lib/note-translation-display'
import { detectReadAloudContentLanguage } from '@/lib/read-aloud-content-language'
import {
getPiperVoiceForChosenLanguage,
isTrinityLanguageCode,
@ -14,10 +15,19 @@ import { @@ -14,10 +15,19 @@ import {
} from '@/lib/piper-tts-cache-policy'
import indexedDb from '@/services/indexed-db.service'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import {
BECH32_NADDR,
BECH32_NEVENT,
BECH32_NOTE,
BECH32_NPROFILE,
BECH32_NPUB,
NOSTR_URI_INLINE_REGEX
} from '@/lib/content-patterns'
import { getLongFormArticleMetadataFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import { Event, kinds } from 'nostr-tools'
import client from '@/services/client.service'
import { Event, kinds, nip19 } from 'nostr-tools'
/** Keep each Piper request small: long JSON bodies and WAV responses can OOM or time out the server. */
const PIPER_CHUNK_MAX_CHARS = 3600
@ -303,6 +313,122 @@ function buildReadAloudPlainText(event: Event): string { @@ -303,6 +313,122 @@ function buildReadAloudPlainText(event: Event): string {
return stripMarkupForReadAloud(raw)
}
/** Bare bech32 not prefixed with `nostr:` (negative lookbehind avoids matching inside `nostr:…`). */
const READ_ALOUD_BARE_BECH32_REGEX = new RegExp(
`(?<!nostr:)\\b(?:${BECH32_NPUB}|${BECH32_NPROFILE}|${BECH32_NOTE}|${BECH32_NEVENT}|${BECH32_NADDR})`,
'gi'
)
const READ_ALOUD_NOSTR_EXPAND_MAX_DEPTH = 5
function readAloudHasRichProfile(p: ReturnType<typeof getProfileFromEvent>): boolean {
return Boolean(
(p.original_username && p.original_username.trim()) || (p.nip05 && String(p.nip05).trim())
)
}
function readAloudProfileLabelForPubkey(hexPubkey: string): string | null {
const meta = client.eventService.getSessionMetadataForPubkey(hexPubkey)
if (!meta) return null
const p = getProfileFromEvent(meta)
if (!readAloudHasRichProfile(p)) return null
try {
const o = JSON.parse(meta.content || '{}') as { display_name?: string; name?: string }
const fromJson = (o.display_name?.trim() || o.name?.trim()) ?? ''
if (fromJson) return fromJson
} catch {
/* ignore */
}
return (p.original_username || p.nip05 || '').trim() || null
}
function readAloudAuthorNameForQuote(hexPubkey: string): string {
return readAloudProfileLabelForPubkey(hexPubkey) ?? i18n.t('Read aloud unknown author')
}
function replacementForNostrBech32ReadAloud(bech32: string, depth: number): string {
if (depth > READ_ALOUD_NOSTR_EXPAND_MAX_DEPTH) {
return i18n.t('Read aloud nostr reference unavailable')
}
const trimmed = bech32.trim()
try {
const decoded = nip19.decode(trimmed)
switch (decoded.type) {
case 'note':
case 'nevent':
case 'naddr': {
const ev = client.peekSessionCachedEvent(trimmed)
if (!ev) {
return i18n.t('Read aloud embedded note unavailable')
}
const quotedFrom = i18n.t('Read aloud quoted from', {
name: readAloudAuthorNameForQuote(ev.pubkey)
})
const body = buildReadAloudPlainText(ev)
return `${quotedFrom} ${expandNostrReferencesForReadAloud(body, depth + 1)}`
}
case 'npub':
case 'nprofile': {
const pk = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey
const label = readAloudProfileLabelForPubkey(pk)
if (label) return label
return i18n.t('Read aloud nostr profile unavailable')
}
default: {
if ((decoded as { type: string }).type === 'nrelay') {
return i18n.t('Read aloud relay reference')
}
return i18n.t('Read aloud nostr reference unavailable')
}
}
} catch {
return i18n.t('Read aloud nostr reference unavailable')
}
}
function replaceAllNostrUriInline(text: string, depth: number): string {
const re = new RegExp(NOSTR_URI_INLINE_REGEX.source, NOSTR_URI_INLINE_REGEX.flags)
const matches = [...text.matchAll(re)]
matches.sort((a, b) => (b.index ?? 0) - (a.index ?? 0))
let out = text
for (const m of matches) {
const idx = m.index
const full = m[0]
const inner = m[1]
if (idx === undefined || !full || !inner) continue
const replacement = replacementForNostrBech32ReadAloud(inner, depth)
out = out.slice(0, idx) + replacement + out.slice(idx + full.length)
}
return out
}
function replaceAllBareNostrBech32(text: string, depth: number): string {
const matches = [...text.matchAll(READ_ALOUD_BARE_BECH32_REGEX)]
matches.sort((a, b) => (b.index ?? 0) - (a.index ?? 0))
let out = text
for (const m of matches) {
const idx = m.index
const full = m[0]
if (idx === undefined || !full) continue
const replacement = replacementForNostrBech32ReadAloud(full, depth)
out = out.slice(0, idx) + replacement + out.slice(idx + full.length)
}
return out
}
/**
* Expands `nostr:` links and bare Nostr bech32 using session cache only (no network).
* Exported for unit tests.
*/
export function expandNostrReferencesForReadAloud(text: string, depth = 0): string {
if (depth > READ_ALOUD_NOSTR_EXPAND_MAX_DEPTH) {
return text
}
let out = replaceAllNostrUriInline(text, depth)
out = replaceAllBareNostrBech32(out, depth)
return out
}
function isAbortError(e: unknown): boolean {
return (
(e instanceof DOMException && e.name === 'AbortError') ||
@ -719,14 +845,17 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult> @@ -719,14 +845,17 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
} else {
text = buildReadAloudPlainText(event)
}
text = expandNostrReferencesForReadAloud(text.trim())
if (!text) {
return 'empty'
}
const title = readAloudTitleFromEvent(event)
const chosenReadAloudLang: string =
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en')
/** Persisted translate action carries an explicit target `lang`; otherwise infer from body (Piper + Web Speech). */
const chosenReadAloudLang: string = persistedTranslation?.lang?.trim()
? persistedTranslation.lang.trim()
: detectReadAloudContentLanguage(text)
const {
voice: piperVoice,
usedEnglishVoiceFallback,

25
src/lib/translate-client.ts

@ -153,6 +153,15 @@ export async function translatePlainText( @@ -153,6 +153,15 @@ export async function translatePlainText(
if (!base) {
throw new Error('Translation URL not configured')
}
/** LibreTranslate often trims `q` / `translatedText`; keep edge whitespace so markup segments still join cleanly. */
const leadingWs = text.match(/^\s*/u)?.[0] ?? ''
const trailingWs = text.match(/\s*$/u)?.[0] ?? ''
const core = text.slice(leadingWs.length, text.length - trailingWs.length)
if (core === '') {
return text
}
const resolvedTarget = translateApiLanguageCode(targetLang)
const resolvedSource =
sourceLang === 'auto' ? 'auto' : translateApiLanguageCode(sourceLang)
@ -171,17 +180,17 @@ export async function translatePlainText( @@ -171,17 +180,17 @@ export async function translatePlainText(
)
}
const key = cacheKey(text, resolvedSource, resolvedTarget)
const key = cacheKey(core, resolvedSource, resolvedTarget)
const hit = memoryCache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
logger.info('[AdvancedLab] translate', {
source: resolvedSource,
target: resolvedTarget,
inputChars: text.length,
outputChars: hit.text.length,
outputChars: hit.text.length + leadingWs.length + trailingWs.length,
cacheHit: true
})
return hit.text
return leadingWs + hit.text + trailingWs
}
const url = `${base}/translate`
@ -189,7 +198,7 @@ export async function translatePlainText( @@ -189,7 +198,7 @@ export async function translatePlainText(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
q: core,
source: resolvedSource,
target: resolvedTarget,
format: 'text'
@ -204,15 +213,15 @@ export async function translatePlainText( @@ -204,15 +213,15 @@ export async function translatePlainText(
)
}
const data = (await res.json()) as { translatedText?: string }
const out = data.translatedText ?? ''
const outCore = data.translatedText ?? ''
pruneMemory()
memoryCache.set(key, { text: out, at: Date.now() })
memoryCache.set(key, { text: outCore, at: Date.now() })
logger.info('[AdvancedLab] translate', {
source: resolvedSource,
target: resolvedTarget,
inputChars: text.length,
outputChars: out.length,
outputChars: leadingWs.length + outCore.length + trailingWs.length,
cacheHit: false
})
return out
return leadingWs + outCore + trailingWs
}

85
src/lib/translate-note-for-menu.test.ts

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Event } from 'nostr-tools'
import { translateNoteForDisplay } from '@/lib/translate-note-for-menu'
const BLOCKQUOTE_THREE_LINES =
'Intro paragraph.\n\n' +
'> Stephen cheered when John was fired.\n' +
'> We do not like Stephen.\n' +
'> John should not have been fired.\n\n' +
'After quote.'
vi.mock('@/lib/translate-client', () => ({
/** Identity so chunk assembly can be asserted without delimiter noise from the mock. */
translatePlainText: vi.fn(async (text: string) => text),
normalizeTranslateLangCode: (c: string) => c,
translateApiLanguageCode: (c: string) => c,
translateServerSupportsLogicalTarget: () => true,
isTranslateConfigured: () => true,
fetchTranslateLanguages: vi.fn(async () => []),
clearTranslateLanguagesCache: vi.fn()
}))
function kind1Event(content: string): Event {
return {
id: '0'.repeat(64),
pubkey: '1'.repeat(64),
kind: 1,
content,
tags: [],
created_at: 0,
sig: ''
} as Event
}
describe('translateNoteForDisplay', () => {
afterEach(async () => {
const { translatePlainText } = await import('@/lib/translate-client')
vi.mocked(translatePlainText).mockImplementation(async (text: string) => text)
})
it('keeps blank lines across chunk boundaries (list → paragraph)', async () => {
const fill = 'word '.repeat(520)
const content = `${fill.trimEnd()}\n\nFINAL_LINE_UNIQUE`
expect(content.length).toBeGreaterThan(2500)
const out = await translateNoteForDisplay(kind1Event(content), 'fr')
expect(out.content).toContain('\n\nFINAL_LINE_UNIQUE')
})
it('keeps markdown bullet list, blank line, and paragraph across chunk splits', async () => {
const long = 'x'.repeat(700)
const tail = 'z'.repeat(2200)
const content = `- ${long}\n- y\n\nPARA_MARKER${tail}`
expect(content.length).toBeGreaterThan(2500)
const out = await translateNoteForDisplay(kind1Event(content), 'fr')
expect(out.content).toContain('\n\nPARA_MARKER')
expect(out.content).toContain('- y')
expect(out.content).toContain('PARA_MARKER')
expect(out.content).toBe(content)
})
it('coalesces consecutive Markdown blockquote bodies into one translatePlainText (embedded newlines)', async () => {
const { translatePlainText } = await import('@/lib/translate-client')
const spy = vi.mocked(translatePlainText)
spy.mockClear()
spy.mockImplementation(async (s: string) => `[${s}]`)
const ev = {
id: '0'.repeat(64),
pubkey: '1'.repeat(64),
kind: 1,
content: BLOCKQUOTE_THREE_LINES,
tags: [],
created_at: 0,
sig: ''
} as Event
await translateNoteForDisplay(ev, 'de')
const payloads = spy.mock.calls.map((c) => String(c[0]))
const merged = payloads.find(
(p) => p.includes('Stephen cheered when John was fired') && p.includes('We do not like Stephen')
)
expect(merged).toBeDefined()
expect(merged).toContain('John should not have been fired')
})
})

228
src/lib/translate-note-for-menu.ts

@ -4,12 +4,122 @@ import { @@ -4,12 +4,122 @@ import {
translateAdvancedLabMarkup,
type AdvancedLabMarkupMode
} from '@/lib/advanced-lab-markup-protect'
import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { getParentEventHexId } from '@/lib/event'
import { setNoteTranslation } from '@/lib/note-translation-display'
import { normalizeTranslateLangCode } from '@/lib/translate-client'
import type { Event } from 'nostr-tools'
import { nip19, type Event } from 'nostr-tools'
const CHUNK_MAX = 2500
/** GFM-style blockquote line (indent, `>`, optional space, body). */
const MD_BLOCKQUOTE_LINE = /^([\t ]{0,3})(> ?)(.*)$/
function isMarkdownFenceDelimiterLine(line: string): boolean {
return /^[\t ]{0,3}```/.test(line.replace(/\r$/u, ''))
}
/**
* LibreTranslate can leave an isolated middle line in English when each `>` line is translated
* separately. Coalesce consecutive blockquote bodies (outside fenced code) into one request with
* embedded newlines preserved via {@link translateAdvancedLabMarkup} options.
*/
async function translateMarkdownBodyCoalescingBlockquotes(text: string, target: string): Promise<string> {
const lines = text.split(/\r?\n/)
let inFence = false
type PlainSeg = { type: 'plain'; lines: string[] }
type BqSeg = { type: 'bq'; lines: string[] }
type Seg = PlainSeg | BqSeg
const segments: Seg[] = []
const pushPlainLine = (ln: string): void => {
const last = segments[segments.length - 1]
if (last?.type === 'plain') last.lines.push(ln)
else segments.push({ type: 'plain', lines: [ln] })
}
let i = 0
while (i < lines.length) {
const line = lines[i]!
if (isMarkdownFenceDelimiterLine(line)) {
inFence = !inFence
pushPlainLine(line)
i++
continue
}
if (inFence) {
pushPlainLine(line)
i++
continue
}
const m = line.match(MD_BLOCKQUOTE_LINE)
if (m) {
const runLines: string[] = []
while (i < lines.length) {
if (isMarkdownFenceDelimiterLine(lines[i]!)) break
const m2 = lines[i]!.match(MD_BLOCKQUOTE_LINE)
if (!m2) break
runLines.push(lines[i]!)
i++
}
segments.push({ type: 'bq', lines: runLines })
continue
}
pushPlainLine(line)
i++
}
const outs: string[] = []
for (const seg of segments) {
if (seg.type === 'plain') {
const joined = seg.lines.join('\n')
outs.push(joined === '' ? '' : await translateAdvancedLabMarkup(joined, target, 'auto', 'markdown'))
continue
}
const runLines = seg.lines
const prefixes: string[] = []
const bodies: string[] = []
for (const ln of runLines) {
const mm = ln.match(MD_BLOCKQUOTE_LINE)!
prefixes.push(mm[1]! + mm[2]!)
bodies.push(mm[3] ?? '')
}
if (bodies.length === 0) continue
if (bodies.length === 1) {
const tb = await translateAdvancedLabMarkup(bodies[0]!, target, 'auto', 'markdown')
outs.push(`${prefixes[0]}${tb}`)
continue
}
const joinedBodies = bodies.join('\n')
const translatedJoined = await translateAdvancedLabMarkup(joinedBodies, target, 'auto', 'markdown', {
preserveEmbeddedNewlinesInTranslatable: true
})
const outLines = translatedJoined.split(/\r?\n/)
if (outLines.length !== bodies.length) {
const perLine = await Promise.all(
bodies.map((b) => translateAdvancedLabMarkup(b, target, 'auto', 'markdown'))
)
outs.push(prefixes.map((pref, idx) => `${pref}${perLine[idx]}`).join('\n'))
} else {
outs.push(prefixes.map((pref, idx) => `${pref}${outLines[idx] ?? ''}`).join('\n'))
}
}
return outs.join('\n')
}
async function translateBodyChunk(
core: string,
target: string,
markupMode: AdvancedLabMarkupMode
): Promise<string> {
if (core.trim() === '') return ''
if (markupMode === 'markdown') {
return translateMarkdownBodyCoalescingBlockquotes(core, target)
}
return translateAdvancedLabMarkup(core, target, 'auto', markupMode)
}
function looksLikeStringifiedJsonObject(content: string): boolean {
const trimmed = content.trim()
if (
@ -40,7 +150,13 @@ export function articleHasTranslatableTitle(event: Event): boolean { @@ -40,7 +150,13 @@ export function articleHasTranslatableTitle(event: Event): boolean {
return Boolean(getLongFormArticleMetadataFromEvent(event).title?.trim())
}
/** Same exclusions as the advanced lab (`translateAdvancedLabMarkup`). Chunk large bodies for the API. */
/**
* Same exclusions as the advanced lab (`translateAdvancedLabMarkup`). Chunk large bodies for the API.
*
* Trailing whitespace/newlines on a chunk must not be dropped when advancing `rest` (they are not
* re-sent on the next iteration). Do not `trimStart()` the remainder or blank lines after lists and
* paragraph breaks vanish from the output.
*/
async function translateLongProtectedBody(
text: string,
target: string,
@ -49,7 +165,7 @@ async function translateLongProtectedBody( @@ -49,7 +165,7 @@ async function translateLongProtectedBody(
const t = text.trim()
if (!t) return text
if (t.length <= CHUNK_MAX) {
return translateAdvancedLabMarkup(t, target, 'auto', markupMode)
return translateBodyChunk(t, target, markupMode)
}
const blocks: string[] = []
let rest = t
@ -59,13 +175,20 @@ async function translateLongProtectedBody( @@ -59,13 +175,20 @@ async function translateLongProtectedBody(
if (nl > 600) {
slice = rest.slice(0, nl + 1)
}
const part = slice.trimEnd()
if (part) {
blocks.push(await translateAdvancedLabMarkup(part, target, 'auto', markupMode))
let endCore = slice.length
while (endCore > 0 && /\s/u.test(slice[endCore - 1]!)) {
endCore--
}
rest = rest.slice(slice.length).trimStart()
const core = slice.slice(0, endCore)
const trailingLiteral = slice.slice(endCore)
const translated =
core.trim() === ''
? ''
: await translateBodyChunk(core, target, markupMode)
blocks.push(translated + trailingLiteral)
rest = rest.slice(slice.length)
}
return blocks.join('\n')
return blocks.join('')
}
/**
@ -88,3 +211,92 @@ export async function translateNoteForDisplay( @@ -88,3 +211,92 @@ export async function translateNoteForDisplay(
: rawContent
return { content: content || rawContent, title }
}
/**
* Parent (`e` reply) and `nostr:…` embeds in the body same scope as prefetch, but not every thread `e` tag.
*/
export function collectRelatedNoteTranslateTargets(event: Event): {
hexIds: string[]
nip19Pointers: string[]
} {
const hexSet = new Set<string>()
const nip19Set = new Set<string>()
const self = event.id.toLowerCase()
const addHex = (id: string | undefined) => {
if (!id) return
const h = id.trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(h) && h !== self) hexSet.add(h)
}
addHex(getParentEventHexId(event))
const body = event.content ?? ''
for (const full of body.match(EMBEDDED_EVENT_REGEX) ?? []) {
const colon = full.indexOf(':')
if (colon < 0) continue
const bech32 = full.slice(colon + 1).trim()
if (!bech32) continue
try {
const { type, data } = nip19.decode(bech32)
if (type === 'note') addHex(data)
else if (type === 'nevent') addHex(data.id)
else if (type === 'naddr') nip19Set.add(bech32)
} catch {
/* ignore */
}
}
return { hexIds: Array.from(hexSet), nip19Pointers: Array.from(nip19Set) }
}
/**
* Translates the note body/title and any reply-parent / embedded notes shown with it, then updates the translation store.
*/
export async function translateNoteAndRelatedForDisplay(
event: Event,
targetCode: string,
langLabel: string,
fetchEvent: (id: string) => Promise<Event | undefined>
): Promise<void> {
const mainOut = await translateNoteForDisplay(event, targetCode)
const { hexIds, nip19Pointers } = collectRelatedNoteTranslateTargets(event)
const coIds: string[] = []
const seenRel = new Set<string>()
const self = event.id.toLowerCase()
const translateRelated = async (rel: Event) => {
const idl = rel.id.toLowerCase()
if (idl === self || seenRel.has(idl)) return
if (!eventHasTranslatableTextBody(rel) && !articleHasTranslatableTitle(rel)) return
seenRel.add(idl)
try {
const out = await translateNoteForDisplay(rel, targetCode)
setNoteTranslation(rel.id, {
lang: targetCode,
langLabel,
content: out.content,
title: out.title
})
coIds.push(rel.id)
} catch {
seenRel.delete(idl)
}
}
for (const hex of hexIds) {
const rel = await fetchEvent(hex)
if (rel) await translateRelated(rel)
}
for (const ptr of nip19Pointers) {
const rel = await fetchEvent(ptr)
if (rel) await translateRelated(rel)
}
setNoteTranslation(event.id, {
lang: targetCode,
langLabel,
content: mainOut.content,
title: mainOut.title,
coTranslatedIds: coIds.length > 0 ? coIds : undefined
})
}

21
src/services/client-events.service.ts

@ -147,13 +147,28 @@ export class EventService { @@ -147,13 +147,28 @@ export class EventService {
/**
* Read parent/root (or any) event from the session cache without removing it.
* Accepts hex, note1, or nevent1 (not naddr).
* Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only).
*/
peekSessionCachedEvent(noteId: string): NEvent | undefined {
const hex = this.resolveHexWaiterKey(noteId.trim())
if (!hex) return undefined
const trimmed = noteId.trim()
const hex = this.resolveHexWaiterKey(trimmed)
if (hex) {
return this.getSessionEventIfAllowed(hex)
}
try {
const { type, data } = nip19.decode(trimmed)
if (type === 'naddr') {
return this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey,
kind: data.kind,
identifier: data.identifier
})
}
} catch {
/* invalid */
}
return undefined
}
/**
* When an event with this id is added to the session cache, invoke `callback` (and when already cached).

Loading…
Cancel
Save