diff --git a/README.md b/README.md index 5930ce6d13..820ca9b76a 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,8 @@ implementation('com.vitorpamplona.quartz:quartz-android:1.16.0') implementation('com.vitorpamplona.quartz:quartz-jvm:1.16.0') implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.16.0') implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.16.0') +implementation('com.vitorpamplona.quartz:quartz-macosarm64:1.16.0') +implementation('com.vitorpamplona.quartz:quartz-linuxx64:1.16.0') ``` Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) @@ -469,28 +471,6 @@ When your app goes to the background, you can use NostrClient's `connect` and `d methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect` to `onPause` methods. -### Feature Parity Table - -| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | -|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------| -| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | | -| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | | -| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | | -| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | | -| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | | -| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. | -| | GZip Compression | ✅ Full | ✅ Full | | -| | BitSet | ✅ Full | ✅ Full | | -| | LargeCache | ✅ Full | ✅ Full | | -| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | | -| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | -| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | -| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | | -| | Unicode Normalization | ✅ Full | ✅ Full | | -| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | -| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | - - ## Contributing See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide — workflow, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt index 68077c7d66..9c44871db6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent +import com.vitorpamplona.quartz.utils.Log +import kotlin.coroutines.cancellation.CancellationException /** * Content-extraction helpers shared by the per-kind notification renderers: @@ -176,15 +178,24 @@ object NotificationContent { return RenderedText(text, resolved.citedUsers, imageUrl) } + /** + * A private zap request is signed by a one-time key; the real zapper and message are encrypted + * for the zapped author. Decrypts them when [signer] is that recipient, otherwise (or when the + * signer can't) returns the request as-is. + */ suspend fun decryptZapContentAuthor( event: ZapRequestEvent, signer: NostrSigner, - ): Event? = - if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { + ): Event? { + if (!event.isPrivateZap() || signer.pubKey !in event.zappedAuthor()) return event + return try { signer.decryptZapEvent(event) - } else { + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NotificationContent", "Could not decrypt private zap ${event.id}", e) event } + } suspend fun decryptContent( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt index 95c69a495f..ff1a900619 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta as AppRende import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader as AppRenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack as AppRenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform as AppRenderAudioWithWaveform +import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent as AppRenderCalendarRSVPEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame as AppRenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent as AppRenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent as AppRenderGitPatchEvent @@ -436,6 +437,17 @@ object AndroidNotePlatform : NotePlatform { nav = nav, ) + @Composable + override fun RenderCalendarRSVPEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, + ) = AppRenderCalendarRSVPEvent( + note = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + @Composable override fun RenderMeetingSpaceEvent( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt new file mode 100644 index 0000000000..5a898c4e03 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView +import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard +import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent + +/** + * Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is + * in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment merged + * into its frame — "going" is only interesting next to *what*. Until the appointment loads the + * card shows its status strip over a "loading" line. + * + * [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in + * `LocalCache` — a shell with a null event if we have never seen the appointment — and + * [WatchNoteEvent]'s event-finder subscription then asks relays for it: `filterMissingAddressables` + * picks up exactly those addressables whose `event == null` and queries the address author's + * outbox relays plus any stored hints. + * + * Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its + * `a` tag into the appointment and reading the participants off it, and every step of that walk + * is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would + * otherwise leave the cache with no entry to walk, so answering it from here would reach the host + * (whose pubkey the coordinate carries) but none of the other invitees. + * + * Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it + * scrolls away or the app backgrounds. + */ +@Composable +fun RenderCalendarRSVPEvent( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? CalendarRSVPEvent ?: return + val address = remember(event) { event.calendarEventAddress() } + + if (address == null) { + CalendarRsvpCard(event) + return + } + + LoadAddressableNote(address) { appointment -> + if (appointment == null) { + CalendarRsvpCard(event) + } else { + // WatchNoteEvent subscribes the event finder only while the appointment is missing. + // Once it is here the card has everything it draws; a standing subscription would + // keep asking relays for the appointment's reactions and zaps on every RSVP in the feed. + WatchNoteEvent( + baseNote = appointment, + onNoteEventFound = { RsvpWithAppointment(event, appointment, accountViewModel, nav) }, + onBlank = { CalendarRsvpCard(event) }, + accountViewModel = accountViewModel, + ) + } + } +} + +/** + * The appointment drawn *inside* the RSVP's frame, under its status strip: cover image full + * width, then the same date badge and text lines the calendar list uses. No author header — the + * post already says who answered — and no card of its own, so there is one frame, not two. + * "In 3 days" moves up into the strip, next to the answer it qualifies. + */ +@Composable +private fun RsvpWithAppointment( + event: CalendarRSVPEvent, + appointment: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val view = appointment.appointmentView() + if (view == null) { + CalendarRsvpCard(event) + return + } + + val context = LocalContext.current + val appointmentEvent = appointment.event + val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) } + val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id) + val route = remember(appointment) { detailRouteFor(appointment) } + + CalendarRsvpCard( + event = event, + statusDetail = relative, + onClick = { nav.nav(route) }, + ) { + val image = view.image + if (!image.isNullOrBlank()) { + MyAsyncImage( + imageUrl = image, + contentDescription = view.title, + contentScale = ContentScale.Crop, + mainImageModifier = CoverImageModifier, + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { Box(CoverImageModifier) }, + onError = null, + ) + } + + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + CalendarDateBadge(view.startSeconds) + Spacer(modifier = Modifier.size(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + CalendarAppointmentLines(view, range, relative = null) + } + } + } +} + +private val CoverImageModifier = Modifier.fillMaxWidth().aspectRatio(2f) + +// -------------------------------------------------------------------------------------------- +// Previews +// -------------------------------------------------------------------------------------------- + +private const val PREVIEW_HOST = "c07b08396b7bffdb659f862dd7ead57ae169caea65ed573d161e13c1cd6d490c" +private const val PREVIEW_FRIEND = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + +private fun previewConference() = + CalendarTimeSlotEvent( + id = "5c1a0e3b8f2d4a6e9b7c1d3f5a7e9c2b4d6f8a1c3e5b7d9f2a4c6e8b1d3f5a7e", + pubKey = PREVIEW_HOST, + createdAt = 1788000000, + tags = + arrayOf( + arrayOf("d", "nostr-conference-2026"), + arrayOf("title", "Nostr Conference 2026"), + arrayOf("image", "https://image.nostr.build/conference-cover.jpg"), + arrayOf("start", "1791622800"), + arrayOf("end", "1791655200"), + arrayOf("start_tzid", "Europe/Lisbon"), + arrayOf("location", "Lisbon, Portugal"), + ), + content = "Two days of talks, workshops and hacking on the protocol.", + sig = "0".repeat(128), + ) + +private fun previewMeetup() = + CalendarDateSlotEvent( + id = "8e2c4a6f1b3d5e7a9c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d8f1a", + pubKey = PREVIEW_HOST, + createdAt = 1788000000, + tags = + arrayOf( + arrayOf("d", "bitcoin-meetup"), + arrayOf("title", "Bitcoin Meetup: open discussion"), + arrayOf("start", "2026-10-24"), + arrayOf("location", "Restaurant Bjørk, Bodø"), + ), + content = "", + sig = "0".repeat(128), + ) + +private fun previewRsvp( + id: String, + target: Address, + status: RSVPStatusTag.STATUS, + comment: String, +) = CalendarRSVPEvent( + id = id, + pubKey = PREVIEW_FRIEND, + createdAt = 1789000000, + tags = + arrayOf( + arrayOf("d", "rsvp-$id"), + arrayOf("a", target.toValue()), + status.toTagArray(), + arrayOf("p", target.pubKeyHex), + ), + content = comment, + sig = "0".repeat(128), +) + +@Composable +private fun PreviewRsvpOf( + appointment: Event, + rsvp: CalendarRSVPEvent, +) { + LocalCache.justConsume(appointment, null, true) + LocalCache.justConsume(rsvp, null, true) + val note = LocalCache.getOrCreateNote(rsvp.id) + + ThemeComparisonColumn { + RenderCalendarRSVPEvent(note, mockAccountViewModel(), EmptyNav()) + } +} + +@Composable +@Preview +fun RenderCalendarRsvpGoingPreview() { + val conference = previewConference() + PreviewRsvpOf( + conference, + previewRsvp( + id = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + target = conference.address(), + status = RSVPStatusTag.STATUS.ACCEPTED, + comment = "See you all there!", + ), + ) +} + +@Composable +@Preview +fun RenderCalendarRsvpMaybeNoImagePreview() { + val meetup = previewMeetup() + PreviewRsvpOf( + meetup, + previewRsvp( + id = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1", + target = meetup.address(), + status = RSVPStatusTag.STATUS.TENTATIVE, + comment = "", + ), + ) +} + +/** The appointment has not arrived yet: the status strip over the loading line. */ +@Composable +@Preview +fun CalendarRsvpCardLoadingPreview() { + val rsvp = + previewRsvp( + id = "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2", + target = Address(CalendarTimeSlotEvent.KIND, PREVIEW_HOST, "not-fetched-yet"), + status = RSVPStatusTag.STATUS.ACCEPTED, + comment = "Who else is coming?", + ) + + ThemeComparisonColumn { + CalendarRsvpCard(rsvp) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 8e9317fa28..7c874b1323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -75,6 +76,8 @@ fun CalendarEventListCard( accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, + // Drawn inside the card under the appointment, for lenses that annotate it (who's going). + footer: (@Composable () -> Unit)? = null, ) { val view = note.appointmentView() ?: return val context = LocalContext.current @@ -118,51 +121,7 @@ fun CalendarEventListCard( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - view.title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - range?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - relative?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - view.location?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.size(4.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + CalendarAppointmentLines(view, range, relative) val image = view.image val summary = view.summary if (!image.isNullOrBlank()) { @@ -189,11 +148,71 @@ fun CalendarEventListCard( } } } + + footer?.invoke() + } +} + +/** + * Title, time range, relative time and location of an appointment — the text half of every + * calendar row. Shared by the calendar list card and the feed's RSVP card so the two cannot + * disagree about how an event reads. Pass a null [relative] where the caller shows it elsewhere. + */ +@Composable +internal fun CalendarAppointmentLines( + view: CalendarAppointmentView, + range: String?, + relative: String?, +) { + view.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + range?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + relative?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + view.location?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } @Composable -private fun CalendarDateBadge(startSeconds: Long?) { +internal fun CalendarDateBadge(startSeconds: Long?) { if (startSeconds == null) { Box( modifier = Modifier.size(width = 52.dp, height = 60.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt new file mode 100644 index 0000000000..fb2224065d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.AppointmentAttendance +import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarEmptyState +import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_count +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_subtitle +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_title +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.UserPicture +import com.vitorpamplona.amethyst.commons.ui.note.types.UserGallery +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel + +/** Faces drawn on a row before the rest collapse into "+N". */ +private const val ATTENDEE_FACES = 6 + +/** + * Keeps a relay request from growing without bound when the list follows people with a long RSVP + * history of appointments this device has never seen. + */ +private const val MAX_APPOINTMENTS_TO_FETCH = 100 + +/** + * The upcoming events the people in the selected list said they're going to, soonest first, each + * with the faces of who's going. + * + * The RSVPs come in on the screen's calendar subscription (it asks the list's authors for their + * kind-31925s). The appointments they point at are usually published by someone else — a + * conference organizer the viewer does not follow — so the ones the cache is missing are fetched + * here, by address, through the per-note event finder. + */ +@Composable +fun CalendarFollowsGoingView( + model: CalendarsViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val state by model.followsGoing.collectAsStateWithLifecycle() + val going = state + if (going == null) { + Box(Modifier.fillMaxSize()) + return + } + + FetchMissingAppointments(going.unresolved, accountViewModel) + + if (going.upcoming.isEmpty()) { + CalendarEmptyState( + title = stringRes(Res.string.calendar_follows_going_empty_title), + subtitle = stringRes(Res.string.calendar_follows_going_empty_subtitle), + ) + return + } + + LazyColumn( + state = model.followsGoingListState, + contentPadding = rememberFeedContentPadding(FeedPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(going.upcoming, key = { it.appointment.idHex }) { entry -> + CalendarEventListCard( + note = entry.appointment, + accountViewModel = accountViewModel, + nav = nav, + footer = { AttendeesRow(entry, accountViewModel, nav) }, + ) + } + } +} + +@Composable +private fun FetchMissingAppointments( + unresolved: List, + accountViewModel: AccountViewModel, +) { + unresolved.take(MAX_APPOINTMENTS_TO_FETCH).forEach { appointment -> + key(appointment.idHex) { + EventFinderFilterAssemblerSubscription(appointment, accountViewModel) + } + } +} + +@Composable +private fun AttendeesRow( + entry: AppointmentAttendance, + accountViewModel: AccountViewModel, + nav: INav, +) { + val shown = remember(entry) { entry.attendees.take(ATTENDEE_FACES).map(LocalCache::getOrCreateUser) } + val total = entry.attendees.size + + Row( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserGallery(shown, total) { user -> + UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = pluralStringRes(Res.plurals.calendar_follows_going_count, total, total), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt index 9ace309e5a..0403996741 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription +import kotlinx.coroutines.launch @Composable fun CalendarsScreen( @@ -69,8 +71,10 @@ fun CalendarsScreen( // itself goes. See [CalendarsViewModel]. val model: CalendarsViewModel = viewModel() model.init(accountViewModel.userProfile().pubkeyHex, feedState) + model.bindAttendeeFilter(accountViewModel.account.liveCalendarsFollowLists, accountViewModel.account.hiddenUsers.flow) val filterDTag by model.filterDTag.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() DisappearingScaffold( isInvertedLayout = false, @@ -80,19 +84,31 @@ fun CalendarsScreen( onViewModeChange = { model.viewMode = it }, accountViewModel = accountViewModel, nav = nav, - trailing = { - CalendarFilterChip( - selectedDTag = filterDTag, - onSelect = model::selectCalendar, - model = model, - ) - }, + // The membership filter narrows the viewer's own calendars; the follows lens is + // built from other people's RSVPs, so the chip would claim a filter it ignores. + trailing = + if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) { + null + } else { + { + CalendarFilterChip( + selectedDTag = filterDTag, + onSelect = model::selectCalendar, + model = model, + ) + } + }, ) }, bottomBar = { AppBottomBar(Route.Calendars, nav, accountViewModel) { route -> if (route == Route.Calendars) { - feedState.sendToTop() + if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) { + // Built from RSVPs, not the feed, so the feed's scroll signal never reaches it. + scope.launch { model.followsGoingListState.animateScrollToItem(0) } + } else { + feedState.sendToTop() + } } else { nav.navBottomBar(route) } @@ -109,6 +125,7 @@ fun CalendarsScreen( Column(modifier = Modifier.fillMaxSize()) { when (model.viewMode) { CalendarsViewMode.FEED -> CalendarFeedView(feedState, model, accountViewModel, nav) + CalendarsViewMode.FOLLOWS_GOING -> CalendarFollowsGoingView(model, accountViewModel, nav) CalendarsViewMode.MONTH -> CalendarMonthView(model, accountViewModel, nav) CalendarsViewMode.WEEK -> CalendarWeekView(model, accountViewModel, nav) CalendarsViewMode.DAY -> CalendarDayView(model, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt index 507baac687..04151d7545 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -84,17 +85,12 @@ fun CalendarsTopBar( ) } - Row( + CalendarsViewModeTabs( + current = viewMode, + onChange = onViewModeChange, + trailing = trailing, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - CalendarsViewModeTabs( - current = viewMode, - onChange = onViewModeChange, - modifier = Modifier.weight(1f), - ) - trailing?.invoke() - } + ) } } @@ -120,13 +116,19 @@ private fun CalendarsTopNavFilterBar( private fun CalendarsViewModeTabs( current: CalendarsViewMode, onChange: (CalendarsViewMode) -> Unit, + trailing: (@Composable () -> Unit)?, modifier: Modifier = Modifier, ) { + // The trailing control scrolls with the lenses instead of being pinned beside them: pinned, + // it took its width out of the scrolling part, and once the lenses outgrew what was left + // (on an 800px tablet, after "Friends going" joined them) the last lens sat half-hidden + // against it with nothing to say the row scrolls. A chip cut by the screen edge does. Row( modifier = modifier .horizontalScroll(rememberScrollState()) .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { CalendarsViewMode.entries.forEach { mode -> FilterChip( @@ -143,5 +145,6 @@ private fun CalendarsViewModeTabs( shape = MaterialTheme.shapes.small, ) } + trailing?.invoke() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt index fe401d2b98..5b7d95610d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt @@ -31,31 +31,49 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FilterByListParams +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavFilter import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarsViewMode +import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.YearMonth +import java.util.concurrent.ConcurrentHashMap /** * Everything the calendar screen is *looking at*: which lens is open, which calendar the feed is @@ -260,6 +278,122 @@ class CalendarsViewModel : ViewModel() { UpcomingPastSplit(emptyList(), emptyList()), ) + // ------------------------------------------------------------------------------------------ + // Where the people in the selected list are going + // ------------------------------------------------------------------------------------------ + + /** + * What decides whose RSVPs count: the screen's top-nav list and the account's mute state, + * handed over as the account's own flows so a list switch or a new mute re-runs the fold. + */ + private data class AttendeeInputs( + val lists: StateFlow, + val hidden: StateFlow, + ) + + private val attendeeInputs = MutableStateFlow(null) + + /** + * Every kind-31925 note seen while this screen lives, held strongly. + * + * `LocalCache.addressables` keeps its values by weak reference, and nothing else in the app + * references an RSVP by someone else: the calendar feed lists appointments, not answers. So + * the RSVPs the screen's subscription pulls in while the user is on another lens could be + * collected before they ever open "Friends going" — and the subscription's EOSE cursor has + * already moved past them, so they would not be asked for again. Pinning them here, from the + * moment the screen binds, is what lets the lens be opened later and still see them. They + * are released with the screen's back-stack entry. Kind-31925 events are a few hundred bytes. + */ + private val heldRsvps: MutableSet = ConcurrentHashMap.newKeySet() + + private var rsvpPinner: Job? = null + + /** Idempotent — the screen calls it on every composition. */ + fun bindAttendeeFilter( + lists: StateFlow, + hidden: StateFlow, + ) { + val bound = AttendeeInputs(lists, hidden) + if (attendeeInputs.value != bound) attendeeInputs.value = bound + + if (rsvpPinner == null) { + rsvpPinner = + viewModelScope.launch(Dispatchers.Default) { + heldRsvps.addAll(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND)) + LocalCache + .observeNewEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))) + .collect { rsvp -> LocalCache.getAddressableNoteIfExists(rsvp.address())?.let(heldRsvps::add) } + } + } + } + + /** + * The upcoming appointments the people in the selected list said they're going to, soonest + * first, plus the appointments they RSVP'd to that are not in the cache yet (for the lens to + * fetch). Null until the first fold lands, so the lens can tell "loading" from "nobody". + * + * Wakes on one cache observer covering both kinds it reads: RSVPs, and appointments, because + * an RSVP usually lands before the appointment it answers — the host is often someone the + * viewer does not follow — and the row can only be placed once the appointment's date is + * known. The observer is only a wake-up signal; each fold re-reads the RSVPs from the + * addressable cache's kind index, which holds exactly the latest version of each one. + * + * That replaced `observeEvents`, whose seed walks every regular note in the cache (its + * `filter()` scans `notes` whatever the kinds) and which then copies its whole list and id + * set on every insert — quadratic on the burst of RSVPs a list switch pulls in, each copy + * followed by a full re-fold. Here the cheap part (combining the signal with the list and + * mute state) runs per event, and [conflate] lets the fold skip every signal that arrived + * while the previous fold was running. + * + * `now` is read on each fold rather than ticking on a timer: an event that ends while the lens + * is open lingers until the next RSVP or appointment arrives, which is harmless. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val followsGoing: StateFlow = + attendeeInputs + .filterNotNull() + .flatMapLatest { (lists, hidden) -> + combine( + LocalCache + .observeNewEvents(Filter(kinds = FOLLOWS_GOING_KINDS)) + .conflate() + .map { } + .onStart { emit(Unit) }, + lists, + hidden, + ) { _, list, hiddenUsers -> + FilterByListParams.create(list, hiddenUsers) + }.conflate() + .map(::foldFollowsGoing) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + private fun foldFollowsGoing(params: FilterByListParams): FollowsGoing { + // The index scan sees an RSVP the pinner has not reached yet; the pin set holds the rest. + val rsvpNotes = LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND) + heldRsvps.addAll(rsvpNotes) + val rsvps = rsvpNotes.mapNotNull { it.event as? CalendarRSVPEvent } + + // Relays only matter to a relay-scoped list, so the others skip the lookup. + val relaysOf: (CalendarRSVPEvent) -> List = + if (params.followLists is RelayTopNavFilter) { + val byId = rsvpNotes.associateBy({ it.event?.id }, { it.relays }) + ({ byId[it.id] ?: emptyList() }) + } else { + ({ emptyList() }) + } + + return computeFollowsGoing( + rsvps = rsvps, + isAttendee = { params.match(it, relaysOf(it)) }, + appointmentFor = LocalCache::getOrCreateAddressableNote, + nowSeconds = TimeUtils.now(), + isAppointmentVisible = { params.isHiddenList || params.isNotHidden(it.pubKeyHex) }, + ) + } + + val followsGoingListState = LazyListState() + // ------------------------------------------------------------------------------------------ // Paging // ------------------------------------------------------------------------------------------ @@ -304,6 +438,8 @@ class CalendarsViewModel : ViewModel() { * list, short enough that a backgrounded screen stops holding observers open. */ private const val STOP_TIMEOUT_MS = 5_000L + + private val FOLLOWS_GOING_KINDS = CalendarAppointmentKinds + CalendarRSVPEvent.KIND } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index e2de31f141..804ce0e0ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -498,7 +499,7 @@ private fun EncryptedKeyCard( label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, supportingText = { Text( - text = stringRes(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH), + text = pluralStringRes(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH), color = if (longEnough) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index 2a9541eea6..1a689f57c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.resources.conversations import com.vitorpamplona.amethyst.commons.resources.home_content_type_articles import com.vitorpamplona.amethyst.commons.resources.home_content_type_attestations import com.vitorpamplona.amethyst.commons.resources.home_content_type_birds +import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps import com.vitorpamplona.amethyst.commons.resources.home_content_type_chess import com.vitorpamplona.amethyst.commons.resources.home_content_type_classifieds import com.vitorpamplona.amethyst.commons.resources.home_content_type_comments @@ -183,6 +184,7 @@ private val HOME_FEED_TYPES = HomeFeedTypeUi(HomeFeedType.PODCASTS, Res.string.home_content_type_podcasts, MaterialSymbols.Podcasts), HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, Res.string.home_content_type_fundraisers, MaterialSymbols.Paid), HomeFeedTypeUi(HomeFeedType.RATINGS, Res.string.home_content_type_ratings, MaterialSymbols.Star), + HomeFeedTypeUi(HomeFeedType.CALENDAR_RSVPS, Res.string.home_content_type_calendar_rsvps, MaterialSymbols.CalendarMonth), ) /** diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt index 99f3195d38..1de5d9b294 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.calendar +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import org.junit.Assert.assertFalse @@ -33,10 +35,23 @@ import org.junit.Test * chain may cancel itself. Getting it wrong in either direction is a bug: * a false "could fire" keeps waking the process forever; a false "can't fire" * silently kills a reminder the user RSVP'd to. + * + * Events go in through [consumePinned], which takes each event's note from the cache *before* + * consuming it and keeps it for the test's lifetime. `LocalCache` holds notes weakly + * (LargeSoftCache) and the worker finds RSVPs and their time slots by reading the cache, so a + * GC before that read would lose them — a lost slot reads as "not fetched yet" and flips + * `couldStillFire` to true. */ class CalendarReminderCandidatesTest { private val now = 2_000_000_000L + private val pinned = mutableListOf() + + private fun consumePinned(event: Event) { + pinned.add(LocalCache.getOrCreateNote(event)) + LocalCache.justConsume(event, null, true) + } + // Unique per-test-class identities so the shared LocalCache singleton // doesn't collide with other suites running in the same JVM. private val organizer = "b0b0000000000000000000000000000000000000000000000000000000000001" @@ -85,8 +100,8 @@ class CalendarReminderCandidatesTest { fun acceptedRsvpsInCache_returnsAcceptedAndSkipsDeclined() { val accepted = rsvp("cand-accepted", "cand-target-1") val declined = rsvp("cand-declined", "cand-target-2", status = "declined") - LocalCache.justConsume(accepted, null, true) - LocalCache.justConsume(declined, null, true) + consumePinned(accepted) + consumePinned(declined) val found = CalendarReminderWorker.acceptedRsvpsInCache() assertTrue("accepted RSVP must be found", found.any { it.dTag() == "cand-accepted" }) @@ -97,8 +112,8 @@ class CalendarReminderCandidatesTest { fun futureTarget_couldStillFire() { val slot = timeSlot("cand-future", startSeconds = now + 3600) val r = rsvp("cand-rsvp-future", "cand-future") - LocalCache.justConsume(slot, null, true) - LocalCache.justConsume(r, null, true) + consumePinned(slot) + consumePinned(r) assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) } @@ -107,8 +122,8 @@ class CalendarReminderCandidatesTest { fun pastTarget_cannotFireAnymore() { val slot = timeSlot("cand-past", startSeconds = now - 3600) val r = rsvp("cand-rsvp-past", "cand-past") - LocalCache.justConsume(slot, null, true) - LocalCache.justConsume(r, null, true) + consumePinned(slot) + consumePinned(r) assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) } @@ -118,7 +133,7 @@ class CalendarReminderCandidatesTest { // The RSVP points at an event the cache hasn't fetched yet: the start // is unknown, so the worker must NOT cancel its chain. val r = rsvp("cand-rsvp-unresolved", "cand-never-fetched") - LocalCache.justConsume(r, null, true) + consumePinned(r) assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) } @@ -128,8 +143,8 @@ class CalendarReminderCandidatesTest { // Target resolved but carries no start tag: still unknown, keep alive. val slot = timeSlot("cand-no-start", startSeconds = null) val r = rsvp("cand-rsvp-no-start", "cand-no-start") - LocalCache.justConsume(slot, null, true) - LocalCache.justConsume(r, null, true) + consumePinned(slot) + consumePinned(r) assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) } @@ -137,7 +152,7 @@ class CalendarReminderCandidatesTest { @Test fun rsvpWithoutTargetAddress_doesNotKeepTheChainAlive() { val r = rsvp("cand-rsvp-no-a-tag", targetDTag = null) - LocalCache.justConsume(r, null, true) + consumePinned(r) assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt index edce670ea6..a213d29eb7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt @@ -120,10 +120,14 @@ class CalendarsViewModelFlowTest { * Consumes [calendars] and hands back the notes they landed in, so the caller can hold them * for the length of the test: `LocalCache.addressables` is a weak cache, and with nothing * holding a reference a calendar can be collected out from under the assertions. + * + * The notes are taken *before* consuming: fetching them afterwards leaves the window between + * the consume and the fetch open, and a GC there drops the calendar before it is ever held. */ - private fun consume(vararg calendars: CalendarCollectionEvent): List { + private fun consume(vararg calendars: CalendarCollectionEvent): List { + val held = calendars.map { LocalCache.getOrCreateAddressableNote(it.address()) } calendars.forEach { LocalCache.justConsumeMyOwnEvent(it) } - return calendars.map { LocalCache.getAddressableNoteIfExists(it.address()) } + return held } /** diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 97512fd09b..6aa5e58758 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -168,6 +168,10 @@ class BuzzWorkspaceChannelTest { // overlay its message — it is anchored on the message note, independent of any relay. val channelId = newChannelId() val original = streamMessage(channelId, "original") + // Taken before consuming and held: with no relay the message joins no channel + // timeline, so nothing else holds its note in LocalCache's weak store, and a GC + // before the read-back would drop it along with its edit overlay. + val target = LocalCache.getOrCreateNote(original.id) LocalCache.checkDeletionAndConsume(original, null, true) val edit = @@ -176,7 +180,6 @@ class BuzzWorkspaceChannelTest { ) LocalCache.checkDeletionAndConsume(edit, null, true) - val target = LocalCache.getNoteIfExists(original.id)!! val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L } assertEquals("edited offline", newest?.event?.content) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index f5b0752a11..93f9ca4a9f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cachedDvmAnnouncements import com.vitorpamplona.amethyst.commons.model.dvmHeartbeatOf import com.vitorpamplona.amethyst.commons.model.hasFreshDvmHeartbeat import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,10 +40,23 @@ import org.junit.Test /** * `LocalCache` is a process-wide object and JUnit 4 runs methods in hash order, so every test * uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest). + * + * Events go in through [consumePinned], which takes each event's note from the cache *before* + * consuming it and keeps it for the test's lifetime. Beat and announcement notes live in + * `LocalCache`'s weakly-held store, and `dvmHeartbeatOf` / `cachedDvmAnnouncements` read them from + * there, so a GC between the consume and the read would make them vanish. Fetching the note after + * the consume is too late: the window is already open. */ class DvmHeartbeatTest { private val appDefPubKey = "f1".repeat(32) + private val pinned = mutableListOf() + + private fun consumePinned(event: Event) { + pinned.add(LocalCache.getOrCreateNote(event)) + LocalCache.justConsume(event, null, true) + } + private fun appDef( dTag: String, pubKey: String = appDefPubKey, @@ -76,7 +91,7 @@ class DvmHeartbeatTest { @Test fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() { val app = appDef("dvm-one") - LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)), null, true) + consumePinned(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32))) val found = LocalCache.dvmHeartbeatOf(app) assertTrue("heartbeat should be found via the announcement's address", found != null) @@ -93,8 +108,8 @@ class DvmHeartbeatTest { val staleApp = appDef("dvm-two-stale") assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp)) - LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true) + consumePinned(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32))) + consumePinned(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32))) assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) @@ -104,8 +119,8 @@ class DvmHeartbeatTest { fun theNewestBeatPerAddressWins() { val now = 1_760_000_000L val app = appDef("dvm-three") - LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)), null, true) + consumePinned(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32))) + consumePinned(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32))) assertEquals(now - 60, LocalCache.dvmHeartbeatOf(app)?.createdAt) } @@ -115,7 +130,7 @@ class DvmHeartbeatTest { val now = 1_760_000_000L val appA = appDef("dvm-a") val appB = appDef("dvm-b") - LocalCache.justConsume(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)), null, true) + consumePinned(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32))) assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now)) assertFalse("no beat for dvm-b", LocalCache.hasFreshDvmHeartbeat(appB, now)) @@ -177,11 +192,10 @@ class DvmHeartbeatTest { // scanning for them clears the lot and the scan returns nothing — which is exactly how // this failed on CI, where the heap is tighter than a dev box's. A fixture has to hold // what it expects to find, the same discipline LargeCacheAddressableFilterTest spells - // out; the forced GC below is what keeps that honest rather than assumed. - val held = - listOf(alive, appDef("dvm-x"), subscriptionApp, nonDiscoveryApp, secondSubscriptionApp) - .onEach { LocalCache.justConsume(it, null, true) } - .map { LocalCache.getOrCreateNote(it) } + // out, and it has to take hold BEFORE consuming (see [consumePinned]); the forced GC + // below is what keeps that honest rather than assumed. + listOf(alive, appDef("dvm-x"), subscriptionApp, nonDiscoveryApp, secondSubscriptionApp) + .forEach { consumePinned(it) } System.gc() @@ -192,6 +206,6 @@ class DvmHeartbeatTest { assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" }) assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned) assertTrue("capped", scanned.size <= 100) - assertEquals("the fixture notes must stay reachable for the whole test", 5, held.size) + assertEquals("the fixture notes must stay reachable for the whole test", 5, pinned.size) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt index 609857aaf5..13cc3ce03f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/EntityRatingIngestionTest.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.HomeFeedType +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -38,10 +40,23 @@ import org.junit.Test * * `LocalCache` is a process-wide object and JUnit 4's method order is hash-based, so every test * below uses its own author and identifier. + * + * Events go in through [consumePinned], which takes each event's note from the cache *before* + * consuming it and keeps it for the test's lifetime. `LocalCache` holds notes weakly + * (LargeSoftCache), so a note nothing references can be collected between two consumes — the + * older rating then lands on a fresh, empty slot — or before the read-back. In the app the + * screen showing the rating is that strong referent. */ class EntityRatingIngestionTest { private val publisher = "b1".repeat(32) + private val pinned = mutableListOf() + + private fun consumePinned(event: Event): Boolean { + pinned.add(LocalCache.getOrCreateNote(event)) + return LocalCache.justConsume(event, null, true) + } + private fun rating( id: String, author: String, @@ -75,7 +90,7 @@ class EntityRatingIngestionTest { val author = "b2".repeat(32) val event = rating("b3".repeat(32), author, "consumed-book", 5) - assertTrue("LocalCache must accept kind 34259", LocalCache.justConsume(event, null, true)) + assertTrue("LocalCache must accept kind 34259", consumePinned(event)) val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) assertNotNull("the rating must land in the addressable index", note) @@ -88,8 +103,8 @@ class EntityRatingIngestionTest { val first = rating("b5".repeat(32), author, "replaced-book", 1, createdAt = 1_000_000L) val second = rating("b6".repeat(32), author, "replaced-book", 5, createdAt = 2_000_000L) - LocalCache.justConsume(first, null, true) - LocalCache.justConsume(second, null, true) + consumePinned(first) + consumePinned(second) val note = LocalCache.getAddressableNoteIfExists(first.addressTag()) assertEquals("the later rating wins the (pubkey, d) slot", second.id, note?.event?.id) @@ -102,8 +117,8 @@ class EntityRatingIngestionTest { val newer = rating("b8".repeat(32), author, "ordered-book", 5, createdAt = 2_000_000L) val older = rating("b9".repeat(32), author, "ordered-book", 1, createdAt = 1_000_000L) - LocalCache.justConsume(newer, null, true) - LocalCache.justConsume(older, null, true) + consumePinned(newer) + consumePinned(older) val note = LocalCache.getAddressableNoteIfExists(newer.addressTag()) assertEquals(newer.id, note?.event?.id) @@ -114,8 +129,8 @@ class EntityRatingIngestionTest { val one = rating("c1".repeat(32), "c2".repeat(32), "shared-book", 5) val two = rating("c3".repeat(32), "c4".repeat(32), "shared-book", 2) - LocalCache.justConsume(one, null, true) - LocalCache.justConsume(two, null, true) + consumePinned(one) + consumePinned(two) assertEquals(one.id, LocalCache.getAddressableNoteIfExists(one.addressTag())?.event?.id) assertEquals(two.id, LocalCache.getAddressableNoteIfExists(two.addressTag())?.event?.id) @@ -134,7 +149,7 @@ class EntityRatingIngestionTest { sig = "sig", ) - assertTrue("LocalCache must accept kind 30040", LocalCache.justConsume(index, null, true)) + assertTrue("LocalCache must accept kind 30040", consumePinned(index)) assertEquals("Wuthering Heights", (LocalCache.getAddressableNoteIfExists(index.addressTag())?.event as PublicationIndexEvent).title()) } @@ -146,7 +161,7 @@ class EntityRatingIngestionTest { val author = "c6".repeat(32) val event = rating("c7".repeat(32), author, "new-thread-book", 4) - LocalCache.justConsume(event, null, true) + consumePinned(event) val note = LocalCache.getAddressableNoteIfExists(event.addressTag())!! assertTrue("replyTo must stay empty", note.replyTo.isNullOrEmpty()) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt index 6bf410abc4..08e2c30f86 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -45,6 +46,11 @@ import java.io.File * * `LocalCache` is a process-wide object and JUnit's method order is hash-based, so the corpus is * loaded once and every assertion here is read-only. + * + * The corpus' notes are taken from the cache *before* consuming and kept in [pinned] for the whole + * class. `LocalCache` holds notes weakly (LargeSoftCache) and nothing else references a corpus note, + * so without the pin any GC between loading and a query drops notes and the query comes back short. + * In the app the screen showing a note is that strong referent. */ class LocalCacheSearchParityTest { companion object { @@ -53,6 +59,9 @@ class LocalCacheSearchParityTest { private lateinit var corpus: List + /** Strong references to every corpus note. See the class KDoc. */ + private lateinit var pinned: List + @BeforeClass @JvmStatic fun loadCorpus() { @@ -69,6 +78,8 @@ class LocalCacheSearchParityTest { .map { Event.fromJson(it.toString()) } .distinctBy { it.id } + pinned = corpus.map { LocalCache.getOrCreateNote(it) } + // LocalCache.consume refuses the main thread; a plain JVM test has no Looper, so the // check passes and the events land synchronously. corpus.forEach { LocalCache.justConsumeMyOwnEvent(it) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt index 5d64a594db..7556ca7a14 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ReportNamingIndexIngestionTest.kt @@ -24,14 +24,20 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test /** * `LocalCache` is a process-wide object (`LocalCache.kt:353`) and JUnit 4's default method order is * hash-based, not source order. So each test method uses its **own** reporter, target and event ids — * sharing them across methods would let one test's consumed report inflate another's count depending - * on which ran first. + * on which ran first. The ids must also be unused by every OTHER test class in the JVM: this suite + * once shared `c3…`/`d3…`/`e5…` with the rating, appointment and calendar suites, and whenever one + * of their notes was still alive the report here was treated as already seen and never indexed. + * + * Each test takes the reported users from the cache *before* consuming and keeps them. `LocalCache` + * holds users weakly (LargeSoftCache) and nothing else references a user that is only named by a + * report, so a GC before the read-back would drop the user and the report index with it. In the app + * the screen showing the user is that strong referent. */ class ReportNamingIndexIngestionTest { private fun reportNamingBothNoteAndAuthor( @@ -54,48 +60,45 @@ class ReportNamingIndexIngestionTest { @Test fun aReportFiledFromANoteStillReachesTheUserNamingIndex() { - val reporter = "c1".repeat(32) - val target = "c2".repeat(32) + val reporter = "7a".repeat(32) + val target = "7b".repeat(32) + val reported = LocalCache.getOrCreateUser(target) LocalCache.consume( - reportNamingBothNoteAndAuthor("c3".repeat(32), reporter, target, "c4".repeat(32)), + reportNamingBothNoteAndAuthor("7c".repeat(32), reporter, target, "7d".repeat(32)), null, true, ) - val reported = LocalCache.getUserIfExists(target) - assertTrue("the reported author must exist in the cache", reported != null) - - assertEquals(1, reported!!.reports().reportsNaming(setOf(reporter)).size) + assertEquals(1, reported.reports().reportsNaming(setOf(reporter)).size) } @Test fun theHideThresholdCountIsUnaffectedByNoteFiledReports() { - val reporter = "d1".repeat(32) - val target = "d2".repeat(32) + val reporter = "8a".repeat(32) + val target = "8b".repeat(32) + val reported = LocalCache.getOrCreateUser(target) LocalCache.consume( - reportNamingBothNoteAndAuthor("d3".repeat(32), reporter, target, "d4".repeat(32)), + reportNamingBothNoteAndAuthor("8c".repeat(32), reporter, target, "8d".repeat(32)), null, true, ) - val reported = LocalCache.getUserIfExists(target)!! - assertEquals(1, reported.reports().reportsNaming(setOf(reporter)).size) assertEquals(0, reported.reports().countReportAuthorsBy(setOf(reporter))) } @Test fun aBareCoMentionedPTagIsNotIndexedWhileTheExplicitlyTypedOffenderIs() { - val reporter = "e1".repeat(32) - val offender = "e2".repeat(32) - val bystander = "e3".repeat(32) - val reportedNoteId = "e4".repeat(32) + val reporter = "9a".repeat(32) + val offender = "9b".repeat(32) + val bystander = "9c".repeat(32) + val reportedNoteId = "9e".repeat(32) val report = ReportEvent( - id = "e5".repeat(32), + id = "7e".repeat(32), pubKey = reporter, createdAt = 1_700_000_000L, tags = @@ -110,14 +113,12 @@ class ReportNamingIndexIngestionTest { sig = "sig", ) + val reportedOffender = LocalCache.getOrCreateUser(offender) + val reportedBystander = LocalCache.getOrCreateUser(bystander) + LocalCache.consume(report, null, true) - val reportedOffender = LocalCache.getUserIfExists(offender) - assertTrue("the offender must exist in the cache", reportedOffender != null) - assertEquals(1, reportedOffender!!.reports().reportsNaming(setOf(reporter)).size) - - val reportedBystander = LocalCache.getUserIfExists(bystander) - assertTrue("the bystander must exist in the cache", reportedBystander != null) - assertEquals(0, reportedBystander!!.reports().reportsNaming(setOf(reporter)).size) + assertEquals(1, reportedOffender.reports().reportsNaming(setOf(reporter)).size) + assertEquals(0, reportedBystander.reports().reportsNaming(setOf(reporter)).size) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt new file mode 100644 index 0000000000..0097f9855d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentPrivateZapTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * A private zap is signed by a one-time key and carries the real sender and message encrypted + * for the zapped author. The notification must decrypt it with the recipient's signer so the + * shade names the real zapper, and fall back to the request itself when it can't. + */ +class NotificationContentPrivateZapTest { + private val relays = setOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!!) + private val recipient = NostrSignerInternal(KeyPair()) + private val sender = NostrSignerInternal(KeyPair()) + + private val zappedPost = + Event( + id = "a".repeat(64), + pubKey = recipient.pubKey, + createdAt = 1000L, + kind = 1, + tags = emptyArray(), + content = "Hello world", + sig = "b".repeat(128), + ) + + private fun zapPost(zapType: ZapReceiptEvent.ZapType) = + runBlocking { + ZapRequestEvent.create( + zappedEvent = zappedPost, + relays = relays, + signer = sender, + pollOption = null, + message = "great post", + zapType = zapType, + toUserPubHex = null, + ) + } + + @Test + fun privateZapOnAPostIsDecryptedForTheRecipient() = + runBlocking { + val decrypted = NotificationContent.decryptZapContentAuthor(zapPost(ZapReceiptEvent.ZapType.PRIVATE), recipient)!! + + assertEquals(sender.pubKey, decrypted.pubKey) + assertEquals("great post", decrypted.content) + } + + @Test + fun privateProfileZapIsDecryptedForTheRecipient() = + runBlocking { + val zap = + ZapRequestEvent.create( + userHex = recipient.pubKey, + relays = relays, + signer = sender, + message = "thanks", + zapType = ZapReceiptEvent.ZapType.PRIVATE, + ) + + val decrypted = NotificationContent.decryptZapContentAuthor(zap, recipient)!! + + assertEquals(sender.pubKey, decrypted.pubKey) + assertEquals("thanks", decrypted.content) + } + + @Test + fun privateZapTheSignerCannotOpenFallsBackToTheRequest() = + runBlocking { + val zap = zapPost(ZapReceiptEvent.ZapType.PRIVATE) + + assertSame(zap, NotificationContent.decryptZapContentAuthor(zap, NostrSignerInternal(KeyPair()))) + } + + @Test + fun publicZapIsReturnedAsIs() = + runBlocking { + val zap = zapPost(ZapReceiptEvent.ZapType.PUBLIC) + + assertSame(zap, NotificationContent.decryptZapContentAuthor(zap, recipient)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/home/dal/HomeNewThreadFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/home/dal/HomeNewThreadFeedFilter.kt index 22e2ac2aac..98515b40aa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/home/dal/HomeNewThreadFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/home/dal/HomeNewThreadFeedFilter.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.isLatestAnswerIn +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.latestRsvpAnswers import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent @@ -45,12 +47,19 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent @@ -87,6 +96,7 @@ class HomeNewThreadFeedFilter( AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND, EntityRatingEvent.KIND, + CalendarRSVPEvent.KIND, ) } @@ -105,18 +115,19 @@ class HomeNewThreadFeedFilter( override fun feed(): List { val filterParams = buildFilterParams(account) val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value) + val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() } val notes = LocalCache.notes.filterIntoSet { _, note -> // Avoids processing addressables twice. - (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds) + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds, latestRsvps) } val longFormNotes = LocalCache.addressables.filterIntoSet( kinds = ADDRESSABLE_KINDS, ) { _, note -> - acceptableEvent(note, filterParams, disabledKinds) + acceptableEvent(note, filterParams, disabledKinds, latestRsvps) } return sort(notes + longFormNotes) @@ -124,12 +135,28 @@ class HomeNewThreadFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + /** + * The additive merge only ever adds, so a "Going" card already on screen would outlive the + * author changing their mind: when a batch carries RSVPs, the old list first loses every + * RSVP that is no longer its author's latest answer. + */ + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + if (newItems.none { it.event is CalendarRSVPEvent }) return super.updateListWith(oldList, newItems) + val latest = cachedRsvpAnswers() + val stillCurrent = oldList.filter { (it.event as? CalendarRSVPEvent)?.isLatestAnswerIn(latest) ?: true } + return super.updateListWith(stillCurrent, newItems) + } + private fun innerApplyFilter(collection: Collection): Set { val filterParams = buildFilterParams(account) val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value) + val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() } return collection.filterTo(HashSet()) { - acceptableEvent(it, filterParams, disabledKinds) + acceptableEvent(it, filterParams, disabledKinds, latestRsvps) } } @@ -137,6 +164,7 @@ class HomeNewThreadFeedFilter( it: Note, filterParams: FilterByListParams, disabledKinds: Set, + latestRsvps: Lazy, CalendarRSVPEvent>>, ): Boolean { val noteEvent = it.event ?: return false if (noteEvent.kind in disabledKinds) return false @@ -174,7 +202,8 @@ class HomeNewThreadFeedFilter( noteEvent is AttestorRecommendationEvent || noteEvent is AttestorProficiencyEvent || // A rating with nothing to point at cannot be rendered. - (noteEvent is EntityRatingEvent && noteEvent.hasTarget()) + (noteEvent is EntityRatingEvent && noteEvent.hasTarget()) || + noteEvent.isGoingRsvp(latestRsvps) ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() @@ -190,3 +219,20 @@ class HomeNewThreadFeedFilter( } }.sortedByDefaultFeedOrder() } + +/** + * Only "going" RSVPs reach the home feed: the point is to see where your people are going. + * Maybes and declines are still visible on the appointment itself, but as posts they are noise. + * And only the author's current answer: someone who said "going" and later "can't go" under a + * new d tag still has the old RSVP live, and it must not keep saying they are going. + */ +private fun Event.isGoingRsvp(latestRsvps: Lazy, CalendarRSVPEvent>>): Boolean { + if (this !is CalendarRSVPEvent || status() != RSVPStatusTag.STATUS.ACCEPTED) return false + // An `a` tag at anything but an appointment has no event card to draw next to "Going". + val target = calendarEventAddress() ?: return false + if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return false + return isLatestAnswerIn(latestRsvps.value) +} + +// Built from the cache's RSVP kind index, and only when a pass actually meets an RSVP. +private fun cachedRsvpAnswers(): Map, CalendarRSVPEvent> = latestRsvpAnswers(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND).mapNotNull { it.event as? CalendarRSVPEvent }) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt index 204864b038..83716db163 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent @@ -108,6 +109,7 @@ enum class HomeFeedType( PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)), FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)), RATINGS("ratings", listOf(EntityRatingEvent.KIND)), + CALENDAR_RSVPS("calendar_rsvps", listOf(CalendarRSVPEvent.KIND)), ; companion object { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt new file mode 100644 index 0000000000..adcbe3d589 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip52Calendar + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent + +/** + * One appointment and the people (already narrowed to the ones the viewer cares about) whose + * latest RSVP to it says "going". [attendees] is newest-RSVP first, so the faces a row shows are + * the people who decided most recently. + * + * A data class on purpose: the fold re-runs on every RSVP or appointment that lands, and value + * equality is what lets the StateFlow drop a fold that changed nothing and lets an unchanged row + * skip recomposition. [appointment] compares by identity, which is right — the cache hands out + * one [AddressableNote] per address. + */ +@Immutable +data class AppointmentAttendance( + val appointment: AddressableNote, + val attendees: List, +) + +/** + * [upcoming] is what a "where are my follows going" list draws, soonest first. [unresolved] are + * the appointments someone RSVP'd to that the cache has no event for yet, most recently answered + * first: whether they are upcoming at all is unknown until they load, so they are handed back for + * the caller to fetch instead of being drawn as blank rows. The order matters to a caller that + * can only fetch a bounded number — a fresh RSVP is the one most likely to be for a future event. + */ +@Immutable +data class FollowsGoing( + val upcoming: List, + val unresolved: List, +) + +/** + * Folds a pile of kind-31925 RSVPs into the appointments the accepted authors are going to. + * + * - Only an author's **latest** RSVP to a given appointment counts. RSVPs are addressable with a + * free-form `d` tag, so a person who said "going" and later "can't go" may have two live events + * (two d-tags); the newest one is their answer. + * - [isAttendee] runs on that latest RSVP only: the follow-list / mute check belongs to the caller. + * - [isAppointmentVisible] runs on the appointment's address before it is resolved, so an event + * hosted by someone the viewer muted is neither drawn nor fetched. + * - An appointment counts as upcoming while it has not ended (an ongoing multi-day conference is + * still somewhere your friends are), matching the feed lens's upcoming/past split. + * + * [nowSeconds] is a parameter so the split is deterministic under test. + */ +fun computeFollowsGoing( + rsvps: Collection, + isAttendee: (CalendarRSVPEvent) -> Boolean, + appointmentFor: (Address) -> AddressableNote, + nowSeconds: Long, + isAppointmentVisible: (Address) -> Boolean = { true }, +): FollowsGoing { + val latest = latestRsvpAnswers(rsvps) + + val goingByAppointment = HashMap>() + latest.forEach { (key, rsvp) -> + if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED && isAttendee(rsvp)) { + goingByAppointment.getOrPut(key.first) { mutableListOf() }.add(rsvp) + } + } + + val upcoming = ArrayList>() + val unresolved = ArrayList>() + + goingByAppointment.forEach { (address, going) -> + if (!isAppointmentVisible(address)) return@forEach + + going.sortWith(NEWEST_FIRST) + + val appointment = appointmentFor(address) + if (appointment.event == null) { + unresolved.add(going.first().createdAt to appointment) + return@forEach + } + val start = appointment.calendarStartSeconds() ?: return@forEach + val end = appointment.calendarEndSeconds() ?: start + if (end < nowSeconds) return@forEach + + upcoming.add(start to AppointmentAttendance(appointment, going.map { it.pubKey })) + } + + upcoming.sortWith( + compareBy> { it.first } + .thenByDescending { it.second.attendees.size } + .thenBy { it.second.appointment.idHex }, + ) + unresolved.sortWith( + compareByDescending> { it.first } + .thenBy { it.second.idHex }, + ) + + return FollowsGoing(upcoming.map { it.second }, unresolved.map { it.second }) +} + +/** + * Each author's newest RSVP to each appointment, keyed (appointment, author). RSVPs are + * addressable with a free-form `d` tag, so one person can hold several live answers to the same + * appointment (said "going", then "can't go" under a new d tag); only the newest is their answer. + * RSVPs pointing at anything but an appointment kind are skipped. + */ +fun latestRsvpAnswers(rsvps: Collection): Map, CalendarRSVPEvent> { + val latest = HashMap, CalendarRSVPEvent>() + rsvps.forEach { rsvp -> + val target = rsvp.calendarEventAddress() ?: return@forEach + if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return@forEach + val key = target to rsvp.pubKey + val current = latest[key] + if (current == null || rsvp.isNewerThan(current)) { + latest[key] = rsvp + } + } + return latest +} + +/** Whether this RSVP is its author's current answer in [latest] (see [latestRsvpAnswers]). */ +fun CalendarRSVPEvent.isLatestAnswerIn(latest: Map, CalendarRSVPEvent>): Boolean { + val target = calendarEventAddress() ?: return false + return latest[target to pubKey]?.id == id +} + +// Newest first; the id breaks a same-second tie so the answer never depends on input order. +private val NEWEST_FIRST = + compareByDescending { it.createdAt }.thenBy { it.id } + +private fun CalendarRSVPEvent.isNewerThan(other: CalendarRSVPEvent) = NEWEST_FIRST.compare(this, other) < 0 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt index 9842410406..b6eaf25d15 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt @@ -30,6 +30,11 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent // a second round-trip, but they don't drive the timeline DAL. val CalendarAppointmentKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND) +// What an author publishes as the owner of a calendar, as opposed to answering someone else's. +val CalendarOwnKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarCollectionEvent.KIND) + +val CalendarRsvpKinds = listOf(CalendarRSVPEvent.KIND) + val AllCalendarKinds = listOf( CalendarTimeSlotEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt index f7dc60e45c..ebf9ae4738 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt @@ -42,7 +42,21 @@ fun filterCalendarsByAuthors( ExplainedFilter( purpose = SubPurpose.TOPIC_FEED, authors = authorList, - kinds = AllCalendarKinds, + kinds = CalendarOwnKinds, + limit = 500, + since = since, + ), + ), + // RSVPs get their own limit: they are what the "follows going" lens is built from, and + // sharing one 500-event window with every appointment and calendar these authors ever + // published let a busy organizer starve out everyone else's answers. + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, + authors = authorList, + kinds = CalendarRsvpKinds, limit = 500, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt index e9d27d4d9f..b4f76d0e51 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent @@ -79,6 +80,9 @@ val HomePostsNewThreadKinds1 = VideoShortEvent.KIND, AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND, + // "Going to " from people you follow. Low-volume per author, so it rides the main + // leg instead of the 5-item rare-kinds leg below, where it would be starved by the others. + CalendarRSVPEvent.KIND, ) val HomePostsNewThreadKinds2 = diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt index 9aeaee304e..4680301de8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt @@ -34,7 +34,7 @@ class UploadFallbackTest { } @Test - fun `a server is never tried twice, even under another spelling`() { + fun `a server is never tried twice even under another spelling`() { val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET") assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band))) } diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt index cf70fa5533..445fb83d3a 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/preview/CharsetDecoder.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.preview +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned @@ -41,7 +42,7 @@ import platform.Foundation.create * to their `NSStringEncoding`; anything else falls back to UTF-8 (matching the * "defaults to UTF-8" behaviour of the charset sniffer). */ -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) actual fun decodeBytes( bytes: ByteArray, charsetName: String?, @@ -55,7 +56,7 @@ actual fun decodeBytes( NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) } - return (NSString.create(data, encoding) as String?) + return NSString.create(data = data, encoding = encoding)?.toString() ?: bytes.decodeToString() } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt new file mode 100644 index 0000000000..9fc3890833 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip52Calendar + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FollowsGoingTest { + private val now = 1_800_000_000L + private val host = "a".repeat(64) + private val alice = "1".repeat(64) + private val bob = "2".repeat(64) + private val carol = "3".repeat(64) + + private val notes = HashMap() + + private fun appointmentFor(address: Address) = notes.getOrPut(address) { AddressableNote(address) } + + private fun timeSlot( + dTag: String, + start: Long, + end: Long? = null, + ): Address { + val address = Address(CalendarTimeSlotEvent.KIND, host, dTag) + val tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("title", dTag)) + add(arrayOf("start", start.toString())) + end?.let { add(arrayOf("end", it.toString())) } + }.toTypedArray() + appointmentFor(address).event = CalendarTimeSlotEvent("id-$dTag", host, 0L, tags, "", "sig") + return address + } + + private var rsvpCounter = 0 + + private fun rsvp( + author: String, + target: Address, + status: String, + createdAt: Long, + dTag: String = "rsvp-${rsvpCounter++}", + ) = CalendarRSVPEvent( + id = "rsvp-${rsvpCounter++}", + pubKey = author, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("a", target.toValue()), + arrayOf("status", status), + ), + content = "", + sig = "sig", + ) + + private fun fold( + rsvps: List, + isAttendee: (CalendarRSVPEvent) -> Boolean = { true }, + ) = computeFollowsGoing(rsvps, isAttendee, ::appointmentFor, now) + + @Test + fun groupsGoingAuthorsPerAppointment_newestAnswerFirst() { + val conf = timeSlot("conf", start = now + 86_400) + + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10), + rsvp(bob, conf, "accepted", createdAt = 20), + ), + ) + + assertEquals(1, result.upcoming.size) + assertEquals(listOf(bob, alice), result.upcoming.single().attendees) + } + + @Test + fun onlyTheLatestAnswerPerAuthorCounts_evenAcrossDTags() { + val conf = timeSlot("conf", start = now + 86_400) + + // Alice said going, then changed her mind in a second RSVP with a different d tag. + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10, dTag = "first"), + rsvp(alice, conf, "declined", createdAt = 20, dTag = "second"), + rsvp(bob, conf, "tentative", createdAt = 30), + ), + ) + + assertTrue(result.upcoming.isEmpty()) + } + + @Test + fun attendeeFilterRunsOnTheLatestAnswer() { + val conf = timeSlot("conf", start = now + 86_400) + + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10), + rsvp(carol, conf, "accepted", createdAt = 20), + ), + isAttendee = { it.pubKey != carol }, + ) + + assertEquals(listOf(alice), result.upcoming.single().attendees) + } + + @Test + fun dropsEndedEvents_keepsOngoingOnes_sortsSoonestFirst() { + val past = timeSlot("past", start = now - 7_200, end = now - 3_600) + val ongoing = timeSlot("ongoing", start = now - 3_600, end = now + 3_600) + val later = timeSlot("later", start = now + 10 * 86_400) + val sooner = timeSlot("sooner", start = now + 86_400) + + val result = + fold( + listOf(past, ongoing, later, sooner).map { rsvp(alice, it, "accepted", createdAt = 10) }, + ) + + assertEquals( + listOf(ongoing, sooner, later), + result.upcoming.map { it.appointment.address }, + ) + } + + @Test + fun appointmentsNotInTheCacheAreReportedForFetching() { + val missing = Address(CalendarDateSlotEvent.KIND, host, "not-loaded") + + val result = fold(listOf(rsvp(alice, missing, "accepted", createdAt = 10))) + + assertTrue(result.upcoming.isEmpty()) + assertEquals(listOf(missing), result.unresolved.map { it.address }) + } + + @Test + fun ignoresRsvpsThatPointAtNonAppointments() { + val calendar = Address(CalendarCollectionEvent.KIND, host, "cal") + + val result = fold(listOf(rsvp(alice, calendar, "accepted", createdAt = 10))) + + assertTrue(result.upcoming.isEmpty()) + assertTrue(result.unresolved.isEmpty()) + } + + @Test + fun appointmentsByAHiddenHostAreNeitherShownNorFetched() { + val conf = timeSlot("conf", start = now + 86_400) + val missing = Address(CalendarTimeSlotEvent.KIND, host, "not-loaded") + + val result = + computeFollowsGoing( + rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(alice, missing, "accepted", 11)), + isAttendee = { true }, + appointmentFor = ::appointmentFor, + nowSeconds = now, + isAppointmentVisible = { it.pubKeyHex != host }, + ) + + assertTrue(result.upcoming.isEmpty()) + assertTrue(result.unresolved.isEmpty()) + } + + @Test + fun unresolvedAreOrderedByTheMostRecentRsvp() { + val old = Address(CalendarTimeSlotEvent.KIND, host, "old") + val fresh = Address(CalendarTimeSlotEvent.KIND, host, "fresh") + val middle = Address(CalendarTimeSlotEvent.KIND, host, "middle") + + val result = + fold( + listOf( + rsvp(alice, old, "accepted", createdAt = 10), + rsvp(alice, fresh, "accepted", createdAt = 30), + rsvp(alice, middle, "accepted", createdAt = 15), + // Bob's newer answer makes "middle" the most recent of all. + rsvp(bob, middle, "accepted", createdAt = 40), + ), + ) + + assertEquals(listOf(middle, fresh, old), result.unresolved.map { it.address }) + } + + @Test + fun anUnchangedFoldIsEqualToThePreviousOne() { + val conf = timeSlot("conf", start = now + 86_400) + val rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(bob, conf, "accepted", 20)) + + // The view model's StateFlow relies on this to drop no-op folds. + assertEquals(fold(rsvps), fold(rsvps.reversed())) + } + + @Test + fun anOlderGoingIsNotTheCurrentAnswerOnceTheAuthorDeclines() { + // The Home feed's rule: the "going" under the old d tag is still live, but it is not + // Alice's answer any more. + val conf = timeSlot("conf", start = now + 86_400) + val going = rsvp(alice, conf, "accepted", createdAt = 10) + val declined = rsvp(alice, conf, "declined", createdAt = 20) + + val latest = latestRsvpAnswers(listOf(going, declined)) + + assertTrue(declined.isLatestAnswerIn(latest)) + assertFalse(going.isLatestAnswerIn(latest)) + } + + @Test + fun anAnswerStaysCurrentWhenOnlyOtherPeopleOrOtherEventsChange() { + val conf = timeSlot("conf", start = now + 86_400) + val party = timeSlot("party", start = now + 2 * 86_400) + val aliceConf = rsvp(alice, conf, "accepted", createdAt = 10) + val aliceParty = rsvp(alice, party, "declined", createdAt = 30) + val bobConf = rsvp(bob, conf, "declined", createdAt = 40) + + val latest = latestRsvpAnswers(listOf(aliceConf, aliceParty, bobConf)) + + assertTrue(aliceConf.isLatestAnswerIn(latest)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index f97f30c142..da517ef139 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -669,6 +669,29 @@ Označit všechny známé jako přečtené Označit všechny nové jako přečtené Označit všechny jako přečtené + Váš tajný klíč je váš účet + Kdokoli ho má, může za vás publikovat. Pokud ho ztratíte, nikdo ho nedokáže obnovit, ani Amethyst. + Klepnutím zobrazíte + Zapište si ho v tomto pořadí. Na velikosti písmen nezáleží. + QR kód + Kopie chráněná heslem + Heslo + ncryptsec1…, který funguje jen s vaším heslem. + + Alespoň %1$d znak + Alespoň %1$d znaky + Alespoň %1$d znaku + Alespoň %1$d znaků + + Zopakujte heslo + Hesla se neshodují + Zašifrovat klíč + Šifrování… + Můžete ho bezpečně uložit do správce hesel nebo do poznámek v cloudu: bez hesla ho nelze otevřít a zapomenuté heslo nelze obnovit. + Použít jiné heslo + Uložte papír tam, kde ho najdete jen vy, nebo si klíč uložte do správce hesel. + Nikdy nevkládejte svůj klíč na weby nebo do aplikací, kterým nedůvěřujete. + Vývojáři Amethystu po vás klíč nikdy nebudou chtít. Zálohujte si klíče Váš tajný klíč je jediný způsob, jak se k tomuto účtu dostat. Pokud ho ztratíte, nelze ho už nikdy obnovit. Uložte si ho někam do bezpečí. Zálohovat nyní @@ -702,6 +725,8 @@ Odstranit toto médium z vaší galerie. Požadavek na smazání Amethyst požádá o smazání vaší poznámky ze spojek, ke kterým jste v současnosti připojeni. Není zaručeno, že vaše poznámka bude trvale smazána z těchto spojek nebo z dalších spojek, kde může být uložena. + Amethyst požádá všechny v této šifrované skupině, aby tuto zprávu smazali. Jejich aplikace by ji měly odstranit, ale není zaručeno, že každý člen svou kopii smaže. + Amethyst pošle lidem v této konverzaci šifrovaný požadavek na smazání. Jejich aplikace by měly zprávu odstranit, ale není zaručeno, že budou smazány všechny kopie. Blokovat Smazat Blokovat @@ -1736,6 +1761,14 @@ Název Popis Obrázek skupiny + URL obrázku banneru + Tato skupina se možná přesunula + Tato skupina možná má odnož + Její administrátoři ji nyní uvádějí na %1$s + Lidé, které sledujete, ji nyní uvádějí na %1$s + Otevřít tam + Přesunout + Zavřít Přidat fotku Změnit fotku Objevování @@ -1840,6 +1873,9 @@ Amethystu se nepodařilo dokončit připojení k síti Tor. Chcete použít běžné připojení (bez Toru), aby se feed mohl načíst? Tuto volbu si zapamatujeme na hodinu a poté znovu zkusíme Tor. Použít běžné připojení Pokračovat v čekání + Připojování k Toru… + Chyba Toru: %1$s + Amethyst používá Tor, aby skryl vaši IP adresu před relayi. Spuštění Toru může trvat až minutu a vyžaduje přístup k internetu. Klepněte na kategorii a otevřete pro ni nastavení oznámení Androidu — zvuk, důležitost, odznaky a režim Nerušit najdete tam. Zapnuto Ztlumeno @@ -2472,6 +2508,8 @@ Vyberte seznam pro filtrování kanálu Přidat algoritmus zdroje k oblíbeným Odebrat z oblíbených + Přidat seznam sledování do oblíbených kanálů + Odebrat seznam sledování z oblíbených kanálů Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. Připněte si oblíbené algoritmy Klepněte na "%1$s" níže pro procházení algoritmů. @@ -2692,6 +2730,20 @@ Viditelné pouze pro vás Odeslat do… Hledání zařízení ve vaší Wi-Fi… + Vyžadováno oprávnění + Amethyst potřebuje přístup k místní síti, aby našel zařízení Cast ve vaší Wi-Fi. Povolte ho prosím v nastavení aplikace. + Otevřít nastavení + Cílové zařízení + %1$s nepodporuje formát tohoto videa + Toto video se na %1$s nepodařilo přehrát + %1$s přestává odpovídat. Obvykle pomůže zařízení restartovat. + Nepodařilo se připojit k %1$s. Obvykle pomůže zařízení restartovat. + %1$s je offline + Toto video se na %1$s nepodařilo načíst + Odesílání vyžaduje služby Google Play, které na tomto zařízení nejsou k dispozici + %1$s nepodporuje formát tohoto videa (%2$s) + Toto video se na %1$s nepodařilo přehrát (%2$s) + Přehrávání tohoto videa se na %1$s vůbec nespustilo (%2$s). Zařízení možná tento formát nepodporuje, nebo může pomoci restart. Vyberte video Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení. Změnit @@ -2803,8 +2855,13 @@ Žádné události k moderování Zakázané události Žádné zakázané události + Povolené události + Žádné povolené události + Povolit událost Povolené druhy Žádné povolené druhy + Nepovolené druhy + Žádné nepovolené druhy Blokované IP adresy Žádné blokované IP adresy Přidat @@ -2837,6 +2894,9 @@ Žádost o připojení odeslána Žádost o opuštění odeslána Jste členem + Kód pozvánky + Tento relay přijímá členy s kódem pozvánky od svého provozovatele + Tento relay nezveřejňuje svůj identifikační klíč (NIP-11 self), takže jeho seznam členů nelze ověřit Seznam členů relé Člen přidán do relé %1$d členů přidáno do relé @@ -4172,6 +4232,11 @@ Administrátorská práva udělena Administrátorská práva odebrána Změnit fotku + Zprávy od ostatních členů už na tomto zařízení nelze přečíst. Resetujte svou kopii skupiny a pak požádejte administrátora, aby vás odebral a znovu přidal. + Resetovat + Resetovat tuto skupinu? + Toto zařízení přestane být členem, dokud vás administrátor znovu nepřidá. Zprávy, které už máte, tu zůstanou. + Čeká se na opětovné přidání. Požádejte administrátora, aby vás odebral a znovu přidal. Nejprve přidejte v Nastavení mediální server Blossom — skupina potřebuje místo, kam nahrávat přílohy. Tato skupina nyní používá šifrované přílohy Nepodařilo se přidat %1$s: %2$s @@ -4712,6 +4777,7 @@ Krátký zvukový nebo video náhled Chyba Value-for-Value Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel). + Vaše peněženka nepodporuje keysend, proto byli příjemci keysend (uzel) přeskočeni. Tento podcast nemá žádné platitelné příjemce hodnoty. Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu. Tento uživatel nemá Lightning adresu @@ -5411,6 +5477,7 @@ Nepodařilo se načíst transakce Nepodařilo se načíst další transakce + Vaše peněženka nenabízí historii transakcí Zobrazí varovnou zprávu, když příspěvky mají 5 nebo více hlášení od vašich sledovaných Upozornit na hlášení Přidat webovou záložku diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index a6b7b9fd49..62fa6899b4 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -653,6 +653,27 @@ Alle Kontakte als gelesen markieren Alle Unbekannten als gelesen markieren Alle als gelesen markieren + Dein geheimer Schlüssel ist dein Konto + Wer ihn hat, kann in deinem Namen posten. Wenn du ihn verlierst, kann ihn niemand wiederherstellen, nicht einmal Amethyst. + Tippen zum Anzeigen + Schreibe ihn in dieser Reihenfolge auf. Groß- und Kleinschreibung spielen keine Rolle. + QR-Code + Passwortgeschützte Kopie + Passwort + Ein ncryptsec1…, das nur mit deinem Passwort funktioniert. + + Mindestens %1$d Zeichen + Mindestens %1$d Zeichen + + Passwort wiederholen + Passwörter stimmen nicht überein + Schlüssel verschlüsseln + Verschlüsseln… + Kann sicher in einem Passwortmanager oder in Cloud-Notizen aufbewahrt werden: Ohne das Passwort lässt es sich nicht öffnen, und ein vergessenes Passwort kann nicht wiederhergestellt werden. + Anderes Passwort verwenden + Bewahre das Papier an einem Ort auf, den nur du kennst, oder speichere den Schlüssel in einem Passwortmanager. + Füge deinen Schlüssel niemals in Websites oder Apps ein, denen du nicht vertraust. + Amethyst-Entwickler werden dich niemals nach deinem Schlüssel fragen. Sichere deine Schlüssel Dein geheimer Schlüssel ist der einzige Weg zu diesem Konto. Wenn du ihn verlierst, kann er nie wiederhergestellt werden. Speichere ihn jetzt an einem sicheren Ort. Jetzt sichern @@ -686,6 +707,8 @@ Entferne dieses Medium von deiner Galerie. Löschung anfordern Amethyst wird darum bitten, dass dein Beitrag von den Relays gelöscht wird, mit denen du derzeit verbunden bist. Es gibt keine Garantie dafür, dass dein Beitrag dauerhaft von diesen oder anderen Relays, auf denen er gespeichert sein könnte, gelöscht wird. + Amethyst bittet alle in dieser verschlüsselten Gruppe, diese Nachricht zu löschen. Ihre Apps sollten sie entfernen, aber es gibt keine Garantie, dass jedes Mitglied seine Kopie löscht. + Amethyst sendet eine verschlüsselte Löschanfrage an die Personen in dieser Unterhaltung. Ihre Apps sollten die Nachricht entfernen, aber es gibt keine Garantie, dass jede Kopie gelöscht wird. Blockieren Löschen Blockieren @@ -1716,6 +1739,14 @@ Name Beschreibung Gruppenbild + Banner-Bild-URL + Diese Gruppe ist möglicherweise umgezogen + Diese Gruppe hat möglicherweise einen Fork + Ihre Admins führen sie jetzt auf %1$s + Personen, denen du folgst, führen sie jetzt auf %1$s + Dort öffnen + Verschieben + Verwerfen Foto hinzufügen Foto ändern Entdeckung @@ -1820,6 +1851,9 @@ Amethyst konnte den Tor-Start nicht abschließen. Eine reguläre (Nicht-Tor-)Verbindung verwenden, damit der Feed laden kann? Wir merken uns diese Auswahl für die nächste Stunde und versuchen Tor danach erneut. Verbindung ohne Tor verwenden Weiter warten + Verbindung zu Tor wird hergestellt… + Tor-Fehler: %1$s + Amethyst nutzt Tor, um deine IP-Adresse vor Relays zu verbergen. Der Start von Tor kann eine Minute dauern und benötigt Internetzugang. Tippe auf eine Kategorie, um die Android-Benachrichtigungseinstellungen dafür zu öffnen — Ton, Wichtigkeit, Badges und „Nicht stören" findest du dort. An Lautlos @@ -2452,6 +2486,8 @@ Liste zum Filtern des Feeds auswählen Feed-Algorithmus zu Favoriten hinzufügen Aus Favoriten entfernen + Follower-Liste zu Lieblings-Feeds hinzufügen + Follower-Liste aus Lieblings-Feeds entfernen Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. Lieblingsalgorithmen anheften Tippe unten auf "%1$s", um Algorithmen zu durchsuchen. @@ -2672,6 +2708,20 @@ Nur für dich sichtbar Streamen an… Suche nach Geräten in deinem WLAN… + Berechtigung erforderlich + Amethyst benötigt Zugriff auf das lokale Netzwerk, um Cast-Geräte in deinem WLAN zu finden. Bitte aktiviere ihn in den App-Einstellungen. + Einstellungen öffnen + Das Streaming-Gerät + %1$s kann das Format dieses Videos nicht abspielen + %1$s konnte dieses Video nicht abspielen + %1$s reagiert nicht mehr. Ein Neustart des Geräts hilft meistens. + Verbindung zu %1$s fehlgeschlagen. Ein Neustart des Geräts hilft meistens. + %1$s ist offline + Dieses Video konnte auf %1$s nicht geladen werden + Streamen erfordert Google Play-Dienste, die auf diesem Gerät nicht verfügbar sind + %1$s kann das Format dieses Videos nicht abspielen (%2$s) + %1$s konnte dieses Video nicht abspielen (%2$s) + %1$s hat die Wiedergabe dieses Videos nie gestartet (%2$s). Vielleicht wird das Format nicht unterstützt, oder ein Neustart des Geräts hilft. Video auswählen Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. Ändern @@ -2783,8 +2833,13 @@ Keine Ereignisse zur Moderation Gesperrte Ereignisse Keine gesperrten Ereignisse + Erlaubte Ereignisse + Keine erlaubten Ereignisse + Ereignis erlauben Erlaubte Arten Keine erlaubten Arten + Nicht erlaubte Arten + Keine nicht erlaubten Arten Blockierte IPs Keine blockierten IPs Hinzufügen @@ -2817,6 +2872,9 @@ Beitrittsanfrage gesendet Austrittsanfrage gesendet Du bist Mitglied + Einladungscode + Dieses Relay lässt Mitglieder mit einem Einladungscode seines Betreibers zu + Dieses Relay veröffentlicht seinen Identitätsschlüssel nicht (NIP-11 self), daher kann seine Mitgliederliste nicht überprüft werden Relay-Mitgliederliste Mitglied zum Relay hinzugefügt %1$d Mitglieder zum Relay hinzugefügt @@ -4074,6 +4132,11 @@ Administratorrechte erteilt Administratorrechte widerrufen Foto ändern + Nachrichten der anderen Mitglieder können auf diesem Gerät nicht mehr gelesen werden. Setze deine Kopie der Gruppe zurück und bitte dann einen Admin, dich zu entfernen und wieder hinzuzufügen. + Zurücksetzen + Diese Gruppe zurücksetzen? + Dieses Gerät ist kein Mitglied mehr, bis ein Admin dich wieder hinzufügt. Deine bisherigen Nachrichten bleiben hier. + Warte darauf, wieder hinzugefügt zu werden. Bitte einen Admin, dich zu entfernen und erneut hinzuzufügen. Füge zuerst in den Einstellungen einen Blossom-Medienserver hinzu — die Gruppe braucht einen Ort, an den Anhänge hochgeladen werden. Diese Gruppe verwendet jetzt verschlüsselte Anhänge %1$s konnte nicht hinzugefügt werden: %2$s @@ -4556,6 +4619,7 @@ Kurze Audio- oder Videovorschau Value-for-Value-Fehler Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden. + Deine Wallet unterstützt kein Keysend, daher wurden Node-Empfänger übersprungen. Dieser Podcast hat keine zahlbaren Value-Empfänger. Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen. Dieser Nutzer hat keine Lightning-Adresse @@ -5189,6 +5253,7 @@ Transaktionen konnten nicht geladen werden Weitere Transaktionen konnten nicht geladen werden + Deine Wallet bietet keinen Transaktionsverlauf Zeigt eine Warnmeldung an, wenn die Beiträge 5 oder mehr Berichte von den folgenden Beiträgen enthalten Bei Berichten warnen Web-Lesezeichen hinzufügen diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index fcfe8d4f70..a18f22e6bf 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -313,7 +313,7 @@ न्यूनतम एक कोष जोडें। प्रकाशन असफल। पुनःप्रसारक संयोजन का जाँच करें तथा पुनःप्रयास करें। इसको ज्साप इनमें विभाजित : - %1$d प्रतिशतप्रतिशत + %1$d प्रतिशत मूल्य के लिए मूल्य खोलें पढें @@ -661,7 +661,10 @@ पारणशब्द रक्षित अनुकृति पारणशब्द एक ncryptsec1… जो केवल आपके पारणशब्द से कार्य करता है। - न्यूनतम %1$d अक्षर + + न्यूनतम %1$d अक्षर + न्यूनतम %1$d अक्षर + पारणशब्द दोहराएँ पारणशब्दों में मेल नहीं रहस्यीकरण कुंचिका @@ -704,6 +707,8 @@ इस श्रव्यदृश्याभिलेख को आपके चित्रालय से हटाएँ। हटाने की याचना अमेथिस्ट अनुरोध करेगा कि आपका टीका मिटा दिया जाए उन पुनःप्रसारकों से जिनके साथ आप अब जुडे हुए हैं। कोई आश्वासन नहीं कि आपका टीका सर्वदा के लिए मिटा दिया जाएगा उन पुनःप्रसारकों से, अथवा अन्य पुनःप्रसारकों में से जहाँ यह रखा गया हो। + अमेथिस्ट इस रहस्यीकृत समूह में सबसे अनुरोध करेगा इस सन्देश को मिटाने के लिए। उनके क्रमक इसको हटाना चाहिए पर कोई आश्वासन नहीं कि सभी सदस्य अपनी अनुकृति मिटा देंगे। + अमेथिस्ट एक रहस्यीकृत विलोपन अनुरोध इस वार्तालाप में सभी लोगों को भेजेगा। उनके क्रमक सन्देश को हटाना चाहिए पर कोई आश्वासन नहीं कि सभी अनुकृतियाँ मिटा दिए जाएँगे। बाधित करें मिटाएँ बाधित करें @@ -1846,6 +1851,9 @@ अमेथिस्ट द्वारा टोर॰ का स्वयम उत्थापन सम्पन्न नहीं हुआ। क्या एक समान्य संयोजन (टोर॰ अतिरिक्त) का प्रयोग करें जिससे कि सूचनावली का आवहन हो सके। इस चयन का स्मरण रखा जाएगा अगले घण्टे तक तथा टोर॰ का पुनःप्रयास किया जाएगा तत्पश्चात। सामान्य संयोजन का प्रयोग करें प्रतीक्षा करते रहें + टोर से संयोजन किया जा रहा है… + टोर अपक्रम। %1$s + अमेथिस्ट टोर का उपयोग करता है आपके अंकीय जालपता छिपाने के लिए पुनःप्रसारकों से। टोर को आरम्भ करने के लिए एक मिनुट लग सकता है। इसके लिए संचारजाल अभिगमन आवश्यक। श्रेणि दबाएँ उसके आण्ड्रोइड सूचना स्थापना विकल्पों को खोलने के लिए। ध्वनि महत्व पदक तथा विघ्नविराम वहाँ हैं। चालू मौन @@ -3138,7 +3146,7 @@ %1$d पुनःप्रसारक ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र - %1$dप्रतिशतप्रतिशत सब में से + %1$d प्रतिशत सब में से सक्रिय पुनःप्रसारक ग्राहकताएँ %1$d छलनी आरोपित नहीं अब तक @@ -4124,6 +4132,11 @@ प्रबन्धन अधिकार दिए गए प्रबन्धन अधिकार हटाए गए चित्र परिवर्तन + अन्य सदस्यों से सन्देश इस यन्त्र पर आगे से पढे नहीं जा सकते। समूह की आपकी अनुकृति को पुनःस्थापित करें। तत्पश्चात एक प्रशासक को पूछें कि आपको हटाएँ तथा पुनःजोडें। + पुनःस्थापन + क्या इस समूह को पुनःस्थापित करें। + इस यन्त्र की सदस्यता रुक जाती है जब तक कि एक प्रशासक आपको पुनःजोडता नहीं। सन्देश जो आपके पास पहले से हैं यहीं रहते हैं। + पुनःजोडे जाने की प्रतीक्षा में। एक प्रशासक को पूछें कि आपको हटाएँ तथा पुनःजोडें। पहले एक ब्लोस्सम श्रव्यदृश्याभिलेख सेवासंगणक जोडें स्थापना विकल्पों में। समूह के लिए कोई स्थान आवश्यक है संलग्न अभिलेखों का आरोहण करने के लिए। यह समूह अब रहस्यीकृत संलग्न अभिलेखों का उपयोग करता है %1$s जोडने में असफल : %2$s @@ -5185,7 +5198,7 @@ ध्वनि आरोहण असफल : %1$s ध्वनि सन्देश आरोहण असफल निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए - %1$d% समय निरन्तर उपलब्ध + %1$d प्रतिशत समय निरन्तर उपलब्ध सक्रिय टोर उपकरण आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके @@ -5539,7 +5552,7 @@ संयोजन रहस्यीकृत जानकारी जो आप भेजते हैं मार्गमध्य पठन असाध्य। संयोजन अरहस्यीकृत - पारणशब्द अथवा भुगतान विवरणों का प्रविष्ट ना करें। + पारणशब्द अथवा भुगतान विवरणों को प्रविष्ट ना करें। टोर पर बहुरहस्यावृत संचार द्वारा खुला जाल प्रमाणपत्र @@ -5882,7 +5895,7 @@ टाँका हटाएँ वह परिवर्तन असफल। समायोजक ने उत्तर नहीं दिया। इसलिए कुछ भी परिवर्तित नहीं। कुछ क्षण पश्चात पुनःप्रयास करें। - %1$s ने कुंचिका पेटली प्रकाशित नहीं की अब तक इस समायोजक के लिए। इसलिए वे जोडे नहीं जा सकते अब तक। उनसे पूछें पहले कोर्डन को खोलने के लिए इस समायोजक पर। + %1$s ने कुंचिका पोटली प्रकाशित नहीं की अब तक इस समायोजक के लिए। इसलिए वे जोडे नहीं जा सकते अब तक। उनसे पूछें पहले कोर्डन को खोलने के लिए इस समायोजक पर। आप अपने को हटा नहीं सकते एक कोर्डन समूह से। %1$s को समूह में आने की विधि नहीं दे सके। इसलिए जोडने का प्रयास परित्यक्त। %1$s इस समुह में नहीं है। @@ -5897,7 +5910,7 @@ पारणवाक्याम्श पुनःस्थापन पुनःस्थापन से इस यन्त्र पर कोर्डन समूह प्रतिस्थापित किए जाएँगे अभिलेखित समूहों के साथ। यह एक संचारयन्त्र पर एक अन्य से विस्थापित करने के लिए है। समूहों को दो यन्त्रों पर एक साथ उपयोग करने की विधि नहीं। - सभी कोर्डन समूह जो इस यन्त्र पर हैं हटाए जाएँगे तथा अभिलेख में उपलब्ध से प्रतिस्थापित होंगे।\n\nदूसरे संचारयन्त्र में पुनःस्थापन ना करें जब पहला का भी उपयोग हो रहा है। दोनों में समान समूह कुंचिकाएँ होंगी। तथा जब दोनों एक सन्देश भेजेंगे तब समूह सब के लिए टूट जाएगा। मौनतः तथा पूर्ववत करने की विधि के बिना। + सभी कोर्डन समूह जो इस यन्त्र पर हैं हटाए जाएँगे तथा अभिलेख में उपलब्ध से प्रतिस्थापित होंगे।\n\nदूसरे संचारयन्त्र में पुनःस्थापन ना करें जब पहला का भी उपयोग हो रहा है। दोनों में समान समूह कुंचिकाएँ होंगी। तथा जब दोनों एक सन्देश भेजेंगे तब समूह सब के लिए टूट जाएगा। मौनतः। तथा पूर्ववत करने की विधि के बिना। क्या इस यन्त्र के कोर्डन समूहों का प्रतिस्थापन करें। पुनःस्थापन पुनःस्थापित। @@ -5936,7 +5949,7 @@ अधिक क्रियाएँ कोई समायोजक नहीं अब तक। मिटाएँ - हटाने से समूह इस यन्त्र पर रहते हैं। इसलिए समायोजक को पुनःजोडने से समूह भी लौट आते हैं। मिटानेसे समूह मिटते हैं। समूह कुंचिकाएँ तथा पठनस्थान तथा आपके सभी कुंचिका पोटलियाँ सभी चले जाते हैं। तथा नए आमन्त्रण से ही उन समूहों का पुनःप्रवेश कर सकते हैं।\n\nसमायोजक को बताया नहीं जाता। तथा जो उसके पास पहले से था वह उसे रखता है। + हटाने से समूह इस यन्त्र पर रहते हैं। इसलिए समायोजक को पुनःजोडने से समूह भी लौट आते हैं। मिटानेसे समूह मिटते हैं। समूह कुंचिकाएँ तथा पठनस्थान तथा आपके सभी कुंचिका पोटलियाँ सभी चले जाते हैं। तथा नए आमन्त्रण से ही उन समूहों में पुनःप्रवेश कर सकते हैं।\n\nसमायोजक को बताया नहीं जाता। तथा जो उसके पास पहले से था वह उसे रखता है। क्या इस समायोजक को मिटा दें। %1$s उत्तर सक्रिय @@ -5997,7 +6010,7 @@ हो गया कोई भी जोडे नहीं गए अब तक। समूह रिक्त रूप में बनाया जा सकता है तथा आमन्त्रण तत्पश्चात भेजे जा सकते हैं। पर व्यक्तियों को अब नामित करने से पता चलेगा किस समायोजक उन तक अभिगम्य। कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए - लोगों को नामित अब करने से ही पता चलेगा किस समायोजक उन तक अभिगम्य। प्रत्येक को एक स्वागत प्राप्त होता है जो उस समायोजक पर छोडा जाता है। जिसे वे उठाते हैं अगली बार वे अपने क्रमक खोलते हैं। + लोगों को नामित अब करने से ही पता चलेगा किस समायोजक उन तक अभिगम्य। प्रत्येक को एक स्वागत प्राप्त होता है जो उस समायोजक पर छोडा जाता है। जिसे वे उठाते हैं अगली बार जब वे अपने क्रमक खोलते हैं। अभिगम्य %1$d समायोजक पर अभिगम्य %1$d समायोजकों पर @@ -6075,7 +6088,7 @@ प्रकाशन से एक अभिलेख हस्ताक्षरित होता है आपकी अपनी लेखा कुंचिका के साथ। तब समायोजक जान जाता है कि यह लेखा है तथा आमन्त्रणशक्य है। स्थायी रूप से। तथा आप को किसी ने आमन्त्रित किया अथवा नहीं इस पर आश्रित नहीं। अन्य लोग आपको एक कोर्डन समूह में जोड सकते हैं केवल आपके द्वारा प्रकाशित कुंचिका पोटली को समायोजक तक ले जाने से। कोर्डन में कोई अन्य स्थल नहीं एक को रखने के लिए। इसलिए प्रकाशित नहीं तो कोई भी आपको आमन्त्रण नहीं दे सकते। तथा कुछ भी उनको बताता नहीं कि यह क्यों है। कुंचिका पोटलियाँ पढने में असफल। - कोई अन्तिम उपाय पोटली नहीं। एकप्रयोग वाले जब समाप्त हो जाते हैं तब आमन्त्रण असफल हो जाते हैं। + कोई अन्तिम उपाय पोटली नहीं। एकप्रयोग वाले जब समाप्त हो जाते हैं तब से आमन्त्रण असफल हो जाते हैं। एक अन्तिम उपाय पोटली प्रकाशित है। इसलिए एक आमन्त्रण तब भी बनाया जा सकता है सभी एकप्रयोग वाले समाप्त होने के पश्चात। %1$d पोटली जिसे यह यन्त्र नहीं खोल सकता diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 9f19ed5a06..3d7081f80a 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -661,7 +661,10 @@ Jelszóval védett másolat Jelszó Egy ncryptsec1…, amely csak az Ön jelszavával működik. - Legalább %1$d karakter + + Legalább %1$d karakter + Legalább %1$d karakter + Jelszó ismét A jelszavak nem egyeznek Kulcs titkosítása @@ -704,6 +707,8 @@ Távolítsa el ezt a médiát a galériából. Törlés kérése Az Amethyst kérni fogja, hogy a bejegyzését töröljék azokról az átjátszókról, amelyekhez jelenleg csatlakozik. Nem garantálható, hogy a bejegyzése véglegesen törlődik ezekről az átjátszókról, vagy más átjátszókról, ahol esetleg tárolva van. + Az Amethyst arra fogja kérni a titkosított csoport minden tagját, hogy töröljék ezt az üzenetet. Az alkalmazásoknak el kellene távolítaniuk azt, de nincs rá garancia, hogy minden tag törli a saját példányát. + Az Amethyst titkosított törlési kérelmet küld a beszélgetés résztvevőinek. Az alkalmazásaiknak el kellene távolítaniuk az üzenetet, de nincs garancia arra, hogy minden példány törlődik. Letiltás Törlés Letiltás @@ -1846,6 +1851,9 @@ Az Amethystnek nem sikerült befejeznie a Tor előkészítését. Legyen normál (nem Tor) kapcsolat használva a hírfolyam betöltéséhez? A választás egy óráig érvényes, utána a rendszer ismételten megkísérli a Tor hálózathoz való kapcsolódást. Normál kapcsolat használata Tovább várok + Kapcsolódás a TOR-hálózathoz… + TOR-hálózati hiba: %1$s + Az Amethyst a TOR-hálózatot használja, hogy elrejtse az IP-címet az átjátszók elől. A Tor elindítása több percig is eltarthat, és internetkapcsolatra van szükség hozzá. Koppintson egy kategóriára, hogy megnyissa az ahhoz tartozó Android értesítési beállításokat – itt találhatók a hang-, a fontossági-, az értesítési jelölők- és a „Ne zavarjanak” beállítások. Be Néma @@ -4124,6 +4132,11 @@ Adminisztrátori jogosultságok megadva Adminisztrátori jogosultságok visszavonva Fotó módosítása + A többi tag üzenetei ezen az eszközön már nem olvashatók. Állítsa vissza a csoportot, majd kérje meg az egyik rendszergazdát, hogy távolítsa el Önt a csoportból, majd adja hozzá újra. + Visszaállítás + Visszaállítja a csoportot? + Ez az eszköz elveszíti a tagságát, amíg egy adminisztrátor hozzá nem adja újra. A már meglévő üzenetek itt maradnak. + Várakozás a visszavételre. Kérjen meg egy adminisztrátort, hogy távolítsa el Önt, majd adja hozzá újra. Először adjon hozzá egy Blossom médiakiszolgálót a beállításokban, mert a csoportnak szüksége van egy helyre, ahová a mellékleteket feltöltheti. Ez a csoport mostantól titkosított mellékleteket használ Nem sikerült hozzáadni a következőt: %1$s: %2$s @@ -6123,7 +6136,7 @@ Hozzáadás Csak az adminisztrátor fogadhat el. Egy adminisztrátorok nélküli csoportban ez mindenki. Kérelmek ellenőrzése - Elvetés + Eltüntetés Nem sikerült beolvasni a kérelmeket. Senki sem vár a csatlakozásra. Csatlakozási kérelmek diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 8d50a907f5..2a9bf95377 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -593,7 +593,7 @@ Każdy nadal posiadający ten link nie będzie mógł już dołączyć. Osoby, które już się do niej dołączyły, pozostają w społeczności. Tego nie można cofnąć. Unieważnij Nazwa społeczności zostanie ujawniona dopiero po dołączeniu - Dołączenie do transmiterów tego zaproszenia, publikuje ogłoszenie o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz. + Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz. Transmiter tego zaproszenia skontaktuje się: %1$s Nie dołączyłeś(aś) jeszcze do kanału Concord. Utwórz kanał lub otwórz link z zaproszeniem. Pokaż wszystkie kanały @@ -677,7 +677,12 @@ Kopia chroniona hasłem Hasło Ncryptsec1… który działa tylko z twoim hasłem. - Co najmniej %1$d znaków + + Co najmniej %1$d znak + Co najmniej %1$d znaki + Co najmniej %1$d znaków + Co najmniej %1$d znaku + Powtórz hasło Hasła nie są identyczne Zaszyfruj klucz @@ -720,6 +725,8 @@ Usuń ten plik z Galerii. Poproś o usunięcie Amethyst poprosi o usunięcie Twojego wpisu z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twój wpis zostanie trwale usunięty z tych lub z innych transmiterów, gdzie może być przechowywany. + Amethyst poprosi wszystkich w tej zaszyfrowanej grupie o usunięcie tej wiadomości. Ich aplikacje powinny ją usunąć, ale nie ma gwarancji, że każdy członek usunie swoją kopię. + Ametyst wyśle zaszyfrowaną prośbę o usunięcie do osób w tej rozmowie. Ich aplikacje powinny usunąć wiadomość, ale nie ma gwarancji, że każda kopia zostanie usunięta. Zablokuj Usuń Zablokuj @@ -1754,6 +1761,14 @@ Nazwa Opis Zdjęcie grupy + Adres URL banera + Ta grupa mogła się przenieść + Ta grupa może mieć fork + Administratorzy wyświetlają teraz listę na %1$s + Osoby, których obserwujesz teraz na liście %1$s + Otwórz tutaj + Przenieś + Odrzuć Dodaj zdjęcie Zmień zdjęcie Odkryj @@ -1858,6 +1873,9 @@ Amethyst nie zdołał uruchomić sieci Tor. Czy można skorzystać ze zwykłego połączenia (nie przez Tor), aby strona mogła się załadować? Zapamiętamy ten wybór na najbliższą godzinę, a potem spróbujemy ponownie połączyć się przez Tor. Użyj zwykłego połączenia Czekaj + Łączenie z Tor… + Błąd Tor: %1$s + Amethyst używa Tor, aby ukryć Twój adres IP przed transmiterami. Uruchamianie Tor może potrwać minutę i wymaga dostępu do Internetu. Kliknij kategorię, aby otworzyć jej ustawienia powiadomień w systemie Android — znajdziesz tam opcje dotyczące dźwięku, priorytetu, ikon powiadomień oraz trybu „Nie przeszkadzać”. Wł. Wyciszony @@ -2424,7 +2442,7 @@ Kod Ładowanie repozytorium… Nie udało się załadować repozytorium z adresu URL klonu. - W ogłoszeniu repozytorium nie podano adresu URL do klonowania za pomocą protokołu http(s), który umożliwia przeglądanie zawartości. + W komunikacie repozytorium nie podano adresu URL do klonowania za pomocą protokołu http(s), który umożliwia przeglądanie zawartości. To repozytorium nie ma pliku README. Nie można załadować tego pliku. Plik binarny (%1$s) — podgląd niedostępny. @@ -2490,6 +2508,8 @@ Wybierz opcję, aby filtrować aktualności Dodaj algorytm kanału do ulubionych Usuń z ulubionych + Dodaj zestaw obserwowanych do ulubionych kanałów + Usuń zestaw obserwowanych z ulubionych kanałów Algorytmy kanałów, które tutaj oznaczyłeś gwiazdką, pojawiają się jako ikony filtrów na stronie głównej. Otwórz sekcję Odkryj, aby dodać kolejne. Przypnij swoje ulubione algorytmy Kliknij "%1$s" poniżej, aby przeglądać algorytmy. @@ -2713,10 +2733,12 @@ Wymagane uprawnienia Aplikacja Amethyst potrzebuje dostępu do sieci lokalnej, aby wykryć urządzenia obsługujące funkcję Cast w Twojej sieci Wi-Fi. Włącz tę opcję w ustawieniach aplikacji. Otwórz ustawienia + Urządzenie castujące %1$s nie może odtworzyć tego formatu wideo %1$s nie mógł odtworzyć tego filmu %1$s przestał odpowiadać. Ponowne uruchomienie urządzenia zwykle naprawia. Nie można połączyć się z %1$s. Ponowne uruchomienie urządzenia zwykle je naprawia. + %1$s jest offline Nie można załadować tego filmu na %1$s Udostępnianie wymaga usług Google Play, które nie są dostępne na tym urządzeniu %1$s nie może odtworzyć formatu tego wideo (%2$s) @@ -2833,8 +2855,13 @@ Brak wydarzeń wymagających moderacji Zbanowane wydarzenia Brak zbanowanych wydarzeń + Dozwolone wydarzenia + Brak dozwolonych wydarzeń + Zezwól na wydarzenie Dozwolone typy Brak dozwolonych typów + Niedozwolone typy + Brak niedozwolonych typów Zablokowane adresy IP Brak zablokowanych adresów IP Dodaj @@ -2867,6 +2894,9 @@ Wysłano prośbę o dołączenie Wysłano prośbę o opuszczenie Jesteś członkiem + Kod zaproszenia + Ten transmiter przyjmuje członków posiadających kod zaproszenia od jego operatora + Ten transmiter nie publikuje swojego klucza tożsamości (NIP-11 self), więc nie można zweryfikować listy jego członków Lista użytkowników transmitera Użytkownik dodany do transmitera %1$d użytkowników dodanych do transmitera @@ -4202,6 +4232,11 @@ Przyznane uprawnienia administracyjne Uprawnienia administratora cofnięte Zmień zdjęcie + Wiadomości od innych użytkowników nie mogą być już odczytywane na tym urządzeniu. Zresetuj swoją kopię grupy, a następnie poproś administratora o usunięcie cię i dodaj cię ponownie. + Reset + Zresetować tę grupę? + To urządzenie przestaje być członkiem grupy, dopóki administrator nie doda cię ponownie. Twoje dotychczasowe wiadomości pozostaną tutaj. + Oczekiwanie na ponowne dodanie. Poproś administratora o usunięcie i dodanie Cię ponownie. Dodaj w ustawieniach serwer multimediów „Blossom” — grupa potrzebuje miejsca, do którego będzie można przesyłać załączniki. Ta grupa używa zaszyfrowanych załączników Nie udało się dodać %1$s: %2$s @@ -4742,6 +4777,7 @@ Krótki fragment audio lub wideo Błąd typu „wartość za wartość” Podłącz portfel Nostr Wallet Connect, aby wysyłać wiadomości do odbiorców korzystających z usługi keysend (node). + Twój portfel nie obsługuje klucza, więc odbiorcy węzłów zostali pominięci. Ten podcast nie ma odbiorców, płacących za niego. Podłącz portfel Nostr Wallet Connect lub portfel debetowy, aby przesyłać satosze podczas słuchania. Ten użytkownik nie posiada adresu Lightning @@ -5441,6 +5477,7 @@ Nie udało się wczytać transakcji Nie udało się wczytać kolejnych transakcji + Twój portfel nie oferuje historii transakcji Wyświetla komunikat ostrzegawczy, gdy posty zostały zgłoszone przez 5 lub więcej obserwowanych osób Ostrzeżenie o zgłoszeniach Dodaj zakładkę @@ -6192,4 +6229,132 @@ Skopiuj npub koordynatora Szukaj koordynatorów Dodaj + Odczytuje komunikaty twoich transmiterów. Nie nawiązuje się połączenia z żadnym koordynatorem, więc żaden z nich nie dowiaduje się, że je przeglądałeś. + Nie można odczytać komunikatów + Nikt nie nadaje na twoich transmiterach. + Ogłoszono %1$s temu + Ostatnio ogłoszone %1$s + + Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedział. + Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziało. + Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziało. + Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziały. + + Koordynator porządkuje komunikaty jednej grupy i jest jedyną instancją, która może to robić. Utrata koordynatora powoduje utratę porządku, a dodanie drugiego nie powoduje odzwierciedlenia stanu pierwszego — są to więc konkretne elementy, które sam wybierasz, a nie zamienne transmitery. + + Brak odpowiedzi — %1$d połączenie z rzędu zakończyło się niepowodzeniem. Grupy w nim zawarte się nie synchronizują. + Brak odpowiedzi — %1$d kolejnych połączeń zakończyło się niepowodzeniem. Grupy w tym obszarze się nie synchronizują. + Brak odpowiedzi — %1$d kolejnych połączeń zakończyło się niepowodzeniem. Grupy w tym obszarze się nie synchronizują. + Brak odpowiedzi — %1$d kolejne połączenia zakończyły się niepowodzeniem. Grupy w tym obszarze się nie synchronizują. + + Odpowiadam. + Połączenie nie powiodło się. Ponawiam próbę. + Jeszcze nikt o to nie pytał. + Ukryj te, które przestały ogłaszać + Zapytaj, kto to jest + Klucz koordynatora + Nazwa (możesz podać własną, opcjonalnie) + Ta nazwa należy do Ciebie i pozostaje na tym urządzeniu. Koordynator nie może zweryfikować nazwy, dlatego nigdy nie jest o nią pytany. + Więcej czynności + Brak koordynatorów. + Czyszczenie + Usunięcie powoduje zachowanie grup na tym urządzeniu, więc ponowne dodanie koordynatora spowoduje ich przywrócenie. Całkowite usunięcie powoduje ich trwałą likwidację: klucze grup, pozycje odczytu oraz pakiety kluczy użytkownika zostaną usunięte, a ponowne dołączenie do tych grup będzie możliwe wyłącznie poprzez nowe zaproszenie.\n\nKoordynator nie otrzymuje o tym informacji i zachowuje wszystko, co już posiadał. + Usunąć tego koordynatora? + %1$s + Odpowiedzi na + %1$s +%2$d więcej + Usuń + Zmień nazwę + Podaje, że to: %1$s + Nazwa i wersja koordynatora to ciągi znaków, które sam wybrał. Jego klucz publiczny jest jedyną rzeczą, która go identyfikuje. + Odpowiedział, ale niczego nie nazwał. + Pokaż wszystkie %1$d + Pokaż mniej + Od ponad miesiąca nie pojawiały się żadne komunikaty. Próba utworzenia grupy na platformie, która przestała działać, zakończy się niepowodzeniem. + Utwórz Grupę + + Utwórz i zaproś %1$d osobę + Utwórz i zaproś %1$d osób + Utwórz i zaproś %1$d osób + Utwórz i zaproś %1$d osoby + + Dodaj kogoś + Admin + + %1$d osoba może dodawać i usuwać + %1$d osób może dodawać i usuwać + %1$d osób może dodawać i usuwać + %1$d osoby mogą dodawać i usuwać + + Tylko ja zarządzam tą grupą + Tylko Ty możesz zaprosić, usunąć członków lub zmienić dane grupy. Możesz to później przekazać komuś innemu. + Koordynator + Zmiana + Znajdź koordynatorów + Gotowe + Inny koordynator + Klucz publiczny koordynatora (hex lub npub) + Transmitery, na których odpowiada – po jednym w każdym wierszu + + Osiąga %1$d osobę + Osiąga %1$d osób + Osiąga %1$d osób + Osiąga %1$d osoby + + Osiąga %1$d z %2$d — reszta zostanie pominięta + Nie zaznaczone — po zaznaczeniu pojawi się pytanie + Opis (opcjonalnie) + Każdy może dodać i usunąć + Każdy członek grupy może zapraszać i usuwać członków oraz zmieniać dane grupy. Każdy członek może później wyznaczyć administratorów, co kończy tę procedurę. + Grupa Cordn jest zarządzana przez jednego koordynatora, którego sam wybierasz. Utworzenie grupy odbywa się na tym urządzeniu — koordynator dowiaduje się o istnieniu grupy dopiero wtedy, gdy zaprosisz kogoś lub wyślesz wiadomość. + Nie można utworzyć grupy. + Nazwa grupy + Nie wybrano koordynatora + Każde zaproszenie to powitanie pozostawione koordynatorowi, dzięki czemu może on odrzucać je pojedynczo. Nikt nie otrzymał powiadomienia: użytkownicy zobaczą je dopiero przy następnym uruchomieniu aplikacji. + Koordynator odrzucił zaproszenie + Wyślij link + Dodano do grupy, ale zaproszenie nie dotarło do nich — spróbuj ponownie z ekranu grupy + Otwórz czat + Nie ma tu klucza, więc nie można było zostawić wiadomości powitalnej + Grupa istnieje + Oczekiwanie na nie + Gotowe + Jeszcze nikt nie został dodany. Grupę można utworzyć jako pustą, a zaproszenia wysłać później, ale właśnie podanie nazwisk osób już teraz pozwala ustalić, który koordynator może się z nimi skontaktować. + Na razie nikt — kliknij, aby dodać osoby + Wskazanie konkretnych osób pozwala nam ustalić, który koordynator może się z nimi skontaktować. Każda z tych osób otrzymuje wiadomość powitalną od tego koordynatora, którą odbierze przy następnym uruchomieniu aplikacji. + + Osiągalny na koordynatorze %1$d + Osiągalny na koordynatorach %1$d + Osiągalny na koordynatorach %1$d + Osiągalny na koordynatorach %1$d + + Nie ma klucza dla żadnego z używanych koordynatorów + Jeszcze nie sprawdzono + Kto może dodać i usunąć + Nazwij + Kto jest w nim + Gdzie występuje + Nowa grupa Cordn + + Żaden z koordynatorów, z których usług korzystasz, nie jest w stanie skontaktować się z %1$d osobą + %1$d osób jest poza zasięgiem wszystkich koordynatorów, z których korzystasz + %1$d osób jest poza zasięgiem wszystkich koordynatorów, z których korzystasz + %1$d osoby są poza zasięgiem wszystkich koordynatorów, z których korzystasz + + Ty, zawsze jako administrator + Wszyscy członkowie grupy zobaczą tę wiadomość jako wycofaną, a aplikacja Amethyst przestanie wyświetlać jej treść. Koordynator nadal posiada zaszyfrowaną kopię, w której została wysłana, a każdy, kto już ją przeczytał, nadal ma dostęp do jej treści. + Wycofaj wiadomość + Zaakceptowane przez koordynatora + Zrozumiałe + Co ten koordynator może zobaczyć + Opublikowano pakiet na wypadek ostateczności, dzięki czemu nadal będzie można wysłać zaproszenie, gdy skończą się zaproszenia jednorazowego użytku. + + %1$d pakiet, którego to urządzenie nie może otworzyć + %1$d pakietów, których to urządzenie nie może otworzyć + %1$d pakietów, których to urządzenie nie może otworzyć + %1$d pakiety, których to urządzenie nie może otworzyć + + Koordynator przekaże je każdemu, kto cię zaprosi, ale część prywatna nie znajduje się na tym urządzeniu — należy ona do innej instalacji. Jeśli ta instalacja zniknie, zaproszenie wysłane przy użyciu jednego z tych plików spowoduje wygenerowanie wiadomości powitalnej, której nikt nigdy nie będzie mógł otworzyć. + Opublikuj jeden + Opublikuj ostatnią instancję diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index d71a888f5b..05faf5e4dd 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -653,6 +653,27 @@ Marcar todos conhecidos como lidos Marcar todas novas solicitações como lidas Marcar todas como lidas + Sua chave secreta é a sua conta + Qualquer pessoa que a tenha pode publicar como você. Se você a perder, ninguém poderá recuperá-la, nem mesmo o Amethyst. + Toque para revelar + Anote nesta ordem. Tanto maiúsculas quanto minúsculas funcionam. + Código QR + Cópia protegida por senha + Senha + Um ncryptsec1… que só funciona com a sua senha. + + Pelo menos %1$d caractere + Pelo menos %1$d caracteres + + Repita a senha + As senhas não coincidem + Criptografar chave + Criptografando… + Pode ser guardado com segurança em um gerenciador de senhas ou em notas na nuvem: não pode ser aberto sem a senha, e uma senha esquecida não pode ser recuperada. + Usar outra senha + Guarde o papel em um lugar que só você encontre, ou salve a chave em um gerenciador de senhas. + Nunca cole sua chave em sites ou apps em que você não confia. + Os desenvolvedores do Amethyst nunca vão pedir sua chave. Faça backup das suas chaves Sua chave secreta é a única forma de acessar esta conta. Se você perdê-la, ela nunca poderá ser recuperada. Guarde-a em um lugar seguro agora. Fazer backup agora @@ -686,6 +707,8 @@ Remover essa mídia de sua galeria. Pedir para excluir Amethyst solicitará que sua nota seja excluída dos relays aos quais você está conectado no momento. Não há garantia de que sua nota será excluída permanentemente desses relays ou de outros relays onde possa estar armazenada. + O Amethyst vai pedir a todos neste grupo criptografado que excluam esta mensagem. Os apps deles devem removê-la, mas não há garantia de que todos os membros excluirão sua cópia. + O Amethyst vai enviar um pedido de exclusão criptografado às pessoas desta conversa. Os apps delas devem remover a mensagem, mas não há garantia de que todas as cópias serão excluídas. Bloquear Excluir Bloquear @@ -1716,6 +1739,14 @@ Nome Descrição Imagem do grupo + URL da imagem do banner + Este grupo pode ter se mudado + Este grupo pode ter um fork + Os admins agora o listam em %1$s + Pessoas que você segue agora o listam em %1$s + Abrir lá + Mover + Dispensar Adicionar foto Alterar foto Descoberta @@ -1820,6 +1851,9 @@ O Amethyst não conseguiu concluir a inicialização do Tor. Usar uma conexão normal (sem Tor) para que o feed possa carregar? Vamos lembrar dessa escolha pela próxima hora e tentar o Tor novamente depois. Usar conexão normal Continuar esperando + Conectando ao Tor… + Erro do Tor: %1$s + O Amethyst usa o Tor para ocultar seu endereço IP dos relays. Iniciar o Tor pode levar um minuto e requer acesso à internet. Toque em uma categoria para abrir as configurações de notificação do Android — som, importância, selos e Não Perturbe ficam lá. Ativado Silencioso @@ -2452,6 +2486,8 @@ Selecione uma lista para filtrar o feed Adicionar algoritmo de feed aos favoritos Remover dos favoritos + Adicionar lista de seguidores aos feeds favoritos + Remover lista de seguidores dos feeds favoritos Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. Fixe seus algoritmos favoritos Toque em "%1$s" abaixo para navegar pelos algoritmos. @@ -2672,6 +2708,20 @@ Visível apenas para você Transmitir para… Procurando dispositivos na sua Wi-Fi… + Permissão necessária + O Amethyst precisa de acesso à rede local para encontrar dispositivos Cast na sua Wi-Fi. Ative-o nas configurações do app. + Abrir configurações + O dispositivo de transmissão + %1$s não consegue reproduzir o formato deste vídeo + %1$s não conseguiu reproduzir este vídeo + %1$s parou de responder. Reiniciar o dispositivo geralmente resolve. + Não foi possível conectar a %1$s. Reiniciar o dispositivo geralmente resolve. + %1$s ficou offline + Não foi possível carregar este vídeo em %1$s + A transmissão requer o Google Play Services, que não está disponível neste dispositivo + %1$s não consegue reproduzir o formato deste vídeo (%2$s) + %1$s não conseguiu reproduzir este vídeo (%2$s) + %1$s nunca começou a reproduzir este vídeo (%2$s). Talvez não suporte esse formato, ou reiniciar o dispositivo pode ajudar. Escolher um vídeo Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. Trocar @@ -2783,8 +2833,13 @@ Nenhum evento para moderar Eventos Banidos Nenhum evento banido + Eventos Permitidos + Nenhum evento permitido + Permitir Evento Tipos Permitidos Nenhum tipo permitido + Tipos Não Permitidos + Nenhum tipo não permitido IPs Bloqueados Nenhum IP bloqueado Adicionar @@ -2817,6 +2872,9 @@ Solicitação de entrada enviada Solicitação de saída enviada Você é membro + Código de convite + Este relay admite membros com um código de convite do seu operador + Este relay não publica sua chave de identidade (NIP-11 self), então sua lista de membros não pode ser verificada Lista de membros do relay Membro adicionado ao relay %1$d membros adicionados ao relay @@ -4074,6 +4132,11 @@ Privilégios de administrador concedidos Privilégios de administrador revogados Alterar foto + As mensagens dos outros membros não podem mais ser lidas neste dispositivo. Redefina sua cópia do grupo e depois peça a um admin para remover você e adicioná-lo de volta. + Redefinir + Redefinir este grupo? + Este dispositivo deixa de ser membro até que um admin adicione você de volta. As mensagens que você já tem continuam aqui. + Aguardando ser adicionado de volta. Peça a um admin para remover você e adicioná-lo novamente. Adicione primeiro um servidor de mídia Blossom nas Configurações — o grupo precisa de um lugar para onde enviar os anexos. Este grupo agora usa anexos criptografados Falha ao adicionar %1$s: %2$s @@ -4556,6 +4619,7 @@ Prévia curta de áudio ou vídeo Erro de Value-for-Value Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó). + Sua carteira não suporta keysend, então os destinatários keysend (nó) foram ignorados. Este podcast não tem destinatários de valor pagáveis. Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve. Este usuário não tem endereço Lightning @@ -5187,6 +5251,7 @@ Não foi possível carregar as transações Não foi possível carregar mais transações + Sua carteira não oferece histórico de transações Mostra uma mensagem de aviso quando as postagens tiverem 5 ou mais relatórios de suas seguintes Avisar em relatórios Adicionar Marcador Web diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index 4a2cf970c1..4501b3697a 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -653,6 +653,27 @@ Markera alla kända som lästa Markera alla nya som lästa Markera allt som läst + Din hemliga nyckel är ditt konto + Den som har den kan posta som du. Om du förlorar den kan ingen återställa den, inte ens Amethyst. + Tryck för att visa + Skriv ner den i den här ordningen. Stora och små bokstäver fungerar båda. + QR-kod + Lösenordsskyddad kopia + Lösenord + En ncryptsec1… som bara fungerar med ditt lösenord. + + Minst %1$d tecken + Minst %1$d tecken + + Upprepa lösenord + Lösenorden matchar inte + Kryptera nyckel + Krypterar… + Säker att förvara i en lösenordshanterare eller i molnanteckningar: den kan inte öppnas utan lösenordet, och ett glömt lösenord kan inte återställas. + Använd ett annat lösenord + Förvara papperet där bara du hittar det, eller spara nyckeln i en lösenordshanterare. + Klistra aldrig in din nyckel på webbplatser eller i appar som du inte litar på. + Amethysts utvecklare kommer aldrig att be om din nyckel. Säkerhetskopiera dina nycklar Din hemliga nyckel är enda sättet att komma åt det här kontot. Om du förlorar den kan den aldrig återskapas. Spara den på ett säkert ställe nu. Säkerhetskopiera nu @@ -686,6 +707,8 @@ Ta bort detta media från ditt galleri. Begär radering Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras. + Amethyst ber alla i den här krypterade gruppen att radera meddelandet. Deras appar bör ta bort det, men det finns ingen garanti för att varje medlem raderar sin kopia. + Amethyst skickar en krypterad raderingsbegäran till personerna i den här konversationen. Deras appar bör ta bort meddelandet, men det finns ingen garanti för att varje kopia raderas. Blockera Radera Blockera @@ -1716,6 +1739,14 @@ Namn Beskrivning Gruppbild + URL för bannerbild + Den här gruppen kan ha flyttat + Den här gruppen kan ha en fork + Dess admins listar den nu på %1$s + Personer du följer listar den nu på %1$s + Öppna där + Flytta + Avfärda Lägg till foto Ändra foto Upptäckt @@ -1820,6 +1851,9 @@ Amethyst kunde inte slutföra uppstart av Tor. Vill du använda en vanlig (icke-Tor) anslutning så att flödet kan laddas? Vi kommer ihåg ditt val under nästa timme och försöker sedan Tor igen. Använd vanlig anslutning Fortsätt vänta + Ansluter till Tor… + Tor-fel: %1$s + Amethyst använder Tor för att dölja din IP-adress för reläer. Att starta Tor kan ta en minut och kräver internetåtkomst. Tryck på en kategori för att öppna Androids aviseringsinställningar — ljud, prioritet, märken och Stör ej finns där. På Tyst @@ -2452,6 +2486,8 @@ Välj en lista för att filtrera flödet Lägg till flödesalgoritm i favoriter Ta bort från favoriter + Lägg till följelista i favoritflöden + Ta bort följelista från favoritflöden Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. Fäst dina favoritalgoritmer Tryck på "%1$s" nedan för att bläddra bland algoritmer. @@ -2672,6 +2708,20 @@ Endast synligt för dig Casta till… Söker efter enheter på ditt Wi-Fi… + Behörighet krävs + Amethyst behöver åtkomst till det lokala nätverket för att hitta Cast-enheter på ditt Wi-Fi. Aktivera det i appinställningarna. + Öppna inställningar + Cast-enheten + %1$s kan inte spela upp den här videons format + %1$s kunde inte spela upp den här videon + %1$s slutade svara. En omstart av enheten brukar lösa det. + Kunde inte ansluta till %1$s. En omstart av enheten brukar lösa det. + %1$s gick offline + Kunde inte läsa in den här videon på %1$s + Casting kräver Google Play-tjänster, som inte är tillgängliga på den här enheten + %1$s kan inte spela upp den här videons format (%2$s) + %1$s kunde inte spela upp den här videon (%2$s) + %1$s började aldrig spela upp den här videon (%2$s). Formatet kanske inte stöds, eller så kan en omstart av enheten hjälpa. Välj en video Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. Ändra @@ -2783,8 +2833,13 @@ Inga händelser att moderera Bannlysta händelser Inga bannlysta händelser + Tillåtna händelser + Inga tillåtna händelser + Tillåt händelse Tillåtna typer Inga tillåtna typer + Otillåtna typer + Inga otillåtna typer Blockerade IP-adresser Inga blockerade IP-adresser Lägg till @@ -2817,6 +2872,9 @@ Begäran om att gå med skickad Begäran om att lämna skickad Du är medlem + Inbjudningskod + Det här reläet släpper in medlemmar med en inbjudningskod från operatören + Det här reläet publicerar inte sin identitetsnyckel (NIP-11 self), så dess medlemslista kan inte verifieras Relämedlemslista Medlem tillagd i relä %1$d medlemmar tillagda i relä @@ -4074,6 +4132,11 @@ Administratörsbehörighet beviljad Administratörsbehörighet återkallad Byt foto + Meddelanden från de andra medlemmarna kan inte längre läsas på den här enheten. Återställ din kopia av gruppen och be sedan en admin att ta bort dig och lägga till dig igen. + Återställ + Återställa den här gruppen? + Den här enheten slutar vara medlem tills en admin lägger till dig igen. De meddelanden du redan har finns kvar här. + Väntar på att läggas till igen. Be en admin att ta bort dig och lägga till dig igen. Lägg först till en Blossom-medieserver i Inställningar — gruppen behöver någonstans att ladda upp bilagor till. Den här gruppen använder nu krypterade bilagor Misslyckades med att lägga till %1$s: %2$s @@ -4556,6 +4619,7 @@ Kort ljud- eller videoförhandsvisning Value-for-Value-fel Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder). + Din plånbok stöder inte keysend, så keysend-mottagare (noder) hoppades över. Den här podden har inga betalbara värdemottagare. Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar. Den här användaren har ingen Lightning-adress @@ -5187,6 +5251,7 @@ Kunde inte läsa in transaktioner Kunde inte läsa in fler transaktioner + Din plånbok erbjuder ingen transaktionshistorik Visar ett varningsmeddelande när inlägg har 5 eller fler rapporter från följande Varna vid rapporter Lägg till webbbokmärke diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 868ac7ec31..e50c6fd0f3 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -340,6 +340,7 @@ Location Going Maybe + Loading event… Can't go %1$d event @@ -675,7 +676,10 @@ Password-protected copy Password An ncryptsec1… that only works with your password. - At least %1$d characters + + At least %1$d character + At least %1$d characters + Repeat password Passwords don't match Encrypt key @@ -3458,6 +3462,13 @@ Calendar reminders Day Feed + Friends going + No plans yet + When people in this list RSVP “Going” to an upcoming event, it shows up here. + + %1$d going + %1$d going + Month Week Accept @@ -3905,6 +3916,7 @@ Podcasts Polls Ratings and reviews + Event RSVPs Reposts Shorts Text notes diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt index ac135fc16d..4b04ae678f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.nip52Calendar.ui import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_view_day import com.vitorpamplona.amethyst.commons.resources.calendar_view_feed +import com.vitorpamplona.amethyst.commons.resources.calendar_view_follows_going import com.vitorpamplona.amethyst.commons.resources.calendar_view_month import com.vitorpamplona.amethyst.commons.resources.calendar_view_week import org.jetbrains.compose.resources.StringResource @@ -31,11 +32,15 @@ import org.jetbrains.compose.resources.StringResource * Lenses on the same appointment timeline. Calendar *collections* (kind 31924) live on their * own screen ([CalendarCollectionsScreen]) since they're a sibling feed, not a different view * of the appointment data. + * + * [FOLLOWS_GOING] is the one lens not drawn from the appointment timeline: it is built from the + * RSVPs of the people in the selected list, showing the upcoming events they said they'd attend. */ enum class CalendarsViewMode( val labelRes: StringResource, ) { FEED(Res.string.calendar_view_feed), + FOLLOWS_GOING(Res.string.calendar_view_follows_going), MONTH(Res.string.calendar_view_month), WEEK(Res.string.calendar_view_week), DAY(Res.string.calendar_view_day), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt index 53068020ce..f0f066ce28 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.ui.note +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -34,84 +37,135 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going +import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_loading_event import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going -import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import org.jetbrains.compose.resources.stringResource -/** Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target. */ +/** + * A NIP-52 calendar RSVP (kind 31925) as one card: the author's note as post text, then a single + * quote-style frame whose tinted strip says how they answered and whose body is the appointment + * they answered. + * + * The status lives *inside* the appointment's frame on purpose. It is a property of "Alice → this + * event", so a header line floating above a separately framed event read as two unrelated posts; + * and the frame is the feed's plain quote border, not a raised card, so the RSVP never nests a + * box inside a box. + * + * [appointment] draws the event once the front end has it (no header of its own — the post's + * author is who answered, and a second avatar row made it read like a quote of a quote). Until + * then the strip stands over a muted "loading" line. [statusDetail] is the strip's trailing text, + * e.g. "in 3 days". [onClick] makes the whole frame open the event. + */ @Composable -fun CalendarRsvpCard(event: CalendarRSVPEvent) { +fun CalendarRsvpCard( + event: CalendarRSVPEvent, + statusDetail: String? = null, + onClick: (() -> Unit)? = null, + appointment: (@Composable ColumnScope.() -> Unit)? = null, +) { val status = remember(event) { event.status() } - val targetAddress = remember(event) { event.calendarEventAddress() } - val freebusy = remember(event) { event.freebusy() } - val statusLabel = - when (status) { - RSVPStatusTag.STATUS.ACCEPTED -> stringResource(Res.string.calendar_rsvp_going) - RSVPStatusTag.STATUS.TENTATIVE -> stringResource(Res.string.calendar_rsvp_maybe) - RSVPStatusTag.STATUS.DECLINED -> stringResource(Res.string.calendar_rsvp_not_going) - null -> "—" - } - - val statusColor = - when (status) { - RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary - RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary - RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error - null -> Color.Gray - } - - Column(MaterialTheme.colorScheme.replyModifier) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.CalendarMonth, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = statusColor, - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = statusLabel, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = statusColor, - ) + val frame = + if (onClick != null) { + MaterialTheme.colorScheme.replyModifier.clickable(onClick = onClick) + } else { + MaterialTheme.colorScheme.replyModifier } + Column { if (event.content.isNotBlank()) { - Spacer(modifier = StdVertSpacer) Text( text = event.content, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 10.dp, end = 10.dp), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 4.dp), ) } - targetAddress?.let { addr -> - Spacer(modifier = StdVertSpacer) - Text( - text = "→ ${addr.toValue()}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp), - ) - } + Column(frame) { + RsvpStatusStrip(status, statusDetail) - if (freebusy != null) { - Spacer(modifier = StdVertSpacer) + if (appointment != null) { + appointment() + } else { + Text( + text = stringResource(Res.string.calendar_rsvp_loading_event), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } } } } + +@Composable +private fun RsvpStatusStrip( + status: RSVPStatusTag.STATUS?, + detail: String?, +) { + val (label, color, symbol) = rsvpStatusLook(status) + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = color, + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = color, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private data class RsvpStatusLook( + val label: String, + val color: Color, + val symbol: MaterialSymbol, +) + +@Composable +private fun rsvpStatusLook(status: RSVPStatusTag.STATUS?): RsvpStatusLook = + when (status) { + RSVPStatusTag.STATUS.ACCEPTED -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_going), MaterialTheme.colorScheme.primary, MaterialSymbols.CheckCircle) + RSVPStatusTag.STATUS.TENTATIVE -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_maybe), MaterialTheme.colorScheme.tertiary, MaterialSymbols.Schedule) + RSVPStatusTag.STATUS.DECLINED -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_not_going), MaterialTheme.colorScheme.error, MaterialSymbols.Cancel) + null -> + RsvpStatusLook("—", Color.Gray, MaterialSymbols.CalendarMonth) + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt index b664ad313e..b15ce3709b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt @@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioHeader import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioTrack +import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderCalendarRSVPEvent import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderChessGame import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitIssueEvent import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitPatchEvent @@ -174,7 +175,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarCollectionEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarDateSlotEvent -import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarRSVPEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderChannelInvite import com.vitorpamplona.amethyst.commons.ui.note.types.RenderChannelMessage diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt index 4278a1a360..ab6e88a774 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt @@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard import com.vitorpamplona.amethyst.commons.ui.note.types.EditState import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.podcasts.PodcastAudio import kotlinx.collections.immutable.ImmutableList @@ -290,6 +292,23 @@ interface NotePlatform { nav: INav, ) {} + // Calendar + + /** + * A NIP-52 RSVP with the appointment it answers merged into its frame. Draws the bare + * [com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard] by default: the appointment + * half still uses the platform's calendar formatting. + */ + @Composable + fun RenderCalendarRSVPEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, + ) { + val event = baseNote.event as? CalendarRSVPEvent ?: return + CalendarRsvpCard(event) + } + // Meeting rooms (audio spaces) @Composable diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt index 357b1d6c8a..00b25ef085 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt @@ -387,6 +387,17 @@ fun RenderGitPullRequestUpdateEvent( nav = nav, ) +@Composable +fun RenderCalendarRSVPEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) = LocalNotePlatform.current.RenderCalendarRSVPEvent( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, +) + @Composable fun RenderMeetingSpaceEvent( baseNote: Note, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/CalendarRsvpRender.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/CalendarRsvpRender.kt deleted file mode 100644 index e958a587c1..0000000000 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/CalendarRsvpRender.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.ui.note.types - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard -import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent - -/** - * Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is - * in the cache, and renders the shared commons [CalendarRsvpCard]. - */ -@Composable -fun RenderCalendarRSVPEvent( - note: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val event = note.event as? CalendarRSVPEvent ?: return - - LoadAppointmentBehind(event, accountViewModel) - - CalendarRsvpCard(event) -} - -/** - * Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the - * two together. - * - * [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in - * `LocalCache` — a shell with a null event if we have never seen the appointment — and - * [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables` - * picks up exactly those addressables whose `event == null` and queries the address author's - * outbox relays plus any stored hints. - * - * Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its - * `a` tag into the appointment and reading the participants off it, and every step of that walk - * is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would - * otherwise leave the cache with no entry to walk, so answering it from here would reach the host - * (whose pubkey the coordinate carries) but none of the other invitees. - * - * Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it - * scrolls away or the app backgrounds. - */ -@Composable -private fun LoadAppointmentBehind( - event: CalendarRSVPEvent, - accountViewModel: AccountViewModel, -) { - val address = remember(event) { event.calendarEventAddress() } ?: return - - LoadAddressableNote(address) { appointment -> - if (appointment != null) { - EventFinderFilterAssemblerSubscription(appointment, accountViewModel) - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt index 5475ced1ee..378a74f069 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt @@ -99,6 +99,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource private const val TOTAL_STEPS = 3 @@ -414,7 +415,7 @@ private fun EncryptedCopySection(nsec: String) { if (error) { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } else { - Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH)) + Text(pluralStringResource(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH)) } }, visualTransformation = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt index a81b62423f..2cff6a8359 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt @@ -96,6 +96,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource /** @@ -378,7 +379,7 @@ private fun EncryptedCopy(nsec: String) { if (error) { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } else { - Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH)) + Text(pluralStringResource(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH)) } }, visualTransformation = diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index 9b8a88949a..b4bcddcca5 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -55,6 +55,12 @@ import kotlin.test.assertTrue * * Key invariant being tested: when coordinator.consumeEvent() is called, * the event should flow through cache → eventStream → ViewModel.feedState. + * + * Tests that expect a note in a feed take it from the cache *before* it is consumed and + * keep it ([pinned]). [DesktopLocalCache] holds notes weakly (LargeSoftCache) and the feed + * filters find notes by scanning it, so a note nothing references can be collected during + * the bundler wait and never reach the feed. In the app the screen showing it is that + * strong referent. */ class CoordinatorPipelineTest { private val userPubKey = "a".repeat(64) @@ -64,6 +70,13 @@ class CoordinatorPipelineTest { private suspend fun waitForBundler() = delay(600) + /** Strong references to notes a test expects to see in a feed. See the class KDoc. */ + private val pinned = mutableListOf() + + private fun DesktopLocalCache.pinNote(id: HexKey) { + pinned.add(getOrCreateNote(id)) + } + /** * Stub INostrClient — records subscription calls but doesn't connect to any relay. * This lets us test the coordinator's event routing without network dependencies. @@ -171,6 +184,7 @@ class CoordinatorPipelineTest { content = "Hello from relay", sig = dummySig, ) + cache.pinNote(event.id) coordinator.consumeEvent(event, relayUrl, wasVerified = true) waitForBundler() @@ -278,6 +292,7 @@ class CoordinatorPipelineTest { content = "Note from followed user", sig = dummySig, ) + cache.pinNote(textEvent.id) coordinator.consumeEvent(textEvent, relayUrl, wasVerified = true) waitForBundler() @@ -352,6 +367,8 @@ class CoordinatorPipelineTest { sig = dummySig, ) + cache.pinNote(event.id) + // Consume same event twice (can happen with multiple relays) coordinator.consumeEvent(event, relayUrl, wasVerified = true) coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/"), wasVerified = true) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt index a5b55291df..e1dfa6c2e3 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt @@ -33,6 +33,12 @@ import kotlin.test.assertTrue * NIP-88 poll consumption: a kind-1068 poll becomes a renderable Note, and a kind-1018 * response is linked into that poll Note's `pollState()` tally. Second identical response * (a relay echo) must not double-count. + * + * Each test takes the poll [com.vitorpamplona.amethyst.commons.model.Note] from the cache + * *before* consuming and keeps it. [DesktopLocalCache] holds notes weakly (LargeSoftCache), + * so a poll note nothing references can be collected between the poll's consume and the + * response's — the response would then tally into a fresh, empty note — or before the + * read-back. In the app the screen showing the poll is that strong referent. */ class DesktopLocalCachePollTest { private val relayUrl = NormalizedRelayUrl("wss://relay.test/") @@ -77,13 +83,13 @@ class DesktopLocalCachePollTest { val voter = NostrSignerSync(KeyPair()) val poll = signedPoll(author, createdAt = 1_700_000_000) + val pollNote = cache.getOrCreateNote(poll.id) assertTrue(cache.consume(poll, relayUrl, wasVerified = true), "poll should be consumed") val response = signedResponse(voter, poll.id, option = "0", createdAt = 1_700_000_100) assertTrue(cache.consume(response, relayUrl, wasVerified = true), "response should be consumed") - val pollNote = cache.getNoteIfExists(poll.id) - assertTrue(pollNote != null, "poll note must exist") + assertTrue(pollNote.event is PollEvent, "poll note must hold the poll") val tally = pollNote.pollState().responses.value assertEquals(1, tally.totalVoters()) assertEquals("0", tally.winning()) @@ -96,6 +102,7 @@ class DesktopLocalCachePollTest { val voter = NostrSignerSync(KeyPair()) val poll = signedPoll(author, createdAt = 1_700_000_000) + val pollNote = cache.getOrCreateNote(poll.id) cache.consume(poll, relayUrl, wasVerified = true) val response = signedResponse(voter, poll.id, option = "1", createdAt = 1_700_000_100) @@ -103,11 +110,7 @@ class DesktopLocalCachePollTest { // Same signed event echoed back by another relay — id-dedup must reject it. assertTrue(!cache.consume(response, relayUrl, wasVerified = true)) - val tally = - cache - .getNoteIfExists(poll.id)!! - .pollState() - .responses.value + val tally = pollNote.pollState().responses.value assertEquals(1, tally.totalVoters()) } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt index 2641907f78..438c5dafb8 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheQuoteBoostTest.kt @@ -36,6 +36,12 @@ import kotlin.test.assertTrue * only collected kind:6/kind:16 reposts — so quote-reposts never showed in the * quoted note's reaction row. These tests pin the fix: consuming a `q`-tagged note * adds it as a boost of the quoted note. + * + * Each test takes the quoted note from the cache *before* consuming and keeps it. + * [DesktopLocalCache] holds notes weakly (LargeSoftCache), and nothing else references the + * quoted note — a quote is kept out of `replyTo` — so a GC before the read-back would drop + * it along with the boost it just received. In the app the screen showing it is that + * strong referent. */ class DesktopLocalCacheQuoteBoostTest { private val relayUrl = NormalizedRelayUrl("wss://relay.test/") @@ -65,13 +71,12 @@ class DesktopLocalCacheQuoteBoostTest { fun `a quote-repost counts as a boost of the quoted note`() { val cache = DesktopLocalCache() val quote = Event.fromJson(quoteJson) + val quotedNote = cache.getOrCreateNote(quotedId) // wasVerified = true: this test pins the boost wiring, not signature checks. val consumed = cache.consume(quote, relayUrl, wasVerified = true) assertTrue(consumed, "The quote-repost should be consumed") - val quotedNote = cache.getNoteIfExists(quotedId) - assertTrue(quotedNote != null, "The quoted note placeholder should exist") assertEquals(1, quotedNote.boosts.size, "The quote should count as one boost") assertEquals(quote.id, quotedNote.boosts.first().idHex) } @@ -88,6 +93,7 @@ class DesktopLocalCacheQuoteBoostTest { tags = emptyArray(), content = "the original post", ) + val originalNote = cache.getOrCreateNote(original.id) cache.consume(original, relayUrl, wasVerified = true) val quote = @@ -99,8 +105,6 @@ class DesktopLocalCacheQuoteBoostTest { ) cache.consume(quote, relayUrl, wasVerified = true) - val originalNote = cache.getNoteIfExists(original.id) - assertTrue(originalNote != null) assertEquals(1, originalNote.boosts.size, "The quote should boost the original") assertEquals(quote.id, originalNote.boosts.first().idHex) } @@ -117,6 +121,7 @@ class DesktopLocalCacheQuoteBoostTest { tags = emptyArray(), content = "target", ) + val targetNote = cache.getOrCreateNote(target.id) cache.consume(target, relayUrl, wasVerified = true) val plain = @@ -128,6 +133,6 @@ class DesktopLocalCacheQuoteBoostTest { ) cache.consume(plain, relayUrl, wasVerified = true) - assertEquals(0, cache.getNoteIfExists(target.id)?.boosts?.size) + assertEquals(0, targetNote.boosts.size) } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt index 3ed2085d90..7d7f005eec 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -37,6 +36,11 @@ import kotlin.test.assertTrue * desktop equivalent of Amethyst Android's `LocalCache.justVerify` guard * and closes the receive-time verification gap flagged in the Quartz * security review (item 2.1, finding #1). + * + * The acceptance tests take the note from the cache *before* consuming and keep it: + * [DesktopLocalCache] holds notes weakly (LargeSoftCache), so a note nothing references + * can be collected before the read-back and look as if it was never stored. The + * rejection tests look notes up without pinning on purpose — pinning would create one. */ class DesktopLocalCacheVerifyTest { private val relayUrl = NormalizedRelayUrl("wss://relay.test/") @@ -102,13 +106,12 @@ class DesktopLocalCacheVerifyTest { fun `consume accepts a properly signed event`() { val cache = DesktopLocalCache() val signed = signedTextNote(content = "authentic") + val note = cache.getOrCreateNote(signed.id) val consumed = cache.consume(signed, relayUrl) assertTrue(consumed, "Signed event should be accepted") - val note = cache.getNoteIfExists(signed.id) - assertNotNull(note, "Signed event must reach the cache") - assertEquals(signed.id, note.event?.id) + assertEquals(signed.id, note.event?.id, "Signed event must reach the cache") } @Test @@ -141,10 +144,11 @@ class DesktopLocalCacheVerifyTest { content = "synthetic", sig = "0".repeat(128), ) + val note = cache.getOrCreateNote(event.id) val consumed = cache.consume(event, relayUrl, wasVerified = true) assertTrue(consumed, "wasVerified=true must skip the signature check") - assertNotNull(cache.getNoteIfExists(event.id)) + assertEquals(event.id, note.event?.id) } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt index e1fe6a991b..c134bb9edf 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt @@ -40,10 +40,13 @@ class FindUsersTest { * and `findUsersStartingWith` could collect them and make the search return fewer results * (flaky failure at line 115). Every test keeps its users in a strong-reference list that * stays reachable through the assertions to pin them in the cache. + * + * The pin must be taken BEFORE `consumeMetadata`: pinning afterwards leaves the window + * between the consume and the pin open, and a GC there drops the user with its metadata. */ - private val retained = mutableListOf() + private val retained = mutableListOf() - private fun DesktopLocalCache.retainUser(pubkey: String): User? = getUserIfExists(pubkey).also { retained.add(it) } + private fun DesktopLocalCache.pinUser(pubkey: String): User = getOrCreateUser(pubkey).also { retained.add(it) } private fun fakeMetadata( pubKey: String, @@ -64,7 +67,7 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() - retained.add(cache.getOrCreateUser(pubkey)) + cache.pinUser(pubkey) val results = cache.findUsersStartingWith("test", 10) assertTrue(results.isEmpty(), "User without metadata should not match name search") @@ -75,8 +78,8 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() + cache.pinUser(pubkey) cache.consumeMetadata(fakeMetadata(pubkey, "vitor", "Vitor Pamplona")) - cache.retainUser(pubkey) val results = cache.findUsersStartingWith("Vitor", 10) assertEquals(1, results.size, "Should find user by display name") @@ -88,8 +91,8 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() + cache.pinUser(pubkey) cache.consumeMetadata(fakeMetadata(pubkey, "vitor")) - cache.retainUser(pubkey) val results = cache.findUsersStartingWith("vit", 10) assertEquals(1, results.size, "Should find user by name prefix") @@ -100,8 +103,8 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() + cache.pinUser(pubkey) cache.consumeMetadata(fakeMetadata(pubkey, "Vitor", "Vitor Pamplona")) - cache.retainUser(pubkey) val lower = cache.findUsersStartingWith("vitor", 10) assertEquals(1, lower.size, "Should find case-insensitively (lowercase)") @@ -115,7 +118,7 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() - retained.add(cache.getOrCreateUser(pubkey)) + cache.pinUser(pubkey) val results = cache.findUsersStartingWith(pubkey.take(8), 10) assertEquals(1, results.size, "Should find user by pubkey prefix") @@ -127,8 +130,8 @@ class FindUsersTest { listOf("alice", "bob", "alex").forEach { name -> val pubkey = KeyPair().pubKey.toHexKey() + cache.pinUser(pubkey) cache.consumeMetadata(fakeMetadata(pubkey, name)) - cache.retainUser(pubkey) } val results = cache.findUsersStartingWith("al", 10) @@ -140,7 +143,7 @@ class FindUsersTest { val cache = createCache() // Simulate users created from kind 1 notes (no metadata) - repeat(10) { retained.add(cache.getOrCreateUser(KeyPair().pubKey.toHexKey())) } + repeat(10) { cache.pinUser(KeyPair().pubKey.toHexKey()) } assertEquals(10, cache.userCount()) @@ -153,10 +156,10 @@ class FindUsersTest { val cache = createCache() val pubkey = KeyPair().pubKey.toHexKey() + val user = cache.pinUser(pubkey) cache.consumeMetadata(fakeMetadata(pubkey, "testuser", "Test User")) - val user = cache.retainUser(pubkey) - val metadata = user?.metadataOrNull() + val metadata = user.metadataOrNull() assertNotNull(metadata, "Metadata should exist after consumeMetadata") assertTrue( diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt index f039e7deb7..25eea26bea 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt @@ -37,7 +37,6 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -208,14 +207,20 @@ class LocalRelayStoreHydrationTest { val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() + + // Pin the note across hydrate -> assert: the cache holds notes weakly + // (LargeSoftCache) and nothing else references a hydrated note here, so a + // GC in between would drop it and read as "never hydrated". In the app the + // feed showing the note is that strong referent. + val pinned = cache.getOrCreateNote(recentNote.id) + try { store.hydrate(cache) } finally { store.close() } - val note = cache.getNoteIfExists(recentNote.id) - assertNotNull(note, "Recent text note must be hydrated into cache") + assertEquals(recentNote.id, pinned.event?.id, "Recent text note must be hydrated into cache") } @Test @@ -258,15 +263,16 @@ class LocalRelayStoreHydrationTest { val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() + + // Pinned for the same reason as in recentTextNotesWithinSevenDayWindowAreHydrated. + val pinned = cache.getOrCreateNote(note.id) + try { store.hydrate(cache) } finally { store.close() } - assertFalse( - cache.getNoteIfExists(note.id) == null, - "Round-trip through hydrate must not drop a valid note", - ) + assertEquals(note.id, pinned.event?.id, "Round-trip through hydrate must not drop a valid note") } } diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index a8c64872d1..6a28bd9344 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -123,6 +123,12 @@ "Vietnamese" ] }, + { + "user": "maxblake2015", + "languages": [ + "Polish" + ] + }, { "user": "summoner001", "languages": [ @@ -135,12 +141,6 @@ "Hindi" ] }, - { - "user": "maxblake2015", - "languages": [ - "Polish" - ] - }, { "user": "markkks", "languages": [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95adf4b6aa..13297f858e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ accompanistAdaptive = "0.37.3" # stdlib Map operators (filter/map/groupBy/associate/count). Bumping needs a port to # the new forEach/forEachKey/forEachValue API, verified on an Apple target. cachemapVersion = "0.2.4" -composeMultiplatform = "1.12.0" +composeMultiplatform = "1.12.1" activityCompose = "1.13.0" # 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every # .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts @@ -26,20 +26,20 @@ audiowaveform = "1.1.2" benchmark = "1.5.0" uiautomator = "2.4.0" biometricKtx = "1.4.0-alpha02" -coil = "3.6.2" +coil = "3.6.3" composeBom = "2026.09.00" tracingPerfetto = "1.0.1" composeRuntimeAnnotation = "1.12.1" -coreKtx = "1.19.0" +coreKtx = "1.19.1" datastore = "1.2.1" devWhyolegCryptography = "0.6.0" espressoCore = "3.7.0" firebaseBom = "34.19.0" -fragmentKtx = "1.9.0" +fragmentKtx = "1.9.1" gms = "4.5.0" healthConnect = "1.1.0" -jackson = "2.22.2" -jacksonModuleKotlin = "2.22.2" +jackson = "2.22.3" +jacksonModuleKotlin = "2.22.3" javaKeyring = "1.0.4" kmpTorRuntime = "2.6.0" kmpTorResource = "409.5.0" @@ -60,7 +60,7 @@ lightcompressor-enhanced = "2.2.3" jlatexmath = "1.5" markdown = "4d3b3dd173" highlights = "1.1.0" -okio = "3.18.1" +okio = "3.18.2" # JetBrains material3 must track Compose Multiplatform's foundation: CMP 1.12.0 changed # the CustomStyle.applyStyle signature, so the stable 1.9.0 (and the 1.11.0-alpha07 that # kodein-emoji pulls in) crash at runtime with AbstractMethodError in OutlinedTextField. @@ -71,34 +71,34 @@ media3 = "1.11.1" mockk = "1.14.11" kotlinx-coroutines-test = "1.11.0" negentropyKmp = "v1.2.0" -navigationCompose = "2.10.1" +navigationCompose = "2.10.2" okhttp = "5.5.0" osmdroid = "6.1.20" runner = "1.7.0" secp256k1KmpJniAndroid = "0.24.0" schnorr256k1Kmp = "1.0.5" securityCryptoKtx = "1.1.0" -slf4j = "2.0.19" +slf4j = "2.0.20" sonarqubeGradlePlugin = "7.5.0.8588" -spotless = "8.10.2" +spotless = "8.10.3" streamWebrtcAndroid = "1.3.10" translate = "17.0.3" -jetbrainsCompose = "1.12.0" +jetbrainsCompose = "1.12.1" unifiedpush = "3.0.10" playServicesCast = "22.3.1" vico-charts-compose = "3.3.1" zelory = "3.0.1" -zoomable = "2.13.0" +zoomable = "2.14.0" composemediaplayer = "0.11.4" jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" thumbnailator = "0.4.21" zxing = "3.5.4" -webkit = "1.17.0" +webkit = "1.17.1" # Cross-process UI embedding (SurfaceControlViewHost wrapper) for the in-app browser surface. Apache-2.0. privacysandboxUi = "1.0.0-alpha17" windowCoreAndroid = "1.5.1" -workRuntime = "2.11.2" +workRuntime = "2.12.0" androidxCamera = "1.6.2" androidxCollection = "1.6.0" androidxProfileinstaller = "1.4.1" @@ -107,7 +107,7 @@ kotlinTest = "2.4.20" core = "1.7.0" mavenPublish = "0.37.0" sqlite = "2.7.1" -ktor = "3.5.2" +ktor = "3.6.0" fourkoma = "1.2.0" # Phase 2 (Gemini App Functions) — both still pre-stable as of May 2026. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7058e46691..1d02f74621 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip -distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 9dc84100a1..e14c1b8ba8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -325,13 +326,27 @@ class NostrSignerRemote( throw convertExceptions("Could not get public key", result) } + /** + * NIP-46 has no private-zap command, but a private zap's payload is NIP-04-compatible, so the + * recipient can open it with the bunker's `nip04_decrypt`. The sender's copy is keyed by a + * hash of their raw private key, which a bunker never reveals, so that side can't be decrypted. + */ override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent { - TODO("Not yet implemented") + if (event.zappedAuthor().firstOrNull() != pubKey) { + throw SignerExceptions.CouldNotPerformException( + "Remote (NIP-46) signers can only decrypt private zaps sent to this user", + ) + } + return PrivateZapRequestBuilder().decryptReceivedZapEvent(event, this) } - override suspend fun deriveKey(nonce: HexKey): HexKey { - TODO("Not yet implemented") - } + /** + * Key derivation needs the raw private key, and NIP-46 has no command for it. + */ + override suspend fun deriveKey(nonce: HexKey): HexKey = + throw SignerExceptions.UnsupportedMethodException( + "Remote (NIP-46) signers do not support key derivation", + ) /** * NIP-BC `sign_psbt` over NIP-46. The bunker-side command is not yet diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt index 266e541d06..a74f95951f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip57Zaps +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.utils.RandomInstance @@ -59,6 +60,19 @@ class PrivateZapEncryption { return encryptedMsgBech32 + "_" + ivBech32 } + /** + * Re-encodes a private zap's `anon` payload as a NIP-04 payload. Both are AES-256-CBC + * under the same ECDH shared secret, so any signer's `nip04Decrypt(payload, zapRequest.pubKey)` + * opens it for the zap recipient, including remote signers that never expose the key. + */ + fun privateZapMessageToNip04(msg: String): String { + val parts = msg.split("_") + require(parts.size == 2) { "Invalid message format" } + val encryptedMsg = Bech32.five2eight(Bech32.decode(parts[0]).second, 0) + val iv = Bech32.five2eight(Bech32.decode(parts[1]).second, 0) + return EncryptedInfo.encode(encryptedMsg, iv) + } + fun decryptPrivateZapMessage( msg: String, privkey: ByteArray, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt index a26cc784bd..fae81fc82d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -125,20 +126,46 @@ class PrivateZapRequestBuilder { return privateEvent } + /** + * Decrypts a private zap addressed to [signer]'s user using only `nip04Decrypt`, so it works + * for signers that don't hold the private key (NIP-46). Only the recipient can do this: the + * sender's side needs the raw key to re-derive the per-zap key. + */ + suspend fun decryptReceivedZapEvent( + event: ZapRequestEvent, + signer: NostrSigner, + ): PrivateZapEvent { + val payload = + try { + PrivateZapEncryption.privateZapMessageToNip04(event.getAnonTag()) + } catch (e: Exception) { + throw IllegalStateException("Could not decrypt private zap. ${e.message}") + } + return parsePrivateZap(signer.nip04Decrypt(payload, event.pubKey)) + } + fun decryptAnonTag( encNote: String, privateKey: ByteArray, pubKey: HexKey, ): PrivateZapEvent = try { - val note = PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray()) - val decryptedEvent = fromJson(note) - if (decryptedEvent.kind == 9733) { - decryptedEvent as PrivateZapEvent - } else { - throw IllegalStateException("The decrypted event is not a private zap.") - } + parsePrivateZap(PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray())) } catch (e: Exception) { throw IllegalStateException("Could not decrypt private zap. ${e.message}") } + + private fun parsePrivateZap(json: String): PrivateZapEvent { + val decryptedEvent = + try { + fromJson(json) + } catch (e: Exception) { + throw IllegalStateException("Could not decrypt private zap. ${e.message}") + } + if (decryptedEvent.kind == PrivateZapEvent.KIND) { + return decryptedEvent as PrivateZapEvent + } else { + throw IllegalStateException("The decrypted event is not a private zap.") + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcData.kt new file mode 100644 index 0000000000..9ad591a7ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcData.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.unicode + +// GENERATED by tools/unicode-nfkc/generate.py from the Unicode 17.0.0 Character Database. +// Do not edit by hand; re-run the script to update. +// +// Derived from Unicode data files, Copyright (c) 1991-2025 Unicode, Inc., +// distributed under the Unicode License v3: https://www.unicode.org/license.txt +internal object NfkcData { + const val UNICODE_VERSION = "17.0.0" + + val CCC = + arrayOf( + "300-314:E6;315-315:E8;316-319:DC;31A-31A:E8;31B-31B:D8;31C-320:DC;321-322:CA;323-326:DC;327-328:CA;329-333:DC;334-338:1;339-33C:DC;33D-344:E6;345-345:F0;346-346:E6;347-349:DC;34A-34C:E6;34D-34E:DC;350-352:E6;353-356:DC;357-357:E6;358-358:E8;359-35A:DC;35B-35B:E6;35C-35C:E9;35D-35E:EA;35F-35F:E9;360-361:EA;362-362:E9;363-36F:E6;483-487:E6;591-591:DC;592-595:E6;596-596:DC;597-599:E6;59A-59A:DE;59B-59B:DC;59C-5A1:E6;5A2-5A7:DC;5A8-5A9:E6;5AA-5AA:DC;5AB-5AC:E6;5AD-5AD:DE;5AE-5AE:E4;5AF-5AF:E6;5B0-5B0:A;5B1-5B1:B;5B2-5B2:C;5B3-5B3:D;5B4-5B4:E;5B5-5B5:F;5B6-5B6:10;5B7-5B7:11;5B8-5B8:12;5B9-5BA:13;5BB-5BB:14;5BC-5BC:15;5BD-5BD:16;5BF-5BF:17;5C1-5C1:18;5C2-5C2:19;5C4-5C4:E6;5C5-5C5:DC;5C7-5C7:12;610-617:E6;618-618:1E;619-619:1F;61A-61A:20;64B-64B:1B;64C-64C:1C;64D-64D:1D;64E-64E:1E;64F-64F:1F;650-650:20;651-651:21;652-652:22;653-654:E6;655-656:DC;657-65B:E6;65C-65C:DC;65D-65E:E6;65F-65F:DC;670-670:23;6D6-6DC:E6;6DF-6E2:E6;6E3-6E3:DC;6E4-6E4:E6;6E7-6E8:E6;6EA-6EA:DC;6EB-6EC:E6;6ED-6ED:DC;711-711:24;730-730:E6;731-731:DC;732-733:E6;734-734:DC;735-736:E6;737-739:DC;73A-73A:E6;73B-73C:DC;73D-73D:E6;73E-73E:DC;73F-741:E6;742-742:DC;743-743:E6;744-744:DC;745-745:E6;746-746:DC;747-747:E6;748-748:DC;749-74A:E6;7EB-7F1:E6;7F2-7F2:DC;7F3-7F3:E6;7FD-7FD:DC;816-819:E6;81B-823:E6;825-827:E6;829-82D:E6;859-85B:DC;897-898:E6;899-89B:DC;89C-89F:E6;8CA-8CE:E6;8CF-8D3:DC;8D4-8E1:E6;8E3-8E3:DC;8E4-8E5:E6;8E6-8E6:DC;8E7-8E8:E6;8E9-8E9:DC;8EA-8EC:E6;8ED-8EF:DC;8F0-8F0:1B;8F1-8F1:1C;8F2-8F2:1D;8F3-8F5:E6;8F6-8F6:DC;8F7-8F8:E6;8F9-8FA:DC;8FB-8FF:E6;93C-93C:7;94D-94D:9;951-951:E6;952-952:DC;953-954:E6;9BC-9BC:7;9CD-9CD:9;9FE-9FE:E6;A3C-A3C:7;A4D-A4D:9;ABC-ABC:7;ACD-ACD:9;B3C-B3C:7;B4D-B4D:9;BCD-BCD:9;C3C-C3C:7;C4D-C4D:9;C55-C55:54;C56-C56:5B;CBC-CBC:7;CCD-CCD:9;D3B-D3C:9;D4D-D4D:9;DCA-DCA:9;E38-E39:67;E3A-E3A:9;E48-E4B:6B;EB8-EB9:76;EBA-EBA:9;EC8-ECB:7A;F18-F19:DC;F35-F35:DC;F37-F37:DC;F39-F39:D8;F71-F71:81;F72-F72:82;F74-F74:84;F7A-F7D:82;F80-F80:82;F82-F83:E6;F84-F84:9;F86-F87:E6;FC6-FC6:DC;1037-1037:7;1039-103A:9;108D-108D:DC;135D-135F:E6;1714-1715:9;1734-1734:9;17D2-17D2:9;17DD-17DD:E6;18A9-18A9:E4;1939-1939:DE;193A-193A:E6;193B-193B:DC;1A17-1A17:E6;1A18-1A18:DC;1A60-1A60:9;1A75-1A7C:E6;1A7F-1A7F:DC;1AB0-1AB4:E6;1AB5-1ABA:DC;1ABB-1ABC:E6;1ABD-1ABD:DC;1ABF-1AC0:DC;1AC1-1AC2:E6;1AC3-1AC4:DC;1AC5-1AC9:E6;1ACA-1ACA:DC;1ACB-1ADC:E6;1ADD-1ADD:DC;1AE0-1AE5:E6;1AE6-1AE6:DC;1AE7-1AEA:E6;1AEB-1AEB:EA;1B34-1B34:7;1B44-1B44:9;1B6B-1B6B:E6;1B6C-1B6C:DC;1B6D-1B73:E6;1BAA-1BAB:9;1BE6-1BE6:7;1BF2-1BF3:9;1C37-1C37:7;1CD0-1CD2:E6;1CD4-1CD4:1;1CD5-1CD9:DC;1CDA-1CDB:E6;1CDC-1CDF:DC;1CE0-1CE0:E6;1CE2-1CE8:1;1CED-1CED:DC;1CF4-1CF4:E6;1CF8-1CF9:E6;1DC0-1DC1:E6;1DC2-1DC2:DC;1DC3-1DC9:E6;1DCA-1DCA:DC;1DCB-1DCC:E6;1DCD-1DCD:EA;1DCE-1DCE:D6;1DCF-1DCF:DC;1DD0-1DD0:CA;1DD1-1DF5:E6;1DF6-1DF6:E8;1DF7-1DF8:E4;1DF9-1DF9:DC;1DFA-1DFA:DA;1DFB-1DFB:E6;1DFC-1DFC:E9;1DFD-1DFD:DC;1DFE-1DFE:E6;1DFF-1DFF:DC;20D0-20D1:E6;20D2-20D3:1;20D4-20D7:E6;20D8-20DA:1;20DB-20DC:E6;20E1-20E1:E6;20E5-20E6:1;20E7-20E7:E6;20E8-20E8:DC;20E9-20E9:E6;20EA-20EB:1;20EC-20EF:DC;20F0-20F0:E6;2CEF-2CF1:E6;2D7F-2D7F:9;2DE0-2DFF:E6;302A-302A:DA;302B-302B:E4;302C-302C:E8;302D-302D:DE;302E-302F:E0;3099-309A:8;A66F-A66F:E6;A674-A67D:E6;A69E-A69F:E6;A6F0-A6F1:E6;A806-A806:9;A82C-A82C:9;A8C4-A8C4:9;A8E0-A8F1:E6;A92B-A92D:DC;A953-A953:9;A9B3-A9B3:7;A9C0-A9C0:9;AAB0-AAB0:E6;AAB2-AAB3:E6;AAB4-AAB4:DC;AAB7-AAB8:E6;AABE-AABF:E6;AAC1-AAC1:E6;AAF6-AAF6:9;ABED-ABED:9;FB1E-FB1E:1A;FE20-FE26:E6;FE27-FE2D:DC;FE2E-FE2F:E6;101FD-101FD:DC;102E0-102E0:DC;10376-1037A:E6;10A0D-10A0D:DC;10A0F-10A0F:E6;10A38-10A38:E6;10A39-10A39:1;10A3A-10A3A:DC;10A3F-10A3F:9;10AE5-10AE5:E6;10AE6-10AE6:DC;10D24-10D27:E6;10D69-10D6D:E6;10EAB-10EAC:E6;10EFA-10EFB:DC;10EFD-10EFF:DC;10F46-10F47:DC;10F48-10F4A:E6;10F4B-10F4B:DC;10F4C-10F4C:E6;10F4D-10F50:DC;10F82-10F82:E6;10F83-10F83:DC;10F84-10F84:E6;10F85-10F85:DC;11046-11046:9;11070-11070:9;1107F-1107F:9;110B9-110B9:9;110BA-110BA:7;11100-11102:E6;11133-11134:9;11173-11173:7;111C0-111C0:9;111CA-111CA:7;11235-11235:9;11236-11236:7;112E9-112E9:7;112EA-112EA:9;1133B-1133C:7;1134D-1134D:9;11366-1136C:E6;11370-11374:E6;113CE-113D0:9;11442-11442:9;11446-11446:7;1145E-1145E:E6;114C2-114C2:9;114C3-114C3:7;115BF-115BF:9;115C0-115C0:7;1163F-1163F:9;116B6-116B6:9;116B7-116B7:7;1172B-1172B:9;11839-11839:9;1183A-1183A:7;1193D-1193E:9;11943-11943:7;119E0-119E0:9;11A34-11A34:9;11A47-11A47:9;11A99-11A99:9;11C3F-11C3F:9;11D42-11D42:7;11D44-11D45:9;11D97-11D97:9;11F41-11F42:9;1612F-1612F:9;16AF0-16AF4:1;16B30-16B36:E6;16FF0-16FF1:6;1BC9E-1BC9E:1;1D165-1D166:D8;1D167-1D169:1;1D16D-1D16D:E2;1D16E-1D172:D8;1D17B-1D182:DC;1D185-1D189:E6;1D18A-1D18B:DC;1D1AA-1D1AD:E6;1D242-1D244:E6;1E000-1E006:E6;1E008-1E018:E6;1E01B-1E021:E6;1E023-1E024:E6;1E026-1E02A:E6;1E08F-1E08F:E6;1E130-1E136:E6;1E2AE-1E2AE:E6;1E2EC-1E2EF:E6;1E4EC-1E4ED:E8;1E4EE-1E4EE:DC;1E4EF-1E4EF:E6;1E5EE-1E5EE:E6;1E5EF-1E5EF:DC;1E6E3-1E6E3:E6;1E6E6-1E6E6:E6;1E6EE-1E6EF:E6;1E6F5-1E6F5:E6;1E8D0-1E8D6:DC;1E944-1E949:E6;1E94A-1E94A:7", + ) + + val DECOMP = + arrayOf( + "A0:20;A8:20 308;AA:61;AF:20 304;B2:32;B3:33;B4:20 301;B5:3BC;B8:20 327;B9:31;BA:6F;BC:31 2044 34;BD:31 2044 32;BE:33 2044 34;C0:41 300;C1:41 301;C2:41 302;C3:41 303;C4:41 308;C5:41 30A;C7:43 327;C8:45 300;C9:45 301;CA:45 302;CB:45 308;CC:49 300;CD:49 301;CE:49 302;CF:49 308;D1:4E 303;D2:4F 300;D3:4F 301;D4:4F 302;D5:4F 303;D6:4F 308;D9:55 300;DA:55 301;DB:55 302;DC:55 308;DD:59 301;E0:61 300;E1:61 301;E2:61 302;E3:61 303;E4:61 308;E5:61 30A;E7:63 327;E8:65 300;E9:65 301;EA:65 302;EB:65 308;EC:69 300;ED:69 301;EE:69 302;EF:69 308;F1:6E 303;F2:6F 300;F3:6F 301;F4:6F 302;F5:6F 303;F6:6F 308;F9:75 300;FA:75 301;FB:75 302;FC:75 308;FD:79 301;FF:79 308;100:41 304;101:61 304;102:41 306;103:61 306;104:41 328;105:61 328;106:43 301;107:63 301;108:43 302;109:63 302;10A:43 307;10B:63 307;10C:43 30C;10D:63 30C;10E:44 30C;10F:64 30C;112:45 304;113:65 304;114:45 306;115:65 306;116:45 307;117:65 307;118:45 328;119:65 328;11A:45 30C;11B:65 30C;11C:47 302;11D:67 302;11E:47 306;11F:67 306;120:47 307;121:67 307;122:47 327;123:67 327;124:48 302;125:68 302;128:49 303;129:69 303;12A:49 304;12B:69 304;12C:49 306;12D:69 306;12E:49 328;12F:69 328;130:49 307;132:49 4A;133:69 6A;134:4A 302;135:6A 302;136:4B 327;137:6B 327;139:4C 301;13A:6C 301;13B:4C 327;13C:6C 327;13D:4C 30C;13E:6C 30C;13F:4C B7;140:6C B7;143:4E 301;144:6E 301;145:4E 327;146:6E 327;147:4E 30C;148:6E 30C;149:2BC 6E;14C:4F 304;14D:6F 304;14E:4F 306;14F:6F 306;150:4F 30B;151:6F 30B;154:52 301;155:72 301;156:52 327;157:72 327;158:52 30C;159:72 30C;15A:53 301;15B:73 301;15C:53 302;15D:73 302;15E:53 327;15F:73 327;160:53 30C;161:73 30C;162:54 327;163:74 327;164:54 30C;165:74 30C;168:55 303;169:75 303;16A:55 304;16B:75 304;16C:55 306;16D:75 306;16E:55 30A;16F:75 30A;170:55 30B;171:75 30B;172:55 328;173:75 328;174:57 302;175:77 302;176:59 302;177:79 302;178:59 308;179:5A 301;17A:7A 301;17B:5A 307;17C:7A 307;17D:5A 30C;17E:7A 30C;17F:73;1A0:4F 31B;1A1:6F 31B;1AF:55 31B;1B0:75 31B;1C4:44 5A 30C;1C5:44 7A 30C;1C6:64 7A 30C;1C7:4C 4A;1C8:4C 6A;1C9:6C 6A;1CA:4E 4A;1CB:4E 6A;1CC:6E 6A;1CD:41 30C;1CE:61 30C;1CF:49 30C;1D0:69 30C;1D1:4F 30C;1D2:6F 30C;1D3:55 30C;1D4:75 30C;1D5:55 308 304;1D6:75 308 304;1D7:55 308 301;1D8:75 308 301;1D9:55 308 30C;1DA:75 308 30C;1DB:55 308 300;1DC:75 308 300;1DE:41 308 304;1DF:61 308 304;1E0:41 307 304;1E1:61 307 304;1E2:C6 304;1E3:E6 304;1E6:47 30C;1E7:67 30C;1E8:4B 30C;1E9:6B 30C;1EA:4F 328;1EB:6F 328;1EC:4F 328 304;1ED:6F 328 304;1EE:1B7 30C;1EF:292 30C;1F0:6A 30C;1F1:44 5A;1F2:44 7A;1F3:64 7A;1F4:47 301;1F5:67 301;1F8:4E 300;1F9:6E 300;1FA:41 30A 301;1FB:61 30A 301;1FC:C6 301;1FD:E6 301;1FE:D8 301;1FF:F8 301;200:41 30F;201:61 30F;202:41 311;203:61 311;204:45 30F;205:65 30F;206:45 311;207:65 311;208:49 30F;209:69 30F;20A:49 311;20B:69 311;20C:4F 30F;20D:6F 30F;20E:4F 311;20F:6F 311;210:52 30F;211:72 30F;212:52 311;213:72 311;214:55 30F;215:75 30F;216:55 311;217:75 311;218:53 326;219:73 326;21A:54 326;21B:74 326;21E:48 30C;21F:68 30C;226:41 307;227:61 307;228:45 327;229:65 327;22A:4F 308 304;22B:6F 308 304;22C:4F 303 304;22D:6F 303 304;22E:4F 307;22F:6F 307;230:4F 307 304;231:6F 307 304;232:59 304;233:79 304;2B0:68;2B1:266;2B2:6A;2B3:72;2B4:279;2B5:27B;2B6:281;2B7:77;2B8:79;2D8:20 306;2D9:20 307;2DA:20 30A;2DB:20 328;2DC:20 303;2DD:20 30B;2E0:263;2E1:6C;2E2:73;2E3:78;2E4:295;340:300;341:301;343:313;344:308 301;374:2B9;37A:20 345;37E:3B;384:20 301;385:20 308 301;386:391 301;387:B7;388:395 301;389:397 301;38A:399 301;38C:39F 301;38E:3A5 301;38F:3A9 301;390:3B9 308 301;3AA:399 308;3AB:3A5 308;3AC:3B1 301;3AD:3B5 301;3AE:3B7 301;3AF:3B9 301;3B0:3C5 308 301;3CA:3B9 308;3CB:3C5 308;3CC:3BF 301;3CD:3C5 301;3CE:3C9 301;3D0:3B2;3D1:3B8;3D2:3A5;3D3:3A5 301;3D4:3A5 308;3D5:3C6;3D6:3C0;3F0:3BA;3F1:3C1;3F2:3C2;3F4:398;3F5:3B5;3F9:3A3;400:415 300;401:415 308;403:413 301;407:406 308;40C:41A 301;40D:418 300;40E:423 306;419:418 306;439:438 306;450:435 300;451:435 308;453:433 301;457:456 308;45C:43A 301;45D:438 300;45E:443 306;476:474 30F;477:475 30F;4C1:416 306;4C2:436 306;4D0:410 306;4D1:430 306;4D2:410 308;4D3:430 308;4D6:415 306;4D7:435 306;4DA:4D8 308;4DB:4D9 308;4DC:416 308;4DD:436 308;4DE:417 308;4DF:437 308;4E2:418 304;4E3:438 304;4E4:418 308;4E5:438 308;4E6:41E 308;4E7:43E 308;4EA:4E8 308;4EB:4E9 308;4EC:42D 308;4ED:44D 308;4EE:423 304;4EF:443 304;4F0:423 308;4F1:443 308;4F2:423 30B;4F3:443 30B;4F4:427 308;4F5:447 308;4F8:42B 308;4F9:44B 308;587:565 582;622:627 653;623:627 654;624:648 654;625:627 655;626:64A 654;675:627 674;676:648 674;677:6C7 674;678:64A 674;6C0:6D5 654;6C2:6C1 654;6D3:6D2 654;929:928 93C;931:930 93C;934:933 93C;958:915 93C;959:916 93C;95A:917 93C;95B:91C 93C;95C:921 93C;95D:922 93C;95E:92B 93C;95F:92F 93C;9CB:9C7 9BE;9CC:9C7 9D7;9DC:9A1 9BC;9DD:9A2 9BC;9DF:9AF 9BC;A33:A32 A3C;A36:A38 A3C;A59:A16 A3C;A5A:A17 A3C;A5B:A1C A3C;A5E:A2B A3C;B48:B47 B56;B4B:B47 B3E;B4C:B47 B57;B5C:B21 B3C;B5D:B22 B3C;B94:B92 BD7;BCA:BC6 BBE;BCB:BC7 BBE;BCC:BC6 BD7;C48:C46 C56;CC0:CBF CD5;CC7:CC6 CD5;CC8:CC6 CD6;CCA:CC6 CC2;CCB:CC6 CC2 CD5;D4A:D46 D3E;D4B:D47 D3E;D4C:D46 D57;DDA:DD9 DCA;DDC:DD9 DCF;DDD:DD9 DCF DCA;DDE:DD9 DDF;E33:E4D E32;EB3:ECD EB2;EDC:EAB E99;EDD:EAB EA1;F0C:F0B;F43:F42 FB7;F4D:F4C FB7;F52:F51 FB7;F57:F56 FB7;F5C:F5B FB7;F69:F40 FB5;F73:F71 F72;F75:F71 F74;F76:FB2 F80;F77:FB2 F71 F80;F78:FB3 F80;F79:FB3 F71 F80;F81:F71 F80;F93:F92 FB7;F9D:F9C FB7;FA2:FA1 FB7;FA7:FA6 FB7;FAC:FAB FB7;FB9:F90 FB5;1026:1025 102E;10FC:10DC;1B06:1B05 1B35;1B08:1B07 1B35;1B0A:1B09 1B35;1B0C:1B0B 1B35;1B0E:1B0D 1B35;1B12:1B11 1B35;1B3B:1B3A 1B35;1B3D:1B3C 1B35;1B40:1B3E 1B35;1B41:1B3F 1B35;1B43:1B42 1B35;1D2C:41;1D2D:C6;1D2E:42;1D30:44;1D31:45;1D32:18E;1D33:47;1D34:48;1D35:49;1D36:4A;1D37:4B;1D38:4C;1D39:4D;1D3A:4E;1D3C:4F;1D3D:222;1D3E:50;1D3F:52;1D40:54;1D41:55;1D42:57;1D43:61;1D44:250;1D45:251;1D46:1D02;1D47:62;1D48:64;1D49:65;1D4A:259;1D4B:25B;1D4C:25C;1D4D:67;1D4F:6B;1D50:6D;1D51:14B;1D52:6F;1D53:254;1D54:1D16;1D55:1D17;1D56:70;1D57:74;1D58:75;1D59:1D1D;1D5A:26F;1D5B:76;1D5C:1D25;1D5D:3B2;1D5E:3B3;1D5F:3B4;1D60:3C6;1D61:3C7;1D62:69;1D63:72;1D64:75;1D65:76;1D66:3B2;1D67:3B3;1D68:3C1;1D69:3C6;1D6A:3C7;1D78:43D;1D9B:252;1D9C:63;1D9D:255;1D9E:F0;1D9F:25C;1DA0:66;1DA1:25F;1DA2:261;1DA3:265;1DA4:268;1DA5:269;1DA6:26A;1DA7:1D7B;1DA8:29D;1DA9:26D;1DAA:1D85;1DAB:29F;1DAC:271;1DAD:270;1DAE:272;1DAF:273;1DB0:274;1DB1:275;1DB2:278;1DB3:282;1DB4:283;1DB5:1AB;1DB6:289;1DB7:28A;1DB8:1D1C;1DB9:28B;1DBA:28C;1DBB:7A;1DBC:290;1DBD:291;1DBE:292;1DBF:3B8;1E00:41 325;1E01:61 325;1E02:42 307;1E03:62 307;1E04:42 323;1E05:62 323;1E06:42 331;1E07:62 331;1E08:43 327 301;1E09:63 327 301;1E0A:44 307;1E0B:64 307;1E0C:44 323;1E0D:64 323;1E0E:44 331;1E0F:64 331;1E10:44 327;1E11:64 327;1E12:44 32D;1E13:64 32D;1E14:45 304 300;1E15:65 304 300;1E16:45 304 301;1E17:65 304 301;1E18:45 32D;1E19:65 32D;1E1A:45 330;1E1B:65 330;1E1C:45 327 306;1E1D:65 327 306;1E1E:46 307;1E1F:66 307;1E20:47 304;1E21:67 304;1E22:48 307;1E23:68 307;1E24:48 323;1E25:68 323;1E26:48 308;1E27:68 308;1E28:48 327;1E29:68 327;1E2A:48 32E;1E2B:68 32E;1E2C:49 330;1E2D:69 330;1E2E:49 308 301;1E2F:69 308 301;1E30:4B 301;1E31:6B 301;1E32:4B 323;1E33:6B 323;1E34:4B 331;1E35:6B 331;1E36:4C 323;1E37:6C 323;1E38:4C 323 304;1E39:6C 323 304;1E3A:4C 331;1E3B:6C 331;1E3C:4C 32D;1E3D:6C 32D;1E3E:4D 301;1E3F:6D 301;1E40:4D 307;1E41:6D 307;1E42:4D 323;1E43:6D 323;1E44:4E 307;1E45:6E 307;1E46:4E 323;1E47:6E 323;1E48:4E 331;1E49:6E 331;1E4A:4E 32D;1E4B:6E 32D;1E4C:4F 303 301;1E4D:6F 303 301;1E4E:4F 303 308;1E4F:6F 303 308;1E50:4F 304 300;1E51:6F 304 300;1E52:4F 304 301;1E53:6F 304 301;1E54:50 301;1E55:70 301;1E56:50 307;1E57:70 307;1E58:52 307;1E59:72 307;1E5A:52 323;1E5B:72 323;1E5C:52 323 304;1E5D:72 323 304;1E5E:52 331;1E5F:72 331;1E60:53 307;1E61:73 307;1E62:53 323;1E63:73 323;1E64:53 301 307;1E65:73 301 307;1E66:53 30C 307;1E67:73 30C 307;1E68:53 323 307;1E69:73 323 307;1E6A:54 307;1E6B:74 307;1E6C:54 323;1E6D:74 323;1E6E:54 331;1E6F:74 331;1E70:54 32D;1E71:74 32D;1E72:55 324;1E73:75 324;1E74:55 330;1E75:75 330;1E76:55 32D;1E77:75 32D;1E78:55 303 301;1E79:75 303 301;1E7A:55 304 308;1E7B:75 304 308;1E7C:56 303;1E7D:76 303;1E7E:56 323;1E7F:76 323;1E80:57 300;1E81:77 300;1E82:57 301;1E83:77 301;1E84:57 308;1E85:77 308;1E86:57 307;1E87:77 307;1E88:57 323;1E89:77 323;1E8A:58 307;1E8B:78 307;1E8C:58 308;1E8D:78 308;1E8E:59 307;1E8F:79 307;1E90:5A 302;1E91:7A 302;1E92:5A 323;1E93:7A 323;1E94:5A 331;1E95:7A 331;1E96:68 331;1E97:74 308;1E98:77 30A;1E99:79 30A;1E9A:61 2BE;1E9B:73 307;1EA0:41 323;1EA1:61 323;1EA2:41 309;1EA3:61 309;1EA4:41 302 301;1EA5:61 302 301;1EA6:41 302 300;1EA7:61 302 300;1EA8:41 302 309;1EA9:61 302 309;1EAA:41 302 303;1EAB:61 302 303;1EAC:41 323 302;1EAD:61 323 302;1EAE:41 306 301;1EAF:61 306 301;1EB0:41 306 300;1EB1:61 306 300;1EB2:41 306 309;1EB3:61 306 309;1EB4:41 306 303;1EB5:61 306 303;1EB6:41 323 306;1EB7:61 323 306;1EB8:45 323;1EB9:65 323;1EBA:45 309;1EBB:65 309;1EBC:45 303;1EBD:65 303;1EBE:45 302 301;1EBF:65 302 301;1EC0:45 302 300;1EC1:65 302 300;1EC2:45 302 309;1EC3:65 302 309;1EC4:45 302 303;1EC5:65 302 303;1EC6:45 323 302;1EC7:65 323 302;1EC8:49 309;1EC9:69 309;1ECA:49 323;1ECB:69 323;1ECC:4F 323;1ECD:6F 323;1ECE:4F 309;1ECF:6F 309;1ED0:4F 302 301;1ED1:6F 302 301;1ED2:4F 302 300;1ED3:6F 302 300;1ED4:4F 302 309;1ED5:6F 302 309;1ED6:4F 302 303;1ED7:6F 302 303;1ED8:4F 323 302;1ED9:6F 323 302;1EDA:4F 31B 301;1EDB:6F 31B 301;1EDC:4F 31B 300;1EDD:6F 31B 300;1EDE:4F 31B 309;1EDF:6F 31B 309;1EE0:4F 31B 303;1EE1:6F 31B 303;1EE2:4F 31B 323;1EE3:6F 31B 323;1EE4:55 323;1EE5:75 323;1EE6:55 309;1EE7:75 309;1EE8:55 31B 301;1EE9:75 31B 301;1EEA:55 31B 300;1EEB:75 31B 300;1EEC:55 31B 309;1EED:75 31B 309;1EEE:55 31B 303;1EEF:75 31B 303;1EF0:55 31B 323;1EF1:75 31B 323;1EF2:59 300;1EF3:79 300;1EF4:59 323;1EF5:79 323;1EF6:59 309;1EF7:79 309;1EF8:59 303;1EF9:79 303;1F00:3B1 313;1F01:3B1 314;1F02:3B1 313 300;1F03:3B1 314 300;1F04:3B1 313 301;1F05:3B1 314 301;1F06:3B1 313 342;1F07:3B1 314 342;1F08:391 313;1F09:391 314;1F0A:391 313 300;1F0B:391 314 300;1F0C:391 313 301;1F0D:391 314 301;1F0E:391 313 342;1F0F:391 314 342;1F10:3B5 313;1F11:3B5 314;1F12:3B5 313 300;1F13:3B5 314 300;1F14:3B5 313 301;1F15:3B5 314 301;1F18:395 313;1F19:395 314;1F1A:395 313 300;1F1B:395 314 300;1F1C:395 313 301;1F1D:395 314 301;1F20:3B7 313;1F21:3B7 314;1F22:3B7 313 300;1F23:3B7 314 300;1F24:3B7 313 301;1F25:3B7 314 301;1F26:3B7 313 342;1F27:3B7 314 342;1F28:397 313;1F29:397 314;1F2A:397 313 300;1F2B:397 314 300;1F2C:397 313 301;1F2D:397 314 301;1F2E:397 313 342;1F2F:397 314 342;1F30:3B9 313;1F31:3B9 314;1F32:3B9 313 300;1F33:3B9 314 300;1F34:3B9 313 301;1F35:3B9 314 301;1F36:3B9 313 342;1F37:3B9 314 342;1F38:399 313;1F39:399 314;1F3A:399 313 300;1F3B:399 314 300;1F3C:399 313 301;1F3D:399 314 301;1F3E:399 313 342;1F3F:399 314 342;1F40:3BF 313;1F41:3BF 314;1F42:3BF 313 300;1F43:3BF 314 300;1F44:3BF 313 301;1F45:3BF 314 301;1F48:39F 313;1F49:39F 314;1F4A:39F 313 300;1F4B:39F 314 300;1F4C:39F 313 301;1F4D:39F 314 301;1F50:3C5 313;1F51:3C5 314;1F52:3C5 313 300;1F53:3C5 314 300;1F54:3C5 313 301;1F55:3C5 314 301;1F56:3C5 313 342;1F57:3C5 314 342;1F59:3A5 314;1F5B:3A5 314 300;1F5D:3A5 314 301;1F5F:3A5 314 342;1F60:3C9 313;1F61:3C9 314;1F62:3C9 313 300;1F63:3C9 314 300;1F64:3C9 313 301;1F65:3C9 314 301;1F66:3C9 313 342;1F67:3C9 314 342;1F68:3A9 313;1F69:3A9 314;1F6A:3A9 313 300;1F6B:3A9 314 300;1F6C:3A9 313 301;1F6D:3A9 314 301;1F6E:3A9 313 342;1F6F:3A9 314 342;1F70:3B1 300;1F71:3B1 301;1F72:3B5 300;1F73:3B5 301;1F74:3B7 300;1F75:3B7 301;1F76:3B9 300;1F77:3B9 301;1F78:3BF 300;1F79:3BF 301;1F7A:3C5 300;1F7B:3C5 301;1F7C:3C9 300;1F7D:3C9 301;1F80:3B1 313 345;1F81:3B1 314 345;1F82:3B1 313 300 345;1F83:3B1 314 300 345;1F84:3B1 313 301 345;1F85:3B1 314 301 345;1F86:3B1 313 342 345;1F87:3B1 314 342 345;1F88:391 313 345;1F89:391 314 345;1F8A:391 313 300 345;1F8B:391 314 300 345;1F8C:391 313 301 345;1F8D:391 314 301 345;1F8E:391 313 342 345;1F8F:391 314 342 345;1F90:3B7 313 345;1F91:3B7 314 345;1F92:3B7 313 300 345;1F93:3B7 314 300 345;1F94:3B7 313 301 345;1F95:3B7 314 301 345;1F96:3B7 313 342 345;1F97:3B7 314 342 345;1F98:397 313 345;1F99:397 314 345;1F9A:397 313 300 345;1F9B:397 314 300 345;1F9C:397 313 301 345;1F9D:397 314 301 345;1F9E:397 313 342 345;1F9F:397 314 342 345;1FA0:3C9 313 345;1FA1:3C9 314 345;1FA2:3C9 313 300 345;1FA3:3C9 314 300 345;1FA4:3C9 313 301 345;1FA5:3C9 314 301 345;1FA6:3C9 313 342 345;1FA7:3C9 314 342 345;1FA8:3A9 313 345;1FA9:3A9 314 345;1FAA:3A9 313 300 345;1FAB:3A9 314 300 345;1FAC:3A9 313 301 345;1FAD:3A9 314 301 345;1FAE:3A9 313 342 345;1FAF:3A9 314 342 345;1FB0:3B1 306;1FB1:3B1 304;1FB2:3B1 300 345;1FB3:3B1 345;1FB4:3B1 301 345;1FB6:3B1 342;1FB7:3B1 342 345;1FB8:391 306;1FB9:391 304;1FBA:391 300;1FBB:391 301;1FBC:391 345;1FBD:20 313;1FBE:3B9;1FBF:20 313;1FC0:20 342;1FC1:20 308 342;1FC2:3B7 300 345;1FC3:3B7 345;1FC4:3B7 301 345;1FC6:3B7 342;1FC7:3B7 342 345;1FC8:395 300;1FC9:395 301;1FCA:397 300;1FCB:397 301;1FCC:397 345;1FCD:20 313 300;1FCE:20 313 301;1FCF:20 313 342;1FD0:3B9 306;1FD1:3B9 304;1FD2:3B9 308 300;1FD3:3B9 308 301;1FD6:3B9 342;1FD7:3B9 308 342;1FD8:399 306;1FD9:399 304;1FDA:399 300;1FDB:399 301;1FDD:20 314 300;1FDE:20 314 301;1FDF:20 314 342;1FE0:3C5 306;1FE1:3C5 304;1FE2:3C5 308 300;1FE3:3C5 308 301;1FE4:3C1 313;1FE5:3C1 314;1FE6:3C5 342;1FE7:3C5 308 342;1FE8:3A5 306;1FE9:3A5 304;1FEA:3A5 300;1FEB:3A5 301;1FEC:3A1 314;1FED:20 308 300;1FEE:20 308 301;1FEF:60;1FF2:3C9 300 345;1FF3:3C9 345;1FF4:3C9 301 345;1FF6:3C9 342;1FF7:3C9 342 345;1FF8:39F 300;1FF9:39F 301;1FFA:3A9 300;1FFB:3A9 301;1FFC:3A9 345;1FFD:20 301;1FFE:20 314;2000:20;2001:20;2002:20;2003:20;2004:20;2005:20;2006:20;2007:20;2008:20;2009:20;200A:20;2011:2010;2017:20 333;2024:2E;2025:2E 2E;2026:2E 2E 2E;202F:20;2033:2032 2032;2034:2032 2032 2032;2036:2035 2035;2037:2035 2035 2035;203C:21 21;203E:20 305;2047:3F 3F;2048:3F 21;2049:21 3F;2057:2032 2032 2032 2032;205F:20;2070:30;2071:69;2074:34;2075:35;2076:36;2077:37;2078:38;2079:39;207A:2B;207B:2212;207C:3D;207D:28;207E:29;207F:6E;2080:30;2081:31;2082:32;2083:33;2084:34;2085:35;2086:36;2087:37;2088:38;2089:39;208A:2B;208B:2212;208C:3D;208D:28;208E:29;2090:61;2091:65;2092:6F;2093:78;2094:259;2095:68;2096:6B;2097:6C;2098:6D;2099:6E;209A:70;209B:73;209C:74;20A8:52 73;2100:61 2F 63;2101:61 2F 73;2102:43;2103:B0 43;2105:63 2F 6F;2106:63 2F 75;2107:190;2109:B0 46;210A:67;210B:48;210C:48;210D:48;210E:68;210F:127;2110:49;2111:49;2112:4C;2113:6C;2115:4E;2116:4E 6F;2119:50;211A:51;211B:52;211C:52;211D:52;2120:53 4D;2121:54 45 4C;2122:54 4D;2124:5A;2126:3A9;2128:5A;212A:4B;212B:41 30A;212C:42;212D:43;212F:65;2130:45;2131:46;2133:4D;2134:6F;2135:5D0;2136:5D1;2137:5D2;2138:5D3;2139:69;213B:46 41 58;213C:3C0;213D:3B3;213E:393;213F:3A0;2140:2211;2145:44;2146:64;2147:65;2148:69;2149:6A;2150:31 2044 37;2151:31 2044 39;2152:31 2044 31 30;2153:31 2044 33;2154:32 2044 33;2155:31 2044 35;2156:32 2044 35;2157:33 2044 35;2158:34 2044 35;2159:31 2044 36;215A:35 2044 36;215B:31 2044 38;215C:33 2044 38;215D:35 2044 38;215E:37 2044 38;215F:31 2044;2160:49;2161:49 49;2162:49 49 49;2163:49 56;2164:56;2165:56 49;2166:56 49 49;2167:56 49 49 49;2168:49 58;2169:58;216A:58 49;216B:58 49 49;216C:4C;216D:43;216E:44;216F:4D;2170:69;2171:69 69;2172:69 69 69;2173:69 76;2174:76;2175:76 69;2176:76 69 69;2177:76 69 69 69;2178:69 78;2179:78;217A:78 69;217B:78 69 69;217C:6C;217D:63;217E:64;217F:6D;2189:30 2044 33;219A:2190 338;219B:2192 338;21AE:2194 338;21CD:21D0 338;21CE:21D4 338;21CF:21D2 338;2204:2203 338;2209:2208 338;220C:220B 338;2224:2223 338;2226:2225 338;222C:222B 222B;222D:222B 222B 222B;222F:222E 222E;2230:222E 222E 222E;2241:223C 338;2244:2243 338;2247:2245 338;2249:2248 338;2260:3D 338;2262:2261 338;226D:224D 338;226E:3C 338;226F:3E 338;2270:2264 338;2271:2265 338;2274:2272 338;2275:2273 338;2278:2276 338;2279:2277 338;2280:227A 338;2281:227B 338;2284:2282 338;2285:2283 338;2288:2286 338;2289:2287 338;22AC:22A2 338;22AD:22A8 338;22AE:22A9 338;22AF:22AB 338;22E0:227C 338;22E1:227D 338;22E2:2291 338;22E3:2292 338;22EA:22B2 338;22EB:22B3 338;22EC:22B4 338;22ED:22B5 338;2329:3008;232A:3009;2460:31;2461:32;2462:33;2463:34;2464:35;2465:36;2466:37", + "2467:38;2468:39;2469:31 30;246A:31 31;246B:31 32;246C:31 33;246D:31 34;246E:31 35;246F:31 36;2470:31 37;2471:31 38;2472:31 39;2473:32 30;2474:28 31 29;2475:28 32 29;2476:28 33 29;2477:28 34 29;2478:28 35 29;2479:28 36 29;247A:28 37 29;247B:28 38 29;247C:28 39 29;247D:28 31 30 29;247E:28 31 31 29;247F:28 31 32 29;2480:28 31 33 29;2481:28 31 34 29;2482:28 31 35 29;2483:28 31 36 29;2484:28 31 37 29;2485:28 31 38 29;2486:28 31 39 29;2487:28 32 30 29;2488:31 2E;2489:32 2E;248A:33 2E;248B:34 2E;248C:35 2E;248D:36 2E;248E:37 2E;248F:38 2E;2490:39 2E;2491:31 30 2E;2492:31 31 2E;2493:31 32 2E;2494:31 33 2E;2495:31 34 2E;2496:31 35 2E;2497:31 36 2E;2498:31 37 2E;2499:31 38 2E;249A:31 39 2E;249B:32 30 2E;249C:28 61 29;249D:28 62 29;249E:28 63 29;249F:28 64 29;24A0:28 65 29;24A1:28 66 29;24A2:28 67 29;24A3:28 68 29;24A4:28 69 29;24A5:28 6A 29;24A6:28 6B 29;24A7:28 6C 29;24A8:28 6D 29;24A9:28 6E 29;24AA:28 6F 29;24AB:28 70 29;24AC:28 71 29;24AD:28 72 29;24AE:28 73 29;24AF:28 74 29;24B0:28 75 29;24B1:28 76 29;24B2:28 77 29;24B3:28 78 29;24B4:28 79 29;24B5:28 7A 29;24B6:41;24B7:42;24B8:43;24B9:44;24BA:45;24BB:46;24BC:47;24BD:48;24BE:49;24BF:4A;24C0:4B;24C1:4C;24C2:4D;24C3:4E;24C4:4F;24C5:50;24C6:51;24C7:52;24C8:53;24C9:54;24CA:55;24CB:56;24CC:57;24CD:58;24CE:59;24CF:5A;24D0:61;24D1:62;24D2:63;24D3:64;24D4:65;24D5:66;24D6:67;24D7:68;24D8:69;24D9:6A;24DA:6B;24DB:6C;24DC:6D;24DD:6E;24DE:6F;24DF:70;24E0:71;24E1:72;24E2:73;24E3:74;24E4:75;24E5:76;24E6:77;24E7:78;24E8:79;24E9:7A;24EA:30;2A0C:222B 222B 222B 222B;2A74:3A 3A 3D;2A75:3D 3D;2A76:3D 3D 3D;2ADC:2ADD 338;2C7C:6A;2C7D:56;2D6F:2D61;2E9F:6BCD;2EF3:9F9F;2F00:4E00;2F01:4E28;2F02:4E36;2F03:4E3F;2F04:4E59;2F05:4E85;2F06:4E8C;2F07:4EA0;2F08:4EBA;2F09:513F;2F0A:5165;2F0B:516B;2F0C:5182;2F0D:5196;2F0E:51AB;2F0F:51E0;2F10:51F5;2F11:5200;2F12:529B;2F13:52F9;2F14:5315;2F15:531A;2F16:5338;2F17:5341;2F18:535C;2F19:5369;2F1A:5382;2F1B:53B6;2F1C:53C8;2F1D:53E3;2F1E:56D7;2F1F:571F;2F20:58EB;2F21:5902;2F22:590A;2F23:5915;2F24:5927;2F25:5973;2F26:5B50;2F27:5B80;2F28:5BF8;2F29:5C0F;2F2A:5C22;2F2B:5C38;2F2C:5C6E;2F2D:5C71;2F2E:5DDB;2F2F:5DE5;2F30:5DF1;2F31:5DFE;2F32:5E72;2F33:5E7A;2F34:5E7F;2F35:5EF4;2F36:5EFE;2F37:5F0B;2F38:5F13;2F39:5F50;2F3A:5F61;2F3B:5F73;2F3C:5FC3;2F3D:6208;2F3E:6236;2F3F:624B;2F40:652F;2F41:6534;2F42:6587;2F43:6597;2F44:65A4;2F45:65B9;2F46:65E0;2F47:65E5;2F48:66F0;2F49:6708;2F4A:6728;2F4B:6B20;2F4C:6B62;2F4D:6B79;2F4E:6BB3;2F4F:6BCB;2F50:6BD4;2F51:6BDB;2F52:6C0F;2F53:6C14;2F54:6C34;2F55:706B;2F56:722A;2F57:7236;2F58:723B;2F59:723F;2F5A:7247;2F5B:7259;2F5C:725B;2F5D:72AC;2F5E:7384;2F5F:7389;2F60:74DC;2F61:74E6;2F62:7518;2F63:751F;2F64:7528;2F65:7530;2F66:758B;2F67:7592;2F68:7676;2F69:767D;2F6A:76AE;2F6B:76BF;2F6C:76EE;2F6D:77DB;2F6E:77E2;2F6F:77F3;2F70:793A;2F71:79B8;2F72:79BE;2F73:7A74;2F74:7ACB;2F75:7AF9;2F76:7C73;2F77:7CF8;2F78:7F36;2F79:7F51;2F7A:7F8A;2F7B:7FBD;2F7C:8001;2F7D:800C;2F7E:8012;2F7F:8033;2F80:807F;2F81:8089;2F82:81E3;2F83:81EA;2F84:81F3;2F85:81FC;2F86:820C;2F87:821B;2F88:821F;2F89:826E;2F8A:8272;2F8B:8278;2F8C:864D;2F8D:866B;2F8E:8840;2F8F:884C;2F90:8863;2F91:897E;2F92:898B;2F93:89D2;2F94:8A00;2F95:8C37;2F96:8C46;2F97:8C55;2F98:8C78;2F99:8C9D;2F9A:8D64;2F9B:8D70;2F9C:8DB3;2F9D:8EAB;2F9E:8ECA;2F9F:8F9B;2FA0:8FB0;2FA1:8FB5;2FA2:9091;2FA3:9149;2FA4:91C6;2FA5:91CC;2FA6:91D1;2FA7:9577;2FA8:9580;2FA9:961C;2FAA:96B6;2FAB:96B9;2FAC:96E8;2FAD:9751;2FAE:975E;2FAF:9762;2FB0:9769;2FB1:97CB;2FB2:97ED;2FB3:97F3;2FB4:9801;2FB5:98A8;2FB6:98DB;2FB7:98DF;2FB8:9996;2FB9:9999;2FBA:99AC;2FBB:9AA8;2FBC:9AD8;2FBD:9ADF;2FBE:9B25;2FBF:9B2F;2FC0:9B32;2FC1:9B3C;2FC2:9B5A;2FC3:9CE5;2FC4:9E75;2FC5:9E7F;2FC6:9EA5;2FC7:9EBB;2FC8:9EC3;2FC9:9ECD;2FCA:9ED1;2FCB:9EF9;2FCC:9EFD;2FCD:9F0E;2FCE:9F13;2FCF:9F20;2FD0:9F3B;2FD1:9F4A;2FD2:9F52;2FD3:9F8D;2FD4:9F9C;2FD5:9FA0;3000:20;3036:3012;3038:5341;3039:5344;303A:5345;304C:304B 3099;304E:304D 3099;3050:304F 3099;3052:3051 3099;3054:3053 3099;3056:3055 3099;3058:3057 3099;305A:3059 3099;305C:305B 3099;305E:305D 3099;3060:305F 3099;3062:3061 3099;3065:3064 3099;3067:3066 3099;3069:3068 3099;3070:306F 3099;3071:306F 309A;3073:3072 3099;3074:3072 309A;3076:3075 3099;3077:3075 309A;3079:3078 3099;307A:3078 309A;307C:307B 3099;307D:307B 309A;3094:3046 3099;309B:20 3099;309C:20 309A;309E:309D 3099;309F:3088 308A;30AC:30AB 3099;30AE:30AD 3099;30B0:30AF 3099;30B2:30B1 3099;30B4:30B3 3099;30B6:30B5 3099;30B8:30B7 3099;30BA:30B9 3099;30BC:30BB 3099;30BE:30BD 3099;30C0:30BF 3099;30C2:30C1 3099;30C5:30C4 3099;30C7:30C6 3099;30C9:30C8 3099;30D0:30CF 3099;30D1:30CF 309A;30D3:30D2 3099;30D4:30D2 309A;30D6:30D5 3099;30D7:30D5 309A;30D9:30D8 3099;30DA:30D8 309A;30DC:30DB 3099;30DD:30DB 309A;30F4:30A6 3099;30F7:30EF 3099;30F8:30F0 3099;30F9:30F1 3099;30FA:30F2 3099;30FE:30FD 3099;30FF:30B3 30C8;3131:1100;3132:1101;3133:11AA;3134:1102;3135:11AC;3136:11AD;3137:1103;3138:1104;3139:1105;313A:11B0;313B:11B1;313C:11B2;313D:11B3;313E:11B4;313F:11B5;3140:111A;3141:1106;3142:1107;3143:1108;3144:1121;3145:1109;3146:110A;3147:110B;3148:110C;3149:110D;314A:110E;314B:110F;314C:1110;314D:1111;314E:1112;314F:1161;3150:1162;3151:1163;3152:1164;3153:1165;3154:1166;3155:1167;3156:1168;3157:1169;3158:116A;3159:116B;315A:116C;315B:116D;315C:116E;315D:116F;315E:1170;315F:1171;3160:1172;3161:1173;3162:1174;3163:1175;3164:1160;3165:1114;3166:1115;3167:11C7;3168:11C8;3169:11CC;316A:11CE;316B:11D3;316C:11D7;316D:11D9;316E:111C;316F:11DD;3170:11DF;3171:111D;3172:111E;3173:1120;3174:1122;3175:1123;3176:1127;3177:1129;3178:112B;3179:112C;317A:112D;317B:112E;317C:112F;317D:1132;317E:1136;317F:1140;3180:1147;3181:114C;3182:11F1;3183:11F2;3184:1157;3185:1158;3186:1159;3187:1184;3188:1185;3189:1188;318A:1191;318B:1192;318C:1194;318D:119E;318E:11A1;3192:4E00;3193:4E8C;3194:4E09;3195:56DB;3196:4E0A;3197:4E2D;3198:4E0B;3199:7532;319A:4E59;319B:4E19;319C:4E01;319D:5929;319E:5730;319F:4EBA;3200:28 1100 29;3201:28 1102 29;3202:28 1103 29;3203:28 1105 29;3204:28 1106 29;3205:28 1107 29;3206:28 1109 29;3207:28 110B 29;3208:28 110C 29;3209:28 110E 29;320A:28 110F 29;320B:28 1110 29;320C:28 1111 29;320D:28 1112 29;320E:28 1100 1161 29;320F:28 1102 1161 29;3210:28 1103 1161 29;3211:28 1105 1161 29;3212:28 1106 1161 29;3213:28 1107 1161 29;3214:28 1109 1161 29;3215:28 110B 1161 29;3216:28 110C 1161 29;3217:28 110E 1161 29;3218:28 110F 1161 29;3219:28 1110 1161 29;321A:28 1111 1161 29;321B:28 1112 1161 29;321C:28 110C 116E 29;321D:28 110B 1169 110C 1165 11AB 29;321E:28 110B 1169 1112 116E 29;3220:28 4E00 29;3221:28 4E8C 29;3222:28 4E09 29;3223:28 56DB 29;3224:28 4E94 29;3225:28 516D 29;3226:28 4E03 29;3227:28 516B 29;3228:28 4E5D 29;3229:28 5341 29;322A:28 6708 29;322B:28 706B 29;322C:28 6C34 29;322D:28 6728 29;322E:28 91D1 29;322F:28 571F 29;3230:28 65E5 29;3231:28 682A 29;3232:28 6709 29;3233:28 793E 29;3234:28 540D 29;3235:28 7279 29;3236:28 8CA1 29;3237:28 795D 29;3238:28 52B4 29;3239:28 4EE3 29;323A:28 547C 29;323B:28 5B66 29;323C:28 76E3 29;323D:28 4F01 29;323E:28 8CC7 29;323F:28 5354 29;3240:28 796D 29;3241:28 4F11 29;3242:28 81EA 29;3243:28 81F3 29;3244:554F;3245:5E7C;3246:6587;3247:7B8F;3250:50 54 45;3251:32 31;3252:32 32;3253:32 33;3254:32 34;3255:32 35;3256:32 36;3257:32 37;3258:32 38;3259:32 39;325A:33 30;325B:33 31;325C:33 32;325D:33 33;325E:33 34;325F:33 35;3260:1100;3261:1102;3262:1103;3263:1105;3264:1106;3265:1107;3266:1109;3267:110B;3268:110C;3269:110E;326A:110F;326B:1110;326C:1111;326D:1112;326E:1100 1161;326F:1102 1161;3270:1103 1161;3271:1105 1161;3272:1106 1161;3273:1107 1161;3274:1109 1161;3275:110B 1161;3276:110C 1161;3277:110E 1161;3278:110F 1161;3279:1110 1161;327A:1111 1161;327B:1112 1161;327C:110E 1161 11B7 1100 1169;327D:110C 116E 110B 1174;327E:110B 116E;3280:4E00;3281:4E8C;3282:4E09;3283:56DB;3284:4E94;3285:516D;3286:4E03;3287:516B;3288:4E5D;3289:5341;328A:6708;328B:706B;328C:6C34;328D:6728;328E:91D1;328F:571F;3290:65E5;3291:682A;3292:6709;3293:793E;3294:540D;3295:7279;3296:8CA1;3297:795D;3298:52B4;3299:79D8;329A:7537;329B:5973;329C:9069;329D:512A;329E:5370;329F:6CE8;32A0:9805;32A1:4F11;32A2:5199;32A3:6B63;32A4:4E0A;32A5:4E2D;32A6:4E0B;32A7:5DE6;32A8:53F3;32A9:533B;32AA:5B97;32AB:5B66;32AC:76E3;32AD:4F01;32AE:8CC7;32AF:5354;32B0:591C;32B1:33 36;32B2:33 37;32B3:33 38;32B4:33 39;32B5:34 30;32B6:34 31;32B7:34 32;32B8:34 33;32B9:34 34;32BA:34 35;32BB:34 36;32BC:34 37;32BD:34 38;32BE:34 39;32BF:35 30;32C0:31 6708;32C1:32 6708;32C2:33 6708;32C3:34 6708;32C4:35 6708;32C5:36 6708;32C6:37 6708;32C7:38 6708;32C8:39 6708;32C9:31 30 6708;32CA:31 31 6708;32CB:31 32 6708;32CC:48 67;32CD:65 72 67;32CE:65 56;32CF:4C 54 44;32D0:30A2;32D1:30A4;32D2:30A6;32D3:30A8;32D4:30AA;32D5:30AB;32D6:30AD;32D7:30AF;32D8:30B1;32D9:30B3;32DA:30B5;32DB:30B7;32DC:30B9;32DD:30BB;32DE:30BD;32DF:30BF;32E0:30C1;32E1:30C4;32E2:30C6;32E3:30C8;32E4:30CA;32E5:30CB;32E6:30CC;32E7:30CD;32E8:30CE;32E9:30CF;32EA:30D2;32EB:30D5;32EC:30D8;32ED:30DB;32EE:30DE;32EF:30DF;32F0:30E0;32F1:30E1;32F2:30E2;32F3:30E4;32F4:30E6;32F5:30E8;32F6:30E9;32F7:30EA;32F8:30EB;32F9:30EC;32FA:30ED;32FB:30EF;32FC:30F0;32FD:30F1;32FE:30F2;32FF:4EE4 548C;3300:30A2 30CF 309A 30FC 30C8;3301:30A2 30EB 30D5 30A1;3302:30A2 30F3 30D8 309A 30A2;3303:30A2 30FC 30EB;3304:30A4 30CB 30F3 30AF 3099;3305:30A4 30F3 30C1;3306:30A6 30A9 30F3;3307:30A8 30B9 30AF 30FC 30C8 3099;3308:30A8 30FC 30AB 30FC;3309:30AA 30F3 30B9;330A:30AA 30FC 30E0;330B:30AB 30A4 30EA;330C:30AB 30E9 30C3 30C8;330D:30AB 30ED 30EA 30FC;330E:30AB 3099 30ED 30F3;330F:30AB 3099 30F3 30DE;3310:30AD 3099 30AB 3099;3311:30AD 3099 30CB 30FC;3312:30AD 30E5 30EA 30FC;3313:30AD 3099 30EB 30BF 3099 30FC;3314:30AD 30ED;3315:30AD 30ED 30AF 3099 30E9 30E0;3316:30AD 30ED 30E1 30FC 30C8 30EB;3317:30AD 30ED 30EF 30C3 30C8;3318:30AF 3099 30E9 30E0;3319:30AF 3099 30E9 30E0 30C8 30F3;331A:30AF 30EB 30BB 3099 30A4 30ED;331B:30AF 30ED 30FC 30CD;331C:30B1 30FC 30B9;331D:30B3 30EB 30CA;331E:30B3 30FC 30DB 309A;331F:30B5 30A4 30AF 30EB;3320:30B5 30F3 30C1 30FC 30E0;3321:30B7 30EA 30F3 30AF 3099;3322:30BB 30F3 30C1;3323:30BB 30F3 30C8;3324:30BF 3099 30FC 30B9;3325:30C6 3099 30B7;3326:30C8 3099 30EB;3327:30C8 30F3;3328:30CA 30CE;3329:30CE 30C3 30C8;332A:30CF 30A4 30C4;332B:30CF 309A 30FC 30BB 30F3 30C8;332C:30CF 309A 30FC 30C4;332D:30CF 3099 30FC 30EC 30EB;332E:30D2 309A 30A2 30B9 30C8 30EB;332F:30D2 309A 30AF 30EB;3330:30D2 309A 30B3;3331:30D2 3099 30EB;3332:30D5 30A1 30E9 30C3 30C8 3099;3333:30D5 30A3 30FC 30C8;3334:30D5 3099 30C3 30B7 30A7 30EB;3335:30D5 30E9 30F3;3336:30D8 30AF 30BF 30FC 30EB;3337:30D8 309A 30BD;3338:30D8 309A 30CB 30D2;3339:30D8 30EB 30C4;333A:30D8 309A 30F3 30B9;333B:30D8 309A 30FC 30B7 3099;333C:30D8 3099 30FC 30BF;333D:30DB 309A 30A4 30F3 30C8;333E:30DB 3099 30EB 30C8;333F:30DB 30F3;3340:30DB 309A 30F3 30C8 3099;3341:30DB 30FC 30EB;3342:30DB 30FC 30F3;3343:30DE 30A4 30AF 30ED;3344:30DE 30A4 30EB;3345:30DE 30C3 30CF;3346:30DE 30EB 30AF;3347:30DE 30F3 30B7 30E7 30F3;3348:30DF 30AF 30ED 30F3;3349:30DF 30EA;334A:30DF 30EA 30CF 3099 30FC 30EB;334B:30E1 30AB 3099;334C:30E1 30AB 3099 30C8 30F3;334D:30E1 30FC 30C8 30EB;334E:30E4 30FC 30C8 3099;334F:30E4 30FC 30EB;3350:30E6 30A2 30F3;3351:30EA 30C3 30C8 30EB;3352:30EA 30E9;3353:30EB 30D2 309A 30FC;3354:30EB 30FC 30D5 3099 30EB;3355:30EC 30E0;3356:30EC 30F3 30C8 30B1 3099 30F3;3357:30EF 30C3 30C8;3358:30 70B9;3359:31 70B9;335A:32 70B9;335B:33 70B9;335C:34 70B9;335D:35 70B9;335E:36 70B9;335F:37 70B9;3360:38 70B9;3361:39 70B9;3362:31 30 70B9;3363:31 31 70B9;3364:31 32 70B9;3365:31 33 70B9;3366:31 34 70B9;3367:31 35 70B9;3368:31 36 70B9;3369:31 37 70B9;336A:31 38 70B9;336B:31 39 70B9;336C:32 30 70B9;336D:32 31 70B9;336E:32 32 70B9;336F:32 33 70B9;3370:32 34 70B9;3371:68 50 61;3372:64 61;3373:41 55;3374:62 61 72;3375:6F 56;3376:70 63;3377:64 6D;3378:64 6D 32;3379:64 6D 33;337A:49 55;337B:5E73 6210;337C:662D 548C;337D:5927 6B63;337E:660E 6CBB;337F:682A 5F0F 4F1A 793E;3380:70 41;3381:6E 41;3382:3BC 41;3383:6D 41;3384:6B 41;3385:4B 42;3386:4D 42;3387:47 42;3388:63 61 6C;3389:6B 63 61 6C;338A:70 46;338B:6E 46;338C:3BC 46;338D:3BC 67;338E:6D 67;338F:6B 67;3390:48 7A;3391:6B 48 7A;3392:4D 48 7A;3393:47 48 7A;3394:54 48 7A;3395:3BC 6C;3396:6D 6C;3397:64 6C;3398:6B 6C;3399:66 6D;339A:6E 6D;339B:3BC 6D;339C:6D 6D;339D:63 6D;339E:6B 6D;339F:6D 6D 32;33A0:63 6D 32;33A1:6D 32;33A2:6B 6D 32;33A3:6D 6D 33;33A4:63 6D 33;33A5:6D 33;33A6:6B 6D 33;33A7:6D 2215 73;33A8:6D 2215 73 32;33A9:50 61;33AA:6B 50 61;33AB:4D 50 61;33AC:47 50 61;33AD:72 61 64;33AE:72 61 64 2215 73;33AF:72 61 64 2215 73 32;33B0:70 73;33B1:6E 73;33B2:3BC 73;33B3:6D 73;33B4:70 56;33B5:6E 56;33B6:3BC 56;33B7:6D 56;33B8:6B 56;33B9:4D 56;33BA:70 57;33BB:6E 57;33BC:3BC 57;33BD:6D 57;33BE:6B 57;33BF:4D 57;33C0:6B 3A9;33C1:4D 3A9;33C2:61 2E 6D 2E;33C3:42 71;33C4:63 63;33C5:63 64;33C6:43 2215 6B 67;33C7:43 6F 2E;33C8:64 42;33C9:47 79;33CA:68 61;33CB:48 50;33CC:69 6E;33CD:4B 4B;33CE:4B 4D;33CF:6B 74;33D0:6C 6D;33D1:6C 6E;33D2:6C 6F 67;33D3:6C 78;33D4:6D 62;33D5:6D 69 6C;33D6:6D 6F 6C;33D7:50 48;33D8:70 2E 6D 2E;33D9:50 50 4D;33DA:50 52;33DB:73 72;33DC:53 76;33DD:57 62;33DE:56 2215 6D;33DF:41 2215 6D;33E0:31 65E5;33E1:32 65E5;33E2:33 65E5;33E3:34 65E5;33E4:35 65E5;33E5:36 65E5;33E6:37 65E5;33E7:38 65E5;33E8:39 65E5;33E9:31 30 65E5;33EA:31 31 65E5;33EB:31 32 65E5;33EC:31 33 65E5;33ED:31 34 65E5;33EE:31 35 65E5;33EF:31 36 65E5;33F0:31 37 65E5;33F1:31 38 65E5;33F2:31 39 65E5;33F3:32 30 65E5;33F4:32 31 65E5;33F5:32 32 65E5;33F6:32 33 65E5;33F7:32 34 65E5;33F8:32 35 65E5;33F9:32 36 65E5;33FA:32 37 65E5;33FB:32 38 65E5;33FC:32 39 65E5;33FD:33 30 65E5;33FE:33 31 65E5;33FF:67 61 6C;A69C:44A;A69D:44C;A770:A76F;A7F1:53;A7F2:43;A7F3:46;A7F4:51;A7F8:126;A7F9:153;AB5C:A727;AB5D:AB37;AB5E:26B;AB5F:AB52;AB69:28D;F900:8C48;F901:66F4;F902:8ECA;F903:8CC8;F904:6ED1;F905:4E32;F906:53E5;F907:9F9C;F908:9F9C;F909:5951;F90A:91D1;F90B:5587;F90C:5948;F90D:61F6;F90E:7669;F90F:7F85;F910:863F;F911:87BA;F912:88F8;F913:908F;F914:6A02;F915:6D1B;F916:70D9;F917:73DE;F918:843D;F919:916A;F91A:99F1;F91B:4E82;F91C:5375;F91D:6B04;F91E:721B;F91F:862D;F920:9E1E;F921:5D50;F922:6FEB;F923:85CD;F924:8964;F925:62C9;F926:81D8;F927:881F;F928:5ECA;F929:6717;F92A:6D6A;F92B:72FC;F92C:90CE;F92D:4F86;F92E:51B7;F92F:52DE;F930:64C4;F931:6AD3;F932:7210;F933:76E7;F934:8001;F935:8606;F936:865C;F937:8DEF;F938:9732;F939:9B6F;F93A:9DFA;F93B:788C;F93C:797F;F93D:7DA0;F93E:83C9;F93F:9304;F940:9E7F;F941:8AD6;F942:58DF;F943:5F04;F944:7C60;F945:807E;F946:7262;F947:78CA;F948:8CC2;F949:96F7;F94A:58D8;F94B:5C62;F94C:6A13;F94D:6DDA;F94E:6F0F;F94F:7D2F;F950:7E37;F951:964B;F952:52D2;F953:808B;F954:51DC;F955:51CC;F956:7A1C;F957:7DBE;F958:83F1;F959:9675;F95A:8B80;F95B:62CF;F95C:6A02;F95D:8AFE;F95E:4E39;F95F:5BE7;F960:6012;F961:7387;F962:7570;F963:5317;F964:78FB;F965:4FBF;F966:5FA9;F967:4E0D;F968:6CCC;F969:6578;F96A:7D22;F96B:53C3;F96C:585E;F96D:7701;F96E:8449;F96F:8AAA;F970:6BBA;F971:8FB0;F972:6C88;F973:62FE;F974:82E5;F975:63A0;F976:7565;F977:4EAE;F978:5169;F979:51C9;F97A:6881;F97B:7CE7;F97C:826F;F97D:8AD2;F97E:91CF;F97F:52F5;F980:5442;F981:5973;F982:5EEC;F983:65C5;F984:6FFE;F985:792A;F986:95AD;F987:9A6A;F988:9E97;F989:9ECE;F98A:529B;F98B:66C6;F98C:6B77;F98D:8F62;F98E:5E74;F98F:6190;F990:6200;F991:649A;F992:6F23;F993:7149;F994:7489;F995:79CA;F996:7DF4;F997:806F;F998:8F26;F999:84EE;F99A:9023;F99B:934A;F99C:5217;F99D:52A3;F99E:54BD;F99F:70C8;F9A0:88C2;F9A1:8AAA;F9A2:5EC9;F9A3:5FF5;F9A4:637B;F9A5:6BAE;F9A6:7C3E;F9A7:7375;F9A8:4EE4;F9A9:56F9;F9AA:5BE7;F9AB:5DBA;F9AC:601C;F9AD:73B2;F9AE:7469;F9AF:7F9A;F9B0:8046;F9B1:9234;F9B2:96F6;F9B3:9748;F9B4:9818;F9B5:4F8B;F9B6:79AE;F9B7:91B4;F9B8:96B8;F9B9:60E1;F9BA:4E86;F9BB:50DA;F9BC:5BEE;F9BD:5C3F;F9BE:6599;F9BF:6A02;F9C0:71CE;F9C1:7642;F9C2:84FC;F9C3:907C;F9C4:9F8D;F9C5:6688;F9C6:962E;F9C7:5289;F9C8:677B;F9C9:67F3;F9CA:6D41;F9CB:6E9C;F9CC:7409;F9CD:7559;F9CE:786B;F9CF:7D10;F9D0:985E;F9D1:516D;F9D2:622E;F9D3:9678;F9D4:502B;F9D5:5D19;F9D6:6DEA;F9D7:8F2A;F9D8:5F8B;F9D9:6144;F9DA:6817;F9DB:7387;F9DC:9686;F9DD:5229;F9DE:540F;F9DF:5C65;F9E0:6613;F9E1:674E;F9E2:68A8;F9E3:6CE5;F9E4:7406;F9E5:75E2;F9E6:7F79;F9E7:88CF;F9E8:88E1;F9E9:91CC;F9EA:96E2;F9EB:533F;F9EC:6EBA;F9ED:541D;F9EE:71D0;F9EF:7498;F9F0:85FA;F9F1:96A3;F9F2:9C57;F9F3:9E9F;F9F4:6797", + "F9F5:6DCB;F9F6:81E8;F9F7:7ACB;F9F8:7B20;F9F9:7C92;F9FA:72C0;F9FB:7099;F9FC:8B58;F9FD:4EC0;F9FE:8336;F9FF:523A;FA00:5207;FA01:5EA6;FA02:62D3;FA03:7CD6;FA04:5B85;FA05:6D1E;FA06:66B4;FA07:8F3B;FA08:884C;FA09:964D;FA0A:898B;FA0B:5ED3;FA0C:5140;FA0D:55C0;FA10:585A;FA12:6674;FA15:51DE;FA16:732A;FA17:76CA;FA18:793C;FA19:795E;FA1A:7965;FA1B:798F;FA1C:9756;FA1D:7CBE;FA1E:7FBD;FA20:8612;FA22:8AF8;FA25:9038;FA26:90FD;FA2A:98EF;FA2B:98FC;FA2C:9928;FA2D:9DB4;FA2E:90DE;FA2F:96B7;FA30:4FAE;FA31:50E7;FA32:514D;FA33:52C9;FA34:52E4;FA35:5351;FA36:559D;FA37:5606;FA38:5668;FA39:5840;FA3A:58A8;FA3B:5C64;FA3C:5C6E;FA3D:6094;FA3E:6168;FA3F:618E;FA40:61F2;FA41:654F;FA42:65E2;FA43:6691;FA44:6885;FA45:6D77;FA46:6E1A;FA47:6F22;FA48:716E;FA49:722B;FA4A:7422;FA4B:7891;FA4C:793E;FA4D:7949;FA4E:7948;FA4F:7950;FA50:7956;FA51:795D;FA52:798D;FA53:798E;FA54:7A40;FA55:7A81;FA56:7BC0;FA57:7DF4;FA58:7E09;FA59:7E41;FA5A:7F72;FA5B:8005;FA5C:81ED;FA5D:8279;FA5E:8279;FA5F:8457;FA60:8910;FA61:8996;FA62:8B01;FA63:8B39;FA64:8CD3;FA65:8D08;FA66:8FB6;FA67:9038;FA68:96E3;FA69:97FF;FA6A:983B;FA6B:6075;FA6C:242EE;FA6D:8218;FA70:4E26;FA71:51B5;FA72:5168;FA73:4F80;FA74:5145;FA75:5180;FA76:52C7;FA77:52FA;FA78:559D;FA79:5555;FA7A:5599;FA7B:55E2;FA7C:585A;FA7D:58B3;FA7E:5944;FA7F:5954;FA80:5A62;FA81:5B28;FA82:5ED2;FA83:5ED9;FA84:5F69;FA85:5FAD;FA86:60D8;FA87:614E;FA88:6108;FA89:618E;FA8A:6160;FA8B:61F2;FA8C:6234;FA8D:63C4;FA8E:641C;FA8F:6452;FA90:6556;FA91:6674;FA92:6717;FA93:671B;FA94:6756;FA95:6B79;FA96:6BBA;FA97:6D41;FA98:6EDB;FA99:6ECB;FA9A:6F22;FA9B:701E;FA9C:716E;FA9D:77A7;FA9E:7235;FA9F:72AF;FAA0:732A;FAA1:7471;FAA2:7506;FAA3:753B;FAA4:761D;FAA5:761F;FAA6:76CA;FAA7:76DB;FAA8:76F4;FAA9:774A;FAAA:7740;FAAB:78CC;FAAC:7AB1;FAAD:7BC0;FAAE:7C7B;FAAF:7D5B;FAB0:7DF4;FAB1:7F3E;FAB2:8005;FAB3:8352;FAB4:83EF;FAB5:8779;FAB6:8941;FAB7:8986;FAB8:8996;FAB9:8ABF;FABA:8AF8;FABB:8ACB;FABC:8B01;FABD:8AFE;FABE:8AED;FABF:8B39;FAC0:8B8A;FAC1:8D08;FAC2:8F38;FAC3:9072;FAC4:9199;FAC5:9276;FAC6:967C;FAC7:96E3;FAC8:9756;FAC9:97DB;FACA:97FF;FACB:980B;FACC:983B;FACD:9B12;FACE:9F9C;FACF:2284A;FAD0:22844;FAD1:233D5;FAD2:3B9D;FAD3:4018;FAD4:4039;FAD5:25249;FAD6:25CD0;FAD7:27ED3;FAD8:9F43;FAD9:9F8E;FB00:66 66;FB01:66 69;FB02:66 6C;FB03:66 66 69;FB04:66 66 6C;FB05:73 74;FB06:73 74;FB13:574 576;FB14:574 565;FB15:574 56B;FB16:57E 576;FB17:574 56D;FB1D:5D9 5B4;FB1F:5F2 5B7;FB20:5E2;FB21:5D0;FB22:5D3;FB23:5D4;FB24:5DB;FB25:5DC;FB26:5DD;FB27:5E8;FB28:5EA;FB29:2B;FB2A:5E9 5C1;FB2B:5E9 5C2;FB2C:5E9 5BC 5C1;FB2D:5E9 5BC 5C2;FB2E:5D0 5B7;FB2F:5D0 5B8;FB30:5D0 5BC;FB31:5D1 5BC;FB32:5D2 5BC;FB33:5D3 5BC;FB34:5D4 5BC;FB35:5D5 5BC;FB36:5D6 5BC;FB38:5D8 5BC;FB39:5D9 5BC;FB3A:5DA 5BC;FB3B:5DB 5BC;FB3C:5DC 5BC;FB3E:5DE 5BC;FB40:5E0 5BC;FB41:5E1 5BC;FB43:5E3 5BC;FB44:5E4 5BC;FB46:5E6 5BC;FB47:5E7 5BC;FB48:5E8 5BC;FB49:5E9 5BC;FB4A:5EA 5BC;FB4B:5D5 5B9;FB4C:5D1 5BF;FB4D:5DB 5BF;FB4E:5E4 5BF;FB4F:5D0 5DC;FB50:671;FB51:671;FB52:67B;FB53:67B;FB54:67B;FB55:67B;FB56:67E;FB57:67E;FB58:67E;FB59:67E;FB5A:680;FB5B:680;FB5C:680;FB5D:680;FB5E:67A;FB5F:67A;FB60:67A;FB61:67A;FB62:67F;FB63:67F;FB64:67F;FB65:67F;FB66:679;FB67:679;FB68:679;FB69:679;FB6A:6A4;FB6B:6A4;FB6C:6A4;FB6D:6A4;FB6E:6A6;FB6F:6A6;FB70:6A6;FB71:6A6;FB72:684;FB73:684;FB74:684;FB75:684;FB76:683;FB77:683;FB78:683;FB79:683;FB7A:686;FB7B:686;FB7C:686;FB7D:686;FB7E:687;FB7F:687;FB80:687;FB81:687;FB82:68D;FB83:68D;FB84:68C;FB85:68C;FB86:68E;FB87:68E;FB88:688;FB89:688;FB8A:698;FB8B:698;FB8C:691;FB8D:691;FB8E:6A9;FB8F:6A9;FB90:6A9;FB91:6A9;FB92:6AF;FB93:6AF;FB94:6AF;FB95:6AF;FB96:6B3;FB97:6B3;FB98:6B3;FB99:6B3;FB9A:6B1;FB9B:6B1;FB9C:6B1;FB9D:6B1;FB9E:6BA;FB9F:6BA;FBA0:6BB;FBA1:6BB;FBA2:6BB;FBA3:6BB;FBA4:6D5 654;FBA5:6D5 654;FBA6:6C1;FBA7:6C1;FBA8:6C1;FBA9:6C1;FBAA:6BE;FBAB:6BE;FBAC:6BE;FBAD:6BE;FBAE:6D2;FBAF:6D2;FBB0:6D2 654;FBB1:6D2 654;FBD3:6AD;FBD4:6AD;FBD5:6AD;FBD6:6AD;FBD7:6C7;FBD8:6C7;FBD9:6C6;FBDA:6C6;FBDB:6C8;FBDC:6C8;FBDD:6C7 674;FBDE:6CB;FBDF:6CB;FBE0:6C5;FBE1:6C5;FBE2:6C9;FBE3:6C9;FBE4:6D0;FBE5:6D0;FBE6:6D0;FBE7:6D0;FBE8:649;FBE9:649;FBEA:64A 654 627;FBEB:64A 654 627;FBEC:64A 654 6D5;FBED:64A 654 6D5;FBEE:64A 654 648;FBEF:64A 654 648;FBF0:64A 654 6C7;FBF1:64A 654 6C7;FBF2:64A 654 6C6;FBF3:64A 654 6C6;FBF4:64A 654 6C8;FBF5:64A 654 6C8;FBF6:64A 654 6D0;FBF7:64A 654 6D0;FBF8:64A 654 6D0;FBF9:64A 654 649;FBFA:64A 654 649;FBFB:64A 654 649;FBFC:6CC;FBFD:6CC;FBFE:6CC;FBFF:6CC;FC00:64A 654 62C;FC01:64A 654 62D;FC02:64A 654 645;FC03:64A 654 649;FC04:64A 654 64A;FC05:628 62C;FC06:628 62D;FC07:628 62E;FC08:628 645;FC09:628 649;FC0A:628 64A;FC0B:62A 62C;FC0C:62A 62D;FC0D:62A 62E;FC0E:62A 645;FC0F:62A 649;FC10:62A 64A;FC11:62B 62C;FC12:62B 645;FC13:62B 649;FC14:62B 64A;FC15:62C 62D;FC16:62C 645;FC17:62D 62C;FC18:62D 645;FC19:62E 62C;FC1A:62E 62D;FC1B:62E 645;FC1C:633 62C;FC1D:633 62D;FC1E:633 62E;FC1F:633 645;FC20:635 62D;FC21:635 645;FC22:636 62C;FC23:636 62D;FC24:636 62E;FC25:636 645;FC26:637 62D;FC27:637 645;FC28:638 645;FC29:639 62C;FC2A:639 645;FC2B:63A 62C;FC2C:63A 645;FC2D:641 62C;FC2E:641 62D;FC2F:641 62E;FC30:641 645;FC31:641 649;FC32:641 64A;FC33:642 62D;FC34:642 645;FC35:642 649;FC36:642 64A;FC37:643 627;FC38:643 62C;FC39:643 62D;FC3A:643 62E;FC3B:643 644;FC3C:643 645;FC3D:643 649;FC3E:643 64A;FC3F:644 62C;FC40:644 62D;FC41:644 62E;FC42:644 645;FC43:644 649;FC44:644 64A;FC45:645 62C;FC46:645 62D;FC47:645 62E;FC48:645 645;FC49:645 649;FC4A:645 64A;FC4B:646 62C;FC4C:646 62D;FC4D:646 62E;FC4E:646 645;FC4F:646 649;FC50:646 64A;FC51:647 62C;FC52:647 645;FC53:647 649;FC54:647 64A;FC55:64A 62C;FC56:64A 62D;FC57:64A 62E;FC58:64A 645;FC59:64A 649;FC5A:64A 64A;FC5B:630 670;FC5C:631 670;FC5D:649 670;FC5E:20 64C 651;FC5F:20 64D 651;FC60:20 64E 651;FC61:20 64F 651;FC62:20 650 651;FC63:20 651 670;FC64:64A 654 631;FC65:64A 654 632;FC66:64A 654 645;FC67:64A 654 646;FC68:64A 654 649;FC69:64A 654 64A;FC6A:628 631;FC6B:628 632;FC6C:628 645;FC6D:628 646;FC6E:628 649;FC6F:628 64A;FC70:62A 631;FC71:62A 632;FC72:62A 645;FC73:62A 646;FC74:62A 649;FC75:62A 64A;FC76:62B 631;FC77:62B 632;FC78:62B 645;FC79:62B 646;FC7A:62B 649;FC7B:62B 64A;FC7C:641 649;FC7D:641 64A;FC7E:642 649;FC7F:642 64A;FC80:643 627;FC81:643 644;FC82:643 645;FC83:643 649;FC84:643 64A;FC85:644 645;FC86:644 649;FC87:644 64A;FC88:645 627;FC89:645 645;FC8A:646 631;FC8B:646 632;FC8C:646 645;FC8D:646 646;FC8E:646 649;FC8F:646 64A;FC90:649 670;FC91:64A 631;FC92:64A 632;FC93:64A 645;FC94:64A 646;FC95:64A 649;FC96:64A 64A;FC97:64A 654 62C;FC98:64A 654 62D;FC99:64A 654 62E;FC9A:64A 654 645;FC9B:64A 654 647;FC9C:628 62C;FC9D:628 62D;FC9E:628 62E;FC9F:628 645;FCA0:628 647;FCA1:62A 62C;FCA2:62A 62D;FCA3:62A 62E;FCA4:62A 645;FCA5:62A 647;FCA6:62B 645;FCA7:62C 62D;FCA8:62C 645;FCA9:62D 62C;FCAA:62D 645;FCAB:62E 62C;FCAC:62E 645;FCAD:633 62C;FCAE:633 62D;FCAF:633 62E;FCB0:633 645;FCB1:635 62D;FCB2:635 62E;FCB3:635 645;FCB4:636 62C;FCB5:636 62D;FCB6:636 62E;FCB7:636 645;FCB8:637 62D;FCB9:638 645;FCBA:639 62C;FCBB:639 645;FCBC:63A 62C;FCBD:63A 645;FCBE:641 62C;FCBF:641 62D;FCC0:641 62E;FCC1:641 645;FCC2:642 62D;FCC3:642 645;FCC4:643 62C;FCC5:643 62D;FCC6:643 62E;FCC7:643 644;FCC8:643 645;FCC9:644 62C;FCCA:644 62D;FCCB:644 62E;FCCC:644 645;FCCD:644 647;FCCE:645 62C;FCCF:645 62D;FCD0:645 62E;FCD1:645 645;FCD2:646 62C;FCD3:646 62D;FCD4:646 62E;FCD5:646 645;FCD6:646 647;FCD7:647 62C;FCD8:647 645;FCD9:647 670;FCDA:64A 62C;FCDB:64A 62D;FCDC:64A 62E;FCDD:64A 645;FCDE:64A 647;FCDF:64A 654 645;FCE0:64A 654 647;FCE1:628 645;FCE2:628 647;FCE3:62A 645;FCE4:62A 647;FCE5:62B 645;FCE6:62B 647;FCE7:633 645;FCE8:633 647;FCE9:634 645;FCEA:634 647;FCEB:643 644;FCEC:643 645;FCED:644 645;FCEE:646 645;FCEF:646 647;FCF0:64A 645;FCF1:64A 647;FCF2:640 64E 651;FCF3:640 64F 651;FCF4:640 650 651;FCF5:637 649;FCF6:637 64A;FCF7:639 649;FCF8:639 64A;FCF9:63A 649;FCFA:63A 64A;FCFB:633 649;FCFC:633 64A;FCFD:634 649;FCFE:634 64A;FCFF:62D 649;FD00:62D 64A;FD01:62C 649;FD02:62C 64A;FD03:62E 649;FD04:62E 64A;FD05:635 649;FD06:635 64A;FD07:636 649;FD08:636 64A;FD09:634 62C;FD0A:634 62D;FD0B:634 62E;FD0C:634 645;FD0D:634 631;FD0E:633 631;FD0F:635 631;FD10:636 631;FD11:637 649;FD12:637 64A;FD13:639 649;FD14:639 64A;FD15:63A 649;FD16:63A 64A;FD17:633 649;FD18:633 64A;FD19:634 649;FD1A:634 64A;FD1B:62D 649;FD1C:62D 64A;FD1D:62C 649;FD1E:62C 64A;FD1F:62E 649;FD20:62E 64A;FD21:635 649;FD22:635 64A;FD23:636 649;FD24:636 64A;FD25:634 62C;FD26:634 62D;FD27:634 62E;FD28:634 645;FD29:634 631;FD2A:633 631;FD2B:635 631;FD2C:636 631;FD2D:634 62C;FD2E:634 62D;FD2F:634 62E;FD30:634 645;FD31:633 647;FD32:634 647;FD33:637 645;FD34:633 62C;FD35:633 62D;FD36:633 62E;FD37:634 62C;FD38:634 62D;FD39:634 62E;FD3A:637 645;FD3B:638 645;FD3C:627 64B;FD3D:627 64B;FD50:62A 62C 645;FD51:62A 62D 62C;FD52:62A 62D 62C;FD53:62A 62D 645;FD54:62A 62E 645;FD55:62A 645 62C;FD56:62A 645 62D;FD57:62A 645 62E;FD58:62C 645 62D;FD59:62C 645 62D;FD5A:62D 645 64A;FD5B:62D 645 649;FD5C:633 62D 62C;FD5D:633 62C 62D;FD5E:633 62C 649;FD5F:633 645 62D;FD60:633 645 62D;FD61:633 645 62C;FD62:633 645 645;FD63:633 645 645;FD64:635 62D 62D;FD65:635 62D 62D;FD66:635 645 645;FD67:634 62D 645;FD68:634 62D 645;FD69:634 62C 64A;FD6A:634 645 62E;FD6B:634 645 62E;FD6C:634 645 645;FD6D:634 645 645;FD6E:636 62D 649;FD6F:636 62E 645;FD70:636 62E 645;FD71:637 645 62D;FD72:637 645 62D;FD73:637 645 645;FD74:637 645 64A;FD75:639 62C 645;FD76:639 645 645;FD77:639 645 645;FD78:639 645 649;FD79:63A 645 645;FD7A:63A 645 64A;FD7B:63A 645 649;FD7C:641 62E 645;FD7D:641 62E 645;FD7E:642 645 62D;FD7F:642 645 645;FD80:644 62D 645;FD81:644 62D 64A;FD82:644 62D 649;FD83:644 62C 62C;FD84:644 62C 62C;FD85:644 62E 645;FD86:644 62E 645;FD87:644 645 62D;FD88:644 645 62D;FD89:645 62D 62C;FD8A:645 62D 645;FD8B:645 62D 64A;FD8C:645 62C 62D;FD8D:645 62C 645;FD8E:645 62E 62C;FD8F:645 62E 645;FD92:645 62C 62E;FD93:647 645 62C;FD94:647 645 645;FD95:646 62D 645;FD96:646 62D 649;FD97:646 62C 645;FD98:646 62C 645;FD99:646 62C 649;FD9A:646 645 64A;FD9B:646 645 649;FD9C:64A 645 645;FD9D:64A 645 645;FD9E:628 62E 64A;FD9F:62A 62C 64A;FDA0:62A 62C 649;FDA1:62A 62E 64A;FDA2:62A 62E 649;FDA3:62A 645 64A;FDA4:62A 645 649;FDA5:62C 645 64A;FDA6:62C 62D 649;FDA7:62C 645 649;FDA8:633 62E 649;FDA9:635 62D 64A;FDAA:634 62D 64A;FDAB:636 62D 64A;FDAC:644 62C 64A;FDAD:644 645 64A;FDAE:64A 62D 64A;FDAF:64A 62C 64A;FDB0:64A 645 64A;FDB1:645 645 64A;FDB2:642 645 64A;FDB3:646 62D 64A;FDB4:642 645 62D;FDB5:644 62D 645;FDB6:639 645 64A;FDB7:643 645 64A;FDB8:646 62C 62D;FDB9:645 62E 64A;FDBA:644 62C 645;FDBB:643 645 645;FDBC:644 62C 645;FDBD:646 62C 62D;FDBE:62C 62D 64A;FDBF:62D 62C 64A;FDC0:645 62C 64A;FDC1:641 645 64A;FDC2:628 62D 64A;FDC3:643 645 645;FDC4:639 62C 645;FDC5:635 645 645;FDC6:633 62E 64A;FDC7:646 62C 64A;FDF0:635 644 6D2;FDF1:642 644 6D2;FDF2:627 644 644 647;FDF3:627 643 628 631;FDF4:645 62D 645 62F;FDF5:635 644 639 645;FDF6:631 633 648 644;FDF7:639 644 64A 647;FDF8:648 633 644 645;FDF9:635 644 649;FDFA:635 644 649 20 627 644 644 647 20 639 644 64A 647 20 648 633 644 645;FDFB:62C 644 20 62C 644 627 644 647;FDFC:631 6CC 627 644;FE10:2C;FE11:3001;FE12:3002;FE13:3A;FE14:3B;FE15:21;FE16:3F;FE17:3016;FE18:3017;FE19:2E 2E 2E;FE30:2E 2E;FE31:2014;FE32:2013;FE33:5F;FE34:5F;FE35:28;FE36:29;FE37:7B;FE38:7D;FE39:3014;FE3A:3015;FE3B:3010;FE3C:3011;FE3D:300A;FE3E:300B;FE3F:3008;FE40:3009;FE41:300C;FE42:300D;FE43:300E;FE44:300F;FE47:5B;FE48:5D;FE49:20 305;FE4A:20 305;FE4B:20 305;FE4C:20 305;FE4D:5F;FE4E:5F;FE4F:5F;FE50:2C;FE51:3001;FE52:2E;FE54:3B;FE55:3A;FE56:3F;FE57:21;FE58:2014;FE59:28;FE5A:29;FE5B:7B;FE5C:7D;FE5D:3014;FE5E:3015;FE5F:23;FE60:26;FE61:2A;FE62:2B;FE63:2D;FE64:3C;FE65:3E;FE66:3D;FE68:5C;FE69:24;FE6A:25;FE6B:40;FE70:20 64B;FE71:640 64B;FE72:20 64C;FE74:20 64D;FE76:20 64E;FE77:640 64E;FE78:20 64F;FE79:640 64F;FE7A:20 650;FE7B:640 650;FE7C:20 651;FE7D:640 651;FE7E:20 652;FE7F:640 652;FE80:621;FE81:627 653;FE82:627 653;FE83:627 654;FE84:627 654;FE85:648 654;FE86:648 654;FE87:627 655;FE88:627 655;FE89:64A 654;FE8A:64A 654;FE8B:64A 654;FE8C:64A 654;FE8D:627;FE8E:627;FE8F:628;FE90:628;FE91:628;FE92:628;FE93:629;FE94:629;FE95:62A;FE96:62A;FE97:62A;FE98:62A;FE99:62B;FE9A:62B;FE9B:62B;FE9C:62B;FE9D:62C;FE9E:62C;FE9F:62C;FEA0:62C;FEA1:62D;FEA2:62D;FEA3:62D;FEA4:62D;FEA5:62E;FEA6:62E;FEA7:62E;FEA8:62E;FEA9:62F;FEAA:62F;FEAB:630;FEAC:630;FEAD:631;FEAE:631;FEAF:632;FEB0:632;FEB1:633;FEB2:633;FEB3:633;FEB4:633;FEB5:634;FEB6:634;FEB7:634;FEB8:634;FEB9:635;FEBA:635;FEBB:635;FEBC:635;FEBD:636;FEBE:636;FEBF:636;FEC0:636;FEC1:637;FEC2:637;FEC3:637;FEC4:637;FEC5:638;FEC6:638;FEC7:638;FEC8:638;FEC9:639;FECA:639;FECB:639;FECC:639;FECD:63A;FECE:63A;FECF:63A;FED0:63A;FED1:641;FED2:641;FED3:641;FED4:641;FED5:642;FED6:642;FED7:642;FED8:642;FED9:643;FEDA:643;FEDB:643;FEDC:643;FEDD:644;FEDE:644;FEDF:644;FEE0:644;FEE1:645;FEE2:645;FEE3:645;FEE4:645;FEE5:646;FEE6:646;FEE7:646;FEE8:646;FEE9:647;FEEA:647;FEEB:647;FEEC:647;FEED:648;FEEE:648;FEEF:649;FEF0:649;FEF1:64A;FEF2:64A;FEF3:64A;FEF4:64A;FEF5:644 627 653;FEF6:644 627 653;FEF7:644 627 654;FEF8:644 627 654;FEF9:644 627 655;FEFA:644 627 655;FEFB:644 627;FEFC:644 627;FF01:21;FF02:22;FF03:23;FF04:24;FF05:25;FF06:26;FF07:27;FF08:28;FF09:29;FF0A:2A;FF0B:2B;FF0C:2C;FF0D:2D;FF0E:2E;FF0F:2F;FF10:30;FF11:31;FF12:32;FF13:33;FF14:34;FF15:35;FF16:36;FF17:37;FF18:38;FF19:39;FF1A:3A;FF1B:3B;FF1C:3C;FF1D:3D;FF1E:3E;FF1F:3F;FF20:40;FF21:41;FF22:42;FF23:43;FF24:44;FF25:45;FF26:46;FF27:47;FF28:48;FF29:49;FF2A:4A;FF2B:4B;FF2C:4C;FF2D:4D;FF2E:4E;FF2F:4F;FF30:50;FF31:51;FF32:52;FF33:53;FF34:54;FF35:55;FF36:56;FF37:57;FF38:58;FF39:59;FF3A:5A;FF3B:5B;FF3C:5C;FF3D:5D;FF3E:5E;FF3F:5F;FF40:60;FF41:61;FF42:62;FF43:63;FF44:64;FF45:65;FF46:66;FF47:67;FF48:68;FF49:69;FF4A:6A;FF4B:6B;FF4C:6C;FF4D:6D;FF4E:6E;FF4F:6F;FF50:70;FF51:71;FF52:72;FF53:73;FF54:74;FF55:75;FF56:76;FF57:77;FF58:78;FF59:79;FF5A:7A;FF5B:7B;FF5C:7C;FF5D:7D;FF5E:7E;FF5F:2985;FF60:2986;FF61:3002;FF62:300C;FF63:300D;FF64:3001;FF65:30FB;FF66:30F2;FF67:30A1;FF68:30A3;FF69:30A5;FF6A:30A7;FF6B:30A9;FF6C:30E3;FF6D:30E5;FF6E:30E7;FF6F:30C3;FF70:30FC;FF71:30A2;FF72:30A4;FF73:30A6;FF74:30A8;FF75:30AA;FF76:30AB;FF77:30AD;FF78:30AF;FF79:30B1;FF7A:30B3;FF7B:30B5;FF7C:30B7;FF7D:30B9;FF7E:30BB;FF7F:30BD;FF80:30BF;FF81:30C1;FF82:30C4;FF83:30C6;FF84:30C8;FF85:30CA;FF86:30CB;FF87:30CC;FF88:30CD;FF89:30CE;FF8A:30CF;FF8B:30D2;FF8C:30D5;FF8D:30D8;FF8E:30DB;FF8F:30DE;FF90:30DF;FF91:30E0;FF92:30E1;FF93:30E2;FF94:30E4;FF95:30E6;FF96:30E8;FF97:30E9;FF98:30EA;FF99:30EB;FF9A:30EC;FF9B:30ED;FF9C:30EF;FF9D:30F3;FF9E:3099;FF9F:309A;FFA0:1160;FFA1:1100;FFA2:1101;FFA3:11AA;FFA4:1102;FFA5:11AC;FFA6:11AD;FFA7:1103;FFA8:1104;FFA9:1105;FFAA:11B0;FFAB:11B1;FFAC:11B2;FFAD:11B3;FFAE:11B4;FFAF:11B5;FFB0:111A;FFB1:1106;FFB2:1107;FFB3:1108;FFB4:1121;FFB5:1109;FFB6:110A;FFB7:110B;FFB8:110C;FFB9:110D;FFBA:110E;FFBB:110F;FFBC:1110;FFBD:1111;FFBE:1112;FFC2:1161;FFC3:1162;FFC4:1163;FFC5:1164;FFC6:1165;FFC7:1166;FFCA:1167;FFCB:1168;FFCC:1169;FFCD:116A;FFCE:116B;FFCF:116C;FFD2:116D;FFD3:116E;FFD4:116F;FFD5:1170;FFD6:1171;FFD7:1172;FFDA:1173;FFDB:1174;FFDC:1175;FFE0:A2;FFE1:A3;FFE2:AC;FFE3:20 304;FFE4:A6;FFE5:A5;FFE6:20A9;FFE8:2502;FFE9:2190;FFEA:2191;FFEB:2192;FFEC:2193;FFED:25A0;FFEE:25CB;105C9:105D2 307;105E4:105DA 307;10781:2D0;10782:2D1;10783:E6;10784:299;10785:253;10787:2A3;10788:AB66;10789:2A5;1078A:2A4;1078B:256;1078C:257;1078D:1D91;1078E:258;1078F:25E;10790:2A9;10791:264;10792:262;10793:260;10794:29B;10795:127;10796:29C;10797:267;10798:284;10799:2AA;1079A:2AB;1079B:26C;1079C:1DF04;1079D:A78E;1079E:26E;1079F:1DF05;107A0:28E;107A1:1DF06;107A2:F8;107A3:276;107A4:277;107A5:71;107A6:27A;107A7:1DF08;107A8:27D;107A9:27E;107AA:280;107AB:2A8;107AC:2A6;107AD:AB67;107AE:2A7;107AF:288;107B0:2C71;107B2:28F;107B3:2A1;107B4:2A2;107B5:298;107B6:1C0;107B7:1C1;107B8:1C2;107B9:1DF0A;107BA:1DF1E;1109A:11099 110BA;1109C:1109B 110BA;110AB:110A5 110BA;1112E:11131 11127;1112F:11132 11127;1134B:11347 1133E;1134C:11347 11357;11383:11382 113C9;11385:11384 113BB;1138E:1138B 113C2;11391:11390 113C9;113C5:113C2 113C2;113C7:113C2 113B8;113C8:113C2 113C9;114BB:114B9 114BA;114BC:114B9 114B0;114BE:114B9 114BD;115BA:115B8 115AF;115BB:115B9 115AF;11938:11935 11930;16121:1611E 1611E;16122:1611E 16129;16123:1611E 1611F;16124:16129 1611F;16125:1611E 16120", + "16126:1611E 1611E 1611F;16127:1611E 16129 1611F;16128:1611E 1611E 16120;16D68:16D67 16D67;16D69:16D63 16D67;16D6A:16D63 16D67 16D67;1CCD6:41;1CCD7:42;1CCD8:43;1CCD9:44;1CCDA:45;1CCDB:46;1CCDC:47;1CCDD:48;1CCDE:49;1CCDF:4A;1CCE0:4B;1CCE1:4C;1CCE2:4D;1CCE3:4E;1CCE4:4F;1CCE5:50;1CCE6:51;1CCE7:52;1CCE8:53;1CCE9:54;1CCEA:55;1CCEB:56;1CCEC:57;1CCED:58;1CCEE:59;1CCEF:5A;1CCF0:30;1CCF1:31;1CCF2:32;1CCF3:33;1CCF4:34;1CCF5:35;1CCF6:36;1CCF7:37;1CCF8:38;1CCF9:39;1D15E:1D157 1D165;1D15F:1D158 1D165;1D160:1D158 1D165 1D16E;1D161:1D158 1D165 1D16F;1D162:1D158 1D165 1D170;1D163:1D158 1D165 1D171;1D164:1D158 1D165 1D172;1D1BB:1D1B9 1D165;1D1BC:1D1BA 1D165;1D1BD:1D1B9 1D165 1D16E;1D1BE:1D1BA 1D165 1D16E;1D1BF:1D1B9 1D165 1D16F;1D1C0:1D1BA 1D165 1D16F;1D400:41;1D401:42;1D402:43;1D403:44;1D404:45;1D405:46;1D406:47;1D407:48;1D408:49;1D409:4A;1D40A:4B;1D40B:4C;1D40C:4D;1D40D:4E;1D40E:4F;1D40F:50;1D410:51;1D411:52;1D412:53;1D413:54;1D414:55;1D415:56;1D416:57;1D417:58;1D418:59;1D419:5A;1D41A:61;1D41B:62;1D41C:63;1D41D:64;1D41E:65;1D41F:66;1D420:67;1D421:68;1D422:69;1D423:6A;1D424:6B;1D425:6C;1D426:6D;1D427:6E;1D428:6F;1D429:70;1D42A:71;1D42B:72;1D42C:73;1D42D:74;1D42E:75;1D42F:76;1D430:77;1D431:78;1D432:79;1D433:7A;1D434:41;1D435:42;1D436:43;1D437:44;1D438:45;1D439:46;1D43A:47;1D43B:48;1D43C:49;1D43D:4A;1D43E:4B;1D43F:4C;1D440:4D;1D441:4E;1D442:4F;1D443:50;1D444:51;1D445:52;1D446:53;1D447:54;1D448:55;1D449:56;1D44A:57;1D44B:58;1D44C:59;1D44D:5A;1D44E:61;1D44F:62;1D450:63;1D451:64;1D452:65;1D453:66;1D454:67;1D456:69;1D457:6A;1D458:6B;1D459:6C;1D45A:6D;1D45B:6E;1D45C:6F;1D45D:70;1D45E:71;1D45F:72;1D460:73;1D461:74;1D462:75;1D463:76;1D464:77;1D465:78;1D466:79;1D467:7A;1D468:41;1D469:42;1D46A:43;1D46B:44;1D46C:45;1D46D:46;1D46E:47;1D46F:48;1D470:49;1D471:4A;1D472:4B;1D473:4C;1D474:4D;1D475:4E;1D476:4F;1D477:50;1D478:51;1D479:52;1D47A:53;1D47B:54;1D47C:55;1D47D:56;1D47E:57;1D47F:58;1D480:59;1D481:5A;1D482:61;1D483:62;1D484:63;1D485:64;1D486:65;1D487:66;1D488:67;1D489:68;1D48A:69;1D48B:6A;1D48C:6B;1D48D:6C;1D48E:6D;1D48F:6E;1D490:6F;1D491:70;1D492:71;1D493:72;1D494:73;1D495:74;1D496:75;1D497:76;1D498:77;1D499:78;1D49A:79;1D49B:7A;1D49C:41;1D49E:43;1D49F:44;1D4A2:47;1D4A5:4A;1D4A6:4B;1D4A9:4E;1D4AA:4F;1D4AB:50;1D4AC:51;1D4AE:53;1D4AF:54;1D4B0:55;1D4B1:56;1D4B2:57;1D4B3:58;1D4B4:59;1D4B5:5A;1D4B6:61;1D4B7:62;1D4B8:63;1D4B9:64;1D4BB:66;1D4BD:68;1D4BE:69;1D4BF:6A;1D4C0:6B;1D4C1:6C;1D4C2:6D;1D4C3:6E;1D4C5:70;1D4C6:71;1D4C7:72;1D4C8:73;1D4C9:74;1D4CA:75;1D4CB:76;1D4CC:77;1D4CD:78;1D4CE:79;1D4CF:7A;1D4D0:41;1D4D1:42;1D4D2:43;1D4D3:44;1D4D4:45;1D4D5:46;1D4D6:47;1D4D7:48;1D4D8:49;1D4D9:4A;1D4DA:4B;1D4DB:4C;1D4DC:4D;1D4DD:4E;1D4DE:4F;1D4DF:50;1D4E0:51;1D4E1:52;1D4E2:53;1D4E3:54;1D4E4:55;1D4E5:56;1D4E6:57;1D4E7:58;1D4E8:59;1D4E9:5A;1D4EA:61;1D4EB:62;1D4EC:63;1D4ED:64;1D4EE:65;1D4EF:66;1D4F0:67;1D4F1:68;1D4F2:69;1D4F3:6A;1D4F4:6B;1D4F5:6C;1D4F6:6D;1D4F7:6E;1D4F8:6F;1D4F9:70;1D4FA:71;1D4FB:72;1D4FC:73;1D4FD:74;1D4FE:75;1D4FF:76;1D500:77;1D501:78;1D502:79;1D503:7A;1D504:41;1D505:42;1D507:44;1D508:45;1D509:46;1D50A:47;1D50D:4A;1D50E:4B;1D50F:4C;1D510:4D;1D511:4E;1D512:4F;1D513:50;1D514:51;1D516:53;1D517:54;1D518:55;1D519:56;1D51A:57;1D51B:58;1D51C:59;1D51E:61;1D51F:62;1D520:63;1D521:64;1D522:65;1D523:66;1D524:67;1D525:68;1D526:69;1D527:6A;1D528:6B;1D529:6C;1D52A:6D;1D52B:6E;1D52C:6F;1D52D:70;1D52E:71;1D52F:72;1D530:73;1D531:74;1D532:75;1D533:76;1D534:77;1D535:78;1D536:79;1D537:7A;1D538:41;1D539:42;1D53B:44;1D53C:45;1D53D:46;1D53E:47;1D540:49;1D541:4A;1D542:4B;1D543:4C;1D544:4D;1D546:4F;1D54A:53;1D54B:54;1D54C:55;1D54D:56;1D54E:57;1D54F:58;1D550:59;1D552:61;1D553:62;1D554:63;1D555:64;1D556:65;1D557:66;1D558:67;1D559:68;1D55A:69;1D55B:6A;1D55C:6B;1D55D:6C;1D55E:6D;1D55F:6E;1D560:6F;1D561:70;1D562:71;1D563:72;1D564:73;1D565:74;1D566:75;1D567:76;1D568:77;1D569:78;1D56A:79;1D56B:7A;1D56C:41;1D56D:42;1D56E:43;1D56F:44;1D570:45;1D571:46;1D572:47;1D573:48;1D574:49;1D575:4A;1D576:4B;1D577:4C;1D578:4D;1D579:4E;1D57A:4F;1D57B:50;1D57C:51;1D57D:52;1D57E:53;1D57F:54;1D580:55;1D581:56;1D582:57;1D583:58;1D584:59;1D585:5A;1D586:61;1D587:62;1D588:63;1D589:64;1D58A:65;1D58B:66;1D58C:67;1D58D:68;1D58E:69;1D58F:6A;1D590:6B;1D591:6C;1D592:6D;1D593:6E;1D594:6F;1D595:70;1D596:71;1D597:72;1D598:73;1D599:74;1D59A:75;1D59B:76;1D59C:77;1D59D:78;1D59E:79;1D59F:7A;1D5A0:41;1D5A1:42;1D5A2:43;1D5A3:44;1D5A4:45;1D5A5:46;1D5A6:47;1D5A7:48;1D5A8:49;1D5A9:4A;1D5AA:4B;1D5AB:4C;1D5AC:4D;1D5AD:4E;1D5AE:4F;1D5AF:50;1D5B0:51;1D5B1:52;1D5B2:53;1D5B3:54;1D5B4:55;1D5B5:56;1D5B6:57;1D5B7:58;1D5B8:59;1D5B9:5A;1D5BA:61;1D5BB:62;1D5BC:63;1D5BD:64;1D5BE:65;1D5BF:66;1D5C0:67;1D5C1:68;1D5C2:69;1D5C3:6A;1D5C4:6B;1D5C5:6C;1D5C6:6D;1D5C7:6E;1D5C8:6F;1D5C9:70;1D5CA:71;1D5CB:72;1D5CC:73;1D5CD:74;1D5CE:75;1D5CF:76;1D5D0:77;1D5D1:78;1D5D2:79;1D5D3:7A;1D5D4:41;1D5D5:42;1D5D6:43;1D5D7:44;1D5D8:45;1D5D9:46;1D5DA:47;1D5DB:48;1D5DC:49;1D5DD:4A;1D5DE:4B;1D5DF:4C;1D5E0:4D;1D5E1:4E;1D5E2:4F;1D5E3:50;1D5E4:51;1D5E5:52;1D5E6:53;1D5E7:54;1D5E8:55;1D5E9:56;1D5EA:57;1D5EB:58;1D5EC:59;1D5ED:5A;1D5EE:61;1D5EF:62;1D5F0:63;1D5F1:64;1D5F2:65;1D5F3:66;1D5F4:67;1D5F5:68;1D5F6:69;1D5F7:6A;1D5F8:6B;1D5F9:6C;1D5FA:6D;1D5FB:6E;1D5FC:6F;1D5FD:70;1D5FE:71;1D5FF:72;1D600:73;1D601:74;1D602:75;1D603:76;1D604:77;1D605:78;1D606:79;1D607:7A;1D608:41;1D609:42;1D60A:43;1D60B:44;1D60C:45;1D60D:46;1D60E:47;1D60F:48;1D610:49;1D611:4A;1D612:4B;1D613:4C;1D614:4D;1D615:4E;1D616:4F;1D617:50;1D618:51;1D619:52;1D61A:53;1D61B:54;1D61C:55;1D61D:56;1D61E:57;1D61F:58;1D620:59;1D621:5A;1D622:61;1D623:62;1D624:63;1D625:64;1D626:65;1D627:66;1D628:67;1D629:68;1D62A:69;1D62B:6A;1D62C:6B;1D62D:6C;1D62E:6D;1D62F:6E;1D630:6F;1D631:70;1D632:71;1D633:72;1D634:73;1D635:74;1D636:75;1D637:76;1D638:77;1D639:78;1D63A:79;1D63B:7A;1D63C:41;1D63D:42;1D63E:43;1D63F:44;1D640:45;1D641:46;1D642:47;1D643:48;1D644:49;1D645:4A;1D646:4B;1D647:4C;1D648:4D;1D649:4E;1D64A:4F;1D64B:50;1D64C:51;1D64D:52;1D64E:53;1D64F:54;1D650:55;1D651:56;1D652:57;1D653:58;1D654:59;1D655:5A;1D656:61;1D657:62;1D658:63;1D659:64;1D65A:65;1D65B:66;1D65C:67;1D65D:68;1D65E:69;1D65F:6A;1D660:6B;1D661:6C;1D662:6D;1D663:6E;1D664:6F;1D665:70;1D666:71;1D667:72;1D668:73;1D669:74;1D66A:75;1D66B:76;1D66C:77;1D66D:78;1D66E:79;1D66F:7A;1D670:41;1D671:42;1D672:43;1D673:44;1D674:45;1D675:46;1D676:47;1D677:48;1D678:49;1D679:4A;1D67A:4B;1D67B:4C;1D67C:4D;1D67D:4E;1D67E:4F;1D67F:50;1D680:51;1D681:52;1D682:53;1D683:54;1D684:55;1D685:56;1D686:57;1D687:58;1D688:59;1D689:5A;1D68A:61;1D68B:62;1D68C:63;1D68D:64;1D68E:65;1D68F:66;1D690:67;1D691:68;1D692:69;1D693:6A;1D694:6B;1D695:6C;1D696:6D;1D697:6E;1D698:6F;1D699:70;1D69A:71;1D69B:72;1D69C:73;1D69D:74;1D69E:75;1D69F:76;1D6A0:77;1D6A1:78;1D6A2:79;1D6A3:7A;1D6A4:131;1D6A5:237;1D6A8:391;1D6A9:392;1D6AA:393;1D6AB:394;1D6AC:395;1D6AD:396;1D6AE:397;1D6AF:398;1D6B0:399;1D6B1:39A;1D6B2:39B;1D6B3:39C;1D6B4:39D;1D6B5:39E;1D6B6:39F;1D6B7:3A0;1D6B8:3A1;1D6B9:398;1D6BA:3A3;1D6BB:3A4;1D6BC:3A5;1D6BD:3A6;1D6BE:3A7;1D6BF:3A8;1D6C0:3A9;1D6C1:2207;1D6C2:3B1;1D6C3:3B2;1D6C4:3B3;1D6C5:3B4;1D6C6:3B5;1D6C7:3B6;1D6C8:3B7;1D6C9:3B8;1D6CA:3B9;1D6CB:3BA;1D6CC:3BB;1D6CD:3BC;1D6CE:3BD;1D6CF:3BE;1D6D0:3BF;1D6D1:3C0;1D6D2:3C1;1D6D3:3C2;1D6D4:3C3;1D6D5:3C4;1D6D6:3C5;1D6D7:3C6;1D6D8:3C7;1D6D9:3C8;1D6DA:3C9;1D6DB:2202;1D6DC:3B5;1D6DD:3B8;1D6DE:3BA;1D6DF:3C6;1D6E0:3C1;1D6E1:3C0;1D6E2:391;1D6E3:392;1D6E4:393;1D6E5:394;1D6E6:395;1D6E7:396;1D6E8:397;1D6E9:398;1D6EA:399;1D6EB:39A;1D6EC:39B;1D6ED:39C;1D6EE:39D;1D6EF:39E;1D6F0:39F;1D6F1:3A0;1D6F2:3A1;1D6F3:398;1D6F4:3A3;1D6F5:3A4;1D6F6:3A5;1D6F7:3A6;1D6F8:3A7;1D6F9:3A8;1D6FA:3A9;1D6FB:2207;1D6FC:3B1;1D6FD:3B2;1D6FE:3B3;1D6FF:3B4;1D700:3B5;1D701:3B6;1D702:3B7;1D703:3B8;1D704:3B9;1D705:3BA;1D706:3BB;1D707:3BC;1D708:3BD;1D709:3BE;1D70A:3BF;1D70B:3C0;1D70C:3C1;1D70D:3C2;1D70E:3C3;1D70F:3C4;1D710:3C5;1D711:3C6;1D712:3C7;1D713:3C8;1D714:3C9;1D715:2202;1D716:3B5;1D717:3B8;1D718:3BA;1D719:3C6;1D71A:3C1;1D71B:3C0;1D71C:391;1D71D:392;1D71E:393;1D71F:394;1D720:395;1D721:396;1D722:397;1D723:398;1D724:399;1D725:39A;1D726:39B;1D727:39C;1D728:39D;1D729:39E;1D72A:39F;1D72B:3A0;1D72C:3A1;1D72D:398;1D72E:3A3;1D72F:3A4;1D730:3A5;1D731:3A6;1D732:3A7;1D733:3A8;1D734:3A9;1D735:2207;1D736:3B1;1D737:3B2;1D738:3B3;1D739:3B4;1D73A:3B5;1D73B:3B6;1D73C:3B7;1D73D:3B8;1D73E:3B9;1D73F:3BA;1D740:3BB;1D741:3BC;1D742:3BD;1D743:3BE;1D744:3BF;1D745:3C0;1D746:3C1;1D747:3C2;1D748:3C3;1D749:3C4;1D74A:3C5;1D74B:3C6;1D74C:3C7;1D74D:3C8;1D74E:3C9;1D74F:2202;1D750:3B5;1D751:3B8;1D752:3BA;1D753:3C6;1D754:3C1;1D755:3C0;1D756:391;1D757:392;1D758:393;1D759:394;1D75A:395;1D75B:396;1D75C:397;1D75D:398;1D75E:399;1D75F:39A;1D760:39B;1D761:39C;1D762:39D;1D763:39E;1D764:39F;1D765:3A0;1D766:3A1;1D767:398;1D768:3A3;1D769:3A4;1D76A:3A5;1D76B:3A6;1D76C:3A7;1D76D:3A8;1D76E:3A9;1D76F:2207;1D770:3B1;1D771:3B2;1D772:3B3;1D773:3B4;1D774:3B5;1D775:3B6;1D776:3B7;1D777:3B8;1D778:3B9;1D779:3BA;1D77A:3BB;1D77B:3BC;1D77C:3BD;1D77D:3BE;1D77E:3BF;1D77F:3C0;1D780:3C1;1D781:3C2;1D782:3C3;1D783:3C4;1D784:3C5;1D785:3C6;1D786:3C7;1D787:3C8;1D788:3C9;1D789:2202;1D78A:3B5;1D78B:3B8;1D78C:3BA;1D78D:3C6;1D78E:3C1;1D78F:3C0;1D790:391;1D791:392;1D792:393;1D793:394;1D794:395;1D795:396;1D796:397;1D797:398;1D798:399;1D799:39A;1D79A:39B;1D79B:39C;1D79C:39D;1D79D:39E;1D79E:39F;1D79F:3A0;1D7A0:3A1;1D7A1:398;1D7A2:3A3;1D7A3:3A4;1D7A4:3A5;1D7A5:3A6;1D7A6:3A7;1D7A7:3A8;1D7A8:3A9;1D7A9:2207;1D7AA:3B1;1D7AB:3B2;1D7AC:3B3;1D7AD:3B4;1D7AE:3B5;1D7AF:3B6;1D7B0:3B7;1D7B1:3B8;1D7B2:3B9;1D7B3:3BA;1D7B4:3BB;1D7B5:3BC;1D7B6:3BD;1D7B7:3BE;1D7B8:3BF;1D7B9:3C0;1D7BA:3C1;1D7BB:3C2;1D7BC:3C3;1D7BD:3C4;1D7BE:3C5;1D7BF:3C6;1D7C0:3C7;1D7C1:3C8;1D7C2:3C9;1D7C3:2202;1D7C4:3B5;1D7C5:3B8;1D7C6:3BA;1D7C7:3C6;1D7C8:3C1;1D7C9:3C0;1D7CA:3DC;1D7CB:3DD;1D7CE:30;1D7CF:31;1D7D0:32;1D7D1:33;1D7D2:34;1D7D3:35;1D7D4:36;1D7D5:37;1D7D6:38;1D7D7:39;1D7D8:30;1D7D9:31;1D7DA:32;1D7DB:33;1D7DC:34;1D7DD:35;1D7DE:36;1D7DF:37;1D7E0:38;1D7E1:39;1D7E2:30;1D7E3:31;1D7E4:32;1D7E5:33;1D7E6:34;1D7E7:35;1D7E8:36;1D7E9:37;1D7EA:38;1D7EB:39;1D7EC:30;1D7ED:31;1D7EE:32;1D7EF:33;1D7F0:34;1D7F1:35;1D7F2:36;1D7F3:37;1D7F4:38;1D7F5:39;1D7F6:30;1D7F7:31;1D7F8:32;1D7F9:33;1D7FA:34;1D7FB:35;1D7FC:36;1D7FD:37;1D7FE:38;1D7FF:39;1E030:430;1E031:431;1E032:432;1E033:433;1E034:434;1E035:435;1E036:436;1E037:437;1E038:438;1E039:43A;1E03A:43B;1E03B:43C;1E03C:43E;1E03D:43F;1E03E:440;1E03F:441;1E040:442;1E041:443;1E042:444;1E043:445;1E044:446;1E045:447;1E046:448;1E047:44B;1E048:44D;1E049:44E;1E04A:A689;1E04B:4D9;1E04C:456;1E04D:458;1E04E:4E9;1E04F:4AF;1E050:4CF;1E051:430;1E052:431;1E053:432;1E054:433;1E055:434;1E056:435;1E057:436;1E058:437;1E059:438;1E05A:43A;1E05B:43B;1E05C:43E;1E05D:43F;1E05E:441;1E05F:443;1E060:444;1E061:445;1E062:446;1E063:447;1E064:448;1E065:44A;1E066:44B;1E067:491;1E068:456;1E069:455;1E06A:45F;1E06B:4AB;1E06C:A651;1E06D:4B1;1EE00:627;1EE01:628;1EE02:62C;1EE03:62F;1EE05:648;1EE06:632;1EE07:62D;1EE08:637;1EE09:64A;1EE0A:643;1EE0B:644;1EE0C:645;1EE0D:646;1EE0E:633;1EE0F:639;1EE10:641;1EE11:635;1EE12:642;1EE13:631;1EE14:634;1EE15:62A;1EE16:62B;1EE17:62E;1EE18:630;1EE19:636;1EE1A:638;1EE1B:63A;1EE1C:66E;1EE1D:6BA;1EE1E:6A1;1EE1F:66F;1EE21:628;1EE22:62C;1EE24:647;1EE27:62D;1EE29:64A;1EE2A:643;1EE2B:644;1EE2C:645;1EE2D:646;1EE2E:633;1EE2F:639;1EE30:641;1EE31:635;1EE32:642;1EE34:634;1EE35:62A;1EE36:62B;1EE37:62E;1EE39:636;1EE3B:63A;1EE42:62C;1EE47:62D;1EE49:64A;1EE4B:644;1EE4D:646;1EE4E:633;1EE4F:639;1EE51:635;1EE52:642;1EE54:634;1EE57:62E;1EE59:636;1EE5B:63A;1EE5D:6BA;1EE5F:66F;1EE61:628;1EE62:62C;1EE64:647;1EE67:62D;1EE68:637;1EE69:64A;1EE6A:643;1EE6C:645;1EE6D:646;1EE6E:633;1EE6F:639;1EE70:641;1EE71:635;1EE72:642;1EE74:634;1EE75:62A;1EE76:62B;1EE77:62E;1EE79:636;1EE7A:638;1EE7B:63A;1EE7C:66E;1EE7E:6A1;1EE80:627;1EE81:628;1EE82:62C;1EE83:62F;1EE84:647;1EE85:648;1EE86:632;1EE87:62D;1EE88:637;1EE89:64A;1EE8B:644;1EE8C:645;1EE8D:646;1EE8E:633;1EE8F:639;1EE90:641;1EE91:635;1EE92:642;1EE93:631;1EE94:634;1EE95:62A;1EE96:62B;1EE97:62E;1EE98:630;1EE99:636;1EE9A:638;1EE9B:63A;1EEA1:628;1EEA2:62C;1EEA3:62F;1EEA5:648;1EEA6:632;1EEA7:62D;1EEA8:637;1EEA9:64A;1EEAB:644;1EEAC:645;1EEAD:646;1EEAE:633;1EEAF:639;1EEB0:641;1EEB1:635;1EEB2:642;1EEB3:631;1EEB4:634;1EEB5:62A;1EEB6:62B;1EEB7:62E;1EEB8:630;1EEB9:636;1EEBA:638;1EEBB:63A;1F100:30 2E;1F101:30 2C;1F102:31 2C;1F103:32 2C;1F104:33 2C;1F105:34 2C;1F106:35 2C;1F107:36 2C;1F108:37 2C;1F109:38 2C;1F10A:39 2C;1F110:28 41 29;1F111:28 42 29;1F112:28 43 29;1F113:28 44 29;1F114:28 45 29;1F115:28 46 29;1F116:28 47 29;1F117:28 48 29;1F118:28 49 29;1F119:28 4A 29;1F11A:28 4B 29;1F11B:28 4C 29;1F11C:28 4D 29;1F11D:28 4E 29;1F11E:28 4F 29;1F11F:28 50 29;1F120:28 51 29;1F121:28 52 29;1F122:28 53 29;1F123:28 54 29;1F124:28 55 29;1F125:28 56 29;1F126:28 57 29;1F127:28 58 29;1F128:28 59 29;1F129:28 5A 29;1F12A:3014 53 3015;1F12B:43;1F12C:52;1F12D:43 44;1F12E:57 5A;1F130:41;1F131:42;1F132:43;1F133:44;1F134:45;1F135:46;1F136:47;1F137:48;1F138:49;1F139:4A;1F13A:4B;1F13B:4C;1F13C:4D;1F13D:4E;1F13E:4F;1F13F:50;1F140:51;1F141:52;1F142:53;1F143:54;1F144:55;1F145:56;1F146:57;1F147:58;1F148:59;1F149:5A;1F14A:48 56;1F14B:4D 56;1F14C:53 44;1F14D:53 53;1F14E:50 50 56;1F14F:57 43;1F16A:4D 43;1F16B:4D 44;1F16C:4D 52;1F190:44 4A;1F200:307B 304B;1F201:30B3 30B3;1F202:30B5;1F210:624B;1F211:5B57;1F212:53CC;1F213:30C6 3099;1F214:4E8C;1F215:591A;1F216:89E3;1F217:5929;1F218:4EA4;1F219:6620;1F21A:7121;1F21B:6599;1F21C:524D;1F21D:5F8C;1F21E:518D;1F21F:65B0;1F220:521D;1F221:7D42;1F222:751F;1F223:8CA9;1F224:58F0;1F225:5439;1F226:6F14;1F227:6295;1F228:6355;1F229:4E00;1F22A:4E09;1F22B:904A;1F22C:5DE6;1F22D:4E2D;1F22E:53F3;1F22F:6307;1F230:8D70;1F231:6253;1F232:7981;1F233:7A7A;1F234:5408;1F235:6E80;1F236:6709;1F237:6708;1F238:7533;1F239:5272;1F23A:55B6;1F23B:914D;1F240:3014 672C 3015;1F241:3014 4E09 3015;1F242:3014 4E8C 3015;1F243:3014 5B89 3015;1F244:3014 70B9 3015;1F245:3014 6253 3015;1F246:3014 76D7 3015;1F247:3014 52DD 3015;1F248:3014 6557 3015;1F250:5F97;1F251:53EF;1FBF0:30;1FBF1:31;1FBF2:32;1FBF3:33;1FBF4:34;1FBF5:35;1FBF6:36;1FBF7:37;1FBF8:38;1FBF9:39;2F800:4E3D;2F801:4E38;2F802:4E41;2F803:20122;2F804:4F60;2F805:4FAE;2F806:4FBB;2F807:5002;2F808:507A;2F809:5099;2F80A:50E7;2F80B:50CF;2F80C:349E;2F80D:2063A;2F80E:514D;2F80F:5154;2F810:5164;2F811:5177;2F812:2051C;2F813:34B9;2F814:5167;2F815:518D;2F816:2054B;2F817:5197;2F818:51A4;2F819:4ECC;2F81A:51AC;2F81B:51B5;2F81C:291DF;2F81D:51F5;2F81E:5203;2F81F:34DF;2F820:523B;2F821:5246;2F822:5272;2F823:5277;2F824:3515;2F825:52C7;2F826:52C9;2F827:52E4;2F828:52FA;2F829:5305;2F82A:5306;2F82B:5317;2F82C:5349;2F82D:5351;2F82E:535A;2F82F:5373;2F830:537D;2F831:537F;2F832:537F;2F833:537F;2F834:20A2C;2F835:7070;2F836:53CA;2F837:53DF;2F838:20B63;2F839:53EB;2F83A:53F1;2F83B:5406;2F83C:549E;2F83D:5438;2F83E:5448;2F83F:5468;2F840:54A2;2F841:54F6;2F842:5510;2F843:5553;2F844:5563;2F845:5584;2F846:5584;2F847:5599;2F848:55AB;2F849:55B3;2F84A:55C2;2F84B:5716;2F84C:5606;2F84D:5717;2F84E:5651;2F84F:5674;2F850:5207;2F851:58EE;2F852:57CE;2F853:57F4;2F854:580D;2F855:578B;2F856:5832;2F857:5831;2F858:58AC;2F859:214E4;2F85A:58F2;2F85B:58F7;2F85C:5906;2F85D:591A;2F85E:5922;2F85F:5962;2F860:216A8;2F861:216EA;2F862:59EC;2F863:5A1B;2F864:5A27;2F865:59D8;2F866:5A66;2F867:36EE;2F868:36FC;2F869:5B08;2F86A:5B3E;2F86B:5B3E;2F86C:219C8;2F86D:5BC3;2F86E:5BD8;2F86F:5BE7;2F870:5BF3;2F871:21B18;2F872:5BFF;2F873:5C06;2F874:5F53;2F875:5C22;2F876:3781;2F877:5C60;2F878:5C6E;2F879:5CC0;2F87A:5C8D;2F87B:21DE4;2F87C:5D43;2F87D:21DE6;2F87E:5D6E;2F87F:5D6B;2F880:5D7C;2F881:5DE1;2F882:5DE2;2F883:382F;2F884:5DFD;2F885:5E28;2F886:5E3D;2F887:5E69;2F888:3862;2F889:22183;2F88A:387C;2F88B:5EB0;2F88C:5EB3;2F88D:5EB6;2F88E:5ECA;2F88F:2A392;2F890:5EFE;2F891:22331;2F892:22331;2F893:8201;2F894:5F22;2F895:5F22;2F896:38C7;2F897:232B8;2F898:261DA;2F899:5F62;2F89A:5F6B;2F89B:38E3;2F89C:5F9A;2F89D:5FCD;2F89E:5FD7;2F89F:5FF9;2F8A0:6081;2F8A1:393A;2F8A2:391C;2F8A3:6094;2F8A4:226D4;2F8A5:60C7;2F8A6:6148;2F8A7:614C;2F8A8:614E;2F8A9:614C;2F8AA:617A;2F8AB:618E;2F8AC:61B2;2F8AD:61A4;2F8AE:61AF;2F8AF:61DE;2F8B0:61F2;2F8B1:61F6;2F8B2:6210;2F8B3:621B;2F8B4:625D;2F8B5:62B1;2F8B6:62D4;2F8B7:6350;2F8B8:22B0C;2F8B9:633D;2F8BA:62FC;2F8BB:6368;2F8BC:6383;2F8BD:63E4;2F8BE:22BF1;2F8BF:6422;2F8C0:63C5;2F8C1:63A9;2F8C2:3A2E;2F8C3:6469", + "2F8C4:647E;2F8C5:649D;2F8C6:6477;2F8C7:3A6C;2F8C8:654F;2F8C9:656C;2F8CA:2300A;2F8CB:65E3;2F8CC:66F8;2F8CD:6649;2F8CE:3B19;2F8CF:6691;2F8D0:3B08;2F8D1:3AE4;2F8D2:5192;2F8D3:5195;2F8D4:6700;2F8D5:669C;2F8D6:80AD;2F8D7:43D9;2F8D8:6717;2F8D9:671B;2F8DA:6721;2F8DB:675E;2F8DC:6753;2F8DD:233C3;2F8DE:3B49;2F8DF:67FA;2F8E0:6785;2F8E1:6852;2F8E2:6885;2F8E3:2346D;2F8E4:688E;2F8E5:681F;2F8E6:6914;2F8E7:3B9D;2F8E8:6942;2F8E9:69A3;2F8EA:69EA;2F8EB:6AA8;2F8EC:236A3;2F8ED:6ADB;2F8EE:3C18;2F8EF:6B21;2F8F0:238A7;2F8F1:6B54;2F8F2:3C4E;2F8F3:6B72;2F8F4:6B9F;2F8F5:6BBA;2F8F6:6BBB;2F8F7:23A8D;2F8F8:21D0B;2F8F9:23AFA;2F8FA:6C4E;2F8FB:23CBC;2F8FC:6CBF;2F8FD:6CCD;2F8FE:6C67;2F8FF:6D16;2F900:6D3E;2F901:6D77;2F902:6D41;2F903:6D69;2F904:6D78;2F905:6D85;2F906:23D1E;2F907:6D34;2F908:6E2F;2F909:6E6E;2F90A:3D33;2F90B:6ECB;2F90C:6EC7;2F90D:23ED1;2F90E:6DF9;2F90F:6F6E;2F910:23F5E;2F911:23F8E;2F912:6FC6;2F913:7039;2F914:701E;2F915:701B;2F916:3D96;2F917:704A;2F918:707D;2F919:7077;2F91A:70AD;2F91B:20525;2F91C:7145;2F91D:24263;2F91E:719C;2F91F:243AB;2F920:7228;2F921:7235;2F922:7250;2F923:24608;2F924:7280;2F925:7295;2F926:24735;2F927:24814;2F928:737A;2F929:738B;2F92A:3EAC;2F92B:73A5;2F92C:3EB8;2F92D:3EB8;2F92E:7447;2F92F:745C;2F930:7471;2F931:7485;2F932:74CA;2F933:3F1B;2F934:7524;2F935:24C36;2F936:753E;2F937:24C92;2F938:7570;2F939:2219F;2F93A:7610;2F93B:24FA1;2F93C:24FB8;2F93D:25044;2F93E:3FFC;2F93F:4008;2F940:76F4;2F941:250F3;2F942:250F2;2F943:25119;2F944:25133;2F945:771E;2F946:771F;2F947:771F;2F948:774A;2F949:4039;2F94A:778B;2F94B:4046;2F94C:4096;2F94D:2541D;2F94E:784E;2F94F:788C;2F950:78CC;2F951:40E3;2F952:25626;2F953:7956;2F954:2569A;2F955:256C5;2F956:798F;2F957:79EB;2F958:412F;2F959:7A40;2F95A:7A4A;2F95B:7A4F;2F95C:2597C;2F95D:25AA7;2F95E:25AA7;2F95F:7AEE;2F960:4202;2F961:25BAB;2F962:7BC6;2F963:7BC9;2F964:4227;2F965:25C80;2F966:7CD2;2F967:42A0;2F968:7CE8;2F969:7CE3;2F96A:7D00;2F96B:25F86;2F96C:7D63;2F96D:4301;2F96E:7DC7;2F96F:7E02;2F970:7E45;2F971:4334;2F972:26228;2F973:26247;2F974:4359;2F975:262D9;2F976:7F7A;2F977:2633E;2F978:7F95;2F979:7FFA;2F97A:8005;2F97B:264DA;2F97C:26523;2F97D:8060;2F97E:265A8;2F97F:8070;2F980:2335F;2F981:43D5;2F982:80B2;2F983:8103;2F984:440B;2F985:813E;2F986:5AB5;2F987:267A7;2F988:267B5;2F989:23393;2F98A:2339C;2F98B:8201;2F98C:8204;2F98D:8F9E;2F98E:446B;2F98F:8291;2F990:828B;2F991:829D;2F992:52B3;2F993:82B1;2F994:82B3;2F995:82BD;2F996:82E6;2F997:26B3C;2F998:82E5;2F999:831D;2F99A:8363;2F99B:83AD;2F99C:8323;2F99D:83BD;2F99E:83E7;2F99F:8457;2F9A0:8353;2F9A1:83CA;2F9A2:83CC;2F9A3:83DC;2F9A4:26C36;2F9A5:26D6B;2F9A6:26CD5;2F9A7:452B;2F9A8:84F1;2F9A9:84F3;2F9AA:8516;2F9AB:273CA;2F9AC:8564;2F9AD:26F2C;2F9AE:455D;2F9AF:4561;2F9B0:26FB1;2F9B1:270D2;2F9B2:456B;2F9B3:8650;2F9B4:865C;2F9B5:8667;2F9B6:8669;2F9B7:86A9;2F9B8:8688;2F9B9:870E;2F9BA:86E2;2F9BB:8779;2F9BC:8728;2F9BD:876B;2F9BE:8786;2F9BF:45D7;2F9C0:87E1;2F9C1:8801;2F9C2:45F9;2F9C3:8860;2F9C4:8863;2F9C5:27667;2F9C6:88D7;2F9C7:88DE;2F9C8:4635;2F9C9:88FA;2F9CA:34BB;2F9CB:278AE;2F9CC:27966;2F9CD:46BE;2F9CE:46C7;2F9CF:8AA0;2F9D0:8AED;2F9D1:8B8A;2F9D2:8C55;2F9D3:27CA8;2F9D4:8CAB;2F9D5:8CC1;2F9D6:8D1B;2F9D7:8D77;2F9D8:27F2F;2F9D9:20804;2F9DA:8DCB;2F9DB:8DBC;2F9DC:8DF0;2F9DD:208DE;2F9DE:8ED4;2F9DF:8F38;2F9E0:285D2;2F9E1:285ED;2F9E2:9094;2F9E3:90F1;2F9E4:9111;2F9E5:2872E;2F9E6:911B;2F9E7:9238;2F9E8:92D7;2F9E9:92D8;2F9EA:927C;2F9EB:93F9;2F9EC:9415;2F9ED:28BFA;2F9EE:958B;2F9EF:4995;2F9F0:95B7;2F9F1:28D77;2F9F2:49E6;2F9F3:96C3;2F9F4:5DB2;2F9F5:9723;2F9F6:29145;2F9F7:2921A;2F9F8:4A6E;2F9F9:4A76;2F9FA:97E0;2F9FB:2940A;2F9FC:4AB2;2F9FD:29496;2F9FE:980B;2F9FF:980B;2FA00:9829;2FA01:295B6;2FA02:98E2;2FA03:4B33;2FA04:9929;2FA05:99A7;2FA06:99C2;2FA07:99FE;2FA08:4BCE;2FA09:29B30;2FA0A:9B12;2FA0B:9C40;2FA0C:9CFD;2FA0D:4CCE;2FA0E:4CED;2FA0F:9D67;2FA10:2A0CE;2FA11:4CF8;2FA12:2A105;2FA13:2A20E;2FA14:2A291;2FA15:9EBB;2FA16:4D56;2FA17:9EF9;2FA18:9EFE;2FA19:9F05;2FA1A:9F0F;2FA1B:9F16;2FA1C:9F3B;2FA1D:2A600", + ) + + val COMPOSE = + arrayOf( + "41 300 C0;41 301 C1;41 302 C2;41 303 C3;41 308 C4;41 30A C5;43 327 C7;45 300 C8;45 301 C9;45 302 CA;45 308 CB;49 300 CC;49 301 CD;49 302 CE;49 308 CF;4E 303 D1;4F 300 D2;4F 301 D3;4F 302 D4;4F 303 D5;4F 308 D6;55 300 D9;55 301 DA;55 302 DB;55 308 DC;59 301 DD;61 300 E0;61 301 E1;61 302 E2;61 303 E3;61 308 E4;61 30A E5;63 327 E7;65 300 E8;65 301 E9;65 302 EA;65 308 EB;69 300 EC;69 301 ED;69 302 EE;69 308 EF;6E 303 F1;6F 300 F2;6F 301 F3;6F 302 F4;6F 303 F5;6F 308 F6;75 300 F9;75 301 FA;75 302 FB;75 308 FC;79 301 FD;79 308 FF;41 304 100;61 304 101;41 306 102;61 306 103;41 328 104;61 328 105;43 301 106;63 301 107;43 302 108;63 302 109;43 307 10A;63 307 10B;43 30C 10C;63 30C 10D;44 30C 10E;64 30C 10F;45 304 112;65 304 113;45 306 114;65 306 115;45 307 116;65 307 117;45 328 118;65 328 119;45 30C 11A;65 30C 11B;47 302 11C;67 302 11D;47 306 11E;67 306 11F;47 307 120;67 307 121;47 327 122;67 327 123;48 302 124;68 302 125;49 303 128;69 303 129;49 304 12A;69 304 12B;49 306 12C;69 306 12D;49 328 12E;69 328 12F;49 307 130;4A 302 134;6A 302 135;4B 327 136;6B 327 137;4C 301 139;6C 301 13A;4C 327 13B;6C 327 13C;4C 30C 13D;6C 30C 13E;4E 301 143;6E 301 144;4E 327 145;6E 327 146;4E 30C 147;6E 30C 148;4F 304 14C;6F 304 14D;4F 306 14E;6F 306 14F;4F 30B 150;6F 30B 151;52 301 154;72 301 155;52 327 156;72 327 157;52 30C 158;72 30C 159;53 301 15A;73 301 15B;53 302 15C;73 302 15D;53 327 15E;73 327 15F;53 30C 160;73 30C 161;54 327 162;74 327 163;54 30C 164;74 30C 165;55 303 168;75 303 169;55 304 16A;75 304 16B;55 306 16C;75 306 16D;55 30A 16E;75 30A 16F;55 30B 170;75 30B 171;55 328 172;75 328 173;57 302 174;77 302 175;59 302 176;79 302 177;59 308 178;5A 301 179;7A 301 17A;5A 307 17B;7A 307 17C;5A 30C 17D;7A 30C 17E;4F 31B 1A0;6F 31B 1A1;55 31B 1AF;75 31B 1B0;41 30C 1CD;61 30C 1CE;49 30C 1CF;69 30C 1D0;4F 30C 1D1;6F 30C 1D2;55 30C 1D3;75 30C 1D4;DC 304 1D5;FC 304 1D6;DC 301 1D7;FC 301 1D8;DC 30C 1D9;FC 30C 1DA;DC 300 1DB;FC 300 1DC;C4 304 1DE;E4 304 1DF;226 304 1E0;227 304 1E1;C6 304 1E2;E6 304 1E3;47 30C 1E6;67 30C 1E7;4B 30C 1E8;6B 30C 1E9;4F 328 1EA;6F 328 1EB;1EA 304 1EC;1EB 304 1ED;1B7 30C 1EE;292 30C 1EF;6A 30C 1F0;47 301 1F4;67 301 1F5;4E 300 1F8;6E 300 1F9;C5 301 1FA;E5 301 1FB;C6 301 1FC;E6 301 1FD;D8 301 1FE;F8 301 1FF;41 30F 200;61 30F 201;41 311 202;61 311 203;45 30F 204;65 30F 205;45 311 206;65 311 207;49 30F 208;69 30F 209;49 311 20A;69 311 20B;4F 30F 20C;6F 30F 20D;4F 311 20E;6F 311 20F;52 30F 210;72 30F 211;52 311 212;72 311 213;55 30F 214;75 30F 215;55 311 216;75 311 217;53 326 218;73 326 219;54 326 21A;74 326 21B;48 30C 21E;68 30C 21F;41 307 226;61 307 227;45 327 228;65 327 229;D6 304 22A;F6 304 22B;D5 304 22C;F5 304 22D;4F 307 22E;6F 307 22F;22E 304 230;22F 304 231;59 304 232;79 304 233;A8 301 385;391 301 386;395 301 388;397 301 389;399 301 38A;39F 301 38C;3A5 301 38E;3A9 301 38F;3CA 301 390;399 308 3AA;3A5 308 3AB;3B1 301 3AC;3B5 301 3AD;3B7 301 3AE;3B9 301 3AF;3CB 301 3B0;3B9 308 3CA;3C5 308 3CB;3BF 301 3CC;3C5 301 3CD;3C9 301 3CE;3D2 301 3D3;3D2 308 3D4;415 300 400;415 308 401;413 301 403;406 308 407;41A 301 40C;418 300 40D;423 306 40E;418 306 419;438 306 439;435 300 450;435 308 451;433 301 453;456 308 457;43A 301 45C;438 300 45D;443 306 45E;474 30F 476;475 30F 477;416 306 4C1;436 306 4C2;410 306 4D0;430 306 4D1;410 308 4D2;430 308 4D3;415 306 4D6;435 306 4D7;4D8 308 4DA;4D9 308 4DB;416 308 4DC;436 308 4DD;417 308 4DE;437 308 4DF;418 304 4E2;438 304 4E3;418 308 4E4;438 308 4E5;41E 308 4E6;43E 308 4E7;4E8 308 4EA;4E9 308 4EB;42D 308 4EC;44D 308 4ED;423 304 4EE;443 304 4EF;423 308 4F0;443 308 4F1;423 30B 4F2;443 30B 4F3;427 308 4F4;447 308 4F5;42B 308 4F8;44B 308 4F9;627 653 622;627 654 623;648 654 624;627 655 625;64A 654 626;6D5 654 6C0;6C1 654 6C2;6D2 654 6D3;928 93C 929;930 93C 931;933 93C 934;9C7 9BE 9CB;9C7 9D7 9CC;B47 B56 B48;B47 B3E B4B;B47 B57 B4C;B92 BD7 B94;BC6 BBE BCA;BC7 BBE BCB;BC6 BD7 BCC;C46 C56 C48;CBF CD5 CC0;CC6 CD5 CC7;CC6 CD6 CC8;CC6 CC2 CCA;CCA CD5 CCB;D46 D3E D4A;D47 D3E D4B;D46 D57 D4C;DD9 DCA DDA;DD9 DCF DDC;DDC DCA DDD;DD9 DDF DDE;1025 102E 1026;1B05 1B35 1B06;1B07 1B35 1B08;1B09 1B35 1B0A;1B0B 1B35 1B0C;1B0D 1B35 1B0E;1B11 1B35 1B12;1B3A 1B35 1B3B;1B3C 1B35 1B3D;1B3E 1B35 1B40;1B3F 1B35 1B41;1B42 1B35 1B43;41 325 1E00;61 325 1E01;42 307 1E02;62 307 1E03;42 323 1E04;62 323 1E05;42 331 1E06;62 331 1E07;C7 301 1E08;E7 301 1E09;44 307 1E0A;64 307 1E0B;44 323 1E0C;64 323 1E0D;44 331 1E0E;64 331 1E0F;44 327 1E10;64 327 1E11;44 32D 1E12;64 32D 1E13;112 300 1E14;113 300 1E15;112 301 1E16;113 301 1E17;45 32D 1E18;65 32D 1E19;45 330 1E1A;65 330 1E1B;228 306 1E1C;229 306 1E1D;46 307 1E1E;66 307 1E1F;47 304 1E20;67 304 1E21;48 307 1E22;68 307 1E23;48 323 1E24;68 323 1E25;48 308 1E26;68 308 1E27;48 327 1E28;68 327 1E29;48 32E 1E2A;68 32E 1E2B;49 330 1E2C;69 330 1E2D;CF 301 1E2E;EF 301 1E2F;4B 301 1E30;6B 301 1E31;4B 323 1E32;6B 323 1E33;4B 331 1E34;6B 331 1E35;4C 323 1E36;6C 323 1E37;1E36 304 1E38;1E37 304 1E39;4C 331 1E3A;6C 331 1E3B;4C 32D 1E3C;6C 32D 1E3D;4D 301 1E3E;6D 301 1E3F;4D 307 1E40;6D 307 1E41;4D 323 1E42;6D 323 1E43;4E 307 1E44;6E 307 1E45;4E 323 1E46;6E 323 1E47;4E 331 1E48;6E 331 1E49;4E 32D 1E4A;6E 32D 1E4B;D5 301 1E4C;F5 301 1E4D;D5 308 1E4E;F5 308 1E4F;14C 300 1E50;14D 300 1E51;14C 301 1E52;14D 301 1E53;50 301 1E54;70 301 1E55;50 307 1E56;70 307 1E57;52 307 1E58;72 307 1E59;52 323 1E5A;72 323 1E5B;1E5A 304 1E5C;1E5B 304 1E5D;52 331 1E5E;72 331 1E5F;53 307 1E60;73 307 1E61;53 323 1E62;73 323 1E63;15A 307 1E64;15B 307 1E65;160 307 1E66;161 307 1E67;1E62 307 1E68;1E63 307 1E69;54 307 1E6A;74 307 1E6B;54 323 1E6C;74 323 1E6D;54 331 1E6E;74 331 1E6F;54 32D 1E70;74 32D 1E71;55 324 1E72;75 324 1E73;55 330 1E74;75 330 1E75;55 32D 1E76;75 32D 1E77;168 301 1E78;169 301 1E79;16A 308 1E7A;16B 308 1E7B;56 303 1E7C;76 303 1E7D;56 323 1E7E;76 323 1E7F;57 300 1E80;77 300 1E81;57 301 1E82;77 301 1E83;57 308 1E84;77 308 1E85;57 307 1E86;77 307 1E87;57 323 1E88;77 323 1E89;58 307 1E8A;78 307 1E8B;58 308 1E8C;78 308 1E8D;59 307 1E8E;79 307 1E8F;5A 302 1E90;7A 302 1E91;5A 323 1E92;7A 323 1E93;5A 331 1E94;7A 331 1E95;68 331 1E96;74 308 1E97;77 30A 1E98;79 30A 1E99;17F 307 1E9B;41 323 1EA0;61 323 1EA1;41 309 1EA2;61 309 1EA3;C2 301 1EA4;E2 301 1EA5;C2 300 1EA6;E2 300 1EA7;C2 309 1EA8;E2 309 1EA9;C2 303 1EAA;E2 303 1EAB;1EA0 302 1EAC;1EA1 302 1EAD;102 301 1EAE;103 301 1EAF;102 300 1EB0;103 300 1EB1;102 309 1EB2;103 309 1EB3;102 303 1EB4;103 303 1EB5;1EA0 306 1EB6;1EA1 306 1EB7;45 323 1EB8;65 323 1EB9;45 309 1EBA;65 309 1EBB;45 303 1EBC;65 303 1EBD;CA 301 1EBE;EA 301 1EBF;CA 300 1EC0;EA 300 1EC1;CA 309 1EC2;EA 309 1EC3;CA 303 1EC4;EA 303 1EC5;1EB8 302 1EC6;1EB9 302 1EC7;49 309 1EC8;69 309 1EC9;49 323 1ECA;69 323 1ECB;4F 323 1ECC;6F 323 1ECD;4F 309 1ECE;6F 309 1ECF;D4 301 1ED0;F4 301 1ED1;D4 300 1ED2;F4 300 1ED3;D4 309 1ED4;F4 309 1ED5;D4 303 1ED6;F4 303 1ED7;1ECC 302 1ED8;1ECD 302 1ED9;1A0 301 1EDA;1A1 301 1EDB;1A0 300 1EDC;1A1 300 1EDD;1A0 309 1EDE;1A1 309 1EDF;1A0 303 1EE0;1A1 303 1EE1;1A0 323 1EE2;1A1 323 1EE3;55 323 1EE4;75 323 1EE5;55 309 1EE6;75 309 1EE7;1AF 301 1EE8;1B0 301 1EE9;1AF 300 1EEA;1B0 300 1EEB;1AF 309 1EEC;1B0 309 1EED;1AF 303 1EEE;1B0 303 1EEF;1AF 323 1EF0;1B0 323 1EF1;59 300 1EF2;79 300 1EF3;59 323 1EF4;79 323 1EF5;59 309 1EF6;79 309 1EF7;59 303 1EF8;79 303 1EF9;3B1 313 1F00;3B1 314 1F01;1F00 300 1F02;1F01 300 1F03;1F00 301 1F04;1F01 301 1F05;1F00 342 1F06;1F01 342 1F07;391 313 1F08;391 314 1F09;1F08 300 1F0A;1F09 300 1F0B;1F08 301 1F0C;1F09 301 1F0D;1F08 342 1F0E;1F09 342 1F0F;3B5 313 1F10;3B5 314 1F11;1F10 300 1F12;1F11 300 1F13;1F10 301 1F14;1F11 301 1F15;395 313 1F18;395 314 1F19;1F18 300 1F1A;1F19 300 1F1B;1F18 301 1F1C;1F19 301 1F1D;3B7 313 1F20;3B7 314 1F21;1F20 300 1F22;1F21 300 1F23;1F20 301 1F24;1F21 301 1F25;1F20 342 1F26;1F21 342 1F27;397 313 1F28;397 314 1F29;1F28 300 1F2A;1F29 300 1F2B;1F28 301 1F2C;1F29 301 1F2D;1F28 342 1F2E;1F29 342 1F2F;3B9 313 1F30;3B9 314 1F31;1F30 300 1F32;1F31 300 1F33;1F30 301 1F34;1F31 301 1F35;1F30 342 1F36;1F31 342 1F37;399 313 1F38;399 314 1F39;1F38 300 1F3A;1F39 300 1F3B;1F38 301 1F3C;1F39 301 1F3D;1F38 342 1F3E;1F39 342 1F3F;3BF 313 1F40;3BF 314 1F41;1F40 300 1F42;1F41 300 1F43;1F40 301 1F44;1F41 301 1F45;39F 313 1F48;39F 314 1F49;1F48 300 1F4A;1F49 300 1F4B;1F48 301 1F4C;1F49 301 1F4D;3C5 313 1F50;3C5 314 1F51;1F50 300 1F52;1F51 300 1F53;1F50 301 1F54;1F51 301 1F55;1F50 342 1F56;1F51 342 1F57;3A5 314 1F59;1F59 300 1F5B;1F59 301 1F5D;1F59 342 1F5F;3C9 313 1F60;3C9 314 1F61;1F60 300 1F62;1F61 300 1F63;1F60 301 1F64;1F61 301 1F65;1F60 342 1F66;1F61 342 1F67;3A9 313 1F68;3A9 314 1F69;1F68 300 1F6A;1F69 300 1F6B;1F68 301 1F6C;1F69 301 1F6D;1F68 342 1F6E;1F69 342 1F6F;3B1 300 1F70;3B5 300 1F72;3B7 300 1F74;3B9 300 1F76;3BF 300 1F78;3C5 300 1F7A;3C9 300 1F7C;1F00 345 1F80;1F01 345 1F81;1F02 345 1F82;1F03 345 1F83;1F04 345 1F84;1F05 345 1F85;1F06 345 1F86;1F07 345 1F87;1F08 345 1F88;1F09 345 1F89;1F0A 345 1F8A;1F0B 345 1F8B;1F0C 345 1F8C;1F0D 345 1F8D;1F0E 345 1F8E;1F0F 345 1F8F;1F20 345 1F90;1F21 345 1F91;1F22 345 1F92;1F23 345 1F93;1F24 345 1F94;1F25 345 1F95;1F26 345 1F96;1F27 345 1F97;1F28 345 1F98;1F29 345 1F99;1F2A 345 1F9A;1F2B 345 1F9B;1F2C 345 1F9C;1F2D 345 1F9D;1F2E 345 1F9E;1F2F 345 1F9F;1F60 345 1FA0;1F61 345 1FA1;1F62 345 1FA2;1F63 345 1FA3;1F64 345 1FA4;1F65 345 1FA5;1F66 345 1FA6;1F67 345 1FA7;1F68 345 1FA8;1F69 345 1FA9;1F6A 345 1FAA;1F6B 345 1FAB;1F6C 345 1FAC;1F6D 345 1FAD;1F6E 345 1FAE;1F6F 345 1FAF;3B1 306 1FB0;3B1 304 1FB1;1F70 345 1FB2;3B1 345 1FB3;3AC 345 1FB4;3B1 342 1FB6;1FB6 345 1FB7;391 306 1FB8;391 304 1FB9;391 300 1FBA;391 345 1FBC;A8 342 1FC1;1F74 345 1FC2;3B7 345 1FC3;3AE 345 1FC4;3B7 342 1FC6;1FC6 345 1FC7;395 300 1FC8;397 300 1FCA;397 345 1FCC;1FBF 300 1FCD;1FBF 301 1FCE;1FBF 342 1FCF;3B9 306 1FD0;3B9 304 1FD1;3CA 300 1FD2;3B9 342 1FD6;3CA 342 1FD7;399 306 1FD8;399 304 1FD9;399 300 1FDA;1FFE 300 1FDD;1FFE 301 1FDE;1FFE 342 1FDF;3C5 306 1FE0;3C5 304 1FE1;3CB 300 1FE2;3C1 313 1FE4;3C1 314 1FE5;3C5 342 1FE6;3CB 342 1FE7;3A5 306 1FE8;3A5 304 1FE9;3A5 300 1FEA;3A1 314 1FEC;A8 300 1FED;1F7C 345 1FF2;3C9 345 1FF3;3CE 345 1FF4;3C9 342 1FF6;1FF6 345 1FF7;39F 300 1FF8;3A9 300 1FFA;3A9 345 1FFC;2190 338 219A;2192 338 219B;2194 338 21AE;21D0 338 21CD;21D4 338 21CE;21D2 338 21CF;2203 338 2204;2208 338 2209;220B 338 220C;2223 338 2224;2225 338 2226;223C 338 2241;2243 338 2244;2245 338 2247;2248 338 2249;3D 338 2260;2261 338 2262;224D 338 226D;3C 338 226E;3E 338 226F;2264 338 2270;2265 338 2271;2272 338 2274;2273 338 2275;2276 338 2278;2277 338 2279;227A 338 2280;227B 338 2281;2282 338 2284;2283 338 2285;2286 338 2288;2287 338 2289;22A2 338 22AC;22A8 338 22AD;22A9 338 22AE;22AB 338 22AF;227C 338 22E0;227D 338 22E1;2291 338 22E2;2292 338 22E3;22B2 338 22EA;22B3 338 22EB;22B4 338 22EC;22B5 338 22ED;304B 3099 304C;304D 3099 304E;304F 3099 3050;3051 3099 3052;3053 3099 3054;3055 3099 3056;3057 3099 3058;3059 3099 305A;305B 3099 305C;305D 3099 305E;305F 3099 3060;3061 3099 3062;3064 3099 3065;3066 3099 3067;3068 3099 3069;306F 3099 3070;306F 309A 3071;3072 3099 3073;3072 309A 3074;3075 3099 3076;3075 309A 3077;3078 3099 3079;3078 309A 307A;307B 3099 307C;307B 309A 307D;3046 3099 3094;309D 3099 309E;30AB 3099 30AC;30AD 3099 30AE;30AF 3099 30B0;30B1 3099 30B2;30B3 3099 30B4;30B5 3099 30B6;30B7 3099 30B8;30B9 3099 30BA;30BB 3099 30BC;30BD 3099 30BE;30BF 3099 30C0;30C1 3099 30C2;30C4 3099 30C5;30C6 3099 30C7;30C8 3099 30C9;30CF 3099 30D0;30CF 309A 30D1;30D2 3099 30D3;30D2 309A 30D4;30D5 3099 30D6;30D5 309A 30D7;30D8 3099 30D9;30D8 309A 30DA;30DB 3099 30DC;30DB 309A 30DD;30A6 3099 30F4;30EF 3099 30F7;30F0 3099 30F8;30F1 3099 30F9;30F2 3099 30FA;30FD 3099 30FE;105D2 307 105C9;105DA 307 105E4;11099 110BA 1109A;1109B 110BA 1109C;110A5 110BA 110AB;11131 11127 1112E;11132 11127 1112F;11347 1133E 1134B;11347 11357 1134C;11382 113C9 11383;11384 113BB 11385;1138B 113C2 1138E;11390 113C9 11391;113C2 113C2 113C5;113C2 113B8 113C7;113C2 113C9 113C8;114B9 114BA 114BB;114B9 114B0 114BC;114B9 114BD 114BE;115B8 115AF 115BA;115B9 115AF 115BB;11935 11930 11938;1611E 1611E 16121;1611E 16129 16122;1611E 1611F 16123;16129 1611F 16124;1611E 16120 16125;16121 1611F 16126;16122 1611F 16127;16121 16120 16128;16D67 16D67 16D68;16D63 16D67 16D69;16D69 16D67 16D6A", + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizer.kt new file mode 100644 index 0000000000..04fcefbf49 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizer.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.unicode + +/** + * Pure-Kotlin Unicode NFKC (UAX #15) for targets whose platform has no + * normalizer: Linux native has no ICU. JVM/Android use `java.text.Normalizer` + * and Apple uses `NSString`. + * + * Runs the standard algorithm (full compatibility decomposition, canonical + * ordering, canonical composition) over the [NfkcData] tables generated from + * the Unicode Character Database. Hangul syllables are handled algorithmically. + * The tables are parsed on first use of non-ASCII input. + */ +internal object NfkcNormalizer { + private const val S_BASE = 0xAC00 + private const val L_BASE = 0x1100 + private const val V_BASE = 0x1161 + private const val T_BASE = 0x11A7 + private const val L_COUNT = 19 + private const val V_COUNT = 21 + private const val T_COUNT = 28 + private const val N_COUNT = V_COUNT * T_COUNT + private const val S_COUNT = L_COUNT * N_COUNT + + // Every code point below U+00A0 is its own NFKC form. + private const val FIRST_CHANGING_CHAR = ' ' + + private val combiningClasses: Map by lazy { + val map = HashMap() + forEachEntry(NfkcData.CCC) { entry -> + val dash = entry.indexOf('-') + val colon = entry.indexOf(':') + val combiningClass = entry.substring(colon + 1).toInt(16) + for (cp in entry.substring(0, dash).toInt(16)..entry.substring(dash + 1, colon).toInt(16)) { + map[cp] = combiningClass + } + } + map + } + + private val decompositions: Map by lazy { + val map = HashMap() + forEachEntry(NfkcData.DECOMP) { entry -> + val colon = entry.indexOf(':') + map[entry.substring(0, colon).toInt(16)] = + entry + .substring(colon + 1) + .split(' ') + .map { it.toInt(16) } + .toIntArray() + } + map + } + + private val compositions: Map by lazy { + val map = HashMap() + forEachEntry(NfkcData.COMPOSE) { entry -> + val parts = entry.split(' ') + map[pairKey(parts[0].toInt(16), parts[1].toInt(16))] = parts[2].toInt(16) + } + map + } + + fun normalize(input: String): String { + if (input.all { it < FIRST_CHANGING_CHAR }) return input + + val buffer = decompose(input) + canonicalOrder(buffer) + val length = compose(buffer) + + val out = StringBuilder(length) + for (i in 0 until length) out.appendCodePoint(buffer.values[i]) + return out.toString() + } + + private fun decompose(input: String): CodePointBuffer { + val out = CodePointBuffer(input.length + 8) + var i = 0 + while (i < input.length) { + val c = input[i] + val cp = + if (c.isHighSurrogate() && i + 1 < input.length && input[i + 1].isLowSurrogate()) { + i++ + 0x10000 + ((c.code - 0xD800) shl 10) + (input[i].code - 0xDC00) + } else { + c.code + } + i++ + + if (cp >= S_BASE && cp < S_BASE + S_COUNT) { + val index = cp - S_BASE + out.add(L_BASE + index / N_COUNT) + out.add(V_BASE + (index % N_COUNT) / T_COUNT) + val trailing = index % T_COUNT + if (trailing != 0) out.add(T_BASE + trailing) + } else { + val mapping = decompositions[cp] + if (mapping != null) { + for (m in mapping) out.add(m) + } else { + out.add(cp) + } + } + } + return out + } + + /** Stable-sorts each run of non-starters by combining class (UAX #15 canonical ordering). */ + private fun canonicalOrder(buffer: CodePointBuffer) { + val values = buffer.values + for (i in 1 until buffer.size) { + val cp = values[i] + val cc = combiningClass(cp) + if (cc == 0) continue + var j = i + while (j > 0 && combiningClass(values[j - 1]) > cc) { + values[j] = values[j - 1] + j-- + } + values[j] = cp + } + } + + /** Canonical composition in place (the UAX #15 reference algorithm). Returns the new length. */ + private fun compose(buffer: CodePointBuffer): Int { + val values = buffer.values + if (buffer.size == 0) return 0 + + var starterPos = 0 + var starter = values[0] + // A leading non-starter has no starter to combine with: 256 blocks it. + var lastClass = if (combiningClass(starter) == 0) 0 else 256 + var writePos = 1 + + for (readPos in 1 until buffer.size) { + val cp = values[readPos] + val cc = combiningClass(cp) + val composite = composePair(starter, cp) + if (composite >= 0 && (lastClass < cc || lastClass == 0)) { + values[starterPos] = composite + starter = composite + } else { + if (cc == 0) { + starterPos = writePos + starter = cp + } + lastClass = cc + values[writePos++] = cp + } + } + return writePos + } + + private fun composePair( + first: Int, + second: Int, + ): Int { + if (first >= L_BASE && first < L_BASE + L_COUNT && second >= V_BASE && second < V_BASE + V_COUNT) { + return S_BASE + ((first - L_BASE) * V_COUNT + (second - V_BASE)) * T_COUNT + } + if (first >= S_BASE && first < S_BASE + S_COUNT && (first - S_BASE) % T_COUNT == 0 && + second > T_BASE && second < T_BASE + T_COUNT + ) { + return first + (second - T_BASE) + } + return compositions[pairKey(first, second)] ?: -1 + } + + private fun combiningClass(cp: Int): Int = if (cp < 0x300) 0 else combiningClasses[cp] ?: 0 + + private fun pairKey( + first: Int, + second: Int, + ): Long = (first.toLong() shl 21) or second.toLong() + + private inline fun forEachEntry( + chunks: Array, + action: (String) -> Unit, + ) { + for (chunk in chunks) { + for (entry in chunk.split(';')) action(entry) + } + } + + private fun StringBuilder.appendCodePoint(cp: Int) { + if (cp < 0x10000) { + append(cp.toChar()) + } else { + val offset = cp - 0x10000 + append((0xD800 + (offset shr 10)).toChar()) + append((0xDC00 + (offset and 0x3FF)).toChar()) + } + } + + private class CodePointBuffer( + capacity: Int, + ) { + var values = IntArray(capacity) + var size = 0 + + fun add(cp: Int) { + if (size == values.size) values = values.copyOf(size * 2) + values[size++] = cp + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemotePrivateZapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemotePrivateZapTest.kt new file mode 100644 index 0000000000..f6cbcbef59 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemotePrivateZapTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer +import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * A bunker holds the user's key, so [NostrSignerRemote] can only do what NIP-46 exposes. + * Private zaps sent to the user open through `nip04_decrypt`; the sender's copy and key + * derivation need the raw key and must fail with a [SignerExceptions] callers handle. + */ +class NostrSignerRemotePrivateZapTest { + private val relay = NormalizedRelayUrl("wss://relay.example.com/") + + private class AllowAuthorizer : Nip46RequestAuthorizer { + override suspend fun isPaired(clientPubKey: HexKey) = true + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ) = Nip46ConnectDecision.Accept(request.secret ?: "ack") + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ) = true + + override suspend fun onLogout(clientPubKey: HexKey) {} + } + + private suspend fun privateZap( + from: NostrSignerInternal, + to: HexKey, + ) = ZapRequestEvent.create( + userHex = to, + relays = setOf(relay), + signer = from, + message = "for your eyes only", + zapType = ZapReceiptEvent.ZapType.PRIVATE, + ) + + private fun remoteWithoutBunker(user: HexKey) = + NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = NostrSignerInternal(KeyPair()).pubKey, + relays = setOf(relay), + client = EmptyNostrClient(), + ).also { it.bindUserPubkey(user) } + + @Test + fun recipientDecryptsPrivateZapThroughBunker() = + runTest { + withContext(Dispatchers.Default) { + val user = NostrSignerInternal(KeyPair()) + val sender = NostrSignerInternal(KeyPair()) + val zap = privateZap(sender, user.pubKey) + + val server = NostrServer(EventStore(null)) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = + NostrClient( + object : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = InProcessWebSocket(server, out) + }, + scope, + ) + + // The bunker: holds the user's key behind its own transport key. + val bunkerTransport = NostrSignerInternal(KeyPair()) + val processor = BunkerRequestProcessor(user, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, bunkerTransport, processor, setOf(relay)) + // UNDISPATCHED so the bunker's REQ is registered before the client publishes a request. + scope.launch(start = CoroutineStart.UNDISPATCHED) { service.run() } + + val remote = + NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = bunkerTransport.pubKey, + relays = setOf(relay), + client = client, + ) + remote.bindUserPubkey(user.pubKey) + remote.openSubscription() + + try { + val decrypted = withTimeout(20_000) { remote.decryptZapEvent(zap) } + + assertEquals("for your eyes only", decrypted.content) + assertEquals(sender.pubKey, decrypted.pubKey, "the private zap reveals the real sender") + assertEquals(user.decryptZapEvent(zap).id, decrypted.id) + } finally { + remote.closeSubscription() + scope.cancel() + server.close() + } + } + } + + @Test + fun senderSideOfPrivateZapIsReportedAsNotPerformable() = + runTest { + val user = NostrSignerInternal(KeyPair()) + val zap = privateZap(user, NostrSignerInternal(KeyPair()).pubKey) + + assertFailsWith { + remoteWithoutBunker(user.pubKey).decryptZapEvent(zap) + } + } + + @Test + fun deriveKeyIsUnsupported() = + runTest { + assertFailsWith { + remoteWithoutBunker(NostrSignerInternal(KeyPair()).pubKey).deriveKey("00".repeat(32)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49NormalizedPasswordTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49NormalizedPasswordTest.kt new file mode 100644 index 0000000000..7c76b1fa34 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49NormalizedPasswordTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip49PrivKeyEnc + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Opens a key another client encrypted under an un-normalized password, on every target. + * Only passes where the platform NFKC-normalizes the password the same way the producer did. + */ +class Nip49NormalizedPasswordTest { + @Test + fun decryptsForeignKeyTypedWithUnnormalizedPassword() { + // Produced by nostr-tools 2.25.2 (see jvmTest Nip49InteropTest). + val ncryptsec = + "ncryptsec1qgggze6805h7slevp5evyekxhh42fl2qk8hlla2uej8934l0f65v7xfhd2njmtdnm2ws9grf6tuzeugj59hctn3rg8dp03s8e62vycvynmf9ymcxp9sczvhx7mntl8wm8gujg00vx9rjvxxt4s8nq893" + + assertEquals( + "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", + Nip49().decrypt(ncryptsec, "ÅΩẛ̣"), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/unicode/UnicodeNormalizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/unicode/UnicodeNormalizerTest.kt new file mode 100644 index 0000000000..a1fdeee879 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/unicode/UnicodeNormalizerTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.unicode + +import com.vitorpamplona.quartz.utils.UnicodeNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +/** + * Runs on every target, so each platform's [UnicodeNormalizer] (JDK, NSString, and the + * pure-Kotlin [NfkcNormalizer] on Linux) must agree with these UAX #15 results. + */ +class UnicodeNormalizerTest { + private val cases = + listOf( + // NIP-49 spec example: the password must be NFKC-normalized before scrypt. + "ÅΩẛ̣" to "ÅΩṩ", + // Decomposed Latin recomposes. + "é" to "é", + "Å" to "Å", + // Compatibility mappings. + " " to " ", + "fi" to "fi", + "①" to "1", + "Abc" to "Abc", + "½" to "1⁄2", + "㎒" to "MHz", + // Canonical ordering: dot below (220) sorts before circumflex (230), then composes. + "ậ" to "ậ", + "ậ" to "ậ", + // Hangul: conjoining jamo compose into syllables, syllables stay composed. + "각" to "각", + "각" to "각", + // Supplementary plane: mathematical bold A maps to A. + "𝐀" to "A", + // A leading combining mark has nothing to compose with. + "́e" to "́e", + // Singleton decomposition: ANGSTROM SIGN is never recomposed. + "Å" to "Å", + // Composition exclusion: DEVANAGARI QA stays decomposed. + "क़" to "क़", + ) + + @Test + fun platformNormalizerMatchesReferenceResults() { + val normalizer = UnicodeNormalizer() + cases.forEach { (input, expected) -> + assertEquals(expected, normalizer.normalizeNFKC(input), "input: ${input.codePoints()}") + } + } + + @Test + fun pureKotlinNormalizerMatchesReferenceResults() { + cases.forEach { (input, expected) -> + assertEquals(expected, NfkcNormalizer.normalize(input), "input: ${input.codePoints()}") + } + } + + @Test + fun asciiIsReturnedUnchanged() { + val ascii = "correct horse battery staple 123!@#" + assertSame(ascii, NfkcNormalizer.normalize(ascii)) + assertEquals("", NfkcNormalizer.normalize("")) + } + + private fun String.codePoints() = map { it.code.toString(16) } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizerJdkParityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizerJdkParityTest.kt new file mode 100644 index 0000000000..954cdd68cd --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcNormalizerJdkParityTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.unicode + +import java.text.Normalizer +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Checks the pure-Kotlin [NfkcNormalizer] (used on Linux native) against the JDK's + * `java.text.Normalizer`. The JDK can trail the Unicode version of [NfkcData], so only + * code points the JDK knows are compared; normalization stability guarantees their + * results don't change in later Unicode versions. + */ +class NfkcNormalizerJdkParityTest { + private val definedCodePoints = + (0..0x10FFFF).filter { Character.isDefined(it) && Character.getType(it) != Character.SURROGATE.toInt() } + + private fun jdk(input: String) = Normalizer.normalize(input, Normalizer.Form.NFKC) + + private fun check(input: String) { + val expected = jdk(input) + val actual = NfkcNormalizer.normalize(input) + if (expected != actual) { + assertEquals(expected.hex(), actual.hex(), "input: ${input.hex()}") + } + } + + @Test + fun everyCodePointAlone() { + definedCodePoints.forEach { check(Character.toString(it)) } + } + + @Test + fun everyCodePointFollowedByCombiningMarks() { + definedCodePoints.forEach { + val cp = Character.toString(it) + check(cp + "̣̂") + check(cp + "̣̂") + check(cp + "̈") + check("a" + cp + "́") + } + } + + @Test + fun randomSequencesOfInterestingCodePoints() { + // Code points that change under NFKD or take part in reordering/composition. + val interesting = + definedCodePoints.filter { + val s = Character.toString(it) + Normalizer.normalize(s, Normalizer.Form.NFKD) != s || Character.getType(it) == Character.NON_SPACING_MARK.toInt() + } + (0x1100..0x11FF) + listOf('a'.code, 'e'.code, 'A'.code, ' '.code) + + val random = Random(49) + repeat(200_000) { + val length = random.nextInt(1, 8) + val sb = StringBuilder() + repeat(length) { sb.appendCodePoint(interesting[random.nextInt(interesting.size)]) } + check(sb.toString()) + } + } + + private fun String.hex() = codePoints().toArray().joinToString(" ") { it.toString(16) } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt index e23796184f..39bad11884 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/UnicodeNormalizer.linux.kt @@ -20,11 +20,9 @@ */ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.utils.unicode.NfkcNormalizer + +// Linux native has no platform normalizer (no ICU), so it uses Quartz's pure-Kotlin one. actual class UnicodeNormalizer { - actual fun normalizeNFKC(input: String): String { - // NFKC normalization on Linux native without ICU - // For most Nostr use cases (NIP-05, etc.) ASCII-safe content works without normalization. - // Full NFKC support would require linking against ICU or a pure-Kotlin normalizer. - return input - } + actual fun normalizeNFKC(input: String): String = NfkcNormalizer.normalize(input) } diff --git a/tools/unicode-nfkc/README.md b/tools/unicode-nfkc/README.md new file mode 100644 index 0000000000..4231ab3657 --- /dev/null +++ b/tools/unicode-nfkc/README.md @@ -0,0 +1,24 @@ +# unicode-nfkc + +Generates `NfkcData.kt`, the tables behind Quartz's pure-Kotlin NFKC normalizer +(`quartz/.../utils/unicode/NfkcNormalizer.kt`). Linux native uses it because it +has no platform normalizer. JVM/Android use `java.text.Normalizer` and Apple +uses `NSString`. NIP-49 NFKC-normalizes passwords before scrypt, so a wrong +normalizer silently breaks key decryption. + +```bash +V=17.0.0 +mkdir -p /tmp/ucd && for f in UnicodeData.txt DerivedNormalizationProps.txt; do + curl -sSfo /tmp/ucd/$f https://www.unicode.org/Public/$V/ucd/$f +done +tools/unicode-nfkc/generate.py /tmp/ucd \ + quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcData.kt +``` + +Commit the regenerated file. `NfkcNormalizerJdkParityTest` (jvmTest) checks the +result against the JDK for every code point the JDK knows. To check a new +Unicode version fully, run the normalizer over that version's +`NormalizationTest.txt` (column 4 is the NFKC of columns 1-5). + +The data is derived from the Unicode Character Database, distributed under the +permissive [Unicode License v3](https://www.unicode.org/license.txt). diff --git a/tools/unicode-nfkc/generate.py b/tools/unicode-nfkc/generate.py new file mode 100755 index 0000000000..310a8eca9c --- /dev/null +++ b/tools/unicode-nfkc/generate.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Generate Quartz's pure-Kotlin NFKC tables from the Unicode Character Database. + +Usage: + tools/unicode-nfkc/generate.py + + must hold UnicodeData.txt and DerivedNormalizationProps.txt from +https://www.unicode.org/Public//ucd/. The version is read from the +DerivedNormalizationProps.txt header and stamped into the output. + +Three tables are emitted, all as hex strings the Kotlin side parses once: + CCC - "start-end:ccc" ranges of non-zero canonical combining classes + DECOMP - "cp:x y z" full compatibility (NFKD) decompositions, already + expanded recursively. Hangul syllables are algorithmic and omitted. + COMPOSE - "first second composite" primary composites, i.e. two-code-point + canonical decompositions minus Full_Composition_Exclusion. +""" + +import os +import re +import sys + +CHUNK = 16000 # keeps each string literal far below the JVM's 64 KB constant limit + + +def read_lines(path): + with open(path, encoding="utf-8") as f: + for line in f: + line = line.split("#", 1)[0].strip() + if line: + yield line + + +def parse_range(text): + if ".." in text: + a, b = text.split("..") + return range(int(a, 16), int(b, 16) + 1) + return range(int(text, 16), int(text, 16) + 1) + + +def main(ucd_dir, out_path): + props_path = os.path.join(ucd_dir, "DerivedNormalizationProps.txt") + with open(props_path, encoding="utf-8") as f: + version = re.search(r"DerivedNormalizationProps-([\d.]+)\.txt", f.readline()).group(1) + + ccc = {} + decomp = {} # cp -> (is_compat, [cps]) + for line in read_lines(os.path.join(ucd_dir, "UnicodeData.txt")): + fields = line.split(";") + cp = int(fields[0], 16) + if int(fields[3]): + ccc[cp] = int(fields[3]) + if fields[5]: + parts = fields[5].split() + compat = parts[0].startswith("<") + if compat: + parts = parts[1:] + decomp[cp] = (compat, [int(p, 16) for p in parts]) + + exclusions = set() + for line in read_lines(props_path): + fields = [x.strip() for x in line.split(";")] + if fields[1] == "Full_Composition_Exclusion": + exclusions.update(parse_range(fields[0])) + + def expand(cp): + if cp not in decomp: + return [cp] + out = [] + for c in decomp[cp][1]: + out.extend(expand(c)) + return out + + ccc_ranges = [] + for cp in sorted(ccc): + last = ccc_ranges[-1] if ccc_ranges else None + if last and last[1] == cp - 1 and last[2] == ccc[cp]: + last[1] = cp + else: + ccc_ranges.append([cp, cp, ccc[cp]]) + + ccc_entries = ["%X-%X:%X" % (a, b, c) for a, b, c in ccc_ranges] + decomp_entries = [ + "%X:%s" % (cp, " ".join("%X" % c for c in expand(cp))) for cp in sorted(decomp) + ] + compose_entries = [ + "%X %X %X" % (parts[0], parts[1], cp) + for cp, (compat, parts) in sorted(decomp.items()) + if not compat and len(parts) == 2 and cp not in exclusions + ] + + def chunks(entries): + out, cur = [], "" + for e in entries: + if cur and len(cur) + len(e) + 1 > CHUNK: + out.append(cur) + cur = "" + cur = e if not cur else cur + ";" + e + if cur: + out.append(cur) + return out + + def kotlin_array(name, entries): + body = "".join(' "%s",\n' % c for c in chunks(entries)) + return " val %s =\n arrayOf(\n%s )\n" % (name, body) + + header_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "quartz", "src", + "commonMain", "kotlin", "com", "vitorpamplona", "quartz", "utils", + "UnicodeNormalizer.kt") + with open(header_path, encoding="utf-8") as f: + license_header = f.read().split("package ", 1)[0] + + with open(out_path, "w", encoding="utf-8") as out: + out.write(license_header) + out.write("package com.vitorpamplona.quartz.utils.unicode\n\n") + out.write("// GENERATED by tools/unicode-nfkc/generate.py from the Unicode %s Character Database.\n" % version) + out.write("// Do not edit by hand; re-run the script to update.\n") + out.write("//\n") + out.write("// Derived from Unicode data files, Copyright (c) 1991-2025 Unicode, Inc.,\n") + out.write("// distributed under the Unicode License v3: https://www.unicode.org/license.txt\n") + out.write("internal object NfkcData {\n") + out.write(' const val UNICODE_VERSION = "%s"\n\n' % version) + out.write(kotlin_array("CCC", ccc_entries)) + out.write("\n") + out.write(kotlin_array("DECOMP", decomp_entries)) + out.write("\n") + out.write(kotlin_array("COMPOSE", compose_entries)) + out.write("}\n") + + print("Unicode %s: %d ccc ranges, %d decompositions, %d compositions" + % (version, len(ccc_ranges), len(decomp_entries), len(compose_entries))) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2])