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. 23
      src/services/client-events.service.ts

52
src/components/ContentPreview/index.tsx

@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -85,6 +86,7 @@ export default function ContentPreview({
forParentReplyBlurb?: boolean forParentReplyBlurb?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const noteTr = useNoteTranslation(event?.id ?? '')
const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER) const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER)
const muteList = useMuteListOptional() const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
@ -120,11 +122,13 @@ export default function ContentPreview({
) )
} }
const previewEvent = mergeTranslatedNote(event, noteTr)
const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className) const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className)
const withKindRow = (node: React.ReactNode) => ( const withKindRow = (node: React.ReactNode) => (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <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 className={cn('min-w-0', previewBody)}>{node}</div>
</div> </div>
) )
@ -139,92 +143,92 @@ export default function ContentPreview({
ExtendedKind.PUBLIC_MESSAGE ExtendedKind.PUBLIC_MESSAGE
].includes(event.kind) ].includes(event.kind)
) { ) {
return withKindRow(<NormalContentPreview event={event} />) return withKindRow(<NormalContentPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.DISCUSSION) { if (event.kind === ExtendedKind.DISCUSSION) {
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <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)}> <div className={cn('min-w-0', previewBody)}>
<DiscussionNote event={event} size="small" /> <DiscussionNote event={previewEvent} size="small" />
</div> </div>
</div> </div>
) )
} }
if (event.kind === kinds.Highlights) { if (event.kind === kinds.Highlights) {
return withKindRow(<HighlightPreview event={event} />) return withKindRow(<HighlightPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.WEB_BOOKMARK) { if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(event) const href = getWebBookmarkArticleUrl(previewEvent)
const title = event.tags.find((t) => t[0] === 'title')?.[1]?.trim() const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim()
const line = title?.trim() || href?.trim() || t('Web bookmark') const line = title?.trim() || href?.trim() || t('Web bookmark')
return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>) return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>)
} }
if (event.kind === ExtendedKind.POLL) { if (event.kind === ExtendedKind.POLL) {
if (forParentReplyBlurb) { if (forParentReplyBlurb) {
const snippet = parentReplyPollQuestionBlurb(event.content ?? '') const snippet = parentReplyPollQuestionBlurb(previewEvent.content ?? '')
return ( return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}> <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 className={cn('min-w-0 truncate', previewBody)}>{snippet || t('Poll')}</div>
</div> </div>
) )
} }
return withKindRow(<PollPreview event={event} />) return withKindRow(<PollPreview event={previewEvent} />)
} }
if (event.kind === kinds.LongFormArticle) { 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) { 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) { if (event.kind === ExtendedKind.PICTURE) {
return withKindRow(<PictureNotePreview event={event} />) return withKindRow(<PictureNotePreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.GROUP_METADATA) { if (event.kind === ExtendedKind.GROUP_METADATA) {
return withKindRow(<GroupMetadataPreview event={event} />) return withKindRow(<GroupMetadataPreview event={previewEvent} />)
} }
if (event.kind === kinds.CommunityDefinition) { if (event.kind === kinds.CommunityDefinition) {
return withKindRow(<CommunityDefinitionPreview event={event} />) return withKindRow(<CommunityDefinitionPreview event={previewEvent} />)
} }
if (event.kind === kinds.LiveEvent) { if (event.kind === kinds.LiveEvent) {
return withKindRow(<LiveEventPreview event={event} />) return withKindRow(<LiveEventPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.ZAP_REQUEST) { 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 (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
if (previewDensity === 'compact') { if (previewDensity === 'compact') {
return ( return (
<div className={cn('min-w-0', previewOuter)}> <div className={cn('min-w-0', previewOuter)}>
<Zap event={event} variant="compact" omitSenderHeading className={previewBody} /> <Zap event={previewEvent} variant="compact" omitSenderHeading className={previewBody} />
</div> </div>
) )
} }
return withKindRow(<ZapPreview event={event} />) return withKindRow(<ZapPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) {
return withKindRow(<ApplicationHandlerInfo event={event} />) return withKindRow(<ApplicationHandlerInfo event={previewEvent} />)
} }
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
return withKindRow(<ApplicationHandlerRecommendation event={event} />) return withKindRow(<ApplicationHandlerRecommendation event={previewEvent} />)
} }
if (event.kind === ExtendedKind.FOLLOW_PACK) { if (event.kind === ExtendedKind.FOLLOW_PACK) {
return withKindRow(<FollowPackPreview event={event} />) return withKindRow(<FollowPackPreview event={previewEvent} />)
} }
if ( if (
@ -232,7 +236,7 @@ export default function ContentPreview({
event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_ISSUE ||
event.kind === ExtendedKind.GIT_RELEASE event.kind === ExtendedKind.GIT_RELEASE
) { ) {
return withKindRow(<GitRepublicEventCard variant="compact" event={event} />) return withKindRow(<GitRepublicEventCard variant="compact" event={previewEvent} />)
} }
if (isNip25ReactionKind(event.kind)) { if (isNip25ReactionKind(event.kind)) {
@ -249,7 +253,7 @@ export default function ContentPreview({
{DISCUSSION_DOWNVOTE_DISPLAY} {DISCUSSION_DOWNVOTE_DISPLAY}
</span> </span>
) : ( ) : (
<ReactionEmojiDisplay event={event} maxRawLength={24} variant="compact" /> <ReactionEmojiDisplay event={previewEvent} maxRawLength={24} variant="compact" />
)} )}
{t(notificationReactionSummaryKey(reactionDisplay))} {t(notificationReactionSummaryKey(reactionDisplay))}
</div> </div>
@ -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(
} }
// Blockquotes (> text or >) and Greentext (>text with no space) // Blockquotes (> text or >) and Greentext (>text with no space)
else if (line.match(/^>\s*/)) { else if (line.match(/^>\s*/)) {
// Check if this is greentext: >text with no space after > const isGreentext = isMarkdownGreentextLine(line.trim())
// Pattern: > followed immediately by non-whitespace, non-> character
const greentextMatch = line.match(/^>([^\s>].*)$/)
const isGreentext = greentextMatch !== null
// Collect consecutive blockquote/greentext lines // Collect consecutive blockquote/greentext lines
const blockquoteLines: string[] = [] const blockquoteLines: string[] = []
@ -1012,8 +1009,7 @@ function parseMarkdownContentLegacy(
while (blockquoteLineIdx < lines.length) { while (blockquoteLineIdx < lines.length) {
const blockquoteLine = lines[blockquoteLineIdx] const blockquoteLine = lines[blockquoteLineIdx]
const lineGreentextMatch = blockquoteLine.match(/^>([^\s>].*)$/) const lineIsGreentext = isMarkdownGreentextLine(blockquoteLine.trim())
const lineIsGreentext = lineGreentextMatch !== null
if (blockquoteLine.match(/^>\s*/)) { if (blockquoteLine.match(/^>\s*/)) {
// If we started with greentext, only continue if this line is also greentext // If we started with greentext, only continue if this line is also greentext
@ -1025,8 +1021,9 @@ function parseMarkdownContentLegacy(
break break
} }
// Strip the > prefix and optional space const content = lineIsGreentext
const content = blockquoteLine.replace(/^>\s?/, '') ? stripMarkdownGreentextMarker(blockquoteLine.trim())
: blockquoteLine.replace(/^>\s?/, '')
blockquoteLines.push(content) blockquoteLines.push(content)
blockquoteLineIdx++ blockquoteLineIdx++
tempIndex += blockquoteLine.length + 1 // +1 for newline tempIndex += blockquoteLine.length + 1 // +1 for newline
@ -2481,7 +2478,7 @@ function parseMarkdownContentLegacy(
parts.push( parts.push(
<span <span
key={`greentext-${patternIdx}`} key={`greentext-${patternIdx}`}
className="greentext block my-1" className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]"
> >
{greentextContent} {greentextContent}
</span> </span>
@ -3113,6 +3110,20 @@ function parseMarkdownContentLegacy(
return { nodes: wrappedParts, hashtagsInContent, footnotes, citations } 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 * Marked-driven markdown renderer (standard markdown blocks/inline), while keeping
* Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom. * Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom.
@ -4224,13 +4235,17 @@ function parseMarkdownContentMarked(
case 'blockquote': { case 'blockquote': {
const rawLines = String(token.raw ?? '') const rawLines = String(token.raw ?? '')
.split('\n') .split('\n')
.map((line) => line.replace(/\r$/u, ''))
.filter((line) => line.trim().length > 0) .filter((line) => line.trim().length > 0)
const isGreentext = const isGreentext =
rawLines.length > 0 && rawLines.every((line) => /^>([^\s>].*)$/.test(line.trim())) rawLines.length > 0 && rawLines.every((line) => isMarkdownGreentextLine(line.trim()))
if (isGreentext) { if (isGreentext) {
const lines = rawLines.map((line) => line.replace(/^>\s?/, '')) const lines = rawLines.map((line) => stripMarkdownGreentextMarker(line.trim()))
nodes.push( 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) => ( {lines.map((line, idx) => (
<React.Fragment key={`${key}-gt-line-${idx}`}> <React.Fragment key={`${key}-gt-line-${idx}`}>
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-gt-inline-${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/
import { import {
clearNoteTranslation, clearNoteTranslation,
getNoteTranslation, getNoteTranslation,
setNoteTranslation,
subscribeNoteTranslations subscribeNoteTranslations
} from '@/lib/note-translation-display' } from '@/lib/note-translation-display'
import { speakNoteReadAloud } from '@/lib/read-aloud' import { speakNoteReadAloud } from '@/lib/read-aloud'
@ -60,7 +59,7 @@ import { nip19 } from 'nostr-tools'
import { import {
articleHasTranslatableTitle, articleHasTranslatableTitle,
eventHasTranslatableTextBody, eventHasTranslatableTextBody,
translateNoteForDisplay translateNoteAndRelatedForDisplay
} from '@/lib/translate-note-for-menu' } from '@/lib/translate-note-for-menu'
import { import {
fetchTranslateLanguages, fetchTranslateLanguages,
@ -892,14 +891,12 @@ export function useMenuActions({
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
void toast.promise( void toast.promise(
translateNoteForDisplay(event, opt.code).then((out) => { translateNoteAndRelatedForDisplay(
setNoteTranslation(event.id, { event,
lang: opt.code, opt.code,
langLabel: languageSelectSingleLine(opt.code), languageSelectSingleLine(opt.code),
content: out.content, (id) => client.fetchEvent(id)
title: out.title ),
})
}),
{ {
loading: t('Translating note…'), loading: t('Translating note…'),
success: t('Note translated', { success: t('Note translated', {

6
src/i18n/locales/en.ts

@ -171,6 +171,12 @@ export default {
"Read this note aloud": "Read this note aloud", "Read this note aloud": "Read this note aloud",
"Read-aloud is not supported in this browser": "Read-aloud is not supported in this browser", "Read-aloud is not supported in this browser": "Read-aloud is not supported in this browser",
"Nothing to read aloud": "Nothing to read aloud", "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 failed": "Read-aloud failed",
"Read aloud": "Read aloud", "Read aloud": "Read aloud",
"Read-aloud idle": "Idle", "Read-aloud idle": "Idle",

11
src/index.css

@ -596,6 +596,17 @@
margin: 0.25rem 0; 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 */ /* Lightbox (yet-another-react-lightbox) caption title: wrap on narrow screens instead of truncating */
.yarl__slide_title { .yarl__slide_title {
white-space: normal !important; white-space: normal !important;

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

@ -10,6 +10,17 @@ vi.mock('@/lib/translate-client', () => ({
})) }))
describe('getMarkupProtectRanges', () => { 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', () => { it('freezes ATX heading marker and following whitespace', () => {
const merged = getMarkupProtectRanges('# Hello', 'markdown') const merged = getMarkupProtectRanges('# Hello', 'markdown')
expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true) expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true)
@ -240,10 +251,39 @@ describe('translateAdvancedLabMarkup', () => {
expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1', 'Line2']) 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 () => { it('preserves blank lines between translated lines', async () => {
const { translatePlainText } = await import('@/lib/translate-client') const { translatePlainText } = await import('@/lib/translate-client')
vi.mocked(translatePlainText).mockImplementation(async (s: string) => (s === 'A' ? 'Aa' : 'Bb')) vi.mocked(translatePlainText).mockImplementation(async (s: string) => (s === 'A' ? 'Aa' : 'Bb'))
const out = await translateAdvancedLabMarkup('A\n\nB', 'de', 'en', 'markdown') const out = await translateAdvancedLabMarkup('A\n\nB', 'de', 'en', 'markdown')
expect(out).toBe('Aa\n\nBb') 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'
export type AdvancedLabMarkupMode = 'markdown' | 'asciidoc' 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][] { function mergeSortedRanges(ranges: [number, number][]): [number, number][] {
if (ranges.length === 0) return [] if (ranges.length === 0) return []
const s = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]) 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[] {
async function translatePreservingLineBreaks( async function translatePreservingLineBreaks(
text: string, text: string,
targetLang: string, targetLang: string,
sourceLang: string sourceLang: string,
opts?: TranslateAdvancedLabMarkupOptions
): Promise<string> { ): Promise<string> {
if (opts?.preserveEmbeddedNewlinesInTranslatable && text.trim() !== '') {
return translatePlainText(text, targetLang, sourceLang)
}
const pieces = text.split(/(\r?\n+)/) const pieces = text.split(/(\r?\n+)/)
const out: string[] = [] const out: string[] = []
for (const p of pieces) { for (const p of pieces) {
@ -976,11 +989,12 @@ export async function translateAdvancedLabMarkup(
text: string, text: string,
targetLang: string, targetLang: string,
sourceLang: string, sourceLang: string,
mode: AdvancedLabMarkupMode mode: AdvancedLabMarkupMode,
options?: TranslateAdvancedLabMarkupOptions
): Promise<string> { ): Promise<string> {
const merged = getMarkupProtectRanges(text, mode) const merged = getMarkupProtectRanges(text, mode)
if (merged.length === 0) { if (merged.length === 0) {
return translatePreservingLineBreaks(text, targetLang, sourceLang) return translatePreservingLineBreaks(text, targetLang, sourceLang, options)
} }
const segs = buildSegments(text, merged) const segs = buildSegments(text, merged)
const parts = await Promise.all( const parts = await Promise.all(
@ -988,7 +1002,7 @@ export async function translateAdvancedLabMarkup(
const chunk = text.slice(s.start, s.end) const chunk = text.slice(s.start, s.end)
if (!s.translatable) return chunk if (!s.translatable) return chunk
if (!chunk) return chunk if (!chunk) return chunk
return translatePreservingLineBreaks(chunk, targetLang, sourceLang) return translatePreservingLineBreaks(chunk, targetLang, sourceLang, options)
}) })
) )
return parts.join('') return parts.join('')

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

@ -9,6 +9,11 @@ export type NoteTranslationEntry = {
content: string content: string
/** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */ /** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */
title?: string 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>() const map = new Map<string, NoteTranslationEntry>()
@ -29,6 +34,12 @@ export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry)
} }
export function clearNoteTranslation(eventId: string): void { 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) map.delete(eventId)
emit() emit()
} }

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

@ -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 @@
/**
* 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 @@
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 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' 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 { getNoteTranslation } from '@/lib/note-translation-display'
import { detectReadAloudContentLanguage } from '@/lib/read-aloud-content-language'
import { import {
getPiperVoiceForChosenLanguage, getPiperVoiceForChosenLanguage,
isTrinityLanguageCode, isTrinityLanguageCode,
@ -14,10 +15,19 @@ import {
} from '@/lib/piper-tts-cache-policy' } from '@/lib/piper-tts-cache-policy'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' 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 logger from '@/lib/logger'
import { normalizeTranslateLangCode } from '@/lib/translate-client' 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. */ /** Keep each Piper request small: long JSON bodies and WAV responses can OOM or time out the server. */
const PIPER_CHUNK_MAX_CHARS = 3600 const PIPER_CHUNK_MAX_CHARS = 3600
@ -303,6 +313,122 @@ function buildReadAloudPlainText(event: Event): string {
return stripMarkupForReadAloud(raw) 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 { function isAbortError(e: unknown): boolean {
return ( return (
(e instanceof DOMException && e.name === 'AbortError') || (e instanceof DOMException && e.name === 'AbortError') ||
@ -719,14 +845,17 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
} else { } else {
text = buildReadAloudPlainText(event) text = buildReadAloudPlainText(event)
} }
text = expandNostrReferencesForReadAloud(text.trim())
if (!text) { if (!text) {
return 'empty' return 'empty'
} }
const title = readAloudTitleFromEvent(event) const title = readAloudTitleFromEvent(event)
const chosenReadAloudLang: string = /** Persisted translate action carries an explicit target `lang`; otherwise infer from body (Piper + Web Speech). */
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en') const chosenReadAloudLang: string = persistedTranslation?.lang?.trim()
? persistedTranslation.lang.trim()
: detectReadAloudContentLanguage(text)
const { const {
voice: piperVoice, voice: piperVoice,
usedEnglishVoiceFallback, usedEnglishVoiceFallback,

25
src/lib/translate-client.ts

@ -153,6 +153,15 @@ export async function translatePlainText(
if (!base) { if (!base) {
throw new Error('Translation URL not configured') 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 resolvedTarget = translateApiLanguageCode(targetLang)
const resolvedSource = const resolvedSource =
sourceLang === 'auto' ? 'auto' : translateApiLanguageCode(sourceLang) sourceLang === 'auto' ? 'auto' : translateApiLanguageCode(sourceLang)
@ -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) const hit = memoryCache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL_MS) { if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
logger.info('[AdvancedLab] translate', { logger.info('[AdvancedLab] translate', {
source: resolvedSource, source: resolvedSource,
target: resolvedTarget, target: resolvedTarget,
inputChars: text.length, inputChars: text.length,
outputChars: hit.text.length, outputChars: hit.text.length + leadingWs.length + trailingWs.length,
cacheHit: true cacheHit: true
}) })
return hit.text return leadingWs + hit.text + trailingWs
} }
const url = `${base}/translate` const url = `${base}/translate`
@ -189,7 +198,7 @@ export async function translatePlainText(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
q: text, q: core,
source: resolvedSource, source: resolvedSource,
target: resolvedTarget, target: resolvedTarget,
format: 'text' format: 'text'
@ -204,15 +213,15 @@ export async function translatePlainText(
) )
} }
const data = (await res.json()) as { translatedText?: string } const data = (await res.json()) as { translatedText?: string }
const out = data.translatedText ?? '' const outCore = data.translatedText ?? ''
pruneMemory() pruneMemory()
memoryCache.set(key, { text: out, at: Date.now() }) memoryCache.set(key, { text: outCore, at: Date.now() })
logger.info('[AdvancedLab] translate', { logger.info('[AdvancedLab] translate', {
source: resolvedSource, source: resolvedSource,
target: resolvedTarget, target: resolvedTarget,
inputChars: text.length, inputChars: text.length,
outputChars: out.length, outputChars: leadingWs.length + outCore.length + trailingWs.length,
cacheHit: false cacheHit: false
}) })
return out return leadingWs + outCore + trailingWs
} }

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

@ -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 {
translateAdvancedLabMarkup, translateAdvancedLabMarkup,
type AdvancedLabMarkupMode type AdvancedLabMarkupMode
} from '@/lib/advanced-lab-markup-protect' } from '@/lib/advanced-lab-markup-protect'
import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' 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 { normalizeTranslateLangCode } from '@/lib/translate-client'
import type { Event } from 'nostr-tools' import { nip19, type Event } from 'nostr-tools'
const CHUNK_MAX = 2500 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 { function looksLikeStringifiedJsonObject(content: string): boolean {
const trimmed = content.trim() const trimmed = content.trim()
if ( if (
@ -40,7 +150,13 @@ export function articleHasTranslatableTitle(event: Event): boolean {
return Boolean(getLongFormArticleMetadataFromEvent(event).title?.trim()) 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( async function translateLongProtectedBody(
text: string, text: string,
target: string, target: string,
@ -49,7 +165,7 @@ async function translateLongProtectedBody(
const t = text.trim() const t = text.trim()
if (!t) return text if (!t) return text
if (t.length <= CHUNK_MAX) { if (t.length <= CHUNK_MAX) {
return translateAdvancedLabMarkup(t, target, 'auto', markupMode) return translateBodyChunk(t, target, markupMode)
} }
const blocks: string[] = [] const blocks: string[] = []
let rest = t let rest = t
@ -59,13 +175,20 @@ async function translateLongProtectedBody(
if (nl > 600) { if (nl > 600) {
slice = rest.slice(0, nl + 1) slice = rest.slice(0, nl + 1)
} }
const part = slice.trimEnd() let endCore = slice.length
if (part) { while (endCore > 0 && /\s/u.test(slice[endCore - 1]!)) {
blocks.push(await translateAdvancedLabMarkup(part, target, 'auto', markupMode)) 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(
: rawContent : rawContent
return { content: content || rawContent, title } 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
})
}

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

@ -147,12 +147,27 @@ export class EventService {
/** /**
* Read parent/root (or any) event from the session cache without removing it. * 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 { peekSessionCachedEvent(noteId: string): NEvent | undefined {
const hex = this.resolveHexWaiterKey(noteId.trim()) const trimmed = noteId.trim()
if (!hex) return undefined const hex = this.resolveHexWaiterKey(trimmed)
return this.getSessionEventIfAllowed(hex) 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
} }
/** /**

Loading…
Cancel
Save