From 54538da8a643f4deec55dbd3b38db1be2995c66e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 27 Jul 2026 14:35:52 -0700 Subject: [PATCH 001/213] Stop the E2E teardown racing the project-scope flush (#780) The instrumented tests delete their project directory in teardown, but finish() and waitForIdleSync() only drain the main thread. The Koin project-scope close flushes scene buffers on IO coroutines after that, so a straggler write can land between deleteRecursively emptying the directory and removing it. The rmdir then fails with "failed to delete ". Seen on develop in both ProjectLifecycleTest and SceneEditorWorkflowTest, so it is the shared teardown rather than any one test. There is no signal to await for that scope close, so retry the delete until the flushes stop. Each attempt removes whatever reappeared, and it still fails loudly after 15s rather than hiding a real leak. ProjectLifecycleTest now uses the same helper instead of deleting inline. --- .../androidTest/kotlin/EditorTestHarness.kt | 32 ++++++++++++++++++- .../kotlin/ProjectLifecycleTest.kt | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/android/src/androidTest/kotlin/EditorTestHarness.kt b/android/src/androidTest/kotlin/EditorTestHarness.kt index 4d23da5a6..b998f01b6 100644 --- a/android/src/androidTest/kotlin/EditorTestHarness.kt +++ b/android/src/androidTest/kotlin/EditorTestHarness.kt @@ -15,6 +15,7 @@ import com.darkrockstudios.apps.hammer.common.data.ClientResult import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import org.koin.java.KoinJavaComponent.getKoin +import java.io.IOException /** * Shared helpers for editor instrumented tests: seed a project through the real Koin graph, @@ -43,8 +44,37 @@ object EditorTestHarness { scenario.onActivity { it.finish() } InstrumentationRegistry.getInstrumentation().waitForIdleSync() scenario.close() - repository().deleteProject(projectDef) + deleteProjectWhenSettled(projectDef) } + + /** + * Delete the project directory, tolerating writes that are still landing. + * + * finish() and waitForIdleSync() only drain the main thread, but the project-scope close + * flushes scene buffers on IO coroutines after that. A straggler write can land between + * deleteRecursively emptying the directory and removing it, so the final rmdir fails with + * "failed to delete ". Each retry deletes whatever reappeared, so this + * converges as soon as the flushes stop. + */ + fun deleteProjectWhenSettled(projectDef: ProjectDef, timeoutMillis: Long = 15_000L) { + val deadline = SystemClock.uptimeMillis() + timeoutMillis + var lastFailure: IOException? = null + while (SystemClock.uptimeMillis() < deadline) { + try { + repository().deleteProject(projectDef) + return + } catch (e: IOException) { + lastFailure = e + SystemClock.sleep(DELETE_RETRY_INTERVAL_MS) + } + } + throw AssertionError( + "Project directory was still being written to ${timeoutMillis}ms after teardown began", + lastFailure, + ) + } + + private const val DELETE_RETRY_INTERVAL_MS = 100L } /** Matches nodes whose testTag starts with [prefix] - for list items keyed by an unknown id. */ diff --git a/android/src/androidTest/kotlin/ProjectLifecycleTest.kt b/android/src/androidTest/kotlin/ProjectLifecycleTest.kt index d6506b6a4..43f4e4da0 100644 --- a/android/src/androidTest/kotlin/ProjectLifecycleTest.kt +++ b/android/src/androidTest/kotlin/ProjectLifecycleTest.kt @@ -89,6 +89,6 @@ class ProjectLifecycleTest { @After fun deleteCreatedProject() { val repository = getKoin().get() - repository.deleteProject(repository.getProjectDefinition(projectName)) + EditorTestHarness.deleteProjectWhenSettled(repository.getProjectDefinition(projectName)) } } From 22520baca300ac1e2000a0fbed1a7828c33326ac Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 28 Jul 2026 22:38:18 -0700 Subject: [PATCH 002/213] Fix flaky iOS scene-editor save UI test (#786) tapUntilGone gated the save tap solely on isHittable. Under CI load the top-bar save button's accessibility frame can never settle to hittable within the timeout, so the loop waited out the whole budget without ever tapping and failed with 'Element still present after 20.0s'. Keep the proven isHittable hit-tested fast path and add a geometry fallback: once the button's frame has settled (fully below the status bar, ruling out the transient dead-pixel position, and stable across two samples) tap it via tapCenter. This guarantees a tap eventually fires even when isHittable never flips true, while still avoiding the dead-pixel/scroll-away hazard the isHittable gate originally fixed. --- ios/iosUITests/HammerUITest.swift | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/ios/iosUITests/HammerUITest.swift b/ios/iosUITests/HammerUITest.swift index 06b110ac5..a8f42c680 100644 --- a/ios/iosUITests/HammerUITest.swift +++ b/ios/iosUITests/HammerUITest.swift @@ -164,21 +164,40 @@ class HammerUITest: XCTestCase { /// The save button lives in the top bar, and right after the edit that surfaces it its /// accessibility frame can be briefly *stale* — reported up under the status bar (a dead pixel) /// before the layout settles. A blind coordinate tap there does nothing, and repeatedly tapping - /// that dead spot can even scroll/navigate the app away. So only tap when the button is actually - /// hittable (settled), using a hit-tested `tap()` that re-resolves its real position; otherwise - /// wait for it to settle. Re-tap until it's gone, which also covers a dropped tap or a late IME - /// keystroke re-dirtying the buffer right after a save. + /// that dead spot can even scroll/navigate the app away. So never tap the stale frame: tap only + /// once the button has *settled*. + /// + /// `isHittable` is the fast settled-signal and the preferred path (a hit-tested `tap()` + /// re-resolves the real position). But under simulator/CI load it can never flip true within the + /// timeout, which would leave a purely-`isHittable`-gated loop waiting out the whole budget + /// without ever tapping. So fall back to a coordinate `tapCenter` once the frame is demonstrably + /// settled by geometry — fully below the status bar (ruling out the dead-pixel position) and + /// stable across two samples. Re-tap until it's gone, which also covers a dropped tap or a late + /// IME keystroke re-dirtying the buffer right after a save. func tapUntilGone(_ tag: String, timeout: TimeInterval = HammerUITest.defaultTimeout, file: StaticString = #file, line: UInt = #line) { let el = waitFor(tag, timeout: timeout, file: file, line: line) let deadline = Date().addingTimeInterval(timeout) + var lastFrame = CGRect.null repeat { if !el.exists { return } if el.isHittable { el.tap() if poll({ !el.exists }, timeout: 3) { return } } else { - _ = poll({ el.isHittable || !el.exists }, timeout: 1) + // Geometry fallback for when `isHittable` never settles under load. A settled top-bar + // button sits fully below the status bar and holds a stable frame; the transient + // dead-pixel frame reads under the status bar and is discarded here. + let frame = el.frame + let statusBarMaxY = app.statusBars.firstMatch.frame.maxY + let settled = frame.height > 0 && frame.minY >= statusBarMaxY && frame == lastFrame + lastFrame = frame + if settled { + tapCenter(el) + if poll({ !el.exists }, timeout: 3) { return } + } else { + _ = poll({ el.isHittable || !el.exists }, timeout: 0.5) + } } } while Date() < deadline XCTFail("Element still present after \(timeout)s: \(tag)", file: file, line: line) From 3a85c461ddc8011b5a86ce94bfccb629c0525c1a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 30 Jul 2026 21:10:36 -0700 Subject: [PATCH 003/213] Stop large story imports from freezing the app Importing a 100k+ word Markdown story froze the editor and consumed all its RAM. Parsing was never slow (~60ms for 120k words); creating the scenes was, and quadratically in their number. Resolving a path by id regex-parsed every filename in the project, sibling counts re-listed the parent directory several times per scene, and the order re-padding loop re-scanned once per renamed sibling. The cached directory scan now carries a scene-id index and a child-count index, in ScenePathIndex. A created file is absorbed in place; anything else drops the cache. The re-padding loop reads what it needs before it rather than inside it, so a run of renames costs one re-scan instead of one each. Measured on an in-memory filesystem, importing 400 scenes drops from 2.1s to ~0.3s and 1600 from 37s to ~3s. A real disk gains more, since the discarded scans are syscalls. Also builds the import preview off the UI thread, debounced and single flighted, shows the dialog while it reads instead of leaving the projects list looking idle, makes the preview pane lazy, and ignores the local scratch directory. --- .gitignore | 3 + .../values/strings_project_home.xml | 1 + .../projectslist/ProjectsList.kt | 2 + .../projectslist/ProjectsListComponent.kt | 109 ++++++++++++++-- .../sceneeditorrepository/SceneDatasource.kt | 68 ++++------ .../sceneeditorrepository/ScenePathIndex.kt | 118 ++++++++++++++++++ .../sceneeditorrepository/SceneRepository.kt | 25 +++- .../projectslist/ProjectsListComponentTest.kt | 85 ++++++++++++- .../sceneeditor/SceneDatasourceCacheTest.kt | 84 ++++++++++++- .../sceneeditor/SceneOrderRescanTest.kt | 111 ++++++++++++++++ .../sceneeditor/ScenePathIndexTest.kt | 73 +++++++++++ .../projectselection/ImportStoryDialog.kt | 77 ++++++++---- .../common/projectselection/ProjectListUi.kt | 1 + .../ImportStoryDialog.Preview.kt | 28 +++++ 14 files changed, 703 insertions(+), 82 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/ScenePathIndex.kt create mode 100644 common/src/desktopTest/kotlin/repositories/sceneeditor/SceneOrderRescanTest.kt create mode 100644 common/src/desktopTest/kotlin/repositories/sceneeditor/ScenePathIndexTest.kt diff --git a/.gitignore b/.gitignore index 2bb275b32..3809a0bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ fastlane/README.md # server/src/main/resources/assets/images/ are committed, their sources are not /scripts/build-home-screenshots.py /scripts/home-screenshot-sources/ + +# Local working area: marketing renders, one-off scripts, anything not shipped +/scratch/ diff --git a/common/src/commonMain/composeResources/values/strings_project_home.xml b/common/src/commonMain/composeResources/values/strings_project_home.xml index 42b3ea49e..8f16d2296 100644 --- a/common/src/commonMain/composeResources/values/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values/strings_project_home.xml @@ -121,6 +121,7 @@ Create chapter groups Preview Nothing to import + Reading your manuscript Will create %1$d scene(s) Cancel Import diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsList.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsList.kt index bc77a453b..5c5446c15 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsList.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsList.kt @@ -64,6 +64,8 @@ interface ProjectsList : HammerComponent, ComponentToaster { val importProjectName: String = "", val importFileContent: ByteArray = ByteArray(0), val importPreview: ImportPreview = ImportPreview(emptyList()), + // A parse in flight is meaningless after a process death; don't restore it. + @Transient val isParsingImport: Boolean = false, ) @Serializable diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt index 75959ab3e..5b60fb718 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt @@ -64,9 +64,13 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import net.peanuuutz.tomlkt.Toml @@ -75,6 +79,7 @@ import okio.IOException import okio.Path.Companion.toPath import org.koin.core.component.inject import org.koin.core.parameter.parametersOf +import kotlin.coroutines.coroutineContext import kotlin.time.Clock import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData as StoredData @@ -101,6 +106,8 @@ class ProjectsListComponent( private var loadProjectsJob: Job? = null private var syncProjectsJob: Job? = null + private var importPreviewJob: Job? = null + private val importPreviewLock = Mutex() private var syncScope: CoroutineScope? = null private val modalRouter = ProjectListModalRouter( @@ -350,24 +357,41 @@ class ProjectsListComponent( _state.getAndUpdate { it.copy(showImportFilePicker = false) } } + // Parsing a full manuscript is not instant, and both entry points are called from the UI + // thread, so the preview is always built off it and applied when it lands. override fun selectImportFile(name: String, content: ByteArray) { val sourceName = name.substringBeforeLast('.') val format = importerRegistry.formatForFileName(name) val initialOptions = ImportOptions(format = format) - val preview = - importerRegistry.forFormat(format).preview(sourceName, content, initialOptions) - val projectName = preview.title?.takeIf { it.isNotBlank() } ?: sourceName + + // Open on the picker closing rather than when the parse lands, so the dialog itself carries + // the wait instead of the projects list sitting there looking like nothing happened. _state.getAndUpdate { it.copy( showImportFilePicker = false, showImportDialog = true, importOptions = initialOptions, importSourceName = sourceName, - importProjectName = projectName, + importProjectName = sourceName, importFileContent = content, - importPreview = preview, + importPreview = ImportPreview(emptyList()), + isParsingImport = true, ) } + + launchPreview { + val preview = buildPreview(sourceName, content, initialOptions) ?: return@launchPreview + val projectName = preview.title?.takeIf { it.isNotBlank() } ?: sourceName + withContext(mainDispatcher) { + _state.getAndUpdate { + it.copy( + importProjectName = projectName, + importPreview = preview, + isParsingImport = false, + ) + } + } + } } override fun updateImportProjectName(name: String) { @@ -376,17 +400,66 @@ class ProjectsListComponent( override fun updateImportOptions(options: ImportOptions) { val current = _state.value - val preview = importerRegistry.forFormat(options.format).preview( - sourceName = current.importSourceName, - content = current.importFileContent, - options = options, - ) - _state.getAndUpdate { - it.copy(importOptions = options, importPreview = preview) + _state.getAndUpdate { it.copy(importOptions = options, isParsingImport = true) } + + launchPreview { + // debounce: options update as you type + delay(PREVIEW_DEBOUNCE_MS) + val preview = buildPreview( + sourceName = current.importSourceName, + content = current.importFileContent, + options = options, + ) ?: return@launchPreview + withContext(mainDispatcher) { + _state.getAndUpdate { it.copy(importPreview = preview, isParsingImport = false) } + } } } + private fun launchPreview(block: suspend CoroutineScope.() -> Unit) { + importPreviewJob?.cancel() + importPreviewJob = scope.launch(block = block) + } + + /** + * Parses off the UI thread, returning null when the import fails or the caller was cancelled. + * The importers hand off to third-party parsers that can throw on a malformed file, and an + * escaping throw would take the component's whole scope down with it. + */ + private suspend fun buildPreview( + sourceName: String, + content: ByteArray, + options: ImportOptions, + ): ImportPreview? { + // The parse never suspends, so cancelling only takes effect around it; hold the lock so a + // superseded parse can't run alongside its replacement. + val preview = importPreviewLock.withLock { + coroutineContext.ensureActive() + withContext(dispatcherDefault) { + try { + importerRegistry.forFormat(options.format).preview(sourceName, content, options) + } catch (e: CancellationException) { + throw e + // Import can fail many ways (parse, malformed file); report and show failure toast. + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Napier.e("Import: failed to parse '$sourceName'", e) + null + } + } + } + if (preview == null) { + withContext(mainDispatcher) { + _state.getAndUpdate { + it.copy(importPreview = ImportPreview(emptyList()), isParsingImport = false) + } + showToast(scope, Res.string.project_home_action_import_toast_failure) + } + } + return preview + } + override fun cancelImportDialog() { + importPreviewJob?.cancel() _state.getAndUpdate { it.copy( showImportDialog = false, @@ -394,13 +467,21 @@ class ProjectsListComponent( importSourceName = "", importProjectName = "", importPreview = ImportPreview(emptyList()), + isParsingImport = false, ) } } override suspend fun confirmImportDialog() { + // A re-parse triggered by a last-moment option change must not land after we snapshot. + importPreviewJob?.join() val projectName = _state.value.importProjectName val previewToImport = _state.value.importPreview + // That re-parse may have emptied the preview, and the button was enabled against the old one. + if (previewToImport.isEmpty) { + Napier.w("Import: nothing to import for '$projectName'") + return + } val result = projectsRepository.createProject(projectName) if (!isSuccess(result)) { @@ -835,4 +916,8 @@ class ProjectsListComponent( override fun dismissProjectDelete() { modalRouter.dismissProjectDelete() } + + private companion object { + const val PREVIEW_DEBOUNCE_MS = 150L + } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt index 2f8635891..704781ddb 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt @@ -11,8 +11,6 @@ import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath import io.github.aakira.napier.Napier -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized import okio.FileSystem import okio.IOException import okio.Path @@ -22,15 +20,11 @@ class SceneDatasource( private val fileSystem: FileSystem, ) { - // Cached recursive scan of the (non-archived) scenes directory; invalidated on structural mutation. - private val scenePathCacheLock = SynchronizedObject() - private var cachedScenePaths: List? = null + private val scenePathIndex = ScenePathIndex(::scanScenePaths) - private fun invalidateScenePathCache() = synchronized(scenePathCacheLock) { cachedScenePaths = null } + private fun invalidateScenePathCache() = scenePathIndex.invalidate() - private fun addScenePathToCache(scenePath: HPath) = synchronized(scenePathCacheLock) { - cachedScenePaths = cachedScenePaths?.let { (it + scenePath).sortedBy { path -> path.name } } - } + private fun addScenePathToCache(scenePath: HPath) = scenePathIndex.onCreated(scenePath) fun getSceneDirectory(): HPath = getSceneDirectory(projectDef, fileSystem) @@ -39,31 +33,28 @@ class SceneDatasource( return getSceneIdFromFilename(fileName) } - fun resolveScenePathFromFilesystem(id: Int, paths: List = getAllScenePaths()): HPath? { - return paths.find { path -> getSceneIdFromPath(path) == id } - } + fun resolveScenePathFromFilesystem(id: Int): HPath? = scenePathIndex.pathFor(id) - fun getPathFromFilesystem(sceneItem: SceneItem): HPath? { - return getAllScenePathsOkio() - .filterScenePathsOkio().firstOrNull { path -> - sceneItem.id == getSceneFromFilename(path).id - } - } + /** + * Number of scenes/groups directly inside [parentPath]. Directories the scan holds no entry for + * are counted from disk: an empty group reads the same to the index as one it never reached, + * and order numbers are derived from this, so guessing zero would mis-number a real directory. + */ + fun countScenePathsIn(parentPath: HPath): Int = + scenePathIndex.childCountOrNull(parentPath) ?: listChildScenePaths(parentPath).size + + private fun listChildScenePaths(parentPath: HPath): List = + fileSystem.list(parentPath.toOkioPath()).filterScenePathsOkio() private fun getAllScenePathsOkio(): List = getAllScenePaths().map { it.toOkioPath() } - // Scan runs inside the lock so an invalidation can't interleave between scan and store and - // resurrect a stale list. The scan is blocking, not suspending, so holding the lock is safe. - fun getAllScenePaths(): List = synchronized(scenePathCacheLock) { - cachedScenePaths ?: run { - val sceneDirPath = getSceneDirectory().toOkioPath() - fileSystem.listRecursively(sceneDirPath) - .filterScenePathsOkio() - .sortedBy { it.name } - .toList() - .also { cachedScenePaths = it } - } - } + fun getAllScenePaths(): List = scenePathIndex.paths() + + private fun scanScenePaths(): List = + fileSystem.listRecursively(getSceneDirectory().toOkioPath()) + .filterScenePathsOkio() + .sortedBy { it.name } + .toList() fun getAllScenes(): List { return getAllScenePathsOkio() @@ -204,7 +195,7 @@ class SceneDatasource( fun moveScene(sourcePath: HPath, targetPath: HPath) { fileSystem.atomicMove(sourcePath.toOkioPath(), targetPath.toOkioPath()) - invalidateScenePathCache() + scenePathIndex.invalidate() } fun getSceneBufferDirectory(): HPath { @@ -224,12 +215,7 @@ class SceneDatasource( fun getSceneFilename(path: HPath) = path.toOkioPath().name - fun getLastOrderNumber(parentPath: HPath): Int { - val numScenes = fileSystem.list(parentPath.toOkioPath()) - .filterScenePathsOkio() - .count() - return numScenes - } + fun getLastOrderNumber(parentPath: HPath): Int = countScenePathsIn(parentPath) fun clearTempScene(sceneItem: SceneItem) { val path = getSceneBufferTempPath(sceneItem).toOkioPath() @@ -279,11 +265,7 @@ class SceneDatasource( } } - fun countScenes(parentPath: HPath): Int { - return fileSystem.list(parentPath.toOkioPath()) - .filterScenePathsOkio() - .count() - 1 - } + fun countScenes(parentPath: HPath): Int = countScenePathsIn(parentPath) - 1 fun storeTempSceneBuffer(buffer: SceneBuffer): Boolean { val scenePath = getSceneBufferTempPath(buffer.content.scene).toOkioPath() @@ -545,7 +527,7 @@ class SceneDatasource( } } -private fun Path.isInArchivedDirectory(): Boolean { +internal fun Path.isInArchivedDirectory(): Boolean { val pathStr = this.toString() return pathStr.contains("${Path.DIRECTORY_SEPARATOR}${SceneDatasource.ARCHIVED_DIRECTORY}${Path.DIRECTORY_SEPARATOR}") || pathStr.contains("${Path.DIRECTORY_SEPARATOR}${SceneDatasource.ARCHIVED_DIRECTORY}") && this.parent?.name == SceneDatasource.ARCHIVED_DIRECTORY diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/ScenePathIndex.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/ScenePathIndex.kt new file mode 100644 index 000000000..c841f5d24 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/ScenePathIndex.kt @@ -0,0 +1,118 @@ +package com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository + +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import okio.Path + +/** + * A cached view of the scene files on disk, backed by one recursive [scan]. Everything a caller can + * ask is derived from that scan: the name-ordered path list, the path owning a scene id, and how + * many scenes sit directly inside a directory. + * + * Invariants, all maintained under one lock: + * - Paths are ordered by file name, matching what the scan produces. + * - A scene id maps to the name-first path claiming it, which is how duplicate ids are settled. + * - A directory's count is the number of cached paths whose parent it is. + * + * A new file is absorbed in place, since it cannot change any of those answers for a file already + * held. Every other change drops the cache and the next read re-scans, so callers that rename in a + * loop should read what they need before it, not inside it. + */ +internal class ScenePathIndex(private val scan: () -> List) { + + private val lock = SynchronizedObject() + + private var paths: List? = null + private var pathsById: MutableMap? = null + private var childCounts: MutableMap? = null + + fun paths(): List = synchronized(lock) { pathsLocked() } + + fun pathFor(id: Int): HPath? = synchronized(lock) { pathsByIdLocked()[id] } + + /** + * Number of scenes and groups directly inside [parent], or null when the scan holds no entry + * for that directory. Null means unknown, not empty: a directory the scan never reached is + * indistinguishable here from one that is genuinely empty, and only the caller can tell them + * apart by looking at the disk. + */ + fun childCountOrNull(parent: HPath): Int? = synchronized(lock) { + childCountsLocked()[parent.toOkioPath()] + } + + fun invalidate() = synchronized(lock) { invalidateLocked() } + + fun onCreated(scenePath: HPath) = synchronized(lock) { + val current = paths ?: return@synchronized + paths = current.withPathInserted(scenePath) + + pathsById?.let { byId -> + val id = sceneIdOf(scenePath) + val owner = byId[id] + if (owner == null || scenePath.name < owner.name) byId[id] = scenePath + } + childCounts?.let { counts -> counts.increment(scenePath.toOkioPath().parent) } + } + + // region Locked internals + + private fun invalidateLocked() { + paths = null + pathsById = null + childCounts = null + } + + private fun pathsLocked(): List = paths ?: scan().also { paths = it } + + private fun pathsByIdLocked(): Map { + pathsById?.let { return it } + + val byId = HashMap() + // The scan is name-ordered, so the first path seen for an id is the one that owns it. + for (path in pathsLocked()) { + val id = sceneIdOf(path) + if (!byId.containsKey(id)) byId[id] = path + } + return byId.also { pathsById = it } + } + + private fun childCountsLocked(): MutableMap { + childCounts?.let { return it } + + val counts = HashMap() + for (path in pathsLocked()) counts.increment(path.toOkioPath().parent) + return counts.also { childCounts = it } + } + + // endregion + + private fun sceneIdOf(path: HPath): Int = + SceneDatasource.getSceneIdFromFilename(path.toOkioPath().name) +} + +private fun MutableMap.increment(parent: Path?) { + if (parent != null) this[parent] = (this[parent] ?: 0) + 1 +} + +/** Inserts in name order, so the list never has to be re-sorted. */ +private fun List.withPathInserted(path: HPath): List { + val at = upperBoundByName(path.name) + return buildList(size + 1) { + addAll(this@withPathInserted.subList(0, at)) + add(path) + addAll(this@withPathInserted.subList(at, this@withPathInserted.size)) + } +} + +/** Index just past the last path whose name is `<= name`. */ +private fun List.upperBoundByName(name: String): Int { + var low = 0 + var high = size + while (low < high) { + val mid = (low + high) ushr 1 + if (this[mid].name <= name) low = mid + 1 else high = mid + } + return low +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt index abab7ab31..b46f7e4ce 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt @@ -160,19 +160,27 @@ class SceneRepository( sceneDatasource.getLastOrderNumber(parentPath).numDigits() } + return sceneFileName(sceneDef, orderDigits) + } + + /** The `order~name~id` filename itself, given how wide the order field is in its directory. */ + private fun sceneFileName(sceneDef: SceneItem, orderDigits: Int): String { val order = sceneDef.order.toString().padStart(orderDigits, '0') val delim = ProjectsRepository.FILENAME_DELIMITER val encodedName = ProjectsRepository.encodeForFilename(sceneDef.name) val bareName = "$order$delim$encodedName$delim${sceneDef.id}" - val filename = if (sceneDef.type == SceneItem.Type.Scene) { + return if (sceneDef.type == SceneItem.Type.Scene) { "$bareName${SceneDatasource.SCENE_FILENAME_EXTENSION}" } else { bareName } - return filename } + /** How wide the order field is for items directly inside [parentPath]. */ + private fun orderDigitsIn(parentPath: HPath): Int = + sceneDatasource.getLastOrderNumber(parentPath).numDigits() + fun getSceneItemFromId(id: Int): SceneItem? { return sceneTree.findValueOrNull { it.id == id } } @@ -346,11 +354,14 @@ class SceneRepository( ?: error("Could not find parent on filesystem: ${parent.value.id}") } val existingSceneFiles = sceneDatasource.getGroupChildPathsById(parentPath) + val orderDigits = orderDigitsIn(parentPath) parent.children().forEach { childNode -> val existingPath = existingSceneFiles[childNode.value.id] ?: error("Scene wasn't present in directory") - val newPath = getSceneFilePath(childNode.value.id) + val newPath = parentPath.toOkioPath() + .div(sceneFileName(childNode.value, orderDigits)) + .toHPath() if (existingPath != newPath) { try { @@ -489,12 +500,18 @@ class SceneRepository( null } + // Renaming siblings changes none of this, so it is read once rather than per child. That + // also keeps the loop off the path cache, so the renames cost one re-scan between them all. + val orderDigits = orderDigitsIn(parentPath) + parent.children().forEachIndexed { index, childNode -> childNode.value = childNode.value.copy(order = index) val existingPath = existingSceneFiles[childNode.value.id] ?: error("Scene wasn't present in directory") - val newPath = getSceneFilePath(childNode.value.id) + val newPath = parentPath.toOkioPath() + .div(sceneFileName(childNode.value, orderDigits)) + .toHPath() if (existingPath != newPath) { try { diff --git a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt index 1359e5607..2d98f50d9 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt @@ -6,6 +6,8 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.projec import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Info import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata import com.darkrockstudios.apps.hammer.common.data.CResult +import com.darkrockstudios.apps.hammer.common.data.ChapterHeadingLevel +import com.darkrockstudios.apps.hammer.common.data.ImportOptions import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.SyncedProjectDefinition import com.darkrockstudios.apps.hammer.common.data.account.AccountReauthUseCase @@ -13,8 +15,10 @@ import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings +import com.darkrockstudios.apps.hammer.common.data.importer.ImportPreview import com.darkrockstudios.apps.hammer.common.data.importer.MarkdownStoryImporter import com.darkrockstudios.apps.hammer.common.data.importer.RtfStoryImporter +import com.darkrockstudios.apps.hammer.common.data.importer.StoryImporter import com.darkrockstudios.apps.hammer.common.data.importer.StoryImporterRegistry import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository @@ -72,6 +76,22 @@ class ProjectsListComponentTest : ComponentTest() { private var selectedProject: ProjectDef? = null + private var previewsParsed = 0 + + /** Counts parses so a test can tell one debounced parse from a burst of them. */ + private inner class CountingImporter(private val delegate: StoryImporter) : StoryImporter { + override val format = delegate.format + + override fun preview( + sourceName: String, + content: ByteArray, + options: ImportOptions, + ): ImportPreview { + previewsParsed++ + return delegate.preview(sourceName, content, options) + } + } + private val projectDefA = ProjectDef("Alpha", HPath("/projects/Alpha", "Alpha", false)) private val fixedNow = Instant.fromEpochSeconds(1_000_000) @@ -114,7 +134,11 @@ class ProjectsListComponentTest : ComponentTest() { single { createTomlSerializer() } single { TestStrRes() } single { object : Clock { override fun now(): Instant = fixedNow } } - single { StoryImporterRegistry(listOf(MarkdownStoryImporter(), RtfStoryImporter())) } + single { + StoryImporterRegistry( + listOf(CountingImporter(MarkdownStoryImporter()), CountingImporter(RtfStoryImporter())) + ) + } single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { VersionCheckRepository(get()) } @@ -227,6 +251,7 @@ class ProjectsListComponentTest : ComponentTest() { val comp = newComponent() comp.selectImportFile("The Wreck.md", "# Chapter One\n\nText\n\n# Chapter Two\n\nMore".encodeToByteArray()) + advanceUntilIdle() assertEquals("The Wreck", comp.state.value.importProjectName) assertTrue(comp.state.value.showImportDialog) @@ -243,10 +268,66 @@ class ProjectsListComponentTest : ComponentTest() { "alice.md", "# Alice in Wonderland\n\n## Chapter One\n\nText".encodeToByteArray(), ) + advanceUntilIdle() assertEquals("Alice in Wonderland", comp.state.value.importProjectName) } + // Auto keeps two H2 scenes; H1 collapses the document to one, so the preview shows which won. + private val twoLevelDoc = + "# Part One\n\n## Chapter A\n\nText\n\n## Chapter B\n\nMore".encodeToByteArray() + + @Test + fun `the dialog opens busy and clears when the parse lands`() = + runTest(mainTestDispatcher) { + val comp = newComponent() + + comp.selectImportFile("The Wreck.md", twoLevelDoc) + + // The picker has closed, so the dialog carries the wait rather than the projects list. + assertTrue(comp.state.value.showImportDialog, "Dialog opens before the parse finishes") + assertTrue(comp.state.value.isParsingImport, "It opens in the busy state") + assertTrue(comp.state.value.importPreview.isEmpty, "With nothing to show yet") + + advanceUntilIdle() + + assertFalse(comp.state.value.isParsingImport, "Busy clears once the preview lands") + assertFalse(comp.state.value.importPreview.isEmpty) + } + + @Test + fun `a burst of option changes costs one parse and settles on the last`() = + runTest(mainTestDispatcher) { + val comp = newComponent() + comp.selectImportFile("Draft.md", twoLevelDoc) + advanceUntilIdle() + assertEquals(2, comp.state.value.importPreview.totalScenes, "Auto splits on H2") + previewsParsed = 0 + + comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.H2)) + comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.Auto)) + comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.H1)) + advanceUntilIdle() + + assertEquals(1, previewsParsed, "Superseded options must be dropped before they parse") + assertEquals(ChapterHeadingLevel.H1, comp.state.value.importOptions.chapterHeadingLevel) + assertEquals(1, comp.state.value.importPreview.totalScenes, "H1 keeps it as one scene") + } + + @Test + fun `confirming an empty preview creates nothing`() = + runTest(mainTestDispatcher) { + val comp = newComponent() + comp.selectImportFile("Blank.md", " \n\n \n".encodeToByteArray()) + advanceUntilIdle() + assertTrue(comp.state.value.importPreview.isEmpty, "A blank document previews as nothing") + + comp.confirmImportDialog() + advanceUntilIdle() + + verify(exactly = 0) { projectsRepository.createProject(any()) } + } + @Test fun `updateImportProjectName updates the import name`() = runTest(mainTestDispatcher) { val comp = newComponent() @@ -260,6 +341,8 @@ class ProjectsListComponentTest : ComponentTest() { fun `cancelImportDialog clears the import state`() = runTest(mainTestDispatcher) { val comp = newComponent() comp.selectImportFile("Draft.md", "# One\n\nText".encodeToByteArray()) + advanceUntilIdle() + assertTrue(comp.state.value.showImportDialog) comp.cancelImportDialog() diff --git a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneDatasourceCacheTest.kt b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneDatasourceCacheTest.kt index 7b68fd67f..95f311235 100644 --- a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneDatasourceCacheTest.kt +++ b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneDatasourceCacheTest.kt @@ -33,11 +33,18 @@ class SceneDatasourceCacheTest : BaseTest() { private class CountingFileSystem(delegate: FakeFileSystem) : ForwardingFileSystem(delegate) { var listRecursivelyCount = 0 private set + var listCount = 0 + private set override fun listRecursively(dir: Path, followSymlinks: Boolean): Sequence { listRecursivelyCount++ return super.listRecursively(dir, followSymlinks) } + + override fun list(dir: Path): List { + listCount++ + return super.list(dir) + } } @BeforeEach @@ -126,7 +133,7 @@ class SceneDatasourceCacheTest : BaseTest() { } @Test - fun `moveScene invalidates the cache`() = runTest { + fun `moveScene keeps the cache in step`() = runTest { val sourceId = 1 val sourcePath = sceneDatasource.resolveScenePathFromFilesystem(sourceId) assertNotNull(sourcePath) @@ -140,5 +147,80 @@ class SceneDatasourceCacheTest : BaseTest() { val paths = sceneDatasource.getAllScenePaths().map { it.toOkioPath() } assertTrue(paths.contains(targetPath.toOkioPath()), "Moved-to path must be present") assertTrue(!paths.contains(sourcePath.toOkioPath()), "Moved-from path must be gone") + assertEquals( + targetPath.toOkioPath(), + sceneDatasource.resolveScenePathFromFilesystem(sourceId)?.toOkioPath(), + "Moved scene must resolve to its new path", + ) + } + + @Test + fun `moving a scene to a new id re-indexes it`() = runTest { + // reIdScene moves a scene to a filename carrying a different id; the cached id index has to + // retire the old id rather than keep pointing it at the moved file. + val oldId = 1 + val newId = 997 + val sourcePath = sceneDatasource.resolveScenePathFromFilesystem(oldId) + assertNotNull(sourcePath) + + val targetPath = sourcePath.toOkioPath().parent!! + .div("1~Re-IDed~$newId.md").toHPath() + sceneDatasource.moveScene(sourcePath, targetPath) + + assertEquals( + targetPath.toOkioPath(), + sceneDatasource.resolveScenePathFromFilesystem(newId)?.toOkioPath(), + "Scene must resolve under its new id", + ) + assertEquals( + null, + sceneDatasource.resolveScenePathFromFilesystem(oldId), + "The retired id must no longer resolve", + ) + } + + @Test + fun `moving one of two files sharing a scene id keeps the winner resolvable`() = runTest { + // Duplicate ids on disk are tolerated, and the id resolves to the name-first of them. An + // incremental cache update cannot maintain that, so a move must fall back to a re-scan. + val sharedId = 1 + val winner = sceneDatasource.resolveScenePathFromFilesystem(sharedId) + assertNotNull(winner) + val loser = sceneDatasource.getSceneDirectory().toOkioPath() + .div("9~Duplicate~$sharedId.md").toHPath() + sceneDatasource.createNewGroup(loser) + assertEquals( + winner.toOkioPath(), + sceneDatasource.resolveScenePathFromFilesystem(sharedId)?.toOkioPath(), + "The name-first path owns the id", + ) + + val renamedLoser = sceneDatasource.getSceneDirectory().toOkioPath() + .div("09~Duplicate~$sharedId.md").toHPath() + sceneDatasource.moveScene(loser, renamedLoser) + + assertEquals( + winner.toOkioPath(), + sceneDatasource.resolveScenePathFromFilesystem(sharedId)?.toOkioPath(), + "Moving the duplicate must not steal the id from the winner", + ) + } + + @Test + fun `countScenes stays correct without re-listing the directory`() = runTest { + val sceneDir = sceneDatasource.getSceneDirectory() + val before = sceneDatasource.countScenes(sceneDir) + val listsBefore = countingFs.listCount + + val newId = 998 + val newPath = sceneDir.toOkioPath().div("6~Counted~$newId.md").toHPath() + sceneDatasource.createNewGroup(newPath) + + assertEquals(before + 1, sceneDatasource.countScenes(sceneDir), "New scene must be counted") + assertEquals( + listsBefore, + countingFs.listCount, + "Counting children must come from the cached scan, not a fresh directory listing", + ) } } diff --git a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneOrderRescanTest.kt b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneOrderRescanTest.kt new file mode 100644 index 000000000..adf2760d7 --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneOrderRescanTest.kt @@ -0,0 +1,111 @@ +package repositories.sceneeditor + +import PROJECT_1_NAME +import com.darkrockstudios.apps.hammer.common.data.id.IdAllocator +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneDatasource +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadataDatasource +import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.SyncJournal +import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer +import createProject +import getProjectDef +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okio.ForwardingFileSystem +import okio.Path +import okio.fakefilesystem.FakeFileSystem +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import utils.BaseTest +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * Re-padding a directory's order digits renames every sibling in turn. Each rename drops the path + * cache, so the loop stays linear only while it reads nothing from that cache between renames. This + * pins that: put a cache read back inside the loop and the scan count grows with the sibling count. + */ +class SceneOrderRescanTest : BaseTest() { + + private lateinit var ffs: FakeFileSystem + private lateinit var countingFs: CountingFileSystem + private lateinit var repository: SceneRepository + + private class CountingFileSystem(delegate: FakeFileSystem) : ForwardingFileSystem(delegate) { + var listRecursivelyCount = 0 + private set + + override fun listRecursively(dir: Path, followSymlinks: Boolean): Sequence { + listRecursivelyCount++ + return super.listRecursively(dir, followSymlinks) + } + } + + @BeforeEach + override fun setup() { + super.setup() + ffs = FakeFileSystem() + createProject(ffs, PROJECT_1_NAME) + countingFs = CountingFileSystem(ffs) + + val projectDef = getProjectDef(PROJECT_1_NAME) + val toml = createTomlSerializer() + val syncJournal: SyncJournal = mockk(relaxed = true) + every { syncJournal.isServerSynchronized() } returns false + val idAllocator: IdAllocator = mockk(relaxed = true) + coEvery { idAllocator.claimNextId() } returnsMany (100..400).toList() + + val datasource = SceneDatasource(projectDef, countingFs) + repository = SceneRepository( + projectDef = projectDef, + syncJournal = syncJournal, + idAllocator = idAllocator, + sceneMetadataDatasource = SceneMetadataDatasource(countingFs, toml, projectDef), + sceneDatasource = datasource, + clock = Clock.System, + ) + } + + @AfterEach + override fun tearDown() { + super.tearDown() + ffs.checkNoOpenFiles() + } + + @Test + fun `crossing the order-digit boundary does not re-scan once per renamed sibling`() = runTest { + repository.initializeSceneEditor() + + // Ten siblings means the next order is 10, which widens the field and re-pads all of them. + while (repository.rawTree.root().numChildrenImmedate() < 10) { + assertNotNull(repository.createScene(parent = null, sceneName = "Filler")) + } + val siblingsBefore = repository.rawTree.root().numChildrenImmedate() + val scansBefore = countingFs.listRecursivelyCount + + // This create takes the order to 10, re-padding every existing sibling to two digits. + assertNotNull(repository.createScene(parent = null, sceneName = "Boundary")) + + val scansForRepad = countingFs.listRecursivelyCount - scansBefore + assertTrue( + scansForRepad < siblingsBefore, + "Re-padding $siblingsBefore siblings took $scansForRepad scans; it must not scale with them", + ) + + // The renames still have to land correctly. + assertEquals( + siblingsBefore + 1, + repository.rawTree.root().numChildrenImmedate(), + "Every sibling survives the re-pad", + ) + assertTrue( + repository.getScenes().all { repository.resolveScenePathFromFilesystem(it.id) != null }, + "Every scene in the project resolves at its new name", + ) + } +} diff --git a/common/src/desktopTest/kotlin/repositories/sceneeditor/ScenePathIndexTest.kt b/common/src/desktopTest/kotlin/repositories/sceneeditor/ScenePathIndexTest.kt new file mode 100644 index 000000000..a285d3774 --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/sceneeditor/ScenePathIndexTest.kt @@ -0,0 +1,73 @@ +package repositories.sceneeditor + +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.ScenePathIndex +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath +import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Covers the two answers the index gives that its callers cannot check for themselves: which of two + * files claiming one scene id owns it, and whether a directory is empty or simply unseen. The rest + * of its behaviour is covered through the datasource in [SceneDatasourceCacheTest]. + */ +class ScenePathIndexTest { + + private val sceneDir = "/proj/.scenes".toPath() + + private fun scenePath(name: String): HPath = sceneDir.div(name).toHPath() + + @Test + fun `the name-first file owns a shared scene id`() { + val incumbent = scenePath("1~Alpha~5.md") + val index = ScenePathIndex { listOf(incumbent) } + index.paths() + + // A later arrival sorting before the incumbent takes the id; one sorting after does not. + index.onCreated(scenePath("0~Aardvark~5.md")) + assertEquals( + scenePath("0~Aardvark~5.md").toOkioPath(), + index.pathFor(5)?.toOkioPath(), + "The name-first duplicate owns the id", + ) + + index.onCreated(scenePath("9~Omega~5.md")) + assertEquals( + scenePath("0~Aardvark~5.md").toOkioPath(), + index.pathFor(5)?.toOkioPath(), + "A later-sorting duplicate does not take it", + ) + } + + @Test + fun `an unseen directory reports unknown rather than empty`() { + // countScenePathsIn reads the disk for these, because order numbers derive from the count + // and treating an unseen directory as empty would mis-number a real one. + val index = ScenePathIndex { listOf(scenePath("1~One~1.md")) } + index.paths() + + assertEquals(1, index.childCountOrNull(sceneDir.toHPath()), "A scanned directory is counted") + assertNull(index.childCountOrNull(scenePath("7~Unseen~7")), "An unseen one is unknown") + } + + @Test + fun `a created file is absorbed without re-scanning`() { + var scans = 0 + val existing = scenePath("1~One~1.md") + val index = ScenePathIndex { scans++; listOf(existing) } + index.paths() + index.pathFor(1) + index.childCountOrNull(sceneDir.toHPath()) + val scansBefore = scans + + index.onCreated(scenePath("2~Two~2.md")) + + assertEquals(2, index.paths().size, "The new file is listed") + assertEquals(scenePath("2~Two~2.md").toOkioPath(), index.pathFor(2)?.toOkioPath()) + assertEquals(2, index.childCountOrNull(sceneDir.toHPath()), "It is counted in its directory") + assertEquals(scansBefore, scans, "Creating a file must not cost a re-scan") + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt index 41bf4b7e1..33de35035 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.common.projectselection import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Folder @@ -38,6 +40,7 @@ fun ImportStoryDialog( projectName: String, options: ImportOptions, preview: ImportPreview, + isParsing: Boolean, onProjectNameChange: (String) -> Unit, onCancel: () -> Unit, onOptionsChange: (ImportOptions) -> Unit, @@ -58,6 +61,7 @@ fun ImportStoryDialog( projectName = projectName, options = options, preview = preview, + isParsing = isParsing, onProjectNameChange = onProjectNameChange, onCancel = onCancel, onOptionsChange = onOptionsChange, @@ -72,6 +76,7 @@ internal fun ImportStoryContent( projectName: String, options: ImportOptions, preview: ImportPreview, + isParsing: Boolean, onProjectNameChange: (String) -> Unit, onCancel: () -> Unit, onOptionsChange: (ImportOptions) -> Unit, @@ -98,6 +103,7 @@ internal fun ImportStoryContent( ImportMasthead( options = options, preview = preview, + isParsing = isParsing, onClose = onCancel, ) HdFolioDivider() @@ -168,13 +174,13 @@ internal fun ImportStoryContent( Spacer(modifier = Modifier.height(Ui.Padding.XL)) - ImportPreviewPane(preview, Modifier.weight(1f)) + ImportPreviewPane(preview, isParsing, Modifier.weight(1f)) } ImportFooter( onCancel = onCancel, onConfirm = onConfirm, - confirmEnabled = !preview.isEmpty && nameValidation.isValid, + confirmEnabled = !isParsing && !preview.isEmpty && nameValidation.isValid, ) } } @@ -184,16 +190,19 @@ internal fun ImportStoryContent( private fun ImportMasthead( options: ImportOptions, preview: ImportPreview, + isParsing: Boolean, onClose: () -> Unit, ) { - val meta = remember(options, preview.totalScenes, preview.isEmpty) { + val meta = remember(options, preview.totalScenes, preview.isEmpty, isParsing) { buildList { add(options.format.metaLabel()) when (options.format) { ImportFormat.Markdown -> add(options.chapterHeadingLevel.name.uppercase()) ImportFormat.Rtf -> add(options.rtfSplitStrategy.name.uppercase()) } - if (!preview.isEmpty) { + if (isParsing) { + add("READING") + } else if (!preview.isEmpty) { add("${preview.totalScenes} SCENES") } } @@ -242,7 +251,11 @@ private fun ImportFooter( } @Composable -private fun ImportPreviewPane(preview: ImportPreview, modifier: Modifier = Modifier) { +private fun ImportPreviewPane( + preview: ImportPreview, + isParsing: Boolean, + modifier: Modifier = Modifier, +) { Column(modifier) { Row( modifier = Modifier.fillMaxWidth(), @@ -250,7 +263,7 @@ private fun ImportPreviewPane(preview: ImportPreview, modifier: Modifier = Modif verticalAlignment = Alignment.CenterVertically, ) { HdMonoLabel(Res.string.project_home_import_preview_label.get()) - if (!preview.isEmpty) { + if (!isParsing && !preview.isEmpty) { HdMonoLabel( Res.string.project_home_import_preview_count.get( preview.totalScenes, @@ -269,7 +282,20 @@ private fun ImportPreviewPane(preview: ImportPreview, modifier: Modifier = Modif shape = RectangleShape, ), ) { - if (preview.isEmpty) { + if (isParsing) { + Row( + modifier = Modifier.fillMaxWidth().padding(Ui.Padding.L), + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + HdMonoLabel(Res.string.project_home_import_preview_reading.get()) + } + } else if (preview.isEmpty) { Box( modifier = Modifier .fillMaxWidth() @@ -279,20 +305,14 @@ private fun ImportPreviewPane(preview: ImportPreview, modifier: Modifier = Modif HdMonoLabel(Res.string.project_home_import_preview_empty.get()) } } else { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(Ui.Padding.M), - ) { - preview.items.forEach { item -> - when (item) { - is PreviewItem.Scene -> PreviewSceneRow(item.name, indented = false) - is PreviewItem.Group -> { - PreviewGroupRow(item.name) - item.scenes.forEach { childScene -> - PreviewSceneRow(childScene.name, indented = true) - } - } + // A manuscript can split into thousands of scenes; only compose the visible rows. + val rows = remember(preview) { preview.toRows() } + LazyColumn(contentPadding = PaddingValues(Ui.Padding.M)) { + items(rows) { row -> + if (row.isGroup) { + PreviewGroupRow(row.name) + } else { + PreviewSceneRow(row.name, indented = row.indented) } } } @@ -301,6 +321,21 @@ private fun ImportPreviewPane(preview: ImportPreview, modifier: Modifier = Modif } } +/** One flat row of the preview tree: a group header, or a scene at top level or inside a group. */ +private class PreviewRow(val name: String, val isGroup: Boolean, val indented: Boolean) + +private fun ImportPreview.toRows(): List = buildList { + items.forEach { item -> + when (item) { + is PreviewItem.Scene -> add(PreviewRow(item.name, isGroup = false, indented = false)) + is PreviewItem.Group -> { + add(PreviewRow(item.name, isGroup = true, indented = false)) + item.scenes.forEach { add(PreviewRow(it.name, isGroup = false, indented = true)) } + } + } + } +} + @Composable private fun PreviewGroupRow(name: String) { Row( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt index 726b903d9..87c3b820e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt @@ -312,6 +312,7 @@ fun ProjectListUi( projectName = state.importProjectName, options = state.importOptions, preview = state.importPreview, + isParsing = state.isParsingImport, onProjectNameChange = component::updateImportProjectName, onCancel = component::cancelImportDialog, onOptionsChange = component::updateImportOptions, diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt index f558f5b7f..48fbca899 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt @@ -38,6 +38,34 @@ fun ImportStoryDialogPreview() { projectName = "Alice In Wonderland 2", options = ImportOptions(), preview = preview, + isParsing = false, + onProjectNameChange = {}, + onCancel = {}, + onOptionsChange = {}, + onConfirm = {}, + ) + } + } + } +} + +/** The window between the file picker closing and the parse landing. */ +@Preview(widthDp = 640, heightDp = 560) +@Composable +fun ImportStoryDialogParsingPreview() { + KoinApplicationPreview { + AppTheme(globalSettingsPreview, true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + ImportStoryContent( + projectName = "Alice In Wonderland 2", + options = ImportOptions(), + preview = ImportPreview(items = emptyList()), + isParsing = true, onProjectNameChange = {}, onCancel = {}, onOptionsChange = {}, From 5acba72867eddeb4fa6525476b53f05db85b023b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 30 Jul 2026 22:16:41 -0700 Subject: [PATCH 004/213] Tell self-hosters when the server is not serving HTTPS (#800) Clients are HTTPS-only, but the Docker image serves plain HTTP, so a client pointed at it fails its TLS handshake and reported only "Network error connecting to /api/account/create". The handshake is rejected by Jetty's HTTP parser before any route runs, so nothing about it reaches the server log either, leaving unrelated UnsupportedProtocolVersionException entries as the only visible clue. Split the IOException arm of Api.makeRequest so a TLS failure names HTTPS and the certificate or reverse-proxy requirement. Detection is expect/actual: SSLException on JVM, message markers on iOS where NSURLSession carries nothing else through. Also fix the server URL field on the way in: cleanUpUrl stripped the scheme with removeSuffix instead of removePrefix, so a pasted http:// URL failed validation, and validateUrl required a dotted TLD, rejected capitals, and admitted ports above 65535 that would crash the unguarded toInt() in url(). Fixes #790 --- .../values/strings_account_settings.xml | 1 + .../AccountSettingsComponent.kt | 23 ++-- .../apps/hammer/common/server/Api.kt | 32 +++-- .../apps/hammer/common/server/TlsFailure.kt | 13 ++ .../ServerUrlValidationTest.kt | 91 ++++++++++++++ .../kotlin/server/ApiTlsFailureTest.kt | 112 ++++++++++++++++++ .../hammer/common/server/TlsFailure.ios.kt | 12 ++ .../hammer/common/server/TlsFailure.jvm.kt | 5 + docker/docker-compose.yml | 3 + docs/HOW-TO-RUN-A-SERVER-DOCKER.md | 22 +++- docs/HOW-TO-RUN-A-SERVER.md | 5 +- 11 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.kt create mode 100644 common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt create mode 100644 common/src/desktopTest/kotlin/server/ApiTlsFailureTest.kt create mode 100644 common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.ios.kt create mode 100644 common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.jvm.kt diff --git a/common/src/commonMain/composeResources/values/strings_account_settings.xml b/common/src/commonMain/composeResources/values/strings_account_settings.xml index 7c0fcb000..5420d5752 100644 --- a/common/src/commonMain/composeResources/values/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values/strings_account_settings.xml @@ -144,5 +144,6 @@ Network error connecting to %1$s. Check your connection and try again. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt index 25cfbdd33..e2acb17d8 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt @@ -454,19 +454,28 @@ class AccountSettingsComponent( } companion object { - // regex to validate url with port number - private val urlWithPortRegex = - Regex("""^([a-z0-9]+\.)*([a-z0-9]+)(\.[a-z]+)(:[0-9]{1,5})?$""") - private val ipWithPortRegex = Regex("""^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?$""") + /** A host name or IPv4 address: dot-separated labels of letters, digits and inner hyphens. */ + private val hostRegex = + Regex("""^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$""") fun validateUrl(url: String): Boolean { - return url.isNotBlank() && (urlWithPortRegex.matches(url) || ipWithPortRegex.matches(url)) + if (url.isBlank()) return false + + val host = url.substringBefore(':') + if (hostRegex.matches(host).not()) return false + + if (url.contains(':')) { + val port = url.substringAfter(':').toIntOrNull() ?: return false + if (port !in 1..65535) return false + } + + return true } fun cleanUpUrl(url: String): String { var cleanUrl: String = url.trim() - cleanUrl = cleanUrl.removeSuffix("http://") - cleanUrl = cleanUrl.removeSuffix("https://") + cleanUrl = cleanUrl.removePrefix("http://") + cleanUrl = cleanUrl.removePrefix("https://") cleanUrl = cleanUrl.removeSuffix("/") return cleanUrl diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/Api.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/Api.kt index 7eaadf406..c11c0b268 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/Api.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/Api.kt @@ -16,6 +16,7 @@ import com.darkrockstudios.apps.hammer.server_error_connection_generic import com.darkrockstudios.apps.hammer.server_error_connection_timeout import com.darkrockstudios.apps.hammer.server_error_dns import com.darkrockstudios.apps.hammer.server_error_timeout +import com.darkrockstudios.apps.hammer.server_error_tls import com.darkrockstudios.apps.hammer.sync_general_error import com.darkrockstudios.apps.hammer.sync_unauthorized import io.github.aakira.napier.Napier @@ -151,17 +152,30 @@ abstract class Api( ) ) } catch (e: IOException) { - // Generic network error (connection refused, SSL errors, etc.) - Napier.e("Network Error", e) - Result.failure( - HttpFailureException( - statusCode = outerResponse?.status ?: HttpStatusCode.RequestTimeout, - error = HttpResponseError( - error = e.message ?: "Network Error", - displayMessage = strRes.get(Res.string.server_error_connection_generic, path), + if (e.isTlsFailure()) { + Napier.e("TLS Error", e) + Result.failure( + HttpFailureException( + statusCode = outerResponse?.status ?: HttpStatusCode.BadGateway, + error = HttpResponseError( + error = e.message ?: "TLS Error", + displayMessage = strRes.get(Res.string.server_error_tls, server.url), + ) ) ) - ) + } else { + // Generic network error (connection refused, etc.) + Napier.e("Network Error", e) + Result.failure( + HttpFailureException( + statusCode = outerResponse?.status ?: HttpStatusCode.RequestTimeout, + error = HttpResponseError( + error = e.message ?: "Network Error", + displayMessage = strRes.get(Res.string.server_error_connection_generic, path), + ) + ) + ) + } } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.kt new file mode 100644 index 000000000..7d4978285 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.kt @@ -0,0 +1,13 @@ +package com.darkrockstudios.apps.hammer.common.server + +/** + * True when this throwable, or anything it wraps, is a TLS handshake or certificate failure. + * + * Clients only ever speak HTTPS, so this is what a server reachable only over plain HTTP looks + * like from the client side, and it needs a different message than a generic network error. + */ +expect fun Throwable.isTlsFailure(): Boolean + +/** Walks the `cause` chain, bounded so a self-referential or cyclic cause cannot spin forever. */ +internal fun Throwable.causeChain(): Sequence = + generateSequence(this) { throwable -> throwable.cause?.takeIf { it !== throwable } }.take(16) diff --git a/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt new file mode 100644 index 000000000..e21e50bba --- /dev/null +++ b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt @@ -0,0 +1,91 @@ +package components.projectselection + +import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AccountSettingsComponent.Companion.cleanUpUrl +import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AccountSettingsComponent.Companion.validateUrl +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class ServerUrlValidationTest { + + @Test + fun `a pasted http scheme is stripped`() { + assertEquals("192.168.1.50:8080", cleanUpUrl("http://192.168.1.50:8080")) + } + + @Test + fun `a pasted https scheme is stripped`() { + assertEquals("hammer.example.com", cleanUpUrl("https://hammer.example.com")) + } + + @Test + fun `surrounding whitespace and a trailing slash are stripped`() { + assertEquals("hammer.example.com", cleanUpUrl(" https://hammer.example.com/ ")) + } + + @Test + fun `a url with no scheme is left alone`() { + assertEquals("hammer.ink", cleanUpUrl("hammer.ink")) + } + + @Test + fun `a cleaned pasted url validates`() { + assertTrue(validateUrl(cleanUpUrl("http://192.168.1.50:8080"))) + } + + @Test + fun `a domain with a port validates`() { + assertTrue(validateUrl("hammer.example.com:8080")) + } + + @Test + fun `a bare lan hostname validates`() { + assertTrue(validateUrl("homeserver:8080")) + } + + @Test + fun `a hostname with a hyphen validates`() { + assertTrue(validateUrl("home-server.lan:8080")) + } + + @Test + fun `a mixed case hostname validates`() { + assertTrue(validateUrl("Hammer.Example.com")) + } + + @Test + fun `an ip address validates`() { + assertTrue(validateUrl("192.168.1.50")) + } + + @Test + fun `a blank url does not validate`() { + assertFalse(validateUrl(" ")) + } + + @Test + fun `a url still carrying its scheme does not validate`() { + assertFalse(validateUrl("http://hammer.example.com")) + } + + @Test + fun `a url with a path does not validate`() { + assertFalse(validateUrl("hammer.example.com/sync")) + } + + @Test + fun `a non numeric port does not validate`() { + assertFalse(validateUrl("hammer.example.com:port")) + } + + @Test + fun `a port beyond the valid range does not validate`() { + assertFalse(validateUrl("hammer.example.com:99999")) + } + + @Test + fun `a host with a trailing hyphen does not validate`() { + assertFalse(validateUrl("hammer-.example.com")) + } +} diff --git a/common/src/desktopTest/kotlin/server/ApiTlsFailureTest.kt b/common/src/desktopTest/kotlin/server/ApiTlsFailureTest.kt new file mode 100644 index 000000000..a579da08f --- /dev/null +++ b/common/src/desktopTest/kotlin/server/ApiTlsFailureTest.kt @@ -0,0 +1,112 @@ +package server + +import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings +import com.darkrockstudios.apps.hammer.common.server.HttpFailureException +import com.darkrockstudios.apps.hammer.common.server.ServerAccountApi +import com.darkrockstudios.apps.hammer.common.util.DeviceLocaleResolver +import com.darkrockstudios.apps.hammer.common.util.StrRes +import com.darkrockstudios.apps.hammer.server_error_connection_generic +import com.darkrockstudios.apps.hammer.server_error_tls +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.StringResource +import org.junit.jupiter.api.BeforeEach +import org.koin.dsl.module +import utils.BaseTest +import java.net.ConnectException +import javax.net.ssl.SSLHandshakeException +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * A server reachable only over plain HTTP fails the client's TLS handshake, which needs to read as + * "this server isn't serving HTTPS" rather than as a generic connectivity problem. + */ +class ApiTlsFailureTest : BaseTest() { + + private lateinit var globalSettingsStore: GlobalSettingsStore + private lateinit var strRes: RecordingStrRes + + @BeforeEach + override fun setup() { + super.setup() + + strRes = RecordingStrRes() + globalSettingsStore = mockk(relaxed = true) + every { globalSettingsStore.serverSettings } returns ServerSettings( + url = "homeserver:8080", + email = "user@example.com", + userId = -1L, + bearerToken = null, + refreshToken = null, + ) + + setupKoin( + module { + single { DeviceLocaleResolver() } + } + ) + } + + private fun createApi(failure: Throwable): ServerAccountApi { + val client = HttpClient(MockEngine { throw failure }) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + } + return ServerAccountApi(client, globalSettingsStore, strRes) + } + + @Test + fun `a handshake failure reports the server is not serving https`() = runTest { + val api = createApi(SSLHandshakeException("Remote host terminated the handshake")) + + val result = api.createAccount("user@example.com", "password", "install-id") + + assertIs(result.exceptionOrNull()) + assertTrue(strRes.requested.contains(Res.string.server_error_tls)) + } + + @Test + fun `a wrapped handshake failure is still recognised`() = runTest { + val api = createApi( + java.io.IOException("request failed", SSLHandshakeException("unable to find valid certification path")) + ) + + val result = api.createAccount("user@example.com", "password", "install-id") + + assertIs(result.exceptionOrNull()) + assertTrue(strRes.requested.contains(Res.string.server_error_tls)) + } + + @Test + fun `an ordinary connection failure keeps the generic message`() = runTest { + val api = createApi(ConnectException("Connection refused")) + + val result = api.createAccount("user@example.com", "password", "install-id") + + assertIs(result.exceptionOrNull()) + assertTrue(strRes.requested.contains(Res.string.server_error_connection_generic)) + } +} + +private class RecordingStrRes : StrRes { + val requested = mutableListOf() + + override suspend fun get(str: StringResource): String { + requested += str + return "test" + } + + override suspend fun get(str: StringResource, vararg args: Any): String { + requested += str + return "test" + } +} diff --git a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.ios.kt b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.ios.kt new file mode 100644 index 000000000..2ccfb9fb2 --- /dev/null +++ b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.ios.kt @@ -0,0 +1,12 @@ +package com.darkrockstudios.apps.hammer.common.server + +/** + * NSURLSession surfaces TLS problems as an `NSError` whose localized description names the + * failure, which is all the Darwin engine carries through into the exception message. + */ +private val TLS_MARKERS = listOf("ssl", "tls", "certificate", "secure connection") + +actual fun Throwable.isTlsFailure(): Boolean = causeChain().any { throwable -> + val text = "${throwable::class.simpleName.orEmpty()} ${throwable.message.orEmpty()}" + TLS_MARKERS.any { marker -> text.contains(marker, ignoreCase = true) } +} diff --git a/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.jvm.kt b/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.jvm.kt new file mode 100644 index 000000000..c4395cc25 --- /dev/null +++ b/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/server/TlsFailure.jvm.kt @@ -0,0 +1,5 @@ +package com.darkrockstudios.apps.hammer.common.server + +import javax.net.ssl.SSLException + +actual fun Throwable.isTlsFailure(): Boolean = causeChain().any { it is SSLException } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e02f81cd7..19dfd0a5c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,6 +21,9 @@ services: # Loopback by default: this connector is plain HTTP, and published ports # bypass host firewall rules. Set HAMMER_HTTP_BIND=0.0.0.0 only if nothing # untrusted can reach the host. HAMMER_HTTP_PORT moves the host-side port. + # + # Clients only speak HTTPS and will not connect to this port. Terminate TLS + # at a reverse proxy in front of it, or configure sslCert and publish 443. - "${HAMMER_HTTP_BIND:-127.0.0.1}:${HAMMER_HTTP_PORT:-8080}:8080" # Uncomment alongside the postgres service below. diff --git a/docs/HOW-TO-RUN-A-SERVER-DOCKER.md b/docs/HOW-TO-RUN-A-SERVER-DOCKER.md index d2582464a..e36a88c8f 100644 --- a/docs/HOW-TO-RUN-A-SERVER-DOCKER.md +++ b/docs/HOW-TO-RUN-A-SERVER-DOCKER.md @@ -32,8 +32,14 @@ docker run -d --name hammer-server \ ghcr.io/darkrock-studios/hammer-editor/server:latest ``` -Then **download a client and create an account**. The first account created -becomes the admin account. +That gets the server running, but **a client cannot talk to it yet**. Hammer +clients only speak HTTPS, and the container serves plain HTTP, so put TLS in +front of it first: see [Networking](#networking) below. A client pointed at the +cleartext port fails its TLS handshake and reports that it could not make a +secure connection. + +Once TLS is in place, **download a client and create an account**. The first +account created becomes the admin account. ## Networking @@ -55,6 +61,18 @@ To change the host-side binding or port: HAMMER_HTTP_BIND=0.0.0.0 HAMMER_HTTP_PORT=8090 docker compose up -d ``` +### A client that will not connect + +If a client reports that it could not make a secure connection, it reached the +cleartext port and got no TLS. Add a reverse proxy or a certificate as above. + +The server log is misleading here. A client's TLS handshake is rejected by the +HTTP parser before any route runs, so nothing about it appears in the +application log. What you will see instead are `UnsupportedProtocolVersionException` +entries from your own browser or `curl` hitting `/api/...`, since only Hammer +clients send the `X-Hammer-Protocol-Version` header. Those are unrelated to the +client's failure. + ### Do not set `bindHosts` [HOW-TO-RUN-A-SERVER.md](HOW-TO-RUN-A-SERVER.md#reverse-proxy-using-nginx) tells diff --git a/docs/HOW-TO-RUN-A-SERVER.md b/docs/HOW-TO-RUN-A-SERVER.md index 50468e1fa..ee2db1cd9 100644 --- a/docs/HOW-TO-RUN-A-SERVER.md +++ b/docs/HOW-TO-RUN-A-SERVER.md @@ -53,7 +53,10 @@ The Hammer server is a Java application that runs on Windows, Linux, and macOS. 4. Run the server (_see platform-specific instructions below_) 5. If everything worked, you should be able to access your server at your server's IP or at the host name you set in your config file and DNS configuration, such as: `http://example.com` -6. **IMPORTANT!** You must now download one of the clients and create an account on the server. The first account +6. **Set up HTTPS before going any further.** Clients only speak `https` and will not connect to a + plain HTTP server, so it needs either its own certificate or a reverse proxy holding one. See + [Setting up SSL](#setting-up-ssl-required-for-direct-connections). +7. **IMPORTANT!** You must now download one of the clients and create an account on the server. The first account created will be the admin account. ## Network binding From ff8b27819525270dd4c033df9224239bec0eb984 Mon Sep 17 00:00:00 2001 From: Xander Smeets Date: Fri, 31 Jul 2026 17:50:51 +0200 Subject: [PATCH 005/213] Set file permissions correctly immediately (#799) Perform the chown on the files copied into /opt/hammer during the COPY instruction, instead of during a separate RUN instruction. This prevents the files from being 'duplicated' due to having been changed on the layer for the RUN instruction. Effectively, this reduces the size of the layer for the RUN instruction from about 211 MB to approximately 0. --- docker/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index af6b30398..293637c61 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,13 +29,13 @@ RUN groupadd --gid 1000 hammer \ ENV SERVER_OPTS="-Duser.home=/data" ARG DIST_DIR=server/build/install/server -COPY ${DIST_DIR}/ /opt/hammer/ +COPY --chown=1000:1000 ${DIST_DIR}/ /opt/hammer/ # hammer_data itself must exist and be owned here: bind-mounting a config file # into it would otherwise have Docker create the parent as root, leaving the # unprivileged server unable to write pgdata. RUN mkdir -p /data/hammer_data \ - && chown -R hammer:hammer /data /opt/hammer + && chown -R hammer:hammer /data USER hammer WORKDIR /opt/hammer From eeba953897ac4a5187777b8c2d441f16dee4cbb9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 31 Jul 2026 17:06:16 -0700 Subject: [PATCH 006/213] Fix download link --- server/src/main/resources/templates/home.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/resources/templates/home.mustache b/server/src/main/resources/templates/home.mustache index a67727c23..bc2800471 100644 --- a/server/src/main/resources/templates/home.mustache +++ b/server/src/main/resources/templates/home.mustache @@ -429,7 +429,7 @@ F-Droid From 94c3465b0ed0172b0b0721296a465e0ed02f1209 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 31 Jul 2026 21:15:45 -0700 Subject: [PATCH 007/213] Fix literal ** and _ around RTF-imported emphasis (#806) RTF writers routinely include the surrounding spaces inside a formatting run, so the importer emitted "** Chapter One **", which CommonMark does not parse as emphasis. Emphasis delimiters now hug the text: leading and trailing whitespace of a run moves outside the markers and a run made only of whitespace opens nothing. Italics emit "*" instead of "_" so intra-word emphasis renders, and the escape set now mirrors ComposeTextEditor's MARKDOWN_SPECIAL_CHARS plus its ordered-list marker escaping, so a paragraph starting with "- " or "1. " no longer imports as a list. Fixes #803 --- .../common/data/importer/RtfStoryImporter.kt | 59 ++++++--- .../data/importer/RtfStoryImporterTest.kt | 113 ++++++++++++++++++ 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporter.kt index 1c2381d25..cd7cabd97 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporter.kt @@ -157,7 +157,9 @@ class RtfStoryImporter : StoryImporter { /** * Walks the body destinations of an RTF document, emitting one [RtfParagraph] per `\par`. Tracks * group-scoped character formatting (bold/italic/font size) and paragraph properties (outline - * level), wrapping bold/italic runs in Markdown the way the library's own converter does. + * level), wrapping bold/italic runs in Markdown. Delimiters hug the emphasized text: RTF writers + * routinely put the surrounding spaces inside the run, and CommonMark rejects emphasis with + * whitespace immediately inside the markers. */ private class RtfParagraphCollector : com.darkrockstudios.libs.rtfparserkmp.parser.RtfListener { val paragraphs = mutableListOf() @@ -195,9 +197,8 @@ class RtfStoryImporter : StoryImporter { override fun processString(string: String) { if (!isBodyDestination()) return - openEmphasis() plain.append(string) - markdown.append(escape(string)) + appendMarkdown(string) for (ch in string) { if (ch.isWhitespace()) continue totalChars++ @@ -241,7 +242,7 @@ class RtfStoryImporter : StoryImporter { private fun endParagraph() { closeEmphasis() - val md = markdown.toString().trim() + val md = escapeOrderedListMarkers(markdown.toString().trim()) val text = plain.toString().trim() val dominantSize = sizeCounts.maxByOrNull { it.value }?.key val mostlyBold = totalChars > 0 && boldChars.toDouble() / totalChars >= BOLD_RATIO @@ -287,6 +288,21 @@ class RtfStoryImporter : StoryImporter { Marker.ITALIC -> italic } + /** + * Opens pending emphasis at the first non-whitespace character, so a run's leading whitespace + * stays outside the delimiters and an all-whitespace run opens nothing. + */ + private fun appendMarkdown(text: String) { + val firstVisible = text.indexOfFirst { !it.isWhitespace() } + if (firstVisible < 0) { + markdown.append(text) + return + } + markdown.append(text.substring(0, firstVisible)) + openEmphasis() + markdown.append(escape(text.substring(firstVisible))) + } + private fun openEmphasis() { if (bold && Marker.BOLD !in openMarkers) { markdown.append(Marker.BOLD.text) @@ -298,28 +314,36 @@ class RtfStoryImporter : StoryImporter { } } + /** + * Closes every marker down to the shallowest inactive one. Still-active markers come off the + * stack too and are reopened by [openEmphasis] at the next non-whitespace character. + */ private fun closeDisabledMarkers() { val deepestDisabled = openMarkers.indexOfFirst { !isActive(it) } if (deepestDisabled < 0) return - val reopen = ArrayDeque() while (openMarkers.size > deepestDisabled) { - val top = openMarkers.removeLast() - markdown.append(top.text) - if (isActive(top)) reopen.addLast(top) - } - while (reopen.isNotEmpty()) { - val m = reopen.removeLast() - markdown.append(m.text) - openMarkers.addLast(m) + closeMarker(openMarkers.removeLast()) } } private fun closeEmphasis() { while (openMarkers.isNotEmpty()) { - markdown.append(openMarkers.removeLast().text) + closeMarker(openMarkers.removeLast()) } } + /** Emits [marker] ahead of any trailing whitespace, keeping the delimiter against the text. */ + private fun closeMarker(marker: Marker) { + markdown.insert(markdown.length - trailingWhitespaceLength(), marker.text) + } + + private fun trailingWhitespaceLength(): Int = + markdown.length - (markdown.indexOfLast { !it.isWhitespace() } + 1) + + /** Keeps an imported paragraph that starts with "1." from parsing as an ordered list. */ + private fun escapeOrderedListMarkers(text: String): String = + text.replace(ORDERED_LIST_MARKER, "$1\\\\.") + private fun escape(text: String): String { val out = StringBuilder(text.length) for (ch in text) { @@ -333,12 +357,15 @@ class RtfStoryImporter : StoryImporter { private enum class Marker(val text: String) { BOLD("**"), - ITALIC("_"), + ITALIC("*"), } private companion object { const val BOLD_RATIO = 0.8 - val MARKDOWN_SPECIAL = "\\`*_{}[]<>#".toSet() + + /** Mirrors `MARKDOWN_SPECIAL_CHARS` in ComposeTextEditor's Markdown escaper. */ + val MARKDOWN_SPECIAL = "\\`*_{}[]()<>#+-!|".toSet() + val ORDERED_LIST_MARKER = Regex("^(\\d+)\\.", RegexOption.MULTILINE) } } } diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporterTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporterTest.kt index dbfc3aaac..8ce960c2c 100644 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporterTest.kt +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/RtfStoryImporterTest.kt @@ -2,6 +2,9 @@ package com.darkrockstudios.apps.hammer.common.data.importer import com.darkrockstudios.apps.hammer.common.data.ImportOptions import com.darkrockstudios.apps.hammer.common.data.RtfSplitStrategy +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -29,6 +32,16 @@ class RtfStoryImporterTest { ), ) + /** Imports [body] as one unsplit scene, so assertions see the paragraph Markdown verbatim. */ + private fun singleScene(body: String): PreviewItem.Scene = + preview(body, strategy = RtfSplitStrategy.SingleScene).items[0] as PreviewItem.Scene + + private fun renderHtml(markdown: String): String { + val flavour = GFMFlavourDescriptor() + val parsed = MarkdownParser(flavour).buildMarkdownTreeFromString(markdown) + return HtmlGenerator(markdown, parsed, flavour).generateHtml() + } + @Test fun `Formatting splits chapters by outline level into groups and scenes`() { val body = """ @@ -111,6 +124,106 @@ class RtfStoryImporterTest { assertTrue(scene.markdown.contains("**strong**"), "Expected bold markdown, was: ${scene.markdown}") } + @Test + fun `Bold run whose whitespace is inside the run keeps the delimiters tight`() { + val scene = singleScene("""\fs24 A\b strong \b0 word.\par""") + + assertEquals("A **strong** word.", scene.markdown) + } + + @Test + fun `Bold run with trailing space before the paragraph end keeps the delimiters tight`() { + val scene = singleScene("""\fs24 She said \b run \b0\par""") + + assertEquals("She said **run**", scene.markdown) + } + + @Test + fun `Emphasis run of only whitespace emits no delimiters`() { + val scene = singleScene("""\fs24 word\b \b0 next.\par""") + + assertEquals("word next.", scene.markdown) + } + + @Test + fun `Bold spanning a tab keeps the delimiters against the text`() { + val scene = singleScene("""\fs24 A\b strong\tab\tab\b0 word.\par""") + + assertEquals("A **strong**\t\tword.", scene.markdown) + } + + @Test + fun `Bold ended by a group close keeps the delimiters tight`() { + val scene = singleScene("""\fs24 A {\b strong }word.\par""") + + assertEquals("A **strong** word.", scene.markdown) + } + + @Test + fun `Emphasis is closed at a hard line break`() { + val scene = singleScene("""\fs24 \b first\line second \b0 last.\par""") + + assertEquals("**first** \n**second** last.", scene.markdown) + } + + @Test + fun `Overlapping bold and italic runs stay balanced`() { + val scene = singleScene("""\fs24 \i one \b two \i0 three \b0 four.\par""") + + assertEquals("*one **two*** **three** four.", scene.markdown) + } + + @Test + fun `Italic uses asterisks so intra-word emphasis renders`() { + val scene = singleScene("""\fs24 foo\i bar\i0 baz.\par""") + + assertEquals("foo*bar*baz.", scene.markdown) + } + + @Test + fun `Nested bold and italic close inside the trailing whitespace`() { + val scene = singleScene("""\fs24 A \b\i very loud \i0\b0 word.\par""") + + assertEquals("A ***very loud*** word.", scene.markdown) + } + + @Test + fun `Emphasis that ends with the paragraph is still closed`() { + val scene = singleScene("""\fs24 He was \i gone\par""") + + assertEquals("He was *gone*", scene.markdown) + } + + @Test + fun `A paragraph starting with a dash is escaped instead of importing as a list`() { + val scene = singleScene("""\fs24 - a spoken line.\par""") + + assertEquals("\\- a spoken line.", scene.markdown) + } + + @Test + fun `A paragraph starting with a number is escaped instead of importing as a list`() { + val scene = singleScene("""\fs24 1. Never speak of it.\par""") + + assertEquals("1\\. Never speak of it.", scene.markdown) + } + + @Test + fun `Markdown special characters in prose are escaped`() { + val scene = singleScene("""\fs24 Wait! (really) 1+1 a|b\par""") + + assertEquals("Wait\\! \\(really\\) 1\\+1 a\\|b", scene.markdown) + } + + @Test + fun `Emphasis with whitespace inside the run renders as emphasis`() { + val scene = singleScene("""\fs24 She said \b run \b0 and\i then\i0 fled.\par""") + + val html = renderHtml(scene.markdown) + assertTrue(html.contains("run"), "Expected bold to render, was: $html") + assertTrue(html.contains("then"), "Expected italics to render, was: $html") + } + @Test fun `createChapterGroups wraps single-tier scenes in groups`() { val body = """ From cace720c9a38e749db2b28e420a2ae272273b111 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 31 Jul 2026 21:15:47 -0700 Subject: [PATCH 008/213] Detect Setext and bold chapter titles on Markdown import (#807) * Fix download link * Detect Setext and bold chapter titles on Markdown import Markdown import only recognized ATX headings, so a manuscript whose chapter titles were Setext-underlined or merely bolded collapsed into a single scene. - Setext headings (`===` level 1, `---` level 2). A `---` only underlines a line that opens a paragraph, so scene-break rules and front-matter fences stay body text. - In Auto, bold-only lines become chapters when heading markup produced at most a story title and several bold lines agree. - A Pattern strategy for Markdown mirroring the RTF one, with the chapter regex field wired through ImportOptions and the import dialog. ChapterHeadingLevel becomes MarkdownSplitStrategy now that it carries a non-level member. * Keep the word-count guard local to the Markdown importer Sharing it via ImportStructure meant touching RtfStoryImporter, which #806 is already rewriting. --- .../apps/hammer/common/data/ImportOptions.kt | 10 +- .../data/importer/MarkdownStoryImporter.kt | 169 ++++++-- .../importer/MarkdownStoryImporterTest.kt | 367 +++++++++++++++++- .../projectslist/ProjectsListComponentTest.kt | 10 +- .../projectselection/ImportStoryDialog.kt | 52 ++- .../ImportStoryDialog.Preview.kt | 34 ++ 6 files changed, 573 insertions(+), 69 deletions(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ImportOptions.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ImportOptions.kt index 6c645b561..5a967d5cf 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ImportOptions.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ImportOptions.kt @@ -5,20 +5,22 @@ import kotlinx.serialization.Serializable @Serializable enum class ImportFormat { Markdown, Rtf } +/** How a Markdown document is divided into scenes. */ @Serializable -enum class ChapterHeadingLevel { Auto, H1, H2 } +enum class MarkdownSplitStrategy { Auto, H1, H2, Pattern } /** How an RTF document is divided into scenes. */ @Serializable enum class RtfSplitStrategy { Formatting, Pattern, SingleScene } -const val DEFAULT_RTF_CHAPTER_PATTERN = "(?i)^(chapter|part|prologue|epilogue)\\b" +const val DEFAULT_CHAPTER_PATTERN = "(?i)^(chapter|part|prologue|epilogue)\\b" @Serializable data class ImportOptions( val format: ImportFormat = ImportFormat.Markdown, - val chapterHeadingLevel: ChapterHeadingLevel = ChapterHeadingLevel.Auto, + val markdownSplitStrategy: MarkdownSplitStrategy = MarkdownSplitStrategy.Auto, val createChapterGroups: Boolean = false, + val markdownChapterPattern: String = DEFAULT_CHAPTER_PATTERN, val rtfSplitStrategy: RtfSplitStrategy = RtfSplitStrategy.Formatting, - val rtfChapterPattern: String = DEFAULT_RTF_CHAPTER_PATTERN, + val rtfChapterPattern: String = DEFAULT_CHAPTER_PATTERN, ) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporter.kt index cacf54dad..1d9f3d003 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporter.kt @@ -1,9 +1,14 @@ package com.darkrockstudios.apps.hammer.common.data.importer -import com.darkrockstudios.apps.hammer.common.data.ChapterHeadingLevel import com.darkrockstudios.apps.hammer.common.data.ImportFormat import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy +/** + * Imports a Markdown document. Chapters come from ATX (`#`) and Setext (`===`/`---`) headings, and + * in [MarkdownSplitStrategy.Auto] from bold-only lines when the document has next to no heading + * markup. [MarkdownSplitStrategy.Pattern] ignores markup entirely and splits on a user regex. + */ class MarkdownStoryImporter : StoryImporter { override val format: ImportFormat = ImportFormat.Markdown @@ -12,23 +17,24 @@ class MarkdownStoryImporter : StoryImporter { content: ByteArray, options: ImportOptions, ): ImportPreview { - val text = content.decodeToString() + val text = content.decodeToString().removePrefix(BOM) val lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") - val tokens = lines.map { line -> - val heading = parseHeading(line) - if (heading != null) { - ImportToken.Heading(heading.level, sanitizeImportName(heading.title), raw = line) - } else { - ImportToken.Body(line) - } + val strategy = options.markdownSplitStrategy + val tokens = if (strategy == MarkdownSplitStrategy.Pattern) { + tokenizeByPattern(lines, options.markdownChapterPattern) + } else { + tokenizeByMarkup(lines, detectBoldLines = strategy == MarkdownSplitStrategy.Auto) } val fallbackName = sanitizeImportName(sourceName) - return when (options.chapterHeadingLevel) { - ChapterHeadingLevel.H1 -> buildImportPreview(tokens, 1, options.createChapterGroups, fallbackName) - ChapterHeadingLevel.H2 -> buildImportPreview(tokens, 2, options.createChapterGroups, fallbackName) - ChapterHeadingLevel.Auto -> previewAuto(tokens, options.createChapterGroups, fallbackName) + return when (strategy) { + MarkdownSplitStrategy.H1, + MarkdownSplitStrategy.Pattern, + -> buildImportPreview(tokens, 1, options.createChapterGroups, fallbackName) + + MarkdownSplitStrategy.H2 -> buildImportPreview(tokens, 2, options.createChapterGroups, fallbackName) + MarkdownSplitStrategy.Auto -> previewAuto(tokens, options.createChapterGroups, fallbackName) } } @@ -77,28 +83,133 @@ class MarkdownStoryImporter : StoryImporter { .copy(title = title) } - private fun parseHeading(line: String): Heading? { - var start = 0 - if (start < line.length && line[start] == BOM) start++ - var leadingSpaces = 0 - while (start < line.length && line[start] == ' ' && leadingSpaces < MAX_HEADING_INDENT) { - start++ - leadingSpaces++ - } + /** + * Bold-only lines are a last resort: they are only accepted when heading markup produced at most + * a story title, and only when several of them agree, so a bolded aside inside a properly headed + * manuscript can never restructure it. They sit one level below the title so that Auto still + * reads that title as the story name. + */ + private fun tokenizeByMarkup(lines: List, detectBoldLines: Boolean): List { + val tokens = tokenizeHeadingMarkup(lines, boldLevel = null) + val markupHeadings = tokens.filterIsInstance() + if (!detectBoldLines || markupHeadings.size >= MIN_MARKUP_HEADINGS) return tokens - var i = start - while (i < line.length && line[i] == '#') i++ - val level = i - start - if (level !in 1..MAX_HEADING_LEVEL) return null - - return Heading(level = level, title = line.substring(i).trim()) + val boldLevel = ((markupHeadings.minOfOrNull { it.level } ?: 0) + 1).coerceAtMost(MAX_HEADING_LEVEL) + val withBold = tokenizeHeadingMarkup(lines, boldLevel) + val boldFound = withBold.count { it is ImportToken.Heading } - markupHeadings.size + return if (boldFound >= MIN_BOLD_HEADINGS) withBold else tokens } - private class Heading(val level: Int, val title: String) + private fun tokenizeHeadingMarkup(lines: List, boldLevel: Int?): List { + val tokens = mutableListOf() + var i = 0 + while (i < lines.size) { + val line = lines[i] + val atxLevel = atxHeadingLevel(line) + val underlineLevel = lines.getOrNull(i + 1)?.let { setextUnderlineLevel(it) } + when { + atxLevel != null -> tokens.add(headingToken(atxLevel, line)) + + underlineLevel != null && isSetextTitle(lines, i, underlineLevel) -> { + tokens.add(headingToken(underlineLevel, line)) + i++ + } + + boldLevel != null && isBoldOnlyLine(line) -> tokens.add(headingToken(boldLevel, line)) + + else -> tokens.add(ImportToken.Body(line)) + } + i++ + } + return tokens + } + + private fun tokenizeByPattern(lines: List, pattern: String): List { + val regex = if (pattern.isBlank()) null else runCatching { Regex(pattern) }.getOrNull() + val tokens = mutableListOf() + var i = 0 + while (i < lines.size) { + val line = lines[i] + val plain = plainText(line) + if (regex != null && plain.isNotBlank() && regex.containsMatchIn(plain)) { + tokens.add(headingToken(1, line)) + // The underline belongs to the title it underlines, not to the scene body. + if (lines.getOrNull(i + 1)?.let { setextUnderlineLevel(it) } != null) i++ + } else { + tokens.add(ImportToken.Body(line)) + } + i++ + } + return tokens + } + + private fun headingToken(level: Int, line: String) = + ImportToken.Heading(level, sanitizeImportName(plainText(line)), raw = line) + + private fun atxHeadingLevel(line: String): Int? { + val body = line.trimStart(' ') + if (line.length - body.length > MAX_HEADING_INDENT) return null + val level = body.takeWhile { it == '#' }.length + return if (level in 1..MAX_HEADING_LEVEL) level else null + } + + /** `===` underlines a level 1 heading, `---` a level 2 one; any other line is not an underline. */ + private fun setextUnderlineLevel(line: String): Int? { + val body = line.trimEnd().trimStart(' ') + if (line.trimEnd().length - body.length > MAX_HEADING_INDENT) return null + val marker = body.firstOrNull() ?: return null + if (marker != '=' && marker != '-') return null + if (body.any { it != marker }) return null + return if (marker == '=') 1 else 2 + } + + /** + * A `---` underline is also the spelling of a scene-break rule and of a front-matter fence, so it + * only makes a heading of the line above when that line opens a paragraph. `===` is unambiguous. + */ + private fun isSetextTitle(lines: List, index: Int, underlineLevel: Int): Boolean { + val line = lines[index] + if (line.isBlank()) return false + if (line.length - line.trimStart(' ').length > MAX_HEADING_INDENT) return false + if (setextUnderlineLevel(line) != null) return false + return underlineLevel == 1 || index == 0 || lines[index - 1].isBlank() + } + + /** True when the line is nothing but one short bold span. */ + private fun isBoldOnlyLine(line: String): Boolean { + val trimmed = line.trim() + return BOLD_MARKERS.any { marker -> + trimmed.length > marker.length * 2 && + trimmed.startsWith(marker) && + trimmed.endsWith(marker) && + trimmed.substring(marker.length, trimmed.length - marker.length).let { + it.isNotBlank() && !it.contains(marker) && it.trim().split(WHITESPACE).size <= HEADING_MAX_WORDS + } + } + } + + /** A line with its heading markers and wrapping emphasis removed. */ + private fun plainText(line: String): String { + var text = if (atxHeadingLevel(line) != null) line.trimStart(' ').trimStart('#').trim() else line.trim() + for (marker in EMPHASIS_MARKERS) { + while (text.length > marker.length * 2 && text.startsWith(marker) && text.endsWith(marker)) { + text = text.substring(marker.length, text.length - marker.length).trim() + } + } + return text + } companion object { private const val MAX_HEADING_LEVEL = 6 private const val MAX_HEADING_INDENT = 3 - private const val BOM = '' + private const val MIN_MARKUP_HEADINGS = 2 + private const val MIN_BOLD_HEADINGS = 2 + + /** Beyond this a bolded line is prose, not a chapter title. */ + private const val HEADING_MAX_WORDS = 12 + private const val BOM = "" + private val BOLD_MARKERS = listOf("**", "__") + private val EMPHASIS_MARKERS = listOf("**", "__", "*", "_") + private val WHITESPACE = Regex("\\s+") } } diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporterTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporterTest.kt index d10521de9..840fdb9b2 100644 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporterTest.kt +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/MarkdownStoryImporterTest.kt @@ -1,7 +1,8 @@ package com.darkrockstudios.apps.hammer.common.data.importer -import com.darkrockstudios.apps.hammer.common.data.ChapterHeadingLevel +import com.darkrockstudios.apps.hammer.common.data.DEFAULT_CHAPTER_PATTERN import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -12,15 +13,17 @@ class MarkdownStoryImporterTest { private fun preview( content: String, - level: ChapterHeadingLevel = ChapterHeadingLevel.H1, + strategy: MarkdownSplitStrategy = MarkdownSplitStrategy.H1, groups: Boolean = false, sourceName: String = "story", + pattern: String = DEFAULT_CHAPTER_PATTERN, ) = importer.preview( sourceName = sourceName, content = content.encodeToByteArray(), options = ImportOptions( - chapterHeadingLevel = level, + markdownSplitStrategy = strategy, createChapterGroups = groups, + markdownChapterPattern = pattern, ), ) @@ -76,7 +79,7 @@ class MarkdownStoryImporterTest { ## Real Chapter Chapter body. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(1, result.items.size) val group = result.items[0] as PreviewItem.Group assertEquals("Outer Title", group.name) @@ -93,7 +96,7 @@ class MarkdownStoryImporterTest { ## Real Chapter Chapter body. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(1, result.items.size) val group = result.items[0] as PreviewItem.Group assertEquals("Outer Title", group.name) @@ -112,7 +115,7 @@ class MarkdownStoryImporterTest { ### A subsection More text. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(1, result.items.size) val scene = result.items[0] as PreviewItem.Scene assertEquals("Chapter One", scene.name) @@ -132,7 +135,7 @@ class MarkdownStoryImporterTest { ## Chapter C c body """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(2, result.items.size) val partOne = result.items[0] as PreviewItem.Group assertEquals("Part One", partOne.name) @@ -151,7 +154,7 @@ class MarkdownStoryImporterTest { ## Chapter B b body """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(2, result.items.size) val sceneA = result.items[0] as PreviewItem.Scene assertEquals("Chapter A", sceneA.name) @@ -168,7 +171,7 @@ class MarkdownStoryImporterTest { # Chapter Two b """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H1) + val result = preview(md, strategy = MarkdownSplitStrategy.H1) assertEquals(2, result.items.size) assertTrue(result.items.all { it is PreviewItem.Scene }) assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) @@ -311,7 +314,7 @@ class MarkdownStoryImporterTest { Chapter two body. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.H2) + val result = preview(md, strategy = MarkdownSplitStrategy.H2) assertEquals(1, result.items.size) val group = result.items[0] as PreviewItem.Group assertEquals("My Novel", group.name) @@ -333,7 +336,7 @@ class MarkdownStoryImporterTest { ## Chapter Two Second. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.Auto) + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) assertEquals("My Novel", result.title) assertEquals(2, result.items.size) assertTrue(result.items.all { it is PreviewItem.Scene }) @@ -349,7 +352,7 @@ class MarkdownStoryImporterTest { # Chapter Two b """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.Auto) + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) assertEquals(null, result.title) assertEquals(2, result.items.size) assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) @@ -368,7 +371,7 @@ class MarkdownStoryImporterTest { ### Chapter C c """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.Auto) + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) assertEquals("My Novel", result.title) assertEquals(2, result.items.size) val partOne = result.items[0] as PreviewItem.Group @@ -385,13 +388,349 @@ class MarkdownStoryImporterTest { Just prose, no chapters. More prose. """.trimIndent() - val result = preview(md, level = ChapterHeadingLevel.Auto) + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) assertEquals("My Novel", result.title) assertEquals(1, result.items.size) val scene = result.items[0] as PreviewItem.Scene assertTrue(scene.markdown.contains("Just prose, no chapters.")) } + @Test + fun `Setext equals underline is an H1 chapter`() { + val md = """ + Chapter One + =========== + First. + + Chapter Two + === + Second. + """.trimIndent() + val result = preview(md) + assertEquals(2, result.items.size) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + assertEquals("First.", (result.items[0] as PreviewItem.Scene).markdown) + assertEquals("Second.", (result.items[1] as PreviewItem.Scene).markdown) + } + + @Test + fun `Setext dash underline is an H2 chapter`() { + val md = """ + Chapter One + ----------- + First. + + Chapter Two + ----------- + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.H2) + assertEquals(2, result.items.size) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Setext underline is dropped from the scene body`() { + val md = "Chapter One\n===\nFirst." + val result = preview(md) + val scene = result.items[0] as PreviewItem.Scene + assertEquals("First.", scene.markdown) + } + + @Test + fun `Dash rule with no paragraph above it is a thematic break not a heading`() { + val md = """ + Scene one ends. + + --- + + Scene two begins. + """.trimIndent() + val result = preview(md, sourceName = "story") + assertEquals(1, result.items.size) + val scene = result.items[0] as PreviewItem.Scene + assertEquals("story", scene.name) + assertTrue(scene.markdown.contains("---")) + } + + @Test + fun `Front matter fence is not a heading`() { + val md = """ + --- + title: My Novel + --- + + # Chapter One + First. + """.trimIndent() + val result = preview(md) + assertEquals(2, result.items.size) + assertEquals("Untitled", result.items[0].name) + assertTrue((result.items[0] as PreviewItem.Scene).markdown.contains("title: My Novel")) + assertEquals("Chapter One", result.items[1].name) + } + + @Test + fun `Auto treats a Setext equals title above Setext dash chapters as the story title`() { + val md = """ + My Novel + ======== + + Chapter One + ----------- + First. + + Chapter Two + ----------- + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals("My Novel", result.title) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Mixed Setext and ATX headings share the same level space`() { + val md = """ + # Chapter One + First. + Chapter Two + =========== + Second. + """.trimIndent() + val result = preview(md) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Setext CRLF line endings are normalized`() { + val md = "Chapter One\r\n===\r\nFirst.\r\n\r\nChapter Two\r\n===\r\nSecond.\r\n" + val result = preview(md) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + assertEquals("First.", (result.items[0] as PreviewItem.Scene).markdown) + } + + @Test + fun `A Setext title indented four spaces is not a heading`() { + val md = " Chapter One\n===\nFirst." + val result = preview(md, sourceName = "story") + assertEquals(1, result.items.size) + assertEquals("story", result.items[0].name) + } + + @Test + fun `Bold-only lines split a document that has no markdown headings`() { + val md = """ + **Chapter One** + + First. + + **Chapter Two** + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals(null, result.title) + assertEquals(2, result.items.size) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + assertEquals("First.", (result.items[0] as PreviewItem.Scene).markdown) + } + + @Test + fun `Underscore bold-only lines are chapters too`() { + val md = "__Chapter One__\nFirst.\n__Chapter Two__\nSecond." + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Bold chapters below a heading title keep the title out of the scenes`() { + val md = """ + # My Novel + + **Chapter One** + + First. + + **Chapter Two** + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals("My Novel", result.title) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Bold chapters can be wrapped in chapter groups`() { + val md = "**Chapter One**\nFirst.\n**Chapter Two**\nSecond." + val result = preview(md, strategy = MarkdownSplitStrategy.Auto, groups = true) + assertEquals(2, result.items.size) + val group = result.items[0] as PreviewItem.Group + assertEquals("Chapter One", group.name) + assertEquals(listOf("Chapter One"), group.scenes.map { it.name }) + } + + @Test + fun `Bold text with prose on the same line is not a chapter`() { + val md = """ + **Chapter One** + + **She** ran, and the world went quiet. + + **Chapter Two** + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals(2, result.items.size) + val scene = result.items[0] as PreviewItem.Scene + assertEquals("Chapter One", scene.name) + assertTrue(scene.markdown.contains("**She** ran")) + } + + @Test + fun `A long bold-only line is prose not a chapter`() { + val md = """ + **Chapter One** + + **And then she said all of the many things she had been holding back for years and years.** + + **Chapter Two** + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals(2, result.items.size) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + assertTrue((result.items[0] as PreviewItem.Scene).markdown.contains("**And then she said")) + } + + @Test + fun `A lone bold-only line is prose not a chapter`() { + val md = """ + # Chapter One + First. + + **A bolded aside** + + More. + + # Chapter Two + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + assertEquals(2, result.items.size) + val scene = result.items[0] as PreviewItem.Scene + assertEquals("Chapter One", scene.name) + assertTrue(scene.markdown.contains("**A bolded aside**")) + } + + @Test + fun `Explicitly choosing H1 does not fall back to bold lines`() { + val md = "**Chapter One**\nFirst.\n**Chapter Two**\nSecond." + val result = preview(md, strategy = MarkdownSplitStrategy.H1, sourceName = "story") + assertEquals(1, result.items.size) + assertEquals("story", result.items[0].name) + } + + @Test + fun `Pattern strategy splits on lines matching the user regex`() { + val md = """ + Chapter One + + First. + + Chapter Two + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern) + assertEquals(2, result.items.size) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + assertEquals("First.", (result.items[0] as PreviewItem.Scene).markdown) + } + + @Test + fun `Pattern strategy sees through bold and heading markers`() { + val md = """ + **Chapter One** + + First. + + ## Chapter Two + + Second. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern) + assertEquals(listOf("Chapter One", "Chapter Two"), result.items.map { it.name }) + } + + @Test + fun `Pattern strategy ignores lines the regex does not match`() { + val md = """ + Scene Break + + First. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern, sourceName = "story") + assertEquals(1, result.items.size) + assertEquals("story", result.items[0].name) + } + + @Test + fun `Pattern strategy with an invalid regex falls back to a single scene`() { + val md = "Chapter One\n\nFirst." + val result = preview( + md, + strategy = MarkdownSplitStrategy.Pattern, + pattern = "[", + sourceName = "story", + ) + assertEquals(1, result.items.size) + assertEquals("story", result.items[0].name) + } + + @Test + fun `Pattern strategy keeps a Setext underline out of the scene body`() { + val md = """ + Chapter One + =========== + First. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern) + assertEquals(1, result.items.size) + assertEquals("Chapter One", result.items[0].name) + assertEquals("First.", (result.items[0] as PreviewItem.Scene).markdown) + } + + @Test + fun `Pattern strategy with a blank pattern falls back to a single scene`() { + val md = "Chapter One\n\nFirst." + val result = preview( + md, + strategy = MarkdownSplitStrategy.Pattern, + pattern = "", + sourceName = "story", + ) + assertEquals(1, result.items.size) + assertEquals("story", result.items[0].name) + } + + @Test + fun `Pattern strategy overrides markdown headings`() { + val md = """ + # Foreword + Front matter prose. + # Chapter One + First. + """.trimIndent() + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern) + assertEquals(2, result.items.size) + assertEquals("Untitled", result.items[0].name) + assertEquals("Chapter One", result.items[1].name) + } + @Test fun `totalScenes counts scenes across mixed groups and scenes`() { val md = """ diff --git a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt index 2d98f50d9..be94a0421 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt @@ -6,8 +6,8 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.projec import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Info import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata import com.darkrockstudios.apps.hammer.common.data.CResult -import com.darkrockstudios.apps.hammer.common.data.ChapterHeadingLevel import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.SyncedProjectDefinition import com.darkrockstudios.apps.hammer.common.data.account.AccountReauthUseCase @@ -304,13 +304,13 @@ class ProjectsListComponentTest : ComponentTest() { assertEquals(2, comp.state.value.importPreview.totalScenes, "Auto splits on H2") previewsParsed = 0 - comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.H2)) - comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.Auto)) - comp.updateImportOptions(ImportOptions(chapterHeadingLevel = ChapterHeadingLevel.H1)) + comp.updateImportOptions(ImportOptions(markdownSplitStrategy = MarkdownSplitStrategy.H2)) + comp.updateImportOptions(ImportOptions(markdownSplitStrategy = MarkdownSplitStrategy.Auto)) + comp.updateImportOptions(ImportOptions(markdownSplitStrategy = MarkdownSplitStrategy.H1)) advanceUntilIdle() assertEquals(1, previewsParsed, "Superseded options must be dropped before they parse") - assertEquals(ChapterHeadingLevel.H1, comp.state.value.importOptions.chapterHeadingLevel) + assertEquals(MarkdownSplitStrategy.H1, comp.state.value.importOptions.markdownSplitStrategy) assertEquals(1, comp.state.value.importPreview.totalScenes, "H1 keeps it as one scene") } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt index 33de35035..76b1b8797 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt @@ -22,9 +22,9 @@ import com.darkrockstudios.apps.hammer.common.compose.NameKind import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.rememberNameValidation import com.darkrockstudios.apps.hammer.common.compose.designsystem.* -import com.darkrockstudios.apps.hammer.common.data.ChapterHeadingLevel import com.darkrockstudios.apps.hammer.common.data.ImportFormat import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy import com.darkrockstudios.apps.hammer.common.data.RtfSplitStrategy import com.darkrockstudios.apps.hammer.common.data.importer.ImportPreview import com.darkrockstudios.apps.hammer.common.data.importer.PreviewItem @@ -137,13 +137,22 @@ internal fun ImportStoryContent( Spacer(modifier = Modifier.height(Ui.Padding.XL)) when (options.format) { - ImportFormat.Markdown -> HdHairlineSegmentedPicker( - title = Res.string.project_home_import_heading_label.get(), - options = ChapterHeadingLevel.entries, - selected = options.chapterHeadingLevel, - onSelect = { onOptionsChange(options.copy(chapterHeadingLevel = it)) }, - label = { (it.labelRes()).get() }, - ) + ImportFormat.Markdown -> { + HdHairlineSegmentedPicker( + title = Res.string.project_home_import_heading_label.get(), + options = MarkdownSplitStrategy.entries, + selected = options.markdownSplitStrategy, + onSelect = { onOptionsChange(options.copy(markdownSplitStrategy = it)) }, + label = { (it.labelRes()).get() }, + ) + if (options.markdownSplitStrategy == MarkdownSplitStrategy.Pattern) { + Spacer(modifier = Modifier.height(Ui.Padding.L)) + ChapterPatternField( + pattern = options.markdownChapterPattern, + onPatternChange = { onOptionsChange(options.copy(markdownChapterPattern = it)) }, + ) + } + } ImportFormat.Rtf -> { HdHairlineSegmentedPicker( @@ -155,10 +164,9 @@ internal fun ImportStoryContent( ) if (options.rtfSplitStrategy == RtfSplitStrategy.Pattern) { Spacer(modifier = Modifier.height(Ui.Padding.L)) - FormField( - value = options.rtfChapterPattern, - onValueChange = { onOptionsChange(options.copy(rtfChapterPattern = it)) }, - label = Res.string.project_home_import_pattern_label.get(), + ChapterPatternField( + pattern = options.rtfChapterPattern, + onPatternChange = { onOptionsChange(options.copy(rtfChapterPattern = it)) }, ) } } @@ -186,6 +194,15 @@ internal fun ImportStoryContent( } } +@Composable +private fun ChapterPatternField(pattern: String, onPatternChange: (String) -> Unit) { + FormField( + value = pattern, + onValueChange = onPatternChange, + label = Res.string.project_home_import_pattern_label.get(), + ) +} + @Composable private fun ImportMasthead( options: ImportOptions, @@ -197,7 +214,7 @@ private fun ImportMasthead( buildList { add(options.format.metaLabel()) when (options.format) { - ImportFormat.Markdown -> add(options.chapterHeadingLevel.name.uppercase()) + ImportFormat.Markdown -> add(options.markdownSplitStrategy.name.uppercase()) ImportFormat.Rtf -> add(options.rtfSplitStrategy.name.uppercase()) } if (isParsing) { @@ -385,10 +402,11 @@ private fun ImportFormat.metaLabel(): String = when (this) { ImportFormat.Rtf -> "RTF" } -private fun ChapterHeadingLevel.labelRes(): StringResource = when (this) { - ChapterHeadingLevel.Auto -> Res.string.project_home_import_heading_auto - ChapterHeadingLevel.H1 -> Res.string.project_home_import_heading_h1 - ChapterHeadingLevel.H2 -> Res.string.project_home_import_heading_h2 +private fun MarkdownSplitStrategy.labelRes(): StringResource = when (this) { + MarkdownSplitStrategy.Auto -> Res.string.project_home_import_heading_auto + MarkdownSplitStrategy.H1 -> Res.string.project_home_import_heading_h1 + MarkdownSplitStrategy.H2 -> Res.string.project_home_import_heading_h2 + MarkdownSplitStrategy.Pattern -> Res.string.project_home_import_split_pattern } private fun RtfSplitStrategy.labelRes(): StringResource = when (this) { diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt index 48fbca899..9945120ca 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy import com.darkrockstudios.apps.hammer.common.data.importer.ImportPreview import com.darkrockstudios.apps.hammer.common.data.importer.PreviewItem import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview @@ -49,6 +50,39 @@ fun ImportStoryDialogPreview() { } } +/** + * Markdown import at phone width with the regex escape hatch selected: the chapter pattern field is + * showing and the four-cell detection picker is at its tightest. + */ +@Preview(widthDp = 400, heightDp = 800) +@Composable +fun ImportStoryDialogMarkdownPatternPreview() { + val preview = ImportPreview( + items = List(6) { i -> PreviewItem.Scene(name = "Chapter ${i + 1}", markdown = "") }, + ) + KoinApplicationPreview { + AppTheme(globalSettingsPreview, true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + ImportStoryContent( + projectName = "Alice In Wonderland 2", + options = ImportOptions(markdownSplitStrategy = MarkdownSplitStrategy.Pattern), + preview = preview, + isParsing = false, + onProjectNameChange = {}, + onCancel = {}, + onOptionsChange = {}, + onConfirm = {}, + ) + } + } + } +} + /** The window between the file picker closing and the parse landing. */ @Preview(widthDp = 640, heightDp = 560) @Composable From dd9774c34cbe87b4c16bbf5d394a834b66d623aa Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 02:59:30 -0700 Subject: [PATCH 009/213] Self-service account deletion (#815) * Add self-service account deletion Users can delete their account from the web dashboard danger zone. The account is soft-deleted: locked out of login and sync, all stories unpublished, pen name released, data retained for a configurable window (accountDeletion.retentionDays, default 30 days) during which an admin can restore it from the users page. A daily job permanently purges accounts past the window. Admin accounts cannot be deleted; the guard is enforced in the SQL, the service, and the UI. * Harden account deletion edge cases from review softDelete verifies the deleted flag actually landed before running its destructive steps, and retries re-run the idempotent cleanup so a partial failure heals; markDeleted leaves an already-deleted row untouched so retries never extend retention. Tokens of soft-deleted accounts are hidden inside the token query itself, restoring the whitelist-off single-query bearer auth path. Re-registration against a soft-deleted email returns the pending-deletion message instead of a misleading "account exists", and the delete dialog warns that the email stays reserved. Shared test account builder replaces per-file duplicates. --- docs/HOW-TO-RUN-A-SERVER.md | 16 ++ server/build.gradle.kts | 2 +- .../apps/hammer/Application.kt | 3 + .../apps/hammer/ServerConfig.kt | 29 +++ .../apps/hammer/account/AccountDeletionJob.kt | 44 ++++ .../hammer/account/AccountDeletionService.kt | 108 +++++++++ .../apps/hammer/account/AccountRoutes.kt | 2 +- .../apps/hammer/account/AccountsComponent.kt | 8 + .../apps/hammer/account/AccountsRepository.kt | 45 +++- .../account/ConfigureAccountDeletionJob.kt | 15 ++ .../apps/hammer/database/AccountDao.kt | 16 ++ .../hammer/database/ServerDatabaseFactory.kt | 1 + .../apps/hammer/database/UserDataPurgeDao.kt | 63 ++++++ .../hammer/dependencyinjection/mainModule.kt | 17 ++ .../apps/hammer/frontend/DashboardPage.kt | 49 ++++ .../apps/hammer/frontend/Frontend.kt | 26 ++- .../apps/hammer/frontend/admin/AdminPage.kt | 32 ++- .../apps/hammer/plugins/Security.kt | 15 +- .../syncsessionmanager/SyncSessionManager.kt | 5 + .../src/main/resources/assets/css/admin.css | 11 + .../main/resources/assets/css/dashboard.css | 13 ++ .../src/main/resources/assets/js/dashboard.js | 35 +++ .../resources/i18n/Messages_de.properties | 21 ++ .../resources/i18n/Messages_en.properties | 20 ++ .../resources/i18n/Messages_es.properties | 21 ++ .../resources/i18n/Messages_fr.properties | 21 ++ .../resources/i18n/Messages_it.properties | 21 ++ .../resources/i18n/Messages_pt_BR.properties | 21 ++ .../resources/i18n/Messages_uk.properties | 21 ++ .../resources/templates/dashboard.mustache | 28 +++ .../partials/delete-account-dialog.mustache | 43 ++++ .../templates/partials/users.mustache | 11 + .../darkrockstudios/apps/hammer/Account.sq | 33 ++- .../darkrockstudios/apps/hammer/AuthToken.sq | 5 +- .../apps/hammer/DeletedEntity.sq | 3 + .../apps/hammer/DeletedIdea.sq | 3 + .../apps/hammer/DeletedProject.sq | 3 + .../darkrockstudios/apps/hammer/ErrorLog.sq | 3 + .../apps/hammer/LoginAttempt.sq | 4 + .../apps/hammer/PasswordResetToken.sq | 3 + .../darkrockstudios/apps/hammer/Project.sq | 3 + .../apps/hammer/ProjectData.sq | 3 + .../apps/hammer/PublishedStoryReader.sq | 8 + .../apps/hammer/ReviewRequest.sq | 10 +- .../apps/hammer/ReviewScene.sq | 4 + .../apps/hammer/ReviewSuggestion.sq | 8 + .../apps/hammer/StoryEntity.sq | 3 + .../darkrockstudios/apps/hammer/StoryIdea.sq | 3 + .../apps/hammer/UserActivity.sq | 3 + .../apps/hammer/WritingActivity.sq | 3 + server/src/main/sqldelight/migrations/7.sqm | 10 + .../apps/hammer/ApplicationTest.kt | 1 + .../hammer/account/AccountDeletionJobTest.kt | 80 +++++++ .../account/AccountDeletionServiceTest.kt | 210 ++++++++++++++++++ .../apps/hammer/account/AccountRoutesTest.kt | 84 ++++++- .../account/AccountsComponentLoginTest.kt | 23 ++ .../hammer/account/AccountsRepositoryTest.kt | 48 ++++ .../account/PasswordResetRepositoryTest.kt | 1 + .../database/AccountDaoSoftDeleteTest.kt | 107 +++++++++ .../hammer/database/UserDataPurgeDaoTest.kt | 53 +++++ .../apps/hammer/frontend/AdminSecurityTest.kt | 1 + .../frontend/FrontendAuthenticationTest.kt | 28 ++- .../project/routes/ProjectDataRoutesTest.kt | 5 + .../routes/WritingActivityRoutesTest.kt | 5 + .../projects/routes/ProjectsRoutesBaseTest.kt | 6 + .../apps/hammer/utils/TestAccounts.kt | 26 +++ 66 files changed, 1545 insertions(+), 31 deletions(-) create mode 100644 server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJob.kt create mode 100644 server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionService.kt create mode 100644 server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/ConfigureAccountDeletionJob.kt create mode 100644 server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDao.kt create mode 100644 server/src/main/resources/templates/partials/delete-account-dialog.mustache create mode 100644 server/src/main/sqldelight/migrations/7.sqm create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJobTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionServiceTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/AccountDaoSoftDeleteTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDaoTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/TestAccounts.kt diff --git a/docs/HOW-TO-RUN-A-SERVER.md b/docs/HOW-TO-RUN-A-SERVER.md index ee2db1cd9..9e935b7eb 100644 --- a/docs/HOW-TO-RUN-A-SERVER.md +++ b/docs/HOW-TO-RUN-A-SERVER.md @@ -167,6 +167,22 @@ caches disabled. Only publicly readable stories are ever written to the HTML cache, so a password-protected story's prose never lands on the cache volume. +## Account deletion retention + +Users can delete their own account from the web dashboard. A deleted account is locked out +immediately (no login, no sync, everything unpublished, pen name released), but its data is +retained on the server for a grace window so you can restore it from **Admin → Users** if the +user changes their mind. A daily job permanently deletes accounts once the window elapses. + +```toml +[accountDeletion] +# Defaults shown. Omit this whole block to accept them. +retentionDays = 30 # days before a deleted account is purged for good (1 to 3650) +``` + +The window is evaluated against each account's deletion time on every job run, so lowering +the value also purges accounts that are already past the new, shorter window. + ## Encryption at rest Content encryption at rest is **optional**: a fresh server stores in plaintext and diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 66c12ad79..3f348926c 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -45,7 +45,7 @@ sqldelight { packageName.set("com.darkrockstudios.apps.hammer.database") dialect(libs.sqldelight.postgresql.dialect.get().toString()) srcDirs("src/main/sqldelight") - version = 7 + version = 8 schemaOutputDirectory.set(project.file("build/generated/sqldelight")) } // Read-only legacy database — used ONLY by the one-time SQLite-to-Postgres migrator. diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt index e721a2646..b5b727fc3 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.apps.hammer +import com.darkrockstudios.apps.hammer.account.configureAccountDeletionJob import com.darkrockstudios.apps.hammer.account.configureTokenMaintenanceJob import com.darkrockstudios.apps.hammer.admin.configureWhitelistExpiryJob import com.darkrockstudios.apps.hammer.base.http.createTokenBase64 @@ -205,6 +206,7 @@ internal fun resolveServerConfig( validateConfigFiles(resolved, fileSystem) validateCacheDirectory(resolved.cache, fileSystem) resolved.extraLinks.forEach { it.validate() } + resolved.accountDeletion.validate() return resolved } @@ -423,6 +425,7 @@ fun Application.appMain( configureMonitoringJob() configureTokenMaintenanceJob() configureWhitelistExpiryJob() + configureAccountDeletionJob() configureDiskCachePruneJob() } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt index a645106f5..75ef69fae 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.Transient import java.net.URI import java.net.URISyntaxException import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days @Serializable data class ServerConfig( @@ -54,6 +56,7 @@ data class ServerConfig( val emailProvider: String? = null, val communityEnabled: Boolean = false, val extraLinks: List = emptyList(), + val accountDeletion: AccountDeletionConfig = AccountDeletionConfig(), val storage: StorageConfig = StorageConfig(), val cache: CacheConfig = CacheConfig(), val analytics: AnalyticsConfig = AnalyticsConfig(), @@ -326,6 +329,32 @@ data class StorageConfig( } } +/** + * Self-service account deletion. A deleted account is soft-deleted first: locked out of + * login and sync, unpublished, pen name released, but its data is retained so an operator + * can restore it. A daily job permanently deletes accounts soft-deleted longer than + * [retentionDays]. The window is evaluated against each account's deletion time on every + * job run, so lowering it also hard-deletes accounts already past the new window. + */ +@Serializable +data class AccountDeletionConfig( + /** Days a soft-deleted account is retained before permanent deletion. */ + val retentionDays: Int = 30, +) { + @Transient + val retention: Duration = retentionDays.days + + fun validate() { + require(retentionDays in 1..MAX_RETENTION_DAYS) { + "accountDeletion.retentionDays must be between 1 and $MAX_RETENTION_DAYS, was $retentionDays" + } + } + + private companion object { + const val MAX_RETENTION_DAYS = 3650 + } +} + /** * The regenerable disk caches: rendered story HTML and OpenGraph share cards. Nothing here is * durable data — losing the whole directory costs a re-render, so it belongs on whatever volume diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJob.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJob.kt new file mode 100644 index 000000000..b5a339d33 --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJob.kt @@ -0,0 +1,44 @@ +package com.darkrockstudios.apps.hammer.account + +import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.scheduling.RecurringTask +import kotlinx.coroutines.CancellationException +import org.slf4j.Logger +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours + +/** + * Hard-deletes accounts whose soft-delete retention window has elapsed. + * Enforcement lives in the auth gates: a soft-deleted account is already locked + * out, so this job only performs the final purge and its cadence never affects + * correctness. + */ +class AccountDeletionJob( + private val accountDeletionService: AccountDeletionService, + private val serverConfig: ServerConfig, + private val clock: Clock, + logger: Logger, +) : RecurringTask("Account deletion job", logger) { + + override suspend fun tick() { + val cutoff = clock.now() - serverConfig.accountDeletion.retention + val dueAccounts = accountDeletionService.findAccountsPastRetention(cutoff) + for (account in dueAccounts) { + try { + accountDeletionService.hardDelete(account.id) + logger.info("Account past retention hard-deleted: ${account.id}") + } catch (e: CancellationException) { + throw e + // One account failing must not starve the rest of the batch. + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.error("Hard delete failed for account ${account.id}", e) + } + } + } + + override suspend fun nextDelay() = CHECK_INTERVAL + + companion object { + private val CHECK_INTERVAL = 24.hours + } +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionService.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionService.kt new file mode 100644 index 000000000..a6a3b19ff --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionService.kt @@ -0,0 +1,108 @@ +package com.darkrockstudios.apps.hammer.account + +import com.darkrockstudios.apps.hammer.Account +import com.darkrockstudios.apps.hammer.admin.WhiteListRepository +import com.darkrockstudios.apps.hammer.database.UserDataPurgeDao +import com.darkrockstudios.apps.hammer.project.ProjectSyncKey +import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession +import com.darkrockstudios.apps.hammer.projects.ProjectsSynchronizationSession +import com.darkrockstudios.apps.hammer.syncsessionmanager.SyncSessionManager +import com.darkrockstudios.apps.hammer.utilities.Msg +import com.darkrockstudios.apps.hammer.utilities.SResult +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * User-initiated account deletion. Deletion is two-phase: [softDelete] locks the + * account out immediately while its data is retained for a configurable window + * ([com.darkrockstudios.apps.hammer.AccountDeletionConfig.retentionDays]) so an + * operator can [restore] it; [AccountDeletionJob] calls [hardDelete] for accounts + * past the window. + */ +class AccountDeletionService( + private val accountsRepository: AccountsRepository, + private val penNameService: PenNameService, + private val whiteListRepository: WhiteListRepository, + private val userDataPurgeDao: UserDataPurgeDao, + private val projectsSyncManager: SyncSessionManager, + private val projectSyncManager: SyncSessionManager, + private val clock: Clock, +) { + /** + * Flags the account as deleted and locks it out everywhere: auth tokens are + * revoked, live sync sessions terminated, every published and privately shared + * story unpublished, and the pen name released for others to claim. The + * account row and story data are retained until [hardDelete]. + * + * Admin accounts can never be deleted; an admin must have their admin status + * removed first. The markDeleted query enforces the same rule at the data + * layer, so no other caller can flag an admin either. + */ + suspend fun softDelete(userId: Long): SResult { + val account = accountsRepository.getAccountOrNull(userId) + ?: return SResult.failure("Account not found", Msg.r("api_error_unknown")) + + if (account.is_admin) { + return SResult.failure( + "Admin accounts cannot be deleted", + Msg.r("account_delete_error_admin") + ) + } + + // Flag first so every auth gate closes before anything else happens. + // The destructive steps only run once the flag is confirmed set: the + // markDeleted query refuses admin rows, so a zero-row update must not + // strip a still-active account. + accountsRepository.markDeleted(userId, clock.now()) + val flagged = accountsRepository.getAccountOrNull(userId)?.deleted_at != null + if (!flagged) { + return SResult.failure( + "Account could not be flagged for deletion", + Msg.r("account_delete_error_generic") + ) + } + + // An already-flagged account runs these again on purpose: each step is + // idempotent, so a retry heals a previously interrupted attempt. + accountsRepository.forceLogout(account.email) + projectsSyncManager.terminateSession(userId) + projectSyncManager.terminateSessions { it.userId == userId } + penNameService.releasePenName(userId) + accountsRepository.updateCommunityMember(userId, false) + + return SResult.success(Unit) + } + + /** + * Un-flags a soft-deleted account so it can log in and sync again. The pen + * name and publish state are NOT restored: the pen name was released and may + * have been claimed by another account since. Returns false when no such + * account exists. + */ + suspend fun restore(userId: Long): Boolean { + accountsRepository.getAccountOrNull(userId) ?: return false + accountsRepository.restoreDeleted(userId) + return true + } + + /** Accounts soft-deleted before [cutoff], due for permanent deletion. */ + suspend fun findAccountsPastRetention(cutoff: Instant): List { + return accountsRepository.getSoftDeletedBefore(cutoff) + } + + /** + * Permanently deletes the account and every row it owns, and removes its + * email from the whitelist. A no-op when the account is missing or no longer + * soft-deleted; the purge transaction re-checks deleted_at under a row lock, + * so a restore racing the retention job always wins. Disk caches of rendered + * story HTML and OG images are left to DiskCachePruneJob; serving is + * DB-gated, so nothing stale stays reachable. + */ + suspend fun hardDelete(userId: Long) { + val account = accountsRepository.getAccountOrNull(userId) ?: return + if (account.deleted_at == null) return + if (userDataPurgeDao.purgeUserData(userId)) { + whiteListRepository.removeFromWhiteList(account.email) + } + } +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutes.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutes.kt index 09ce679c5..ef8a2fd2f 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutes.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutes.kt @@ -119,7 +119,7 @@ private fun Route.refreshToken() { status = HttpStatusCode.Unauthorized, HttpResponseError( error = "Unauthorized", - displayMessage = call.t(R("api_accounts_tokenrefresh_error")) + displayMessage = result.displayMessageText(call, R("api_accounts_tokenrefresh_error")) ) ) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt index bc50a5559..098498395 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt @@ -67,6 +67,14 @@ class AccountsComponent( // a bad token does — never surface AccountNotFound as a 500, since the status // difference would reveal whether the account exists. val account = accountsRepository.getAccountOrNull(userId) + if (account?.deleted_at != null) { + // Real tokens were revoked at soft delete, so anyone reaching this + // held a valid refresh token; the explicit message is safe. + return SResult.failure( + "Account pending deletion", + Msg.r("api_accounts_login_error_pending_deletion") + ) + } if (account != null && checkIfWhiteListRejected(account)) { return whiteListRejectedFailure() } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepository.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepository.kt index 37bcb0cfc..72136614b 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepository.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepository.kt @@ -95,11 +95,21 @@ class AccountsRepository( // Hash anyway so the existing-account path costs the same Argon2 time as // creating a new account — no timing oracle for account enumeration. hashPassword(password) - SResult.failure( - "account already exists", - Msg.r("api_accounts_create_error_accountexists"), - CreateFailed("Account already exists") - ) + if (existingAccount.deleted_at != null) { + // The row must survive so the account can be restored, which + // keeps the email reserved until the retention window ends. + SResult.failure( + "account pending deletion", + Msg.r("api_accounts_login_error_pending_deletion"), + CreateFailed("Account pending deletion") + ) + } else { + SResult.failure( + "account already exists", + Msg.r("api_accounts_create_error_accountexists"), + CreateFailed("Account already exists") + ) + } } !EmailValidator.validate(email) -> SResult.failure( @@ -172,8 +182,17 @@ class AccountsRepository( } return if (account != null && passwordValid) { - val token = createToken(account.id, installId) - SResult.success(token) + if (account.deleted_at != null) { + // Only reachable with valid credentials, so this reveals account + // state to its owner and nothing to password guessers. + SResult.failure( + "Account pending deletion", + Msg.r("api_accounts_login_error_pending_deletion") + ) + } else { + val token = createToken(account.id, installId) + SResult.success(token) + } } else { // One message for both unknown-account and wrong-password so the // response body doesn't reveal whether the account exists. @@ -236,6 +255,18 @@ class AccountsRepository( return accountDao.getAccount(userId)?.is_admin == true } + suspend fun markDeleted(userId: Long, deletedAt: Instant = clock.now()) { + accountDao.markDeleted(userId, deletedAt) + } + + suspend fun restoreDeleted(userId: Long) { + accountDao.restoreDeleted(userId) + } + + suspend fun getSoftDeletedBefore(cutoff: Instant): List { + return accountDao.getSoftDeletedBefore(cutoff) + } + suspend fun findAccount(email: String): Account? { return accountDao.findAccount(email) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/ConfigureAccountDeletionJob.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/ConfigureAccountDeletionJob.kt new file mode 100644 index 000000000..eee0c29a5 --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/ConfigureAccountDeletionJob.kt @@ -0,0 +1,15 @@ +package com.darkrockstudios.apps.hammer.account + +import com.darkrockstudios.apps.hammer.scheduling.launchRecurringTask +import io.ktor.server.application.* +import org.koin.ktor.ext.inject + +/** + * Starts the job that permanently deletes accounts past their soft-delete + * retention window. Always runs; a pass with nothing due is a no-op. + */ +fun Application.configureAccountDeletionJob() { + val job: AccountDeletionJob by inject() + launchRecurringTask(job) + log.info("Account deletion job started") +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt index 2d0f9cd57..c7d2978c7 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt @@ -98,6 +98,7 @@ class AccountDao( pen_name = it.pen_name, created = it.created, last_sync = it.last_sync, + deleted_at = it.deleted_at, most_recent_sync = it.most_recent_sync, project_count = it.project_count ) @@ -114,6 +115,7 @@ class AccountDao( pen_name = it.pen_name, created = it.created, last_sync = it.last_sync, + deleted_at = it.deleted_at, most_recent_sync = it.most_recent_sync, project_count = it.project_count ) @@ -121,6 +123,20 @@ class AccountDao( } } + suspend fun markDeleted(userId: Long, deletedAt: Instant) = withContext(ioDispatcher) { + queries.markDeleted(deletedAt = deletedAt, userId = userId) + } + + suspend fun restoreDeleted(userId: Long) = withContext(ioDispatcher) { + queries.restoreDeleted(userId) + } + + suspend fun getSoftDeletedBefore(cutoff: Instant): List = withContext(ioDispatcher) { + // The IS NOT NULL predicate narrows deleted_at in the generated row type, + // so map back to the plain Account shape. + queries.getSoftDeletedBefore(cutoff, ::Account).executeAsList() + } + suspend fun updatePassword(userId: Long, hashedPassword: String) = withContext(ioDispatcher) { queries.updatePassword(hashedPassword, userId) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ServerDatabaseFactory.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ServerDatabaseFactory.kt index ae0e9adbd..1862709a8 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ServerDatabaseFactory.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ServerDatabaseFactory.kt @@ -31,6 +31,7 @@ fun buildServerDatabase(driver: SqlDriver): ServerDatabase { accountAdapter = Account.Adapter( createdAdapter = instant, last_syncAdapter = instant, + deleted_atAdapter = instant, ), auth_tokenAdapter = Auth_token.Adapter( createdAdapter = instant, diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDao.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDao.kt new file mode 100644 index 000000000..a3061167a --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDao.kt @@ -0,0 +1,63 @@ +package com.darkrockstudios.apps.hammer.database + +import com.darkrockstudios.apps.hammer.utilities.injectIoDispatcher +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent + +/** + * Hard-deletes every row belonging to one account, for the final stage of + * account deletion. Deletes run child-tables-first in a single transaction, + * explicitly rather than relying on ON DELETE CASCADE, because tests disable + * FK enforcement. + */ +class UserDataPurgeDao( + database: Database, +) : KoinComponent { + + private val ioDispatcher by injectIoDispatcher() + private val db = database.serverDatabase + + /** + * Returns false without deleting anything when the account no longer exists + * or is no longer soft-deleted: a restore that lands after the retention job + * snapshotted its due batch must win over the purge. + */ + suspend fun purgeUserData(userId: Long): Boolean = withContext(ioDispatcher) { + db.accountQueries.transactionWithResult { + // Row-locked so a concurrent restore serializes against this transaction. + val account = db.accountQueries.getAccountForUpdate(userId).executeAsOneOrNull() + if (account?.deleted_at == null) return@transactionWithResult false + + // Review tree (suggestion -> scene -> request). + db.reviewSuggestionQueries.deleteAllForUser(userId) + db.reviewSceneQueries.deleteAllForUser(userId) + db.reviewRequestQueries.deleteAllForUser(userId) + + // Everything hanging off the user's projects. + db.publishedStoryReaderQueries.deleteAllForUser(userId) + db.publishedStoryReaderQueries.deleteAllTotalsForUser(userId) + db.deletedEntityQueries.deleteAllForUser(userId) + db.projectAccessQueries.deleteAllAccessForUser(userId) + db.storyEntityQueries.deleteAllForUser(userId) + db.projectDataQueries.deleteAllForUser(userId) + db.writingActivityQueries.deleteAllForUser(userId) + db.projectQueries.deleteAllForUser(userId) + + // Account-scoped data. + db.storyIdeaQueries.deleteAllForUser(userId) + db.deletedIdeaQueries.deleteAllForUser(userId) + db.deletedProjectQueries.deleteAllForUser(userId) + db.userActivityQueries.deleteAllForUser(userId) + db.authTokenQueries.deleteByUserId(userId) + db.passwordResetTokenQueries.deleteAllForUser(userId) + + // No-FK tables that would otherwise keep dangling references. + // login_attempt rows are keyed by lower-cased email rather than user id. + db.errorLogQueries.deleteAllForUser(userId) + db.loginAttemptQueries.deleteAllForEmail(account.email.lowercase()) + + db.accountQueries.hardDeleteAccount(userId) + true + } + } +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt index cea8b9ea5..5f75c9962 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.dependencyinjection import com.darkrockstudios.apps.hammer.ServerConfig import com.darkrockstudios.apps.hammer.StorageMode +import com.darkrockstudios.apps.hammer.account.AccountDeletionJob +import com.darkrockstudios.apps.hammer.account.AccountDeletionService import com.darkrockstudios.apps.hammer.account.AccountsComponent import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.account.BioService @@ -40,6 +42,7 @@ import com.darkrockstudios.apps.hammer.database.ServerConfigDao import com.darkrockstudios.apps.hammer.database.StoryEntityDao import com.darkrockstudios.apps.hammer.database.StoryIdeaDao import com.darkrockstudios.apps.hammer.database.UserActivityDao +import com.darkrockstudios.apps.hammer.database.UserDataPurgeDao import com.darkrockstudios.apps.hammer.database.WhiteListDao import com.darkrockstudios.apps.hammer.database.WritingActivityDao import com.darkrockstudios.apps.hammer.email.EmailProvider @@ -177,6 +180,7 @@ fun mainModule( single() single() single() + single() single() single() @@ -205,6 +209,7 @@ fun mainModule( single() single() single() + single() single() single { val cacheConfig = get().cache @@ -229,6 +234,18 @@ fun mainModule( single { StoryRendererService(get(), get(), get()) } single() single() + // Explicit ctor: the two sync managers are named qualifiers. + single { + AccountDeletionService( + accountsRepository = get(), + penNameService = get(), + whiteListRepository = get(), + userDataPurgeDao = get(), + projectsSyncManager = get(named(PROJECTS_SYNC_MANAGER)), + projectSyncManager = get(named(PROJECT_SYNC_MANAGER)), + clock = get(), + ) + } single() single() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt index 7f789c744..2e96302f2 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt @@ -1,10 +1,12 @@ package com.darkrockstudios.apps.hammer.frontend import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.account.AccountDeletionService import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.account.BioService import com.darkrockstudios.apps.hammer.account.PenNameService import com.darkrockstudios.apps.hammer.account.PenNameService.PenNameResult +import com.darkrockstudios.apps.hammer.frontend.data.UserSession import com.darkrockstudios.apps.hammer.frontend.utils.ProjectName import com.darkrockstudios.apps.hammer.frontend.utils.Toast import com.darkrockstudios.apps.hammer.frontend.utils.authenticatedOnly @@ -17,6 +19,7 @@ import com.darkrockstudios.apps.hammer.frontend.utils.respondTemplateWithToast import com.darkrockstudios.apps.hammer.frontend.utils.respondToast import com.darkrockstudios.apps.hammer.projects.ProjectsRepository import com.darkrockstudios.apps.hammer.utilities.MarkdownService +import com.darkrockstudios.apps.hammer.utilities.isSuccess import io.ktor.htmx.HxResponseHeaders import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode @@ -32,6 +35,7 @@ import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route +import io.ktor.server.sessions.clear import io.ktor.server.sessions.sessions import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -42,6 +46,7 @@ fun Route.dashboardPage( accountsRepository: AccountsRepository, penNameService: PenNameService, bioService: BioService, + accountDeletionService: AccountDeletionService, serverConfig: ServerConfig, markdownService: MarkdownService ) { @@ -292,6 +297,50 @@ fun Route.dashboardPage( ) } + hx.get("/delete-account-dialog") { + val session = call.sessions.requireUser() + val account = accountsRepository.getAccount(session.userId) + + val model = call.withDefaults( + mapOf( + "email" to account.email, + "projectCount" to projectsRepository.getProjectsCount(session.userId), + "retentionNote" to call.msg( + "account_delete_dialog_retention_note", + serverConfig.accountDeletion.retentionDays + ), + ) + ) + + call.respond(MustacheContent("partials/delete-account-dialog.mustache", model)) + } + + hx.post("/delete-account") { + val session = call.sessions.requireUser() + val account = accountsRepository.getAccount(session.userId) + val typedEmail = call.receiveParameters()["confirmEmail"]?.trim() ?: "" + + // The JS arming of the button is UX only; the typed email is the + // real confirmation and must be verified here. Errors respond OK + // because htmx does not swap OOB toasts on 4xx responses. + if (!typedEmail.equals(account.email, ignoreCase = true)) { + respondToast(call.msg("account_delete_error_email_mismatch"), Toast.Error) + return@post + } + + val result = accountDeletionService.softDelete(session.userId) + if (isSuccess(result)) { + call.sessions.clear() + call.response.header(HxResponseHeaders.Redirect, "/") + call.respond(HttpStatusCode.NoContent) + } else { + respondToast( + result.displayMessageText(call) ?: call.msg("account_delete_error_generic"), + Toast.Error + ) + } + } + hx.post("/community/leave") { if (!serverConfig.communityEnabled) { respondHtmlWithToast( diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt index c68bfa417..e12f8e6dd 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.apps.hammer.frontend import com.darkrockstudios.apps.hammer.ExtraLink import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.account.AccountDeletionService import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.account.BioService import com.darkrockstudios.apps.hammer.account.PasswordResetRepository @@ -84,6 +85,7 @@ fun Route.frontend() { val projectAccessRepository: ProjectAccessRepository by inject() val penNameService: PenNameService by inject() val bioService: BioService by inject() + val accountDeletionService: AccountDeletionService by inject() val serverConfig: ServerConfig by inject() val passwordResetRepository: PasswordResetRepository by inject() val markdownService: MarkdownService by inject() @@ -128,7 +130,15 @@ fun Route.frontend() { localeRoutes() authRoutes(accountsRepository, whiteListRepository, configRepository, serverConfig) passwordResetRoutes(passwordResetRepository) - dashboardPage(projectsRepository, accountsRepository, penNameService, bioService, serverConfig, markdownService) + dashboardPage( + projectsRepository, + accountsRepository, + penNameService, + bioService, + accountDeletionService, + serverConfig, + markdownService, + ) storyPage( storyRendererService, projectAccessRepository, @@ -167,6 +177,7 @@ fun Route.frontend() { configRepository, accountsRepository, projectsRepository, + accountDeletionService, serverConfig, patreonSyncService, emailService, @@ -308,11 +319,13 @@ fun AuthenticationConfig.frontendAuthentication(accountRepo: AccountsRepository, } /** - * Whether [session] should reach whitelist-protected pages: the account must exist - * and either be an admin or, when the whitelist is active, be on it. Admin status is - * read from the account row, not the cookie, so a revoked admin can't ride a stale - * session past the gate. Must match the redirect gate in the login page — if the two - * disagree, a logged-in but unauthorized user loops between /login and /dashboard. + * Whether [session] should reach whitelist-protected pages: the account must exist, + * not be pending deletion, and either be an admin or, when the whitelist is active, + * be on it. Admin status is read from the account row, not the cookie, so a revoked + * admin can't ride a stale session past the gate; the deletion check precedes the + * admin allowance for the same reason. Must match the redirect gate in the login + * page: if the two disagree, a logged-in but unauthorized user loops between + * /login and /dashboard. */ suspend fun sessionIsAuthorized( session: UserSession, @@ -322,6 +335,7 @@ suspend fun sessionIsAuthorized( val account = runCatching { accountRepo.getAccount(session.userId) }.getOrNull() return when { account == null -> false + account.deleted_at != null -> false account.is_admin -> true whitelistRepo.useWhiteList() -> whitelistRepo.isOnWhiteList(account.email) else -> true diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt index 8ae5c037a..96695d7b1 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.apps.hammer.frontend import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.account.AccountDeletionService import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.account.SortDirection import com.darkrockstudios.apps.hammer.account.UserSortField @@ -9,10 +10,12 @@ import com.darkrockstudios.apps.hammer.admin.ConfigRepository import com.darkrockstudios.apps.hammer.admin.WhiteListRepository import com.darkrockstudios.apps.hammer.email.EmailService import com.darkrockstudios.apps.hammer.frontend.admin.* +import com.darkrockstudios.apps.hammer.frontend.utils.Toast import com.darkrockstudios.apps.hammer.frontend.utils.adminOnly import com.darkrockstudios.apps.hammer.frontend.utils.formatInstant import com.darkrockstudios.apps.hammer.frontend.utils.formatSyncDate import com.darkrockstudios.apps.hammer.frontend.utils.msg +import com.darkrockstudios.apps.hammer.frontend.utils.respondToast import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService import com.darkrockstudios.apps.hammer.projects.ProjectsRepository import com.darkrockstudios.apps.hammer.utilities.ResUtils @@ -30,6 +33,7 @@ fun Route.adminPage( configRepository: ConfigRepository, accountsRepository: AccountsRepository, projectsRepository: ProjectsRepository, + accountDeletionService: AccountDeletionService, serverConfig: ServerConfig, patreonSyncService: PatreonSyncService?, emailService: EmailService?, @@ -65,7 +69,7 @@ fun Route.adminPage( adminUsersPage(patreonFeatureEnabled, emailFeatureEnabled) whiteListRoutes(whiteListRepository, configRepository, serverConfig, clock) serverSettingsRoutes(configRepository) - usersRoutes(accountsRepository, projectsRepository) + usersRoutes(accountsRepository, projectsRepository, accountDeletionService) if (patreonFeatureEnabled && patreonSyncService != null) { adminPatreonPage(configRepository, patreonSyncService, emailFeatureEnabled) patreonSettingsRoutes(configRepository, patreonSyncService, serverConfig) @@ -174,9 +178,29 @@ private fun Route.adminUsersPage(patreonFeatureEnabled: Boolean, emailFeatureEna } } -private fun Route.usersRoutes(accountsRepository: AccountsRepository, projectsRepository: ProjectsRepository) { +private fun Route.usersRoutes( + accountsRepository: AccountsRepository, + projectsRepository: ProjectsRepository, + accountDeletionService: AccountDeletionService, +) { route("/users") { usersFragment(accountsRepository, projectsRepository) + + hx.post("/restore") { + val userId = call.receiveParameters()["userId"]?.toLongOrNull() + if (userId == null) { + call.respond(io.ktor.http.HttpStatusCode.BadRequest, "") + return@post + } + + if (accountDeletionService.restore(userId)) { + call.response.header(HxResponseHeaders.Refresh, "true") + call.respond(io.ktor.http.HttpStatusCode.OK, "") + } else { + // The account was already hard-deleted; a refresh would lose the toast. + respondToast(call.msg("admin_users_restore_failed"), Toast.Error) + } + } } } @@ -212,12 +236,14 @@ private suspend fun getUsersModel( val accounts = accountsRepository.getAccountsPaginated(currentPage, pageSize, actualSortBy, actualSortDirection) val usersList = accounts.map { account -> mutableMapOf( + "id" to account.id, "email" to account.email, "created" to formatDate(account.created), "lastSync" to (formatLastSync(account.most_recent_sync) ?: call.msg("admin_patreon_last_sync_never")), "penName" to account.pen_name, "hasPenName" to (account.pen_name != null), - "projectCount" to account.project_count + "projectCount" to account.project_count, + "pendingDeletion" to (account.deleted_at != null) ) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Security.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Security.kt index 028469938..976517d9e 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Security.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Security.kt @@ -34,20 +34,23 @@ fun Application.configureSecurity() { realm = AUTH_REALM authenticate { tokenCredential -> val userId = parameters["userId"]?.toLongOrNull() ?: INVALID_USER_ID + // checkToken's query already refuses tokens of soft-deleted + // accounts, so the whitelist-off path stays a single round trip. val result = accountRepo.checkToken(userId, tokenCredential.token) if (isSuccess(result)) { val okay = if (whitelistRepo.useWhiteList()) { - val account = accountRepo.getAccount(userId) - account.is_admin || whitelistRepo.isOnWhiteList(account.email) + // deleted_at is checked before the admin allowance so a + // soft-deleted admin is locked out like anyone else. + val account = accountRepo.getAccountOrNull(result.data) + account != null && + account.deleted_at == null && + (account.is_admin || whitelistRepo.isOnWhiteList(account.email)) } else { true } - val dbUserId = result.data - ServerUserIdPrincipal(dbUserId) - if (okay) { - ServerUserIdPrincipal(dbUserId) + ServerUserIdPrincipal(result.data) } else { null } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/syncsessionmanager/SyncSessionManager.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/syncsessionmanager/SyncSessionManager.kt index e010d5c76..09787daae 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/syncsessionmanager/SyncSessionManager.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/syncsessionmanager/SyncSessionManager.kt @@ -19,6 +19,11 @@ class SyncSessionManager( fun terminateSession(key: K): Boolean = synchronizationSessions.remove(key) != null + /** Removes every session whose key matches [predicate]. */ + fun terminateSessions(predicate: (K) -> Boolean) { + synchronizationSessions.keys.removeAll { predicate(it) } + } + suspend fun createNewSession(key: K, createSession: (key: K, syncId: String) -> T): String { val newSyncId = syncIdGenerator.nextString() val newSession = createSession(key, newSyncId) diff --git a/server/src/main/resources/assets/css/admin.css b/server/src/main/resources/assets/css/admin.css index f7a8ca56c..615d86d3a 100644 --- a/server/src/main/resources/assets/css/admin.css +++ b/server/src/main/resources/assets/css/admin.css @@ -169,6 +169,17 @@ border: 1px solid var(--neutral-300); } +.status-badge--danger { + background: rgba(239, 68, 68, 0.12); + color: #991b1b; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.user-restore-btn { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + /* ======================================== Form Styling ======================================== */ diff --git a/server/src/main/resources/assets/css/dashboard.css b/server/src/main/resources/assets/css/dashboard.css index 4751ac1e5..e75d0c6a8 100644 --- a/server/src/main/resources/assets/css/dashboard.css +++ b/server/src/main/resources/assets/css/dashboard.css @@ -613,6 +613,19 @@ box-shadow: none; } +/* Account-level danger zone sits below the account info card */ +.account-danger-zone { + margin-top: 1.5rem; +} + +.account-admin-delete-note { + margin-top: 1.5rem; + font-family: 'Lora', Georgia, serif; + font-size: 0.9375rem; + font-style: italic; + color: var(--neutral-500); +} + /* Projects and Account containers */ .projects-container { margin-top: 1.5rem; diff --git a/server/src/main/resources/assets/js/dashboard.js b/server/src/main/resources/assets/js/dashboard.js index 41c555e09..67d3ca1a1 100644 --- a/server/src/main/resources/assets/js/dashboard.js +++ b/server/src/main/resources/assets/js/dashboard.js @@ -274,6 +274,41 @@ document.addEventListener('DOMContentLoaded', function () { } }); +// ======================================== +// Account Deletion Functions +// ======================================== + +function onDeleteAccountConfirmInput(input) { + const expected = (input.dataset.email || '').trim().toLowerCase(); + const matches = input.value.trim().toLowerCase() === expected; + document.getElementById('delete-account-btn').disabled = !matches; +} + +function closeDeleteAccountDialog(event) { + // If event is provided and it's not on the overlay itself, ignore + if (event && event.target !== event.currentTarget) { + return; + } + + const overlay = document.getElementById('delete-account-dialog-overlay'); + if (overlay) { + overlay.classList.add('closing'); + setTimeout(function () { + const container = document.getElementById('delete-account-dialog-container'); + if (container) { + container.innerHTML = ''; + } + }, 200); + } +} + +// Close the delete-account dialog on Escape +document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + closeDeleteAccountDialog(); + } +}); + // ======================================== // Bio Management Functions // ======================================== diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 5e64e028d..84bbcc8b6 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=Ideen-ID fehlt api_ideas_error_notfound=Idee nicht gefunden api_ideas_error_deleted=Idee wurde gelöscht api_ideas_error_toolarge=Idee ist zu groß + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/i18n/Messages_en.properties b/server/src/main/resources/i18n/Messages_en.properties index b55eece4c..5bcf73c86 100644 --- a/server/src/main/resources/i18n/Messages_en.properties +++ b/server/src/main/resources/i18n/Messages_en.properties @@ -192,6 +192,7 @@ api_accounts_login_error_notfound=Account not found api_accounts_login_error_badpassword=Incorrect Password api_accounts_login_error_invalid=Invalid email or password api_accounts_login_error_notoken=No valid token found +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. api_accounts_tokenrefresh_error=Failed to refresh auth token api_accounts_testauth_error=Authenticated as ${0,number} api_project_sync_begin_error_session=User {0} already has a synchronization session @@ -513,6 +514,25 @@ bio_toast_cleared=Bio cleared bio_validation_too_long=Bio must be at most {0} characters bio_error_no_penname=You must set a pen name before adding a bio bio_clear_confirm=Are you sure you want to remove your bio? +# Account Deletion +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. # Public Story Page public_story_by=A story by public_story_powered_by=Written with diff --git a/server/src/main/resources/i18n/Messages_es.properties b/server/src/main/resources/i18n/Messages_es.properties index 328c005f6..ca28660a7 100644 --- a/server/src/main/resources/i18n/Messages_es.properties +++ b/server/src/main/resources/i18n/Messages_es.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=Falta el ID de la idea api_ideas_error_notfound=Idea no encontrada api_ideas_error_deleted=La idea se ha eliminado api_ideas_error_toolarge=La idea es demasiado grande + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/i18n/Messages_fr.properties b/server/src/main/resources/i18n/Messages_fr.properties index 8eec526f3..51f06090b 100644 --- a/server/src/main/resources/i18n/Messages_fr.properties +++ b/server/src/main/resources/i18n/Messages_fr.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=ID d'idée manquant api_ideas_error_notfound=Idée introuvable api_ideas_error_deleted=L'idée a été supprimée api_ideas_error_toolarge=L'idée est trop volumineuse + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/i18n/Messages_it.properties b/server/src/main/resources/i18n/Messages_it.properties index 31572a4d3..4e8d69866 100644 --- a/server/src/main/resources/i18n/Messages_it.properties +++ b/server/src/main/resources/i18n/Messages_it.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=ID dell’idea mancante api_ideas_error_notfound=Idea non trovata api_ideas_error_deleted=L’idea è stata eliminata api_ideas_error_toolarge=L’idea è troppo grande + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/i18n/Messages_pt_BR.properties b/server/src/main/resources/i18n/Messages_pt_BR.properties index a6c5ec3f0..43315c6a3 100644 --- a/server/src/main/resources/i18n/Messages_pt_BR.properties +++ b/server/src/main/resources/i18n/Messages_pt_BR.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=ID da ideia faltando api_ideas_error_notfound=Ideia não encontrada api_ideas_error_deleted=A ideia foi excluída api_ideas_error_toolarge=A ideia é grande demais + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/i18n/Messages_uk.properties b/server/src/main/resources/i18n/Messages_uk.properties index 6fe712f4b..076429004 100644 --- a/server/src/main/resources/i18n/Messages_uk.properties +++ b/server/src/main/resources/i18n/Messages_uk.properties @@ -856,3 +856,24 @@ api_ideas_error_ideaidmissing=Відсутній ID ідеї api_ideas_error_notfound=Ідею не знайдено api_ideas_error_deleted=Ідею видалено api_ideas_error_toolarge=Ідея завелика + +# Account Deletion (English placeholders; translations come from the translation pipeline) +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. diff --git a/server/src/main/resources/templates/dashboard.mustache b/server/src/main/resources/templates/dashboard.mustache index d762f16c6..e22fcfeb9 100644 --- a/server/src/main/resources/templates/dashboard.mustache +++ b/server/src/main/resources/templates/dashboard.mustache @@ -60,6 +60,34 @@ {{/communityEnabled}} + + + {{^isAdmin}} + +
+ {{/isAdmin}} + {{#isAdmin}} + + {{/isAdmin}} diff --git a/server/src/main/resources/templates/partials/delete-account-dialog.mustache b/server/src/main/resources/templates/partials/delete-account-dialog.mustache new file mode 100644 index 000000000..cc03871a4 --- /dev/null +++ b/server/src/main/resources/templates/partials/delete-account-dialog.mustache @@ -0,0 +1,43 @@ +
+
+
+

{{msg.account_delete_dialog_title}}

+ +
+
+
+

{{msg.account_delete_dialog_devices_note}}

+

{{retentionNote}}

+

{{msg.account_delete_dialog_email_note}}

+

{{msg.account_delete_dialog_permanent_note}}

+
+ {{msg.account_delete_dialog_project_count_label}} + {{projectCount}} +
+
+ + +
+
+
+ + +
+
+
+
diff --git a/server/src/main/resources/templates/partials/users.mustache b/server/src/main/resources/templates/partials/users.mustache index d064b06f7..b30f7a4d9 100644 --- a/server/src/main/resources/templates/partials/users.mustache +++ b/server/src/main/resources/templates/partials/users.mustache @@ -81,6 +81,9 @@ {{#hasPenName}} {{penName}} {{/hasPenName}} + {{#pendingDeletion}} + {{msg.admin_users_pending_deletion}} + {{/pendingDeletion}}
@@ -95,6 +98,14 @@ {{msg.admin_users_last_sync}}: {{lastSync}} + {{#pendingDeletion}} + + {{/pendingDeletion}}
{{/.}} diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq index 0f2a0fda2..df6884213 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq @@ -15,6 +15,7 @@ CREATE TABLE account( bio TEXT, email_verified BOOLEAN NOT NULL DEFAULT FALSE, community_member BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMPTZ AS Instant, CONSTRAINT account_email_shape CHECK (email LIKE '%@%.%'), CONSTRAINT account_bio_len CHECK (bio IS NULL OR length(bio) <= 10000), CONSTRAINT account_pen_name_len CHECK (pen_name IS NULL OR length(pen_name) BETWEEN 1 AND 64) @@ -23,6 +24,7 @@ CREATE TABLE account( CREATE UNIQUE INDEX account_email_unique ON account (email); CREATE UNIQUE INDEX account_pen_name_unique ON account (pen_name) WHERE pen_name IS NOT NULL; CREATE INDEX account_community_idx ON account (id) WHERE community_member = TRUE; +CREATE INDEX account_deleted_at_idx ON account (deleted_at) WHERE deleted_at IS NOT NULL; getAllAccount: SELECT * @@ -90,7 +92,7 @@ ORDER BY created DESC LIMIT :limit OFFSET :offset; getAccountsPaginatedSortByCreated: -SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, +SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, a.deleted_at, COALESCE((SELECT MAX(p.last_sync) FROM project p WHERE p.user_id = a.id), a.last_sync) AS most_recent_sync, COALESCE((SELECT COUNT(*) FROM project p WHERE p.user_id = a.id), 0) AS project_count FROM account a @@ -99,7 +101,7 @@ ORDER BY CASE WHEN :ascending = 1 THEN a.created END ASC, LIMIT :limit OFFSET :offset; getAccountsPaginatedSortByLastSync: -SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, +SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, a.deleted_at, COALESCE((SELECT MAX(p.last_sync) FROM project p WHERE p.user_id = a.id), a.last_sync) AS most_recent_sync, COALESCE((SELECT COUNT(*) FROM project p WHERE p.user_id = a.id), 0) AS project_count FROM account a @@ -108,7 +110,7 @@ ORDER BY CASE WHEN :ascending = 1 THEN COALESCE((SELECT MAX(p.last_sync) FROM pr LIMIT :limit OFFSET :offset; getAccountsPaginatedSortByProjectCount: -SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, +SELECT a.id, a.email, a.pen_name, a.created, a.last_sync, a.deleted_at, COALESCE((SELECT MAX(p.last_sync) FROM project p WHERE p.user_id = a.id), a.last_sync) AS most_recent_sync, COALESCE((SELECT COUNT(*) FROM project p WHERE p.user_id = a.id), 0) AS project_count FROM account a @@ -117,6 +119,31 @@ ORDER BY CASE WHEN :ascending = 1 THEN COALESCE((SELECT COUNT(*) FROM project p a.created DESC LIMIT :limit OFFSET :offset; +-- Admin accounts can never be soft-deleted; the guard lives in the query as +-- well as the service so no caller can mark one deleted by mistake. An +-- already-deleted row is left untouched, so a retry that heals a partially +-- failed softDelete never extends the retention window. +markDeleted: +UPDATE account +SET deleted_at = :deletedAt +WHERE id = :userId AND is_admin = FALSE AND deleted_at IS NULL; + +restoreDeleted: +UPDATE account SET deleted_at = NULL WHERE id = :userId; + +getSoftDeletedBefore: +SELECT * +FROM account +WHERE deleted_at IS NOT NULL AND deleted_at < :cutoff; + +-- Row-locked read for the hard-delete transaction, so a concurrent restore +-- serializes against the purge instead of racing it. +getAccountForUpdate: +SELECT * FROM account WHERE id = :userId FOR UPDATE; + +hardDeleteAccount: +DELETE FROM account WHERE id = :userId; + updatePassword: UPDATE account SET password_hash = ? WHERE id = ?; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq index 4064d0198..b6dadfd38 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq @@ -26,10 +26,13 @@ SELECT * FROM auth_token WHERE user_id = ?; +-- Tokens of soft-deleted accounts are invisible here, so the bearer auth gate +-- rejects them in the same round trip that validates the token. getTokenByToken: SELECT * FROM auth_token -WHERE token = ?; +WHERE token = ? + AND user_id IN (SELECT id FROM account WHERE deleted_at IS NULL); getTokenByInstallId: SELECT * diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq index 079d2bc11..3985a2665 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq @@ -27,3 +27,6 @@ SELECT EXISTS( markEntityDeleted: INSERT INTO deleted_entity(user_id, project_id, entity_id) VALUES (:userId, :projectId, :id); + +deleteAllForUser: +DELETE FROM deleted_entity WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq index 81eaf7f97..50ecae9f5 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq @@ -28,3 +28,6 @@ ON CONFLICT (user_id, uuid) DO NOTHING; hasDeletedIdea: SELECT EXISTS(SELECT 1 FROM deleted_idea WHERE user_id = :userId AND uuid = CAST(:uuid AS UUID)); + +deleteAllForUser: +DELETE FROM deleted_idea WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq index 36d1ba8a3..33c9a1cd8 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq @@ -28,3 +28,6 @@ WHERE user_id = :userId AND uuid = CAST(:uuid AS UUID); hasDeletedProject: SELECT EXISTS(SELECT 1 FROM deleted_project WHERE user_id = :userId AND uuid = CAST(:uuid AS UUID)); + +deleteAllForUser: +DELETE FROM deleted_project WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq index ebadac557..aac782682 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq @@ -67,3 +67,6 @@ UPDATE error_log SET notified_at = ? WHERE id = ?; deleteErrorsBefore: DELETE FROM error_log WHERE last_seen < ?; + +deleteAllForUser: +DELETE FROM error_log WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq index 16c77f601..38d356f9a 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq @@ -59,3 +59,7 @@ HAVING count(*) >= ? OR count(DISTINCT email) >= ?; deleteAttemptsBefore: DELETE FROM login_attempt WHERE attempted_at < ?; + +-- email is stored lower-cased by the repository; callers must lower-case the parameter. +deleteAllForEmail: +DELETE FROM login_attempt WHERE email = :email; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq index 0af2e1edb..84d7d979f 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq @@ -32,3 +32,6 @@ getRecentTokenCount: SELECT COUNT(*) FROM password_reset_token WHERE user_id = ? AND created > ?; + +deleteAllForUser: +DELETE FROM password_reset_token WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq index aa7948bbb..2522146b6 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq @@ -95,3 +95,6 @@ WHERE id = :id; deleteProject: DELETE FROM project WHERE user_id = :userId AND uuid = CAST(:uuid AS UUID); + +deleteAllForUser: +DELETE FROM project WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq index a39d3f429..3f13d52f7 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq @@ -27,3 +27,6 @@ ON CONFLICT (user_id, project_id) DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, updated_at = EXCLUDED.updated_at; + +deleteAllForUser: +DELETE FROM project_data WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq index d379299f3..d078eba62 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq @@ -76,3 +76,11 @@ ON CONFLICT (project_id) DO UPDATE SET total = published_story_reader_total.tota deleteReadersBefore: DELETE FROM published_story_reader WHERE day_bucket < ?; + +deleteAllForUser: +DELETE FROM published_story_reader +WHERE project_id IN (SELECT id FROM project WHERE user_id = :userId); + +deleteAllTotalsForUser: +DELETE FROM published_story_reader_total +WHERE project_id IN (SELECT id FROM project WHERE user_id = :userId); diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq index 77ffeab55..3337c8d7b 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq @@ -25,8 +25,13 @@ createRequest: INSERT INTO review_request(user_id, project_id, token, reviewer_email, label, note, status, expires) VALUES (:userId, :projectId, :token, :reviewerEmail, :label, :note, :status, :expires); +-- Excludes requests whose author is soft-deleted, so every tokenized reviewer +-- path stops serving a deleted account's prose immediately; the links come +-- back if the account is restored. getRequestByToken: -SELECT * FROM review_request WHERE token = :token; +SELECT * FROM review_request +WHERE token = :token + AND user_id IN (SELECT id FROM account WHERE deleted_at IS NULL); getRequest: SELECT * FROM review_request WHERE id = :id AND user_id = :userId; @@ -62,3 +67,6 @@ WHERE id = :id AND status IN :allowed; markResolved: UPDATE review_request SET status = :status, resolved_at = :at WHERE id = :id AND status = :expected; + +deleteAllForUser: +DELETE FROM review_request WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewScene.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewScene.sq index 89b0be290..0488488e5 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewScene.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewScene.sq @@ -67,3 +67,7 @@ SELECT review_request_id, FROM review_scene WHERE review_request_id IN :reviewRequestIds GROUP BY review_request_id; + +deleteAllForUser: +DELETE FROM review_scene +WHERE review_request_id IN (SELECT id FROM review_request WHERE user_id = :userId); diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq index b55d7419c..603ce747c 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq @@ -52,3 +52,11 @@ countSuggestionsForRequest: SELECT COUNT(*) FROM review_suggestion JOIN review_scene ON review_suggestion.review_scene_id = review_scene.id WHERE review_scene.review_request_id = :reviewRequestId; + +deleteAllForUser: +DELETE FROM review_suggestion +WHERE review_scene_id IN ( + SELECT rs.id FROM review_scene rs + JOIN review_request rr ON rr.id = rs.review_request_id + WHERE rr.user_id = :userId +); diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryEntity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryEntity.sq index 92262ae3d..695988623 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryEntity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryEntity.sq @@ -134,3 +134,6 @@ WHERE user_id = :userId AND project_id = :projectId AND id = :id; deleteAllForProject: DELETE FROM story_entity WHERE user_id = :userId AND project_id = :projectId; + +deleteAllForUser: +DELETE FROM story_entity WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryIdea.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryIdea.sq index 523bdf2b6..86df58046 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryIdea.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/StoryIdea.sq @@ -48,6 +48,9 @@ deleteIdea: DELETE FROM story_idea WHERE user_id = :userId AND uuid = CAST(:uuid AS UUID); +deleteAllForUser: +DELETE FROM story_idea WHERE user_id = :userId; + countEncrypted: SELECT COUNT(*) FROM story_idea WHERE cipher IS NOT NULL AND cipher != 'none'; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq index 01237c9a0..41164d96f 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq @@ -36,3 +36,6 @@ WHERE hour_bucket >= ?; deleteActivityBefore: DELETE FROM user_activity WHERE hour_bucket < ?; + +deleteAllForUser: +DELETE FROM user_activity WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WritingActivity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WritingActivity.sq index f28c10918..04456d1bd 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WritingActivity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WritingActivity.sq @@ -20,3 +20,6 @@ INSERT INTO writing_activity(user_id, project_id, device_id, content) VALUES (:userId, :projectId, :deviceId, :content) ON CONFLICT (user_id, project_id, device_id) DO UPDATE SET content = EXCLUDED.content; + +deleteAllForUser: +DELETE FROM writing_activity WHERE user_id = :userId; diff --git a/server/src/main/sqldelight/migrations/7.sqm b/server/src/main/sqldelight/migrations/7.sqm new file mode 100644 index 000000000..e4fa85fd4 --- /dev/null +++ b/server/src/main/sqldelight/migrations/7.sqm @@ -0,0 +1,10 @@ +-- Migration 7 -> 8: soft-delete timestamp for the account-deletion retention +-- window. Mirrors Account.sq for already-deployed v7 databases; fresh installs +-- get this from Schema.create. +-- +-- The column is nullable with no default, so every existing row reads as NULL: +-- not deleted, preserving current behaviour. + +ALTER TABLE account ADD COLUMN deleted_at TIMESTAMPTZ; + +CREATE INDEX account_deleted_at_idx ON account (deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/ApplicationTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/ApplicationTest.kt index b2eedfe1b..255b835e5 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/ApplicationTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/ApplicationTest.kt @@ -99,6 +99,7 @@ class ApplicationTest : BaseTest() { single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { mockk(relaxed = true) } + single { mockk(relaxed = true) } single { mockk() } } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJobTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJobTest.kt new file mode 100644 index 000000000..1bac92406 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionJobTest.kt @@ -0,0 +1,80 @@ +package com.darkrockstudios.apps.hammer.account + +import com.darkrockstudios.apps.hammer.AccountDeletionConfig +import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.utils.TestClock +import com.darkrockstudios.apps.hammer.utils.testAccount +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class AccountDeletionJobTest { + + @MockK + private lateinit var accountDeletionService: AccountDeletionService + + private lateinit var clock: TestClock + private val logger = LoggerFactory.getLogger(AccountDeletionJobTest::class.java) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + clock = TestClock(Clock.System) + } + + private fun createJob(retentionDays: Int = 30) = AccountDeletionJob( + accountDeletionService = accountDeletionService, + serverConfig = ServerConfig(accountDeletion = AccountDeletionConfig(retentionDays = retentionDays)), + clock = clock, + logger = logger, + ) + + @Test + fun `tick - hard-deletes every account past the retention window`() = runTest { + val cutoff = slot() + val due = listOf( + testAccount(userId = 1, deletedAt = clock.now() - 40.days), + testAccount(userId = 2, deletedAt = clock.now() - 31.days), + ) + coEvery { accountDeletionService.findAccountsPastRetention(capture(cutoff)) } returns due + + createJob(retentionDays = 30).tick() + + assertEquals(clock.now() - 30.days, cutoff.captured) + coVerify { accountDeletionService.hardDelete(1) } + coVerify { accountDeletionService.hardDelete(2) } + } + + @Test + fun `tick - one failing account does not starve the rest`() = runTest { + val due = listOf( + testAccount(userId = 1, deletedAt = clock.now() - 40.days), + testAccount(userId = 2, deletedAt = clock.now() - 31.days), + ) + coEvery { accountDeletionService.findAccountsPastRetention(any()) } returns due + coEvery { accountDeletionService.hardDelete(1) } throws IllegalStateException("boom") + + createJob().tick() + + coVerify { accountDeletionService.hardDelete(2) } + } + + @Test + fun `tick - nothing due is a no-op`() = runTest { + coEvery { accountDeletionService.findAccountsPastRetention(any()) } returns emptyList() + + createJob().tick() + + coVerify(exactly = 0) { accountDeletionService.hardDelete(any()) } + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionServiceTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionServiceTest.kt new file mode 100644 index 000000000..7f00deca8 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountDeletionServiceTest.kt @@ -0,0 +1,210 @@ +package com.darkrockstudios.apps.hammer.account + +import com.darkrockstudios.apps.hammer.Account +import com.darkrockstudios.apps.hammer.admin.WhiteListRepository +import com.darkrockstudios.apps.hammer.database.UserDataPurgeDao +import com.darkrockstudios.apps.hammer.project.ProjectSyncKey +import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession +import com.darkrockstudios.apps.hammer.projects.ProjectsSynchronizationSession +import com.darkrockstudios.apps.hammer.syncsessionmanager.SyncSessionManager +import com.darkrockstudios.apps.hammer.utilities.isSuccess +import com.darkrockstudios.apps.hammer.utils.TestClock +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days + +class AccountDeletionServiceTest { + + @MockK + private lateinit var accountsRepository: AccountsRepository + + @MockK + private lateinit var penNameService: PenNameService + + @MockK + private lateinit var whiteListRepository: WhiteListRepository + + @MockK + private lateinit var userDataPurgeDao: UserDataPurgeDao + + @MockK + private lateinit var projectsSyncManager: SyncSessionManager + + @MockK + private lateinit var projectSyncManager: SyncSessionManager + + private lateinit var clock: TestClock + private lateinit var service: AccountDeletionService + + private val userId = 1L + private val email = "writer@example.com" + + private fun account( + isAdmin: Boolean = false, + deletedAt: kotlin.time.Instant? = null, + ) = Account( + id = userId, + email = email, + pen_name = "Ada Lovelace", + password_hash = "hash", + cipher_secret = "secret", + created = Clock.System.now() - 128.days, + is_admin = isAdmin, + last_sync = Clock.System.now(), + bio = null, + email_verified = true, + community_member = true, + deleted_at = deletedAt, + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + clock = TestClock(Clock.System) + service = AccountDeletionService( + accountsRepository = accountsRepository, + penNameService = penNameService, + whiteListRepository = whiteListRepository, + userDataPurgeDao = userDataPurgeDao, + projectsSyncManager = projectsSyncManager, + projectSyncManager = projectSyncManager, + clock = clock, + ) + + coEvery { accountsRepository.forceLogout(any()) } returns true + every { projectsSyncManager.terminateSession(any()) } returns true + } + + @Test + fun `softDelete - locks the account out then releases its public presence`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account() andThen account(deletedAt = clock.now()) + + val result = service.softDelete(userId) + + assertTrue(isSuccess(result)) + coVerifyOrder { + accountsRepository.markDeleted(userId, clock.now()) + accountsRepository.forceLogout(email) + projectsSyncManager.terminateSession(userId) + projectSyncManager.terminateSessions(any()) + penNameService.releasePenName(userId) + accountsRepository.updateCommunityMember(userId, false) + } + } + + @Test + fun `softDelete - missing account fails`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns null + + val result = service.softDelete(userId) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { accountsRepository.markDeleted(any(), any()) } + } + + @Test + fun `softDelete - retry on an already-deleted account re-runs the idempotent cleanup`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account(deletedAt = clock.now() - 1.days) + + val result = service.softDelete(userId) + + assertTrue(isSuccess(result)) + coVerify { penNameService.releasePenName(userId) } + coVerify { accountsRepository.forceLogout(email) } + } + + @Test + fun `softDelete - aborts before destructive steps when the flag did not land`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account() andThen account(deletedAt = null) + + val result = service.softDelete(userId) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { accountsRepository.forceLogout(any()) } + coVerify(exactly = 0) { penNameService.releasePenName(any()) } + } + + @Test + fun `softDelete - admin accounts can never be deleted`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns account(isAdmin = true) + + val result = service.softDelete(userId) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { accountsRepository.markDeleted(any(), any()) } + coVerify(exactly = 0) { penNameService.releasePenName(any()) } + } + + @Test + fun `restore - un-flags an existing account`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account(deletedAt = clock.now() - 1.days) + + assertTrue(service.restore(userId)) + coVerify { accountsRepository.restoreDeleted(userId) } + } + + @Test + fun `restore - missing account returns false`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns null + + assertFalse(service.restore(userId)) + coVerify(exactly = 0) { accountsRepository.restoreDeleted(any()) } + } + + @Test + fun `hardDelete - purges user data and removes the whitelist entry`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account(deletedAt = clock.now() - 31.days) + coEvery { userDataPurgeDao.purgeUserData(userId) } returns true + + service.hardDelete(userId) + + coVerify { userDataPurgeDao.purgeUserData(userId) } + coVerify { whiteListRepository.removeFromWhiteList(email) } + } + + @Test + fun `hardDelete - missing account is a no-op`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns null + + service.hardDelete(userId) + + coVerify(exactly = 0) { userDataPurgeDao.purgeUserData(any()) } + coVerify(exactly = 0) { whiteListRepository.removeFromWhiteList(any()) } + } + + @Test + fun `hardDelete - account restored since the job snapshot is left alone`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns account(deletedAt = null) + + service.hardDelete(userId) + + coVerify(exactly = 0) { userDataPurgeDao.purgeUserData(any()) } + coVerify(exactly = 0) { whiteListRepository.removeFromWhiteList(any()) } + } + + @Test + fun `hardDelete - whitelist entry kept when the purge transaction refuses`() = runTest { + coEvery { accountsRepository.getAccountOrNull(userId) } returns + account(deletedAt = clock.now() - 31.days) + coEvery { userDataPurgeDao.purgeUserData(userId) } returns false + + service.hardDelete(userId) + + coVerify(exactly = 0) { whiteListRepository.removeFromWhiteList(any()) } + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutesTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutesTest.kt index 062717def..6c4a40cb1 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutesTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountRoutesTest.kt @@ -28,6 +28,7 @@ import com.darkrockstudios.apps.hammer.utils.BaseTest import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -134,6 +135,7 @@ class AccountRoutesTest : BaseTest() { single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { mockk(relaxed = true) } + single { mockk(relaxed = true) } } } @@ -141,7 +143,7 @@ class AccountRoutesTest : BaseTest() { fun `Account - Refresh Token - No User`() = testApplication { coEvery { accountsComponent.refreshToken(USER_ID, any(), any()) - } returns SResult.failure("No valid token not found", mockk()) + } returns SResult.failure("No valid token not found", null) val mockRefreshToken = "invalid_refresh_token" application { @@ -165,6 +167,36 @@ class AccountRoutesTest : BaseTest() { } } + @Test + fun `Account - Refresh Token - pending-deletion message reaches the client`() = testApplication { + coEvery { + accountsComponent.refreshToken(USER_ID, any(), any()) + } returns SResult.failure( + "Account pending deletion", + com.darkrockstudios.apps.hammer.utilities.Msg.r("api_accounts_login_error_pending_deletion") + ) + + application { + setupKtorTestKoin(this@AccountRoutesTest, testModule) + + configureSerialization() + configureLocalization() + configureSecurity() + configureRouting() + } + + createClient { + install(ContentNegotiation) { + json(json) + } + } + + makeRefreshCall(USER_ID, "any-refresh-token").apply { + assertEquals(HttpStatusCode.Unauthorized, status) + assertTrue(bodyAsText().contains("pending deletion")) + } + } + @Test fun `Account - Refresh Token`() = testApplication { val mockRefreshToken = "valid_refresh_token" @@ -266,6 +298,56 @@ class AccountRoutesTest : BaseTest() { } } + // The soft-deleted gate lives in the token query itself (AuthToken.sq hides + // tokens of deleted accounts), so at the route level a deleted account is a + // failed checkToken; the data-layer behavior is covered in AccountDaoSoftDeleteTest. + @Test + fun `Account - Test Auth - invisible token is rejected at the bearer gate`() = testApplication { + coEvery { accountsRepository.checkToken(USER_ID, "bearer-token") } returns + SResult.failure("No valid token found", null) + coEvery { whiteListRepository.useWhiteList() } returns false + + application { + setupKtorTestKoin(this@AccountRoutesTest, testModule) + + configureSerialization() + configureLocalization() + configureSecurity() + configureRouting() + } + + makeTestAuthCall(USER_ID).apply { + assertEquals(HttpStatusCode.Unauthorized, status) + } + } + + @Test + fun `Account - Test Auth - active account passes the bearer gate`() = testApplication { + coEvery { accountsRepository.checkToken(USER_ID, "bearer-token") } returns SResult.success(USER_ID) + coEvery { whiteListRepository.useWhiteList() } returns false + + application { + setupKtorTestKoin(this@AccountRoutesTest, testModule) + + configureSerialization() + configureLocalization() + configureSecurity() + configureRouting() + } + + makeTestAuthCall(USER_ID).apply { + assertTrue(status.isSuccess()) + } + } + + private suspend fun ApplicationTestBuilder.makeTestAuthCall(userId: Long): HttpResponse = + client.get("/api/account/test_auth/$userId") { + header(HttpHeaders.Authorization, "Bearer bearer-token") + header(HAMMER_PROTOCOL_HEADER, HAMMER_PROTOCOL_VERSION) + header(HEADER_CLIENT_VERSION, BuildMetadata.APP_VERSION) + header(HttpHeaders.Accept, ContentType.Application.Json.toString()) + } + private suspend fun ApplicationTestBuilder.makeCreateCall(acceptedTosVersion: String?): HttpResponse = client.post("/api/account/create") { header(HAMMER_PROTOCOL_HEADER, HAMMER_PROTOCOL_VERSION) diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt index bf7bce084..77774756c 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt @@ -59,6 +59,7 @@ class AccountsComponentLoginTest { bio = null, email_verified = true, community_member = false, + deleted_at = null, ) @BeforeEach @@ -193,6 +194,28 @@ class AccountsComponentLoginTest { assertTrue(isFailure(result)) } + @Test + fun `Refresh - soft-deleted account is rejected with the pending-deletion failure`() = runTest { + coEvery { accountsRepository.getAccountOrNull(token.userId) } returns + account.copy(deleted_at = Instant.parse("2026-07-01T00:00:00Z")) + + val comp = AccountsComponent( + accountsRepository, + whiteListRepository, + projectsRepository, + configRepository, + termsOfServiceRepository, + serverConfig + ) + + val result = comp.refreshToken(token.userId, installId, "some-refresh") + + assertTrue(isFailure(result)) + assertEquals("Account pending deletion", result.error) + // The gate must short-circuit before a token could be refreshed. + coVerify(exactly = 0) { accountsRepository.refreshToken(any(), any(), any()) } + } + @Test fun `Login - Failure - Bad Login`() = runTest { coEvery { whiteListRepository.useWhiteList() } returns false diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt index e9c7ff9d9..51e132858 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt @@ -92,6 +92,7 @@ class AccountsRepositoryTest : BaseTest() { bio = null, email_verified = true, community_member = false, + deleted_at = null, ) } @@ -143,6 +144,53 @@ class AccountsRepositoryTest : BaseTest() { assertTrue(result.isFailure) } + @Test + fun `Login - soft-deleted account with correct password gets the pending-deletion failure`() = runTest { + coEvery { accountDao.findAccount(any()) } returns account.copy(deleted_at = Clock.System.now()) + val accountsRepository = + AccountsRepository(accountDao, authTokenDao, clock, tokenHasher, b64) + + val result = + accountsRepository.login(email = email, installId = installId, password = password) + + assertTrue(result.isFailure) + result as ServerResult.Failure + assertEquals("Account pending deletion", result.error) + // A locked-out account must never be issued a token. + coVerify(exactly = 0) { authTokenDao.setToken(any(), any(), any(), any()) } + } + + @Test + fun `Login - soft-deleted account with wrong password gets the generic failure`() = runTest { + coEvery { accountDao.findAccount(any()) } returns account.copy(deleted_at = Clock.System.now()) + val accountsRepository = + AccountsRepository(accountDao, authTokenDao, clock, tokenHasher, b64) + + val result = accountsRepository.login( + email = email, + installId = installId, + password = password + "wrong" + ) + + assertTrue(result.isFailure) + result as ServerResult.Failure + // The account's deletion state must not leak to a password guesser. + assertEquals("Invalid credentials", result.error) + } + + @Test + fun `CreateAccount - soft-deleted existing account gets the pending-deletion failure`() = runTest { + coEvery { accountDao.findAccount(any()) } returns account.copy(deleted_at = Clock.System.now()) + val accountsRepository = + AccountsRepository(accountDao, authTokenDao, clock, tokenHasher, b64) + + val result = accountsRepository.createAccount(email, installId, password) + + assertTrue(result.isFailure) + result as ServerResult.Failure + assertEquals("account pending deletion", result.error) + } + @Test fun `Login - unknown account and wrong password are indistinguishable`() = runTest { coEvery { accountDao.findAccount(email) } returns account diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/PasswordResetRepositoryTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/PasswordResetRepositoryTest.kt index ca2ad9dee..ced0b4d60 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/PasswordResetRepositoryTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/PasswordResetRepositoryTest.kt @@ -77,6 +77,7 @@ class PasswordResetRepositoryTest : BaseTest() { bio = null, email_verified = true, community_member = false, + deleted_at = null, ) private fun resetToken( diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/AccountDaoSoftDeleteTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/AccountDaoSoftDeleteTest.kt new file mode 100644 index 000000000..31ddf8375 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/AccountDaoSoftDeleteTest.kt @@ -0,0 +1,107 @@ +package com.darkrockstudios.apps.hammer.database + +import com.darkrockstudios.apps.hammer.base.http.Token +import com.darkrockstudios.apps.hammer.e2e.util.SharedPostgresTestDatabase +import com.darkrockstudios.apps.hammer.utils.BaseTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days + +class AccountDaoSoftDeleteTest : BaseTest() { + + private lateinit var db: SharedPostgresTestDatabase + private lateinit var accountDao: AccountDao + private lateinit var authTokenDao: AuthTokenDao + + @BeforeEach + override fun setup() { + super.setup() + + db = SharedPostgresTestDatabase() + db.initialize() + accountDao = AccountDao(db) + authTokenDao = AuthTokenDao(db) + + setupKoin() + } + + @Test + fun `markDeleted - flags a regular account`() = runTest { + val userId = accountDao.createAccount("writer@example.com", "hash", "secret", isAdmin = false) + + accountDao.markDeleted(userId, Clock.System.now()) + + assertNotNull(accountDao.getAccount(userId)?.deleted_at) + } + + @Test + fun `markDeleted - refuses admin accounts at the data layer`() = runTest { + val userId = accountDao.createAccount("admin@example.com", "hash", "secret", isAdmin = true) + + accountDao.markDeleted(userId, Clock.System.now()) + + assertNull( + accountDao.getAccount(userId)?.deleted_at, + "The markDeleted query itself must refuse admin rows", + ) + } + + @Test + fun `markDeleted - retry keeps the original deletion time`() = runTest { + val userId = accountDao.createAccount("retry@example.com", "hash", "secret", isAdmin = false) + // Millisecond precision so the value round-trips Postgres' microsecond columns exactly. + val now = Clock.System.now() + val firstDeletion = kotlin.time.Instant.fromEpochMilliseconds(now.toEpochMilliseconds()) - 5.days + + accountDao.markDeleted(userId, firstDeletion) + accountDao.markDeleted(userId, Clock.System.now()) + + assertEquals(firstDeletion, accountDao.getAccount(userId)?.deleted_at) + } + + @Test + fun `auth tokens of a soft-deleted account are invisible to the bearer gate`() = runTest { + val userId = accountDao.createAccount("locked-out@example.com", "hash", "secret", isAdmin = false) + authTokenDao.setToken( + userId = userId, + installId = "install-1", + token = Token(userId = userId, auth = "auth-locked", refresh = "refresh-locked"), + expires = Clock.System.now() + 30.days, + ) + assertNotNull(authTokenDao.getTokenByAuthToken("auth-locked")) + + accountDao.markDeleted(userId, Clock.System.now()) + assertNull(authTokenDao.getTokenByAuthToken("auth-locked")) + + accountDao.restoreDeleted(userId) + assertNotNull(authTokenDao.getTokenByAuthToken("auth-locked")) + } + + @Test + fun `restoreDeleted - un-flags a soft-deleted account`() = runTest { + val userId = accountDao.createAccount("writer2@example.com", "hash", "secret", isAdmin = false) + accountDao.markDeleted(userId, Clock.System.now()) + + accountDao.restoreDeleted(userId) + + assertNull(accountDao.getAccount(userId)?.deleted_at) + } + + @Test + fun `getSoftDeletedBefore - returns only accounts past the cutoff`() = runTest { + val now = Clock.System.now() + val oldId = accountDao.createAccount("old@example.com", "hash", "secret", isAdmin = false) + val recentId = accountDao.createAccount("recent@example.com", "hash", "secret", isAdmin = false) + accountDao.markDeleted(oldId, now - 40.days) + accountDao.markDeleted(recentId, now - 2.days) + + val due = accountDao.getSoftDeletedBefore(now - 30.days) + + assertEquals(listOf(oldId), due.map { it.id }) + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDaoTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDaoTest.kt new file mode 100644 index 000000000..01f5a1a9e --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/database/UserDataPurgeDaoTest.kt @@ -0,0 +1,53 @@ +package com.darkrockstudios.apps.hammer.database + +import com.darkrockstudios.apps.hammer.e2e.util.SharedPostgresTestDatabase +import com.darkrockstudios.apps.hammer.utils.BaseTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +class UserDataPurgeDaoTest : BaseTest() { + + private lateinit var db: SharedPostgresTestDatabase + private lateinit var accountDao: AccountDao + private lateinit var purgeDao: UserDataPurgeDao + + @BeforeEach + override fun setup() { + super.setup() + + db = SharedPostgresTestDatabase() + db.initialize() + accountDao = AccountDao(db) + purgeDao = UserDataPurgeDao(db) + + setupKoin() + } + + @Test + fun `purgeUserData - deletes a soft-deleted account`() = runTest { + val userId = accountDao.createAccount("purge-me@example.com", "hash", "secret", isAdmin = false) + accountDao.markDeleted(userId, Clock.System.now()) + + assertTrue(purgeDao.purgeUserData(userId)) + assertNull(accountDao.getAccount(userId)) + } + + @Test + fun `purgeUserData - refuses an account that is not soft-deleted`() = runTest { + val userId = accountDao.createAccount("keep-me@example.com", "hash", "secret", isAdmin = false) + + assertFalse(purgeDao.purgeUserData(userId), "A restored account must never be purged") + assertNotNull(accountDao.getAccount(userId)) + } + + @Test + fun `purgeUserData - missing account returns false`() = runTest { + assertFalse(purgeDao.purgeUserData(999_999L)) + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt index 4f0c25c13..5bdc4f33e 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt @@ -49,6 +49,7 @@ class AdminSecurityTest : BaseTest() { bio = null, email_verified = true, community_member = false, + deleted_at = null, ) private fun ApplicationTestBuilder.configureApp() { diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FrontendAuthenticationTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FrontendAuthenticationTest.kt index 172a17c3e..de8a76395 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FrontendAuthenticationTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FrontendAuthenticationTest.kt @@ -39,7 +39,12 @@ class FrontendAuthenticationTest { private val accountRepo: AccountsRepository = mockk() private val whitelistRepo: WhiteListRepository = mockk() - private fun account(userId: Long, email: String, isAdmin: Boolean = false) = Account( + private fun account( + userId: Long, + email: String, + isAdmin: Boolean = false, + deletedAt: kotlin.time.Instant? = null, + ) = Account( id = userId, email = email, password_hash = "hash", @@ -51,6 +56,7 @@ class FrontendAuthenticationTest { bio = null, email_verified = true, community_member = false, + deleted_at = deletedAt, ) private fun ApplicationTestBuilder.configureApp() { @@ -163,6 +169,26 @@ class FrontendAuthenticationTest { assertTrue(sessionIsAuthorized(session, accountRepo, whitelistRepo)) } + @Test + fun `session is unauthorized when account is pending deletion`() = runBlocking { + coEvery { accountRepo.getAccount(7) } returns + account(7, "user@example.com", deletedAt = Clock.System.now()) + coEvery { whitelistRepo.useWhiteList() } returns false + + val session = UserSession(userId = 7, username = "user@example.com", isAdmin = false) + assertFalse(sessionIsAuthorized(session, accountRepo, whitelistRepo)) + } + + @Test + fun `soft-deleted admin session is unauthorized despite admin bypass`() = runBlocking { + coEvery { accountRepo.getAccount(7) } returns + account(7, "admin@example.com", isAdmin = true, deletedAt = Clock.System.now()) + coEvery { whitelistRepo.useWhiteList() } returns false + + val session = UserSession(userId = 7, username = "admin@example.com", isAdmin = true) + assertFalse(sessionIsAuthorized(session, accountRepo, whitelistRepo)) + } + @Test fun `session is authorized when whitelisted`() = runBlocking { coEvery { accountRepo.getAccount(7) } returns account(7, "user@example.com") diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/ProjectDataRoutesTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/ProjectDataRoutesTest.kt index fbe3ceed4..2a56ce8de 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/ProjectDataRoutesTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/ProjectDataRoutesTest.kt @@ -35,6 +35,7 @@ import com.darkrockstudios.apps.hammer.utilities.MarkdownService import com.darkrockstudios.apps.hammer.utilities.SResult import com.darkrockstudios.apps.hammer.utils.BaseTest import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin +import com.darkrockstudios.apps.hammer.utils.testAccount import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get @@ -100,6 +101,9 @@ class ProjectDataRoutesTest : BaseTest() { super.setup() MockKAnnotations.init(this, relaxUnitFun = true) + // The bearer gate loads the account after token validation; default to an active one. + coEvery { accountsRepository.getAccountOrNull(any()) } returns testAccount() + coEvery { projectEntityDatasource.getProject(userId, projectId) } returns ProjectDefinition(projectName, projectId) @@ -123,6 +127,7 @@ class ProjectDataRoutesTest : BaseTest() { single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { mockk(relaxed = true) } + single { mockk(relaxed = true) } single { json } } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/WritingActivityRoutesTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/WritingActivityRoutesTest.kt index efe04eb05..bbd160f7a 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/WritingActivityRoutesTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/project/routes/WritingActivityRoutesTest.kt @@ -29,6 +29,7 @@ import com.darkrockstudios.apps.hammer.utilities.MarkdownService import com.darkrockstudios.apps.hammer.utilities.SResult import com.darkrockstudios.apps.hammer.utils.BaseTest import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin +import com.darkrockstudios.apps.hammer.utils.testAccount import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get @@ -119,6 +120,9 @@ class WritingActivityRoutesTest : BaseTest() { super.setup() MockKAnnotations.init(this, relaxUnitFun = true) + // The bearer gate loads the account after token validation; default to an active one. + coEvery { accountsRepository.getAccountOrNull(any()) } returns testAccount() + coEvery { projectEntityDatasource.getProject(userId, projectId) } returns ProjectDefinition(projectName, projectId) @@ -142,6 +146,7 @@ class WritingActivityRoutesTest : BaseTest() { single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { mockk(relaxed = true) } + single { mockk(relaxed = true) } single { json } } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/projects/routes/ProjectsRoutesBaseTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/projects/routes/ProjectsRoutesBaseTest.kt index 84dffbee7..07b5c7722 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/projects/routes/ProjectsRoutesBaseTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/projects/routes/ProjectsRoutesBaseTest.kt @@ -21,9 +21,11 @@ import com.darkrockstudios.apps.hammer.story.StoryRendererService import com.darkrockstudios.apps.hammer.utilities.MarkdownService import com.darkrockstudios.apps.hammer.utils.BaseTest import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin +import com.darkrockstudios.apps.hammer.utils.testAccount import io.ktor.server.application.Application import io.ktor.server.testing.ApplicationTestBuilder import io.mockk.MockKAnnotations +import io.mockk.coEvery import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.serialization.json.Json @@ -90,6 +92,9 @@ abstract class ProjectsRoutesBaseTest : BaseTest() { MockKAnnotations.init(this, relaxUnitFun = true) + // The bearer gate loads the account after token validation; default to an active one. + coEvery { accountsRepository.getAccountOrNull(any()) } returns testAccount() + testModule = module { single { accountsRepository } single { whiteListRepository } @@ -110,6 +115,7 @@ abstract class ProjectsRoutesBaseTest : BaseTest() { single { mockk(relaxed = true) } single { mockk(relaxed = true) } single { mockk(relaxed = true) } + single { mockk(relaxed = true) } } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/TestAccounts.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/TestAccounts.kt new file mode 100644 index 000000000..29f6a93c5 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/TestAccounts.kt @@ -0,0 +1,26 @@ +package com.darkrockstudios.apps.hammer.utils + +import com.darkrockstudios.apps.hammer.Account +import kotlin.time.Clock +import kotlin.time.Instant + +/** A plain account row for tests that need the auth gates to see a real account. */ +fun testAccount( + userId: Long = 0L, + email: String = "test@test.com", + isAdmin: Boolean = false, + deletedAt: Instant? = null, +) = Account( + id = userId, + email = email, + pen_name = null, + password_hash = "hash", + cipher_secret = "secret", + created = Clock.System.now(), + is_admin = isAdmin, + last_sync = Clock.System.now(), + bio = null, + email_verified = true, + community_member = false, + deleted_at = deletedAt, +) From 707fa8dece639934303c78a42cf2d16917769e8d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 12:17:55 -0700 Subject: [PATCH 010/213] Warn in the import preview when a scene is too large (#810) Markdown chapter detection that finds nothing collapses a whole manuscript into one scene. The first sign of that today is the editor locking up, after the import has already been committed. The preview now carries a word count per scene and flags any at or over 10,000 words: an amber notice above the list, and the offending rows tinted and stamped with their real count. The import stays enabled; one huge scene is a legitimate thing to want. 10,000 clears almost every real chapter (3,000 to 5,000 typical, ~20,000 for the longest) and sits far below any whole book, so it fires on the collapse case without nagging. Also cuts three main-thread wastes profiling turned up on very large scenes. SceneEditorUi and FocusModeUi passed getInitialEditorContent(...) to rememberSpellCheckState unremembered; the value is only read on first composition, but the argument was still evaluated every recomposition and rebuilt the whole document's AnnotatedString on the UI thread, while the scene buffer republishes every 500ms during typing. MarkdownEditField built a fresh spell checker each recomposition, re-keying the library's full-rescan effect. countWords no longer materialises a list of every word to count them. --- .../values/strings_project_home.xml | 4 + .../common/data/importer/StoryImporter.kt | 23 ++- .../data/projectstatistics/WordCount.kt | 16 +- .../importer/OversizedSceneWarningTest.kt | 147 ++++++++++++++++++ .../projectstatistics/WordCountTest.kt | 5 + .../compose/designsystem/DESIGN_README.md | 5 + .../compose/designsystem/HdWarningNotice.kt | 59 +++++++ .../markdowneditor/MarkdownEditField.kt | 5 +- .../projectselection/ImportStoryDialog.kt | 72 +++++++-- .../storyeditor/focusmode/FocusModeUi.kt | 5 +- .../storyeditor/sceneeditor/SceneEditorUi.kt | 5 +- .../ImportStoryDialog.Preview.kt | 36 +++++ 12 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/OversizedSceneWarningTest.kt create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdWarningNotice.kt diff --git a/common/src/commonMain/composeResources/values/strings_project_home.xml b/common/src/commonMain/composeResources/values/strings_project_home.xml index 8f16d2296..694f4a16d 100644 --- a/common/src/commonMain/composeResources/values/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values/strings_project_home.xml @@ -123,6 +123,10 @@ Nothing to import Reading your manuscript Will create %1$d scene(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Cancel Import diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/StoryImporter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/StoryImporter.kt index 3233bb57a..925330839 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/StoryImporter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/StoryImporter.kt @@ -2,7 +2,12 @@ package com.darkrockstudios.apps.hammer.common.data.importer import com.darkrockstudios.apps.hammer.common.data.ImportFormat import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.projectstatistics.countWords import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +/** Above this, a scene reads as a failed chapter split rather than a long chapter. */ +const val LARGE_SCENE_WORD_COUNT = 10_000 interface StoryImporter { val format: ImportFormat @@ -29,6 +34,17 @@ data class ImportPreview( is PreviewItem.Group -> item.scenes.size } } + + /** Scenes at or over [LARGE_SCENE_WORD_COUNT], in document order. */ + val oversizedScenes: List + get() = buildList { + items.forEach { item -> + when (item) { + is PreviewItem.Scene -> if (item.isOversized) add(item) + is PreviewItem.Group -> item.scenes.filterTo(this) { it.isOversized } + } + } + } } @Serializable @@ -39,7 +55,12 @@ sealed interface PreviewItem { data class Scene( override val name: String, val markdown: String, - ) : PreviewItem + ) : PreviewItem { + @Transient + val wordCount: Int = countWords(markdown) + + val isOversized: Boolean get() = wordCount >= LARGE_SCENE_WORD_COUNT + } @Serializable data class Group( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/WordCount.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/WordCount.kt index e4efb20bb..b14c92a7d 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/WordCount.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/WordCount.kt @@ -1,8 +1,16 @@ package com.darkrockstudios.apps.hammer.common.data.projectstatistics -private val whitespaceRun = Regex("""\s+""") - +/** Counts runs of non-whitespace. Single pass: a whole manuscript is one string, not a word list. */ fun countWords(text: String): Int { - val trimmed = text.trim() - return if (trimmed.isEmpty()) 0 else trimmed.split(whitespaceRun).size + var count = 0 + var inWord = false + for (char in text) { + if (char.isWhitespace()) { + inWord = false + } else if (!inWord) { + inWord = true + count++ + } + } + return count } diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/OversizedSceneWarningTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/OversizedSceneWarningTest.kt new file mode 100644 index 000000000..3d8b5eba4 --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/importer/OversizedSceneWarningTest.kt @@ -0,0 +1,147 @@ +package com.darkrockstudios.apps.hammer.common.data.importer + +import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OversizedSceneWarningTest { + + private val importer = MarkdownStoryImporter() + + private fun words(count: Int): String = "word ".repeat(count) + + private fun preview( + content: String, + groups: Boolean = false, + strategy: MarkdownSplitStrategy = MarkdownSplitStrategy.H1, + ) = importer.preview( + sourceName = "story", + content = content.encodeToByteArray(), + options = ImportOptions(markdownSplitStrategy = strategy, createChapterGroups = groups), + ) + + @Test + fun `A whole manuscript collapsed into one scene is flagged`() { + val result = preview(words(LARGE_SCENE_WORD_COUNT + 500)) + + assertEquals(1, result.totalScenes) + val flagged = result.oversizedScenes.single() + assertEquals(LARGE_SCENE_WORD_COUNT + 500, flagged.wordCount) + } + + @Test + fun `Chapters that split correctly are not flagged`() { + val md = (1..5).joinToString("\n") { "# Chapter $it\n${words(2_000)}" } + + val result = preview(md) + + assertEquals(5, result.totalScenes) + assertTrue(result.oversizedScenes.isEmpty()) + } + + @Test + fun `A scene exactly at the threshold is flagged`() { + val result = preview(words(LARGE_SCENE_WORD_COUNT)) + + assertEquals(LARGE_SCENE_WORD_COUNT, result.oversizedScenes.single().wordCount) + } + + @Test + fun `A scene one word under the threshold is not flagged`() { + val result = preview(words(LARGE_SCENE_WORD_COUNT - 1)) + + assertTrue(result.oversizedScenes.isEmpty()) + } + + @Test + fun `Only the oversized chapter of a mixed import is flagged`() { + val md = buildString { + appendLine("# Chapter One") + appendLine(words(500)) + appendLine("# Chapter Two") + appendLine(words(LARGE_SCENE_WORD_COUNT + 1)) + appendLine("# Chapter Three") + appendLine(words(500)) + } + + val result = preview(md) + + assertEquals(3, result.totalScenes) + assertEquals(listOf("Chapter Two"), result.oversizedScenes.map { it.name }) + } + + @Test + fun `An oversized scene nested in a chapter group is flagged`() { + val md = "# Chapter One\n${words(LARGE_SCENE_WORD_COUNT + 1)}" + + val result = preview(md, groups = true) + + assertTrue(result.items.single() is PreviewItem.Group) + assertEquals(listOf("Chapter One"), result.oversizedScenes.map { it.name }) + } + + @Test + fun `An empty preview flags nothing`() { + assertTrue(preview("").oversizedScenes.isEmpty()) + } + + @Test + fun `Scenes split by Setext headings carry their word count`() { + val md = """ + Chapter One + =========== + + ${words(500)} + + Chapter Two + =========== + + ${words(LARGE_SCENE_WORD_COUNT + 1)} + """.trimIndent() + + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + + assertEquals(2, result.totalScenes) + assertEquals(listOf("Chapter Two"), result.oversizedScenes.map { it.name }) + } + + @Test + fun `A Setext title with no chapters collapses to one flagged scene`() { + val md = "The Long Walk Home\n==================\n\n${words(LARGE_SCENE_WORD_COUNT + 1)}" + + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + + assertEquals(LARGE_SCENE_WORD_COUNT + 1, result.oversizedScenes.single().wordCount) + } + + @Test + fun `Scenes split by a bold chapter title carry their word count`() { + val md = (1..4).joinToString("\n") { "**Chapter $it**\n${words(3_000)}" } + + "\n**Chapter Five**\n${words(LARGE_SCENE_WORD_COUNT + 1)}" + + val result = preview(md, strategy = MarkdownSplitStrategy.Auto) + + assertEquals(listOf("Chapter Five"), result.oversizedScenes.map { it.name }) + } + + @Test + fun `Scenes split by the chapter pattern carry their word count`() { + val md = "Chapter One\n${words(500)}\nChapter Two\n${words(LARGE_SCENE_WORD_COUNT + 1)}" + + val result = preview(md, strategy = MarkdownSplitStrategy.Pattern) + + assertEquals(listOf("Chapter Two"), result.oversizedScenes.map { it.name }) + } + + @Test + fun `Word count is derived on decode rather than carried in the serialized form`() { + val decoded = Json.decodeFromString( + """{"name":"Chapter One","markdown":"one two three"}""", + ) + + assertEquals(3, decoded.wordCount) + } +} diff --git a/common/src/desktopTest/kotlin/repositories/projectstatistics/WordCountTest.kt b/common/src/desktopTest/kotlin/repositories/projectstatistics/WordCountTest.kt index b3d75b5ca..b8fcc80c7 100644 --- a/common/src/desktopTest/kotlin/repositories/projectstatistics/WordCountTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectstatistics/WordCountTest.kt @@ -46,4 +46,9 @@ class WordCountTest { fun `mixed whitespace runs collapse to single separator`() { assertEquals(3, countWords("one \t two\n\n three")) } + + @Test + fun `a non-breaking space separates words`() { + assertEquals(2, countWords("hello world")) + } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md index 913dfa887..53079832a 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md @@ -235,6 +235,11 @@ The handwriting of the system. Reach for these instead of styling `LocalHammerColors`. - **[`HdDailyGoalProgress`](HdDailyGoalProgress.kt)** — daily-goal pair with thin progress bar. +- **[`HdWarningNotice`](HdWarningNotice.kt)** — amber hairline callout: + `HdWarnGlyph` + mono label over a body message. For a condition the + user should see before acting on it, where an error dialog would be + too much and silence too little. Non-blocking by design — the action + it warns about stays available. ### Buttons & inputs diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdWarningNotice.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdWarningNotice.kt new file mode 100644 index 000000000..2625ee5fc --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdWarningNotice.kt @@ -0,0 +1,59 @@ +package com.darkrockstudios.apps.hammer.common.compose.designsystem + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors + +/** + * Amber hairline callout: mono [label] over a body [message]. + * + * ``` + * ┌──────────────────────────────┐ + * │ [!] LABEL │ + * │ Message body. │ + * └──────────────────────────────┘ + * ``` + * + * Non-blocking, unlike an error: the action it warns about stays available. + */ +@Composable +fun HdWarningNotice( + label: String, + message: String, + modifier: Modifier = Modifier, +) { + val amber = LocalHammerColors.current.warning + Row( + modifier = modifier + .fillMaxWidth() + .border(width = Dp.Hairline, color = amber, shape = RectangleShape) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + HdWarnGlyph(size = 16.dp) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HdMonoLabel(text = label, color = amber) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt index a8cd23c5b..6c97867a7 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt @@ -51,8 +51,11 @@ fun MarkdownEditField( val spellCheckRepository = rememberKoinInject() val platformSpellChecker by spellCheckRepository.dictionaryFlow.collectAsState(initial = null) + // rememberSpellCheckState re-scans the whole document whenever this instance changes identity. + val editorSpellChecker = remember(platformSpellChecker) { platformSpellChecker.toEditorSpellChecker() } + val textEditorState = rememberSpellCheckState( - spellChecker = platformSpellChecker.toEditorSpellChecker(), + spellChecker = editorSpellChecker, initialText = null, // The repository only emits a dictionary while spell checking is globally enabled, // so a null checker also means "disabled" — clear decorations rather than keep checking. diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt index 76b1b8797..965eb0abc 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ImportStoryDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties @@ -22,12 +23,15 @@ import com.darkrockstudios.apps.hammer.common.compose.NameKind import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.rememberNameValidation import com.darkrockstudios.apps.hammer.common.compose.designsystem.* +import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.data.ImportFormat import com.darkrockstudios.apps.hammer.common.data.ImportOptions import com.darkrockstudios.apps.hammer.common.data.MarkdownSplitStrategy import com.darkrockstudios.apps.hammer.common.data.RtfSplitStrategy import com.darkrockstudios.apps.hammer.common.data.importer.ImportPreview +import com.darkrockstudios.apps.hammer.common.data.importer.LARGE_SCENE_WORD_COUNT import com.darkrockstudios.apps.hammer.common.data.importer.PreviewItem +import com.darkrockstudios.apps.hammer.common.util.formatDecimalSeparator import org.jetbrains.compose.resources.StringResource import com.darkrockstudios.apps.hammer.common.compose.resources.get @@ -289,6 +293,9 @@ private fun ImportPreviewPane( } } Spacer(modifier = Modifier.height(Ui.Padding.S)) + if (!isParsing) { + LargeSceneWarning(remember(preview) { preview.oversizedScenes }) + } Box( modifier = Modifier .fillMaxWidth() @@ -326,10 +333,9 @@ private fun ImportPreviewPane( val rows = remember(preview) { preview.toRows() } LazyColumn(contentPadding = PaddingValues(Ui.Padding.M)) { items(rows) { row -> - if (row.isGroup) { - PreviewGroupRow(row.name) - } else { - PreviewSceneRow(row.name, indented = row.indented) + when (row) { + is PreviewRow.Group -> PreviewGroupRow(row.name) + is PreviewRow.Scene -> PreviewSceneRow(row) } } } @@ -338,16 +344,42 @@ private fun ImportPreviewPane( } } +/** Amber notice when the import would produce a scene over [LARGE_SCENE_WORD_COUNT]. Never blocks the import. */ +@Composable +private fun LargeSceneWarning(oversized: List) { + if (oversized.isEmpty()) return + + val message = if (oversized.size == 1) { + Res.string.project_home_import_large_scene_one.get( + oversized.first().wordCount.formatDecimalSeparator(), + ) + } else { + Res.string.project_home_import_large_scene_many.get( + oversized.size, + oversized.minOf { it.wordCount }.formatDecimalSeparator(), + ) + } + + HdWarningNotice( + label = Res.string.project_home_import_large_scene_label.get(), + message = message, + ) + Spacer(modifier = Modifier.height(Ui.Padding.S)) +} + /** One flat row of the preview tree: a group header, or a scene at top level or inside a group. */ -private class PreviewRow(val name: String, val isGroup: Boolean, val indented: Boolean) +private sealed interface PreviewRow { + class Group(val name: String) : PreviewRow + class Scene(val item: PreviewItem.Scene, val indented: Boolean) : PreviewRow +} private fun ImportPreview.toRows(): List = buildList { items.forEach { item -> when (item) { - is PreviewItem.Scene -> add(PreviewRow(item.name, isGroup = false, indented = false)) + is PreviewItem.Scene -> add(PreviewRow.Scene(item, indented = false)) is PreviewItem.Group -> { - add(PreviewRow(item.name, isGroup = true, indented = false)) - item.scenes.forEach { add(PreviewRow(it.name, isGroup = false, indented = true)) } + add(PreviewRow.Group(item.name)) + item.scenes.forEach { add(PreviewRow.Scene(it, indented = true)) } } } } @@ -375,25 +407,41 @@ private fun PreviewGroupRow(name: String) { } @Composable -private fun PreviewSceneRow(name: String, indented: Boolean) { +private fun PreviewSceneRow(row: PreviewRow.Scene) { + val oversized = row.item.isOversized Row( modifier = Modifier .fillMaxWidth() - .padding(start = if (indented) Ui.Padding.L else 0.dp, top = 2.dp, bottom = 2.dp), + .padding(start = if (row.indented) Ui.Padding.L else 0.dp, top = 2.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { + val amber = LocalHammerColors.current.warning Icon( Icons.AutoMirrored.Filled.Article, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = if (oversized) amber else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(Ui.Padding.S)) Text( - name, + row.item.name, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + if (oversized) { + Spacer(modifier = Modifier.width(Ui.Padding.S)) + HdMonoLabel( + text = Res.string.project_home_import_scene_word_count.get( + row.item.wordCount.formatDecimalSeparator(), + ), + color = amber, + maxLines = 1, + softWrap = false, + ) + } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt index d732d3e31..e63472103 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt @@ -64,9 +64,12 @@ fun FocusModeUi(component: FocusMode) { val editorSpellChecker = remember(state.spellChecker) { state.spellChecker.toEditorSpellChecker() } + // Only read once, but unremembered it rebuilds the whole document's AnnotatedString on the UI + // thread every recomposition, and the buffer republishes every 500ms while typing. + val initialEditorContent = remember { getInitialEditorContent(state.sceneBuffer?.content, markdownConfig) } val textEditorState = rememberSpellCheckState( spellChecker = editorSpellChecker, - initialText = getInitialEditorContent(state.sceneBuffer?.content, markdownConfig), + initialText = initialEditorContent, enableSpellChecking = state.spellCheckingEnabled, ) val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt index 4affc36a8..b51cb09b2 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt @@ -90,9 +90,12 @@ fun SceneEditorUi( val editorSpellChecker = remember(state.spellChecker) { state.spellChecker.toEditorSpellChecker() } + // Only read once, but unremembered it rebuilds the whole document's AnnotatedString on the UI + // thread every recomposition, and the buffer republishes every 500ms while typing. + val initialEditorContent = remember { getInitialEditorContent(state.sceneBuffer?.content, markdownConfig) } val textEditorState = rememberSpellCheckState( spellChecker = editorSpellChecker, - initialText = getInitialEditorContent(state.sceneBuffer?.content, markdownConfig), + initialText = initialEditorContent, enableSpellChecking = state.spellCheckingEnabled, spellCheckMode = SpellCheckMode.Word, ) diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt index 9945120ca..ab735d3e7 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ImportStoryDialog.Preview.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -83,6 +84,41 @@ fun ImportStoryDialogMarkdownPatternPreview() { } } +/** Chapter detection found nothing, so the whole manuscript landed in one scene. */ +@Preview(widthDp = 640, heightDp = 760) +@Composable +fun ImportStoryDialogLargeScenePreview() { + val preview = remember { + ImportPreview( + items = listOf( + PreviewItem.Scene(name = "Chapter One", markdown = "A normal opening chapter."), + PreviewItem.Scene(name = "The Long Walk Home", markdown = "word ".repeat(54_300)), + ), + ) + } + KoinApplicationPreview { + AppTheme(globalSettingsPreview, true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + ImportStoryContent( + projectName = "The Long Walk Home", + options = ImportOptions(), + preview = preview, + isParsing = false, + onProjectNameChange = {}, + onCancel = {}, + onOptionsChange = {}, + onConfirm = {}, + ) + } + } + } +} + /** The window between the file picker closing and the parse landing. */ @Preview(widthDp = 640, heightDp = 560) @Composable From f21fc6e26be8f60437433b13da70a5c021560896 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 13:30:58 -0700 Subject: [PATCH 011/213] Lowercase the sync server URL when cleaning it up (#816) Hostnames are case-insensitive, but the cleaned value is what gets stored, so it should be normalized. Lowercasing before the removePrefix calls also fixes uppercase schemes: removePrefix is case-sensitive, so HTTPS://example.com kept its scheme and then failed validation. Refs #797 --- .../accountsettings/AccountSettingsComponent.kt | 2 +- .../projectselection/ServerUrlValidationTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt index e2acb17d8..d53c8f64b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/AccountSettingsComponent.kt @@ -473,7 +473,7 @@ class AccountSettingsComponent( } fun cleanUpUrl(url: String): String { - var cleanUrl: String = url.trim() + var cleanUrl: String = url.trim().lowercase() cleanUrl = cleanUrl.removePrefix("http://") cleanUrl = cleanUrl.removePrefix("https://") cleanUrl = cleanUrl.removeSuffix("/") diff --git a/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt index e21e50bba..d367dcb62 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt @@ -29,6 +29,16 @@ class ServerUrlValidationTest { assertEquals("hammer.ink", cleanUpUrl("hammer.ink")) } + @Test + fun `a mixed case host is lowercased`() { + assertEquals("hammer.example.com", cleanUpUrl("Hammer.Example.COM")) + } + + @Test + fun `an uppercase scheme is stripped`() { + assertEquals("hammer.example.com", cleanUpUrl("HTTPS://Hammer.Example.com")) + } + @Test fun `a cleaned pasted url validates`() { assertTrue(validateUrl(cleanUpUrl("http://192.168.1.50:8080"))) From 23502a6ea12e6986f024714e1485c17595ce8bd2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 13:40:13 -0700 Subject: [PATCH 012/213] Stop the encyclopedia E2E test failing on a dropped tap (#817) createEntryThenOpenIt intermittently timed out waiting for the create FAB to disappear: the tap on the new entry card never became an onClick, so the browse list never navigated. The card is present, displayed and correctly positioned when this happens. Its centre resolves to the card's own clickable rather than the type stamp or a tag chip, the outgoing create screen is gone well before the card appears, and the card's bounds are stable from the frame it renders. Only the software renderer CI uses reproduces it, at roughly one run in six. clickUntil re-injects the click until the expected outcome shows up, the same way typeIntoEditor already re-injects keystrokes. It stops re-clicking once the matcher resolves to nothing, so a slow but successful navigation does not get a second click on the next screen. --- .../androidTest/kotlin/EditorTestHarness.kt | 34 +++++++++++++++++++ .../kotlin/EncyclopediaWorkflowTest.kt | 3 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/android/src/androidTest/kotlin/EditorTestHarness.kt b/android/src/androidTest/kotlin/EditorTestHarness.kt index b998f01b6..eb0dfbd55 100644 --- a/android/src/androidTest/kotlin/EditorTestHarness.kt +++ b/android/src/androidTest/kotlin/EditorTestHarness.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario @@ -97,6 +98,39 @@ fun ComposeTestRule.navigateTo(navTag: String) { onNodeWithTag(navTag).performClick() } +/** + * Click the first node matching [matcher] and wait for [landed] to confirm the click took effect. + * + * A single injected tap is occasionally dropped before `clickable` resolves it into an onClick, + * which leaves the screen unchanged and reports no error. Re-inject until the expected outcome + * appears rather than trusting one injection. + */ +fun ComposeTestRule.clickUntil( + matcher: SemanticsMatcher, + timeoutMillis: Long = 10_000L, + landed: () -> Boolean, +) { + val deadline = SystemClock.uptimeMillis() + timeoutMillis + while (true) { + // Only re-click while the target is still on screen; once it's gone the click did land and + // the screen is mid-change, so clicking again would hit whatever replaced it. + if (onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()) { + onAllNodes(matcher).onFirst().performClick() + } + try { + waitUntil(timeoutMillis = 1_000L) { landed() } + return + } catch (e: ComposeTimeoutException) { + if (SystemClock.uptimeMillis() >= deadline) { + throw AssertionError( + "Click on '${matcher.description}' never took effect within ${timeoutMillis}ms", + e, + ) + } + } + } +} + /** * Type into the tagged markdown/scene editor and wait for the edit to land. * diff --git a/android/src/androidTest/kotlin/EncyclopediaWorkflowTest.kt b/android/src/androidTest/kotlin/EncyclopediaWorkflowTest.kt index a2871256e..4f9f6a1e3 100644 --- a/android/src/androidTest/kotlin/EncyclopediaWorkflowTest.kt +++ b/android/src/androidTest/kotlin/EncyclopediaWorkflowTest.kt @@ -66,8 +66,7 @@ class EncyclopediaWorkflowTest { composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().assertIsDisplayed() // Opening it leaves the browse list - the create FAB only shows there. - composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().performClick() - composeRule.waitUntil(10_000) { + composeRule.clickUntil(hasTestTagPrefix("encyclopedia-entry-")) { composeRule.onAllNodesWithTag(ENCYCLOPEDIA_CREATE_FAB_TAG).fetchSemanticsNodes().isEmpty() } } From 8edd78f0523862a4a84df99fbfefaf285be82c04 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 14:21:50 -0700 Subject: [PATCH 013/213] Cover the remaining host validation cases from #797 (#819) The acceptance criteria call for leading-hyphen labels, empty labels and underscores to be rejected, but only the trailing-hyphen case had a test. Hyphens in subdomain and middle labels were untested too. All five already behave correctly; this just pins the behavior down. --- .../ServerUrlValidationTest.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt index d367dcb62..1faeeaf8f 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/ServerUrlValidationTest.kt @@ -59,6 +59,16 @@ class ServerUrlValidationTest { assertTrue(validateUrl("home-server.lan:8080")) } + @Test + fun `a hyphen in a subdomain validates`() { + assertTrue(validateUrl("my-sub.example.com")) + } + + @Test + fun `a hyphen in a middle label validates`() { + assertTrue(validateUrl("machine.tailnet-name.ts.net")) + } + @Test fun `a mixed case hostname validates`() { assertTrue(validateUrl("Hammer.Example.com")) @@ -98,4 +108,19 @@ class ServerUrlValidationTest { fun `a host with a trailing hyphen does not validate`() { assertFalse(validateUrl("hammer-.example.com")) } + + @Test + fun `a host with a leading hyphen does not validate`() { + assertFalse(validateUrl("-hammer.example.com")) + } + + @Test + fun `a host with an empty label does not validate`() { + assertFalse(validateUrl("hammer..com")) + } + + @Test + fun `a host with an underscore does not validate`() { + assertFalse(validateUrl("hammer_example.com")) + } } From 199bc7dc6d5f4720c77fec2992091feed70e26f3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 15:43:48 -0700 Subject: [PATCH 014/213] Show error toasts on htmx error responses (#818) htmx discards the body of a 4xx, so the out-of-band toasts the dashboard's rejection paths send never reached the user. The toast helpers mark error responses with X-Hammer-Swap-Error and toast.js swaps the ones carrying it. Marking each response rather than every 4xx keeps the swap away from responses that are not swap payloads: a bare respond(BadRequest) would blank its target, and a 404 or 401 would land a whole error page inside it. Toast-only errors also reswap to none so the toast lands without emptying the request's target. Delete-account no longer needs its 200-on-error workaround. Fixes #812 --- docs/WEB-DESIGN-SYSTEM.md | 6 + server/src/jsTest/toast-logic.test.js | 29 ++++ .../apps/hammer/frontend/DashboardPage.kt | 12 +- .../apps/hammer/frontend/utils/ToastUtils.kt | 49 ++++-- .../main/resources/assets/js/toast-logic.js | 30 ++++ server/src/main/resources/assets/js/toast.js | 11 ++ .../main/resources/templates/footer.mustache | 1 + .../apps/hammer/e2e/DashboardBioTest.kt | 38 +---- .../hammer/e2e/DashboardErrorToastTest.kt | 63 +++++++ .../frontend/utils/ToastResponseTest.kt | 156 ++++++++++++++++++ .../apps/hammer/e2e/util/WebEndToEndTest.kt | 65 ++++++++ 11 files changed, 409 insertions(+), 51 deletions(-) create mode 100644 server/src/jsTest/toast-logic.test.js create mode 100644 server/src/main/resources/assets/js/toast-logic.js create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardErrorToastTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastResponseTest.kt create mode 100644 server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/WebEndToEndTest.kt diff --git a/docs/WEB-DESIGN-SYSTEM.md b/docs/WEB-DESIGN-SYSTEM.md index 481c49c96..de08b640e 100644 --- a/docs/WEB-DESIGN-SYSTEM.md +++ b/docs/WEB-DESIGN-SYSTEM.md @@ -757,6 +757,12 @@ For displaying when lists have no content. **Variants:** `.toast-success`, `.toast-warning`, `.toast-error`, `.toast-info` +Send toasts with the `respondToast` / `respondHtmlWithToast` / `respondTemplateWithToast` helpers +rather than hand-writing the markup. An endpoint that rejects a request should answer with its real +error status: the helpers mark error responses with `X-Hammer-Swap-Error`, which is what makes htmx +swap a body it would otherwise discard, and toast-only errors also reswap to `none` so the request's +target keeps its content. A raw `call.respond(HttpStatusCode.BadRequest, ...)` gets no toast. + --- ## Form Components diff --git a/server/src/jsTest/toast-logic.test.js b/server/src/jsTest/toast-logic.test.js new file mode 100644 index 000000000..b9ff7d073 --- /dev/null +++ b/server/src/jsTest/toast-logic.test.js @@ -0,0 +1,29 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const logic = require('../main/resources/assets/js/toast-logic.js'); + +test('marked error responses are swapped so their toast survives', () => { + assert.equal(logic.shouldSwapErrorResponse(400, 'true'), true); + assert.equal(logic.shouldSwapErrorResponse(422, 'true'), true); + assert.equal(logic.shouldSwapErrorResponse(500, 'true'), true); +}); + +test('unmarked error responses keep htmx default handling', () => { + // A bare 400 would swap an empty body over the target; a 404 would swap a whole error page. + assert.equal(logic.shouldSwapErrorResponse(400, null), false); + assert.equal(logic.shouldSwapErrorResponse(404, null), false); + assert.equal(logic.shouldSwapErrorResponse(401, undefined), false); + assert.equal(logic.shouldSwapErrorResponse(403, 'false'), false); + assert.equal(logic.shouldSwapErrorResponse(400, ''), false); +}); + +test('successful responses are left to htmx even if marked', () => { + assert.equal(logic.shouldSwapErrorResponse(200, 'true'), false); + assert.equal(logic.shouldSwapErrorResponse(204, 'true'), false); + assert.equal(logic.shouldSwapErrorResponse(302, 'true'), false); + assert.equal(logic.shouldSwapErrorResponse(399, 'true'), false); +}); + +test('the header name matches what the server sends', () => { + assert.equal(logic.SWAP_ERROR_HEADER, 'X-Hammer-Swap-Error'); +}); diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt index 2e96302f2..4c360f6a5 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/DashboardPage.kt @@ -321,10 +321,13 @@ fun Route.dashboardPage( val typedEmail = call.receiveParameters()["confirmEmail"]?.trim() ?: "" // The JS arming of the button is UX only; the typed email is the - // real confirmation and must be verified here. Errors respond OK - // because htmx does not swap OOB toasts on 4xx responses. + // real confirmation and must be verified here. if (!typedEmail.equals(account.email, ignoreCase = true)) { - respondToast(call.msg("account_delete_error_email_mismatch"), Toast.Error) + respondToast( + call.msg("account_delete_error_email_mismatch"), + Toast.Error, + HttpStatusCode.BadRequest + ) return@post } @@ -336,7 +339,8 @@ fun Route.dashboardPage( } else { respondToast( result.displayMessageText(call) ?: call.msg("account_delete_error_generic"), - Toast.Error + Toast.Error, + HttpStatusCode.BadRequest ) } } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastUtils.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastUtils.kt index 6feac81a7..92e606fbe 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastUtils.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastUtils.kt @@ -1,11 +1,19 @@ package com.darkrockstudios.apps.hammer.frontend.utils import com.github.mustachejava.DefaultMustacheFactory +import io.ktor.htmx.HxResponseHeaders +import io.ktor.htmx.HxSwap import io.ktor.http.* import io.ktor.server.response.* import io.ktor.server.routing.* import java.io.StringWriter +/** + * Marks an error response whose body is an htmx swap payload. htmx discards 4xx and 5xx bodies by + * default; toast.js swaps the ones carrying this header so their toast reaches the user. + */ +const val SWAP_ERROR_HEADER = "X-Hammer-Swap-Error" + /** * Toast notification types for server-driven OOB swaps */ @@ -48,12 +56,7 @@ suspend fun RoutingContext.respondHtmlWithToast( toast: Toast = Toast.Success, status: HttpStatusCode = HttpStatusCode.OK ) { - val toastHtml = toastHtml(message, toast) - call.respondText( - text = "$content$toastHtml", - contentType = ContentType.Text.Html, - status = status - ) + respondSwap(content, message, toast, status) } /** @@ -65,8 +68,32 @@ suspend fun RoutingContext.respondToast( toast: Toast = Toast.Success, status: HttpStatusCode = HttpStatusCode.OK ) { + respondSwap(content = "", message = message, toast = toast, status = status) +} + +/** + * The single exit for htmx swap payloads: content, its toast, and the headers that decide what + * htmx does with an error response. Error statuses are marked with [SWAP_ERROR_HEADER] so the + * client swaps them at all, and those with nothing to show reswap to `none` so the toast lands + * without emptying the request's target. Whitespace counts as nothing to show: a template of + * all-conditional sections renders to a stray newline, which would blank the target just as + * thoroughly as an empty body. + */ +private suspend fun RoutingContext.respondSwap( + content: String, + message: String, + toast: Toast, + status: HttpStatusCode +) { + if (status.value >= 400) { + call.response.header(SWAP_ERROR_HEADER, "true") + if (content.isBlank()) { + call.response.header(HxResponseHeaders.Reswap, HxSwap.none) + } + } + call.respondText( - text = toastHtml(message, toast), + text = content + toastHtml(message, toast), contentType = ContentType.Text.Html, status = status ) @@ -97,11 +124,5 @@ suspend fun RoutingContext.respondTemplateWithToast( toast: Toast = Toast.Success, status: HttpStatusCode = HttpStatusCode.OK ) { - val content = renderTemplate(templatePath, model) - val toastHtml = toastHtml(message, toast) - call.respondText( - text = "$content$toastHtml", - contentType = ContentType.Text.Html, - status = status - ) + respondSwap(renderTemplate(templatePath, model), message, toast, status) } diff --git a/server/src/main/resources/assets/js/toast-logic.js b/server/src/main/resources/assets/js/toast-logic.js new file mode 100644 index 000000000..29cf97fa1 --- /dev/null +++ b/server/src/main/resources/assets/js/toast-logic.js @@ -0,0 +1,30 @@ +/** + * Pure decision logic for htmx error responses — no DOM, no globals beyond these + * functions and constants. Kept DOM-free so it can be unit-tested under Node + * while still loading as a plain browser {{#page_pre_script}} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardBioTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardBioTest.kt index 79028c280..7e3ed9a97 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardBioTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardBioTest.kt @@ -3,12 +3,8 @@ package com.darkrockstudios.apps.hammer.e2e import ch.qos.logback.classic.Level import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.AppenderBase -import com.darkrockstudios.apps.hammer.e2e.util.E2eTestData -import com.darkrockstudios.apps.hammer.e2e.util.EndToEndTest -import com.darkrockstudios.apps.hammer.e2e.util.TestAccount -import io.ktor.client.HttpClient +import com.darkrockstudios.apps.hammer.e2e.util.WebEndToEndTest import io.ktor.client.plugins.compression.ContentEncoding -import io.ktor.client.plugins.cookies.HttpCookies import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -25,7 +21,6 @@ import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Clock /** * Saving a bio renders a fragment large enough for the Compression plugin to rewrite the response, @@ -34,7 +29,7 @@ import kotlin.time.Clock * The failure this guards against surfaces only in the log: the response is already written by the * time the handler's coroutine resumes, so a crash there leaves the client with a healthy 200. */ -class DashboardBioTest : EndToEndTest() { +class DashboardBioTest : WebEndToEndTest() { private val email = "author@test.com" private val password = "password123!@#" @@ -59,35 +54,12 @@ class DashboardBioTest : EndToEndTest() { errorCollector.stop() } - private fun seed() = runBlocking { - E2eTestData.createAccount(TestAccount(email, password), database()) - database().serverDatabase.whiteListQueries - .addToWhiteList(email, Clock.System.now(), "Test author", null) - val account = database().serverDatabase.accountQueries.findAccount(email).executeAsOne() - database().serverDatabase.accountQueries.updatePenName("Test Author", account.id) - } - - private suspend fun login(): HttpClient { - val authed = HttpClient { - install(HttpCookies) - install(ContentEncoding) { gzip() } - } - val response = authed.post(route("login")) { - contentType(ContentType.Application.FormUrlEncoded) - setBody( - "email=${URLEncoder.encode(email, "UTF-8")}" + - "&password=${URLEncoder.encode(password, "UTF-8")}" - ) - } - assertEquals(HttpStatusCode.Found, response.status) - return authed - } - @Test fun `saving a bio renders the section and a toast without failing the handler`(): Unit = runBlocking { doStartServer() - seed() - val authed = login() + seedWhitelistedAccount(email, password, penName = "Test Author") + // Compression is what makes the send pipeline suspend, which is the point of this test. + val authed = login(email, password) { install(ContentEncoding) { gzip() } } collectServerErrors() val bio = "I write stories about the sea. ".repeat(30) diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardErrorToastTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardErrorToastTest.kt new file mode 100644 index 000000000..723ca8585 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/DashboardErrorToastTest.kt @@ -0,0 +1,63 @@ +package com.darkrockstudios.apps.hammer.e2e + +import com.darkrockstudios.apps.hammer.e2e.util.WebEndToEndTest +import com.darkrockstudios.apps.hammer.frontend.utils.SWAP_ERROR_HEADER +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +/** + * The dashboard's rejection paths answer with an error status and an out-of-band toast. htmx + * discards the body of a 4xx unless the response says otherwise, so without these headers the + * user gets no feedback at all. + */ +class DashboardErrorToastTest : WebEndToEndTest() { + + private val email = "author@test.com" + private val password = "password123!@#" + + private suspend fun HttpClient.postForm(path: String, body: String): HttpResponse = + post(route(path)) { + header("HX-Request", "true") + contentType(ContentType.Application.FormUrlEncoded) + setBody(body) + } + + @Test + fun `a rejected pen name answers with a toast htmx will swap`(): Unit = runBlocking { + doStartServer() + seedWhitelistedAccount(email, password) + val authed = login(email, password) + + val response = authed.postForm("dashboard/penname", "penName=") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("true", response.headers[SWAP_ERROR_HEADER]) + // The form targets the displayed pen name; an empty error body must not wipe it. + assertEquals("none", response.headers["HX-Reswap"]) + assertContains(response.bodyAsText(), "toast-error") + } + + @Test + fun `a mismatched deletion confirmation answers with a toast htmx will swap`(): Unit = runBlocking { + doStartServer() + seedWhitelistedAccount(email, password) + val authed = login(email, password) + + val response = authed.postForm("dashboard/delete-account", "confirmEmail=someone@else.com") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("true", response.headers[SWAP_ERROR_HEADER]) + assertContains(response.bodyAsText(), "toast-error") + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastResponseTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastResponseTest.kt new file mode 100644 index 000000000..9a46dd7d1 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/ToastResponseTest.kt @@ -0,0 +1,156 @@ +package com.darkrockstudios.apps.hammer.frontend.utils + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import org.junit.jupiter.api.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * htmx drops the body of a 4xx response, out-of-band toast and all, so an error toast only reaches + * the user if the response opts back in with [SWAP_ERROR_HEADER]. The client-side handler in + * toast.js keys off exactly what is asserted here. + */ +class ToastResponseTest { + + @Test + fun `a toast-only error response is marked swappable`() = testApplication { + application { + routing { + get("/error-toast") { + respondToast("nope", Toast.Error, HttpStatusCode.BadRequest) + } + } + } + + val response = client.get("/error-toast") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("true", response.headers[SWAP_ERROR_HEADER]) + assertContains(response.bodyAsText(), "toast-error") + } + + @Test + fun `an error response with no content leaves the target alone`() = testApplication { + application { + routing { + get("/error-toast") { + respondHtmlWithToast( + content = "", + message = "nope", + toast = Toast.Error, + status = HttpStatusCode.BadRequest + ) + } + } + } + + val response = client.get("/error-toast") + + assertEquals("true", response.headers[SWAP_ERROR_HEADER]) + assertEquals("none", response.headers["HX-Reswap"]) + } + + @Test + fun `an error response of only whitespace leaves the target alone`() = testApplication { + application { + routing { + get("/error-toast") { + // What a template of all-conditional sections renders to when none of them match. + respondHtmlWithToast( + content = "\n", + message = "nope", + toast = Toast.Error, + status = HttpStatusCode.BadRequest + ) + } + } + } + + val response = client.get("/error-toast") + + assertEquals("none", response.headers["HX-Reswap"]) + } + + @Test + fun `an error response carrying content still swaps into the target`() = testApplication { + application { + routing { + get("/error-toast") { + respondHtmlWithToast( + content = "

replacement

", + message = "nope", + toast = Toast.Error, + status = HttpStatusCode.BadRequest + ) + } + } + } + + val response = client.get("/error-toast") + + assertEquals("true", response.headers[SWAP_ERROR_HEADER]) + assertNull(response.headers["HX-Reswap"]) + assertContains(response.bodyAsText(), "

replacement

") + } + + @Test + fun `successful toast responses are left untouched`() = testApplication { + application { + routing { + get("/ok-toast") { + respondToast("saved", Toast.Success) + } + get("/ok-content") { + respondHtmlWithToast(content = "", message = "saved") + } + } + } + + listOf("/ok-toast", "/ok-content").forEach { path -> + val response = client.get(path) + + assertEquals(HttpStatusCode.OK, response.status, path) + assertNull(response.headers[SWAP_ERROR_HEADER], path) + assertNull(response.headers["HX-Reswap"], path) + } + } + + /** + * The client half of the contract is a string literal in a JS asset, so a rename here would + * leave every Kotlin test passing while error toasts silently stopped appearing again. + */ + @Test + fun `the client keys off the same header name`() { + val script = checkNotNull(javaClass.getResource("/assets/js/toast-logic.js")) { + "toast-logic.js is not on the classpath" + }.readText() + + assertContains(script, "'$SWAP_ERROR_HEADER'") + } + + @Test + fun `rendered template responses carry the toast`() = testApplication { + application { + routing { + get("/template") { + respondTemplateWithToast( + templatePath = "partials/server-notice.mustache", + model = mapOf("hasInstanceNotice" to false), + message = "saved" + ) + } + } + } + + val response = client.get("/template") + + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "toast-success") + } +} diff --git a/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/WebEndToEndTest.kt b/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/WebEndToEndTest.kt new file mode 100644 index 000000000..608ec6d6e --- /dev/null +++ b/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/WebEndToEndTest.kt @@ -0,0 +1,65 @@ +package com.darkrockstudios.apps.hammer.e2e.util + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import java.net.URLEncoder +import kotlin.time.Clock + +/** + * Base for end-to-end tests that drive the web frontend rather than the sync API: seeds a + * whitelisted account and signs it in through the real login form, leaving the session cookie + * on the returned client. + */ +abstract class WebEndToEndTest : EndToEndTest() { + + /** Creates [email] as a whitelisted account, optionally with [penName] already claimed. */ + protected fun seedWhitelistedAccount( + email: String, + password: String, + penName: String? = null, + ) = runBlocking { + E2eTestData.createAccount(TestAccount(email, password), database()) + database().serverDatabase.whiteListQueries + .addToWhiteList(email, Clock.System.now(), "Test author", null) + + if (penName != null) { + val account = database().serverDatabase.accountQueries.findAccount(email).executeAsOne() + database().serverDatabase.accountQueries.updatePenName(penName, account.id) + } + } + + /** + * Signs in over the login form and returns the client holding the session. [configure] adds + * to the client being built, for tests that need a particular transport behaviour. + */ + protected suspend fun login( + email: String, + password: String, + configure: HttpClientConfig<*>.() -> Unit = {}, + ): HttpClient { + val authed = HttpClient { + install(HttpCookies) + configure() + } + + val response = authed.post(route("login")) { + contentType(ContentType.Application.FormUrlEncoded) + setBody( + "email=${URLEncoder.encode(email, "UTF-8")}" + + "&password=${URLEncoder.encode(password, "UTF-8")}" + ) + } + check(response.status == HttpStatusCode.Found) { + "Harness could not log $email in: got ${response.status}" + } + + return authed + } +} From 480453415133ddbc07b398d69f230571ff1b888a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 15:45:52 -0700 Subject: [PATCH 015/213] Add visible scrollbars to scrolling screens (#820) Fixes #787. Settings and other long screens had no scrollbar, so on Linux there was nothing to indicate the page scrolled at all. Adds ScrollState, LazyGridState and LazyStaggeredGridState scrollbars alongside the existing LazyListState one, and wires them into the screens that scroll: account and project settings, About, project stats, the encyclopedia/notes/timeline/story-idea detail views and browse grids, the outline reading column and chapter rail, the scene metadata panel, and the shared reorderable list. Compose ships no staggered-grid scrollbar adapter, so that one is implemented over the state's ScrollIndicatorState. Desktop draws the bar as a hatched rail: an outlineVariant hairline on the scrollable edge with 45 degree hatching behind an opaque thumb, so the hatch that remains marks the part of the document still to come. Both are drawn only while there is somewhere to scroll. Android keeps its transient fading thumb, now themed rather than black on white, and iOS keeps its native indicators. Screens overlay the bar with scrollBarOverlay(); full-bleed content reserves MpScrollBarGutter so the rail does not sit on top of it. --- .../apps/hammer/common/compose/MpScrollBar.kt | 309 ++++++++-------- .../apps/hammer/common/compose/MpScrollBar.kt | 29 ++ .../hammer/common/compose/ScrollBarOverlay.kt | 22 ++ .../compose/designsystem/DESIGN_README.md | 53 +++ .../compose/reorderable/DragDropList.kt | 110 +++--- .../common/encyclopedia/BrowseEntriesUi.kt | 71 ++-- .../hammer/common/encyclopedia/ViewEntryUi.kt | 261 +++++++------- .../apps/hammer/common/notes/BrowseNotesUi.kt | 84 +++-- .../apps/hammer/common/notes/ViewNoteUi.kt | 89 ++--- .../common/projecthome/ProjectSettingsUi.kt | 61 ++-- .../common/projecthome/ProjectStatsUi.kt | 86 +++-- .../common/projectselection/ProjectListUi.kt | 9 +- .../projectselection/about/AboutAppUi.kt | 135 +++---- .../settings/AccountSettingsUi.kt | 183 +++++----- .../storyideas/StoryIdeasUi.kt | 206 ++++++----- .../common/storyeditor/OutlineOverviewUi.kt | 86 +++-- .../sceneeditor/SceneMetadataPanelUi.kt | 340 +++++++++--------- .../common/timeline/TimeLineOverviewUi.kt | 34 +- .../common/timeline/ViewTimeLineEventUi.kt | 89 ++--- .../apps/hammer/common/compose/MpScrollBar.kt | 166 ++++++++- .../apps/hammer/common/compose/MpScrollBar.kt | 31 ++ 21 files changed, 1450 insertions(+), 1004 deletions(-) create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScrollBarOverlay.kt diff --git a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt index 169df5975..03537e682 100644 --- a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt +++ b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt @@ -3,9 +3,13 @@ package com.darkrockstudios.apps.hammer.common.compose import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -16,209 +20,176 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +// The Android bar fades in over the content, so it reserves no layout space. +actual val MpScrollBarGutter: Dp = 0.dp + @Composable actual fun MpScrollBarList( modifier: Modifier, state: LazyListState ) { - AndroidScrollbar( - modifier = modifier, - state = state, - horizontal = false, - fixedKnobRatio = 0.1f + ScrollbarCanvas(modifier = modifier, isScrollInProgress = state.isScrollInProgress) { viewportSize -> + val firstVisibleItem = state.layoutInfo.visibleItemsInfo.firstOrNull() + val firstItemSize = firstVisibleItem?.size ?: 0 + // Estimated from the first visible item, so the knob drifts when item sizes are not uniform. + // A fixed knob size keeps it from visibly growing and shrinking as you scroll. + val estimatedContentSize = (firstItemSize * state.layoutInfo.totalItemsCount).toFloat() + if (estimatedContentSize <= 0f) { + null + } else { + val offset = state.firstVisibleItemIndex * firstItemSize + state.firstVisibleItemScrollOffset + ScrollbarKnob( + position = (viewportSize / estimatedContentSize) * offset, + size = viewportSize * FixedKnobRatio, + ) + } + } +} + +@Composable +actual fun MpScrollBarColumn( + modifier: Modifier, + state: ScrollState +) { + ScrollbarCanvas(modifier = modifier, isScrollInProgress = state.isScrollInProgress) { viewportSize -> + val scrollRange = state.maxValue + // A ScrollState that was never attached to a scrolling container reports Int.MAX_VALUE. + if (scrollRange <= 0 || scrollRange == Int.MAX_VALUE) { + null + } else { + val contentSize = viewportSize + scrollRange + ScrollbarKnob( + position = (viewportSize / contentSize) * state.value, + size = (viewportSize / contentSize) * viewportSize, + ) + } + } +} + +@Composable +actual fun MpScrollBarGrid( + modifier: Modifier, + state: LazyGridState +) { + ScrollbarCanvas(modifier = modifier, isScrollInProgress = state.isScrollInProgress) { viewportSize -> + itemCountKnob( + viewportSize = viewportSize, + firstVisibleIndex = state.firstVisibleItemIndex, + visibleCount = state.layoutInfo.visibleItemsInfo.size, + totalCount = state.layoutInfo.totalItemsCount, + ) + } +} + +@Composable +actual fun MpScrollBarStaggeredGrid( + modifier: Modifier, + state: LazyStaggeredGridState +) { + ScrollbarCanvas(modifier = modifier, isScrollInProgress = state.isScrollInProgress) { viewportSize -> + itemCountKnob( + viewportSize = viewportSize, + firstVisibleIndex = state.firstVisibleItemIndex, + visibleCount = state.layoutInfo.visibleItemsInfo.size, + totalCount = state.layoutInfo.totalItemsCount, + ) + } +} + +/** Knob ratio for lists, whose content extent can only be estimated. */ +private const val FixedKnobRatio = 0.1f + +/** + * Knob driven by item counts rather than pixel extents, so it works the same for uniform and + * staggered grids. It moves one item at a time, fine-grained enough for grids that hold more items + * than fit on screen. + */ +private fun itemCountKnob( + viewportSize: Float, + firstVisibleIndex: Int, + visibleCount: Int, + totalCount: Int, +): ScrollbarKnob? = if (totalCount <= 0 || visibleCount <= 0 || visibleCount >= totalCount) { + null +} else { + ScrollbarKnob( + position = viewportSize * (firstVisibleIndex.toFloat() / totalCount), + size = viewportSize * (visibleCount.toFloat() / totalCount), ) } +/** Where the knob sits along the track and how long it is, both in pixels. */ +private data class ScrollbarKnob( + val position: Float, + val size: Float, +) /** - * Renders a scrollbar. + * Vertical scrollbar that appears when the user starts scrolling and fades out once they stop. + * It is composed of a track and a knob that moves across it; [knob] supplies the knob's position + * and length for a given viewport, and returns `null` when there is nothing to scroll. * - *
  • A scrollbar is composed of two components: a track and a knob. The knob moves across - * the track
  • The scrollbar appears automatically when the user starts scrolling and disappears - * after the scrolling is finished
+ * The knob is measured inside the draw lambda, so scroll position reads invalidate draw only. * - * @param state The [LazyListState] that has been passed into the lazy list or lazy row - * @param horizontal If `true`, this will be a horizontally-scrolling (left and right) scroll bar, - * if `false`, it will be vertically-scrolling (up and down) - * @param alignEnd If `true`, the scrollbar will appear at the "end" of the scrollable composable it - * is decorating (at the right-hand side in left-to-right locales or left-hand side in right-to-left - * locales, for the vertical scrollbars -or- the bottom for horizontal scrollbars). If `false`, the - * scrollbar will appear at the "start" of the scrollable composable it is decorating (at the - * left-hand side in left-to-right locales or right-hand side in right-to-left locales, for the - * vertical scrollbars -or- the top for horizontal scrollbars) - * @param thickness How thick/wide the track and knob should be - * @param fixedKnobRatio If not `null`, the knob will always have this size, proportional to the - * size of the track. You should consider doing this if the size of the items in the scrollable - * composable is not uniform, to avoid the knob from oscillating in size as you scroll through the - * list - * @param knobCornerRadius The corner radius for the knob - * @param trackCornerRadius The corner radius for the track - * @param knobColor The color of the knob - * @param trackColor The color of the track. Make it [Color.Transparent] to hide it - * @param padding Edge padding to "squeeze" the scrollbar start/end in so it's not flush with the - * contents of the scrollable composable it is decorating - * @param visibleAlpha The alpha when the scrollbar is fully faded-in - * @param hiddenAlpha The alpha when the scrollbar is fully faded-out. Use a non-`0` number to keep - * the scrollbar from ever fading out completely - * @param fadeInAnimationDurationMs The duration of the fade-in animation when the scrollbar appears - * once the user starts scrolling - * @param fadeOutAnimationDurationMs The duration of the fade-out animation when the scrollbar - * disappears after the user is finished scrolling - * @param fadeOutAnimationDelayMs Amount of time to wait after the user is finished scrolling before - * the scrollbar begins its fade-out animation + * @param thickness how thick the track and knob should be + * @param trackColor colour of the track; [Color.Transparent] hides it + * @param padding edge padding, so the knob is not flush with the ends of the track + * @param hiddenAlpha use a non-zero value to keep the scrollbar from fading out completely */ @Composable -fun AndroidScrollbar( - modifier: Modifier = Modifier, - state: LazyListState, - horizontal: Boolean, - alignEnd: Boolean = true, +private fun ScrollbarCanvas( + modifier: Modifier, + isScrollInProgress: Boolean, thickness: Dp = 4.dp, - fixedKnobRatio: Float? = null, knobCornerRadius: Dp = 4.dp, trackCornerRadius: Dp = 2.dp, - knobColor: Color = Color.Black, - trackColor: Color = Color.White, + knobColor: Color = MaterialTheme.colorScheme.outline, + trackColor: Color = Color.Transparent, padding: Dp = 0.dp, visibleAlpha: Float = 1f, hiddenAlpha: Float = 0f, fadeInAnimationDurationMs: Int = 150, fadeOutAnimationDurationMs: Int = 500, fadeOutAnimationDelayMs: Int = 1000, + knob: (viewportSize: Float) -> ScrollbarKnob?, ) { - check(thickness > 0.dp) { "Thickness must be a positive integer." } - check(fixedKnobRatio == null || fixedKnobRatio < 1f) { - "A fixed knob ratio must be smaller than 1." - } - check(knobCornerRadius >= 0.dp) { "Knob corner radius must be greater than or equal to 0." } - check(trackCornerRadius >= 0.dp) { "Track corner radius must be greater than or equal to 0." } - check(hiddenAlpha <= visibleAlpha) { "Hidden alpha cannot be greater than visible alpha." } - check(fadeInAnimationDurationMs >= 0) { - "Fade in animation duration must be greater than or equal to 0." - } - check(fadeOutAnimationDurationMs >= 0) { - "Fade out animation duration must be greater than or equal to 0." - } - check(fadeOutAnimationDelayMs >= 0) { - "Fade out animation delay must be greater than or equal to 0." - } - - val targetAlpha = - if (state.isScrollInProgress) { - visibleAlpha - } else { - hiddenAlpha - } + val targetAlpha = if (isScrollInProgress) visibleAlpha else hiddenAlpha val animationDurationMs = - if (state.isScrollInProgress) { - fadeInAnimationDurationMs - } else { - fadeOutAnimationDurationMs - } - val animationDelayMs = - if (state.isScrollInProgress) { - 0 - } else { - fadeOutAnimationDelayMs - } + if (isScrollInProgress) fadeInAnimationDurationMs else fadeOutAnimationDurationMs + val animationDelayMs = if (isScrollInProgress) 0 else fadeOutAnimationDelayMs - val alpha by - animateFloatAsState( + val alpha by animateFloatAsState( targetValue = targetAlpha, - animationSpec = - tween(delayMillis = animationDelayMs, durationMillis = animationDurationMs) + animationSpec = tween(delayMillis = animationDelayMs, durationMillis = animationDurationMs), ) - return Canvas( + Canvas( modifier = modifier .width(thickness) .fillMaxHeight() ) { - state.layoutInfo.visibleItemsInfo.firstOrNull()?.let { firstVisibleItem -> - if (state.isScrollInProgress || alpha > 0f) { - // Size of the viewport, the entire size of the scrollable composable we are decorating with - // this scrollbar. - val viewportSize = - if (horizontal) { - size.width - } else { - size.height - } - padding.toPx() * 2 + if (!isScrollInProgress && alpha <= 0f) return@Canvas - // The size of the first visible item. We use this to estimate how many items can fit in the - // viewport. Of course, this works perfectly when all items have the same size. When they - // don't, the scrollbar knob size will grow and shrink as we scroll. - val firstItemSize = firstVisibleItem.size + val thicknessPx = thickness.toPx() + val paddingPx = padding.toPx() + val trackLength = size.height - paddingPx * 2 + val measured = knob(trackLength) ?: return@Canvas + val left = size.width - thicknessPx - // The *estimated* size of the entire scrollable composable, as if it's all on screen at - // once. It is estimated because it's possible that the size of the first visible item does - // not represent the size of other items. This will cause the scrollbar knob size to grow - // and shrink as we scroll, if the item sizes are not uniform. - val estimatedFullListSize = firstItemSize * state.layoutInfo.totalItemsCount + drawRoundRect( + color = trackColor, + topLeft = Offset(left, paddingPx), + size = Size(thicknessPx, trackLength), + alpha = alpha, + cornerRadius = CornerRadius(x = trackCornerRadius.toPx(), y = trackCornerRadius.toPx()), + ) - // The difference in position between the first pixels visible in our viewport as we scroll - // and the top of the fully-populated scrollable composable, if it were to show all the - // items at once. At first, the value is 0 since we start all the way to the top (or start - // edge). As we scroll down (or towards the end), this number will grow. - val viewportOffsetInFullListSpace = - state.firstVisibleItemIndex * firstItemSize + state.firstVisibleItemScrollOffset - - // Where we should render the knob in our composable. - val knobPosition = - (viewportSize / estimatedFullListSize) * viewportOffsetInFullListSpace + padding.toPx() - // How large should the knob be. - val knobSize = - fixedKnobRatio?.let { it * viewportSize } - ?: (viewportSize * viewportSize) / estimatedFullListSize - - // Draw the track - drawRoundRect( - color = trackColor, - topLeft = - when { - // When the scrollbar is horizontal and aligned to the bottom: - horizontal && alignEnd -> Offset(padding.toPx(), size.height - thickness.toPx()) - // When the scrollbar is horizontal and aligned to the top: - horizontal && !alignEnd -> Offset(padding.toPx(), 0f) - // When the scrollbar is vertical and aligned to the end: - alignEnd -> Offset(size.width - thickness.toPx(), padding.toPx()) - // When the scrollbar is vertical and aligned to the start: - else -> Offset(0f, padding.toPx()) - }, - size = - if (horizontal) { - Size(size.width - padding.toPx() * 2, thickness.toPx()) - } else { - Size(thickness.toPx(), size.height - padding.toPx() * 2) - }, - alpha = alpha, - cornerRadius = CornerRadius(x = trackCornerRadius.toPx(), y = trackCornerRadius.toPx()), - ) - - // Draw the knob - drawRoundRect( - color = knobColor, - topLeft = - when { - // When the scrollbar is horizontal and aligned to the bottom: - horizontal && alignEnd -> Offset(knobPosition, size.height - thickness.toPx()) - // When the scrollbar is horizontal and aligned to the top: - horizontal && !alignEnd -> Offset(knobPosition, 0f) - // When the scrollbar is vertical and aligned to the end: - alignEnd -> Offset(size.width - thickness.toPx(), knobPosition) - // When the scrollbar is vertical and aligned to the start: - else -> Offset(0f, knobPosition) - }, - size = - if (horizontal) { - Size(knobSize, thickness.toPx()) - } else { - Size(thickness.toPx(), knobSize) - }, - alpha = alpha, - cornerRadius = CornerRadius(x = knobCornerRadius.toPx(), y = knobCornerRadius.toPx()), - ) - } - } + drawRoundRect( + color = knobColor, + topLeft = Offset(left, measured.position + paddingPx), + size = Size(thicknessPx, measured.size), + alpha = alpha, + cornerRadius = CornerRadius(x = knobCornerRadius.toPx(), y = knobCornerRadius.toPx()), + ) } -} \ No newline at end of file +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt index 37230f2a2..003625877 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt @@ -1,12 +1,41 @@ package com.darkrockstudios.apps.hammer.common.compose +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +/** + * Width the scrollbar occupies at the scrollable edge. Screens whose content runs edge to edge + * should reserve this much trailing space so the bar does not sit on top of it. Zero on platforms + * whose scrollbar is a transient overlay. + */ +expect val MpScrollBarGutter: Dp @Composable expect fun MpScrollBarList( modifier: Modifier = Modifier.fillMaxHeight(), state: LazyListState ) + +@Composable +expect fun MpScrollBarColumn( + modifier: Modifier = Modifier.fillMaxHeight(), + state: ScrollState +) + +@Composable +expect fun MpScrollBarGrid( + modifier: Modifier = Modifier.fillMaxHeight(), + state: LazyGridState +) + +@Composable +expect fun MpScrollBarStaggeredGrid( + modifier: Modifier = Modifier.fillMaxHeight(), + state: LazyStaggeredGridState +) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScrollBarOverlay.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScrollBarOverlay.kt new file mode 100644 index 000000000..c0f2ee183 --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScrollBarOverlay.kt @@ -0,0 +1,22 @@ +package com.darkrockstudios.apps.hammer.common.compose + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** Whether there is anywhere left to scroll in either direction. */ +val ScrollableState.isScrollable: Boolean + get() = canScrollForward || canScrollBackward + +/** + * Placement for a scrollbar overlaid on a scrolling sibling: full height, pinned to the trailing + * edge. Uses [BoxScope.matchParentSize] rather than `fillMaxHeight` so the bar never contributes to + * the parent's size, which matters in the wrap-content boxes the detail screens use. + */ +@Suppress("ModifierFactoryExtensionFunction") +fun BoxScope.scrollBarOverlay(): Modifier = + Modifier + .matchParentSize() + .wrapContentWidth(Alignment.End) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md index 53079832a..86e253305 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md @@ -689,6 +689,59 @@ Encyclopedia). Tag collapse lives inside `HdHairlineTagField` so it works wherever the field is used; date and crumb rows are wrapped at their call sites. +### Scrollbar rail + +Desktop scrollbars are drawn as a **hatched rail** rather than a bare +thumb: a vertical `outlineVariant` hairline marking the scrollable edge, +with 45° hairline hatching filling the track behind an opaque square +thumb. + +``` +│╱╱╱│ +│███│ <- thumb, masking the hatch +│╱╱╱│ + ^ edge rule +``` + +The hatch is [greeble](#greebles) logic applied to texture — it means +something. The thumb masks it as it travels, so the hatching that +remains is exactly the part of the document you haven't reached. It also +follows rule 1: the edge rule is the same vertical hairline +`HdHairlineSection` uses to group content, so a scrolling screen reads +as bounded rather than cut off. + +Both the rule and the hatch are drawn **only while there is somewhere to +scroll**, so a screen whose content fits gets no chrome at all. The +thumb rests at `outlineVariant` and promotes to `outline` on hover — +Compose's default is 12% black, which vanishes on a dark surface. + +Screens don't build any of this. Overlay the bar on its scrolling +sibling inside a `Box`: + +```kotlin +Box(Modifier.weight(1f)) { + LazyColumn(state = listState) { … } + MpScrollBarList(modifier = scrollBarOverlay(), state = listState) +} +``` + +- **[`scrollBarOverlay()`](../ScrollBarOverlay.kt)** is the only correct + placement — `matchParentSize()`, so the bar never contributes to the + parent's size. `fillMaxHeight()` would stretch the wrap-content boxes + the detail screens use. +- **[`MpScrollBarGutter`](../MpScrollBar.kt)** is the width to reserve + when content runs *edge to edge* (a full-bleed row that paints its own + background, or a drag surface). Screens with normal horizontal padding + need nothing — the rail sits in the padding they already have. + +**Platform caveat, and a known exception to cross-platform parity.** The +rail is `desktopMain` only. Android draws a transient fading thumb and +iOS uses its native inline indicators, so `MpScrollBarGutter` is `0.dp` +on both. This is the one piece of visual vocabulary that isn't +`commonMain`; a persistent hatched rail is right for a pointer and wrong +for a touch screen. If it ever needs to be shared, the drawing belongs +in an `Hd*` here and the platform files should supply only the thumb. + --- ## Adding to the system diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/reorderable/DragDropList.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/reorderable/DragDropList.kt index 3a27b62de..ab04d6379 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/reorderable/DragDropList.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/reorderable/DragDropList.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable @@ -20,6 +22,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.zIndex +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarGutter +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -64,58 +69,67 @@ fun DragDropList( }) val hapticFeedback = LocalHapticFeedback.current - LazyColumn( - modifier = modifier - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDrag = { change, offset -> - change.consume() - dragDropListState.onDrag(offset) + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(end = MpScrollBarGutter) + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropListState.onDrag(offset) - if (overscrollJob?.isActive == true) - return@detectDragGesturesAfterLongPress + if (overscrollJob?.isActive == true) + return@detectDragGesturesAfterLongPress - dragDropListState.checkForOverScroll() - .takeIf { it != 0f } - ?.let { overscrollJob = scope.launch { dragDropListState.lazyListState.scrollBy(it) } } - ?: run { overscrollJob?.cancel() } - }, - onDragStart = { offset -> - if (dragDropListState.onDragStart(offset)) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - }, - onDragEnd = { dragDropListState.onDragEnd() }, - onDragCancel = { dragDropListState.onDragInterrupted() } - ) - }, - state = dragDropListState.lazyListState, - contentPadding = contentPadding - ) { - itemsIndexed(data, key) { index, item -> - val isDragging = index == dragDropListState.currentIndexOfDraggedItem - val isReordering = dragDropListState.currentIndexOfDraggedItem != null - val zIndex = if (isDragging) { - 1f - } else { - 0f - } - // Animate placement only while reordering, so the displaced items - // slide into place; otherwise plain scrolling would animate them too. - val itemModifier = when { - isDragging -> Modifier.graphicsLayer { - translationY = dragDropListState.elementDisplacement ?: 0f + dragDropListState.checkForOverScroll() + .takeIf { it != 0f } + ?.let { overscrollJob = scope.launch { dragDropListState.lazyListState.scrollBy(it) } } + ?: run { overscrollJob?.cancel() } + }, + onDragStart = { offset -> + if (dragDropListState.onDragStart(offset)) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + onDragEnd = { dragDropListState.onDragEnd() }, + onDragCancel = { dragDropListState.onDragInterrupted() } + ) + }, + state = dragDropListState.lazyListState, + contentPadding = contentPadding + ) { + itemsIndexed(data, key) { index, item -> + val isDragging = index == dragDropListState.currentIndexOfDraggedItem + val isReordering = dragDropListState.currentIndexOfDraggedItem != null + val zIndex = if (isDragging) { + 1f + } else { + 0f + } + // Animate placement only while reordering, so the displaced items + // slide into place; otherwise plain scrolling would animate them too. + val itemModifier = when { + isDragging -> Modifier.graphicsLayer { + translationY = dragDropListState.elementDisplacement ?: 0f + } + isReordering -> Modifier.animateItem() + else -> Modifier + } + Box( + modifier = Modifier + .zIndex(zIndex) + .then(itemModifier) + ) { + itemContent(item, isDragging) } - isReordering -> Modifier.animateItem() - else -> Modifier - } - Box( - modifier = Modifier - .zIndex(zIndex) - .then(itemModifier) - ) { - itemContent(item, isDragging) } } + + MpScrollBarList( + modifier = scrollBarOverlay(), + state = dragDropListState.lazyListState, + ) } } \ No newline at end of file diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/BrowseEntriesUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/BrowseEntriesUi.kt index 2320245a8..8d3d03218 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/BrowseEntriesUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/BrowseEntriesUi.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create @@ -51,6 +53,7 @@ import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.encyclopedia.BrowseEntries import com.darkrockstudios.apps.hammer.common.components.encyclopedia.Encyclopedia import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarGrid import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCollapsingStrip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEntryFilterBar @@ -63,6 +66,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchRow import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSectionHeader import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagChip import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType import com.darkrockstudios.apps.hammer.common.data.search.parseQuery @@ -224,37 +228,46 @@ fun BrowseEntriesUi( // Card grid — adaptive 320dp min cell so on wide screens we get // 3-4 columns and on narrow screens cards stack into a single // column. - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 320.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(Ui.Padding.XL), - verticalArrangement = Arrangement.spacedBy(Ui.Padding.XL), - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.XL), - ) { - if (filteredEntries.isEmpty()) { - item { - Text( - text = Res.string.encyclopedia_browse_list_empty.get(), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(Ui.Padding.XL), - ) - } - } else { - items(filteredEntries.size) { index -> - EncyclopediaEntryItem( - entryDef = filteredEntries[index], - component = component, - viewEntry = viewEntry, - scope = scope, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - activeTags = activeTags.toSet(), - tagsScrollHorizontally = !isWide, - filterByType = onSelectType, - ) + val gridState = rememberLazyGridState() + Box(modifier = Modifier.weight(1f)) { + LazyVerticalGrid( + state = gridState, + columns = GridCells.Adaptive(minSize = 320.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(Ui.Padding.XL), + verticalArrangement = Arrangement.spacedBy(Ui.Padding.XL), + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.XL), + ) { + if (filteredEntries.isEmpty()) { + item { + Text( + text = Res.string.encyclopedia_browse_list_empty.get(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(Ui.Padding.XL), + ) + } + } else { + items(filteredEntries.size) { index -> + EncyclopediaEntryItem( + entryDef = filteredEntries[index], + component = component, + viewEntry = viewEntry, + scope = scope, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + activeTags = activeTags.toSet(), + tagsScrollHorizontally = !isWide, + filterByType = onSelectType, + ) + } } } + + MpScrollBarGrid( + modifier = scrollBarOverlay(), + state = gridState, + ) } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt index 2d3b7ae95..18e95df7c 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt @@ -136,147 +136,158 @@ internal fun ViewEntryUi( } else { Modifier.fillMaxSize().verticalScroll(outerScrollState) } - Column( - modifier = outerModifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - with(sharedTransitionScope) { - Column( - modifier = modifier - .padding( - start = Ui.Padding.M, - end = Ui.Padding.M, - ) - .widthIn(max = Ui.MaxWidth.CatalogueCard) - .fillMaxWidth() - .then(if (editing) Modifier.fillMaxHeight() else Modifier) - .background(MaterialTheme.colorScheme.surface) - .border(width = Dp.Hairline, color = ruleColor, shape = RectangleShape) - .sharedElement( - sharedContentState = rememberSharedContentState( - key = "encyclopedia-card-${state.entryDef.id}", + Box { + Column( + modifier = outerModifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + with(sharedTransitionScope) { + Column( + modifier = modifier + .padding( + start = Ui.Padding.M, + end = Ui.Padding.M, + ) + .widthIn(max = Ui.MaxWidth.CatalogueCard) + .fillMaxWidth() + .then(if (editing) Modifier.fillMaxHeight() else Modifier) + .background(MaterialTheme.colorScheme.surface) + .border(width = Dp.Hairline, color = ruleColor, shape = RectangleShape) + .sharedElement( + sharedContentState = rememberSharedContentState( + key = "encyclopedia-card-${state.entryDef.id}", + ), + animatedVisibilityScope = animatedVisibilityScope, ), - animatedVisibilityScope = animatedVisibilityScope, - ), - ) { - CollapseWhileTyping(enabled = editing) { - Column { - CrumbRow( - title = entryNameText.ifBlank { state.entryDef.name }.uppercase(), - menuSlot = { DetailViewDropdownMenu(menuItems = state.menuItems) }, - onClose = { - val isDirty = content != null && - (entryNameText != content.name || entryText != content.text) - when { - editing && isDirty -> component.confirmClose() - editing -> { - component.finishNameEdit() - component.finishTextEdit() - closeEntry() + ) { + CollapseWhileTyping(enabled = editing) { + Column { + CrumbRow( + title = entryNameText.ifBlank { state.entryDef.name }.uppercase(), + menuSlot = { DetailViewDropdownMenu(menuItems = state.menuItems) }, + onClose = { + val isDirty = content != null && + (entryNameText != content.name || entryText != content.text) + when { + editing && isDirty -> component.confirmClose() + editing -> { + component.finishNameEdit() + component.finishTextEdit() + closeEntry() + } + + else -> closeEntry() } + }, + ) - else -> closeEntry() - } - }, - ) - - HorizontalDivider(thickness = Dp.Hairline, color = ruleColor) - } - } - - StampRow( - entryDef = state.entryDef, - editing = editing, - onEdit = { - component.startNameEdit() - component.startTextEdit() - }, - onSave = saveChanges, - onCancel = { - val isDirty = content != null && - (entryNameText != content.name || entryText != content.text) - if (isDirty) { - discardConfirm = true - } else { - discardChanges() + HorizontalDivider(thickness = Dp.Hairline, color = ruleColor) } - }, - ) + } - HorizontalDivider(thickness = 2.dp, color = ruleStrong) - - // The big name collapses while the body editor has focus; kept visible while the name - // field itself holds focus so it stays reachable. - var nameFocused by remember { mutableStateOf(false) } - CollapseWhileTyping(keepVisible = nameFocused) { - NameZone( - entryNameText = entryNameText, - onNameChange = { entryNameText = it }, - editName = state.editName, - onStartEdit = component::startNameEdit, - onFocusChanged = { nameFocused = it }, - compact = isCompact, - sharedKey = "encyclopedia-title-${state.entryDef.id}", - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, + StampRow( + entryDef = state.entryDef, + editing = editing, + onEdit = { + component.startNameEdit() + component.startTextEdit() + }, + onSave = saveChanges, + onCancel = { + val isDirty = content != null && + (entryNameText != content.name || entryText != content.text) + if (isDirty) { + discardConfirm = true + } else { + discardChanges() + } + }, ) - } - val bodyOuterModifier = if (editing) Modifier.weight(1f) else Modifier - if (isCompact) { - CompactBody( - state = state, - entryText = entryText, - setEntryText = { entryText = it }, - onStartTextEdit = component::startTextEdit, - onShowDeleteImage = component::showDeleteImageDialog, - onAddImage = component::showAddImageDialog, - outerModifier = bodyOuterModifier, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - } else { - WideBody( - state = state, - entryText = entryText, - setEntryText = { entryText = it }, - onStartTextEdit = component::startTextEdit, - onShowDeleteImage = component::showDeleteImageDialog, - onAddImage = component::showAddImageDialog, - outerModifier = bodyOuterModifier, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - } + HorizontalDivider(thickness = 2.dp, color = ruleStrong) - if (!editing) { - if (content != null) { - ParticularsLedger( - state = state, - modifier = Modifier - .padding(horizontal = Ui.Padding.XXL) - .padding(top = 28.dp), + // The big name collapses while the body editor has focus; kept visible while the name + // field itself holds focus so it stays reachable. + var nameFocused by remember { mutableStateOf(false) } + CollapseWhileTyping(keepVisible = nameFocused) { + NameZone( + entryNameText = entryNameText, + onNameChange = { entryNameText = it }, + editName = state.editName, + onStartEdit = component::startNameEdit, + onFocusChanged = { nameFocused = it }, + compact = isCompact, + sharedKey = "encyclopedia-title-${state.entryDef.id}", + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) } - TagsAndAliasesZone( - state = state, - component = component, - compact = isCompact, - ) + val bodyOuterModifier = if (editing) Modifier.weight(1f) else Modifier + if (isCompact) { + CompactBody( + state = state, + entryText = entryText, + setEntryText = { entryText = it }, + onStartTextEdit = component::startTextEdit, + onShowDeleteImage = component::showDeleteImageDialog, + onAddImage = component::showAddImageDialog, + outerModifier = bodyOuterModifier, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + WideBody( + state = state, + entryText = entryText, + setEntryText = { entryText = it }, + onStartTextEdit = component::startTextEdit, + onShowDeleteImage = component::showDeleteImageDialog, + onAddImage = component::showAddImageDialog, + outerModifier = bodyOuterModifier, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } - AppearsInZone( - state = state, - component = component, - ) + if (!editing) { + if (content != null) { + ParticularsLedger( + state = state, + modifier = Modifier + .padding(horizontal = Ui.Padding.XXL) + .padding(top = 28.dp), + ) + } - FooterColophon( - compact = isCompact, - ruleSoft = ruleSoft, - ) + TagsAndAliasesZone( + state = state, + component = component, + compact = isCompact, + ) + + AppearsInZone( + state = state, + component = component, + ) + + FooterColophon( + compact = isCompact, + ruleSoft = ruleSoft, + ) + } } } } + + // Editing swaps in a non-scrolling layout, leaving outerScrollState detached but still + // reporting its last measured extent, so only the screen knows the bar is stale. + if (!editing) { + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = outerScrollState, + ) + } } if (discardConfirm) { diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt index e4f52ac68..5a2d9a09d 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Search @@ -51,6 +52,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.notes.BrowseNotes import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarStaggeredGrid import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdActiveFiltersStrip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCollapsingStrip @@ -66,6 +68,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSortOption import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagFilterBar import com.darkrockstudios.apps.hammer.common.compose.firstNonBlankLine import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent import com.darkrockstudios.apps.hammer.common.util.format import com.darkrockstudios.apps.hammer.notes_create_note_button @@ -326,46 +329,55 @@ fun BrowseNotesUi( ) } - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(400.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - horizontal = Ui.Padding.XL, - vertical = Ui.Padding.L, - ), - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), - ) { - if (visibleNotes.isEmpty()) { - item(span = StaggeredGridItemSpan.FullLine) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = Ui.Padding.XXL), - contentAlignment = Alignment.Center, - ) { - Text( - text = Res.string.notes_list_empty.get(), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val gridState = rememberLazyStaggeredGridState() + Box(modifier = Modifier.weight(1f)) { + LazyVerticalStaggeredGrid( + state = gridState, + columns = StaggeredGridCells.Adaptive(400.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = Ui.Padding.XL, + vertical = Ui.Padding.L, + ), + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), + ) { + if (visibleNotes.isEmpty()) { + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Ui.Padding.XXL), + contentAlignment = Alignment.Center, + ) { + Text( + text = Res.string.notes_list_empty.get(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } + + staggeredItems( + items = visibleNotes, + key = { note -> note.id }, + ) { note -> + NoteCard( + note = note, + activeTags = activeTags, + onTagClick = toggleTag, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier.padding(bottom = Ui.Padding.L), + onClick = { component.viewNote(note.id) }, + ) + } } - staggeredItems( - items = visibleNotes, - key = { note -> note.id }, - ) { note -> - NoteCard( - note = note, - activeTags = activeTags, - onTagClick = toggleTag, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - modifier = Modifier.padding(bottom = Ui.Padding.L), - onClick = { component.viewNote(note.id) }, - ) - } + MpScrollBarStaggeredGrid( + modifier = scrollBarOverlay(), + state = gridState, + ) } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt index 93cfe45f8..14836f3d8 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt @@ -314,52 +314,59 @@ private fun ViewBody( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - Column( - modifier = modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), - ) { - val noteTags = note?.tags - if (!noteTags.isNullOrEmpty()) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = Ui.Padding.L), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - noteTags.sorted().forEach { tag -> - HdTagChip( - label = tag, - active = true, - onClick = { onTagClick(tag) }, - onRemove = { onTagRemove(tag) }, - ) + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), + ) { + val noteTags = note?.tags + if (!noteTags.isNullOrEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Ui.Padding.L), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + noteTags.sorted().forEach { tag -> + HdTagChip( + label = tag, + active = true, + onClick = { onTagClick(tag) }, + onRemove = { onTagRemove(tag) }, + ) + } } + + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = Ui.Padding.L), + ) } - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(bottom = Ui.Padding.L), - ) + with(sharedTransitionScope) { + MarkdownView( + markdown = noteText, + modifier = Modifier + .fillMaxWidth() + .sharedElement( + sharedContentState = rememberSharedContentState( + key = "note-content-${note?.id}", + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + .clickable(onClick = onEnterEdit), + ) + } } - with(sharedTransitionScope) { - MarkdownView( - markdown = noteText, - modifier = Modifier - .fillMaxWidth() - .sharedElement( - sharedContentState = rememberSharedContentState( - key = "note-content-${note?.id}", - ), - animatedVisibilityScope = animatedVisibilityScope, - ) - .clickable(onClick = onEnterEdit), - ) - } + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt index 187b5d14c..7c29d5903 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt @@ -30,12 +30,14 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.projecthome.ProjectSettings import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCrumbBackLink import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineSection import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.projectselection.settings.SpellCheckSettingsContent import com.darkrockstudios.apps.hammer.project_settings_autosaved @@ -75,41 +77,52 @@ fun ProjectSettingsUi( ) HdFolioDivider() - Column( + val scrollState = rememberScrollState() + Box( modifier = Modifier .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = outerHorizontal, vertical = outerVertical), + .fillMaxWidth(), ) { - Box( + Column( modifier = Modifier - .widthIn(max = MaxColumnWidth) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = outerHorizontal, vertical = outerVertical), ) { - Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { - Hero( - projectName = component.projectName, - authorName = state.data.authorName, - isCompact = isCompact, - ) + Box( + modifier = Modifier + .widthIn(max = MaxColumnWidth) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + ) { + Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { + Hero( + projectName = component.projectName, + authorName = state.data.authorName, + isCompact = isCompact, + ) - if (state.isLoaded) { - ProjectInfoSettingsUi(component) + if (state.isLoaded) { + ProjectInfoSettingsUi(component) - HdHairlineSection( - section = 4, - title = Res.string.project_settings_spellcheck_section_title.get(), - contentSpacing = 18.dp, - ) { - SpellCheckSettingsContent(component.spellCheckSettings) + HdHairlineSection( + section = 4, + title = Res.string.project_settings_spellcheck_section_title.get(), + contentSpacing = 18.dp, + ) { + SpellCheckSettingsContent(component.spellCheckSettings) + } } - } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) + } } } + + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } FolioCaption( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectStatsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectStatsUi.kt index c7bd7b1ab..70a68134e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectStatsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectStatsUi.kt @@ -36,8 +36,10 @@ import com.darkrockstudios.apps.hammer.common.components.projecthome.ProjectHome import com.darkrockstudios.apps.hammer.common.components.projecthome.TagBreakdown import com.darkrockstudios.apps.hammer.common.compose.HeaderUi import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.compose.theme.hammerMonoFontFamily import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType @@ -70,52 +72,60 @@ fun ProjectStatsUi( val state by component.state.subscribeAsState() val isWide = LocalScreenCharacteristic.current.isWide - Column( - modifier = modifier - .testTag(PROJECT_STATS_TAG) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - DashboardHeader( - state = state, - component = component, - scope = scope, - ) + val scrollState = rememberScrollState() + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .testTag(PROJECT_STATS_TAG) + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + DashboardHeader( + state = state, + component = component, + scope = scope, + ) - if (state.isLoadingStats) { - LoadingRow() - } + if (state.isLoadingStats) { + LoadingRow() + } - StatsStrip(state = state, isWide = isWide) + StatsStrip(state = state, isWide = isWide) - StructureSection( - state = state, - isWide = isWide, - onShowLongestScene = component::showLongestScene, - onShowLastEditedScene = component::showLastEditedScene, - ) - - if (state.dailyWordTotals.isNotEmpty() || state.encyclopediaEntriesByType.isNotEmpty() || state.topAppearances.isNotEmpty()) { - InhabitantsSection( + StructureSection( state = state, isWide = isWide, - onShowEntry = component::showEntry, + onShowLongestScene = component::showLongestScene, + onShowLastEditedScene = component::showLastEditedScene, ) + + if (state.dailyWordTotals.isNotEmpty() || state.encyclopediaEntriesByType.isNotEmpty() || state.topAppearances.isNotEmpty()) { + InhabitantsSection( + state = state, + isWide = isWide, + onShowEntry = component::showEntry, + ) + } + + if (state.tagBreakdowns.isNotEmpty()) { + ThemesSection( + state = state, + isWide = isWide, + onTagClick = component::showGlobalSearchForTag, + ) + } + + if (state.wordsPerDevice.size >= 2) { + DevicesSection(state = state) + } } - if (state.tagBreakdowns.isNotEmpty()) { - ThemesSection( - state = state, - isWide = isWide, - onTagClick = component::showGlobalSearchForTag, - ) - } - - if (state.wordsPerDevice.size >= 2) { - DevicesSection(state = state) - } + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } ExportOptionsDialog( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt index 87c3b820e..cd9c8a20b 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt @@ -11,9 +11,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -58,6 +58,7 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.Projec import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsList import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.filterProjects import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarGutter import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.Toaster @@ -71,6 +72,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSortOption import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagChip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdToolButton import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.search.parseQuery import com.darkrockstudios.apps.hammer.common.protocolmismatch.ProtocolMismatchDialog import com.darkrockstudios.apps.hammer.common.reauthentication.ReauthenticationUi @@ -254,6 +256,7 @@ fun ProjectListUi( .fillMaxSize() .nestedScroll(scrollConnection), state = listState, + contentPadding = PaddingValues(end = MpScrollBarGutter), ) { if (visibleProjects.isEmpty()) { item(key = "empty") { @@ -281,7 +284,7 @@ fun ProjectListUi( } } MpScrollBarList( - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + modifier = scrollBarOverlay(), state = listState, ) } @@ -507,7 +510,7 @@ private fun ColumnHeader(showLastOpen: Boolean) { .fillMaxWidth() .padding( start = 4.dp + Ui.Padding.XL, - end = Ui.Padding.L, + end = Ui.Padding.L + MpScrollBarGutter, top = Ui.Padding.M, bottom = Ui.Padding.M, ), diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt index b7b3bf37c..817f042b2 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt @@ -20,6 +20,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* import com.darkrockstudios.apps.hammer.common.components.projectselection.aboutapp.AboutApp import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.icons.AboutIcons @@ -27,6 +28,7 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.Discord import com.darkrockstudios.apps.hammer.common.compose.icons.Github import com.darkrockstudios.apps.hammer.common.compose.icons.Reddit import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import korlibs.io.lang.format import org.jetbrains.compose.resources.painterResource @@ -51,76 +53,87 @@ fun AboutAppUi(component: AboutApp, modifier: Modifier = Modifier) { Breadcrumb(isCompact = isCompact) HdFolioDivider() - Column( + val scrollState = rememberScrollState() + Box( modifier = Modifier .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = outerHorizontal, vertical = outerVertical), + .fillMaxWidth(), ) { - Box( + Column( modifier = Modifier - .widthIn(max = MaxColumnWidth) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = outerHorizontal, vertical = outerVertical), ) { - Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { - Hero(isCompact = isCompact) + Box( + modifier = Modifier + .widthIn(max = MaxColumnWidth) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + ) { + Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { + Hero(isCompact = isCompact) - HdHairlineSection( - section = 1, - title = "MANUSCRIPT", - contentSpacing = 12.dp, - ) { - Text( - text = Res.string.about_description_line_two.get(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) + HdHairlineSection( + section = 1, + title = "MANUSCRIPT", + contentSpacing = 12.dp, + ) { + Text( + text = Res.string.about_description_line_two.get(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + HdHairlineSection( + section = 2, + title = Res.string.about_community_header.get(), + contentSpacing = 0.dp, + ) { + CommunityLink( + label = Res.string.about_community_discord_link.get(), + icon = AboutIcons.Discord, + onClick = component::openDiscord, + ) + CommunityRowDivider() + CommunityLink( + label = Res.string.about_community_reddit_link.get(), + icon = AboutIcons.Reddit, + onClick = component::openReddit, + ) + CommunityRowDivider() + CommunityLink( + label = Res.string.about_community_github_link.get(), + icon = AboutIcons.Github, + onClick = component::openGithub, + ) + } + + VersionCard(state, onViewReleaseDetails = component::viewReleaseDetails) + + HdHairlineSection( + section = 4, + title = Res.string.about_attribution_header.get(), + contentSpacing = 14.dp, + ) { + HdHairlineButton( + label = Res.string.about_attribution_libraries_button.get(), + onClick = { showLibraries = true }, + ) + } + + PlatformAboutSection(component, section = 5) + + Spacer(Modifier.height(8.dp)) } - - HdHairlineSection( - section = 2, - title = Res.string.about_community_header.get(), - contentSpacing = 0.dp, - ) { - CommunityLink( - label = Res.string.about_community_discord_link.get(), - icon = AboutIcons.Discord, - onClick = component::openDiscord, - ) - CommunityRowDivider() - CommunityLink( - label = Res.string.about_community_reddit_link.get(), - icon = AboutIcons.Reddit, - onClick = component::openReddit, - ) - CommunityRowDivider() - CommunityLink( - label = Res.string.about_community_github_link.get(), - icon = AboutIcons.Github, - onClick = component::openGithub, - ) - } - - VersionCard(state, onViewReleaseDetails = component::viewReleaseDetails) - - HdHairlineSection( - section = 4, - title = Res.string.about_attribution_header.get(), - contentSpacing = 14.dp, - ) { - HdHairlineButton( - label = Res.string.about_attribution_libraries_button.get(), - onClick = { showLibraries = true }, - ) - } - - PlatformAboutSection(component, section = 5) - - Spacer(Modifier.height(8.dp)) } } + + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } FolioCaption( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt index 2ad4de7fd..ca4721e61 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt @@ -21,11 +21,13 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AccountSettings import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.Toaster import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.InitialProjectScreen @@ -60,104 +62,115 @@ internal fun AccountSettingsUi( Breadcrumb(isCompact = isCompact) HdFolioDivider() - Column( + val scrollState = rememberScrollState() + Box( modifier = Modifier .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = outerHorizontal, vertical = outerVertical), + .fillMaxWidth(), ) { - Box( + Column( modifier = Modifier - .widthIn(max = MaxColumnWidth) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = outerHorizontal, vertical = outerVertical), ) { - Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { - Hero( - loggedIn = state.serverIsLoggedIn, - email = state.currentEmail, - isCompact = isCompact, - ) - - HdHairlineSection( - section = 1, - title = Res.string.settings_theme_label.get(), - headerTrailing = { - HdMonoLabel(text = uiThemeLabel(state.uiTheme)) - }, - contentSpacing = 12.dp, - ) { - HdHairlineSegmentedPicker( - options = UiTheme.entries, - selected = state.uiTheme, - onSelect = { component.setUiTheme(it) }, - label = { uiThemeLabel(it) }, + Box( + modifier = Modifier + .widthIn(max = MaxColumnWidth) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + ) { + Column(verticalArrangement = Arrangement.spacedBy(64.dp)) { + Hero( + loggedIn = state.serverIsLoggedIn, + email = state.currentEmail, + isCompact = isCompact, ) - } - HdHairlineSection( - section = 2, - title = Res.string.settings_initial_screen_label.get(), - headerTrailing = { - HdMonoLabel(text = initialScreenLabel(state.initialProjectScreen)) - }, - contentSpacing = 12.dp, - ) { - HdHairlineSegmentedPicker( - options = InitialProjectScreen.entries, - selected = state.initialProjectScreen, - onSelect = { component.setInitialProjectScreen(it) }, - label = { initialScreenLabel(it) }, - ) - } - - HdHairlineSection( - section = 3, - title = Res.string.settings_spellcheck_heading.get(), - contentSpacing = 18.dp, - ) { - SpellCheckSettingsContent(component.spellCheckSettings) - } - - ServerSettingsUi( - component = component, - scope = scope, - rootSnackbar = rootSnackbar, - ) - - HdHairlineSection( - section = 5, - title = Res.string.settings_backups_header.get(), - headerTrailing = { - HdMonoLabel( - text = "${state.maxBackups} / ${GlobalSettings.MAX_BACKUPS}", + HdHairlineSection( + section = 1, + title = Res.string.settings_theme_label.get(), + headerTrailing = { + HdMonoLabel(text = uiThemeLabel(state.uiTheme)) + }, + contentSpacing = 12.dp, + ) { + HdHairlineSegmentedPicker( + options = UiTheme.entries, + selected = state.uiTheme, + onSelect = { component.setUiTheme(it) }, + label = { uiThemeLabel(it) }, ) - }, - contentSpacing = 16.dp, - ) { - BackupsSettingsUi(component, scope) - } + } - HdHairlineSection( - section = 6, - title = Res.string.settings_platform_settings_title.get(), - contentSpacing = 16.dp, - ) { - PlatformSettingsUi(component.platformSettings) - } + HdHairlineSection( + section = 2, + title = Res.string.settings_initial_screen_label.get(), + headerTrailing = { + HdMonoLabel(text = initialScreenLabel(state.initialProjectScreen)) + }, + contentSpacing = 12.dp, + ) { + HdHairlineSegmentedPicker( + options = InitialProjectScreen.entries, + selected = state.initialProjectScreen, + onSelect = { component.setInitialProjectScreen(it) }, + label = { initialScreenLabel(it) }, + ) + } - HdHairlineSection( - section = 7, - title = Res.string.settings_example_project_header.get(), - contentSpacing = 14.dp, - ) { - ExampleProjectSection(component, rootSnackbar) - } + HdHairlineSection( + section = 3, + title = Res.string.settings_spellcheck_heading.get(), + contentSpacing = 18.dp, + ) { + SpellCheckSettingsContent(component.spellCheckSettings) + } - Spacer(Modifier.height(8.dp)) + ServerSettingsUi( + component = component, + scope = scope, + rootSnackbar = rootSnackbar, + ) + + HdHairlineSection( + section = 5, + title = Res.string.settings_backups_header.get(), + headerTrailing = { + HdMonoLabel( + text = "${state.maxBackups} / ${GlobalSettings.MAX_BACKUPS}", + ) + }, + contentSpacing = 16.dp, + ) { + BackupsSettingsUi(component, scope) + } + + HdHairlineSection( + section = 6, + title = Res.string.settings_platform_settings_title.get(), + contentSpacing = 16.dp, + ) { + PlatformSettingsUi(component.platformSettings) + } + + HdHairlineSection( + section = 7, + title = Res.string.settings_example_project_header.get(), + contentSpacing = 14.dp, + ) { + ExampleProjectSection(component, rootSnackbar) + } + + Spacer(Modifier.height(8.dp)) + } } } + + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } FolioCaption( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt index 34319a280..cd17cc595 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search @@ -75,6 +76,8 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.storyi import com.darkrockstudios.apps.hammer.common.compose.CollapseWhileTyping import com.darkrockstudios.apps.hammer.common.compose.DetailViewDropdownMenu import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarStaggeredGrid import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.SimpleConfirm import com.darkrockstudios.apps.hammer.common.compose.Ui @@ -102,6 +105,7 @@ import com.darkrockstudios.apps.hammer.common.compose.rememberMainDispatcher import com.darkrockstudios.apps.hammer.common.compose.rememberStrRes import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.saveShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.MenuItemDescriptor import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeaError import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeasRepository @@ -450,47 +454,56 @@ private fun IdeasBrowse( ) } - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(400.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - horizontal = Ui.Padding.XL, - vertical = Ui.Padding.L, - ), - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), - ) { - if (visibleIdeas.isEmpty()) { - item(span = StaggeredGridItemSpan.FullLine) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = Ui.Padding.XXL), - contentAlignment = Alignment.Center, - ) { - Text( - text = (if (showArchived) Res.string.ideas_list_empty_archived - else Res.string.ideas_list_empty).get(), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val gridState = rememberLazyStaggeredGridState() + Box(modifier = Modifier.weight(1f)) { + LazyVerticalStaggeredGrid( + state = gridState, + columns = StaggeredGridCells.Adaptive(400.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = Ui.Padding.XL, + vertical = Ui.Padding.L, + ), + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), + ) { + if (visibleIdeas.isEmpty()) { + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Ui.Padding.XXL), + contentAlignment = Alignment.Center, + ) { + Text( + text = (if (showArchived) Res.string.ideas_list_empty_archived + else Res.string.ideas_list_empty).get(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } + + staggeredItems( + items = visibleIdeas, + key = { idea -> idea.id.id }, + ) { idea -> + IdeaCard( + idea = idea, + activeTags = activeTags, + onTagClick = toggleTag, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier.padding(bottom = Ui.Padding.L), + onClick = { component.editIdea(idea.id) }, + ) + } } - staggeredItems( - items = visibleIdeas, - key = { idea -> idea.id.id }, - ) { idea -> - IdeaCard( - idea = idea, - activeTags = activeTags, - onTagClick = toggleTag, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - modifier = Modifier.padding(bottom = Ui.Padding.L), - onClick = { component.editIdea(idea.id) }, - ) - } + MpScrollBarStaggeredGrid( + modifier = scrollBarOverlay(), + state = gridState, + ) } } @@ -1073,77 +1086,84 @@ private fun ViewBody( modifier: Modifier = Modifier, ) = with(sharedTransitionScope) { val scrollState = rememberScrollState() - Column( - modifier = modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(bottom = Ui.Padding.M) + .then( + if (idea != null) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = "idea-title-${idea.id.id}", + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + Modifier + } + ), + ) + } + + idea?.let { IdeaStamps(it) } + + if (tags.isNotEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = Ui.Padding.M, bottom = Ui.Padding.L), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + tags.sorted().forEach { tag -> + HdTagChip( + label = tag, + active = true, + onClick = {}, + ) + } + } + + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = Ui.Padding.L), + ) + } + + MarkdownView( + markdown = markdown, modifier = Modifier - .padding(bottom = Ui.Padding.M) + .fillMaxWidth() .then( if (idea != null) { Modifier.sharedElement( sharedContentState = rememberSharedContentState( - key = "idea-title-${idea.id.id}", + key = "idea-content-${idea.id.id}", ), animatedVisibilityScope = animatedVisibilityScope, ) } else { Modifier } - ), - ) - } - - idea?.let { IdeaStamps(it) } - - if (tags.isNotEmpty()) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = Ui.Padding.M, bottom = Ui.Padding.L), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - tags.sorted().forEach { tag -> - HdTagChip( - label = tag, - active = true, - onClick = {}, ) - } - } - - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(bottom = Ui.Padding.L), + .clickable(onClick = onEnterEdit), ) } - MarkdownView( - markdown = markdown, - modifier = Modifier - .fillMaxWidth() - .then( - if (idea != null) { - Modifier.sharedElement( - sharedContentState = rememberSharedContentState( - key = "idea-content-${idea.id.id}", - ), - animatedVisibilityScope = animatedVisibilityScope, - ) - } else { - Modifier - } - ) - .clickable(onClick = onEnterEdit), + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, ) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt index 312530b29..f326b8a67 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt @@ -32,11 +32,14 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* import com.darkrockstudios.apps.hammer.common.components.storyeditor.outlineoverview.OutlineOverview import com.darkrockstudios.apps.hammer.common.compose.AnimatedFullScreenDialog +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarGutter +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.designsystem.romanNumeral import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.SceneItem import kotlinx.coroutines.launch @@ -324,15 +327,27 @@ private fun ChapterRail( bottom = Ui.Padding.M, ), ) - LazyColumn(modifier = Modifier.fillMaxSize()) { - itemsIndexed(chapters, key = { idx, _ -> "rail-$idx" }) { index, ch -> - ChapterRailItem( - index = index, - chapter = ch, - selected = index == activeChapterIndex, - onClick = { onJumpTo(index) }, - ) + val railState = rememberLazyListState() + Box { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = railState, + contentPadding = PaddingValues(end = MpScrollBarGutter), + ) { + itemsIndexed(chapters, key = { idx, _ -> "rail-$idx" }) { index, ch -> + ChapterRailItem( + index = index, + chapter = ch, + selected = index == activeChapterIndex, + onClick = { onJumpTo(index) }, + ) + } } + + MpScrollBarList( + modifier = scrollBarOverlay(), + state = railState, + ) } } } @@ -519,31 +534,38 @@ private fun ReadingColumn( modifier: Modifier = Modifier, ) { val horizontalPad = if (wide) Ui.Padding.XXL else Ui.Padding.XL - LazyColumn( - modifier = modifier, - state = listState, - contentPadding = PaddingValues( - start = horizontalPad, - end = horizontalPad, - top = Ui.Padding.XXL, - bottom = Ui.Padding.XXL, - ), - ) { - item(key = "frontispiece") { - Frontispiece(projectName = projectName) - } - itemsIndexed(chapters, key = { idx, _ -> "ch-$idx" }) { index, ch -> - ChapterBlock( - chapterIndex = index, - chapter = ch, - showSeparator = index < chapters.size - 1, - wide = wide, - onSceneClick = onSceneClick, - ) - } - item(key = "end") { - EndOfOutline() + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + start = horizontalPad, + end = horizontalPad, + top = Ui.Padding.XXL, + bottom = Ui.Padding.XXL, + ), + ) { + item(key = "frontispiece") { + Frontispiece(projectName = projectName) + } + itemsIndexed(chapters, key = { idx, _ -> "ch-$idx" }) { index, ch -> + ChapterBlock( + chapterIndex = index, + chapter = ch, + showSeparator = index < chapters.size - 1, + wide = wide, + onSceneClick = onSceneClick, + ) + } + item(key = "end") { + EndOfOutline() + } } + + MpScrollBarList( + modifier = scrollBarOverlay(), + state = listState, + ) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt index e8a9d1a3d..8c553a288 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt @@ -72,191 +72,199 @@ fun SceneMetadataPanelUi( color = MaterialTheme.colorScheme.surfaceContainerLow, tonalElevation = 0.dp, ) { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + val scrollState = rememberScrollState() + Box { + Column(modifier = Modifier.verticalScroll(scrollState)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = Ui.Padding.M, end = Ui.Padding.L, top = Ui.Padding.S, bottom = Ui.Padding.S), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = closeMetadata, - modifier = Modifier.size(36.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = Ui.Padding.M, end = Ui.Padding.L, top = Ui.Padding.S, bottom = Ui.Padding.S), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = Res.string.scene_editor_metadata_hide_button.get(), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(Modifier.width(Ui.Padding.S)) - Text( - text = Res.string.scene_editor_metadata_title.get(), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.L), - verticalAlignment = Alignment.Bottom, - ) { - Column(modifier = Modifier.weight(1f).padding(end = Ui.Padding.M)) { + IconButton( + onClick = closeMetadata, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = Res.string.scene_editor_metadata_hide_button.get(), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer(Modifier.width(Ui.Padding.S)) Text( - text = state.sceneItem.name, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - HdEntityId( - prefix = "SCN", - id = state.sceneItem.id, - modifier = Modifier.padding(top = 2.dp), - ) - } - Row(verticalAlignment = Alignment.Bottom) { - HdMonoLabel( - text = Res.string.scene_editor_metadata_word_count_label.get().removeSuffix(":"), - modifier = Modifier.padding(end = 6.dp, bottom = 2.dp), - ) - Text( - text = "${state.wordCount}", + text = Res.string.scene_editor_metadata_title.get(), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } - } - TimestampStrip( - created = state.metadata.created, - edited = state.metadata.lastEdited, - ) - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - ) - - Column(modifier = Modifier.padding(Ui.Padding.XL)) { - - HdHairlineField( - label = Res.string.scene_editor_metadata_outline_label.get(), - value = state.metadata.outline, - onValueChange = component::updateOutline, - placeholder = Res.string.scene_editor_metadata_outline_placeholder.get(), - singleLine = false, - minLines = 3, + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, ) - SpacerXL() - - HdHairlineField( - label = Res.string.scene_editor_metadata_notes_label.get(), - value = state.metadata.notes, - onValueChange = component::updateNotes, - placeholder = Res.string.scene_editor_metadata_notes_placeholder.get(), - singleLine = false, - minLines = 3, - ) - - SpacerXL() - - var isDraftNameValid by remember(state.metadata.currentDraftName) { - mutableStateOf(component.validateDraftName(state.metadata.currentDraftName)) - } - HdHairlineField( - label = Res.string.scene_editor_metadata_draft_name_label.get(), - value = state.metadata.currentDraftName, - onValueChange = { newName -> - isDraftNameValid = component.validateDraftName(newName) - component.updateDraftName(newName) - }, - singleLine = true, - ) - if (!isDraftNameValid) { - SpacerM() - Text( - Res.string.scene_draft_invalid_name.get(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - SpacerXL() - - CollapsableSection( - startExpanded = true, - header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.L), + verticalAlignment = Alignment.Bottom, + ) { + Column(modifier = Modifier.weight(1f).padding(end = Ui.Padding.M)) { + Text( + text = state.sceneItem.name, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + HdEntityId( + prefix = "SCN", + id = state.sceneItem.id, + modifier = Modifier.padding(top = 2.dp), + ) + } + Row(verticalAlignment = Alignment.Bottom) { HdMonoLabel( - text = Res.string.scene_editor_metadata_references_header.get(), - modifier = Modifier.padding(end = Ui.Padding.M), + text = Res.string.scene_editor_metadata_word_count_label.get().removeSuffix(":"), + modifier = Modifier.padding(end = 6.dp, bottom = 2.dp), ) - }, - trailingAction = { - HdHairlineButton( - label = Res.string.scene_editor_metadata_references_add_button.get(), - onClick = { showAddDialog = true }, + Text( + text = "${state.wordCount}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, ) - }, - body = { ReferencesBody(state, component) } + } + } + TimestampStrip( + created = state.metadata.created, + edited = state.metadata.lastEdited, + ) + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, ) - if (state.dismissedRefs.isNotEmpty()) { + Column(modifier = Modifier.padding(Ui.Padding.XL)) { + + HdHairlineField( + label = Res.string.scene_editor_metadata_outline_label.get(), + value = state.metadata.outline, + onValueChange = component::updateOutline, + placeholder = Res.string.scene_editor_metadata_outline_placeholder.get(), + singleLine = false, + minLines = 3, + ) + SpacerXL() - CollapsableSection( - startExpanded = false, - header = { - Row(verticalAlignment = Alignment.CenterVertically) { - HdMonoLabel( - text = Res.string.scene_editor_metadata_references_dismissed_label.get(), - ) - Text( - text = " · ${state.dismissedRefs.size}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 4.dp, end = Ui.Padding.M), - ) - } + + HdHairlineField( + label = Res.string.scene_editor_metadata_notes_label.get(), + value = state.metadata.notes, + onValueChange = component::updateNotes, + placeholder = Res.string.scene_editor_metadata_notes_placeholder.get(), + singleLine = false, + minLines = 3, + ) + + SpacerXL() + + var isDraftNameValid by remember(state.metadata.currentDraftName) { + mutableStateOf(component.validateDraftName(state.metadata.currentDraftName)) + } + HdHairlineField( + label = Res.string.scene_editor_metadata_draft_name_label.get(), + value = state.metadata.currentDraftName, + onValueChange = { newName -> + isDraftNameValid = component.validateDraftName(newName) + component.updateDraftName(newName) }, - body = { DismissedBody(state, component) } + singleLine = true, + ) + if (!isDraftNameValid) { + SpacerM() + Text( + Res.string.scene_draft_invalid_name.get(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + SpacerXL() + + CollapsableSection( + startExpanded = true, + header = { + HdMonoLabel( + text = Res.string.scene_editor_metadata_references_header.get(), + modifier = Modifier.padding(end = Ui.Padding.M), + ) + }, + trailingAction = { + HdHairlineButton( + label = Res.string.scene_editor_metadata_references_add_button.get(), + onClick = { showAddDialog = true }, + ) + }, + body = { ReferencesBody(state, component) } + ) + + if (state.dismissedRefs.isNotEmpty()) { + SpacerXL() + CollapsableSection( + startExpanded = false, + header = { + Row(verticalAlignment = Alignment.CenterVertically) { + HdMonoLabel( + text = Res.string.scene_editor_metadata_references_dismissed_label.get(), + ) + Text( + text = " · ${state.dismissedRefs.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, end = Ui.Padding.M), + ) + } + }, + body = { DismissedBody(state, component) } + ) + } + + SpacerXL() + + CollapsableSection( + startExpanded = true, + header = { + HdMonoLabel( + text = Res.string.scene_editor_metadata_tags_header.get(), + modifier = Modifier.padding(end = Ui.Padding.M), + ) + }, + trailingAction = { + HdHairlineButton( + label = Res.string.scene_editor_metadata_tags_add_button.get(), + onClick = { showAddTagDialog = true }, + ) + }, + body = { TagsBody(state, component) } + ) + + SpacerXL() + + CollapsableSection( + header = { + HdMonoLabel( + text = Res.string.scene_editor_metadata_advanced_header.get(), + modifier = Modifier.padding(end = Ui.Padding.M), + ) + }, + body = { AdvancedBody(state) } ) } - - SpacerXL() - - CollapsableSection( - startExpanded = true, - header = { - HdMonoLabel( - text = Res.string.scene_editor_metadata_tags_header.get(), - modifier = Modifier.padding(end = Ui.Padding.M), - ) - }, - trailingAction = { - HdHairlineButton( - label = Res.string.scene_editor_metadata_tags_add_button.get(), - onClick = { showAddTagDialog = true }, - ) - }, - body = { TagsBody(state, component) } - ) - - SpacerXL() - - CollapsableSection( - header = { - HdMonoLabel( - text = Res.string.scene_editor_metadata_advanced_header.get(), - modifier = Modifier.padding(end = Ui.Padding.M), - ) - }, - body = { AdvancedBody(state) } - ) } + + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt index 860f87794..483f674ca 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api @@ -67,6 +68,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.timeline.TimeLineOverview import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdDragHandle import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider @@ -78,6 +80,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagChip import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownView import com.darkrockstudios.apps.hammer.common.compose.reorderable.DragDropList import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.data.tagindex.TagCount import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent @@ -340,18 +343,27 @@ fun TimeLineOverviewUi( } else -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .testTag(TIME_LINE_LIST_TAG), - contentPadding = listContentPadding, - ) { - items( - items = visibleEvents, - key = { event -> event.id }, - ) { event -> - eventRow(event, false) + val listState = rememberLazyListState() + Box { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag(TIME_LINE_LIST_TAG), + contentPadding = listContentPadding, + ) { + items( + items = visibleEvents, + key = { event -> event.id }, + ) { event -> + eventRow(event, false) + } } + + MpScrollBarList( + modifier = scrollBarOverlay(), + state = listState, + ) } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt index 9e0918e78..3098eb42e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt @@ -321,52 +321,59 @@ private fun ViewBody( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - Column( - modifier = modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), - ) { - val eventTags = event?.tags - if (!eventTags.isNullOrEmpty()) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = Ui.Padding.L), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - eventTags.sorted().forEach { tag -> - HdTagChip( - label = tag, - active = true, - onClick = { onTagClick(tag) }, - onRemove = { onTagRemove(tag) }, - ) + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.XL), + ) { + val eventTags = event?.tags + if (!eventTags.isNullOrEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Ui.Padding.L), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + eventTags.sorted().forEach { tag -> + HdTagChip( + label = tag, + active = true, + onClick = { onTagClick(tag) }, + onRemove = { onTagRemove(tag) }, + ) + } } + + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = Ui.Padding.L), + ) } - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(bottom = Ui.Padding.L), - ) + with(sharedTransitionScope) { + MarkdownView( + markdown = eventText, + modifier = Modifier + .fillMaxWidth() + .sharedElement( + sharedContentState = rememberSharedContentState( + key = "timeline-content-${event?.id}", + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + .clickable(onClick = onEnterEdit), + ) + } } - with(sharedTransitionScope) { - MarkdownView( - markdown = eventText, - modifier = Modifier - .fillMaxWidth() - .sharedElement( - sharedContentState = rememberSharedContentState( - key = "timeline-content-${event?.id}", - ), - animatedVisibilityScope = animatedVisibilityScope, - ) - .clickable(onClick = onEnterEdit), - ) - } + MpScrollBarColumn( + modifier = scrollBarOverlay(), + state = scrollState, + ) } } diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt index c396b5728..d90fb168c 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt @@ -1,18 +1,180 @@ package com.darkrockstudios.apps.hammer.common.compose +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.v2.ScrollbarAdapter +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val RailWidth = 10.dp + +/** Strip reserved for the edge rule; the thumb covers the rest of the rail exactly. */ +private val RuleInset = 1.dp +private val ThumbThickness = RailWidth - RuleInset +private val HatchSpacing = 5.dp + +actual val MpScrollBarGutter: Dp = RailWidth @Composable actual fun MpScrollBarList( modifier: Modifier, state: LazyListState ) { - VerticalScrollbar( + ScrollbarRail(modifier, state.isScrollable, rememberScrollbarAdapter(state)) +} + +@Composable +actual fun MpScrollBarColumn( + modifier: Modifier, + state: ScrollState +) { + // A ScrollState that was never attached to a scrolling container reports maxValue as + // Int.MAX_VALUE, which would otherwise read as "scrollable" and draw a rail over nothing. + val scrollable = state.maxValue != Int.MAX_VALUE && state.isScrollable + ScrollbarRail(modifier, scrollable, rememberScrollbarAdapter(state)) +} + +@Composable +actual fun MpScrollBarGrid( + modifier: Modifier, + state: LazyGridState +) { + ScrollbarRail(modifier, state.isScrollable, rememberScrollbarAdapter(state)) +} + +@Composable +actual fun MpScrollBarStaggeredGrid( + modifier: Modifier, + state: LazyStaggeredGridState +) { + ScrollbarRail( modifier = modifier, - adapter = rememberScrollbarAdapter(state) + scrollable = state.isScrollable, + adapter = remember(state) { StaggeredGridScrollbarAdapter(state) }, ) } + +/** + * Gutter the scrollbar lives in: a vertical `outlineVariant` hairline marking the scrollable edge, + * with a hatched track inside it that the thumb masks as it travels. Both are drawn only while + * there is somewhere to scroll, so screens that fit get no chrome at all. + * + * ``` + * │╱╱╱│ + * │███│ <- thumb, masking the hatch + * │╱╱╱│ + * ^ edge rule + * ``` + */ +@Composable +private fun ScrollbarRail( + modifier: Modifier, + scrollable: Boolean, + adapter: ScrollbarAdapter, +) { + val colorScheme = MaterialTheme.colorScheme + val ruleColor = colorScheme.outlineVariant + val hatchColor = ruleColor.copy(alpha = 0.55f) + val style = remember(colorScheme) { + ScrollbarStyle( + minimalHeight = 24.dp, + thickness = ThumbThickness, + shape = RectangleShape, + hoverDurationMillis = 220, + unhoverColor = colorScheme.outlineVariant, + hoverColor = colorScheme.outline, + ) + } + Box( + modifier = modifier + .width(RailWidth) + .drawWithCache { + val stroke = Stroke(width = Dp.Hairline.toPx().coerceAtLeast(1f)) + val rule = Path().apply { + val x = stroke.width / 2f + moveTo(x, 0f) + lineTo(x, size.height) + } + val hatch = hatchPath( + inset = RuleInset.toPx(), + spacing = HatchSpacing.toPx(), + width = size.width, + height = size.height, + ) + onDrawBehind { + if (!scrollable) return@onDrawBehind + drawPath(rule, ruleColor, style = stroke) + drawPath(hatch, hatchColor, style = stroke) + } + }, + contentAlignment = Alignment.CenterEnd, + ) { + VerticalScrollbar( + adapter = adapter, + modifier = Modifier.fillMaxHeight(), + style = style, + ) + } +} + +/** + * 45 degree hatching for the track, built once per size rather than a line per draw pass. Segments + * are clamped to the track rather than clipped, so no off-screen geometry is submitted. + */ +private fun hatchPath(inset: Float, spacing: Float, width: Float, height: Float): Path { + val path = Path() + if (spacing <= 0f || width <= inset || height <= 0f) return path + // Each line runs bottom-left to top-right: y = height - (x - origin). + var origin = inset - height + while (origin < width) { + val x1 = maxOf(inset, origin) + val x2 = minOf(width, origin + height) + if (x2 > x1) { + path.moveTo(x1, height - (x1 - origin)) + path.lineTo(x2, height - (x2 - origin)) + } + origin += spacing + } + return path +} + +/** + * Compose ships no scrollbar adapter for staggered grids, but the state exposes the same metrics + * the built-in adapters are built from. Dragging scrolls by pixel delta rather than snapping. + */ +private class StaggeredGridScrollbarAdapter( + private val state: LazyStaggeredGridState, +) : ScrollbarAdapter { + + override val viewportSize: Double + get() = state.scrollIndicatorState?.viewportSize?.toDouble() ?: 0.0 + + // Matching the viewport means "nothing to scroll", which is the right reading before measurement. + override val contentSize: Double + get() = state.scrollIndicatorState?.contentSize?.toDouble() ?: viewportSize + + override val scrollOffset: Double + get() = state.scrollIndicatorState?.scrollOffset?.toDouble() ?: 0.0 + + override suspend fun scrollTo(scrollOffset: Double) { + state.scrollBy((scrollOffset - this.scrollOffset).toFloat()) + } +} diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt index a06aeb804..726847387 100644 --- a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MpScrollBar.kt @@ -1,8 +1,15 @@ package com.darkrockstudios.apps.hammer.common.compose +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +actual val MpScrollBarGutter: Dp = 0.dp @Composable actual fun MpScrollBarList( @@ -11,3 +18,27 @@ actual fun MpScrollBarList( ) { // iOS shows inline scroll indicators natively; no explicit scrollbar composable needed. } + +@Composable +actual fun MpScrollBarColumn( + modifier: Modifier, + state: ScrollState +) { + // iOS shows inline scroll indicators natively; no explicit scrollbar composable needed. +} + +@Composable +actual fun MpScrollBarGrid( + modifier: Modifier, + state: LazyGridState +) { + // iOS shows inline scroll indicators natively; no explicit scrollbar composable needed. +} + +@Composable +actual fun MpScrollBarStaggeredGrid( + modifier: Modifier, + state: LazyStaggeredGridState +) { + // iOS shows inline scroll indicators natively; no explicit scrollbar composable needed. +} From 210696f66ad9ea5d61bc0064a8e918d99bb4bdac Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 16:33:35 -0700 Subject: [PATCH 016/213] Send htmx callers somewhere when access is denied (#823) Both access-control plugins built their htmx denial with createHTML().div, whose result was discarded, so the response was an empty and the caller saw nothing happen. Redirect by header instead of trying to deliver a message in the body. htmx acts on HX-Redirect before it decides what to do with the body, which matters here: it discards the body of a 4xx outright, and StatusPages answers a 401 with the whole unauthorized page, which must not end up swapped into a fragment. Browser requests keep the redirect they already had, so both kinds of caller now land in the same place. --- .../hammer/frontend/utils/AdminSecurity.kt | 42 +++++---- .../apps/hammer/frontend/AdminSecurityTest.kt | 94 +++++++++++++++++++ 2 files changed, 117 insertions(+), 19 deletions(-) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/AdminSecurity.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/AdminSecurity.kt index e58fec826..2ced993ab 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/AdminSecurity.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/AdminSecurity.kt @@ -3,16 +3,17 @@ package com.darkrockstudios.apps.hammer.frontend.utils import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.frontend.data.UserSession import io.ktor.htmx.HxRequestHeaders +import io.ktor.htmx.HxResponseHeaders import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.auth.authenticate -import io.ktor.server.html.respondHtml +import io.ktor.server.response.header +import io.ktor.server.response.respond import io.ktor.server.response.respondRedirect import io.ktor.server.routing.Route import io.ktor.server.sessions.get import io.ktor.server.sessions.sessions -import kotlinx.html.div -import kotlinx.html.stream.createHTML import org.koin.ktor.ext.get val AdminOnlyPlugin = createRouteScopedPlugin( @@ -23,14 +24,7 @@ val AdminOnlyPlugin = createRouteScopedPlugin( val session = call.sessions.get() if (session == null || !accountsRepository.isAdmin(session.userId)) { - if (call.request.headers[HxRequestHeaders.Request] == "true") { - val message = call.msg("security_access_denied") - call.respondHtml(HttpStatusCode.Forbidden) { - createHTML().div { +message } - } - } else { - call.respondRedirect("/unauthorized") - } + call.denyAccess(HttpStatusCode.Forbidden, "/unauthorized") return@onCall } } @@ -50,14 +44,7 @@ val AuthenticatedOnlyPlugin = createRouteScopedPlugin( val session = call.sessions.get() if (session == null) { - if (call.request.headers[HxRequestHeaders.Request] == "true") { - val message = call.msg("security_please_login") - call.respondHtml(HttpStatusCode.Unauthorized) { - createHTML().div { +message } - } - } else { - call.respondRedirect("/login") - } + call.denyAccess(HttpStatusCode.Unauthorized, "/login") return@onCall } } @@ -69,3 +56,20 @@ fun Route.authenticatedOnly(build: Route.() -> Unit): Route { apply(build) } } + +/** + * Turns a caller away to [destination], keeping [status] honest for the access log. + * + * An htmx request is redirected by header rather than by body: htmx acts on HX-Redirect before it + * decides what to do with the response body, so this still works where a body cannot be relied on. + * A body would be the wrong tool anyway — htmx discards the body of a 4xx, and StatusPages answers + * a 401 with the whole unauthorized page, which has no business being swapped into a fragment. + */ +private suspend fun ApplicationCall.denyAccess(status: HttpStatusCode, destination: String) { + if (request.headers[HxRequestHeaders.Request] == "true") { + response.header(HxResponseHeaders.Redirect, destination) + respond(status) + } else { + respondRedirect(destination) + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt index 5bdc4f33e..27e3c52f1 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/AdminSecurityTest.kt @@ -5,16 +5,22 @@ import com.darkrockstudios.apps.hammer.account.AccountsRepository import com.darkrockstudios.apps.hammer.admin.WhiteListRepository import com.darkrockstudios.apps.hammer.frontend.data.UserSession import com.darkrockstudios.apps.hammer.frontend.utils.adminOnly +import com.darkrockstudios.apps.hammer.frontend.utils.authenticatedOnly import com.darkrockstudios.apps.hammer.utils.BaseTest import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin import io.ktor.client.request.cookie import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.statement.bodyAsText +import io.ktor.htmx.HxRequestHeaders +import io.ktor.htmx.HxResponseHeaders +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.parseServerSetCookieHeader import io.ktor.server.application.install import io.ktor.server.auth.Authentication +import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.post @@ -29,6 +35,7 @@ import io.mockk.coEvery import io.mockk.mockk import org.junit.jupiter.api.Test import org.koin.dsl.module +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.time.Clock @@ -102,6 +109,93 @@ class AdminSecurityTest : BaseTest() { assertEquals("/unauthorized", response.headers["Location"]) } + @Test + fun `an htmx request from a non-admin is redirected like any other`() = testApplication { + coEvery { accountRepo.getAccount(7) } returns account(admin = false) + coEvery { whitelistRepo.useWhiteList() } returns false + coEvery { accountRepo.isAdmin(7) } returns false + + configureApp() + val noRedirect = createClient { followRedirects = false } + val (name, value) = adminClaimingCookie() + + val response = noRedirect.get("/admin-area") { + cookie(name, value) + header(HxRequestHeaders.Request, "true") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertEquals("/unauthorized", response.headers[HxResponseHeaders.Redirect]) + } + + /** + * StatusPages answers a 401 with the whole unauthorized page, replacing whatever the plugin + * wrote. Redirecting by header rather than by body is what makes the denial survive that, so + * this pins both halves: the page comes back, and the redirect still reaches htmx. + */ + @Test + fun `an htmx request with no session is redirected even though StatusPages rewrites the body`() = + testApplication { + application { + setupKtorTestKoin( + this@AdminSecurityTest, + module { + single { accountRepo } + single { whitelistRepo } + } + ) + install(Sessions) { cookie(COOKIE_USER_SESSION) } + install(Authentication) { frontendAuthentication(accountRepo, whitelistRepo) } + install(StatusPages) { + status(HttpStatusCode.Unauthorized) { call, _ -> + call.respondText( + "FULL-UNAUTHORIZED-PAGE", + ContentType.Text.Html, + HttpStatusCode.Unauthorized + ) + } + } + routing { + authenticatedOnly { + get("/private") { call.respondText("private ok") } + } + } + } + + val noRedirect = createClient { followRedirects = false } + val response = noRedirect.get("/private") { header(HxRequestHeaders.Request, "true") } + + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals("/login", response.headers[HxResponseHeaders.Redirect]) + assertContains(response.bodyAsText(), "FULL-UNAUTHORIZED-PAGE") + } + + @Test + fun `a browser request with no session is redirected to login`() = testApplication { + application { + setupKtorTestKoin( + this@AdminSecurityTest, + module { + single { accountRepo } + single { whitelistRepo } + } + ) + install(Sessions) { cookie(COOKIE_USER_SESSION) } + install(Authentication) { frontendAuthentication(accountRepo, whitelistRepo) } + routing { + authenticatedOnly { + get("/private") { call.respondText("private ok") } + } + } + } + + val noRedirect = createClient { followRedirects = false } + val response = noRedirect.get("/private") + + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/login", response.headers["Location"]) + } + @Test fun `admin per the database reaches the admin route`() = testApplication { coEvery { accountRepo.getAccount(7) } returns account(admin = true) From 804d9cb75cb1c0ddebd3e9db8fdac1e01eeddcf9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 17:01:18 -0700 Subject: [PATCH 017/213] Harden the remaining E2E workflow clicks against dropped taps (#822) Reuse clickUntil for the navigational and menu clicks in the timeline, scene-list, scene-editor and project-lifecycle tests. Also waits for the add-menu to open before picking an item, which previously ran with no wait at all. Mutating confirms are left as single clicks: those buttons stay on screen across the async create, so a retry would persist a second item. * Make clickUntil prove the target existed before reporting success clickUntil skips the click while its matcher resolves to nothing, so a landed condition that is already true returned success having clicked nothing, and a target that never rendered failed as "click never took effect". Track whether a click was injected: never return without one, and report a target that never appeared as its own failure. Drops the per-site pre-waits that duplicated this. --- .../androidTest/kotlin/EditorTestHarness.kt | 18 ++++++++++++++++++ .../androidTest/kotlin/ProjectLifecycleTest.kt | 9 ++++----- .../kotlin/SceneEditorWorkflowTest.kt | 13 +++++-------- .../kotlin/SceneListWorkflowTest.kt | 10 ++++------ .../androidTest/kotlin/TimelineWorkflowTest.kt | 14 ++++---------- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/android/src/androidTest/kotlin/EditorTestHarness.kt b/android/src/androidTest/kotlin/EditorTestHarness.kt index eb0dfbd55..2ee48a5b3 100644 --- a/android/src/androidTest/kotlin/EditorTestHarness.kt +++ b/android/src/androidTest/kotlin/EditorTestHarness.kt @@ -104,6 +104,9 @@ fun ComposeTestRule.navigateTo(navTag: String) { * A single injected tap is occasionally dropped before `clickable` resolves it into an onClick, * which leaves the screen unchanged and reports no error. Re-inject until the expected outcome * appears rather than trusting one injection. + * + * Fails if [matcher] never resolves, and never reports success without having clicked: a [landed] + * that is already true on entry would otherwise pass the step without touching the target. */ fun ComposeTestRule.clickUntil( matcher: SemanticsMatcher, @@ -111,12 +114,25 @@ fun ComposeTestRule.clickUntil( landed: () -> Boolean, ) { val deadline = SystemClock.uptimeMillis() + timeoutMillis + var clicked = false while (true) { // Only re-click while the target is still on screen; once it's gone the click did land and // the screen is mid-change, so clicking again would hit whatever replaced it. if (onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()) { onAllNodes(matcher).onFirst().performClick() + clicked = true } + + if (!clicked) { + if (SystemClock.uptimeMillis() >= deadline) { + throw AssertionError( + "'${matcher.description}' never appeared within ${timeoutMillis}ms, so it was never clicked", + ) + } + SystemClock.sleep(CLICK_RETRY_INTERVAL_MS) + continue + } + try { waitUntil(timeoutMillis = 1_000L) { landed() } return @@ -131,6 +147,8 @@ fun ComposeTestRule.clickUntil( } } +private const val CLICK_RETRY_INTERVAL_MS = 100L + /** * Type into the tagged markdown/scene editor and wait for the edit to land. * diff --git a/android/src/androidTest/kotlin/ProjectLifecycleTest.kt b/android/src/androidTest/kotlin/ProjectLifecycleTest.kt index 43f4e4da0..ba99751ff 100644 --- a/android/src/androidTest/kotlin/ProjectLifecycleTest.kt +++ b/android/src/androidTest/kotlin/ProjectLifecycleTest.kt @@ -3,10 +3,10 @@ import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput @@ -44,12 +44,11 @@ class ProjectLifecycleTest { @Test fun createProjectThenOpenIt() { // 1. Open the create dialog (masthead button when wide, bottom bar when narrow). - composeRule.onNodeWithTag(CreateProjectButtonTestTag).performClick() - - // 2. Type the project name into the dialog's only editable field, then confirm. - composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.clickUntil(hasTestTag(CreateProjectButtonTestTag)) { composeRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + + // 2. Type the project name into the dialog's only editable field, then confirm. composeRule.onNode(hasSetTextAction()).performTextInput(projectName) // The confirm button and the dialog title both read "Create Project"; diff --git a/android/src/androidTest/kotlin/SceneEditorWorkflowTest.kt b/android/src/androidTest/kotlin/SceneEditorWorkflowTest.kt index ff59e0ea3..1c2722646 100644 --- a/android/src/androidTest/kotlin/SceneEditorWorkflowTest.kt +++ b/android/src/androidTest/kotlin/SceneEditorWorkflowTest.kt @@ -1,7 +1,7 @@ +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ActivityScenario @@ -44,12 +44,10 @@ class SceneEditorWorkflowTest { composeRule.navigateTo(NAV_EDITOR_TAG) // Create a scene and open it. - composeRule.waitUntil(10_000) { - composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty() + composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) { + composeRule.onAllNodesWithTag(SCENE_LIST_ADD_SCENE_TAG).fetchSemanticsNodes().isNotEmpty() } - composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick() - composeRule.onNodeWithTag(SCENE_LIST_ADD_SCENE_TAG).performClick() - composeRule.waitUntil(10_000) { + composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_SCENE_TAG)) { composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput("Opening") @@ -65,8 +63,7 @@ class SceneEditorWorkflowTest { } // Saving clears the dirty buffer, so the save action disappears. - composeRule.onNodeWithTag(SCENE_EDITOR_SAVE_TAG).performClick() - composeRule.waitUntil(10_000) { + composeRule.clickUntil(hasTestTag(SCENE_EDITOR_SAVE_TAG)) { composeRule.onAllNodesWithTag(SCENE_EDITOR_SAVE_TAG).fetchSemanticsNodes().isEmpty() } } diff --git a/android/src/androidTest/kotlin/SceneListWorkflowTest.kt b/android/src/androidTest/kotlin/SceneListWorkflowTest.kt index 735442ebc..5d28b525b 100644 --- a/android/src/androidTest/kotlin/SceneListWorkflowTest.kt +++ b/android/src/androidTest/kotlin/SceneListWorkflowTest.kt @@ -1,9 +1,9 @@ import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ActivityScenario @@ -62,12 +62,10 @@ class SceneListWorkflowTest { // Open the add menu, pick scene/group, type a name, submit via the IME action. private fun addItem(menuItemTag: String, name: String) { - composeRule.waitUntil(10_000) { - composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty() + composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) { + composeRule.onAllNodesWithTag(menuItemTag).fetchSemanticsNodes().isNotEmpty() } - composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick() - composeRule.onNodeWithTag(menuItemTag).performClick() - composeRule.waitUntil(10_000) { + composeRule.clickUntil(hasTestTag(menuItemTag)) { composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput(name) diff --git a/android/src/androidTest/kotlin/TimelineWorkflowTest.kt b/android/src/androidTest/kotlin/TimelineWorkflowTest.kt index 185201f81..cbd077033 100644 --- a/android/src/androidTest/kotlin/TimelineWorkflowTest.kt +++ b/android/src/androidTest/kotlin/TimelineWorkflowTest.kt @@ -1,4 +1,5 @@ import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -42,14 +43,8 @@ class TimelineWorkflowTest { fun createEventThenOpenIt() { composeRule.navigateTo(NAV_TIMELINE_TAG) - // Open the create screen via the FAB. - composeRule.waitUntil(10_000) { - composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isNotEmpty() - } - composeRule.onNodeWithTag(TIME_LINE_CREATE_TAG).performClick() - - // Content is optional, so a date alone persists an event. - composeRule.waitUntil(10_000) { + // Open the create screen via the FAB. Content is optional, so a date alone persists an event. + composeRule.clickUntil(hasTestTag(TIME_LINE_CREATE_TAG)) { composeRule.onAllNodesWithTag(TIME_LINE_CREATE_DATE_TAG).fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag(TIME_LINE_CREATE_DATE_TAG).performTextInput("Year One") @@ -62,8 +57,7 @@ class TimelineWorkflowTest { composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].assertIsDisplayed() // Opening the event leaves the overview - the create FAB only shows there. - composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].performClick() - composeRule.waitUntil(10_000) { + composeRule.clickUntil(hasTestTag(EVENT_CARD_TAG)) { composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isEmpty() } } From c51fc1ff8bb75eba54fcf5634b87111adafb3ab1 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 21:16:35 -0700 Subject: [PATCH 018/213] Resolve Markdown escapes when searching project text (#824) * Resolve Markdown escapes when searching project text Global search compared queries against raw stored Markdown, so a phrase that crosses a backslash escape never matched: "well-known" missed a document holding "well\-known", and the snippet showed the backslash. Resolve escapes before matching, and build the snippet from the resolved text. This inverts exactly what the editor does on save, so it is safe for imported and hand-edited content too, where a backslash escape means the same thing. Nothing else is rewritten. Emphasis, code, link and block markers are left as stored, because telling syntax apart from a literal character needs a parser, and guessing wrong silently alters the author's words. Timeline dates are a plain-text field, so only the event body is resolved. A query holding a backslash also tries the raw source, so searching for a literal escape keeps working. * Resolve escapes in search titles and in-screen search The title above a result still came from raw storage while its snippet was unescaped, so one card rendered the same sentence two ways. Resolve escapes for the title as well. The Notes and Timeline screens filter the same text with a raw substring test, so a query that found a note in global search returned the empty state on the screen that owns it. Resolve escapes there too. Move unescapeMarkdown out of StoryExportCommon into data/search so the exporters and search share one implementation rather than two copies, and take its tests with it. Guard the timeline raw fallback the way findMarkdownMatch already is, so a query without a backslash stops rescanning an identical string. --- .../projecthome/PdfProseMarkdown.kt | 1 + .../projecthome/StoryExportCommon.kt | 21 --- .../data/globalsearch/SearchProjectUseCase.kt | 52 +++++-- .../common/data/search/MarkdownEscapes.kt | 31 ++++ .../projecthome/StoryExportCommonTest.kt | 39 ----- .../common/data/search/MarkdownEscapesTest.kt | 65 +++++++++ .../globalsearch/SearchProjectUseCaseTest.kt | 133 ++++++++++++++++++ .../apps/hammer/common/notes/BrowseNotesUi.kt | 5 +- .../common/timeline/TimeLineOverviewUi.kt | 3 +- 9 files changed, 276 insertions(+), 74 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapes.kt delete mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommonTest.kt create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapesTest.kt diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt index e5642f05d..c7d38762f 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.conamobile.pdfkmp.dsl.ContainerScope +import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.conamobile.pdfkmp.dsl.TextScope import com.conamobile.pdfkmp.geometry.Padding import com.conamobile.pdfkmp.layout.BoxAlignment diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt index df59b5b23..b65cfbeb5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt @@ -28,27 +28,6 @@ internal fun chapterBookmark(index: Int): String = "chapter${index + 1}" // Shared markdown text helpers used when turning the parsed prose model into export output. -private const val ASCII_PUNCTUATION = "!\"#\$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - -/** Resolves CommonMark backslash escapes (`\*` → `*`) to their bare ASCII punctuation. */ -internal fun unescapeMarkdown(text: CharSequence): String { - if (!text.contains('\\')) return text.toString() - val sb = StringBuilder(text.length) - var i = 0 - while (i < text.length) { - val c = text[i] - val next = if (i + 1 < text.length) text[i + 1] else null - if (c == '\\' && next != null && next in ASCII_PUNCTUATION) { - sb.append(next) - i += 2 - } else { - sb.append(c) - i++ - } - } - return sb.toString() -} - /** Splits a code block's text into lines, trimming leading and trailing blank lines. */ internal fun codeBlockLines(code: String): List = code.split("\n").dropWhile { it.isBlank() }.dropLastWhile { it.isBlank() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt index 0e6fdbd8b..1bc0503f5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt @@ -13,6 +13,7 @@ import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRe import com.darkrockstudios.apps.hammer.common.data.search.ParsedQuery import com.darkrockstudios.apps.hammer.common.data.search.matchesAllTags import com.darkrockstudios.apps.hammer.common.data.search.parseQuery +import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_DEFAULT import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_IO @@ -95,12 +96,8 @@ class SearchProjectUseCase( return timeline.events .filter { it.tags.matchesAllTags(parsed.tags) } .mapNotNull { event -> - val combined = if (event.date.isNullOrBlank()) { - event.content - } else { - "${event.date} — ${event.content}" - } - val snippet = matchOrPreview(combined, parsed.text) ?: return@mapNotNull null + val snippet = matchTimelineEvent(event.date, event.content, parsed.text) + ?: return@mapNotNull null SearchResult.TimelineEvent( eventId = event.id, title = event.date?.takeIf { it.isNotBlank() } @@ -165,7 +162,7 @@ class SearchProjectUseCase( ) } - val textMatch = findMatch(entry.text, freeText) ?: return null + val textMatch = findMarkdownMatch(entry.text, freeText) ?: return null return SearchResult.EncyclopediaEntry( entryDef = def, title = def.name, @@ -197,7 +194,7 @@ class SearchProjectUseCase( return SearchResult.Scene(sceneItem = scene, title = scene.name, snippet = nameMatch) } val text = withContext(dispatcherIo) { loadSceneText(scene) } ?: return null - val bodyMatch = findMatch(text, query) ?: return null + val bodyMatch = findMarkdownMatch(text, query) ?: return null return SearchResult.Scene(sceneItem = scene, title = scene.name, snippet = bodyMatch) } @@ -220,15 +217,28 @@ class SearchProjectUseCase( return SearchResult.Scene(sceneItem = scene, title = title, snippet = nameMatch) } val text = withContext(dispatcherIo) { loadSceneText(scene) } ?: return null - val bodyMatch = findMatch(text, query) ?: return null + val bodyMatch = findMarkdownMatch(text, query) ?: return null return SearchResult.Scene(sceneItem = scene, title = title, snippet = bodyMatch) } + /** Both callers pass stored Markdown, so escapes are resolved before matching. */ private fun matchOrPreview(content: String, query: String): AnnotatedSnippet? { - if (query.isEmpty()) return previewSnippet(content) - return findMatch(content, query) + if (query.isEmpty()) return previewSnippet(unescapeMarkdown(content)) + return findMarkdownMatch(content, query) } + /** The date is a plain-text field, so only the event body is unescaped. */ + private fun matchTimelineEvent(date: String?, content: String, query: String): AnnotatedSnippet? { + val resolved = withDate(date, unescapeMarkdown(content)) + if (query.isEmpty()) return previewSnippet(resolved) + findMatch(resolved, query)?.let { return it } + if (query.contains('\\')) return findMatch(withDate(date, content), query) + return null + } + + private fun withDate(date: String?, content: String): String = + if (date.isNullOrBlank()) content else "$date — $content" + private fun loadSceneText(scene: SceneItem): String? { val buffer = sceneContentRepository.getSceneBuffer(scene) val bufferText = buffer?.content?.markdown @@ -236,8 +246,13 @@ class SearchProjectUseCase( return runCatching { sceneEditor.loadSceneMarkdownRaw(scene) }.getOrNull() } + /** [content] is stored Markdown, so the title resolves escapes the way its snippet does. */ private fun firstLineTitle(content: String, fallback: String): String { - val firstLine = content.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + val firstLine = content.lineSequence() + .firstOrNull { it.isNotBlank() } + ?.let { unescapeMarkdown(it) } + ?.trim() + .orEmpty() return when { firstLine.isEmpty() -> fallback firstLine.length > TITLE_MAX -> firstLine.take(TITLE_MAX).trimEnd() + "…" @@ -264,6 +279,19 @@ class SearchProjectUseCase( return AnnotatedSnippet(text = truncated, matchStart = 0, matchEnd = 0) } + /** + * Matches stored Markdown with its escapes resolved, so a query matches the prose on screen + * and the snippet renders the same way. A query holding a backslash also tries the raw + * source, so searching for a literal escape keeps working. + */ + internal fun findMarkdownMatch(markdown: String, query: String): AnnotatedSnippet? { + if (markdown.isEmpty() || query.isEmpty()) return null + val resolved = unescapeMarkdown(markdown) + findMatch(resolved, query)?.let { return it } + if (query.contains('\\')) return findMatch(markdown, query) + return null + } + internal fun findMatch(text: String, query: String): AnnotatedSnippet? { if (text.isEmpty() || query.isEmpty()) return null val pos = text.indexOf(query, ignoreCase = true) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapes.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapes.kt new file mode 100644 index 000000000..cbd55bbf8 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapes.kt @@ -0,0 +1,31 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +private const val ASCII_PUNCTUATION = "!\"#\$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +/** + * Resolves CommonMark backslash escapes (`\*` becomes `*`) to their bare ASCII punctuation, so text + * reads the way it does on screen rather than the way it is stored. Allocates only when [text] + * actually holds a backslash. + * + * Escapes are resolved everywhere, including inside code spans and fenced blocks where CommonMark + * keeps them literal. Telling those apart needs a parser, and no marker is added or removed either + * way, so the text is never rewritten beyond dropping a backslash. + */ +fun unescapeMarkdown(text: CharSequence): String { + if (!text.contains('\\')) return text.toString() + + val sb = StringBuilder(text.length) + var i = 0 + while (i < text.length) { + val c = text[i] + val next = if (i + 1 < text.length) text[i + 1] else null + if (c == '\\' && next != null && next in ASCII_PUNCTUATION) { + sb.append(next) + i += 2 + } else { + sb.append(c) + i++ + } + } + return sb.toString() +} diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommonTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommonTest.kt deleted file mode 100644 index 4b2d53953..000000000 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommonTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.darkrockstudios.apps.hammer.common.components.projecthome - -import kotlin.test.Test -import kotlin.test.assertEquals - -class StoryExportCommonTest { - - @Test - fun `text without a backslash is returned unchanged`() { - val input = "Plain prose, no escapes." - assertEquals(input, unescapeMarkdown(input)) - } - - @Test - fun `backslash before ASCII punctuation resolves to the bare character`() { - assertEquals("*", unescapeMarkdown("\\*")) - assertEquals("_", unescapeMarkdown("\\_")) - assertEquals("#", unescapeMarkdown("\\#")) - assertEquals("\\", unescapeMarkdown("\\\\")) - assertEquals("[link]", unescapeMarkdown("\\[link\\]")) - } - - @Test - fun `emphasis markers are unescaped inside a sentence`() { - assertEquals("use *stars* and _unders_", unescapeMarkdown("use \\*stars\\* and \\_unders\\_")) - } - - @Test - fun `a backslash before a non-punctuation character is preserved`() { - // Only ASCII punctuation is a valid CommonMark escape; everything else keeps the backslash. - assertEquals("\\a", unescapeMarkdown("\\a")) - assertEquals("C:\\temp", unescapeMarkdown("C:\\temp")) - } - - @Test - fun `a trailing backslash is preserved`() { - assertEquals("end\\", unescapeMarkdown("end\\")) - } -} diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapesTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapesTest.kt new file mode 100644 index 000000000..cc52528d1 --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownEscapesTest.kt @@ -0,0 +1,65 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MarkdownEscapesTest { + + @Test + fun `text without a backslash is returned unchanged`() { + val input = "Plain prose, no escapes." + assertEquals(input, unescapeMarkdown(input)) + } + + @Test + fun `empty input is returned unchanged`() { + assertEquals("", unescapeMarkdown("")) + } + + @Test + fun `backslash before ASCII punctuation resolves to the bare character`() { + assertEquals("*", unescapeMarkdown("\\*")) + assertEquals("_", unescapeMarkdown("\\_")) + assertEquals("#", unescapeMarkdown("\\#")) + assertEquals("\\", unescapeMarkdown("\\\\")) + assertEquals("[link]", unescapeMarkdown("\\[link\\]")) + } + + @Test + fun `escapes resolve inside a sentence`() { + assertEquals("use *stars* and _unders_", unescapeMarkdown("use \\*stars\\* and \\_unders\\_")) + assertEquals("well-known", unescapeMarkdown("well\\-known")) + assertEquals("the garden!", unescapeMarkdown("the garden\\!")) + assertEquals("mock(turtle)", unescapeMarkdown("mock\\(turtle\\)")) + assertEquals("1. not a list", unescapeMarkdown("1\\. not a list")) + } + + @Test + fun `a backslash before a non-punctuation character is preserved`() { + // Only ASCII punctuation is a valid CommonMark escape; everything else keeps the backslash. + assertEquals("\\a", unescapeMarkdown("\\a")) + assertEquals("C:\\temp", unescapeMarkdown("C:\\temp")) + assertEquals("\\d{4}", unescapeMarkdown("\\d{4}")) + } + + @Test + fun `a trailing backslash is preserved`() { + assertEquals("end\\", unescapeMarkdown("end\\")) + } + + @Test + fun `unescaped markup is left exactly as stored`() { + assertEquals("**Chapter** One", unescapeMarkdown("**Chapter** One")) + assertEquals("user_name", unescapeMarkdown("user_name")) + assertEquals("* * *", unescapeMarkdown("* * *")) + assertEquals("***", unescapeMarkdown("***")) + assertEquals("# Chapter One", unescapeMarkdown("# Chapter One")) + assertEquals("run `build` now", unescapeMarkdown("run `build` now")) + assertEquals("The cost is 5*4", unescapeMarkdown("The cost is 5*4")) + } + + @Test + fun `line structure is preserved`() { + assertEquals("a-b\n\nc-d", unescapeMarkdown("a\\-b\n\nc\\-d")) + } +} diff --git a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt index c6cbb7087..05b8fae14 100644 --- a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt @@ -356,6 +356,139 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals(7, results.first().sceneItem.id) } + @Test + fun `search matches across a backslash escape`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "A well\\-known secret")) + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + } + + @Test + fun `snippets render escaped text the way it reads on screen`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "A well\\-known garden\\! at dusk")) + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + val snippet = results.first().snippet + assertEquals("A well-known garden! at dusk", snippet.text) + assertEquals("well-known", snippet.text.substring(snippet.matchStart, snippet.matchEnd)) + } + + @Test + fun `the title reads the same way as the snippet under it`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "Alice \\(the elder\\) is well\\-known\\!")) + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("Alice (the elder) is well-known!", results.first().title) + assertEquals("Alice (the elder) is well-known!", results.first().snippet.text) + } + + @Test + fun `escaped markers are searchable as literal characters`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "the \\_shape\\_ of it")) + + val results = createUseCase().search("_shape_", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertTrue(results.first().snippet.text.contains("_shape_")) + } + + @Test + fun `searching for a literal backslash escape still finds it`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "raw well\\-known marker")) + + val results = createUseCase().search("well\\-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + } + + @Test + fun `unescaped markup is left alone so literal text is not rewritten`() = runTest { + every { notes.getNotes() } returns listOf( + note(1, "The cost is 5*4"), + note(2, "the user_name field"), + ) + + val math = createUseCase().search("5*4", GlobalSearchFilter.All) + .filterIsInstance() + val identifier = createUseCase().search("user_name", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, math.size) + assertEquals("The cost is 5*4", math.first().snippet.text) + assertEquals(1, identifier.size) + assertTrue(identifier.first().snippet.text.contains("user_name")) + } + + @Test + fun `scene bodies match across a backslash escape`() = runTest { + val scene = SceneItem( + projectDef = projectDef, + type = SceneItem.Type.Scene, + id = 7, + name = "Opening", + order = 0, + ) + every { sceneEditor.getScenes() } returns listOf(scene) + every { sceneContentRepository.getSceneBuffer(scene) } returns null + every { sceneEditor.loadSceneMarkdownRaw(scene, any()) } returns "A well\\-known road" + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("A well-known road", results.first().snippet.text) + } + + @Test + fun `timeline dates are not unescaped`() = runTest { + coEvery { timeLine.loadTimeline() } returns TimeLineContainer( + listOf(TimeLineEvent(id = 21, order = 0, date = "1990\\-2000", content = "A long war")), + ) + + val verbatim = createUseCase().search("1990\\-2000", GlobalSearchFilter.All) + .filterIsInstance() + val resolvedForm = createUseCase().search("1990-2000", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, verbatim.size) + // Unescaping the date would make this hit and would show a date the event does not have. + assertEquals(0, resolvedForm.size) + } + + @Test + fun `tag-only search previews escaped text as it reads`() = runTest { + every { notes.getNotes() } returns listOf( + NoteContainer( + NoteContent( + id = 1, + created = Clock.System.now(), + content = "A well\\-known secret", + tags = setOf("fantasy"), + ) + ), + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("A well-known secret", results.first().snippet.text) + } + + private fun note(id: Int, content: String) = + NoteContainer(NoteContent(id = id, created = Clock.System.now(), content = content)) + @Test fun `search honors filter to a single source`() = runTest { every { notes.getNotes() } returns listOf( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt index 5a2d9a09d..1f944c5e7 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt @@ -70,6 +70,7 @@ import com.darkrockstudios.apps.hammer.common.compose.firstNonBlankLine import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent +import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.darkrockstudios.apps.hammer.common.util.format import com.darkrockstudios.apps.hammer.notes_create_note_button import com.darkrockstudios.apps.hammer.notes_filter_all @@ -161,7 +162,9 @@ fun BrowseNotesUi( val byText = if (searchQuery.isBlank()) { state.notes } else { - state.notes.filter { it.content.contains(searchQuery.trim(), ignoreCase = true) } + state.notes.filter { + unescapeMarkdown(it.content).contains(searchQuery.trim(), ignoreCase = true) + } } val byTags = if (activeTags.isEmpty()) { byText diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt index 483f674ca..187dfd9c8 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt @@ -82,6 +82,7 @@ import com.darkrockstudios.apps.hammer.common.compose.reorderable.DragDropList import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors +import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.darkrockstudios.apps.hammer.common.data.tagindex.TagCount import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent import com.darkrockstudios.apps.hammer.timeline_filter_all @@ -137,7 +138,7 @@ fun TimeLineOverviewUi( events } else { events.filter { event -> - event.content.contains(searchQuery.trim(), ignoreCase = true) || + unescapeMarkdown(event.content).contains(searchQuery.trim(), ignoreCase = true) || event.date?.contains(searchQuery.trim(), ignoreCase = true) == true } } From e7cdad3cdcd7502853f3ab4055be93cc7723e44d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 1 Aug 2026 22:40:47 -0700 Subject: [PATCH 019/213] Allow accented and non-latin characters in tags (#829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow accented and non-latin characters in tags The tag pattern used \w, which is ASCII-only on both JVM and Native, so cleanTags() silently dropped anything like "thème" on the way to disk. Validate with Unicode-aware character predicates instead, including combining marks so decomposed input from iOS/macOS survives too. Fixes #778 * Address review of the Unicode tag fix Splitting tag input, stripping the `#` and validating the result were all still assuming ASCII in one way or another: - Split on any Unicode whitespace and on the fullwidth/ideographic commas, so an IME's fullwidth space separates tags instead of fusing them into one token that then fails validation. - Normalize to NFC, so the precomposed and decomposed spellings of a tag are one index key rather than two identical-looking ones. - Validate by code point, so an astral letter (rare kanji, Adlam) is not read as two non-letter surrogates and rejected. - Accept ZWJ/ZWNJ, which Persian and Indic scripts need word-internally, and require at least one letter or digit so a mark-only tag has a base glyph. - Strip a fullwidth `#` prefix as well as the ASCII one. The tag field now commits chips on the same separators and parses them with parseTagInput, so what it shows is what survives the save. --- .../scenemetadata/SceneMetadataPanel.kt | 5 +- .../common/data/tagindex/TagNormalizer.kt | 89 +++++++++++++-- .../tagindex/TagNormalizerTest.kt | 106 ++++++++++++++++++ .../common/data/tagindex/TagNormalizer.ios.kt | 14 +++ .../common/data/tagindex/TagNormalizer.jvm.kt | 10 ++ .../designsystem/HdHairlineTagField.kt | 14 ++- 6 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt create mode 100644 common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.ios.kt create mode 100644 common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.jvm.kt diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt index 9a30ea60a..ec7d8ded7 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt @@ -36,9 +36,8 @@ interface SceneMetadataPanel : HammerComponent, TagSuggesting { fun searchEntriesForAdd(query: String, maxResults: Int = 20): List /** - * Adds one or more tags from a free-form space-separated string. Input is - * normalized (trim, strip `#`, regex `[\w-]+`); invalid pieces are dropped. - * Tags already on the scene are silently no-ops. + * Adds one or more tags from a free-form space-separated string. Input is normalized by + * `parseTagInput`; invalid pieces are dropped. Tags already on the scene are silently no-ops. */ fun addTags(input: String) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt index b071110f1..621b127ba 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt @@ -1,17 +1,92 @@ package com.darkrockstudios.apps.hammer.common.data.tagindex -private val TAG_PATTERN = Regex("""[\w-]+""") -private val TAG_INPUT_SPLIT = Regex("""[\s,]+""") +private const val ZERO_WIDTH_NON_JOINER = '\u200C' +private const val ZERO_WIDTH_JOINER = '\u200D' +private const val FULLWIDTH_NUMBER_SIGN = '\uFF03' +private const val FULLWIDTH_COMMA = '\uFF0C' +private const val IDEOGRAPHIC_COMMA = '\u3001' + +/** + * Canonically compose [text] (Unicode NFC) so the two spellings of an accented tag - precomposed + * `è`, and `e` plus a combining grave - are stored and indexed as one string. Tags are compared + * by exact equality everywhere downstream, so they have to agree on a single form. + */ +internal expect fun normalizeTagForm(text: String): String + +/** True when the supplementary-plane [codePoint] is a letter or a digit. */ +internal expect fun isSupplementaryLetterOrDigit(codePoint: Int): Boolean + +/** Separates one tag from the next in free-form input, in any script's punctuation. */ +fun Char.isTagSeparator(): Boolean = + isWhitespace() || + this == ',' || + this == FULLWIDTH_COMMA || + this == IDEOGRAPHIC_COMMA + +private fun Char.isTagChar(): Boolean = + isLetterOrDigit() || + category == CharCategory.NON_SPACING_MARK || + category == CharCategory.COMBINING_SPACING_MARK || + this == ZERO_WIDTH_NON_JOINER || + this == ZERO_WIDTH_JOINER || + this == '_' || + this == '-' + +/** + * A tag is a run of letters, digits, combining marks and word-joiners, plus `_` and `-`, and + * must carry at least one letter or digit so it has something to render. + */ +private fun isValidTag(tag: String): Boolean { + var hasLetterOrDigit = false + var i = 0 + while (i < tag.length) { + val char = tag[i] + val next = if (i + 1 < tag.length) tag[i + 1] else null + // Astral characters (rare kanji, Adlam, ...) are a surrogate pair, and neither half is a + // letter on its own, so they have to be tested as a whole code point. + if (char.isHighSurrogate() && next != null && next.isLowSurrogate()) { + if (!isSupplementaryLetterOrDigit(codePointOf(char, next))) return false + hasLetterOrDigit = true + i += 2 + } else { + if (!char.isTagChar()) return false + if (char.isLetterOrDigit()) hasLetterOrDigit = true + i++ + } + } + return hasLetterOrDigit +} + +private fun codePointOf(high: Char, low: Char): Int = + 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00) + +private fun String.removeTagPrefix(): String = + if (startsWith('#') || startsWith(FULLWIDTH_NUMBER_SIGN)) substring(1) else this fun cleanTags(tags: Set): Set = tags.asSequence() - .map { it.trim().removePrefix("#") } - .filter { it.isNotEmpty() && TAG_PATTERN.matches(it) } + .map { normalizeTagForm(it.trim()).removeTagPrefix() } + .filter(::isValidTag) .toSet() /** * Parse free-form user tag input into a normalized set: split on whitespace + commas, - * strip leading `#`, drop pieces that don't match [TAG_PATTERN]. + * strip a leading `#`, drop pieces that aren't valid tags. */ -fun parseTagInput(input: String): Set = - cleanTags(input.split(TAG_INPUT_SPLIT).toSet()) +fun parseTagInput(input: String): Set { + val pieces = mutableListOf() + val piece = StringBuilder() + for (char in input) { + if (char.isTagSeparator()) { + if (piece.isNotEmpty()) { + pieces.add(piece.toString()) + piece.clear() + } + } else { + piece.append(char) + } + } + if (piece.isNotEmpty()) pieces.add(piece.toString()) + + return cleanTags(pieces.toSet()) +} diff --git a/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt b/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt new file mode 100644 index 000000000..27e9e3e6e --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt @@ -0,0 +1,106 @@ +package repositories.tagindex + +import com.darkrockstudios.apps.hammer.common.data.tagindex.cleanTags +import com.darkrockstudios.apps.hammer.common.data.tagindex.parseTagInput +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class TagNormalizerTest { + + @Test + fun `Clean tags keeps accented letters`() { + val tags = setOf("th\u00e8me", "caf\u00e9", "a\u00f1o", "\u00c6r\u00f8") + + assertEquals(tags, cleanTags(tags)) + } + + @Test + fun `Clean tags keeps non-latin scripts`() { + val tags = setOf("\u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436", "\u4eba\u7269", "\u3061\u304b\u3089") + + assertEquals(tags, cleanTags(tags)) + } + + @Test + fun `Clean tags keeps supplementary plane letters`() { + val rareKanji = "\uD842\uDFB7\u7530" + val adlam = "\uD83A\uDD00\uD83A\uDD22" + + assertEquals(setOf(rareKanji, adlam), cleanTags(setOf(rareKanji, adlam))) + } + + @Test + fun `Clean tags keeps word joiners`() { + val withZwnj = "\u0645\u06cc\u200c\u0631\u0648\u062f" + + assertEquals(setOf(withZwnj), cleanTags(setOf(withZwnj))) + } + + @Test + fun `Clean tags keeps ascii word characters`() { + val tags = setOf("animal", "side-quest", "act_two", "chapter3") + + assertEquals(tags, cleanTags(tags)) + } + + /** Text entered on iOS/macOS can arrive decomposed: base letter plus a combining mark. */ + @Test + fun `Clean tags composes decomposed accents`() { + val decomposed = "the\u0300me" + + assertEquals(setOf("th\u00e8me"), cleanTags(setOf(decomposed))) + } + + @Test + fun `Clean tags treats both spellings of a tag as one`() { + val composed = "th\u00e8me" + val decomposed = "the\u0300me" + + assertEquals(setOf(composed), cleanTags(setOf(composed, decomposed))) + } + + @Test + fun `Clean tags strips leading hash and surrounding whitespace`() { + assertEquals(setOf("th\u00e8me"), cleanTags(setOf(" #th\u00e8me "))) + } + + @Test + fun `Clean tags strips a fullwidth hash`() { + assertEquals(setOf("\u4eba\u7269"), cleanTags(setOf("\uff03\u4eba\u7269"))) + } + + @Test + fun `Clean tags drops separators and punctuation`() { + val tags = setOf("two words", "comma,tag", "quote\"tag", "slash/tag", "") + + assertEquals(emptySet(), cleanTags(tags)) + } + + @Test + fun `Clean tags drops tags with no letter or digit`() { + val tags = setOf("\u0301", "---", "___", "\u200c") + + assertEquals(emptySet(), cleanTags(tags)) + } + + @Test + fun `Parse tag input splits on whitespace and commas`() { + val input = " #th\u00e8me, guerre \u00e9t\u00e9,h\u00e9ros " + + assertEquals(setOf("th\u00e8me", "guerre", "\u00e9t\u00e9", "h\u00e9ros"), parseTagInput(input)) + } + + @Test + fun `Parse tag input splits on ideographic separators`() { + val input = "\u4eba\u7269\u3000\u5834\u6240\u3001\u9b54\u6cd5" + + assertEquals(setOf("\u4eba\u7269", "\u5834\u6240", "\u9b54\u6cd5"), parseTagInput(input)) + } + + @Test + fun `Parse tag input splits on non-breaking spaces`() { + val input = "one\u00a0two" + + assertEquals(setOf("one", "two"), parseTagInput(input)) + } +} \ No newline at end of file diff --git a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.ios.kt b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.ios.kt new file mode 100644 index 000000000..431a98f20 --- /dev/null +++ b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.ios.kt @@ -0,0 +1,14 @@ +package com.darkrockstudios.apps.hammer.common.data.tagindex + +import platform.Foundation.NSCharacterSet +import platform.Foundation.NSString +import platform.Foundation.precomposedStringWithCanonicalMapping + +internal actual fun normalizeTagForm(text: String): String = + (text as NSString).precomposedStringWithCanonicalMapping + +/** Foundation's alphanumeric set is Unicode categories L, M and N, which all read as tag content. */ +private val alphanumeric = NSCharacterSet.alphanumericCharacterSet + +internal actual fun isSupplementaryLetterOrDigit(codePoint: Int): Boolean = + alphanumeric.longCharacterIsMember(codePoint.toUInt()) diff --git a/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.jvm.kt b/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.jvm.kt new file mode 100644 index 000000000..3452680f4 --- /dev/null +++ b/common/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.jvm.kt @@ -0,0 +1,10 @@ +package com.darkrockstudios.apps.hammer.common.data.tagindex + +import java.text.Normalizer + +internal actual fun normalizeTagForm(text: String): String = + if (Normalizer.isNormalized(text, Normalizer.Form.NFC)) text + else Normalizer.normalize(text, Normalizer.Form.NFC) + +internal actual fun isSupplementaryLetterOrDigit(codePoint: Int): Boolean = + Character.isLetterOrDigit(codePoint) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt index 3a196e629..2256936fd 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.hammer.common.compose.CollapseWhileTyping +import com.darkrockstudios.apps.hammer.common.data.tagindex.isTagSeparator +import com.darkrockstudios.apps.hammer.common.data.tagindex.parseTagInput /** * Tag chip field — labeled hairline-underline input that keeps its @@ -50,15 +52,17 @@ fun HdHairlineTagField( onDraftChange(it) } + // Parsed with the same rule that persistence uses, so a chip is never shown for input + // that would be dropped on save. val addTag: (String) -> Unit = { raw -> - val candidate = raw.trim().removePrefix("#") - if (candidate.isNotEmpty() && !tags.contains(candidate)) { - onTagsChange(tags + candidate) + val candidates = parseTagInput(raw).filter { it !in tags } + if (candidates.isNotEmpty()) { + onTagsChange(tags + candidates) } updateDraft("") } val addCurrent: () -> Boolean = add@{ - if (draft.trim().removePrefix("#").isEmpty()) return@add false + if (parseTagInput(draft).isEmpty()) return@add false addTag(draft) true } @@ -126,7 +130,7 @@ fun HdHairlineTagField( value = draft, onValueChange = { next -> val last = next.lastOrNull() - if (last == ',' || last == ' ') { + if (last != null && last.isTagSeparator()) { updateDraft(next.dropLast(1)) addCurrent() } else { From b9df69de84b4f91b1bcdaf3dc2036c0933500f34 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 2 Aug 2026 01:19:39 -0700 Subject: [PATCH 020/213] Fix tag search dropping bodiless items and ignoring entry names (#833) A tag match stands on its own, so an item with an empty body no longer loses its result to a null preview snippet. Covers notes, encyclopedia entries, timeline events and scenes. The tags-present branch of the encyclopedia match now tries the entry name before the body, the way the scene path already does, so "#fantasy alice" returns the entry named Alice. Fixes #828 --- .../data/globalsearch/SearchProjectUseCase.kt | 25 +++- .../globalsearch/SearchProjectUseCaseTest.kt | 141 ++++++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt index 1bc0503f5..ebfb4f211 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt @@ -26,6 +26,9 @@ import org.koin.core.component.inject import org.koin.core.qualifier.named import kotlin.coroutines.CoroutineContext +/** A tag match with nothing to preview: the result stands on the tag alone. */ +private val EMPTY_SNIPPET = AnnotatedSnippet(text = "", matchStart = 0, matchEnd = 0) + /** * Stateless cross-repo project search. Given a query and a filter it fans out across the * scene/notes/encyclopedia/timeline repositories and returns the matched results. Holds no state. @@ -142,7 +145,9 @@ class SearchProjectUseCase( parsed.tags.any { tag.contains(it, ignoreCase = true) } } val title = if (matchedTag != null) "${def.name} • #$matchedTag" else def.name - val snippet = matchOrPreview(entry.text, freeText) ?: return null + val snippet = findMatch(def.name, freeText) + ?: matchOrPreview(entry.text, freeText, fallback = def.name) + ?: return null return SearchResult.EncyclopediaEntry( entryDef = def, title = title, @@ -209,7 +214,7 @@ class SearchProjectUseCase( val title = if (matchedTag != null) "${scene.name} • #$matchedTag" else scene.name if (query.isEmpty()) { - val snippet = previewSnippet(scene.name) ?: return null + val snippet = previewSnippet(scene.name) ?: EMPTY_SNIPPET return SearchResult.Scene(sceneItem = scene, title = title, snippet = snippet) } val nameMatch = findMatch(scene.name, query) @@ -221,16 +226,24 @@ class SearchProjectUseCase( return SearchResult.Scene(sceneItem = scene, title = title, snippet = bodyMatch) } - /** Both callers pass stored Markdown, so escapes are resolved before matching. */ - private fun matchOrPreview(content: String, query: String): AnnotatedSnippet? { - if (query.isEmpty()) return previewSnippet(unescapeMarkdown(content)) + /** + * Both callers pass stored Markdown, so escapes are resolved before matching. With no free text + * the item already matched on its tags, so a blank body falls back to [fallback] and then to an + * empty snippet rather than discarding the result. + */ + private fun matchOrPreview(content: String, query: String, fallback: String = ""): AnnotatedSnippet? { + if (query.isEmpty()) { + return previewSnippet(unescapeMarkdown(content)) + ?: previewSnippet(fallback) + ?: EMPTY_SNIPPET + } return findMarkdownMatch(content, query) } /** The date is a plain-text field, so only the event body is unescaped. */ private fun matchTimelineEvent(date: String?, content: String, query: String): AnnotatedSnippet? { val resolved = withDate(date, unescapeMarkdown(content)) - if (query.isEmpty()) return previewSnippet(resolved) + if (query.isEmpty()) return previewSnippet(resolved) ?: EMPTY_SNIPPET findMatch(resolved, query)?.let { return it } if (query.contains('\\')) return findMatch(withDate(date, content), query) return null diff --git a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt index 05b8fae14..37226100d 100644 --- a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt @@ -486,6 +486,147 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals("A well-known secret", results.first().snippet.text) } + @Test + fun `tag-only search returns a note with no content`() = runTest { + every { notes.getNotes() } returns listOf( + NoteContainer( + NoteContent( + id = 1, + created = Clock.System.now(), + content = "", + tags = setOf("fantasy"), + ) + ), + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals(1, results.first().noteId) + } + + @Test + fun `tag-only search returns an encyclopedia entry with no body text`() = runTest { + val def = EntryDef(projectDef = projectDef, id = 13, type = EntryType.PERSON, name = "Alice") + coEvery { encyclopedia.ensureEntriesLoaded() } returns listOf(def) + every { encyclopedia.loadEntry(def) } returns EntryContainer( + EntryContent( + id = 13, + name = "Alice", + type = EntryType.PERSON, + text = "", + tags = setOf("fantasy"), + ) + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals(13, results.first().entryDef.id) + assertTrue(results.first().title.contains("Alice")) + assertEquals("Alice", results.first().snippet.text) + } + + @Test + fun `tag-only search returns timeline events with and without content`() = runTest { + coEvery { timeLine.loadTimeline() } returns TimeLineContainer( + listOf( + TimeLineEvent(id = 21, order = 0, date = null, content = "", tags = setOf("fantasy")), + TimeLineEvent(id = 22, order = 1, date = "Year 3", content = "Coronation", tags = setOf("fantasy")), + ) + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(listOf(21, 22), results.map { it.eventId }) + assertTrue(results.last().snippet.text.contains("Coronation")) + } + + @Test + fun `tag search combined with free text matches an encyclopedia entry name`() = runTest { + val def = EntryDef(projectDef = projectDef, id = 14, type = EntryType.PERSON, name = "Alice") + coEvery { encyclopedia.ensureEntriesLoaded() } returns listOf(def) + every { encyclopedia.loadEntry(def) } returns EntryContainer( + EntryContent( + id = 14, + name = "Alice", + type = EntryType.PERSON, + text = "A traveler from the north.", + tags = setOf("fantasy"), + ) + ) + + val results = createUseCase().search("#fantasy alice", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals(14, results.first().entryDef.id) + assertTrue(results.first().snippet.text.contains("Alice")) + } + + @Test + fun `tag search with free text falls through to the encyclopedia entry body`() = runTest { + val def = EntryDef(projectDef = projectDef, id = 15, type = EntryType.PLACE, name = "Mordor") + coEvery { encyclopedia.ensureEntriesLoaded() } returns listOf(def) + every { encyclopedia.loadEntry(def) } returns EntryContainer( + EntryContent( + id = 15, + name = "Mordor", + type = EntryType.PLACE, + text = "Sauron rules here.", + tags = setOf("fantasy"), + ) + ) + + val results = createUseCase().search("#fantasy sauron", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertTrue(results.first().snippet.text.contains("Sauron")) + } + + @Test + fun `tag search drops an encyclopedia entry when the free text matches neither name nor body`() = runTest { + val def = EntryDef(projectDef = projectDef, id = 16, type = EntryType.PERSON, name = "Alice") + coEvery { encyclopedia.ensureEntriesLoaded() } returns listOf(def) + every { encyclopedia.loadEntry(def) } returns EntryContainer( + EntryContent( + id = 16, + name = "Alice", + type = EntryType.PERSON, + text = "A traveler from the north.", + tags = setOf("fantasy"), + ) + ) + + val results = createUseCase().search("#fantasy dragon", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(0, results.size) + } + + @Test + fun `tag-only search returns a scene with a blank name`() = runTest { + val scene = SceneItem( + projectDef = projectDef, + type = SceneItem.Type.Scene, + id = 7, + name = " ", + order = 0, + ) + every { sceneEditor.getScenes() } returns listOf(scene) + coEvery { sceneMetadataRepository.loadSceneMetadata(7) } returns SceneMetadata(tags = setOf("plot")) + + val results = createUseCase().search("#plot", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals(7, results.first().sceneItem.id) + } + private fun note(id: Int, content: String) = NoteContainer(NoteContent(id = id, created = Clock.System.now(), content = content)) From 22eef19f4eab04532f84f517f6c46302d9d4722c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 2 Aug 2026 10:23:17 -0700 Subject: [PATCH 021/213] Unify search matching on resolved Markdown (#831) * Match every search surface through one rule Global search resolved escapes and then retried the raw source for a query containing a backslash, while the Notes and Timeline filters only did the first half and Story Ideas matched raw storage. The same query gave different answers depending on which screen it was typed into. Drop the retry rather than spread it. Searching the storage form of your own text is not a thing readers do, and supporting it cost real precision: a query of \* fell back to * and matched every emphasis marker in the project, and a query ending in a backslash matched nothing at all. It also forced the snippet and the title to be built from different strings, so one result row could spell the same sentence two ways. What is left is one rule: resolve the stored escapes, match the query literally. markdownContains carries it, next to matchesAllTags, and all four surfaces call it. Names, tags, dates and idea titles are not Markdown, so matching them as stored is now consistent rather than a bypass. Each screen's own composition of the rule is extracted so it can be tested; composeUi/src/desktopTest covers all three. * Say what the search rule actually does The KDoc claimed markdownContains compares the prose a reader sees. It only resolves backslash escapes, so a note stored as "the **big** dog" is still unfindable by "big dog" on every surface, which is the #811 emphasis gap. State the limit instead of denying it. SearchFilterTest claimed to pin each screen's composition of the rule while composing no screen, so reverting a filter to an inline raw contains would have kept it green. TimeLineOverviewUiTest now drives a screen's search field end to end, and the predicate tests say that is what they are. Global search resolves and matches separately because it needs offsets rather than a boolean, so add a test asserting it answers the same as markdownContains across a table of content and query shapes. * Drop the comma from a test name Kotlin/Native rejects Native forbids commas in backtick-quoted names, so common/commonTest failed to compile for iOS while desktop was fine. --- .../data/globalsearch/SearchProjectUseCase.kt | 13 +--- .../hammer/common/data/search/SearchQuery.kt | 14 ++++ .../data/search/MarkdownContainsTest.kt | 42 +++++++++++ .../globalsearch/SearchProjectUseCaseTest.kt | 43 ++++++++++- .../apps/hammer/common/notes/BrowseNotesUi.kt | 11 ++- .../storyideas/StoryIdeasUi.kt | 11 ++- .../common/timeline/TimeLineOverviewUi.kt | 15 ++-- .../desktopTest/kotlin/SearchFilterTest.kt | 73 +++++++++++++++++++ .../kotlin/TimeLineOverviewUiTest.kt | 36 +++++++++ 9 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/SearchFilterTest.kt diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt index ebfb4f211..1680831c4 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt @@ -244,9 +244,7 @@ class SearchProjectUseCase( private fun matchTimelineEvent(date: String?, content: String, query: String): AnnotatedSnippet? { val resolved = withDate(date, unescapeMarkdown(content)) if (query.isEmpty()) return previewSnippet(resolved) ?: EMPTY_SNIPPET - findMatch(resolved, query)?.let { return it } - if (query.contains('\\')) return findMatch(withDate(date, content), query) - return null + return findMatch(resolved, query) } private fun withDate(date: String?, content: String): String = @@ -294,15 +292,12 @@ class SearchProjectUseCase( /** * Matches stored Markdown with its escapes resolved, so a query matches the prose on screen - * and the snippet renders the same way. A query holding a backslash also tries the raw - * source, so searching for a literal escape keeps working. + * and the snippet renders the same way. The resolved text is the only thing searched, so a + * snippet can never disagree with the title derived from it. */ internal fun findMarkdownMatch(markdown: String, query: String): AnnotatedSnippet? { if (markdown.isEmpty() || query.isEmpty()) return null - val resolved = unescapeMarkdown(markdown) - findMatch(resolved, query)?.let { return it } - if (query.contains('\\')) return findMatch(markdown, query) - return null + return findMatch(unescapeMarkdown(markdown), query) } internal fun findMatch(text: String, query: String): AnnotatedSnippet? { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt index ba136a3c2..6844d7e7d 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt @@ -38,6 +38,20 @@ fun parseQuery(query: String): ParsedQuery { return ParsedQuery(text = text, tags = tags) } +/** + * True when [query] appears in [content] once the stored backslash escapes are resolved, so + * `well-known` finds text stored as `well\-known`. + * + * Only escapes are resolved. Emphasis, code and link markers are compared as stored, so a phrase + * spanning them does not match; see #811. The query is taken literally, so the escaped storage form + * of a phrase does not match either. + * + * Global search needs offsets rather than a boolean, so it resolves and matches separately in + * `SearchProjectUseCase.findMarkdownMatch`. `MarkdownContainsTest` pins the two to the same answer. + */ +fun markdownContains(content: String, query: String): Boolean = + unescapeMarkdown(content).contains(query, ignoreCase = true) + /** True when every needle is contained (case-insensitively) in at least one tag. */ fun Set.matchesAllTags(needles: List): Boolean { if (needles.isEmpty()) return true diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt new file mode 100644 index 000000000..c9c997b02 --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt @@ -0,0 +1,42 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MarkdownContainsTest { + + @Test + fun `a query matches text stored with escapes`() { + assertTrue(markdownContains("A well\\-known secret", "well-known")) + assertTrue(markdownContains("Alice \\(the elder\\) waited", "Alice (the elder)")) + } + + @Test + fun `a phrase spanning an escape matches`() { + assertTrue(markdownContains("the well\\-known road home", "known road")) + } + + @Test + fun `matching is case insensitive`() { + assertTrue(markdownContains("A Well\\-Known secret", "well-known")) + } + + @Test + fun `the query is taken literally rather than as markdown`() { + // Searching the storage form is deliberately unsupported: readers type what they see. + assertFalse(markdownContains("A well\\-known secret", "well\\-known")) + } + + @Test + fun `unescaped markup is matched as it is stored`() { + assertTrue(markdownContains("The cost is 5*4 today", "5*4")) + assertTrue(markdownContains("a **Chapter** heading", "**Chapter**")) + } + + @Test + fun `text that is absent does not match`() { + assertFalse(markdownContains("A well\\-known secret", "unrelated")) + assertFalse(markdownContains("", "anything")) + } +} diff --git a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt index 37226100d..cf57b0ef3 100644 --- a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt @@ -20,6 +20,7 @@ import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneCo import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata +import com.darkrockstudios.apps.hammer.common.data.search.markdownContains import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineContainer import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository @@ -78,6 +79,29 @@ class SearchProjectUseCaseTest : BaseTest() { timeLine = timeLine, ) + @Test + fun `global search agrees with the shared match rule`() { + // Global search needs offsets so it resolves separately; the two must not drift apart. + val cases = listOf( + "A well\\-known secret" to "well-known", + "A well\\-known secret" to "well\\-known", + "A well\\-known secret" to "unrelated", + "the user_name field" to "user_name", + "The cost is 5\\*4" to "5*4", + "a **Chapter** heading" to "**Chapter**", + "a **Chapter** heading" to "Chapter heading", + "path C:\\temp here" to "C:\\temp", + ) + + for ((content, query) in cases) { + assertEquals( + markdownContains(content, query), + SearchProjectUseCase.findMarkdownMatch(content, query) != null, + "disagreement on content=<$content> query=<$query>", + ) + } + } + @Test fun `findMatch returns null for empty text or query`() { assertNull(SearchProjectUseCase.findMatch("", "foo")) @@ -403,13 +427,28 @@ class SearchProjectUseCaseTest : BaseTest() { } @Test - fun `searching for a literal backslash escape still finds it`() = runTest { + fun `searching the storage form of text is not supported`() = runTest { every { notes.getNotes() } returns listOf(note(1, "raw well\\-known marker")) - val results = createUseCase().search("well\\-known", GlobalSearchFilter.All) + val asStored = createUseCase().search("well\\-known", GlobalSearchFilter.All) + .filterIsInstance() + val asRead = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(0, asStored.size) + assertEquals(1, asRead.size) + } + + @Test + fun `title and snippet render the same way`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "Alice \\(the elder\\) is well\\-known")) + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) .filterIsInstance() assertEquals(1, results.size) + assertEquals("Alice (the elder) is well-known", results.first().title) + assertEquals("Alice (the elder) is well-known", results.first().snippet.text) } @Test diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt index 1f944c5e7..94fe778c5 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/BrowseNotesUi.kt @@ -70,7 +70,7 @@ import com.darkrockstudios.apps.hammer.common.compose.firstNonBlankLine import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent -import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown +import com.darkrockstudios.apps.hammer.common.data.search.markdownContains import com.darkrockstudios.apps.hammer.common.util.format import com.darkrockstudios.apps.hammer.notes_create_note_button import com.darkrockstudios.apps.hammer.notes_filter_all @@ -114,6 +114,10 @@ private enum class NotesSortMode( TitleAsc(Res.string.notes_sort_title_az, Res.string.notes_sort_glyph_title_az), } +/** A note matches on its body, read as prose rather than as stored Markdown. */ +internal fun noteMatchesQuery(note: NoteContent, query: String): Boolean = + markdownContains(note.content, query) + private fun NoteContent.wordCount(): Int = content.split(Regex("\\s+")).count { it.isNotBlank() } @@ -162,9 +166,8 @@ fun BrowseNotesUi( val byText = if (searchQuery.isBlank()) { state.notes } else { - state.notes.filter { - unescapeMarkdown(it.content).contains(searchQuery.trim(), ignoreCase = true) - } + val query = searchQuery.trim() + state.notes.filter { noteMatchesQuery(it, query) } } val byTags = if (activeTags.isEmpty()) { byText diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt index cd17cc595..f79b92e1c 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/storyideas/StoryIdeasUi.kt @@ -111,6 +111,7 @@ import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeaError import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeasRepository import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea import com.darkrockstudios.apps.hammer.common.data.isSuccess +import com.darkrockstudios.apps.hammer.common.data.search.markdownContains import com.darkrockstudios.apps.hammer.common.data.tagindex.TagCount import com.darkrockstudios.apps.hammer.common.util.format import com.darkrockstudios.apps.hammer.ideas_archive_button @@ -203,6 +204,11 @@ private enum class IdeasSortMode( private fun StoryIdea.wordCount(): Int = content.split(Regex("\\s+")).count { it.isNotBlank() } +/** An idea matches on its body read as prose, or on its plain-text title. */ +internal fun ideaMatchesQuery(idea: StoryIdea, query: String): Boolean = + markdownContains(idea.content, query) || + idea.title?.contains(query, ignoreCase = true) == true + private fun StoryIdea.displayTitle(): String = title ?: content.firstNonBlankLine() private fun applySort(ideas: List, mode: IdeasSortMode): List = when (mode) { @@ -288,10 +294,7 @@ private fun IdeasBrowse( val byText = if (query.isBlank()) { scopedIdeas } else { - scopedIdeas.filter { idea -> - idea.content.contains(query, ignoreCase = true) || - idea.title?.contains(query, ignoreCase = true) == true - } + scopedIdeas.filter { ideaMatchesQuery(it, query) } } val byTags = if (activeTags.isEmpty()) { byText diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt index 187dfd9c8..6829ea14d 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/TimeLineOverviewUi.kt @@ -82,7 +82,7 @@ import com.darkrockstudios.apps.hammer.common.compose.reorderable.DragDropList import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors -import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown +import com.darkrockstudios.apps.hammer.common.data.search.markdownContains import com.darkrockstudios.apps.hammer.common.data.tagindex.TagCount import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent import com.darkrockstudios.apps.hammer.timeline_filter_all @@ -100,8 +100,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.roundToInt +/** An event matches on its body read as prose, or on its plain-text date. */ +internal fun eventMatchesQuery(event: TimeLineEvent, query: String): Boolean = + markdownContains(event.content, query) || + event.date?.contains(query, ignoreCase = true) == true + const val TIME_LINE_CREATE_TAG = "Timeline Overview Create" const val TIME_LINE_LIST_TAG = "Timeline Overview List" +const val TIME_LINE_SEARCH_TAG = "Timeline Overview Search" const val EVENT_CARD_TAG = "Timeline Event Card" const val EVENT_CARD_DATE_TAG = "Timeline Event Card Date" const val EVENT_CARD_CONTENT_TAG = "Timeline Event Card Content" @@ -137,10 +143,8 @@ fun TimeLineOverviewUi( val byText = if (searchQuery.isBlank()) { events } else { - events.filter { event -> - unescapeMarkdown(event.content).contains(searchQuery.trim(), ignoreCase = true) || - event.date?.contains(searchQuery.trim(), ignoreCase = true) == true - } + val query = searchQuery.trim() + events.filter { eventMatchesQuery(it, query) } } val byTags = if (activeTags.isEmpty()) { byText @@ -190,6 +194,7 @@ fun TimeLineOverviewUi( }, collapseContentDescription = Res.string.timeline_search_close.get(), modifier = Modifier.fillMaxSize(), + testTag = TIME_LINE_SEARCH_TAG, ) } else { Row( diff --git a/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt b/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt new file mode 100644 index 000000000..b92149630 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt @@ -0,0 +1,73 @@ +import com.darkrockstudios.apps.hammer.base.IdeaId +import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea +import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent +import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent +import com.darkrockstudios.apps.hammer.common.notes.noteMatchesQuery +import com.darkrockstudios.apps.hammer.common.projectselection.storyideas.ideaMatchesQuery +import com.darkrockstudios.apps.hammer.common.timeline.eventMatchesQuery +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Each screen's match predicate, covering which fields it consults and how. A screen matching one of + * its own fields the wrong way is invisible to the shared helper's tests. + * + * These assert the predicates, not the composables that call them; `TimeLineOverviewUiTest` drives a + * screen's search field end to end. + */ +class SearchFilterTest { + + private fun note(content: String) = + NoteContent(id = 1, created = Clock.System.now(), content = content) + + private fun event(content: String, date: String? = null) = + TimeLineEvent(id = 1, order = 0, date = date, content = content) + + private fun idea(content: String, title: String? = null) = StoryIdea( + id = IdeaId("00000000-0000-0000-0000-000000000001"), + created = Instant.parse("2026-07-01T12:00:00Z"), + updated = Instant.parse("2026-07-01T12:00:00Z"), + title = title, + content = content, + archived = null, + ) + + @Test + fun `notes match a query across a stored escape`() { + assertTrue(noteMatchesQuery(note("A well\\-known secret"), "well-known")) + assertFalse(noteMatchesQuery(note("A well\\-known secret"), "unrelated")) + } + + @Test + fun `timeline events match on the body across a stored escape`() { + assertTrue(eventMatchesQuery(event("A well\\-known war"), "well-known")) + } + + @Test + fun `timeline events match on the plain-text date as stored`() { + assertTrue(eventMatchesQuery(event("A long war", date = "1990-2000"), "1990-2000")) + // The date is not Markdown, so it is matched exactly as the user typed it. + assertTrue(eventMatchesQuery(event("A long war", date = "1990\\-2000"), "1990\\-2000")) + } + + @Test + fun `story ideas match on the body across a stored escape`() { + assertTrue(ideaMatchesQuery(idea("A well\\-known twist"), "well-known")) + } + + @Test + fun `story ideas match on the plain-text title`() { + assertTrue(ideaMatchesQuery(idea("body text", title = "The well-known twist"), "well-known")) + assertFalse(ideaMatchesQuery(idea("body text", title = "Something else"), "well-known")) + } + + @Test + fun `literal markup is not rewritten by any screen filter`() { + assertTrue(noteMatchesQuery(note("The cost is 5*4"), "5*4")) + assertTrue(eventMatchesQuery(event("the user_name field"), "user_name")) + assertTrue(ideaMatchesQuery(idea("a **Chapter** heading"), "**Chapter**")) + } +} diff --git a/composeUi/src/desktopTest/kotlin/TimeLineOverviewUiTest.kt b/composeUi/src/desktopTest/kotlin/TimeLineOverviewUiTest.kt index 3618c8895..40998d90f 100644 --- a/composeUi/src/desktopTest/kotlin/TimeLineOverviewUiTest.kt +++ b/composeUi/src/desktopTest/kotlin/TimeLineOverviewUiTest.kt @@ -136,6 +136,42 @@ class TimeLineOverviewUiTest : BaseTest() { } */ + @OptIn(ExperimentalTestApi::class, ExperimentalSharedTransitionApi::class) + @Test + fun `Search filters the list on the prose a reader sees`() { + val data = TimeLineOverview.State( + timeLine = TimeLineContainer( + events = listOf( + TimeLineEvent(id = 0, order = 0, date = null, content = "A well\\-known road"), + TimeLineEvent(id = 1, order = 1, date = null, content = "An unrelated event"), + ) + ) + ) + val component = componentSetup(data) + + compose.setContent { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + TimeLineOverviewUi( + component = component, + scope = scope, + showCreate = {}, + viewEvent = {}, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@AnimatedVisibility, + ) + } + } + } + + compose.onNodeWithContentDescription("Search timeline").performClick() + compose.onNodeWithTag(TIME_LINE_SEARCH_TAG).performTextInput("well-known") + + // The query never contains the stored escape, so only resolving the content can match it. + compose.onAllNodesWithTag(EVENT_CARD_TAG).assertCountEquals(1) + compose.onNodeWithText("An unrelated event").assertDoesNotExist() + } + @OptIn(ExperimentalSharedTransitionApi::class) @Test fun `Event Card Click`() { From a523cd521b52b0f49f7ff2b9a43c1f615130370f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 2 Aug 2026 23:19:38 -0700 Subject: [PATCH 022/213] Float the scroll-away footers over their lists (#834) The Outline Overview strip was a sibling in the scene list's Column, so showing and hiding it resized the LazyColumn. Combined with the rule that pins it open once the list bottoms out, that formed a feedback loop: the strip appears, the list shrinks, the list can scroll forward again, the strip hides, the list grows, and it repeats every frame. Fixes #827. Extract HdScrollAwayFooter, which hosts the strip as an overlay in the same Box as the list and reports its measured height for the list's bottom content padding, so the last row still clears it. Hiding now waits until the list has consumed more downward scroll than the strip's own height, which keeps a list whose only scroll room is that padding from hiding and immediately re-pinning. Apply it to the project list's create bar too, which had the same resize-on-hide shape. * Hide the scroll-away footer on direction, not distance The distance threshold reset on any upward pixel, so the jitter in a real drag kept it from ever accumulating and the strip hid only sometimes. Responsiveness matters more here than the narrow case it was guarding: a list whose only scroll room is the strip's own bottom padding can still hide the strip and re-pin it at the bottom. --- .../compose/designsystem/DESIGN_README.md | 10 ++ .../designsystem/HdScrollAwayFooter.kt | 135 +++++++++++++++ .../common/projectselection/ProjectListUi.kt | 83 ++++----- .../storyeditor/scenelist/SceneListUi.kt | 163 ++++++++---------- .../designsystem/DesignSystem.Preview.kt | 47 +++++ .../projectselection/ProjectListUi.Preview.kt | 36 +++- .../kotlin/HdScrollAwayFooterTest.kt | 99 +++++++++++ .../kotlin/ProjectListFooterTest.kt | 87 ++++++++++ .../desktopTest/kotlin/SceneListFooterTest.kt | 141 +++++++++++++++ 9 files changed, 651 insertions(+), 150 deletions(-) create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdScrollAwayFooter.kt create mode 100644 composeUi/src/desktopTest/kotlin/HdScrollAwayFooterTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/ProjectListFooterTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md index 86e253305..3f017236e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md @@ -208,6 +208,16 @@ These set up the page. Use them before reaching for raw `Column` / │ └─[ KTOR · HTTPS ]─────────[ LAST SYNC 14:32 ]┘ │ └──────────────────────────────────────────────────┘ +- **[`HdScrollAwayFooter`](HdScrollAwayFooter.kt)**: hairline action + strip that floats over the bottom of a scrolling pane and slides away + as the reader scrolls down. It is an **overlay**, never a sibling in + a `Column`: host it in the same `Box` as the list, wire + `state.nestedScrollConnection` to the list, and pass `state.height` + as the list's bottom `contentPadding` so the last row clears the + strip. A footer that takes layout space changes what the list can + scroll, which flips any visibility rule keyed off scroll position and + flickers the strip on and off. + ### Mono & numeric labels The handwriting of the system. Reach for these instead of styling diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdScrollAwayFooter.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdScrollAwayFooter.kt new file mode 100644 index 000000000..9ce1b0bb9 --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdScrollAwayFooter.kt @@ -0,0 +1,135 @@ +package com.darkrockstudios.apps.hammer.common.compose.designsystem + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.Ui + +/** + * Tracks the scroll-away state of a [HdScrollAwayFooter]: wire + * [nestedScrollConnection] to the scrolling content and feed [height] into + * that content's bottom `contentPadding` so its last row can clear the strip. + */ +@Stable +class HdScrollAwayFooterState internal constructor() { + var isHiddenByScroll by mutableStateOf(false) + private set + + /** + * Measured height of the footer strip. Retained while the footer is + * hidden, so the list's bottom padding never changes. + */ + var height by mutableStateOf(0.dp) + internal set + + /** + * Reacts to raw scroll direction, not accumulated distance. Any + * distance threshold has to survive the jitter in a real drag, and + * the reveal has to feel immediate, so direction wins here. + */ + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (available.y < -1f) isHiddenByScroll = true + else if (available.y > 1f) isHiddenByScroll = false + return Offset.Zero + } + } +} + +@Composable +fun rememberHdScrollAwayFooterState(): HdScrollAwayFooterState = remember { HdScrollAwayFooterState() } + +/** + * Hairline action strip that floats over the bottom of a scrolling pane and + * slides away as the reader scrolls down. + * + * ┌───────────────────────┐ + * │ list row │ + * │ list row │ + * ├───────────────────────┤ + * │ [ ACTION ] + │ + * └───────────────────────┘ + * + * The strip is an **overlay**, never a sibling in a `Column`: hiding it must + * not resize the list. A footer that takes layout space changes what the list + * can scroll, which flips any visibility rule keyed off scroll position and + * flickers the strip on and off. + * + * Host it in the same `Box` as the scrolling content, and pass + * [HdScrollAwayFooterState.height] as that content's bottom `contentPadding`. + * The host `Box` should `clipToBounds()` so the strip disappears at the pane + * edge as it slides out. + * + * [visible] defaults to the scroll-away rule; widen it to pin the strip open + * (e.g. `!listState.canScrollForward || !state.isHiddenByScroll` keeps it + * reachable once the list bottoms out). + */ +@Composable +fun BoxScope.HdScrollAwayFooter( + state: HdScrollAwayFooterState, + modifier: Modifier = Modifier, + visible: Boolean = !state.isHiddenByScroll, + containerColor: Color = MaterialTheme.colorScheme.surface, + dividerColor: Color = MaterialTheme.colorScheme.outlineVariant, + contentPadding: PaddingValues = PaddingValues(Ui.Padding.M), + content: @Composable RowScope.() -> Unit, +) { + val density = LocalDensity.current + AnimatedVisibility( + visible = visible, + modifier = modifier.align(Alignment.BottomCenter), + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(containerColor) + .onSizeChanged { size -> + if (size.height > 0) { + state.height = with(density) { size.height.toDp() } + } + }, + ) { + HorizontalDivider( + thickness = Dp.Hairline, + color = dividerColor, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(contentPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M), + content = content, + ) + } + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt index cd9c8a20b..390c1de03 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt @@ -1,15 +1,12 @@ package com.darkrockstudios.apps.hammer.common.projectselection import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -40,10 +37,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontStyle @@ -66,11 +61,14 @@ import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooter +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooterState import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchRow import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSortMenu import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSortOption import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagChip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdToolButton +import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdScrollAwayFooterState import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.data.search.parseQuery @@ -116,6 +114,7 @@ private val NarrowContentPadding: Dp = Ui.Padding.XL /** Tags the "Create Project" affordance (masthead button when wide, bottom bar when narrow). */ const val CreateProjectButtonTestTag = "create-project-button" +const val ProjectListTestTag = "project-list" internal enum class ProjectsSortMode( override val labelRes: StringResource, @@ -191,18 +190,11 @@ fun ProjectListUi( } } - // Same scroll-away pattern as the scene list outline button: hide the - // bar when the user scrolls down, reveal it on any upward scroll. - var createBarVisible by remember { mutableStateOf(true) } - val scrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (available.y < -1f) createBarVisible = false - else if (available.y > 1f) createBarVisible = true - return Offset.Zero - } - } - } + val listState: LazyListState = rememberLazyListState() + val createBarState = rememberHdScrollAwayFooterState() + // Always reachable when the list can't scroll down to it. + val createBarVisible = + !isWide && (!listState.canScrollForward || !createBarState.isHiddenByScroll) Column( modifier = modifier.fillMaxSize(), @@ -249,14 +241,17 @@ fun ProjectListUi( ColumnHeader(showLastOpen = isWide) - Box(modifier = Modifier.weight(1f)) { - val listState: LazyListState = rememberLazyListState() + Box(modifier = Modifier.weight(1f).clipToBounds()) { LazyColumn( modifier = Modifier .fillMaxSize() - .nestedScroll(scrollConnection), + .testTag(ProjectListTestTag) + .nestedScroll(createBarState.nestedScrollConnection), state = listState, - contentPadding = PaddingValues(end = MpScrollBarGutter), + contentPadding = PaddingValues( + end = MpScrollBarGutter, + bottom = if (isWide) 0.dp else createBarState.height, + ), ) { if (visibleProjects.isEmpty()) { item(key = "empty") { @@ -287,19 +282,13 @@ fun ProjectListUi( modifier = scrollBarOverlay(), state = listState, ) - } - if (!isWide) { - AnimatedVisibility( + BottomCreateBar( + state = createBarState, visible = createBarVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - ) { - BottomCreateBar( - onCreate = component::showCreate, - horizontalPadding = horizontalPadding, - ) - } + onCreate = component::showCreate, + horizontalPadding = horizontalPadding, + ) } FooterFolio( @@ -603,25 +592,25 @@ private fun FooterFolio( } @Composable -private fun BottomCreateBar( +private fun BoxScope.BottomCreateBar( + state: HdScrollAwayFooterState, + visible: Boolean, onCreate: () -> Unit, horizontalPadding: Dp, ) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outline, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(horizontal = horizontalPadding, vertical = Ui.Padding.L), - contentAlignment = Alignment.Center, + HdScrollAwayFooter( + state = state, + visible = visible, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dividerColor = MaterialTheme.colorScheme.outline, + contentPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = Ui.Padding.L, + ), ) { Box( modifier = Modifier - .fillMaxWidth() + .weight(1f) .height(44.dp) .testTag(CreateProjectButtonTestTag) .background(MaterialTheme.colorScheme.primary) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt index adf112a48..20236bd17 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt @@ -1,10 +1,5 @@ package com.darkrockstudios.apps.hammer.common.storyeditor.scenelist -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -46,14 +41,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.archived_scenes_restored_snackbar @@ -63,6 +55,8 @@ import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooter +import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdScrollAwayFooterState import com.darkrockstudios.apps.hammer.common.compose.rememberMainDispatcher import com.darkrockstudios.apps.hammer.common.compose.rememberStrRes import com.darkrockstudios.apps.hammer.common.compose.resources.get @@ -101,6 +95,7 @@ const val SCENE_LIST_ADD_BUTTON_TAG = "scene-list-add" const val SCENE_LIST_ADD_SCENE_TAG = "scene-list-add-scene" const val SCENE_LIST_ADD_GROUP_TAG = "scene-list-add-group" const val CREATE_ITEM_NAME_FIELD_TAG = "create-item-name-field" +const val SCENE_LIST_TREE_TAG = "scene-list-tree" @OptIn( ExperimentalMaterialApi::class, @@ -151,26 +146,19 @@ fun SceneListUi( // TODO implement a real NUX system val shouldNux = remember { Random.nextInt(0, 9) == 0 } - var hiddenByScroll by remember { mutableStateOf(false) } - val scrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (available.y < -1f) hiddenByScroll = true - else if (available.y > 1f) hiddenByScroll = false - return Offset.Zero - } - } - } + val footerState = rememberHdScrollAwayFooterState() // Always reachable when the list can't scroll down to it (e.g. all groups collapsed). - val footerVisible = !treeState.listState.canScrollForward || !hiddenByScroll + val footerVisible = !treeState.listState.canScrollForward || !footerState.isHiddenByScroll + + val surfaceColor = if (inSplitPane) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.surface + } Surface( modifier = modifier, - color = if (inSplitPane) { - MaterialTheme.colorScheme.surfaceContainerLow - } else { - MaterialTheme.colorScheme.surface - }, + color = surfaceColor, ) { Column(modifier = Modifier.fillMaxSize()) { val (sceneCount, groupCount) = remember(state.sceneSummary) { @@ -200,9 +188,10 @@ fun SceneListUi( color = MaterialTheme.colorScheme.outlineVariant, ) - Box(modifier = Modifier.weight(1f)) { + Box(modifier = Modifier.weight(1f).clipToBounds()) { SceneTree( - modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection), + modifier = Modifier.fillMaxSize().testTag(SCENE_LIST_TREE_TAG) + .nestedScroll(footerState.nestedScrollConnection), state = treeState, itemUi = { node: TreeValue, toggleExpanded: (nodeId: Int) -> Unit, @@ -234,76 +223,62 @@ fun SceneListUi( createGroup = { parent -> showCreateGroupDialog = parent }, ) }, - contentPadding = PaddingValues(bottom = 80.dp) + contentPadding = PaddingValues(bottom = footerState.height) ) - } - AnimatedVisibility( - visible = footerVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - ) { - Column { - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, + HdScrollAwayFooter( + state = footerState, + visible = footerVisible, + containerColor = surfaceColor, + ) { + HdHairlineButton( + label = Res.string.scene_list_outline_overview_button.get(), + onClick = component::showOutlineOverview, + modifier = Modifier.weight(1f), ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Ui.Padding.M, vertical = Ui.Padding.M), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M), - ) { - HdHairlineButton( - label = Res.string.scene_list_outline_overview_button.get(), - onClick = component::showOutlineOverview, - modifier = Modifier.weight(1f), - ) - Box { - IconButton( - onClick = { addMenuOpen = true }, - modifier = Modifier.testTag(SCENE_LIST_ADD_BUTTON_TAG), - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = Res.string.scene_list_add_button.get(), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu( - expanded = addMenuOpen, - onDismissRequest = { addMenuOpen = false }, - ) { - DropdownMenuItem( - modifier = Modifier.testTag(SCENE_LIST_ADD_SCENE_TAG), - text = { Text(Res.string.scene_list_create_menu_scene.get()) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.PostAdd, - contentDescription = null, - ) - }, - onClick = { - addMenuOpen = false - showCreateSceneDialog = treeState.summary.sceneTree.root.value - }, - ) - DropdownMenuItem( - modifier = Modifier.testTag(SCENE_LIST_ADD_GROUP_TAG), - text = { Text(Res.string.scene_list_create_menu_group.get()) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.CreateNewFolder, - contentDescription = null, - ) - }, - onClick = { - addMenuOpen = false - showCreateGroupDialog = treeState.summary.sceneTree.root.value - }, - ) - } + Box { + IconButton( + onClick = { addMenuOpen = true }, + modifier = Modifier.testTag(SCENE_LIST_ADD_BUTTON_TAG), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = Res.string.scene_list_add_button.get(), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu( + expanded = addMenuOpen, + onDismissRequest = { addMenuOpen = false }, + ) { + DropdownMenuItem( + modifier = Modifier.testTag(SCENE_LIST_ADD_SCENE_TAG), + text = { Text(Res.string.scene_list_create_menu_scene.get()) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.PostAdd, + contentDescription = null, + ) + }, + onClick = { + addMenuOpen = false + showCreateSceneDialog = treeState.summary.sceneTree.root.value + }, + ) + DropdownMenuItem( + modifier = Modifier.testTag(SCENE_LIST_ADD_GROUP_TAG), + text = { Text(Res.string.scene_list_create_menu_group.get()) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.CreateNewFolder, + contentDescription = null, + ) + }, + onClick = { + addMenuOpen = false + showCreateGroupDialog = treeState.summary.sceneTree.root.value + }, + ) } } } diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt index e812eea44..fb4cbaace 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt @@ -4,21 +4,27 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdAttributionItem @@ -54,6 +60,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdPlainSectio import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdReferenceChip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdReferenceChipVariant import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResponsiveStrip +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooter import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchField import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSectionHeader import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdStatBlock @@ -61,6 +68,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTagChip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTypeStamp import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdTypographicHero import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdUnsavedBadge +import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdScrollAwayFooterState import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType @@ -580,6 +588,45 @@ fun BottomBarPreview() { } } +@Preview(widthDp = 360, heightDp = 280) +@Composable +fun ScrollAwayFooterPreview() { + AppTheme(globalSettingsPreview, useDarkTheme = true) { + val footerState = rememberHdScrollAwayFooterState() + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(footerState.nestedScrollConnection), + contentPadding = PaddingValues(bottom = footerState.height), + ) { + items(12) { index -> + Text( + text = "Scene ${index + 1}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } + } + + HdScrollAwayFooter(state = footerState) { + HdHairlineButton( + label = "Outline Overview", + onClick = {}, + modifier = Modifier.weight(1f), + ) + HdHairlineButton(label = "+", onClick = {}) + } + } + } +} + @Preview @Composable fun FabPreview() { diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ProjectListUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ProjectListUi.Preview.kt index 6f0b48477..3673fb5d1 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ProjectListUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projectselection/ProjectListUi.Preview.kt @@ -29,6 +29,15 @@ import kotlinx.coroutines.flow.MutableSharedFlow import org.jetbrains.compose.resources.StringResource import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData as StoredData +@Preview +@Composable +fun ScreenProjectListUiPreview() { + val rootSnackbar = rememberRootSnackbarHostState() + KoinApplicationPreview { + ProjectListUi(previewComponent, rootSnackbar) + } +} + @Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_HEIGHT_DP) @Composable fun ScreenProjectListUiTabletPreview() { @@ -40,22 +49,31 @@ fun ScreenProjectListUiTabletPreview() { } } -private fun previewProject(name: String) = ProjectData( +fun previewProject(name: String, tags: Set = setOf("fantasy", "draft")) = ProjectData( definition = ProjectDef(name = name, path = HPath(name = name, path = "/$name", isAbsolute = true)), metadata = fakeProjectMetadata(), - storedData = StoredData(authorName = "A. Writer", tags = setOf("fantasy", "draft")), + storedData = StoredData(authorName = "A. Writer", tags = tags), ) -private val previewComponent = object : ProjectsList { +private val previewComponent = fakeProjectsList( + listOf( + previewProject("The Lighthouse"), + previewProject("Wonderland"), + previewProject("Salt & Ash"), + ), + isServerSynced = true, +) + +/** Shared by the previews above and the composeUi footer tests. */ +fun fakeProjectsList( + projects: List, + isServerSynced: Boolean = false, +) = object : ProjectsList { override val state: Value = MutableValue( ProjectsList.State( - projects = listOf( - previewProject("The Lighthouse"), - previewProject("Wonderland"), - previewProject("Salt & Ash"), - ), + projects = projects, projectsPath = fakeProjectDef().path, - isServerSynced = true, + isServerSynced = isServerSynced, ) ) override val modalRouterState: Value> = diff --git a/composeUi/src/desktopTest/kotlin/HdScrollAwayFooterTest.kt b/composeUi/src/desktopTest/kotlin/HdScrollAwayFooterTest.kt new file mode 100644 index 000000000..6977b06a5 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/HdScrollAwayFooterTest.kt @@ -0,0 +1,99 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooter +import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdScrollAwayFooterState +import org.junit.Rule +import org.junit.Test + +private const val LIST_TAG = "list" +private const val ACTION_TAG = "footer-action" +private val PaneHeight = 300.dp +private val RowHeight = 30.dp +private val ActionHeight = 44.dp + +class HdScrollAwayFooterTest { + + @get:Rule + val compose = createComposeRule() + + private fun showFooter(rowCount: Int) { + compose.setContent { + val footerState = rememberHdScrollAwayFooterState() + val listState = rememberLazyListState() + Box(modifier = Modifier.size(400.dp, PaneHeight).clipToBounds()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(LIST_TAG) + .nestedScroll(footerState.nestedScrollConnection), + state = listState, + contentPadding = PaddingValues(bottom = footerState.height), + ) { + items(rowCount) { index -> TestRow(index) } + } + + HdScrollAwayFooter( + state = footerState, + visible = !listState.canScrollForward || !footerState.isHiddenByScroll, + ) { + Box(modifier = Modifier.weight(1f).height(ActionHeight).testTag(ACTION_TAG)) + } + } + } + } + + @Test + fun `Footer hides on a downward scroll`() { + showFooter(rowCount = 40) + + compose.onNodeWithTag(LIST_TAG).performTouchInput { swipeUp() } + + compose.onNodeWithTag(ACTION_TAG).assertDoesNotExist() + } + + @Test + fun `Footer is pinned once the list bottoms out`() { + showFooter(rowCount = 40) + + compose.onNodeWithTag(LIST_TAG).performScrollToIndex(39) + + compose.onNodeWithTag(ACTION_TAG).assertIsDisplayed() + } + + @Test + fun `Footer returns on an upward scroll`() { + showFooter(rowCount = 40) + + compose.onNodeWithTag(LIST_TAG).performTouchInput { swipeUp() } + compose.onNodeWithTag(LIST_TAG).performTouchInput { swipeDown() } + + compose.onNodeWithTag(ACTION_TAG).assertIsDisplayed() + } +} + +@Composable +private fun TestRow(index: Int) { + Box(modifier = Modifier.fillMaxWidth().height(RowHeight)) { + HdMonoLabel(text = "ROW $index") + } +} diff --git a/composeUi/src/desktopTest/kotlin/ProjectListFooterTest.kt b/composeUi/src/desktopTest/kotlin/ProjectListFooterTest.kt new file mode 100644 index 000000000..b9e246bf5 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/ProjectListFooterTest.kt @@ -0,0 +1,87 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import com.darkrockstudios.apps.hammer.common.preview.projectselection.fakeProjectsList +import com.darkrockstudios.apps.hammer.common.preview.projectselection.previewProject +import com.darkrockstudios.apps.hammer.common.projectselection.CreateProjectButtonTestTag +import com.darkrockstudios.apps.hammer.common.projectselection.ProjectListTestTag +import com.darkrockstudios.apps.hammer.common.projectselection.ProjectListUi +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertTrue + +private const val PANE_TAG = "test-pane" +private const val PROJECT_COUNT = 20 + +/** The phone layout: [ProjectListUi] only shows the create bar at Compact width. */ +class ProjectListFooterTest : BaseTest() { + + @get:Rule + val compose = createComposeRule() + + private fun showProjectList() { + compose.setContent { + KoinApplicationPreview { + Box(modifier = Modifier.testTag(PANE_TAG).size(360.dp, 640.dp)) { + ProjectListUi( + component = fakeProjectsList(List(PROJECT_COUNT) { testProject(it) }), + rootSnackbar = rememberRootSnackbarHostState(), + ) + } + } + } + } + + private fun SemanticsNodeInteraction.bounds() = fetchSemanticsNode().boundsInRoot + + @Test + fun `Project list fills its pane beneath the create bar`() { + showProjectList() + + val list = compose.onNodeWithTag(ProjectListTestTag).bounds() + val createBar = compose.onNodeWithTag(CreateProjectButtonTestTag).bounds() + + assertTrue( + list.bottom >= createBar.bottom, + "Create bar must overlay the list, not shrink it: list bottom ${list.bottom}, bar bottom ${createBar.bottom}", + ) + } + + @Test + fun `Last project clears the create bar when scrolled to the bottom`() { + showProjectList() + + compose.onNodeWithTag(ProjectListTestTag).performScrollToIndex(PROJECT_COUNT - 1) + + val createBar = compose.onNodeWithTag(CreateProjectButtonTestTag).bounds() + val lastProject = compose.onNodeWithText(projectName(PROJECT_COUNT - 1)).bounds() + + assertTrue( + lastProject.bottom <= createBar.top + 1f, + "Last project must scroll clear of the create bar: row bottom ${lastProject.bottom}, bar top ${createBar.top}", + ) + } + + @Test + fun `Create bar stays visible at the bottom of the list`() { + showProjectList() + + compose.onNodeWithTag(ProjectListTestTag).performScrollToIndex(PROJECT_COUNT - 1) + + compose.onNodeWithTag(CreateProjectButtonTestTag).assertIsDisplayed() + } +} + +private fun projectName(index: Int) = "Project ${index + 1}" + +private fun testProject(index: Int) = previewProject(projectName(index), tags = emptySet()) diff --git a/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt b/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt new file mode 100644 index 000000000..24f95b128 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt @@ -0,0 +1,141 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.darkrockstudios.apps.hammer.common.components.storyeditor.scenelist.SceneList +import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.data.MoveRequest +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.SceneBuffer +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.SceneSummary +import com.darkrockstudios.apps.hammer.common.data.tree.Tree +import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SCENE_LIST_ADD_BUTTON_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SCENE_LIST_TREE_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SceneListUi +import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.sceneItemTag +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertTrue + +private const val PANE_TAG = "test-pane" +private const val SCENE_COUNT = 40 + +class SceneListFooterTest : BaseTest() { + + @get:Rule + val compose = createComposeRule() + + private fun showSceneList(paneHeight: Dp = 600.dp) { + compose.setContent { + KoinApplicationPreview { + Box(modifier = Modifier.testTag(PANE_TAG).size(400.dp, paneHeight)) { + SceneListUi( + component = fakeComponent(), + snackbarHostState = rememberRootSnackbarHostState(), + ) + } + } + } + } + + private fun SemanticsNodeInteraction.bounds() = fetchSemanticsNode().boundsInRoot + + @Test + fun `Scene list fills the pane beneath the footer`() { + showSceneList() + + val pane = compose.onNodeWithTag(PANE_TAG).bounds() + val tree = compose.onNodeWithTag(SCENE_LIST_TREE_TAG).bounds() + + assertTrue( + tree.bottom >= pane.bottom - 1f, + "Footer must overlay the list, not shrink it: tree bottom ${tree.bottom}, pane bottom ${pane.bottom}", + ) + } + + @Test + fun `Last scene clears the footer when scrolled to the bottom`() { + showSceneList() + + compose.onNodeWithTag(SCENE_LIST_TREE_TAG).performScrollToIndex(SCENE_COUNT - 1) + + val footer = compose.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).bounds() + val lastScene = compose.onNodeWithTag(sceneItemTag(SCENE_COUNT)).bounds() + + assertTrue( + lastScene.bottom <= footer.top + 1f, + "Last scene must scroll clear of the footer: scene bottom ${lastScene.bottom}, footer top ${footer.top}", + ) + } + + @Test + fun `Footer stays visible at the bottom of the list`() { + showSceneList() + + compose.onNodeWithTag(SCENE_LIST_TREE_TAG).performScrollToIndex(SCENE_COUNT - 1) + + compose.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).assertIsDisplayed() + } +} + +private fun testProjectDef() = ProjectDef( + name = "Test", + path = HPath(name = "Test", path = "/", isAbsolute = true), +) + +private fun testScene(id: Int, order: Int, type: SceneItem.Type = SceneItem.Type.Scene) = SceneItem( + projectDef = testProjectDef(), + type = type, + id = id, + name = "Test Scene $id", + order = order, +) + +private fun testSceneSummary(): SceneSummary { + val tree = Tree() + val root = TreeNode(testScene(0, 0, SceneItem.Type.Root)) + for (i in 1..SCENE_COUNT) { + root.addChild(TreeNode(testScene(i, i - 1))) + } + tree.setRoot(root) + + return SceneSummary(tree.toImmutableTree(), persistentSetOf()) +} + +private fun fakeComponent() = object : SceneList { + override val state: Value = MutableValue( + SceneList.State( + projectDef = testProjectDef(), + sceneSummary = testSceneSummary(), + ) + ) + + override fun onSceneSelected(sceneDef: SceneItem) {} + override suspend fun moveScene(moveRequest: MoveRequest) {} + override fun loadScenes() {} + override suspend fun createScene(parent: SceneItem?, sceneName: String) {} + override suspend fun createGroup(parent: SceneItem?, groupName: String) {} + override suspend fun deleteScene(scene: SceneItem) {} + override suspend fun renameScene(scene: SceneItem, newName: String): Boolean = false + override fun onSceneListUpdate(scenes: SceneSummary) {} + override fun onSceneBufferUpdate(sceneBuffer: SceneBuffer) {} + override fun showOutlineOverview() {} + override suspend fun archiveScene(scene: SceneItem) {} + override suspend fun unarchiveScene(scene: SceneItem) {} + override fun showArchivedScenes() {} + override fun dismissArchivedDialog() {} +} From 83c8d4feab79d3269ea0d2ea7b095922b8b2ff6a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 21:20:12 -0700 Subject: [PATCH 023/213] Fix scene drag grabbing the wrong row (#839) * Fix scene drag grabbing the wrong row The drag gesture resolves the pressed row against LazyListLayoutInfo.visibleItemsInfo, which reports each item's target offset. With animateItem() sliding rows into place, a row is drawn somewhere other than where layoutInfo says it is, so a press right after a move grabs whichever row now owns that target slot: the one below. Drop the placement animation so the layout geometry the whole drag stack reads is the geometry on screen. Fades on add/remove are kept. * Resolve the grabbed row by hit test so placement can animate * Scope the drag candidate to the pointer that claimed it --- .../scenelist/scenetree/SceneTree.kt | 45 +++- .../scenelist/scenetree/SceneTreeState.kt | 30 +++ .../desktopTest/kotlin/SceneTreeDragTest.kt | 253 ++++++++++++++++++ 3 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt index 9cd27646f..522097d6d 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.scenetree import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -22,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextAlign @@ -73,7 +76,8 @@ fun SceneTree( toggleExpanded = state::toggleExpanded, modifier = Modifier.wrapContentHeight() .fillMaxWidth() - .animateItem(), + .animateItem() + .pressCandidate(state, node.value.id), itemUi = itemUi ) } @@ -85,20 +89,43 @@ fun SceneTree( } } +/** + * Records this row as the drag candidate while it is pressed. + * + * Thar be dragons: resolving the row from [LazyListLayoutInfo] instead looks equivalent but is + * not. Its offsets are where items are headed, not where they are drawn, so a press landing + * during a placement animation grabs whichever row now owns that target slot. Compose's own hit + * test uses the drawn position, so it stays right mid-animation. + */ +private fun Modifier.pressCandidate(state: SceneTreeState, id: Int): Modifier = + pointerInput(id) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!state.claimPress(down.id, id)) return@awaitEachGesture + + // The release must survive this row being disposed mid-press, which cancels us. + try { + var change: PointerInputChange? + do { + change = awaitPointerEvent().changes.firstOrNull { it.id == down.id } + } while (change?.pressed == true) + } finally { + state.releasePress(down.id) + } + } + } + @Composable private fun Modifier.reorderableModifier(state: SceneTreeState): Modifier { val hapticFeedback = LocalHapticFeedback.current state.apply { return pointerInput(Unit) { detectDragGesturesAfterLongPress( - onDragStart = { offset -> - for (itemInfo in listState.layoutInfo.visibleItemsInfo) { - if (offset.y >= itemInfo.offset && offset.y <= (itemInfo.offset + itemInfo.size)) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - val id = itemInfo.key as Int - startDragging(id) - break - } + onDragStart = { + val id = pressedRowId + if (id != SceneTreeState.NO_SELECTION) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + startDragging(id) } }, onDragCancel = { diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt index 500c1765b..3bd0378d7 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.input.pointer.PointerId import com.darkrockstudios.apps.hammer.common.data.InsertPosition import com.darkrockstudios.apps.hammer.common.data.MoveRequest import com.darkrockstudios.apps.hammer.common.data.SceneItem @@ -75,6 +76,35 @@ class SceneTreeState( visibleSceneNodes(summary.sceneTree, collapsedNodes) } + /** + * Row this press started on, recorded by the rows themselves so it reflects the drawn + * position rather than a layout target. + * Deliberately not snapshot state: it changes on every press and must not recompose the tree. + */ + internal var pressedRowId: Int = NO_SELECTION + private set + + private var pressedPointer: PointerId? = null + + /** + * Records [rowId] as the press target, but only for the first pointer down. The drag detector + * follows that same pointer, so later fingers must not be able to retarget the drag. + */ + internal fun claimPress(pointer: PointerId, rowId: Int): Boolean { + if (pressedPointer != null) return false + + pressedPointer = pointer + pressedRowId = rowId + return true + } + + internal fun releasePress(pointer: PointerId) { + if (pressedPointer != pointer) return + + pressedPointer = null + pressedRowId = NO_SELECTION + } + private var scrollJob by mutableStateOf(null) private var treeHash by mutableStateOf(sceneSummary.sceneTree.hashCode()) diff --git a/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt new file mode 100644 index 000000000..ce2c42a33 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt @@ -0,0 +1,253 @@ +package com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.scenetree + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.data.MoveRequest +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.SceneSummary +import com.darkrockstudios.apps.hammer.common.data.tree.Tree +import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val TREE_TAG = "tree" +private val RowHeight = 30.dp +private fun rowTag(id: Int) = "row-$id" + +/** + * Tree shape, mirroring the report in issue #837: + * + * 0 Root + * ├─ 1 Preface + * ├─ 2 Chapter 1 ─ 3 The Door Opens, 4 The Discovery + * ├─ 5 Chapter 2 ─ 6 The Vacuum, 7 Exploration + * ├─ 8 Chapter 3 ─ 9 More Cameras, 10 Verification, 11 Mouse Trap + * ├─ 12 Chapter 4 ─ 13 Wildlife Expert + * ├─ 14 Chapter 5 (empty) + * └─ 15 Water, 16 Ghost Child, 17 Skeleton, 18 Bowl of Water, 19 Overview + */ +private object Ids { + const val PREFACE = 1 + const val CH1 = 2 + const val DOOR_OPENS = 3 + const val DISCOVERY = 4 + const val CH2 = 5 + const val VACUUM = 6 + const val EXPLORATION = 7 + const val CH3 = 8 + const val MORE_CAMERAS = 9 + const val VERIFICATION = 10 + const val MOUSE_TRAP = 11 + const val CH4 = 12 + const val WILDLIFE = 13 + const val CH5 = 14 + const val WATER = 15 + const val GHOST_CHILD = 16 + const val SKELETON = 17 + const val BOWL = 18 + const val OVERVIEW = 19 +} + +class SceneTreeDragTest { + + @get:Rule + val compose = createComposeRule() + + private var moveRequest: MoveRequest? = null + private lateinit var treeState: SceneTreeState + private var summary by mutableStateOf(buildSummary(camerasAtRoot = false)) + + private fun showTree() { + compose.setContent { + val state = rememberReorderableLazyListState( + summary = summary, + moveItem = { moveRequest = it }, + ) + treeState = state + LaunchedEffect(summary) { state.updateSummary(summary) } + + Box(modifier = Modifier.size(400.dp, 800.dp)) { + SceneTree( + state = state, + modifier = Modifier.fillMaxSize().testTag(TREE_TAG), + itemUi = { node, _, _, draggable -> + Box( + modifier = draggable.fillMaxWidth().height(RowHeight) + .testTag(rowTag(node.value.id)) + ) + }, + contentPadding = PaddingValues(), + ) + } + } + } + + /** Where the row actually sits on screen, which is where the user aims. */ + private fun visualCenterOf(id: Int): Offset { + val bounds = compose.onNodeWithTag(rowTag(id)).getBoundsInRoot() + return with(compose.density) { + Offset(50f, (bounds.top + (bounds.bottom - bounds.top) / 2).toPx()) + } + } + + /** The tree is taller than its rows, so this lands on the list but on no row. */ + private fun emptySpaceBelowRows(): Offset { + val lastRow = compose.onNodeWithTag(rowTag(Ids.OVERVIEW)).getBoundsInRoot() + return with(compose.density) { Offset(50f, (lastRow.bottom + 20.dp).toPx()) } + } + + private fun longPressAt(offset: Offset) { + compose.onNodeWithTag(TREE_TAG).performMouseInput { moveTo(offset); press() } + compose.mainClock.advanceTimeBy(1_000) + } + + @Test + fun `long pressing a row grabs that row`() { + showTree() + + longPressAt(visualCenterOf(Ids.MORE_CAMERAS)) + + assertEquals(Ids.MORE_CAMERAS, treeState.selectedId) + } + + @Test + fun `long pressing a row grabs that row right after the tree changed`() { + showTree() + compose.mainClock.autoAdvance = false + + // A previous move landed and the list re-flowed. Every row must still be grabbable + // where it is drawn, not where a settling animation is heading. + summary = buildSummary(camerasAtRoot = true) + compose.mainClock.advanceTimeByFrame() + compose.mainClock.advanceTimeByFrame() + + longPressAt(visualCenterOf(Ids.VERIFICATION)) + + assertEquals(Ids.VERIFICATION, treeState.selectedId, "Grabbed the wrong row") + } + + @Test + fun `a row disposed while pressed does not stay grabbable`() { + showTree() + + // Press a row, then have it deleted out from under the finger. + compose.onNodeWithTag(TREE_TAG) + .performMouseInput { moveTo(visualCenterOf(Ids.MORE_CAMERAS)); press() } + summary = buildSummary(camerasAtRoot = false, keepCameras = false) + compose.mainClock.advanceTimeBy(1_000) + compose.onNodeWithTag(TREE_TAG).performMouseInput { release() } + + longPressAt(emptySpaceBelowRows()) + + assertEquals( + SceneTreeState.NO_SELECTION, + treeState.selectedId, + "Empty space grabbed a scene", + ) + } + + @Test + fun `a second finger cannot retarget the drag`() { + showTree() + + compose.onNodeWithTag(TREE_TAG).performTouchInput { + down(0, visualCenterOf(Ids.CH3)) + down(1, visualCenterOf(Ids.OVERVIEW)) + } + compose.mainClock.advanceTimeBy(1_000) + + assertEquals(Ids.CH3, treeState.selectedId, "The second finger retargeted the drag") + } + + @Test + fun `dragging a scene to the bottom of the list moves the scene that was grabbed`() { + showTree() + + longPressAt(visualCenterOf(Ids.MORE_CAMERAS)) + compose.onNodeWithTag(TREE_TAG) + .performMouseInput { moveTo(visualCenterOf(Ids.BOWL) + Offset(0f, 10f)) } + compose.mainClock.advanceTimeBy(16) + compose.onNodeWithTag(TREE_TAG).performMouseInput { release() } + compose.waitForIdle() + + val request = moveRequest + assertNotNull(request, "No move was requested") + assertEquals(Ids.MORE_CAMERAS, request.id, "Wrong scene was moved") + assertEquals(Ids.BOWL, treeState.getTree()[request.toPosition.coords.globalIndex].value.id) + assertEquals(false, request.toPosition.before) + } +} + +/** + * [camerasAtRoot] models "More Cameras" having been dragged out of Chapter 3 to the root, and + * [keepCameras] models it having been deleted outright. + */ +private fun buildSummary(camerasAtRoot: Boolean, keepCameras: Boolean = true): SceneSummary { + val moreCameras = sceneItem(Ids.MORE_CAMERAS, SceneItem.Type.Scene, "More Cameras") + val chapter3Children = mutableListOf>( + sceneNode(sceneItem(Ids.VERIFICATION, SceneItem.Type.Scene, "Verification")), + sceneNode(sceneItem(Ids.MOUSE_TRAP, SceneItem.Type.Scene, "Mouse Trap")), + ) + val rootTail = mutableListOf>() + if (keepCameras) { + if (camerasAtRoot) rootTail.add(sceneNode(moreCameras)) + else chapter3Children.add(0, sceneNode(moreCameras)) + } + + val tree = Tree() + tree.setRoot( + sceneNode( + sceneItem(0, SceneItem.Type.Root, ""), + sceneNode(sceneItem(Ids.PREFACE, SceneItem.Type.Scene, "Preface")), + sceneNode( + sceneItem(Ids.CH1, SceneItem.Type.Group, "Chapter 1"), + sceneNode(sceneItem(Ids.DOOR_OPENS, SceneItem.Type.Scene, "The Door Opens")), + sceneNode(sceneItem(Ids.DISCOVERY, SceneItem.Type.Scene, "The Discovery")), + ), + sceneNode( + sceneItem(Ids.CH2, SceneItem.Type.Group, "Chapter 2"), + sceneNode(sceneItem(Ids.VACUUM, SceneItem.Type.Scene, "The Vacuum")), + sceneNode(sceneItem(Ids.EXPLORATION, SceneItem.Type.Scene, "Exploration")), + ), + sceneNode( + sceneItem(Ids.CH3, SceneItem.Type.Group, "Chapter 3"), + *chapter3Children.toTypedArray(), + ), + sceneNode( + sceneItem(Ids.CH4, SceneItem.Type.Group, "Chapter 4"), + sceneNode(sceneItem(Ids.WILDLIFE, SceneItem.Type.Scene, "Wildlife Expert")), + ), + sceneNode(sceneItem(Ids.CH5, SceneItem.Type.Group, "Chapter 5")), + sceneNode(sceneItem(Ids.WATER, SceneItem.Type.Scene, "Water, Apple, Insects")), + sceneNode(sceneItem(Ids.GHOST_CHILD, SceneItem.Type.Scene, "The Ghost Child")), + sceneNode(sceneItem(Ids.SKELETON, SceneItem.Type.Scene, "A black skeleton")), + sceneNode(sceneItem(Ids.BOWL, SceneItem.Type.Scene, "A bowl of water")), + *rootTail.toTypedArray(), + sceneNode(sceneItem(Ids.OVERVIEW, SceneItem.Type.Scene, "Overview")), + ) + ) + return SceneSummary( + sceneTree = tree.toImmutableTree(), + hasDirtyBuffer = persistentSetOf(), + ) +} From ad2553908b17856ced515be4fcabf3ffc4dd103b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 22:07:22 -0700 Subject: [PATCH 024/213] New Crowdin updates (#843) * Update source file strings.xml [ci skip] * Update source file strings_about_app.xml [ci skip] * Update source file strings_account_settings.xml [ci skip] * Update source file strings_desktop.xml [ci skip] * Update source file strings_drafts.xml [ci skip] * Update source file strings_encyclopedia.xml [ci skip] * Update source file strings_globalsearch.xml [ci skip] * Update source file strings_ideas.xml [ci skip] * Update source file strings_notes.xml [ci skip] * Update source file strings_project_home.xml [ci skip] * Update source file strings_project_navigation.xml [ci skip] * Update source file strings_project_select_navigation.xml [ci skip] * Update source file strings_projects_list.xml [ci skip] * Update source file strings_scene_editor.xml [ci skip] * Update source file strings_scene_list.xml [ci skip] * Update source file strings_sync.xml [ci skip] * Update source file strings_timeline.xml [ci skip] * Update source file strings_android.xml [ci skip] * Update source file full_description.txt [ci skip] * Update source file Messages_en.properties [ci skip] * New translations messages_en.properties (French) [ci skip] * New translations messages_en.properties (Spanish) [ci skip] * New translations messages_en.properties (German) [ci skip] * New translations messages_en.properties (Italian) [ci skip] * New translations messages_en.properties (Ukrainian) [ci skip] * New translations messages_en.properties (Portuguese, Brazilian) [ci skip] * New translations strings.xml (French) [ci skip] * New translations strings_about_app.xml (French) [ci skip] * New translations strings_account_settings.xml (French) [ci skip] * New translations strings_account_settings.xml (Spanish) [ci skip] * New translations strings_account_settings.xml (German) [ci skip] * New translations strings_account_settings.xml (Italian) [ci skip] * New translations strings_account_settings.xml (Ukrainian) [ci skip] * New translations strings_account_settings.xml (Portuguese, Brazilian) [ci skip] * New translations strings_desktop.xml (French) [ci skip] * New translations strings_drafts.xml (French) [ci skip] * New translations strings_encyclopedia.xml (French) [ci skip] * New translations strings_notes.xml (French) [ci skip] * New translations strings_project_home.xml (French) [ci skip] * New translations strings_project_home.xml (Spanish) [ci skip] * New translations strings_project_home.xml (German) [ci skip] * New translations strings_project_home.xml (Italian) [ci skip] * New translations strings_project_home.xml (Ukrainian) [ci skip] * New translations strings_project_home.xml (Portuguese, Brazilian) [ci skip] * New translations strings_project_navigation.xml (French) [ci skip] * New translations strings_project_navigation.xml (Spanish) [ci skip] * New translations strings_project_navigation.xml (German) [ci skip] * New translations strings_project_navigation.xml (Italian) [ci skip] * New translations strings_project_navigation.xml (Ukrainian) [ci skip] * New translations strings_project_navigation.xml (Portuguese, Brazilian) [ci skip] * New translations strings_projects_list.xml (French) [ci skip] * New translations strings_scene_editor.xml (French) [ci skip] * New translations strings_scene_list.xml (French) [ci skip] * New translations strings_sync.xml (French) [ci skip] * New translations strings_timeline.xml (French) [ci skip] * New translations strings_timeline.xml (Spanish) [ci skip] * New translations strings_timeline.xml (German) [ci skip] * New translations strings_timeline.xml (Italian) [ci skip] * New translations strings_timeline.xml (Ukrainian) [ci skip] * New translations strings_timeline.xml (Portuguese, Brazilian) [ci skip] * New translations strings_android.xml (French) [ci skip] * New translations strings_android.xml (Spanish) [ci skip] * New translations strings_android.xml (German) [ci skip] * New translations strings_android.xml (Italian) [ci skip] * New translations strings_android.xml (Ukrainian) [ci skip] * New translations strings_android.xml (Portuguese, Brazilian) [ci skip] * New translations full_description.txt (French) [ci skip] * New translations full_description.txt (Spanish) [ci skip] * New translations full_description.txt (Italian) [ci skip] * New translations full_description.txt (Ukrainian) [ci skip] * New translations short_description.txt (French) [ci skip] * New translations short_description.txt (Spanish) [ci skip] * New translations short_description.txt (Italian) [ci skip] * New translations short_description.txt (Ukrainian) [ci skip] * New translations strings_globalsearch.xml (French) [ci skip] * New translations strings_ideas.xml (French) [ci skip] --- .../main/res/values-de/strings_android.xml | 2 +- .../main/res/values-es/strings_android.xml | 2 +- .../main/res/values-fr/strings_android.xml | 8 +- .../main/res/values-it/strings_android.xml | 12 +- .../res/values-pt-rBR/strings_android.xml | 2 +- .../main/res/values-uk/strings_android.xml | 12 +- .../src/main/res/values/strings_android.xml | 94 +- .../values-de/strings_account_settings.xml | 1 + .../values-de/strings_project_home.xml | 5 + .../values-de/strings_project_navigation.xml | 2 +- .../values-de/strings_timeline.xml | 2 +- .../values-es/strings_account_settings.xml | 1 + .../values-es/strings_project_home.xml | 5 + .../values-es/strings_project_navigation.xml | 2 +- .../values-es/strings_timeline.xml | 2 +- .../composeResources/values-fr/strings.xml | 10 +- .../values-fr/strings_about_app.xml | 4 +- .../values-fr/strings_account_settings.xml | 22 +- .../values-fr/strings_desktop.xml | 6 +- .../values-fr/strings_drafts.xml | 6 +- .../values-fr/strings_encyclopedia.xml | 22 +- .../values-fr/strings_globalsearch.xml | 2 +- .../values-fr/strings_ideas.xml | 26 +- .../values-fr/strings_notes.xml | 16 +- .../values-fr/strings_project_home.xml | 11 +- .../values-fr/strings_project_navigation.xml | 2 +- .../values-fr/strings_projects_list.xml | 2 +- .../values-fr/strings_scene_editor.xml | 6 +- .../values-fr/strings_scene_list.xml | 2 +- .../values-fr/strings_sync.xml | 12 +- .../values-fr/strings_timeline.xml | 6 +- .../values-it/strings_account_settings.xml | 1 + .../values-it/strings_project_home.xml | 5 + .../values-it/strings_project_navigation.xml | 2 +- .../values-it/strings_timeline.xml | 2 +- .../strings_account_settings.xml | 1 + .../values-pt-rBR/strings_project_home.xml | 5 + .../strings_project_navigation.xml | 2 +- .../values-pt-rBR/strings_timeline.xml | 2 +- .../values-uk/strings_account_settings.xml | 1 + .../values-uk/strings_project_home.xml | 5 + .../values-uk/strings_project_navigation.xml | 2 +- .../values-uk/strings_timeline.xml | 2 +- .../composeResources/values/strings.xml | 108 +- .../values/strings_about_app.xml | 62 +- .../values/strings_account_settings.xml | 297 ++- .../values/strings_desktop.xml | 48 +- .../values/strings_drafts.xml | 32 +- .../values/strings_encyclopedia.xml | 174 +- .../values/strings_globalsearch.xml | 38 +- .../composeResources/values/strings_ideas.xml | 188 +- .../composeResources/values/strings_notes.xml | 118 +- .../values/strings_project_home.xml | 267 ++- .../values/strings_project_navigation.xml | 28 +- .../strings_project_select_navigation.xml | 18 +- .../values/strings_projects_list.xml | 160 +- .../values/strings_scene_editor.xml | 186 +- .../values/strings_scene_list.xml | 136 +- .../composeResources/values/strings_sync.xml | 502 ++--- .../values/strings_timeline.xml | 114 +- .../android/en-US/full_description.txt | 40 +- .../android/es-ES/full_description.txt | 4 +- .../android/es-ES/short_description.txt | 2 +- .../android/fr-FR/full_description.txt | 22 +- .../android/fr-FR/short_description.txt | 2 +- .../android/it-IT/full_description.txt | 4 +- .../android/it-IT/short_description.txt | 2 +- .../metadata/android/uk/full_description.txt | 4 +- .../metadata/android/uk/short_description.txt | 2 +- .../resources/i18n/Messages_de.properties | 197 +- .../resources/i18n/Messages_en.properties | 1828 ++++++++--------- .../resources/i18n/Messages_es.properties | 197 +- .../resources/i18n/Messages_fr.properties | 273 ++- .../resources/i18n/Messages_it.properties | 197 +- .../resources/i18n/Messages_pt_BR.properties | 197 +- .../resources/i18n/Messages_uk.properties | 197 +- 76 files changed, 3222 insertions(+), 2759 deletions(-) diff --git a/android/src/main/res/values-de/strings_android.xml b/android/src/main/res/values-de/strings_android.xml index c884ed268..b47af77f3 100644 --- a/android/src/main/res/values-de/strings_android.xml +++ b/android/src/main/res/values-de/strings_android.xml @@ -30,7 +30,7 @@ Configure Story Info Widget Tap to configure § %1$s · STORY - by %1$s + von %1$s WORDS %1$d%% OF %2$s %3$s TODAY diff --git a/android/src/main/res/values-es/strings_android.xml b/android/src/main/res/values-es/strings_android.xml index 0bc6d2fe1..bb7e3d19f 100644 --- a/android/src/main/res/values-es/strings_android.xml +++ b/android/src/main/res/values-es/strings_android.xml @@ -30,7 +30,7 @@ Configure Story Info Widget Tap to configure § %1$s · STORY - by %1$s + por %1$s WORDS %1$d%% OF %2$s %3$s TODAY diff --git a/android/src/main/res/values-fr/strings_android.xml b/android/src/main/res/values-fr/strings_android.xml index 9f7519a39..79e806fb3 100644 --- a/android/src/main/res/values-fr/strings_android.xml +++ b/android/src/main/res/values-fr/strings_android.xml @@ -25,24 +25,24 @@ Ajouter la Note au projet Si aucun projet n\'est sélectionné, on vous demandera de choisir un projet à chaque fois. Hammer · Bibliothèque -- Ouvrir une histoire - Hammer · Histoire — aperçu des statistiques pour un projet + Hammer · Histoire — Aperçu des statistiques pour un projet Widget d\'info pour une histoire : Configurer le Widget d\'info pour une histoire Appuyez pour configurer § %1$s · HISTOIRE de %1$s MOTS - %1$d%% / %2$s %3$s + %1$d%% SUR %2$s %3$s AUJOURD\'HUI CETTE SEMAINE +%1$d AUJOURD\'HUI AUCUNE SESSION AUJOURD\'HUI SERIE DE %1$dJ SESSIONS · 7J - %1$s SEM. + %1$s S OUVRIR NOTE § %1$s M - CONTINUER + CONTINUER · diff --git a/android/src/main/res/values-it/strings_android.xml b/android/src/main/res/values-it/strings_android.xml index 0716ec255..a8bc3754c 100644 --- a/android/src/main/res/values-it/strings_android.xml +++ b/android/src/main/res/values-it/strings_android.xml @@ -7,13 +7,13 @@ More Menu Navigation Drawer Add Note to: - Save - Cancel - Projects + Salva + Annulla + Progetti Project not found: %1$s Discard Note? - Discard - Cancel + Scarta + Annulla Add Note No projects Note Added @@ -30,7 +30,7 @@ Configure Story Info Widget Tap to configure § %1$s · STORY - by %1$s + di %1$s WORDS %1$d%% OF %2$s %3$s TODAY diff --git a/android/src/main/res/values-pt-rBR/strings_android.xml b/android/src/main/res/values-pt-rBR/strings_android.xml index e0b4c4e85..2be293595 100644 --- a/android/src/main/res/values-pt-rBR/strings_android.xml +++ b/android/src/main/res/values-pt-rBR/strings_android.xml @@ -30,7 +30,7 @@ Configure Story Info Widget Tap to configure § %1$s · STORY - by %1$s + por %1$s WORDS %1$d%% OF %2$s %3$s TODAY diff --git a/android/src/main/res/values-uk/strings_android.xml b/android/src/main/res/values-uk/strings_android.xml index 0716ec255..7c787e0e9 100644 --- a/android/src/main/res/values-uk/strings_android.xml +++ b/android/src/main/res/values-uk/strings_android.xml @@ -7,13 +7,13 @@ More Menu Navigation Drawer Add Note to: - Save - Cancel - Projects + Зберегти + Скасувати + Проєкти Project not found: %1$s Discard Note? - Discard - Cancel + Відхилити + Скасувати Add Note No projects Note Added @@ -30,7 +30,7 @@ Configure Story Info Widget Tap to configure § %1$s · STORY - by %1$s + автор: %1$s WORDS %1$d%% OF %2$s %3$s TODAY diff --git a/android/src/main/res/values/strings_android.xml b/android/src/main/res/values/strings_android.xml index 5e3ec4f64..99dc8ede9 100644 --- a/android/src/main/res/values/strings_android.xml +++ b/android/src/main/res/values/strings_android.xml @@ -1,48 +1,48 @@ - - - Hammer - Add Note - Loading… - Configure Widget - More Menu - Navigation Drawer - Add Note to: - Save - Cancel - Projects - Project not found: %1$s - Discard Note? - Discard - Cancel - Add Note - No projects - Note Added - Add Note - Add Story Note - Adding notes is disabled right now - Configure Widget - Configure Note Widget: - Add notes to specified project - If no project is selected, you will be asked to choose a project each time. - Hammer · Library — open a story - Hammer · Story — at-a-glance stats for one project - Story Info Widget: - Configure Story Info Widget - Tap to configure - § %1$s · STORY - by %1$s - WORDS - %1$d%% OF %2$s %3$s - TODAY - THIS WEEK - +%1$d TODAY - NO SESSION TODAY - %1$dD STREAK - SESSIONS · 7D - %1$s W - OPEN - NOTE - § %1$s - W - CONTINUE · + + + Hammer + Add Note + Loading… + Configure Widget + More Menu + Navigation Drawer + Add Note to: + Save + Cancel + Projects + Project not found: %1$s + Discard Note? + Discard + Cancel + Add Note + No projects + Note Added + Add Note + Add Story Note + Adding notes is disabled right now + Configure Widget + Configure Note Widget: + Add notes to specified project + If no project is selected, you will be asked to choose a project each time. + Hammer · Library — open a story + Hammer · Story — at-a-glance stats for one project + Story Info Widget: + Configure Story Info Widget + Tap to configure + § %1$s · STORY + by %1$s + WORDS + %1$d%% OF %2$s %3$s + TODAY + THIS WEEK + +%1$d TODAY + NO SESSION TODAY + %1$dD STREAK + SESSIONS · 7D + %1$s W + OPEN + NOTE + § %1$s + W + CONTINUE · \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml index c98b12fb1..41ac50011 100644 --- a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml @@ -110,4 +110,5 @@ Der Server reagiert nicht. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut. Server nicht gefunden. Überprüfen Sie die Serveradresse und Ihre Internetverbindung. Netzwerkfehler beim Verbinden mit %1$s. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-de/strings_project_home.xml b/common/src/commonMain/composeResources/values-de/strings_project_home.xml index f02f65c29..754ecfcb3 100644 --- a/common/src/commonMain/composeResources/values-de/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-de/strings_project_home.xml @@ -104,7 +104,12 @@ Kapitelgruppen erstellen Vorschau Nichts zu importieren + Reading your manuscript Erstellt %1$d Szene(n) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Abbrechen Importieren Einstellungen diff --git a/common/src/commonMain/composeResources/values-de/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-de/strings_project_navigation.xml index f1bdfb3e9..20a758b15 100644 --- a/common/src/commonMain/composeResources/values-de/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-de/strings_project_navigation.xml @@ -4,7 +4,7 @@ Geschichte Notiz Lexikon - Timeline + Zeitstrahl STRT GESC NOTZ diff --git a/common/src/commonMain/composeResources/values-de/strings_timeline.xml b/common/src/commonMain/composeResources/values-de/strings_timeline.xml index d95cade95..d500f47fb 100644 --- a/common/src/commonMain/composeResources/values-de/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-de/strings_timeline.xml @@ -39,7 +39,7 @@ Löschen Ereignis löschen? Dies kann nicht rückgängig gemacht werden! - Timeline + Zeitstrahl %1$d EINTRÄGE · %2$d TAGS Zeitstrahl durchsuchen Zeitstrahl durchsuchen diff --git a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml index 39d9b7fe0..4a0bae9a1 100644 --- a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml @@ -110,4 +110,5 @@ El servidor no responde. Comprueba tu conexión a internet e inténtalo de nuevo. Servidor no encontrado. Comprueba la dirección del servidor y tu conexión a internet. Error de red al conectar con %1$s. Comprueba tu conexión e inténtalo de nuevo. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-es/strings_project_home.xml b/common/src/commonMain/composeResources/values-es/strings_project_home.xml index 9bfeca1e8..223deca28 100644 --- a/common/src/commonMain/composeResources/values-es/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-es/strings_project_home.xml @@ -104,7 +104,12 @@ Crear grupos de capítulos Vista previa Nada que importar + Reading your manuscript Creará %1$d escena(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Cancelar Importar Ajustes diff --git a/common/src/commonMain/composeResources/values-es/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-es/strings_project_navigation.xml index ee9f255e2..348c43edb 100644 --- a/common/src/commonMain/composeResources/values-es/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-es/strings_project_navigation.xml @@ -4,7 +4,7 @@ Historia Notas Enciclopedia - Timeline + Cronología INIC HIST NOTA diff --git a/common/src/commonMain/composeResources/values-es/strings_timeline.xml b/common/src/commonMain/composeResources/values-es/strings_timeline.xml index e94f4073c..fcbd9a5f8 100644 --- a/common/src/commonMain/composeResources/values-es/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-es/strings_timeline.xml @@ -39,7 +39,7 @@ Eliminar ¿Eliminar el evento de cronología? ¡Esta acción no se puede deshacer! - Timeline + Cronología %1$d ENTRADAS · %2$d ETIQUETAS Buscar en la cronología Buscar en la cronología diff --git a/common/src/commonMain/composeResources/values-fr/strings.xml b/common/src/commonMain/composeResources/values-fr/strings.xml index 5ca9b4285..8ebd794e0 100644 --- a/common/src/commonMain/composeResources/values-fr/strings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings.xml @@ -1,7 +1,7 @@ Hammer - Toutes les scènes enregistrées + Scènes enregistrées Scènes non enregistrées Que faire des scènes non enregistrées ? Entrée d'encyclopédie non enregistrée @@ -28,13 +28,13 @@ Rechercher et remplacer Gras Italique - Barré - Titre + Barrer + En-tête Citation Liste à puces Liste numérotée - Ligne horizontale + Règle horizontale Annuler Rétablir - ESC annuler + ECHAP annuler diff --git a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml index 1786dd156..a0b65e573 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml @@ -1,6 +1,6 @@ - Un outil simple pour construire des histoires. + Un outil pour rédiger des histoires. Un projet libre et open source. Communauté Discord @@ -15,7 +15,7 @@ Une nouvelle version de Hammer est disponible Notes de version pour %s Notes de version - Aucune note de version fournie. + Aucune note de version publiée. Ouvrir la version Ignorer Journaux de l'application diff --git a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml index 5d787622d..3ba0e81ab 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml @@ -4,7 +4,7 @@ INSTALLATION LOCALE PARAMÈTRES DU COMPTE Sauvegardes - Ce système de sauvegarde est destiné à protéger contre la perte et la corruption des données. Il n'est PAS conçu pour conserver d'anciennes versions de vos histoires. Utilisez le système de brouillons pour cela. + Ce système de sauvegarde est destiné à protéger contre la perte et la corruption des données. Il n'est PAS conçu pour conserver d'anciennes versions de vos histoires. Utilisez le système de brouillons pour conserver vos données. Gérer les sauvegardes Thème de l'interface CLAIR @@ -14,12 +14,12 @@ Accueil Éditeur de scène Dossier des projets - Définissez le dossier que Hammer utilisera pour stocker les données des projets. - Choisir un dossier - Projet d'exemple - Installez un projet d'exemple pour voir comment les différentes fonctionnalités sont utilisées. + Définissez le dossier que Hammer utilisera pour stocker les données de vos projets. + Sélectionnez un dossier + Projet de démonstration + Installez un projet de démonstration pour voir comment les différentes fonctionnalités sont utilisées. Réinstaller - Projet d'exemple créé. + Projet de démonstration créé. CONNECTÉ NON CONNECTÉ URL @@ -28,7 +28,7 @@ Configurer Synchronisation automatique Sauvegarder lors de la synchronisation - Fermer les boîtes de synchronisation en cas de succès + Fermer les dialogues de synchronisation en cas de succès Sauvegardes max par projet Doit être compris entre 1 et %1$d Supprimer le serveur @@ -36,7 +36,7 @@ Voulez-vous vraiment supprimer le serveur ? Configurer le serveur URL du serveur : hammer.ink - E-mail : user@example.com + E-mail : utilisateur@exemple.com Mot de passe Afficher le mot de passe Masquer le mot de passe @@ -44,7 +44,8 @@ Créer Annuler Supprimer le contenu local ? - Devons-nous supprimer votre contenu local actuel avant la synchronisation ? Sinon, nous tenterons de fusionner votre contenu local avec celui du serveur. Si les contenus n'ont aucun rapport, cela pourrait causer des problèmes. + Devons-nous supprimer votre contenu local actuel avant la synchronisation ? Sinon, nous tenterons de fusionner votre contenu local avec celui du serveur. +Des conflits peuvent se déclarer et causer des erreurs si les contenus ne correspondent pas Paramètres de la plateforme Aucun paramètre spécifique à la plateforme. Garder l'écran allumé @@ -80,7 +81,7 @@ Sélectionnez un projet Aucune sauvegarde pour ce projet Restaurer - Delete backup + Supprimer la sauvegarde Afficher dans le dossier Exporter Annuler la synchronisation ? @@ -110,4 +111,5 @@ Le serveur ne répond pas. Vérifiez votre connexion Internet et réessayez. Serveur introuvable. Vérifiez l'adresse du serveur et votre connexion Internet. Erreur réseau lors de la connexion à %1$s. Vérifiez votre connexion et réessayez. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-fr/strings_desktop.xml b/common/src/commonMain/composeResources/values-fr/strings_desktop.xml index 444000e0b..0975f195a 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_desktop.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_desktop.xml @@ -8,11 +8,9 @@ Projets récents Bienvenue dans Hammer - Hammer stores your projects in a folder you choose, so your writing stays in - your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change - this later in Settings. + Hammer range vos projets à l'intérieur du dossier que vous avez défini, vos écrits restes entre vos mains.\n\nSur le prochain écran, sélectionnez un dossier de projet Hammer existant ou créez en un nouveau. Vous pourrez effectuer des modifications plus tard dans les Paramètres de l'application. Choisissez où stocker vos projets Hammer Choisir un dossier Quitter - Hammer needs a folder to store your projects.\nChoose a folder now, or quit. + Hammer a besoin d'un dossier pour ranger vos projets.\nChoisissez un dossier maintenant, ou quittez l'application diff --git a/common/src/commonMain/composeResources/values-fr/strings_drafts.xml b/common/src/commonMain/composeResources/values-fr/strings_drafts.xml index 10edb294b..fc3ef0c86 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_drafts.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_drafts.xml @@ -2,10 +2,10 @@ Fusionner avec l'actuel Copiez ici des parties du brouillon - Prendre la fusion + Fusionner Brouillon : %1$s - Copiez les parties voulues dans l'actuel, ou acceptez tout ceci. - Prendre tout ce brouillon + Copiez les parties voulues dans le brouillon actuel, ou accepter tout cela. + Prendre tout le brouillon Brouillon Actuel Brouillons de %1$s : diff --git a/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml b/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml index 703f838f2..1d585fe7a 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml @@ -9,37 +9,37 @@ Événement Idée Aucune entrée trouvée - Créer une entrée + Créer une Entrée Créer une entrée Nouveau Brouillon Nom p. ex. Chat du Cheshire - Tags - ↵ pour ajouter + Étiquettes + ↵ ajouter rituel, magique, récurrent… Description Illustration Déposez une image · ou Parcourir les fichiers - 1 jointe + 1 pièce jointe Remplacer L'image est trop volumineuse. La taille maximale est de %1$d Mo. Impossible de lire l'image sélectionnée. Créer une entrée Annuler Entrée créée - Le nom de l'entrée était trop long. Max %1$d + Le nom de l'entrée est trop longue. Max %1$d Le nom de l'entrée doit être alphanumérique - Le tag est trop long. Max %1$d + L'Étiquette est trop longue. Max %1$d L'alias est trop long. Max %1$d Alias Ajouter un alias Ajouter Surnom ou autre nom Apparaît dans les scènes - Pas encore référencé dans aucune scène - Entry name cannot be empty + Encore référencé dans aucune scène + Le nom d'entrée ne peut pas être vide Abandonner la nouvelle entrée ? Erreur : échec du chargement de l'entrée ! Cliquez pour saisir une description @@ -65,11 +65,11 @@ Modifier Détails Type - Tags + Étiquettes Alias Scènes - Tags - tag + Étiquettes + étiquette alias Fig. I · Entrée Fig. I diff --git a/common/src/commonMain/composeResources/values-fr/strings_globalsearch.xml b/common/src/commonMain/composeResources/values-fr/strings_globalsearch.xml index a65b65ab6..137d563ed 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_globalsearch.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_globalsearch.xml @@ -2,7 +2,7 @@ Rechercher dans le projet Rechercher dans le projet - Saisissez pour rechercher, utilisez #tag pour filtrer… + Saisissez pour rechercher, utilisez #étiquette pour filtrer… Effacer la recherche Aucun résultat Saisissez au moins 2 caractères pour rechercher diff --git a/common/src/commonMain/composeResources/values-fr/strings_ideas.xml b/common/src/commonMain/composeResources/values-fr/strings_ideas.xml index aa1d844e3..b5ce4668f 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_ideas.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_ideas.xml @@ -1,18 +1,18 @@ - Idées d'histoire + Mes idées IDÉE - Idées d'histoire - %1$d IDÉES · %2$d TAGS - Aucune idée d'histoire pour l'instant. Capturez votre première étincelle. + Mes idées + %1$d IDÉES · %2$d ÉTIQUETTES + Vous n'avez déposé aucune de vos idées pour le moment. Une petite étincelle peut être le début de toutes choses. Aucune idée archivée. %1$d M - %1$d TAGS - Rechercher des idées - Rechercher des idées + %1$d Étiquettes + Rechercher mes idées + Rechercher mes idées Effacer la recherche Fermer la recherche - Toutes + Tous Archivées FILTRÉ %1$d / %2$d Tout effacer @@ -30,14 +30,14 @@ Idée d'histoire Modification NON ENREGISTRÉ - IDÉES D'HISTOIRE + MES IDÉES Fermer l'idée Modifier Titre Titre facultatif - Tags - Entrée ou virgule ajoute un tag - Ajouter un tag… + Étiquettes + Entrée ou virgule ajoute une étiquette + Ajouter une étiquette… %1$d / %2$d C Créer Enregistrer @@ -78,6 +78,6 @@ Conflit laissé non résolu pour l'idée : %1$s Conflit d'idée d'histoire Titre - Tags + Étiquettes Contenu diff --git a/common/src/commonMain/composeResources/values-fr/strings_notes.xml b/common/src/commonMain/composeResources/values-fr/strings_notes.xml index 0a06b8acd..7e8b2c8a6 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_notes.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_notes.xml @@ -12,15 +12,15 @@ Créer Annuler Note créée - La note était trop longue - La note était vide - Le tag était trop long (max %1$d) - Tags - ↵ pour ajouter + La note est trop longue + La note est vide + L'étiquette est trop longue (max %1$d) + Étiquettes + ↵ ajouter idée, à faire, suivi… Supprimer la note ? Cette action est irréversible ! - Note Deleted + Note Supprimée Note Supprimer Note @@ -31,12 +31,12 @@ Enregistrer NON ENREGISTRÉ %1$d M · %2$d C - ESC POUR ANNULER + ECHAP POUR ANNULER Rechercher des notes Rechercher des notes Fermer la recherche Effacer la recherche - %1$d NOTES · %2$d TAGS + %1$d NOTES · %2$d ÉTIQUETTES %1$d M TOUT FILTRÉ · %1$d SUR %2$d diff --git a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml index d8b89d89b..ffc0cffde 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml @@ -1,7 +1,7 @@ Créé : %1$s - Calcul des statistiques… + Calcul des statistiques... Actualiser les statistiques Les statistiques peuvent être obsolètes Scènes @@ -10,7 +10,7 @@ Mots par chapitre Chapitre %1$s : %2$s mots - Mots moy. / scène + Mots moyens / scène Notes Événements de chronologie Scène la plus longue @@ -54,7 +54,7 @@ %1$d des 4 types Distribution Affichage de %1$d sur %2$d - Rang · Tag · Portée · Utilisations + Rang · Étiquette · Portée · Utilisations Scènes Notes Encyclopédie @@ -104,7 +104,12 @@ Créer des groupes de chapitres Aperçu Rien à importer + Reading your manuscript Créera %1$d scène(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s S Annuler Importer Paramètres diff --git a/common/src/commonMain/composeResources/values-fr/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-fr/strings_project_navigation.xml index b5655fde9..2fd36bf67 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_project_navigation.xml @@ -4,7 +4,7 @@ Histoire Notes Encyclopédie - Timeline + Chronologie ACCU HIST NOTE diff --git a/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml b/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml index c45d89793..f3f4e81f8 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml @@ -14,7 +14,7 @@ Projet %1$s Rechercher des projets Fermer la recherche - filtrer par nom ou #tag + filtrer par nom ou #étiquette Effacer le filtre Aucun projet ne correspond à votre filtre Hammer · Bibliothèque diff --git a/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml index 487a30dc6..e02678afc 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml @@ -38,7 +38,7 @@ « %1$s » a été restaurée « %1$s » a été archivée Voir les archives - Mode concentration + Mode Focus Métadonnées Masquer les infos Mots : @@ -71,11 +71,11 @@ Rechercher des entrées… Aucune entrée correspondante. Fermer la boîte de dialogue - Tags + Étiquettes Aucun tag pour l'instant + Ajouter un tag Ajouter des tags - tag1 tag2 … + étiquette1 étiquette2 … Terminé Sélectionnez une scène à modifier Nouveau brouillon diff --git a/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml b/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml index a688e16f3..a5d583ee2 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml @@ -45,7 +45,7 @@ Déplacer vers Destination Rechercher des groupes - Niveau supérieur + Niveau Haut Aucun groupe correspondant Position (1–%1$d) Saisissez une position entre 1 et %1$d diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index 11fde1fed..3c6d9af7b 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -15,7 +15,7 @@ Entrée d'encyclopédie Brouillon de scène Paramètres du projet - Vous pouvez prendre la copie locale ou distante telle quelle, ou fusionner les deux manuellement en copiant des parties de la version distante dans votre copie locale, puis en la retenant. + Vous pouvez prendre la copie locale ou distante telle quelle, ou fusionner les deux manuellement en copiant des parties de la version distante dans votre copie locale Conflit d'entrée d'encyclopédie Auteur Thème personnalisé @@ -30,10 +30,10 @@ Semaine Effectif M / jour - Tags - Tags - ↵ pour ajouter - fantasy, brouillon, nanowrimo… + Étiquettes + Étiquettes + ↵ ajouter + fantaisie, brouillon, nanowrimo… Modifier Annuler OK @@ -51,7 +51,7 @@ Nom de l'auteur Thème du projet Objectif de nombre de mots - Tags + Étiquettes (non défini) jour semaine diff --git a/common/src/commonMain/composeResources/values-fr/strings_timeline.xml b/common/src/commonMain/composeResources/values-fr/strings_timeline.xml index d73cc4242..287487903 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_timeline.xml @@ -4,7 +4,7 @@ Créer un événement Événement créé Le tag était trop long (max %1$d) - Tags + Étiquettes ↵ pour ajouter arc, préfiguration, conflit… Événement @@ -22,7 +22,7 @@ %1$d M · %2$d C ESC POUR ANNULER %1$d M - %1$d TAGS + %1$d ÉTIQUETTES Enregistré Échec de l'enregistrement Créer un événement @@ -39,7 +39,7 @@ Supprimer Supprimer l'événement de chronologie ? Cette action est irréversible ! - Timeline + Chronologie %1$d ENTRÉES · %2$d TAGS Rechercher dans la chronologie Rechercher dans la chronologie diff --git a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml index eca45ef27..df9c5851c 100644 --- a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml @@ -110,4 +110,5 @@ Il server non risponde. Controlla la tua connessione a internet e riprova. Server non trovato. Controlla l’indirizzo del server e la tua connessione a internet. Errore di rete durante la connessione a %1$s. Controlla la tua connessione e riprova. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-it/strings_project_home.xml b/common/src/commonMain/composeResources/values-it/strings_project_home.xml index 35548e43e..4d3f70efa 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_home.xml @@ -104,7 +104,12 @@ Crea gruppi di capitoli Anteprima Niente da importare + Reading your manuscript Creerà %1$d scene + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Annulla Importa Impostazioni diff --git a/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml index eb513f3f1..a18770c78 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml @@ -4,7 +4,7 @@ Storia Note Enciclopedia - Timeline + Cronologia HOME STOR NOTE diff --git a/common/src/commonMain/composeResources/values-it/strings_timeline.xml b/common/src/commonMain/composeResources/values-it/strings_timeline.xml index 85bcd239c..de95c8700 100644 --- a/common/src/commonMain/composeResources/values-it/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-it/strings_timeline.xml @@ -39,7 +39,7 @@ Elimina Eliminare l’evento di cronologia? Questa azione non può essere annullata! - Timeline + Cronologia %1$d VOCI · %2$d TAG Cerca nella cronologia Cerca nella cronologia diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml index f4104e4da..63e7efb06 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml @@ -110,4 +110,5 @@ O servidor não está respondendo. Verifique sua conexão com a 'internet' e tente novamente. Servidor não encontrado. Verifique o endereço do servidor e sua conexão de 'internet'. Erro de rede ao conectar-se a %1$s. Verifique sua conexão e tente novamente. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml index 53ee366f6..50a49180e 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml @@ -104,7 +104,12 @@ Criar grupos de capítulos Prévia Nada para importar + Reading your manuscript Criará %1$d cena(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Cancelar Importar Configurações diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_navigation.xml index 723b634da..de1fd8dd2 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_navigation.xml @@ -4,7 +4,7 @@ História Anotações Enciclopédia - Timeline + Linha do Tempo INÍC HIST ANOT diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_timeline.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_timeline.xml index a09cac551..5b3fd64cb 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_timeline.xml @@ -39,7 +39,7 @@ Excluir Excluir Evento da Linha do Tempo? Isso não pode ser desfeito! - Timeline + Linha do Tempo %1$d ENTRADAS · %2$d ETIQUETAS Pesquisar na linha do tempo Pesquisar na linha do tempo diff --git a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml index 999f1d972..e6a0a0902 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml @@ -110,4 +110,5 @@ Сервер не відповідає. Перевірте підключення до Інтернету та спробуйте ще раз. Сервер не знайдено. Перевірте адресу сервера та підключення до Інтернету. Помилка мережі під час під'єднання до %1$s. Перевірте з'єднання та спробуйте ще раз. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml index 8fa48da49..51b275181 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml @@ -104,7 +104,12 @@ Створювати групи розділів Попередній перегляд Немає що імпортувати + Reading your manuscript Буде створено %1$d сцен + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Скасувати Імпортувати Налаштування diff --git a/common/src/commonMain/composeResources/values-uk/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-uk/strings_project_navigation.xml index 426e0c780..95ab1db03 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_project_navigation.xml @@ -4,7 +4,7 @@ Історія Нотатки Енциклопедія - Timeline + Хронологія ГОЛ ІСТ НОТ diff --git a/common/src/commonMain/composeResources/values-uk/strings_timeline.xml b/common/src/commonMain/composeResources/values-uk/strings_timeline.xml index e9f0e945d..06930d5d6 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_timeline.xml @@ -39,7 +39,7 @@ Видалити Видалити подію хронології? Цю дію неможливо скасувати! - Timeline + Хронологія %1$d ЗАПИСІВ · %2$d ТЕГІВ Пошук у хронології Пошук у хронології diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index f76a6b2a6..8a5827fe2 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -1,54 +1,54 @@ - - - Hammer - - Saved all Scenes - - Unsaved Scenes - What should be done with unsaved scenes? - - Unsaved Encyclopedia Entry - Discard entry? - - Unsaved Note - Discard note? - - Unsaved Timeline Event - Discard event? - - Cancel - Discard and close - - Discard - Cancel - Save - - Close - More - - Failed to parse response body: %1$s - - Yes - No - - - Expand - Collapse - - Decrease text size - Increase text size - Reset text size - Find and replace - - Bold - Italic - Strikethrough - Heading - Quote - Bulleted list - Numbered list - Horizontal rule - Undo - Redo - ESC cancel - + + + Hammer + + Saved all Scenes + + Unsaved Scenes + What should be done with unsaved scenes? + + Unsaved Encyclopedia Entry + Discard entry? + + Unsaved Note + Discard note? + + Unsaved Timeline Event + Discard event? + + Cancel + Discard and close + + Discard + Cancel + Save + + Close + More + + Failed to parse response body: %1$s + + Yes + No + + + Expand + Collapse + + Decrease text size + Increase text size + Reset text size + Find and replace + + Bold + Italic + Strikethrough + Heading + Quote + Bulleted list + Numbered list + Horizontal rule + Undo + Redo + ESC cancel + diff --git a/common/src/commonMain/composeResources/values/strings_about_app.xml b/common/src/commonMain/composeResources/values/strings_about_app.xml index 8985c3be2..8cfee963a 100644 --- a/common/src/commonMain/composeResources/values/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values/strings_about_app.xml @@ -1,31 +1,31 @@ - - - A simple tool for building stories. - A free and open source project. - - Community - Discord - Reddit - Github - - Attribution - Libraries - - Version %s is now available - Up to date! - View Update - Release Notes - - A new version of Hammer is available - Release notes for %s - Release notes - No release notes provided. - Open Release - Dismiss - - Application Logs - Log Directory: - Export Logs - No log files found - Open log directory - + + + A simple tool for building stories. + A free and open source project. + + Community + Discord + Reddit + Github + + Attribution + Libraries + + Version %s is now available + Up to date! + View Update + Release Notes + + A new version of Hammer is available + Release notes for %s + Release notes + No release notes provided. + Open Release + Dismiss + + Application Logs + Log Directory: + Export Logs + No log files found + Open log directory + diff --git a/common/src/commonMain/composeResources/values/strings_account_settings.xml b/common/src/commonMain/composeResources/values/strings_account_settings.xml index 5420d5752..d384fe86f 100644 --- a/common/src/commonMain/composeResources/values/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values/strings_account_settings.xml @@ -1,149 +1,148 @@ - - - ACCOUNT - LOCAL INSTALL - ACCOUNT SETTINGS - Backups - This backup system is meant to protect against data loss and corruption. It is NOT intended as a way to keep older revisions of your stories. Please use the Drafts system for that. - - Manage Backups - UI Theme - LIGHT - DARK - SYSTEM - Initial Project Screen - Home - Scene Editor - Projects Directory - Set the directory Hammer will use to store project data in. - - Select Dir - Example Project - Install an example project to see how different features are used. - - Reinstall - Example project created. - CONNECTED - NOT CONNECTED - URL - HELP ↗ - Configure data syncing - Set Up - Auto-Sync - Backup on sync - Close Sync Dialogs on Success - Max Backups per Project - Must be between 1 and %1$d - Remove Server - Remove Server - Are you sure you want to remove the server? - Set Up Server - Server URL: hammer.ink - E-Mail: user@example.com - Password - Show password - Hide password - Log In - Create - Cancel - Remove Local Content? - Should we remove your current local content before syncing? If not, we will attempt to merge your local content with the server content. If the content is unrelated, it could cause problems. - - Platform Settings - No platform-specific settings. - Keep screen on - Enable Do Not Disturb when in Focus Mode - Grant DND Permission - Hammer needs permission to enable Do Not Disturb mode when you are in Focus Mode. - - Store projects in public storage - Keep your projects in the shared Documents folder so other apps and your file manager can reach them. - - Projects Location - Hammer needs file access permission to store projects in public storage. - - Grant File Access - - Experimental - Spell Checking - If it is causing problems just turn it off. - Enable spell checking - Enable in focus mode - Dictionary - Spell check language - On Android, the spell check language is chosen by your device, not the app. To change it, update your system and keyboard languages in Android Settings. - Set up a server to sync your projects across devices and create secure backups. - - Sync & Backup Preferences - Danger Zone - - - Failed to load backups: %1$s - Failed to load backups for project: %1$s - Failed to delete backup: %1$s - Restore Backup - Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. - - Restore - Cancel - Manage Backups - No backups found - Select a project - No backups for this project - Restore - Delete backup - Show in Folder - Export - Cancel Sync? - Are you sure you want to cancel the sync? - Sync canceled - - - An unexpected error occurred. Please try again. - Invalid server address. Enter a URL like "hammer.ink" or an IP address. - - Please enter a valid email address. - Password must be at least 8 characters. - Password must be 64 characters or less. - Password must contain at least one uppercase letter. - - Password must contain at least one lowercase letter. - - Password must contain at least one number. - Password must contain at least one special character. - - - Server setup successful! - Server setup failed: %1$s - Server setup failed. Please check your connection and try again. - - - - Terms of Service - Accept - Decline - You must accept the Terms of Service to create an account. - - - Could not connect to server. Make sure the server is running and the address is correct. - - The server is not responding. Check your internet connection and try again. - - Server not found. Check the server address and your internet connection. - Network error connecting to %1$s. Check your connection and try again. - - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. - - + + + ACCOUNT + LOCAL INSTALL + ACCOUNT SETTINGS + Backups + This backup system is meant to protect against data loss and corruption. It is NOT intended as a way to keep older revisions of your stories. Please use the Drafts system for that. + + Manage Backups + UI Theme + LIGHT + DARK + SYSTEM + Initial Project Screen + Home + Scene Editor + Projects Directory + Set the directory Hammer will use to store project data in. + + Select Dir + Example Project + Install an example project to see how different features are used. + + Reinstall + Example project created. + CONNECTED + NOT CONNECTED + URL + HELP ↗ + Configure data syncing + Set Up + Auto-Sync + Backup on sync + Close Sync Dialogs on Success + Max Backups per Project + Must be between 1 and %1$d + Remove Server + Remove Server + Are you sure you want to remove the server? + Set Up Server + Server URL: hammer.ink + E-Mail: user@example.com + Password + Show password + Hide password + Log In + Create + Cancel + Remove Local Content? + Should we remove your current local content before syncing? If not, we will attempt to merge your local content with the server content. If the content is unrelated, it could cause problems. + + Platform Settings + No platform-specific settings. + Keep screen on + Enable Do Not Disturb when in Focus Mode + Grant DND Permission + Hammer needs permission to enable Do Not Disturb mode when you are in Focus Mode. + + Store projects in public storage + Keep your projects in the shared Documents folder so other apps and your file manager can reach them. + + Projects Location + Hammer needs file access permission to store projects in public storage. + + Grant File Access + + Experimental + Spell Checking + If it is causing problems just turn it off. + Enable spell checking + Enable in focus mode + Dictionary + Spell check language + On Android, the spell check language is chosen by your device, not the app. To change it, update your system and keyboard languages in Android Settings. + Set up a server to sync your projects across devices and create secure backups. + + Sync & Backup Preferences + Danger Zone + + + Failed to load backups: %1$s + Failed to load backups for project: %1$s + Failed to delete backup: %1$s + Restore Backup + Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. + + Restore + Cancel + Manage Backups + No backups found + Select a project + No backups for this project + Restore + Delete backup + Show in Folder + Export + Cancel Sync? + Are you sure you want to cancel the sync? + Sync canceled + + + An unexpected error occurred. Please try again. + Invalid server address. Enter a URL like "hammer.ink" or an IP address. + + Please enter a valid email address. + Password must be at least 8 characters. + Password must be 64 characters or less. + Password must contain at least one uppercase letter. + + Password must contain at least one lowercase letter. + + Password must contain at least one number. + Password must contain at least one special character. + + + Server setup successful! + Server setup failed: %1$s + Server setup failed. Please check your connection and try again. + + + + Terms of Service + Accept + Decline + You must accept the Terms of Service to create an account. + + + Could not connect to server. Make sure the server is running and the address is correct. + + The server is not responding. Check your internet connection and try again. + + Server not found. Check the server address and your internet connection. + Network error connecting to %1$s. Check your connection and try again. + + + diff --git a/common/src/commonMain/composeResources/values/strings_desktop.xml b/common/src/commonMain/composeResources/values/strings_desktop.xml index e60657985..2b1867a7f 100644 --- a/common/src/commonMain/composeResources/values/strings_desktop.xml +++ b/common/src/commonMain/composeResources/values/strings_desktop.xml @@ -1,24 +1,24 @@ - - - Hammer - Project Selection - - Hammer - %1$s - File - Close Project - Exit - - Recent Projects - - - Welcome to Hammer - Hammer stores your projects in a folder you choose, so your writing stays in - your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change - this later in Settings. - - Choose where to store your Hammer projects - Choose Folder - Quit - Hammer needs a folder to store your projects.\nChoose a folder now, or quit. - - - + + + Hammer - Project Selection + + Hammer - %1$s + File + Close Project + Exit + + Recent Projects + + + Welcome to Hammer + Hammer stores your projects in a folder you choose, so your writing stays in + your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change + this later in Settings. + + Choose where to store your Hammer projects + Choose Folder + Quit + Hammer needs a folder to store your projects.\nChoose a folder now, or quit. + + + diff --git a/common/src/commonMain/composeResources/values/strings_drafts.xml b/common/src/commonMain/composeResources/values/strings_drafts.xml index 248b08a57..ef56c03bd 100644 --- a/common/src/commonMain/composeResources/values/strings_drafts.xml +++ b/common/src/commonMain/composeResources/values/strings_drafts.xml @@ -1,16 +1,16 @@ - - - Merge with Current - Copy parts of the draft into here - Take Merged - Draft: %1$s - Copy desired parts into current, or accept all of this. - Take this whole draft - Draft - Current - - %1$s Drafts: - No Drafts Found - - Invalid name - + + + Merge with Current + Copy parts of the draft into here + Take Merged + Draft: %1$s + Copy desired parts into current, or accept all of this. + Take this whole draft + Draft + Current + + %1$s Drafts: + No Drafts Found + + Invalid name + diff --git a/common/src/commonMain/composeResources/values/strings_encyclopedia.xml b/common/src/commonMain/composeResources/values/strings_encyclopedia.xml index d41a73dfd..423aa634b 100644 --- a/common/src/commonMain/composeResources/values/strings_encyclopedia.xml +++ b/common/src/commonMain/composeResources/values/strings_encyclopedia.xml @@ -1,87 +1,87 @@ - - - Encyclopedia - - Search - Clear - - Person - Place - Thing - Event - Idea - - No Entries Found - Create Entry - Create entry - New - Draft - Name - e.g. Cheshire Cat - Tags - ↵ to add - ritual, magical, recurring… - Description - Cover Art - Drop image · or - Browse files - 1 attached - Replace - Image is too large. Maximum size is %1$d MB. - Could not read the selected image. - Create entry - Cancel - Entry Created - Entry Name was too long. Max %1$d - The entry name must be alphanumeric - Tag is too long. Max %1$d - Alias is too long. Max %1$d - Aliases - Add Alias - Add - Nickname or other name - Appears in scenes - Not yet referenced in any scene - Entry name cannot be empty - Discard New Entry? - Error: Failed to load entry! - Click to enter description - Cancel - Save - Entry Saved - Discard Changes? - You will lose any changes you have made. - - Delete Entry? - This cannot be undone! - Entry Deleted - Delete Image? - This cannot be undone! - Name - Close Entry - Entry - Delete Entry - Remove Image - Add Image - Add Tags - Add Tags - - Encyclopedia - Edit - Particulars - Type - Tags - Aliases - Scenes - Tags - tag - alias - Fig. I · Entry - Fig. I - + Add - Fol. %1$s · Ent-%2$s - Sc.%1$s - Entry · Catalogue card - Edited - End of entry ✦ - + + + Encyclopedia + + Search + Clear + + Person + Place + Thing + Event + Idea + + No Entries Found + Create Entry + Create entry + New + Draft + Name + e.g. Cheshire Cat + Tags + ↵ to add + ritual, magical, recurring… + Description + Cover Art + Drop image · or + Browse files + 1 attached + Replace + Image is too large. Maximum size is %1$d MB. + Could not read the selected image. + Create entry + Cancel + Entry Created + Entry Name was too long. Max %1$d + The entry name must be alphanumeric + Tag is too long. Max %1$d + Alias is too long. Max %1$d + Aliases + Add Alias + Add + Nickname or other name + Appears in scenes + Not yet referenced in any scene + Entry name cannot be empty + Discard New Entry? + Error: Failed to load entry! + Click to enter description + Cancel + Save + Entry Saved + Discard Changes? + You will lose any changes you have made. + + Delete Entry? + This cannot be undone! + Entry Deleted + Delete Image? + This cannot be undone! + Name + Close Entry + Entry + Delete Entry + Remove Image + Add Image + Add Tags + Add Tags + + Encyclopedia + Edit + Particulars + Type + Tags + Aliases + Scenes + Tags + tag + alias + Fig. I · Entry + Fig. I + + Add + Fol. %1$s · Ent-%2$s + Sc.%1$s + Entry · Catalogue card + Edited + End of entry ✦ + diff --git a/common/src/commonMain/composeResources/values/strings_globalsearch.xml b/common/src/commonMain/composeResources/values/strings_globalsearch.xml index bc78f6853..459a6c232 100644 --- a/common/src/commonMain/composeResources/values/strings_globalsearch.xml +++ b/common/src/commonMain/composeResources/values/strings_globalsearch.xml @@ -1,19 +1,19 @@ - - - Search project - Search project - Type to search, use #tag to filter… - Clear search - No results - Type at least 2 characters to search - %1$d results - Scene - Note - Encyclopedia - Timeline - All - Scenes - Notes - Encyclopedia - Timeline - + + + Search project + Search project + Type to search, use #tag to filter… + Clear search + No results + Type at least 2 characters to search + %1$d results + Scene + Note + Encyclopedia + Timeline + All + Scenes + Notes + Encyclopedia + Timeline + diff --git a/common/src/commonMain/composeResources/values/strings_ideas.xml b/common/src/commonMain/composeResources/values/strings_ideas.xml index 8f0eb8f28..3911d12e7 100644 --- a/common/src/commonMain/composeResources/values/strings_ideas.xml +++ b/common/src/commonMain/composeResources/values/strings_ideas.xml @@ -1,94 +1,94 @@ - - - Story Ideas - IDEA - - Story Ideas - %1$d IDEAS · %2$d TAGS - No story ideas yet. Capture your first spark. - No archived ideas. - %1$d W - %1$d TAGS - - Search ideas - Search ideas - Clear search - Close search - - All - Archived - FILTERED %1$d / %2$d - Clear All - - SORT - Newest - Oldest - Title A–Z - ↓ DATE - ↑ DATE - ↓ A–Z - - PROMOTED · %1$s - ARCHIVED · %1$s - - Capture a new idea - New Idea - Story Idea - Editing - UNSAVED - STORY IDEAS - Close idea - Edit - Title - Optional title - Tags - Enter or comma adds a tag - Add tag… - %1$d / %2$d CH - Create - Save - Cancel - Delete - Archive - Unarchive - - Idea captured - Idea saved - Idea deleted - Idea archived - Idea unarchived - Idea cannot be empty - Idea is too long: %1$d characters max - Tags can be at most %1$d characters - - Create Project - Create Project from Idea - A new project will be created and this idea will be added to it as its first note. - Project created: %1$s - Failed to create project - - Delete Idea - This idea will be permanently deleted. This cannot be undone. - Discard Changes - You have unsaved changes. Discard them? - - Syncing story ideas - Story ideas unchanged — skipped - Story ideas sync complete - Failed to fetch story ideas state: %1$s - Removed deleted idea: %1$s - Propagated idea deletion to server: %1$s - Failed to delete idea on server: %1$s - Uploaded idea: %1$s - Failed to upload idea "%1$s": %2$s - Downloaded idea: %1$s - Failed to download idea %1$s: %2$s - Conflict detected for idea: %1$s - Resolved conflict for idea: %1$s - Conflict left unresolved for idea: %1$s - - Story Idea Conflict - Title - Tags - Content - + + + Story Ideas + IDEA + + Story Ideas + %1$d IDEAS · %2$d TAGS + No story ideas yet. Capture your first spark. + No archived ideas. + %1$d W + %1$d TAGS + + Search ideas + Search ideas + Clear search + Close search + + All + Archived + FILTERED %1$d / %2$d + Clear All + + SORT + Newest + Oldest + Title A–Z + ↓ DATE + ↑ DATE + ↓ A–Z + + PROMOTED · %1$s + ARCHIVED · %1$s + + Capture a new idea + New Idea + Story Idea + Editing + UNSAVED + STORY IDEAS + Close idea + Edit + Title + Optional title + Tags + Enter or comma adds a tag + Add tag… + %1$d / %2$d CH + Create + Save + Cancel + Delete + Archive + Unarchive + + Idea captured + Idea saved + Idea deleted + Idea archived + Idea unarchived + Idea cannot be empty + Idea is too long: %1$d characters max + Tags can be at most %1$d characters + + Create Project + Create Project from Idea + A new project will be created and this idea will be added to it as its first note. + Project created: %1$s + Failed to create project + + Delete Idea + This idea will be permanently deleted. This cannot be undone. + Discard Changes + You have unsaved changes. Discard them? + + Syncing story ideas + Story ideas unchanged — skipped + Story ideas sync complete + Failed to fetch story ideas state: %1$s + Removed deleted idea: %1$s + Propagated idea deletion to server: %1$s + Failed to delete idea on server: %1$s + Uploaded idea: %1$s + Failed to upload idea "%1$s": %2$s + Downloaded idea: %1$s + Failed to download idea %1$s: %2$s + Conflict detected for idea: %1$s + Resolved conflict for idea: %1$s + Conflict left unresolved for idea: %1$s + + Story Idea Conflict + Title + Tags + Content + diff --git a/common/src/commonMain/composeResources/values/strings_notes.xml b/common/src/commonMain/composeResources/values/strings_notes.xml index 0a18cfe1b..228820d47 100644 --- a/common/src/commonMain/composeResources/values/strings_notes.xml +++ b/common/src/commonMain/composeResources/values/strings_notes.xml @@ -1,59 +1,59 @@ - - - Notes - No Notes Found - Create Note - Cancel - Discard Changes? - You will lose any changes you have made. - Create Note - III · NEW - DRAFT - Create - Cancel - Note Created - Note was too long - Note was empty - Tag was too long (max %1$d) - Tags - ↵ to add - idea, todo, follow-up… - Delete Note? - This action cannot be undone! - Note Deleted - - Note - Delete - - Note - Editing - Close Note - Notes - Edit - Save - UNSAVED - %1$d W · %2$d CH - ESC TO CANCEL - - Search notes - Search notes - Close search - Clear search - - %1$d NOTES · %2$d TAGS - %1$d W - ALL - FILTERED · %1$d OF %2$d - CLEAR ALL - SORT - Newest - Oldest - Longest - Shortest - Title A–Z - ↓ DATE - ↑ DATE - ↓ WORDS - ↑ WORDS - ↓ A–Z - + + + Notes + No Notes Found + Create Note + Cancel + Discard Changes? + You will lose any changes you have made. + Create Note + III · NEW + DRAFT + Create + Cancel + Note Created + Note was too long + Note was empty + Tag was too long (max %1$d) + Tags + ↵ to add + idea, todo, follow-up… + Delete Note? + This action cannot be undone! + Note Deleted + + Note + Delete + + Note + Editing + Close Note + Notes + Edit + Save + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + + Search notes + Search notes + Close search + Clear search + + %1$d NOTES · %2$d TAGS + %1$d W + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + SORT + Newest + Oldest + Longest + Shortest + Title A–Z + ↓ DATE + ↑ DATE + ↓ WORDS + ↑ WORDS + ↓ A–Z + diff --git a/common/src/commonMain/composeResources/values/strings_project_home.xml b/common/src/commonMain/composeResources/values/strings_project_home.xml index 694f4a16d..5831024a3 100644 --- a/common/src/commonMain/composeResources/values/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values/strings_project_home.xml @@ -1,136 +1,131 @@ - - - Created: %1$s - Calculating statistics... - Refresh statistics - Statistics may be out of date - Scenes - Total Words - Encyclopedia Entries - - Words in Chapters - Chapter - %1$s: %2$s words - - Avg Words / Scene - Notes - Timeline Events - Longest Scene - %1$s words - No scenes yet - Last Edited - Median - - ≈ %1$d min reading - %1$d pages - - Daily goal - Weekly goal - - This week - no change vs last week - no data last week - Today - Daily avg - - Streak - %1$d days - Longest: %1$d - Days written - Best day - %1$s · %2$s - - Characters by Appearances - %1$d connections - - Words by Device - - Activity · %1$d weeks - %1$s · %2$s words - %1$s · no writing - Avg weekday - Avg weekend - - σ %1$d · min %2$d · max %3$d - entries - %1$s · %2$s entries - - Themes - %1$d tags · %2$d uses - %1$d/4 - Most connective - Appears in all four types · %1$d uses - Appears in %1$d of 4 types · %2$d uses - All 4 types - %1$d of 4 types - Distribution - Showing %1$d of %2$d - Rank · Tag · Breadth · Uses - Scenes - Notes - Encyclopedia - Events - - Export Story - Sync Story - Backup - Backup Created: %1$s - Backup Failed - - Story Exported - Export failed - - Export Story - EXPORT - × CLOSE - Treat top-level scenes as chapters - Format - Markdown (.md) - EPub (.epub) - PDF (.pdf) - Word (.docx) - RTF (.rtf) - Cancel - Export - - Learn about export formats - Choosing an Export Format - Each format suits a different purpose. Pick the one that matches how you plan to use the exported story. - Plain text with light formatting; ideal for version control, re-importing, or other writing tools. - The standard e-book format; best for beta readers reading on e-readers and phones with reflowable text. - A fixed page layout that looks the same everywhere; best for printing or sharing a final, read-only copy. - Opens in Word and Google Docs; best for editors and beta readers who leave comments or track changes. - A broadly compatible rich-text format; a safe choice when you are unsure which word processor the recipient uses. - Got it - - Contents - by %1$s - - Story Imported - Import failed - Import Story - Chapter detection - Auto - H1 (#) - H2 (##) - Split into scenes - Formatting - Pattern - Single scene - Chapter pattern (regex) - Create chapter groups - Preview - Nothing to import - Reading your manuscript - Will create %1$d scene(s) - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W - Cancel - Import - - Settings - - Project menu - + + + Created: %1$s + Calculating statistics... + Refresh statistics + Statistics may be out of date + Scenes + Total Words + Encyclopedia Entries + + Words in Chapters + Chapter + %1$s: %2$s words + + Avg Words / Scene + Notes + Timeline Events + Longest Scene + %1$s words + No scenes yet + Last Edited + Median + + ≈ %1$d min reading + %1$d pages + + Daily goal + Weekly goal + + This week + no change vs last week + no data last week + Today + Daily avg + + Streak + %1$d days + Longest: %1$d + Days written + Best day + %1$s · %2$s + + Characters by Appearances + %1$d connections + + Words by Device + + Activity · %1$d weeks + %1$s · %2$s words + %1$s · no writing + Avg weekday + Avg weekend + + σ %1$d · min %2$d · max %3$d + entries + %1$s · %2$s entries + + Themes + %1$d tags · %2$d uses + %1$d/4 + Most connective + Appears in all four types · %1$d uses + Appears in %1$d of 4 types · %2$d uses + All 4 types + %1$d of 4 types + Distribution + Showing %1$d of %2$d + Rank · Tag · Breadth · Uses + Scenes + Notes + Encyclopedia + Events + + Export Story + Sync Story + Backup + Backup Created: %1$s + Backup Failed + + Story Exported + Export failed + + Export Story + EXPORT + × CLOSE + Treat top-level scenes as chapters + Format + Markdown (.md) + EPub (.epub) + PDF (.pdf) + Word (.docx) + RTF (.rtf) + Cancel + Export + + Learn about export formats + Choosing an Export Format + Each format suits a different purpose. Pick the one that matches how you plan to use the exported story. + Plain text with light formatting; ideal for version control, re-importing, or other writing tools. + The standard e-book format; best for beta readers reading on e-readers and phones with reflowable text. + A fixed page layout that looks the same everywhere; best for printing or sharing a final, read-only copy. + Opens in Word and Google Docs; best for editors and beta readers who leave comments or track changes. + A broadly compatible rich-text format; a safe choice when you are unsure which word processor the recipient uses. + Got it + + Contents + by %1$s + + Story Imported + Import failed + Import Story + Chapter detection + Auto + H1 (#) + H2 (##) + Split into scenes + Formatting + Pattern + Single scene + Chapter pattern (regex) + Create chapter groups + Preview + Nothing to import + Will create %1$d scene(s) + Cancel + Import + + Settings + + Project menu + diff --git a/common/src/commonMain/composeResources/values/strings_project_navigation.xml b/common/src/commonMain/composeResources/values/strings_project_navigation.xml index 456df9eb4..0e4b081d8 100644 --- a/common/src/commonMain/composeResources/values/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values/strings_project_navigation.xml @@ -1,15 +1,15 @@ - - - Home - Story - Notes - Encyclopedia - Timeline - HOME - STRY - NOTE - ENCY - TIME - Expand navigation - Collapse navigation + + + Home + Story + Notes + Encyclopedia + Timeline + HOME + STRY + NOTE + ENCY + TIME + Expand navigation + Collapse navigation \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml b/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml index 50ed7f191..77dec77e5 100644 --- a/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml +++ b/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml @@ -1,10 +1,10 @@ - - - Projects - Settings - About - PROJ - SETT - INFO - Libraries + + + Projects + Settings + About + PROJ + SETT + INFO + Libraries \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values/strings_projects_list.xml b/common/src/commonMain/composeResources/values/strings_projects_list.xml index a26dc0f55..fa89daab0 100644 --- a/common/src/commonMain/composeResources/values/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values/strings_projects_list.xml @@ -1,80 +1,80 @@ - - - No Projects Found - Delete - Rename - Sync Projects - Create Project - Projects synced - Failed to sync projects - More - %1$s words - by %1$s - Created %1$s - Project %1$s - Search projects - Close search - filter by name or #tag - Clear filter - No projects match your filter - Hammer · Library - Title - Last Open - — Empty Index — - Library - Sync · On - Local - 0 entries - 1 entry - %1$d entries - SORT - Recently Opened - Least Recent - Newest - Oldest - Longest - Shortest - ↓ OPENED - ↑ OPENED - ↓ CREATED - ↑ CREATED - ↓ WORDS - ↑ WORDS - Account Sync - Sync Log - Project - All - Pending - Error - Conflict - Sync Complete - Canceled - Delete Project - This action cannot be undone. All project data will be permanently deleted. - Type "%1$s" to confirm - Delete - Cancel - Rename Project - New Project Name - Rename Project - Cancel - Create Project - New - Project - New Project Name - Create Project - Cancel - Import - Learn about importing a story - Create from an Existing Story - Already have a manuscript? Import it to create a new project. Hammer names the project after your file and automatically splits the text into scenes. - Supported Formats - Markdown (.md) - Rich Text (.rtf) - Got it - Project already exists - Missing Project Name - Project name too long - The project name contains invalid characters - The project name was left blank - + + + No Projects Found + Delete + Rename + Sync Projects + Create Project + Projects synced + Failed to sync projects + More + %1$s words + by %1$s + Created %1$s + Project %1$s + Search projects + Close search + filter by name or #tag + Clear filter + No projects match your filter + Hammer · Library + Title + Last Open + — Empty Index — + Library + Sync · On + Local + 0 entries + 1 entry + %1$d entries + SORT + Recently Opened + Least Recent + Newest + Oldest + Longest + Shortest + ↓ OPENED + ↑ OPENED + ↓ CREATED + ↑ CREATED + ↓ WORDS + ↑ WORDS + Account Sync + Sync Log + Project + All + Pending + Error + Conflict + Sync Complete + Canceled + Delete Project + This action cannot be undone. All project data will be permanently deleted. + Type "%1$s" to confirm + Delete + Cancel + Rename Project + New Project Name + Rename Project + Cancel + Create Project + New + Project + New Project Name + Create Project + Cancel + Import + Learn about importing a story + Create from an Existing Story + Already have a manuscript? Import it to create a new project. Hammer names the project after your file and automatically splits the text into scenes. + Supported Formats + Markdown (.md) + Rich Text (.rtf) + Got it + Project already exists + Missing Project Name + Project name too long + The project name contains invalid characters + The project name was left blank + diff --git a/common/src/commonMain/composeResources/values/strings_scene_editor.xml b/common/src/commonMain/composeResources/values/strings_scene_editor.xml index d4ecabcfd..69e11dc01 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_editor.xml @@ -1,93 +1,93 @@ - - - Start New Draft: - Draft name - Save - Cancel - Draft Saved - Saving current draft as: - Current draft name is invalid! - Rename Scene - Scene Name - Rename - Cancel - Unsaved - Save - Scene Info - Saved - Scene - New Draft - View Drafts - Rename - Discard - Delete - Archive - Save - Close - - Archive Scene - Are you sure you want to archive '%1$s'? The scene will be hidden from the scene list but can be restored later. - Archive - Cancel - - Discard Changes - Are you sure you want to discard all unsaved changes to '%1$s'? This will reload the last saved version from disk. - Discard - Cancel - - Archived Scenes - No archived scenes - Restore - "%1$s" has been restored - "%1$s" has been archived - View Archived - - Enter Focus Mode - - Metadata - Hide Info - Words: - Advanced - Entity ID: - Filename: - Path: - Created - Edited - — - - Outline - A short description of what happens in the scene. - - Notes - Notes to yourself. - Draft Name - - References - Dismissed - No references yet - Dismiss - Restore - (dismissed) - Characters - Locations - Other - + Add reference - Add reference - Done - Search characters… - Search locations… - Search entries… - No matching entries. - Close dialog - - Tags - No tags yet - + Add tag - Add tags - tag1 tag2 … - Done - - Select a Scene to edit - - New Draft - + + + Start New Draft: + Draft name + Save + Cancel + Draft Saved + Saving current draft as: + Current draft name is invalid! + Rename Scene + Scene Name + Rename + Cancel + Unsaved + Save + Scene Info + Saved + Scene + New Draft + View Drafts + Rename + Discard + Delete + Archive + Save + Close + + Archive Scene + Are you sure you want to archive '%1$s'? The scene will be hidden from the scene list but can be restored later. + Archive + Cancel + + Discard Changes + Are you sure you want to discard all unsaved changes to '%1$s'? This will reload the last saved version from disk. + Discard + Cancel + + Archived Scenes + No archived scenes + Restore + "%1$s" has been restored + "%1$s" has been archived + View Archived + + Enter Focus Mode + + Metadata + Hide Info + Words: + Advanced + Entity ID: + Filename: + Path: + Created + Edited + — + + Outline + A short description of what happens in the scene. + + Notes + Notes to yourself. + Draft Name + + References + Dismissed + No references yet + Dismiss + Restore + (dismissed) + Characters + Locations + Other + + Add reference + Add reference + Done + Search characters… + Search locations… + Search entries… + No matching entries. + Close dialog + + Tags + No tags yet + + Add tag + Add tags + tag1 tag2 … + Done + + Select a Scene to edit + + New Draft + diff --git a/common/src/commonMain/composeResources/values/strings_scene_list.xml b/common/src/commonMain/composeResources/values/strings_scene_list.xml index b61709de8..784b20dcf 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_list.xml @@ -1,68 +1,68 @@ - - - Scenes - Create Group - Group Name - Create Scene - Scene Name - Group Collapsed - Group Expanded - Delete Scene - Are you sure you want to delete this scene: "%1$s" - Delete - Dismiss - Cannot Delete Group - You cannot delete a group while it still has children: "%1$s" - - Create - Cancel - - Story Outline - Outline Overview - <no outline> - Dismiss - Manuscript - An outline of - Jump to - Chapters - Scenes - Chapter %1$s · %2$d Scenes - %1$d SCN - End of outline - %1$d/%2$d - Untitled chapter - - Options - Delete - Rename - Archive - Archive Scene - Are you sure you want to archive this scene: "%1$s" - Archive - Cancel - Rename Scene - Scene Name - Rename - - Create Scene - Create Group - - Move To - Destination - Search groups - Top Level - No matching groups - Position (1–%1$d) - Enter a position between 1 and %1$d - Move - Cancel - - %1$d · %2$d GR - New Scene - New Group - Add - Move - Scene - Group - Root - + + + Scenes + Create Group + Group Name + Create Scene + Scene Name + Group Collapsed + Group Expanded + Delete Scene + Are you sure you want to delete this scene: "%1$s" + Delete + Dismiss + Cannot Delete Group + You cannot delete a group while it still has children: "%1$s" + + Create + Cancel + + Story Outline + Outline Overview + <no outline> + Dismiss + Manuscript + An outline of + Jump to + Chapters + Scenes + Chapter %1$s · %2$d Scenes + %1$d SCN + End of outline + %1$d/%2$d + Untitled chapter + + Options + Delete + Rename + Archive + Archive Scene + Are you sure you want to archive this scene: "%1$s" + Archive + Cancel + Rename Scene + Scene Name + Rename + + Create Scene + Create Group + + Move To + Destination + Search groups + Top Level + No matching groups + Position (1–%1$d) + Enter a position between 1 and %1$d + Move + Cancel + + %1$d · %2$d GR + New Scene + New Group + Add + Move + Scene + Group + Root + diff --git a/common/src/commonMain/composeResources/values/strings_sync.xml b/common/src/commonMain/composeResources/values/strings_sync.xml index 4fa3410fb..92de48a52 100644 --- a/common/src/commonMain/composeResources/values/strings_sync.xml +++ b/common/src/commonMain/composeResources/values/strings_sync.xml @@ -1,251 +1,251 @@ - - - Project Synchronization - - Scene Conflict - Scene Draft Conflict - Note Conflict - Timeline Event Conflict - - No Date - - Local - Remote - - %1$s Conflict - Scene - Note - Timeline Event - Encyclopedia Entry - Scene Draft - Project Settings - - - You can take either the local or remote copy as is, or you can manually merge the two by copying parts of the remote into your local copy, and then taking that. - - - - Encyclopedia Entry Conflict - - Author - Custom theme - Use a custom theme for this project - Primary - Secondary - Word-count goal - Track a writing goal - Words - Per - Day - Week - Effective - W / day - Tags - Tags - ↵ to add - fantasy, draft, nanowrimo… - Edit - Cancel - OK - Home - Project Settings - Autosaved - § 0 · Project - by %1$s - by (no author set) - Spell checking - Project Settings · %1$s - §§ %1$d - - Project Settings Conflict - Pick a side for each field. Local is what you have on this device; Server is what was synced from another device. - Author Name - Project Theme - Word Count Goal - Tags - (not set) - day - week - Resolve - - - Name: - Type: - Image: - Content: - Tags: - - Has Image - - Name - Scene Content - Outline - Notes - Content - Metadata - References - Confirmed - Dismissed - None - Aliases: - - Name - Scene Content - - Note Content - - Date - Event Content - - Sync - Start Sync - - Synchronizing... - Sync Failed - Sync Complete - - - Project Sync Complete - Project Sync Failed - - Begin Sync - Server data loaded - Account Sync complete - Failed to sync projects: %1$s - Deleting server project: - ID(%1$s) - - Failed to delete project on server: %1$s - Deleting local project: %1$s - - Renaming server project: - ID(%1$s) - - Failed to rename project on - server: %1$s - - - Rename project from - server: %1$s - - Failed to rename - project from server: %1$s - - - Created project on server: %1$s - Failed to create project on server: %1$s - Created local project: %1$s - Setting local project Server ID: - %1$s - - Failed to create server project: - %1$s - - - Syncing Account... - Syncing Projects... - Projects Sync Complete - Syncing Project: %1$s - - There is a conflict in project: %1$s, open that project and sync in order to resolve it - - - Client Entity data calculated - Server data received - Client data loaded - Local Backup made: %1$s - Entity %1$d downloaded - Failed to download entity %1$d - Entity %1$d not found on server - Entity %1$d not modified - Rejected download for entity %1$d: server returned a mismatched id or type - Failed to upload entity %1$d: type not owned by anything, probably deleted - Failed to delete ID %1$d on server: %2$s - Deleted ID %1$d on server - Encountered conflict for new Entity, this should not be possible! ID: %1$d TYPE: %2$s - - Sync failed: %1$s - Server cached hash is stale for entity %1$d. Healing by force uploading. - Successfully healed server cache for entity %1$d by force uploading - Failed to heal server cache for entity %1$d - force upload failed - Entities transferred - Sync finalized - Sync data saved - Sync data not saved due to errors - Failed to end sync - - Rejected draft %1$d from server: unsafe draft name - Skipped image for encyclopedia entry %1$d from server: unsafe file extension - - Deleted Encyclopedia ID %1$d from client - Deleted note ID %1$d from client - Deleted draft %1$d from client - Deleted scene %1$d from client - Failed to delete scene %1$d from client - Failed find scene to delete: %1$d - Deleted event %1$d from client - Failed to delete event %1$d from client - Failed find event to delete: %1$d - - Creating new scene %1$d - Downloaded scene content for: %1$d - Moved scene %1$d to new parent %2$d - Failed to save downloaded scene content for: %1$d - - Creating new group %1$d - Downloaded scene content for: %1$d - Moved scene %1$d to new parent %2$d - - Unauthorized - Unhandled server error - - UPDATE REQUIRED - Your app is out of date - The sync server is out of date - This version of Hammer can no longer sync with the server. Update to the latest version to keep syncing your work. - The sync server is running an older protocol than this app. The server needs to be updated before syncing will work again. - Latest version: %s - You have: %s - Get the Update - Dismiss - - Reauthorize - Your token has expired, please reauthorize with your sync server. - Token Expired - Address - E-Mail - - About Sync Servers - Sync Servers for Hammer allow you to work on your projects from multiple devices. They will sync your work across whatever devices you configure with the same server. - - Official Server - You can run your own Sync server, but if that is too technical, or you just want to support the project, we run an official server at hammer.ink. - - If you subscribe to our Patreon, you get access to the official server. This is the best way to support this project. - - Patreon - Hammer.Ink - Registration - By default, servers have closed registration, meaning you need to get added to that server's whitelist to be able to create an account. - - SetUp - Enter the server's URL, such as "hammer.ink" for the official server. - - Enter your email and password, then hit "Create" to create your account. - - In the future, and on other devices, you will simply hit "Log In". - - Important Note - Syncing in Hammer is different from online-first applications like Google Docs. The syncing is not real-time. It usually happens when you open or close the app. Make sure you're synced with the latest versions of your projects before going offline. Hammer does automatic syncing, but it's always good to check if you're going to be offline for a while. - - Got It - + + + Project Synchronization + + Scene Conflict + Scene Draft Conflict + Note Conflict + Timeline Event Conflict + + No Date + + Local + Remote + + %1$s Conflict + Scene + Note + Timeline Event + Encyclopedia Entry + Scene Draft + Project Settings + + + You can take either the local or remote copy as is, or you can manually merge the two by copying parts of the remote into your local copy, and then taking that. + + + + Encyclopedia Entry Conflict + + Author + Custom theme + Use a custom theme for this project + Primary + Secondary + Word-count goal + Track a writing goal + Words + Per + Day + Week + Effective + W / day + Tags + Tags + ↵ to add + fantasy, draft, nanowrimo… + Edit + Cancel + OK + Home + Project Settings + Autosaved + § 0 · Project + by %1$s + by (no author set) + Spell checking + Project Settings · %1$s + §§ %1$d + + Project Settings Conflict + Pick a side for each field. Local is what you have on this device; Server is what was synced from another device. + Author Name + Project Theme + Word Count Goal + Tags + (not set) + day + week + Resolve + + + Name: + Type: + Image: + Content: + Tags: + + Has Image + + Name + Scene Content + Outline + Notes + Content + Metadata + References + Confirmed + Dismissed + None + Aliases: + + Name + Scene Content + + Note Content + + Date + Event Content + + Sync + Start Sync + + Synchronizing... + Sync Failed + Sync Complete + + + Project Sync Complete + Project Sync Failed + + Begin Sync + Server data loaded + Account Sync complete + Failed to sync projects: %1$s + Deleting server project: + ID(%1$s) + + Failed to delete project on server: %1$s + Deleting local project: %1$s + + Renaming server project: + ID(%1$s) + + Failed to rename project on + server: %1$s + + + Rename project from + server: %1$s + + Failed to rename + project from server: %1$s + + + Created project on server: %1$s + Failed to create project on server: %1$s + Created local project: %1$s + Setting local project Server ID: + %1$s + + Failed to create server project: + %1$s + + + Syncing Account... + Syncing Projects... + Projects Sync Complete + Syncing Project: %1$s + + There is a conflict in project: %1$s, open that project and sync in order to resolve it + + + Client Entity data calculated + Server data received + Client data loaded + Local Backup made: %1$s + Entity %1$d downloaded + Failed to download entity %1$d + Entity %1$d not found on server + Entity %1$d not modified + Rejected download for entity %1$d: server returned a mismatched id or type + Failed to upload entity %1$d: type not owned by anything, probably deleted + Failed to delete ID %1$d on server: %2$s + Deleted ID %1$d on server + Encountered conflict for new Entity, this should not be possible! ID: %1$d TYPE: %2$s + + Sync failed: %1$s + Server cached hash is stale for entity %1$d. Healing by force uploading. + Successfully healed server cache for entity %1$d by force uploading + Failed to heal server cache for entity %1$d - force upload failed + Entities transferred + Sync finalized + Sync data saved + Sync data not saved due to errors + Failed to end sync + + Rejected draft %1$d from server: unsafe draft name + Skipped image for encyclopedia entry %1$d from server: unsafe file extension + + Deleted Encyclopedia ID %1$d from client + Deleted note ID %1$d from client + Deleted draft %1$d from client + Deleted scene %1$d from client + Failed to delete scene %1$d from client + Failed find scene to delete: %1$d + Deleted event %1$d from client + Failed to delete event %1$d from client + Failed find event to delete: %1$d + + Creating new scene %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + Failed to save downloaded scene content for: %1$d + + Creating new group %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + + Unauthorized + Unhandled server error + + UPDATE REQUIRED + Your app is out of date + The sync server is out of date + This version of Hammer can no longer sync with the server. Update to the latest version to keep syncing your work. + The sync server is running an older protocol than this app. The server needs to be updated before syncing will work again. + Latest version: %s + You have: %s + Get the Update + Dismiss + + Reauthorize + Your token has expired, please reauthorize with your sync server. + Token Expired + Address + E-Mail + + About Sync Servers + Sync Servers for Hammer allow you to work on your projects from multiple devices. They will sync your work across whatever devices you configure with the same server. + + Official Server + You can run your own Sync server, but if that is too technical, or you just want to support the project, we run an official server at hammer.ink. + + If you subscribe to our Patreon, you get access to the official server. This is the best way to support this project. + + Patreon + Hammer.Ink + Registration + By default, servers have closed registration, meaning you need to get added to that server's whitelist to be able to create an account. + + SetUp + Enter the server's URL, such as "hammer.ink" for the official server. + + Enter your email and password, then hit "Create" to create your account. + + In the future, and on other devices, you will simply hit "Log In". + + Important Note + Syncing in Hammer is different from online-first applications like Google Docs. The syncing is not real-time. It usually happens when you open or close the app. Make sure you're synced with the latest versions of your projects before going offline. Hammer does automatic syncing, but it's always good to check if you're going to be offline for a while. + + Got It + diff --git a/common/src/commonMain/composeResources/values/strings_timeline.xml b/common/src/commonMain/composeResources/values/strings_timeline.xml index 5a389c0bf..28e4dc5d0 100644 --- a/common/src/commonMain/composeResources/values/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values/strings_timeline.xml @@ -1,57 +1,57 @@ - - - No Events - Create Event - - Event Created - Tag was too long (max %1$d) - Tags - ↵ to add - arc, foreshadow, conflict… - - Event - Editing - Timeline - Save - Cancel - Edit - Close Entry - Date - optional - e.g. Spring of Y3 - UNDATED - UNSAVED - %1$d W · %2$d CH - ESC TO CANCEL - %1$d W - %1$d TAGS - Saved - Failed to save - Create Event - III · NEW - DRAFT - %1$d W · %2$d CH - Cancel - Create - optional - e.g. Spring of Y3 - - Discard Changes? - You will lose any changes you have made. - - Event - Delete - - Delete Timeline Event? - This cannot be undone! - - Timeline - %1$d ENTRIES · %2$d TAGS - Search timeline - Search timeline - Close search - Clear search - ALL - FILTERED · %1$d OF %2$d - CLEAR ALL - + + + No Events + Create Event + + Event Created + Tag was too long (max %1$d) + Tags + ↵ to add + arc, foreshadow, conflict… + + Event + Editing + Timeline + Save + Cancel + Edit + Close Entry + Date + optional + e.g. Spring of Y3 + UNDATED + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + %1$d W + %1$d TAGS + Saved + Failed to save + Create Event + III · NEW + DRAFT + %1$d W · %2$d CH + Cancel + Create + optional + e.g. Spring of Y3 + + Discard Changes? + You will lose any changes you have made. + + Event + Delete + + Delete Timeline Event? + This cannot be undone! + + Timeline + %1$d ENTRIES · %2$d TAGS + Search timeline + Search timeline + Close search + Clear search + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 331f7a35f..38b979da3 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multi-platform - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Transparent Data - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multi-platform + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Transparent Data + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 331f7a35f..38256b092 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,6 +1,6 @@ Please join our Discord to report issues! https://discord.gg/GTmgjZcupk -Multi-platform +Multiplataforma This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. @@ -8,7 +8,7 @@ Offline first I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. -Transparent Data +Datos transparentes Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt index 39875ab6f..eff42488a 100644 --- a/fastlane/metadata/android/es-ES/short_description.txt +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -1 +1 @@ -A simple tool for building stories. \ No newline at end of file +Una herramienta sencilla para crear historias. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 331f7a35f..fff9b8f79 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk +Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk -Multi-platform +Multiplateforme -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. +Cette application est utilisable peu importe l'endroit où vous vous trouvez. Votre smartphone, tablette, ordinateur de bureau et ordinateur portable sera en mesure de pouvoir installer et utiliser ce programme, ce n'est pas une application web de poche, mais une véritable application client afin de fournir la meilleure expérience possible. -Offline first +Hors ligne avant tout -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. +J'ai été frustré par la plupart des logiciels de rédaction d'histoire que je trouvais puisqu'ils utilisaient tous des technologies web (ex : page web déguisé en application) qui semblent toujours rencontrer des problèmes lorsqu'ils sont utilisés hors ligne trop longtemps. Hammer a été conçu dans le but d'être entièrement fonctionnel en local, sans aucune connexion internet nécessaire, pour toujours. -Transparent Data +Transparence des données -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. +Vos données vous appartiennent. Ils ne sont pas stockés dans le cloud, ou dans une obscure base de données. Tout est stocké dans des fichiers humainement lisibles, utilisant uniquement des fichiers et des dossiers pour définir la structure du projet. Vous pouvez donc y jeter un œil par vous-même en naviguant dans vos fichiers. Si ce programme disparaissait aujourd'hui, vous serez en mesure d'interagir facilement avec vos données. -Intelligent Syncing between devices +Synchronisation intelligente entre vos appareils -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. +Vos données peuvent être synchronisées entre plusieurs appareils vous permettant de poursuivre l'écriture de votre histoire là où vous le souhaitez, sans avoir la crainte qu'une édition faite sur l'un de vos appareils n'en écrase une faite sur un autre. -Optional and Self hosted Syncing +Synchronisation optionnelle et auto-hébergée -Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file +La synchronisation est entièrement optionnelle, vous pouvez installer le client et l'utiliser sur votre appareil sans aucune action supplémentaire, ou vous pouvez installer un serveur pour la synchronisation entre vos appareils. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt index 39875ab6f..b1e12f28d 100644 --- a/fastlane/metadata/android/fr-FR/short_description.txt +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -1 +1 @@ -A simple tool for building stories. \ No newline at end of file +Un outil pour créer des histoires. \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 331f7a35f..70819e6e8 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -1,6 +1,6 @@ Please join our Discord to report issues! https://discord.gg/GTmgjZcupk -Multi-platform +Multipiattaforma This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. @@ -8,7 +8,7 @@ Offline first I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. -Transparent Data +Dati trasparenti Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt index 39875ab6f..bc989424e 100644 --- a/fastlane/metadata/android/it-IT/short_description.txt +++ b/fastlane/metadata/android/it-IT/short_description.txt @@ -1 +1 @@ -A simple tool for building stories. \ No newline at end of file +Uno strumento semplice per costruire storie. \ No newline at end of file diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 331f7a35f..ba77097fb 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -1,6 +1,6 @@ Please join our Discord to report issues! https://discord.gg/GTmgjZcupk -Multi-platform +Багатоплатформність This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. @@ -8,7 +8,7 @@ Offline first I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. -Transparent Data +Прозорі дані Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. diff --git a/fastlane/metadata/android/uk/short_description.txt b/fastlane/metadata/android/uk/short_description.txt index 39875ab6f..861f56616 100644 --- a/fastlane/metadata/android/uk/short_description.txt +++ b/fastlane/metadata/android/uk/short_description.txt @@ -1 +1 @@ -A simple tool for building stories. \ No newline at end of file +Простий інструмент для створення історій. \ No newline at end of file diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 84bbcc8b6..8cc99f94b 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -1,18 +1,122 @@ language=Deutsch page_title=Hammer -home_servermessage=Server-Nachricht +home_servermessage=Server notice home_servermessage_whitelist=Für diesen Server benötigst du eine Freigabe, bevor du ein Konto erstellen kannst. Bitte kontaktiere {0} um auf die Whitelist zu gelangen. -home_whatishammer_title=Was ist Hammer? -home_whatishammer_subtitle=Hammer ist ein Open-Source-Tool zum Schreiben von Geschichten und den Welten, in denen sie existieren. -home_feature_multiplatform_header=Plattformübergreifend -home_feature_multiplatform_body=Überall verfügbar – auf deinem Smartphone, Tablet, PC oder Laptop. Es ist nicht einfach nur eine "website-in-a-box". Es werden native Technologien des Endgerätes verwendet, um die bestmögliche Erfahrung zu bieten. -home_feature_multiscreen_header=Bildschirmgröße -home_feature_multiscreen_body=Egal, ob Sie auf einem Telefon, Tablet oder Desktop sind, Hammer nutzt Ihren Bildschirmbereich optimal, um Ihnen Platz zum Arbeiten zu geben. Unterstützt auch den hell als auch den dunkel Modus. -home_feature_offlinefirst_header=Offline First -home_feature_offlinefirst_body=Hammer ist eine durchdacht entwickelte Open-Source-Software, die von Grund auf für den lokalen Einsatz konzipiert wurde. Eine Internetverbindung ist nicht erforderlich, was Ihnen volle Kontrolle und Unabhängigkeit garantiert.\nFür erweiterte Funktionen besteht die Möglichkeit, Ihre Daten mit einem Server zu synchronisieren – sei es ein eigener Server oder ein Dienstleister wie hammer.ink. Diese Flexibilität ermöglicht es Ihnen, die Software optimal an Ihre Bedürfnisse anzupassen, ohne auf externe Abhängigkeiten angewiesen zu sein. -home_feature_transparentdata_header=Datenhoheit -home_feature_transparentdata_body=Ihre Daten gehören Ihnen. Sie werden nicht in der Cloud oder einer undurchsichtigen Datenbank gespeichert. Sie werden in einfachen, menschenlesbaren Dateien gespeichert, nur mit Dateien und Ordnern, um die Projektstruktur zu definieren. Sie können den Dateibrowser Ihres Systems öffnen und sie sich selbst anschauen. Wenn dieses Programm plötzlich verschwände, könnten Sie leicht mit Ihren Daten interagieren. -home_download_header=Hol dir deinen Hammer\: +home_meta_description=Hammer is an open source tool for writing stories, and the worlds in which they exist. +home_meta_tagline=Own your book, build your world +# Masthead +home_masthead_title_first=Own your book, +home_masthead_title_second=build your world +home_masthead_subtitle=Free and open source. Works offline, on every device you own, and every chapter stays a plain text file on your own disk. +home_hero_download=Download free +home_hero_platforms=Windows, macOS, Linux, Android and iOS. Free and open source. +# Writing features +home_features_eyebrow=Writing in Hammer +home_features_title=Built for the way a novel actually gets written +home_features_lede=Scenes you can move, an encyclopedia that knows who is in them, a timeline, notes, and a record of what you actually wrote. +home_feature_editor_title=An editor that stays out of the way +home_feature_editor_body1=Your scene, the outline you wrote for it, and your notes about it, all on one screen. Word count and metadata sit to the side when you want them and disappear when you don't. +home_feature_editor_body2=Break the book into scenes as small as you like, then drag them into a different order when two characters need to meet earlier. +home_feature_editor_alt=The Hammer editor showing a scene, with the scene list on the left and outline, notes and references on the right +home_feature_encyclopedia_title=An encyclopedia that looks like your world +home_feature_encyclopedia_body1=Characters, places, events and anything else you invent, with pictures. Tag them, search them, keep them a keystroke from the page you are on. +home_feature_encyclopedia_body2=Hammer reads your scenes and works out who is in them. Open a character and it lists every scene they appear in; open a scene and it shows the characters and locations it found, which you confirm or dismiss. Nobody has to remember to tag anything. +home_feature_encyclopedia_body3=Which means you can answer the question every draft eventually raises\: where did she go? Three chapters without your antagonist show up as an absence you can see. +home_feature_encyclopedia_alt=The Hammer encyclopedia showing illustrated entries for characters, places and events +home_feature_timeline_title=See the plot laid out in order +home_feature_timeline_body1=Put the events of your story, or the history of your world, on a dated and tagged timeline, so you can see that the potion comes before the trial, and that three chapters pass without your antagonist. +home_feature_timeline_alt=The Hammer timeline listing dated, tagged story events +home_feature_notes_title=Somewhere to put the thought you just had +home_feature_notes_body1=The observation about your own book that arrives at the wrong moment. Tag it, find it later, keep it next to the manuscript instead of in a file on the desktop called notes2-final.txt. +home_feature_notes_alt=The Hammer notes view with tagged notes about a story +home_feature_activity_title=It counts what you wrote, not what survived +home_feature_activity_body1=Hammer watches each save and counts the words you added, ignoring deletions and rearrangements. Spend a day cutting a chapter to pieces and it still records the writing you did, because you did do it. +home_feature_activity_body2=Daily and weekly goals, today's count, this week against last, your current and longest streak, your best day, and a twelve-week heat map. It also knows your weekday average against your weekend one, which turns out to be more useful than it sounds. +home_feature_activity_body3=Sync, and sessions merge across devices\: the four hundred words you got at a bus stop land in the same total as the ones at your desk. Hammer can even tell you which device you write best on. +home_feature_activity_body4=And the log is a plain text file in your project folder, like everything else. Your writing history is yours to read, or to delete. +home_feature_activity_alt=The Hammer project home showing total words, a weekly goal bar, this week's total against last week, and a writing streak +# After the draft +home_afterdraft_eyebrow=After the draft +home_afterdraft_title=When the book has to leave your desk +home_afterdraft_lede=Writing is only half of it. At some point a draft has to reach an editor, a beta reader, an agent, or a stranger. That is usually where a writing app stops and the copy-paste starts. +home_feature_review_title=Send it to your editor. They don't need an account. +home_feature_review_body1=Choose the scenes, type an email address, and Hammer sends a link. Your editor opens it in a browser and marks your prose up with suggested edits. No download, no signup, no copy of your project handed over. +home_feature_review_body2=You see when they opened it, how many of the scenes they have read, and how many suggestions are waiting for you. Label each request for your own benefit\: my agent, beta read 2. Links expire, and you can revoke one at any point. +home_feature_review_alt=A review request open in a browser, with the editor's suggested edits marked on the manuscript and accept or reject controls beside each one +home_feature_private_title=Show it to three people +home_feature_private_body1=Private links are password protected, can carry an expiry date, and can be revoked whenever you like. Make a separate one per reader if you want to know who is still reading. +home_feature_private_alt=The create private access dialog asking for a password and an optional expiry date, over a story's sharing panel +home_feature_publish_title=Or publish it to everyone +home_feature_publish_body1=A public story page under your pen name, an author page collecting your published work, and a place in the community feed. +home_feature_publish_body2=You can see how many people actually read it, all time and day by day. +home_feature_publish_body3=And it tells you what publishing publicly can cost you before you do it, not after. +home_feature_publish_alt=A published story showing its public sharing link and a readers panel with a total count and a daily chart +home_feature_export_title=Five formats, and it tells you which to pick +home_feature_export_markdown_name=Markdown +home_feature_export_markdown_body=For version control, or moving into another tool. +home_feature_export_epub_name=EPUB +home_feature_export_epub_body=For beta readers on e-readers and phones, where the text has to reflow. +home_feature_export_pdf_name=PDF +home_feature_export_pdf_body=For when the layout must not move. +home_feature_export_word_name=Word +home_feature_export_word_body=For anyone who will leave comments or track changes. +home_feature_export_rtf_name=RTF +home_feature_export_rtf_body=For when you don't know what they use. +home_feature_export_body1=Hammer explains each one at the moment you are choosing, instead of leaving you to guess from a row of file extensions. +home_feature_export_alt=The export dialog with a format picker and an option to treat top-level scenes as chapters +# The three rules +home_rules_eyebrow=The three rules +home_rules_title=Now the part that matters in ten years +home_rules_lede=Everything you just saw is built on three rules. Every one of those features writes to plain files in a folder you own, which is the whole reason to pick this over the alternatives. +home_rule_offline_name=Offline first +home_rule_offline_title=It works with no internet, forever +home_rule_offline_body=Hammer has never required a connection and never will. Write in a cabin, on a plane, in a van parked somewhere with no signal. Sync between your devices if you want it, through a server like this one or one you run yourself, but nothing waits on a server and nothing breaks because a service is down. +home_rule_files_name=Transparent data +home_rule_files_title=Open the folder and it makes sense +home_rule_files_body=Not a database, not a proprietary bundle. Open the project folder with Hammer shut and the shape of your book is already there\: a file per scene, named the way you named it. Search it, back it up, mail it to yourself. Reading your own work should never require this program, or a computer science degree. +home_rule_safety_name=Never lose data +home_rule_safety_title=It is paranoid about losing your work +home_rule_safety_body=Losing your work is the one bug this program is not allowed to have. Sync starts by backing up the whole project. A draft saved before a big rewrite is always there to go back to. Unsaved edits are written to disk as you type, so a crash costs you a sentence at worst. When two devices disagree, Hammer asks which one is right instead of quietly picking. +# Origin story +home_origin_eyebrow=Why it works this way +home_origin_title=I wrote these rules after losing a manuscript +home_origin_body1=I once lived in a van, traveling and writing in often very remote places. I was not working at the time, and was using free writing software that claimed to support offline work. As I came to learn, that claim didn't hold up. After about a week offline, it stopped working. When I got back to civilization, it started working again, but my manuscript was gone. +home_origin_body2=I browsed the app's community forum and saw many other authors asking the same question I was\: Where did my manuscript go? +home_origin_body3=The seeds of the idea for Hammer were born out of this experience. Why was everything a webpage these days? I just wanted to use my own computer that was right in my hands to write and store my stories. +home_origin_caption=Where the three rules came from. September 2020 +home_origin_alt=A camper van parked on red dirt at sunset, its back doors open to show a bed and storage bins, a black dog and climbing gear on the ground beside it +home_origin_link=Read the whole story +# Price +home_price_eyebrow=What it costs +home_price_amount=Nothing +home_price_body=Hammer is free and open source under the MIT license. No trial, no card, no account needed to write, and no paid tier of the app. If this server vanished tomorrow, the copy on your machine would carry on working exactly as it does today. +# FAQ +home_faq_title=Reasonable questions +home_faq_account_q=Do I need an account? +home_faq_account_a=No. Download it and write. An account only exists so you can sync between devices, and it is entirely optional. +home_faq_where_q=Where does my writing live? +home_faq_where_a=In a folder you choose on your own device, as plain text files, one per scene, alongside small readable files for your notes and encyclopedia entries. +home_faq_export_q=Can I get my manuscript out? +home_faq_export_a=It was never in. Your scenes are already ordinary files you can open, copy, or hand to anyone, whether or not Hammer is installed. When you want one file instead of a folder, export the whole book as Markdown, EPUB, PDF, Word or RTF. +home_faq_editor_q=Does my editor need to buy anything? +home_faq_editor_a=No, and they don't need an account either. You send a link, they mark up the scenes you chose in their browser, and you get the suggestions back. The link expires and you can revoke it. +home_faq_goesaway_q=What happens if Hammer goes away? +home_faq_goesaway_a=Nothing happens to your book. It is already in a format every text editor on earth can open. The source is MIT licensed, so anyone who wants to keep the app alive can. +home_faq_privacy_q=Is my writing sent anywhere? +home_faq_privacy_a=Only if you switch sync on, and then only to the sync server you chose, whether that is this one or one you run yourself. +home_faq_platforms_q=Which platforms? +home_faq_platforms_a=Windows, macOS, Linux, Android and iOS. The same app and the same files on each, not a website in a box. +# Under the hood +home_hood_eyebrow=Under the hood +home_hood_title=For the technically minded +home_hood_native_header=Native, not a web page +home_hood_native_body=Built in Kotlin with Compose Multiplatform. One codebase, genuinely native clients on five platforms, not a website wrapped in an app shell. +home_hood_screens_header=Fits the screen you have +home_hood_screens_body=Phone, tablet, laptop or a large desktop display\: the layout expands to use the space you have. Light and dark themes throughout. +home_hood_license_header=MIT licensed, self-hostable +home_hood_license_body=Read the source, build it yourself, run your own sync server. No telemetry in the app, and nothing phoning home for a license check. +home_download_header=Get your Hammer +home_download_lede=Free, and free to keep. home_download_windows=Windows home_download_linux=Linux home_download_macos=macOS @@ -27,24 +131,6 @@ home_download_link_macos=Download Hammer for macOS home_download_link_linux=Download Hammer for Linux home_download_link_android=Download Hammer for Android home_download_link_ios=Download Hammer for iOS -# Section Labels -home_section_technical=Technische Features -home_section_writing=Schreib-Features -# Detailed Features -home_detail_scenes_title=In Szenen schreiben -home_detail_scenes_body=Teilen Sie Ihre Geschichte in kleinere Szenen auf. Machen Sie sich nebenbei Notizen und schreiben Sie eine kurze Zusammenfassung, um Ihre Geschichte im Gliederungsmodus zu betrachten. -home_detail_organize_title=Organisiere deine Szenen -home_detail_organize_body=Sollen sich zwei Figuren früher begegnen? Einfach die Reihenfolge der Szenen per Drag and Drop ändern. -home_detail_drafts_title=Verwende Entwürfe deiner Szenen -home_detail_drafts_body=Speichern Sie einen Entwurf ab, bevor Sie eine Szene ändern. Dies macht Änderungen an Ihrer Geschichte weniger beängstigend, da Sie immer wieder auf den alten Stand zurückkehren können. -home_detail_timeline_title=Erstelle deinen eigenen Zeitlichen ablauf -home_detail_timeline_body=Nutzen Sie die Zeitstrahl-Funktion, um den Verlauf Ihrer Handlung oder sogar Ihrer Welt zu visualisieren. -home_detail_encyclopedia_title=Schreiben Sie Ihre Enzyklopädie -home_detail_encyclopedia_body=Erstellen Sie Einträge für Ihre Figuren, Orte, Ereignisse und Weiteres. Fügen Sie Bilder hinzu, um darzustellen, was Worte nicht vermögen. -home_detail_notes_title=Notizen -home_detail_notes_body=Müssen Sie einen Gedanken zu Ihrer Geschichte oder Welt festhalten? Schreiben Sie es in den Abschnitt Notizen. -home_masthead_title=Hammer\: -home_masthead_subtitle=Ein einfaches Werkzeug zum Erstellen von Geschichten home_about_callout_title=Über diesen Server home_about_callout_text=Erfahren Sie mehr über diesen Hammer-Server home_header_title=Hammer @@ -68,6 +154,7 @@ footer_about=Info footer_terms=Nutzungsbedingungen terms_page_title=Nutzungsbedingungen footer_privacy=Datenschutz +footer_studio=Darkrock Studios privacy_page_title=Datenschutzerklärung admin_signin_header=Login admin_signin_email=E-Mail\: @@ -105,6 +192,7 @@ api_accounts_login_error_notfound=Kein Konto gefunden api_accounts_login_error_badpassword=Falsches Passwort api_accounts_login_error_invalid=Ungültige E-Mail oder Passwort api_accounts_login_error_notoken=Kein gültiges Token gefunden +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. api_accounts_tokenrefresh_error=Fehler beim Aktualisieren des Authentifizierungs-Tokens api_accounts_testauth_error=Authentifiziert als ${0,number} api_project_sync_begin_error_session=Benutzer {0} hat bereits eine Synchronisationssitzung @@ -324,7 +412,7 @@ admin_whitelist_save_reason=Speichern admin_whitelist_edit_expiry_title=Edit Expiry admin_whitelist_expiry_label=Expires admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. -admin_whitelist_expiry_never=Never +admin_whitelist_expiry_never=Nie admin_whitelist_expiry_7days=In 7 days admin_whitelist_expiry_30days=In 30 days admin_whitelist_expiry_90days=In 90 days @@ -337,7 +425,7 @@ admin_whitelist_expiry_change_label=Ändern auf admin_whitelist_expiry_extend_7days=Um 7 Tage verlängern admin_whitelist_expiry_extend_30days=Um 30 Tage verlängern admin_whitelist_expiry_extend_90days=Um 90 Tage verlängern -admin_whitelist_save_expiry=Save +admin_whitelist_save_expiry=Speichern admin_whitelist_cannot_disable_patreon=Die Whitelist kann nicht deaktiviert werden, solange die Patreon-Integration aktiv ist admin_whitelist_sort_newest_first=Sort\: Newest First admin_whitelist_sort_oldest_first=Sort\: Oldest First @@ -426,6 +514,25 @@ bio_toast_cleared=Biografie gelöscht bio_validation_too_long=Die Biografie darf höchstens {0} Zeichen lang sein bio_error_no_penname=Sie müssen einen Autorennamen festlegen, bevor Sie eine Biografie hinzufügen bio_clear_confirm=Möchten Sie Ihre Biografie wirklich entfernen? +# Account Deletion +account_delete_title=Gefahrenzone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. +account_delete_dialog_project_count_label=Projects that will be deleted from this server\: +account_delete_confirm_label=Type your email address to confirm\: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Abbrechen +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Wiederherstellen +admin_users_restore_failed=Restore failed. That account no longer exists on this server. # Public Story Page public_story_by=Geschrieben von public_story_powered_by=Erstellt mit @@ -558,6 +665,11 @@ community_callout_sharing=teilen community_callout_stories=Geschichten community_callout_authors_only=Authors are ready to share their stories\! community_callout_empty=Be the first to join our writing community\! +# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". +community_badge_authors={0,choice,0\#authors|1\#author|1Test Email from Hammer Server

If you received this email, your SMTP configuration is working correctly.

-admin_email_test_email_text=Test Email from Hammer Server\n\nIf you received this email, your SMTP configuration is working correctly. -# Admin - User Management -admin_users_title=User Management -admin_users_subtitle=View and manage registered users -admin_users_list_title=Registered Users -admin_users_description=All users registered on this server. -admin_users_projects=Projects -admin_users_created=Created -admin_users_last_sync=Last Sync -admin_users_empty=No users registered yet. -admin_users_sort_controls=Sort Controls -admin_users_sort_by=Sort By -admin_users_sort_direction=Direction -admin_users_sort_created=Created Date -admin_users_sort_lastsync=Last Sync -admin_users_sort_projectcount=Project Count -admin_users_sort_desc=Newest First -admin_users_sort_asc=Oldest First -admin_whitelist_add_title=Add to Whitelist -admin_whitelist_date_added_unknown=Unknown -admin_whitelist_edit_button=Edit -admin_whitelist_edit_reason_title=Edit Reason -admin_whitelist_reason_label=Reason -admin_whitelist_reason_placeholder=e.g., Beta tester, Friend of the project -admin_whitelist_reason_help=Brief note about why this user was whitelisted (max 32 characters) -admin_whitelist_save_reason=Save -admin_whitelist_edit_expiry_title=Edit Expiry -admin_whitelist_expiry_label=Expires -admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. -admin_whitelist_expiry_never=Never -admin_whitelist_expiry_7days=In 7 days -admin_whitelist_expiry_30days=In 30 days -admin_whitelist_expiry_90days=In 90 days -admin_whitelist_expiry_custom=On a specific date -admin_whitelist_expiry_date_label=Expiry date -admin_whitelist_expires_prefix=Expires: -admin_whitelist_edit_expiry_button=Edit expiry -admin_whitelist_expiry_current=Current expiry: -admin_whitelist_expiry_change_label=Change to -admin_whitelist_expiry_extend_7days=Extend 7 days -admin_whitelist_expiry_extend_30days=Extend 30 days -admin_whitelist_expiry_extend_90days=Extend 90 days -admin_whitelist_save_expiry=Save -admin_whitelist_cannot_disable_patreon=Cannot disable whitelist while Patreon integration is active -admin_whitelist_sort_newest_first=Sort: Newest First -admin_whitelist_sort_oldest_first=Sort: Oldest First -admin_whitelist_account_created=Account Created -admin_whitelist_no_account=No Account -admin_dialog_cancel=Cancel -# Error Pages - Common -error_button_home=Go Home -error_button_signin=Sign In -error_button_back=Go Back -error_button_retry=Try Again -# Error Pages - 403 Unauthorized -error_unauthorized_title=Unauthorized Access -error_unauthorized_message=You don't have permission to access this page. This area is restricted to authorized users only. -error_unauthorized_note=If you believe you should have access, please contact the server administrator. -# Error Pages - 404 Not Found -error_notfound_title=Page Not Found -error_notfound_message=Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed in the first place. -error_notfound_note=If you think this is a mistake, please let us know! -# Error Pages - 500 Server Error -error_servererror_title=Server Error -error_servererror_message=Oops! Something went wrong on our end. Our server encountered an error and couldn't complete your request. -error_servererror_note=We've been notified and are working to fix the issue. Please try again in a few moments. -# Security -security_access_denied=Access denied -security_please_login=Please log in -# Story Page -story_back_to_dashboard=Back to Dashboard -story_no_scenes=This story doesn't have any scenes yet. Add scenes in the Hammer Editor app to see them here. -story_error_title=Error Loading Story -story_info_title=Story Info -story_readers_title=Readers -story_readers_total_label=total readers, all time -story_readers_graph_caption=Readers per day · last 30 days -story_info_scenes=Scenes -story_info_last_sync=Last Synced -story_settings_title=Sharing -story_publish_title=Public Access -story_publish_description=Make this story publicly accessible to anyone with the link. No login required. -story_private_share_title=Private Access -story_status_published=Published -story_status_private=Private -story_public_url=Sharing URL -story_copy_url=Copy URL -story_toast_published=Story published! Anyone with the link can now read it. -story_toast_unpublished=Story unpublished. It is now private. -# Pen Name Feature -penname_label=Pen Name: -penname_release_button=Release -penname_claim_button=Claim Your Pen Name -penname_explainer=Pen names allow you to share your work publicly. After setting one, you can share stories under that name. -penname_edit_label=Choose your pen name: -penname_edit_placeholder=e.g., Virginia Woolf -penname_set_button=Set Pen Name -penname_cancel_button=Cancel -penname_release_title=Release Your Pen Name -penname_release_warning1=Once released, your pen name will become available for others to claim. You won''t be able to get it back unless you claim it again before someone else does. -penname_release_warning2=Any links you''ve shared using this pen name will no longer work. -penname_release_current_label=Current pen name: -penname_release_confirm_label=Type your pen name to confirm: -penname_release_confirm_placeholder=Type pen name here -penname_release_confirm_button=Release Pen Name -penname_toast_saved=Pen name saved! -penname_toast_released=Pen name released -penname_validation_valid=Pen name is valid -penname_validation_too_short=Pen name must be at least {0} characters -penname_validation_too_long=Pen name must be at most {0} characters -penname_validation_invalid_chars=Pen name can only contain letters, numbers, spaces, hyphens, and underscores -penname_validation_taken=Pen name is already taken -penname_validation_available=Pen name is available -penname_checking_availability=Checking availability... -penname_check_failed=Could not check availability -penname_release_confirm_message=Are you sure you want to release "{0}"? This action cannot be undone. -# Bio -bio_label=Bio: -bio_add_button=Add Bio -bio_edit_button=Edit -bio_clear_button=Clear -bio_edit_label=Your author biography: -bio_edit_placeholder=Write a brief biography in Markdown. You can use **bold**, *italic*, [links](url), lists, and more. -bio_markdown_help=Markdown formatting supported: **bold**, *italic*, [links](url), headings, lists, etc. -bio_save_button=Save Bio -bio_cancel_button=Cancel -bio_toast_saved=Bio saved! -bio_toast_cleared=Bio cleared -bio_validation_too_long=Bio must be at most {0} characters -bio_error_no_penname=You must set a pen name before adding a bio -bio_clear_confirm=Are you sure you want to remove your bio? -# Account Deletion -account_delete_title=Danger Zone -account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. -account_delete_button=Delete Account -account_delete_dialog_title=Delete Your Account -account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. -account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. -account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. -account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. -account_delete_dialog_project_count_label=Projects that will be deleted from this server: -account_delete_confirm_label=Type your email address to confirm: -account_delete_confirm_button=Delete My Account -account_delete_cancel=Cancel -account_delete_error_email_mismatch=The email address you entered does not match your account -account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. -account_delete_error_generic=Account deletion failed -admin_users_pending_deletion=Pending Deletion -admin_users_restore=Restore -admin_users_restore_failed=Restore failed. That account no longer exists on this server. -# Public Story Page -public_story_by=A story by -public_story_powered_by=Written with -public_story_meta_description={0} by {1}. Read the full story on Hammer. -# OpenGraph share cards -og_attribution=Written with Hammer -og_author_subtitle=An author on {0} -# Story Access Management -story_share_private=Share with Password -story_share_private_title=Create Private Access -story_access_password=Password -story_access_password_placeholder=Enter a password -story_access_expires=Expiration Date -story_access_optional=optional -story_access_no_expiry=No expiration -story_access_expired=Expired -story_access_list_title=Private Access Links -story_access_passworded=Password Protected -story_access_delete_confirm=Are you sure you want to remove this access? -story_access_delete=Delete access -story_access_show_password=Show password -story_create_access=Create Access -story_cancel=Cancel -story_toast_access_created=Private access created successfully -story_toast_access_deleted=Access removed -# Public Story Password -public_story_password_required=Password Required -public_story_password_description=This story requires a password to view. -public_story_password_label=Password -public_story_password_placeholder=Enter password -public_story_password_submit=View Story -public_story_password_incorrect=Incorrect password. Please try again. -# Publish Warning Dialog -story_publish_warning_title=Publishing Publicly -story_publish_warning_content=Publishing the story here publicly, however briefly, could jeopardize your ability to sell it later. Many publishers demand "First Publication Rights", meaning it has never been publicly available anywhere before. -story_publish_warning_recommendation=If you intend to submit this project for publication, it is recommended to use Private Access sharing instead. -story_publish_publicly=Publish Publicly -# Author Page -author_published_works=Published Works -author_read_story=Read story -author_story_count=published stories -author_no_stories_title=No Published Stories Yet -author_no_stories_subtitle=This author hasn't published any stories yet. Check back later! -author_story_published_on=Published on -# Public Story Pagination -public_story_words=words -public_story_min_read=min read -public_story_prev_page=Previous -public_story_next_page=Next -story_info_words=Words -# Scene Selector -story_scene_selector_label=Scene -story_view_all=View All -# About Page -about_page_title=About This Server -# Password Reset -password_reset_forgot_title=Forgot Your Password? -password_reset_forgot_subtitle=Enter your email address and we'll send you a link to reset your password. -password_reset_forgot_link=Forgot your password? -password_reset_enter_email_help=Enter the email address associated with your account -password_reset_send_link=Send Reset Link -password_reset_email_sent=Reset Link Sent! -password_reset_check_email=If an account exists for -password_reset_link_expires=The reset link will expire in 15 minutes. -password_reset_back_to_login=Back to Login -password_reset_title=Reset Your Password -password_reset_subtitle=Enter your new password below -password_reset_new_password=New Password -password_reset_confirm_password=Confirm Password -password_reset_requirements=Password must be 8-64 characters long -password_reset_submit=Reset Password -password_reset_success_title=Password Reset Successful -password_reset_success_subtitle=Your password has been changed -password_reset_success_message=You can now log in with your new password. All other sessions have been logged out for security. -password_reset_go_to_login=Go to Login -password_reset_error_title=Reset Link Invalid -password_reset_error_invalid_token=This password reset link is invalid or has already been used. -password_reset_error_expired=This password reset link has expired. Please request a new one. -password_reset_error_already_used=This password reset link has already been used. Please request a new one if needed. -password_reset_request_new=Request New Reset Link -password_reset_error_email_required=Please enter your email address -password_reset_error_fields_required=Please fill in all fields -password_reset_error_mismatch=Passwords do not match -password_reset_error_generic=Failed to reset password. Please try again. -# Password Reset Email Template -password_reset_email_title=Reset Your Hammer Password -password_reset_email_intro=You requested to reset your password for your Hammer account. -password_reset_email_cta=Click the button below to reset your password: -password_reset_email_button=Reset Password -password_reset_email_or_copy=Or copy and paste this link into your browser: -password_reset_email_expires=This link will expire in 15 minutes. -password_reset_email_ignore=If you didn't request a password reset, you can safely ignore this email. -password_reset_email_footer=This is an automated message from Hammer Server. Please do not reply to this email. -# Community -community_label=Community: -community_promo_title=Join this server's community! -community_promo_text=Share your stories and discover other's work. -community_toggle_label=Join Community -community_status_joined=Community Member -community_status_not_joined=Not a member -community_explanation=Joining the community means your pen name will be listed on the authors page of this server, and any stories that you publish publicly will be discoverable by the rest of the community. This is entirely optional, and if you do join, will still only list stories that you explicitly publish publicly. -community_toast_joined=Welcome to the community! -community_toast_left=You have left the community -community_error_no_penname=You must set a pen name before joining the community -community_error_disabled=Community features are not enabled on this server -# Authors Page -authors_page_title=Community Authors -authors_page_subtitle=Discover writers sharing their stories on this server -authors_count_label=authors -authors_view_profile=View Profile -authors_no_bio=No bio yet -authors_empty_title=No Authors Yet -authors_empty_text=Be the first to join the community! Set a pen name and opt in to appear here. -# Feed Page -feed_page_title=Story Feed -feed_page_subtitle=The latest stories from our community of writers -feed_count_label=stories -feed_latest_stories=Latest Publications -feed_story_by=by -feed_read_story=Read Story -feed_empty_title=No Stories Yet -feed_empty_text=When community members publish their stories, they'll appear here. Check back soon! -# Community Navigation -community_nav_authors=Authors -community_nav_feed=Story Feed -# Community Callout -community_callout_title=Explore Our Community -community_callout_authors=authors -community_callout_sharing=sharing -community_callout_stories=stories -community_callout_authors_only=Authors are ready to share their stories! -community_callout_empty=Be the first to join our writing community! -# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". -community_badge_authors={0,choice,0#authors|1#author|1Test Email from Hammer Server

If you received this email, your SMTP configuration is working correctly.

+admin_email_test_email_text=Test Email from Hammer Server\n\nIf you received this email, your SMTP configuration is working correctly. +# Admin - User Management +admin_users_title=User Management +admin_users_subtitle=View and manage registered users +admin_users_list_title=Registered Users +admin_users_description=All users registered on this server. +admin_users_projects=Projects +admin_users_created=Created +admin_users_last_sync=Last Sync +admin_users_empty=No users registered yet. +admin_users_sort_controls=Sort Controls +admin_users_sort_by=Sort By +admin_users_sort_direction=Direction +admin_users_sort_created=Created Date +admin_users_sort_lastsync=Last Sync +admin_users_sort_projectcount=Project Count +admin_users_sort_desc=Newest First +admin_users_sort_asc=Oldest First +admin_whitelist_add_title=Add to Whitelist +admin_whitelist_date_added_unknown=Unknown +admin_whitelist_edit_button=Edit +admin_whitelist_edit_reason_title=Edit Reason +admin_whitelist_reason_label=Reason +admin_whitelist_reason_placeholder=e.g., Beta tester, Friend of the project +admin_whitelist_reason_help=Brief note about why this user was whitelisted (max 32 characters) +admin_whitelist_save_reason=Save +admin_whitelist_edit_expiry_title=Edit Expiry +admin_whitelist_expiry_label=Expires +admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. +admin_whitelist_expiry_never=Never +admin_whitelist_expiry_7days=In 7 days +admin_whitelist_expiry_30days=In 30 days +admin_whitelist_expiry_90days=In 90 days +admin_whitelist_expiry_custom=On a specific date +admin_whitelist_expiry_date_label=Expiry date +admin_whitelist_expires_prefix=Expires: +admin_whitelist_edit_expiry_button=Edit expiry +admin_whitelist_expiry_current=Current expiry: +admin_whitelist_expiry_change_label=Change to +admin_whitelist_expiry_extend_7days=Extend 7 days +admin_whitelist_expiry_extend_30days=Extend 30 days +admin_whitelist_expiry_extend_90days=Extend 90 days +admin_whitelist_save_expiry=Save +admin_whitelist_cannot_disable_patreon=Cannot disable whitelist while Patreon integration is active +admin_whitelist_sort_newest_first=Sort: Newest First +admin_whitelist_sort_oldest_first=Sort: Oldest First +admin_whitelist_account_created=Account Created +admin_whitelist_no_account=No Account +admin_dialog_cancel=Cancel +# Error Pages - Common +error_button_home=Go Home +error_button_signin=Sign In +error_button_back=Go Back +error_button_retry=Try Again +# Error Pages - 403 Unauthorized +error_unauthorized_title=Unauthorized Access +error_unauthorized_message=You don't have permission to access this page. This area is restricted to authorized users only. +error_unauthorized_note=If you believe you should have access, please contact the server administrator. +# Error Pages - 404 Not Found +error_notfound_title=Page Not Found +error_notfound_message=Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed in the first place. +error_notfound_note=If you think this is a mistake, please let us know! +# Error Pages - 500 Server Error +error_servererror_title=Server Error +error_servererror_message=Oops! Something went wrong on our end. Our server encountered an error and couldn't complete your request. +error_servererror_note=We've been notified and are working to fix the issue. Please try again in a few moments. +# Security +security_access_denied=Access denied +security_please_login=Please log in +# Story Page +story_back_to_dashboard=Back to Dashboard +story_no_scenes=This story doesn't have any scenes yet. Add scenes in the Hammer Editor app to see them here. +story_error_title=Error Loading Story +story_info_title=Story Info +story_readers_title=Readers +story_readers_total_label=total readers, all time +story_readers_graph_caption=Readers per day · last 30 days +story_info_scenes=Scenes +story_info_last_sync=Last Synced +story_settings_title=Sharing +story_publish_title=Public Access +story_publish_description=Make this story publicly accessible to anyone with the link. No login required. +story_private_share_title=Private Access +story_status_published=Published +story_status_private=Private +story_public_url=Sharing URL +story_copy_url=Copy URL +story_toast_published=Story published! Anyone with the link can now read it. +story_toast_unpublished=Story unpublished. It is now private. +# Pen Name Feature +penname_label=Pen Name: +penname_release_button=Release +penname_claim_button=Claim Your Pen Name +penname_explainer=Pen names allow you to share your work publicly. After setting one, you can share stories under that name. +penname_edit_label=Choose your pen name: +penname_edit_placeholder=e.g., Virginia Woolf +penname_set_button=Set Pen Name +penname_cancel_button=Cancel +penname_release_title=Release Your Pen Name +penname_release_warning1=Once released, your pen name will become available for others to claim. You won''t be able to get it back unless you claim it again before someone else does. +penname_release_warning2=Any links you''ve shared using this pen name will no longer work. +penname_release_current_label=Current pen name: +penname_release_confirm_label=Type your pen name to confirm: +penname_release_confirm_placeholder=Type pen name here +penname_release_confirm_button=Release Pen Name +penname_toast_saved=Pen name saved! +penname_toast_released=Pen name released +penname_validation_valid=Pen name is valid +penname_validation_too_short=Pen name must be at least {0} characters +penname_validation_too_long=Pen name must be at most {0} characters +penname_validation_invalid_chars=Pen name can only contain letters, numbers, spaces, hyphens, and underscores +penname_validation_taken=Pen name is already taken +penname_validation_available=Pen name is available +penname_checking_availability=Checking availability... +penname_check_failed=Could not check availability +penname_release_confirm_message=Are you sure you want to release "{0}"? This action cannot be undone. +# Bio +bio_label=Bio: +bio_add_button=Add Bio +bio_edit_button=Edit +bio_clear_button=Clear +bio_edit_label=Your author biography: +bio_edit_placeholder=Write a brief biography in Markdown. You can use **bold**, *italic*, [links](url), lists, and more. +bio_markdown_help=Markdown formatting supported: **bold**, *italic*, [links](url), headings, lists, etc. +bio_save_button=Save Bio +bio_cancel_button=Cancel +bio_toast_saved=Bio saved! +bio_toast_cleared=Bio cleared +bio_validation_too_long=Bio must be at most {0} characters +bio_error_no_penname=You must set a pen name before adding a bio +bio_clear_confirm=Are you sure you want to remove your bio? +# Public Story Page +public_story_by=A story by +public_story_powered_by=Written with +public_story_meta_description={0} by {1}. Read the full story on Hammer. +# OpenGraph share cards +og_attribution=Written with Hammer +og_author_subtitle=An author on {0} +# Story Access Management +story_share_private=Share with Password +story_share_private_title=Create Private Access +story_access_password=Password +story_access_password_placeholder=Enter a password +story_access_expires=Expiration Date +story_access_optional=optional +story_access_no_expiry=No expiration +story_access_expired=Expired +story_access_list_title=Private Access Links +story_access_passworded=Password Protected +story_access_delete_confirm=Are you sure you want to remove this access? +story_access_delete=Delete access +story_access_show_password=Show password +story_create_access=Create Access +story_cancel=Cancel +story_toast_access_created=Private access created successfully +story_toast_access_deleted=Access removed +# Public Story Password +public_story_password_required=Password Required +public_story_password_description=This story requires a password to view. +public_story_password_label=Password +public_story_password_placeholder=Enter password +public_story_password_submit=View Story +public_story_password_incorrect=Incorrect password. Please try again. +# Publish Warning Dialog +story_publish_warning_title=Publishing Publicly +story_publish_warning_content=Publishing the story here publicly, however briefly, could jeopardize your ability to sell it later. Many publishers demand "First Publication Rights", meaning it has never been publicly available anywhere before. +story_publish_warning_recommendation=If you intend to submit this project for publication, it is recommended to use Private Access sharing instead. +story_publish_publicly=Publish Publicly +# Author Page +author_published_works=Published Works +author_read_story=Read story +author_story_count=published stories +author_no_stories_title=No Published Stories Yet +author_no_stories_subtitle=This author hasn't published any stories yet. Check back later! +author_story_published_on=Published on +# Public Story Pagination +public_story_words=words +public_story_min_read=min read +public_story_prev_page=Previous +public_story_next_page=Next +story_info_words=Words +# Scene Selector +story_scene_selector_label=Scene +story_view_all=View All +# About Page +about_page_title=About This Server +# Password Reset +password_reset_forgot_title=Forgot Your Password? +password_reset_forgot_subtitle=Enter your email address and we'll send you a link to reset your password. +password_reset_forgot_link=Forgot your password? +password_reset_enter_email_help=Enter the email address associated with your account +password_reset_send_link=Send Reset Link +password_reset_email_sent=Reset Link Sent! +password_reset_check_email=If an account exists for +password_reset_link_expires=The reset link will expire in 15 minutes. +password_reset_back_to_login=Back to Login +password_reset_title=Reset Your Password +password_reset_subtitle=Enter your new password below +password_reset_new_password=New Password +password_reset_confirm_password=Confirm Password +password_reset_requirements=Password must be 8-64 characters long +password_reset_submit=Reset Password +password_reset_success_title=Password Reset Successful +password_reset_success_subtitle=Your password has been changed +password_reset_success_message=You can now log in with your new password. All other sessions have been logged out for security. +password_reset_go_to_login=Go to Login +password_reset_error_title=Reset Link Invalid +password_reset_error_invalid_token=This password reset link is invalid or has already been used. +password_reset_error_expired=This password reset link has expired. Please request a new one. +password_reset_error_already_used=This password reset link has already been used. Please request a new one if needed. +password_reset_request_new=Request New Reset Link +password_reset_error_email_required=Please enter your email address +password_reset_error_fields_required=Please fill in all fields +password_reset_error_mismatch=Passwords do not match +password_reset_error_generic=Failed to reset password. Please try again. +# Password Reset Email Template +password_reset_email_title=Reset Your Hammer Password +password_reset_email_intro=You requested to reset your password for your Hammer account. +password_reset_email_cta=Click the button below to reset your password: +password_reset_email_button=Reset Password +password_reset_email_or_copy=Or copy and paste this link into your browser: +password_reset_email_expires=This link will expire in 15 minutes. +password_reset_email_ignore=If you didn't request a password reset, you can safely ignore this email. +password_reset_email_footer=This is an automated message from Hammer Server. Please do not reply to this email. +# Community +community_label=Community: +community_promo_title=Join this server's community! +community_promo_text=Share your stories and discover other's work. +community_toggle_label=Join Community +community_status_joined=Community Member +community_status_not_joined=Not a member +community_explanation=Joining the community means your pen name will be listed on the authors page of this server, and any stories that you publish publicly will be discoverable by the rest of the community. This is entirely optional, and if you do join, will still only list stories that you explicitly publish publicly. +community_toast_joined=Welcome to the community! +community_toast_left=You have left the community +community_error_no_penname=You must set a pen name before joining the community +community_error_disabled=Community features are not enabled on this server +# Authors Page +authors_page_title=Community Authors +authors_page_subtitle=Discover writers sharing their stories on this server +authors_count_label=authors +authors_view_profile=View Profile +authors_no_bio=No bio yet +authors_empty_title=No Authors Yet +authors_empty_text=Be the first to join the community! Set a pen name and opt in to appear here. +# Feed Page +feed_page_title=Story Feed +feed_page_subtitle=The latest stories from our community of writers +feed_count_label=stories +feed_latest_stories=Latest Publications +feed_story_by=by +feed_read_story=Read Story +feed_empty_title=No Stories Yet +feed_empty_text=When community members publish their stories, they'll appear here. Check back soon! +# Community Navigation +community_nav_authors=Authors +community_nav_feed=Story Feed +# Community Callout +community_callout_title=Explore Our Community +community_callout_authors=authors +community_callout_sharing=sharing +community_callout_stories=stories +community_callout_authors_only=Authors are ready to share their stories! +community_callout_empty=Be the first to join our writing community! +# Setup Mode +setup_icon=\uD83D\uDEE0\uFE0F +setup_title=Setup Mode +setup_message=Welcome! This server needs to be configured before use. +setup_step1=Open the Hammer app on your device +setup_step2=Go to Settings and enter this server address: +setup_step3=Create your account to complete setup +setup_admin_note=The first account created will become the administrator +setup_return_note=After creating your account, refresh this page to continue. +# Monitoring dashboard +admin_nav_monitoring=Monitoring +admin_mon_nav_overview=Overview +admin_mon_nav_performance=Performance +admin_mon_overview_title=Monitoring +admin_mon_overview_subtitle=How your server is being used, at a glance +admin_mon_disabled_notice=Monitoring collection is currently disabled. Enable it in Server Settings to start gathering data. +admin_mon_alerts_title=Alerts +admin_mon_alerts_nominal=All systems nominal — no alerts right now. +admin_mon_alerts_investigate=Investigate +admin_mon_stat_requests=Requests +admin_mon_errors=Errors +admin_mon_stat_error_rate=Error rate +admin_mon_stat_p95=p95 latency +admin_mon_stat_active_syncs=Active syncs +admin_mon_window_24h=last 24h +admin_mon_window_7d=last 7d +admin_mon_window_30d=last 30d +admin_mon_live=live +admin_mon_active_users_title=Active users +admin_mon_active_users_sync=Syncing +admin_mon_active_users_web=Web +admin_mon_active_users_trend=Daily active users (last 30d) +admin_mon_readers_title=Story readers +admin_mon_readers_subtitle=Best-effort unique readers of published & shared stories. +admin_mon_readers_unique=Unique +admin_mon_readers_trend=Daily readers (last 30d) +admin_mon_traffic_title=API Traffic (last 30d) +admin_mon_slowest_title=Slowest endpoints +admin_mon_view_all=View all +admin_mon_no_data=No data collected yet. +admin_mon_perf_title=Performance +admin_mon_perf_subtitle=Per-endpoint request volume, errors, and latency +admin_mon_perf_endpoints=Endpoints +admin_mon_perf_error_rate_title=Error Rate +admin_mon_perf_error_rate_label=Error Rate % +admin_mon_perf_latency_title=p95 Latency +admin_mon_perf_latency_label=p95 Latency +admin_mon_range_24h=24h +admin_mon_range_7d=7d +admin_mon_range_30d=30d +admin_mon_col_endpoint=Endpoint +admin_mon_col_requests=Requests +admin_mon_col_errors=Error rate +admin_mon_col_p50=p50 +admin_mon_col_p95=p95 +admin_mon_col_p99=p99 +admin_mon_settings_title=Monitoring +admin_mon_settings_help=Control in-app server monitoring. Collection runs in the background; data is rolled up and purged per the retention windows below. +admin_mon_settings_enabled=Enable monitoring (master switch) +admin_mon_settings_track_api=Track API usage & performance +admin_mon_settings_track_errors=Track errors +admin_mon_settings_track_logins=Track login attempts +admin_mon_settings_track_readers=Count unique story readers (cookieless) +admin_mon_settings_store_ip=Store source IP with login attempts +admin_mon_settings_metrics_retention=Metrics retention (days) +admin_mon_settings_error_retention=Error retention (days) +admin_mon_settings_login_retention=Login attempt retention (days) +admin_mon_settings_alert_email_enabled=Email the admin on persistent errors +admin_mon_settings_alert_email=Alert email address +admin_mon_nav_errors=Errors +admin_mon_errors_title=Errors +admin_mon_errors_subtitle=Grouped server-side errors and their stack traces +admin_mon_errors_grouped=Recent errors +admin_mon_errors_none=No errors recorded. Nice and quiet. +admin_mon_errors_filtered_by=Filtered by endpoint: +admin_mon_errors_filter_clear=Clear filter +admin_mon_errors_export=Export +admin_mon_errors_copy_stack=Copy +admin_mon_errors_copied=Copied +admin_mon_errors_ignore_type=Ignore this type +admin_mon_errors_ignore_route=Ignore only on this route +admin_mon_errors_ignored_section=Ignored errors +admin_mon_errors_ignored_help=Errors matching these rules are still recorded here, but never emailed. +admin_mon_errors_rules_label=Rules +admin_mon_errors_rule_remove=Stop ignoring +admin_mon_errors_rule_add=Add rule +admin_mon_errors_rule_type_placeholder=Exception type +admin_mon_errors_rule_route_placeholder=Route pattern, e.g. /api/* (optional) +admin_mon_errors_ignored_truncated=Only the most recent ignored groups are shown here; the export includes everything. +admin_mon_nav_security=Security +admin_mon_security_title=Security +admin_mon_security_subtitle=Login attempts and brute-force signals +admin_mon_security_top_failures=Top failed logins (last 24h) +admin_mon_security_no_failures=No failed logins in the last 24h. +admin_mon_security_recent=Recent login attempts +admin_mon_security_no_attempts=No login attempts recorded yet. +admin_mon_security_col_result=Result +admin_mon_security_col_email=Email +admin_mon_security_col_ip=IP +admin_mon_security_col_time=Time +admin_mon_security_ok=OK +admin_mon_security_fail=FAIL +admin_mon_nav_jobs=Jobs +admin_mon_jobs_title=Background Jobs +admin_mon_jobs_subtitle=Status of recurring server tasks +admin_mon_jobs_section=Recurring tasks +admin_mon_jobs_col_name=Task +admin_mon_jobs_col_status=Status +admin_mon_jobs_col_last_run=Last run +admin_mon_jobs_col_next_run=Next run +admin_mon_jobs_alive=Alive +admin_mon_jobs_stopped=Stopped +admin_mon_jobs_failing=Last run failed +admin_mon_jobs_error=Error +admin_mon_jobs_none=No background jobs are registered. +admin_mon_jobs_health_ok=All background jobs are healthy. +admin_mon_jobs_health_warn=A job reported an error on its most recent run. +admin_mon_nav_logs=Logs +admin_mon_logs_title=Logs +admin_mon_logs_subtitle=Recent server log output (live) +admin_mon_logs_level=Level +admin_mon_logs_all=All +admin_mon_logs_search=Search +admin_mon_logs_search_placeholder=Filter by text or logger +admin_mon_logs_autorefresh=Auto-refresh +admin_mon_logs_export=Export +admin_mon_logs_empty=No log lines match. +api_review_create_error_no_scenes=No scenes were selected for review +api_review_create_error_invalid_scene=One of the selected scenes is invalid +api_review_create_error_invalid_email=The editor's email address is invalid +api_review_create_error_too_long=The label or note is too long +api_review_create_error_project_not_found=Project not found +api_review_create_error_draft_failed=Failed to snapshot scenes for review +api_review_create_error_sync_busy=A sync is in progress, try again in a moment +api_review_token_error_invalid=This review link is not valid +api_review_token_error_expired=This review link has expired +api_review_token_error_revoked=This review request was revoked by the author +api_review_error_not_found=Review not found +api_review_revoke_error_submitted=A submitted review can no longer be revoked +review_panel_title=Editorial Reviews +review_panel_description=Invite an editor by email to mark up your scenes with suggested edits. No account needed. +review_panel_empty=No review requests yet. +review_panel_request_button=Request a Review +review_panel_revoke=Revoke +review_panel_revoke_confirm=Revoke this review request? The link will stop working. +review_status_sent=Sent +review_status_opened=Opened +review_status_in_progress=In Progress +review_status_submitted=Submitted +review_status_resolved=Resolved +review_status_canceled=Revoked +review_status_expired=Expired +review_meta_scenes={0} scenes +review_meta_one_scene=1 scene +review_meta_sent=sent {0} +review_meta_opened=opened {0} +review_meta_not_opened=not opened yet +review_meta_expires=expires {0} +review_meta_submitted=submitted {0} +review_meta_suggestions={0} suggestions +review_meta_progress={0} of {1} scenes read +review_dialog_title=Request a Review +review_dialog_email=Editor's email +review_dialog_email_placeholder=them@example.com +review_dialog_label=Label +review_dialog_label_hint=(just for you) +review_dialog_label_placeholder=e.g. My agent +review_dialog_note=Note to your editor +review_dialog_note_hint=(optional) +review_dialog_note_placeholder=Anything they should know before reading? +review_dialog_expiry=Link expires +review_dialog_expiry_30=30 days +review_dialog_expiry_60=60 days +review_dialog_expiry_90=90 days +review_dialog_expiry_never=Never +review_dialog_scenes=Scenes to review +review_dialog_scenes_none=none selected +review_dialog_scenes_count={0} of {1} selected +review_dialog_all_scenes=All scenes +review_dialog_clear_all=Clear all +review_dialog_select_all=Select all +review_dialog_clear=Clear +review_dialog_cancel=Cancel +review_dialog_send=Send Request +review_toast_created=Review request emailed to {0}. +review_toast_created_no_email=Review request created. Share the link with your editor. +review_toast_email_failed=Review request created, but the email failed to send. Share the link manually. +review_toast_revoked=Review request for "{0}" revoked. The link no longer works. +review_link_dialog_title=Share this review link +review_link_dialog_body=Email isn't configured on this server, so send your editor this link yourself. For security it won't be shown again. +review_link_dialog_copy=Copy Link +review_link_dialog_copied=Copied! +review_link_dialog_done=Done +review_page_overline=Editorial Review +review_page_by=by +review_page_expires=Link expires {0} +review_page_note_title=A note from the author +review_page_scene_position=Scene {0} of {1} +review_page_error_title=This link isn't working +review_error_the_author=the author +review_error_invalid_title=This link isn't working +review_error_invalid_body=Check that the whole link made it here from your email — these links are long and easy to cut short. +review_error_expired_title=This review link has expired +review_error_expired_body=For the author''s security, review links only last so long. If you''d still like to mark up "{1}", ask {0} to send a fresh request. +review_error_revoked_title=The author closed this review +review_error_revoked_body={0} has withdrawn the review request for "{1}". If that seems like a mistake, reach out to them directly. +review_invite_subject={0} would like your editorial eye on "{1}" +review_invite_title=You've been asked to edit a manuscript +review_invite_intro={0} has invited you to read and mark up scenes from their story "{1}" on Hammer. +review_invite_explain=No account is needed — the link below opens a private review page where you can suggest edits and leave comments, right in the text. +review_invite_button=Open the Manuscript +review_invite_or_copy=Or copy this link into your browser: +review_invite_expires=This link expires on {0}. +review_invite_no_expiry=This link does not expire. +review_invite_ignore=Not expecting this? You can safely ignore this email. +review_invite_footer=Sent by Hammer, an open-source app for writers. +api_review_token_error_submitted=This review has already been submitted +api_review_suggestion_error_invalid=That suggestion could not be saved +api_review_suggestion_error_overlap=That selection overlaps an existing suggestion +api_review_suggestion_error_too_long=That suggestion is too long to save +api_review_commit_error_not_submitted=This review hasn't been submitted yet +api_review_commit_error_resolved=This review has already been resolved +api_review_commit_error_draft_failed=Failed to save the revised drafts +review_submit=Submit Revisions +review_locked_banner=This review has been submitted and is now locked. The author has been notified of your suggestions. +review_hint=Select a passage to suggest an edit — or click anywhere in the text to insert at that spot. +review_scenes_title=Scenes in this request +review_suggestions_title=Suggestions +review_no_suggestions=No suggestions in this scene yet. Select some text to begin. +review_action_reword=Reword +review_action_delete=Delete +review_action_insert=Insert after +review_action_insert_here=Insert here +review_action_comment=Comment +review_action_save=Save +review_action_remove=Remove suggestion +review_action_replacement_label=Suggested rewording +review_action_insert_label=Text to insert +review_action_comment_label=Comment +review_action_reason_placeholder=Reason (optional) +review_action_comment_placeholder=What should the author know? +review_action_type_placeholder=Type the new text… +review_save_failed=Could not save that suggestion. Please try again. +review_action_remove_confirm=Remove this suggestion? This can't be undone. +review_action_reason_label=Reason +review_action_edit=Edit suggestion +review_submit_confirm_title=Submit your revisions? +review_submit_confirm_body=You're about to send {0} to {1}. After submitting, the manuscript locks and you won't be able to add or change your suggestions. +review_submit_tally_one=1 suggestion +review_submit_tally_many={0} suggestions +review_submit_confirm_action=Submit & Lock +review_done_mark=Mark scene as done +review_done_marked=Done — tap to unmark +review_welcome_title=A manuscript awaits your pen +review_welcome_intro={0} has shared {1} for your editorial pass. +review_welcome_how=Select any passage to suggest a rewording, a cut, or a comment — or click between words to insert something new. Your marks save as you go. +review_welcome_submit_note=When you're done with every scene, click "{0}" to send your edits back. Until then, the author can't see your work and you can change anything. +review_welcome_action=Start Editing +review_author_suggestions_from=Suggestions from {0} +review_author_submitted_on=Submitted {0} +review_author_resolved_banner=This review was resolved on {0}. The suggestions below are read-only. +review_author_hint=Accept or reject each suggestion — the manuscript shows the result as you go. When you're done, apply the revisions. +review_commit=Apply Revisions +review_accept=Accept +review_reject=Reject +review_resolve=Resolve +review_undo=Undo +review_accept_all=Accept all +review_reject_all=Reject all +review_chip_accepted=Accepted +review_chip_rejected=Rejected +review_chip_resolved=Resolved +review_commit_confirm_title=Apply your revisions? +review_commit_confirm_body=Accepted changes are saved as a new draft for each scene. Scenes you haven't touched since sending the request are updated to the revised text. This resolves the review and can't be undone. +review_commit_tally_accepted={0} accepted +review_commit_tally_rejected={0} rejected +review_commit_tally_pending={0} undecided +review_commit_pending_warning=Undecided suggestions won't be applied. +review_commit_confirm_action=Apply & Resolve +review_commit_failed=Could not apply the revisions. Please try again. +review_commit_result_title=Revisions applied +review_commit_result_draft=Saved as draft '{0}' +review_commit_outcome_applied=Working copy updated +review_commit_outcome_diverged=Changed since the review was sent — merge the new draft with Draft Compare in the app +review_commit_outcome_unchanged=No accepted changes +review_commit_outcome_scene_missing=This scene no longer exists in the project +review_commit_done=Done +review_panel_open_review=Review suggestions +review_panel_view_review=View suggestions +review_submitted_subject={0} has sent back their edits on {1} +review_submitted_title=Your manuscript is back +review_submitted_intro={0} has finished editing {1} and sent back {2}. +review_submitted_explain=Open the review to read their suggestions, accept or reject each one, and apply the revisions as a new draft. +review_submitted_button=See the Suggestions +api_ideas_error_ideaidmissing=Missing idea id +api_ideas_error_notfound=Idea not found +api_ideas_error_deleted=Idea has been deleted +api_ideas_error_toolarge=Idea is too large diff --git a/server/src/main/resources/i18n/Messages_es.properties b/server/src/main/resources/i18n/Messages_es.properties index ca28660a7..c2e01b775 100644 --- a/server/src/main/resources/i18n/Messages_es.properties +++ b/server/src/main/resources/i18n/Messages_es.properties @@ -1,18 +1,122 @@ language=Español page_title=Hammer -home_servermessage=Mensaje del servidor +home_servermessage=Server notice home_servermessage_whitelist=Este servidor requiere que obtengas permiso antes de crear una cuenta. Ponte en contacto con {0} para entrar en la lista blanca. -home_whatishammer_title=¿Qué es Hammer? -home_whatishammer_subtitle=Hammer es una herramienta de código abierto para escribir historias y los mundos en los que existen. -home_feature_multiplatform_header=Multiplataforma -home_feature_multiplatform_body=Lleva esta aplicación a cualquier parte\: en tu teléfono, tableta, ordenador de sobremesa o portátil. No es simplemente un sitio web enlatado. Usa tecnologías nativas del lado del cliente para ofrecer la mejor experiencia posible. -home_feature_multiscreen_header=Multipantalla -home_feature_multiscreen_body=Ya sea en un teléfono, una tableta o un ordenador, Hammer aprovecha al máximo el espacio de tu pantalla, expandiéndose para darte sitio para trabajar. También admite los modos claro y oscuro. -home_feature_offlinefirst_header=Sin conexión primero -home_feature_offlinefirst_body=Hammer está diseñado desde cero para ser totalmente local, sin necesidad de conexión a internet, nunca. Puedes sincronizar con un servidor, ya sea autoalojado o alojado por un tercero como hammer.ink. Pero es totalmente opcional. -home_feature_transparentdata_header=Datos transparentes -home_feature_transparentdata_body=Tus datos son tuyos. No se almacenan en la nube ni en alguna base de datos opaca. Se guardan en archivos sencillos y legibles, usando solo archivos y carpetas para definir la estructura del proyecto. Puedes abrir el explorador de archivos de tu sistema y echar un vistazo tú mismo. Si este programa desapareciera hoy, podrías seguir interactuando fácilmente con tus datos. -home_download_header=Consigue tu Hammer\: +home_meta_description=Hammer is an open source tool for writing stories, and the worlds in which they exist. +home_meta_tagline=Own your book, build your world +# Masthead +home_masthead_title_first=Own your book, +home_masthead_title_second=build your world +home_masthead_subtitle=Free and open source. Works offline, on every device you own, and every chapter stays a plain text file on your own disk. +home_hero_download=Download free +home_hero_platforms=Windows, macOS, Linux, Android and iOS. Free and open source. +# Writing features +home_features_eyebrow=Writing in Hammer +home_features_title=Built for the way a novel actually gets written +home_features_lede=Scenes you can move, an encyclopedia that knows who is in them, a timeline, notes, and a record of what you actually wrote. +home_feature_editor_title=An editor that stays out of the way +home_feature_editor_body1=Your scene, the outline you wrote for it, and your notes about it, all on one screen. Word count and metadata sit to the side when you want them and disappear when you don't. +home_feature_editor_body2=Break the book into scenes as small as you like, then drag them into a different order when two characters need to meet earlier. +home_feature_editor_alt=The Hammer editor showing a scene, with the scene list on the left and outline, notes and references on the right +home_feature_encyclopedia_title=An encyclopedia that looks like your world +home_feature_encyclopedia_body1=Characters, places, events and anything else you invent, with pictures. Tag them, search them, keep them a keystroke from the page you are on. +home_feature_encyclopedia_body2=Hammer reads your scenes and works out who is in them. Open a character and it lists every scene they appear in; open a scene and it shows the characters and locations it found, which you confirm or dismiss. Nobody has to remember to tag anything. +home_feature_encyclopedia_body3=Which means you can answer the question every draft eventually raises\: where did she go? Three chapters without your antagonist show up as an absence you can see. +home_feature_encyclopedia_alt=The Hammer encyclopedia showing illustrated entries for characters, places and events +home_feature_timeline_title=See the plot laid out in order +home_feature_timeline_body1=Put the events of your story, or the history of your world, on a dated and tagged timeline, so you can see that the potion comes before the trial, and that three chapters pass without your antagonist. +home_feature_timeline_alt=The Hammer timeline listing dated, tagged story events +home_feature_notes_title=Somewhere to put the thought you just had +home_feature_notes_body1=The observation about your own book that arrives at the wrong moment. Tag it, find it later, keep it next to the manuscript instead of in a file on the desktop called notes2-final.txt. +home_feature_notes_alt=The Hammer notes view with tagged notes about a story +home_feature_activity_title=It counts what you wrote, not what survived +home_feature_activity_body1=Hammer watches each save and counts the words you added, ignoring deletions and rearrangements. Spend a day cutting a chapter to pieces and it still records the writing you did, because you did do it. +home_feature_activity_body2=Daily and weekly goals, today's count, this week against last, your current and longest streak, your best day, and a twelve-week heat map. It also knows your weekday average against your weekend one, which turns out to be more useful than it sounds. +home_feature_activity_body3=Sync, and sessions merge across devices\: the four hundred words you got at a bus stop land in the same total as the ones at your desk. Hammer can even tell you which device you write best on. +home_feature_activity_body4=And the log is a plain text file in your project folder, like everything else. Your writing history is yours to read, or to delete. +home_feature_activity_alt=The Hammer project home showing total words, a weekly goal bar, this week's total against last week, and a writing streak +# After the draft +home_afterdraft_eyebrow=After the draft +home_afterdraft_title=When the book has to leave your desk +home_afterdraft_lede=Writing is only half of it. At some point a draft has to reach an editor, a beta reader, an agent, or a stranger. That is usually where a writing app stops and the copy-paste starts. +home_feature_review_title=Send it to your editor. They don't need an account. +home_feature_review_body1=Choose the scenes, type an email address, and Hammer sends a link. Your editor opens it in a browser and marks your prose up with suggested edits. No download, no signup, no copy of your project handed over. +home_feature_review_body2=You see when they opened it, how many of the scenes they have read, and how many suggestions are waiting for you. Label each request for your own benefit\: my agent, beta read 2. Links expire, and you can revoke one at any point. +home_feature_review_alt=A review request open in a browser, with the editor's suggested edits marked on the manuscript and accept or reject controls beside each one +home_feature_private_title=Show it to three people +home_feature_private_body1=Private links are password protected, can carry an expiry date, and can be revoked whenever you like. Make a separate one per reader if you want to know who is still reading. +home_feature_private_alt=The create private access dialog asking for a password and an optional expiry date, over a story's sharing panel +home_feature_publish_title=Or publish it to everyone +home_feature_publish_body1=A public story page under your pen name, an author page collecting your published work, and a place in the community feed. +home_feature_publish_body2=You can see how many people actually read it, all time and day by day. +home_feature_publish_body3=And it tells you what publishing publicly can cost you before you do it, not after. +home_feature_publish_alt=A published story showing its public sharing link and a readers panel with a total count and a daily chart +home_feature_export_title=Five formats, and it tells you which to pick +home_feature_export_markdown_name=Markdown +home_feature_export_markdown_body=For version control, or moving into another tool. +home_feature_export_epub_name=EPUB +home_feature_export_epub_body=For beta readers on e-readers and phones, where the text has to reflow. +home_feature_export_pdf_name=PDF +home_feature_export_pdf_body=For when the layout must not move. +home_feature_export_word_name=Word +home_feature_export_word_body=For anyone who will leave comments or track changes. +home_feature_export_rtf_name=RTF +home_feature_export_rtf_body=For when you don't know what they use. +home_feature_export_body1=Hammer explains each one at the moment you are choosing, instead of leaving you to guess from a row of file extensions. +home_feature_export_alt=The export dialog with a format picker and an option to treat top-level scenes as chapters +# The three rules +home_rules_eyebrow=The three rules +home_rules_title=Now the part that matters in ten years +home_rules_lede=Everything you just saw is built on three rules. Every one of those features writes to plain files in a folder you own, which is the whole reason to pick this over the alternatives. +home_rule_offline_name=Offline first +home_rule_offline_title=It works with no internet, forever +home_rule_offline_body=Hammer has never required a connection and never will. Write in a cabin, on a plane, in a van parked somewhere with no signal. Sync between your devices if you want it, through a server like this one or one you run yourself, but nothing waits on a server and nothing breaks because a service is down. +home_rule_files_name=Transparent data +home_rule_files_title=Open the folder and it makes sense +home_rule_files_body=Not a database, not a proprietary bundle. Open the project folder with Hammer shut and the shape of your book is already there\: a file per scene, named the way you named it. Search it, back it up, mail it to yourself. Reading your own work should never require this program, or a computer science degree. +home_rule_safety_name=Never lose data +home_rule_safety_title=It is paranoid about losing your work +home_rule_safety_body=Losing your work is the one bug this program is not allowed to have. Sync starts by backing up the whole project. A draft saved before a big rewrite is always there to go back to. Unsaved edits are written to disk as you type, so a crash costs you a sentence at worst. When two devices disagree, Hammer asks which one is right instead of quietly picking. +# Origin story +home_origin_eyebrow=Why it works this way +home_origin_title=I wrote these rules after losing a manuscript +home_origin_body1=I once lived in a van, traveling and writing in often very remote places. I was not working at the time, and was using free writing software that claimed to support offline work. As I came to learn, that claim didn't hold up. After about a week offline, it stopped working. When I got back to civilization, it started working again, but my manuscript was gone. +home_origin_body2=I browsed the app's community forum and saw many other authors asking the same question I was\: Where did my manuscript go? +home_origin_body3=The seeds of the idea for Hammer were born out of this experience. Why was everything a webpage these days? I just wanted to use my own computer that was right in my hands to write and store my stories. +home_origin_caption=Where the three rules came from. September 2020 +home_origin_alt=A camper van parked on red dirt at sunset, its back doors open to show a bed and storage bins, a black dog and climbing gear on the ground beside it +home_origin_link=Read the whole story +# Price +home_price_eyebrow=What it costs +home_price_amount=Nothing +home_price_body=Hammer is free and open source under the MIT license. No trial, no card, no account needed to write, and no paid tier of the app. If this server vanished tomorrow, the copy on your machine would carry on working exactly as it does today. +# FAQ +home_faq_title=Reasonable questions +home_faq_account_q=Do I need an account? +home_faq_account_a=No. Download it and write. An account only exists so you can sync between devices, and it is entirely optional. +home_faq_where_q=Where does my writing live? +home_faq_where_a=In a folder you choose on your own device, as plain text files, one per scene, alongside small readable files for your notes and encyclopedia entries. +home_faq_export_q=Can I get my manuscript out? +home_faq_export_a=It was never in. Your scenes are already ordinary files you can open, copy, or hand to anyone, whether or not Hammer is installed. When you want one file instead of a folder, export the whole book as Markdown, EPUB, PDF, Word or RTF. +home_faq_editor_q=Does my editor need to buy anything? +home_faq_editor_a=No, and they don't need an account either. You send a link, they mark up the scenes you chose in their browser, and you get the suggestions back. The link expires and you can revoke it. +home_faq_goesaway_q=What happens if Hammer goes away? +home_faq_goesaway_a=Nothing happens to your book. It is already in a format every text editor on earth can open. The source is MIT licensed, so anyone who wants to keep the app alive can. +home_faq_privacy_q=Is my writing sent anywhere? +home_faq_privacy_a=Only if you switch sync on, and then only to the sync server you chose, whether that is this one or one you run yourself. +home_faq_platforms_q=Which platforms? +home_faq_platforms_a=Windows, macOS, Linux, Android and iOS. The same app and the same files on each, not a website in a box. +# Under the hood +home_hood_eyebrow=Under the hood +home_hood_title=For the technically minded +home_hood_native_header=Native, not a web page +home_hood_native_body=Built in Kotlin with Compose Multiplatform. One codebase, genuinely native clients on five platforms, not a website wrapped in an app shell. +home_hood_screens_header=Fits the screen you have +home_hood_screens_body=Phone, tablet, laptop or a large desktop display\: the layout expands to use the space you have. Light and dark themes throughout. +home_hood_license_header=MIT licensed, self-hostable +home_hood_license_body=Read the source, build it yourself, run your own sync server. No telemetry in the app, and nothing phoning home for a license check. +home_download_header=Get your Hammer +home_download_lede=Free, and free to keep. home_download_windows=Windows home_download_linux=Linux home_download_macos=macOS @@ -27,24 +131,6 @@ home_download_link_macos=Download Hammer for macOS home_download_link_linux=Download Hammer for Linux home_download_link_android=Download Hammer for Android home_download_link_ios=Download Hammer for iOS -# Section Labels -home_section_technical=Funciones técnicas -home_section_writing=Funciones de escritura -# Detailed Features -home_detail_scenes_title=Escribe por escenas -home_detail_scenes_body=Divide tu historia en escenas más pequeñas. Toma notas al lado, escribe un breve resumen para ver tu historia en el modo esquema. -home_detail_organize_title=Organiza tus escenas -home_detail_organize_body=¿Necesitas que dos personajes se encuentren antes? Simplemente arrastra y suelta tus escenas para cambiar su orden. -home_detail_drafts_title=Usa borradores de tus escenas -home_detail_drafts_body=Guarda un borrador antes de una reescritura importante de tu escena. Esto hace que los grandes cambios den menos miedo, porque siempre puedes volver atrás. -home_detail_timeline_title=Traza tu cronología -home_detail_timeline_body=Usa la función de cronología para visualizar el desarrollo de tu trama o incluso de tu mundo. -home_detail_encyclopedia_title=Escribe tu enciclopedia -home_detail_encyclopedia_body=Crea entradas para tus personajes, lugares, eventos, todo lo que se te ocurra. Añade imágenes para capturar mil palabras. -home_detail_notes_title=Notas -home_detail_notes_body=¿Necesitas apuntar una idea rápida sobre tu historia o tu mundo? Lánzala a la sección de notas. -home_masthead_title=Hammer\: -home_masthead_subtitle=Una herramienta sencilla para crear historias home_about_callout_title=Acerca de este servidor home_about_callout_text=Más información sobre este servidor de Hammer home_header_title=Hammer @@ -68,6 +154,7 @@ footer_about=Acerca de footer_terms=Términos terms_page_title=Términos del servicio footer_privacy=Privacidad +footer_studio=Darkrock Studios privacy_page_title=Política de privacidad admin_signin_header=Iniciar sesión admin_signin_email=Correo\: @@ -105,6 +192,7 @@ api_accounts_login_error_notfound=Cuenta no encontrada api_accounts_login_error_badpassword=Contraseña incorrecta api_accounts_login_error_invalid=Correo o contraseña no válidos api_accounts_login_error_notoken=No se encontró ningún token válido +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. api_accounts_tokenrefresh_error=No se pudo actualizar el token de autenticación api_accounts_testauth_error=Autenticado como ${0,number} api_project_sync_begin_error_session=El usuario {0} ya tiene una sesión de sincronización @@ -324,7 +412,7 @@ admin_whitelist_save_reason=Guardar admin_whitelist_edit_expiry_title=Edit Expiry admin_whitelist_expiry_label=Expires admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. -admin_whitelist_expiry_never=Never +admin_whitelist_expiry_never=Nunca admin_whitelist_expiry_7days=In 7 days admin_whitelist_expiry_30days=In 30 days admin_whitelist_expiry_90days=In 90 days @@ -337,7 +425,7 @@ admin_whitelist_expiry_change_label=Cambiar a admin_whitelist_expiry_extend_7days=Ampliar 7 días admin_whitelist_expiry_extend_30days=Ampliar 30 días admin_whitelist_expiry_extend_90days=Ampliar 90 días -admin_whitelist_save_expiry=Save +admin_whitelist_save_expiry=Guardar admin_whitelist_cannot_disable_patreon=No se puede desactivar la lista blanca mientras la integración con Patreon esté activa admin_whitelist_sort_newest_first=Orden\: más recientes primero admin_whitelist_sort_oldest_first=Orden\: más antiguos primero @@ -426,6 +514,25 @@ bio_toast_cleared=Biografía borrada bio_validation_too_long=La biografía debe tener como máximo {0} caracteres bio_error_no_penname=Debes establecer un seudónimo antes de añadir una biografía bio_clear_confirm=¿Seguro que quieres eliminar tu biografía? +# Account Deletion +account_delete_title=Zona de peligro +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. +account_delete_dialog_project_count_label=Projects that will be deleted from this server\: +account_delete_confirm_label=Type your email address to confirm\: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancelar +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restaurar +admin_users_restore_failed=Restore failed. That account no longer exists on this server. # Public Story Page public_story_by=Una historia de public_story_powered_by=Escrito con @@ -558,6 +665,11 @@ community_callout_sharing=comparten community_callout_stories=historias community_callout_authors_only=¡Hay autores listos para compartir sus historias\! community_callout_empty=¡Sé el primero en unirte a nuestra comunidad de escritura\! +# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". +community_badge_authors={0,choice,0\#authors|1\#author|1 Date: Mon, 3 Aug 2026 22:22:00 -0700 Subject: [PATCH 025/213] Allow a project to set the language it's written in (#838) * Add per-project language setting (#754) An optional BCP-47 language on ProjectData, picked from a searchable list of all platform locales in project settings. New projects default to the device locale; the Alice example project is en-US. Spell check is gated per project: when the project language does not leniently match the dictionary locale, the dictionary is withheld (ProjectSpellCheckRepository) and project settings explain why. The public story page emits and JSON-LD inLanguage from the declared language, and EPUB export prefers it over the device locale. The hasher contributes zero bytes when unset so existing sync hashes stay stable. * Fix review findings in the project-language feature createProject now only seeds the default language for genuinely new projects (seedDefaultLanguage), so account sync materializes server projects with the never-synced baseline intact, and the seed is language-only so it cannot gate spell check against a same-language dictionary. The hasher's language block gets a -1 marker plus length prefix so it can never collide with a tags block, and the initial write goes through the shared saveStoredProjectData path. The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant style locales distinct in the picker. The picker's clear row is pinned above the list so it survives an empty search, watchSpellCheckAllowed delivers on the main dispatcher, and the public story page hashes the stored project-data hash into its validator instead of parsing the blob per request, applying the language override after withDefaults so chrome links keep the viewer's locale. * Enforce single-owner persisted formats The tags write in PromoteIdeaUseCase rewrote project_data.toml from scratch, erasing the language seed createProject had just written: the exact hazard of a second inline writer. It now read-modify-writes through the datasource's scope-less helpers, and ProjectsListComponent's hand-rolled reader delegates to a new blocking readStoredProjectData. The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md) and enforced by PersistedFormatOwnershipTest, which fails the build when raw TOML I/O appears outside a Datasource file. Migrators are exempt by role; the two remaining legacy offenders are allowlisted as a burn-down that can only shrink. * Burn down the last raw TOML I/O outside datasources ProjectStatisticsCacheReader now delegates to a scope-less readProjectStatistics helper in StatisticsDatasource, and the example project's fabricated activity log goes through writeDeviceLog in WritingActivityDatasource, which also becomes the single owner of the .activity path convention. With no offenders left, PersistedFormatOwnershipTest drops its burn-down allowlist entirely: only Datasource files and migrators may touch persisted TOML formats from here on. * Pass seedDefaultLanguage in the Android instrumented-test harness * Pass seedDefaultLanguage in the round-trip sync HeadlessClient --- .../androidTest/kotlin/EditorTestHarness.kt | 5 +- .../base/http/projectdata/ProjectData.kt | 2 + .../http/synchronizer/ProjectDataHasher.kt | 10 + .../synchronizer/ProjectDataHasherTest.kt | 36 +++ .../dependencyinjection/platformModule.kt | 1 + .../common/util/AvailableLocalesProvider.kt | 11 + .../composeResources/values/strings_sync.xml | 9 + .../common/components/ProjectComponentBase.kt | 15 ++ .../components/encyclopedia/CreateEntry.kt | 3 +- .../encyclopedia/CreateEntryComponent.kt | 8 + .../components/encyclopedia/ViewEntry.kt | 1 + .../encyclopedia/ViewEntryComponent.kt | 4 + .../common/components/notes/CreateNote.kt | 1 + .../components/notes/CreateNoteComponent.kt | 4 + .../common/components/notes/ViewNote.kt | 1 + .../components/notes/ViewNoteComponent.kt | 4 + .../projecthome/ExportStoryUseCase.kt | 6 +- .../components/projecthome/ProjectSettings.kt | 11 + .../projecthome/ProjectSettingsComponent.kt | 19 ++ .../projectslist/ProjectsListComponent.kt | 23 +- .../focusmode/FocusModeComponent.kt | 4 +- .../sceneeditor/SceneEditorComponent.kt | 4 +- .../timeline/CreateTimeLineEvent.kt | 1 + .../timeline/CreateTimeLineEventComponent.kt | 4 + .../components/timeline/ViewTimeLineEvent.kt | 3 +- .../timeline/ViewTimeLineEventComponent.kt | 4 + .../common/data/ExampleProjectRepository.kt | 9 +- .../ideasrepository/PromoteIdeaUseCase.kt | 25 +- .../data/projectdata/ProjectDataDatasource.kt | 27 ++- .../projectsrepository/ProjectsRepository.kt | 31 ++- .../ProjectStatisticsCacheReader.kt | 12 +- .../projectstatistics/StatisticsDatasource.kt | 26 +- .../accountsync/ClientAccountSynchronizer.kt | 2 +- .../WritingActivityDatasource.kt | 27 ++- .../common/dependencyinjection/mainModule.kt | 2 + .../common/spellcheck/LanguageMatching.kt | 27 +++ .../spellcheck/ProjectSpellCheckRepository.kt | 45 ++++ .../common/util/AvailableLocalesProvider.kt | 6 + .../apps/hammer/common/util/Locale.kt | 38 ++- .../resources/raw/alice_in_wonderland_zip | Bin 738935 -> 738814 bytes .../apps/hammer/common/util/LocaleTest.kt | 22 +- .../dependencyinjection/platformModule.kt | 1 + .../common/util/AvailableLocalesProvider.kt | 11 + .../PersistedFormatOwnershipTest.kt | 49 ++++ .../components/notes/NotesComponentTest.kt | 7 + .../projecthome/ExportStoryUseCaseTest.kt | 45 ++++ .../ProjectSettingsComponentTest.kt | 49 ++++ .../projectslist/ProjectsListComponentTest.kt | 12 +- .../focusmode/FocusModeComponentTest.kt | 4 +- .../sceneeditor/SceneEditorComponentTest.kt | 6 +- .../timeline/TimeLineComponentTest.kt | 2 +- .../ideas/PromoteIdeaUseCaseTest.kt | 10 +- .../projectdata/ProjectDataRepositoryTest.kt | 27 +++ .../ProjectsRepositoryBaseTest.kt | 10 + .../ProjectsRepositoryBasicTest.kt | 53 ++++- .../ProjectsRepositoryConcurrencyTest.kt | 2 +- .../ProjectsRepositoryFindTest.kt | 12 +- .../ProjectsRepositoryIdTest.kt | 6 +- .../ProjectsRepositoryRenameTest.kt | 8 +- .../spellcheck/LanguageMatchingTest.kt | 70 ++++++ .../ProjectSpellCheckRepositoryTest.kt | 183 ++++++++++++++ .../repositories/timeline/TimeLineTestBase.kt | 7 + .../ClientAccountSynchronizerTest.kt | 14 +- .../desktopTest/kotlin/utils/ComponentTest.kt | 18 +- .../dependencyinjection/platformModule.kt | 1 + .../common/util/AvailableLocalesProvider.kt | 13 + .../compose/designsystem/DESIGN_README.md | 6 + .../designsystem/HdSearchableListDialog.kt | 225 ++++++++++++++++++ .../common/encyclopedia/CreateEntryUi.kt | 1 + .../hammer/common/encyclopedia/ViewEntryUi.kt | 4 + .../apps/hammer/common/notes/CreateNoteUi.kt | 1 + .../apps/hammer/common/notes/ViewNoteUi.kt | 3 + .../projecthome/ProjectInfoSettingsUi.kt | 87 +++++++ .../common/projecthome/ProjectSettingsUi.kt | 30 +++ .../common/projectsync/ProjectDataConflict.kt | 23 ++ .../common/timeline/CreateTimeLineEventUi.kt | 1 + .../common/timeline/ViewTimeLineEventUi.kt | 3 + .../HdSearchableListDialog.Preview.kt | 72 ++++++ .../projecthome/ProjectSettingsUi.Preview.kt | 35 ++- docs/ARCHITECTURE.md | 8 + .../apps/hammer/integration/HeadlessClient.kt | 7 +- .../apps/hammer/database/ProjectDataDao.kt | 5 + .../apps/hammer/frontend/Frontend.kt | 2 + .../apps/hammer/frontend/PublicStoryPage.kt | 28 ++- .../apps/hammer/frontend/utils/SeoJsonLd.kt | 3 + .../project/ServerProjectDataRepository.kt | 22 ++ .../apps/hammer/ProjectData.sq | 6 + .../apps/hammer/e2e/PublicStoryPageTest.kt | 53 +++++ .../hammer/frontend/utils/SeoJsonLdTest.kt | 16 ++ .../utilities/EntityHashSensitivityTest.kt | 2 + 90 files changed, 1616 insertions(+), 140 deletions(-) create mode 100644 common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/LanguageMatching.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/ProjectSpellCheckRepository.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt create mode 100644 common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt create mode 100644 common/src/desktopTest/kotlin/architecture/PersistedFormatOwnershipTest.kt create mode 100644 common/src/desktopTest/kotlin/repositories/spellcheck/LanguageMatchingTest.kt create mode 100644 common/src/desktopTest/kotlin/repositories/spellcheck/ProjectSpellCheckRepositoryTest.kt create mode 100644 common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdSearchableListDialog.kt create mode 100644 composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/HdSearchableListDialog.Preview.kt diff --git a/android/src/androidTest/kotlin/EditorTestHarness.kt b/android/src/androidTest/kotlin/EditorTestHarness.kt index 2ee48a5b3..4f0bbc24f 100644 --- a/android/src/androidTest/kotlin/EditorTestHarness.kt +++ b/android/src/androidTest/kotlin/EditorTestHarness.kt @@ -32,7 +32,10 @@ object EditorTestHarness { /** Create a uniquely-named project so re-runs don't collide; returns its def. */ fun seedProject(baseName: String): ProjectDef { - val result = repository().createProject("$baseName ${System.currentTimeMillis()}") + val result = repository().createProject( + "$baseName ${System.currentTimeMillis()}", + seedDefaultLanguage = true, + ) check(result is ClientResult.Success) { "Failed to seed project: $result" } return result.data } diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/projectdata/ProjectData.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/projectdata/ProjectData.kt index 973955f1e..7c727abd2 100644 --- a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/projectdata/ProjectData.kt +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/projectdata/ProjectData.kt @@ -9,6 +9,8 @@ data class ProjectData( val wordCountGoal: WordCountGoal? = null, /** Picked as a unit during conflict resolution — the whole set comes from one side, never a merge. */ val tags: Set = emptySet(), + /** BCP-47 tag of the language the project is written in (e.g. "en", "pt-BR"); null when unset. */ + val language: String? = null, ) /** Picked as a unit during conflict resolution — one device's primary paired with another's secondary is undesigned. */ diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasher.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasher.kt index 82a0c73e8..52213976c 100644 --- a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasher.kt +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasher.kt @@ -46,6 +46,16 @@ object ProjectDataHasher { data.tags.sorted().forEach { tag -> d.update(tag, buf) } } + // Zero bytes when null, same rule as tags: pre-language hashes must stay identical. + // The -1 marker can never be a tags size and the length prefix bounds the string, + // so a language block cannot be read as a continuation of the tags block + // (tags=["x"] and language="x" must not collide). + if (data.language != null) { + d.update(-1, buf) + d.update(data.language.length, buf) + d.update(data.language, buf) + } + return d.digest().base64Url } } diff --git a/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasherTest.kt b/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasherTest.kt index 3b23e831c..630a48a36 100644 --- a/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasherTest.kt +++ b/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/http/synchronizer/ProjectDataHasherTest.kt @@ -56,6 +56,42 @@ class ProjectDataHasherTest { ) } + @Test + fun `language affects hash`() { + val unset = ProjectData(authorName = "Pat") + val set = unset.copy(language = "en-US") + assertNotEquals(ProjectDataHasher.hash(unset), ProjectDataHasher.hash(set)) + } + + @Test + fun `language region affects hash`() { + val en = ProjectData(language = "en") + val enUs = ProjectData(language = "en-US") + assertNotEquals(ProjectDataHasher.hash(en), ProjectDataHasher.hash(enUs)) + } + + @Test + fun `language block does not collide with a one-element tags block`() { + val tagged = ProjectData(tags = setOf("en")) + val languaged = ProjectData(language = "en") + assertNotEquals( + ProjectDataHasher.hash(tagged), + ProjectDataHasher.hash(languaged), + "tags=[x] and language=x must hash differently", + ) + } + + @Test + fun `null vs empty language distinguishable`() { + val nullLanguage = ProjectData(language = null) + val emptyLanguage = ProjectData(language = "") + assertNotEquals( + ProjectDataHasher.hash(nullLanguage), + ProjectDataHasher.hash(emptyLanguage), + "null and empty-string language must hash differently", + ) + } + @Test fun `same input produces same hash`() { val data = ProjectData( diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt index 130055b91..7c1db01fd 100644 --- a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt +++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt @@ -19,6 +19,7 @@ actual val platformModule = module { single() single() bind StrRes::class single() + single() single() bind UrlLauncher::class single() factory { params -> diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt new file mode 100644 index 000000000..648861890 --- /dev/null +++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt @@ -0,0 +1,11 @@ +package com.darkrockstudios.apps.hammer.common.util + +actual class AvailableLocalesProvider { + actual fun allLocales(): List = + java.util.Locale.getAvailableLocales() + // Parse the full tag rather than language+country so script-distinct + // locales (zh-Hans vs zh-Hant) stay separate entries. + .map { Locale.forLanguageTag(it.toLanguageTag()) } + .filter { it.language != null } + .distinctBy { it.toLanguageTag() } +} diff --git a/common/src/commonMain/composeResources/values/strings_sync.xml b/common/src/commonMain/composeResources/values/strings_sync.xml index 92de48a52..045031529 100644 --- a/common/src/commonMain/composeResources/values/strings_sync.xml +++ b/common/src/commonMain/composeResources/values/strings_sync.xml @@ -45,6 +45,13 @@ Tags ↵ to add fantasy, draft, nanowrimo… + Language + Not set + Choose a language + Close + Search languages + Clear (not set) + No languages match Edit Cancel OK @@ -55,6 +62,7 @@ by %1$s by (no author set) Spell checking + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Project Settings · %1$s §§ %1$d @@ -64,6 +72,7 @@ Project Theme Word Count Goal Tags + Language (not set) day week diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/ProjectComponentBase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/ProjectComponentBase.kt index 8f0ca3d65..c71c3f47b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/ProjectComponentBase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/ProjectComponentBase.kt @@ -7,6 +7,9 @@ import com.darkrockstudios.apps.hammer.common.data.projectInject import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService import com.darkrockstudios.apps.hammer.common.data.tagindex.TagSuggesting import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext abstract class ProjectComponentBase( val projectDef: ProjectDef, @@ -15,9 +18,21 @@ abstract class ProjectComponentBase( override val projectScope = ProjectDefScope(projectDef) private val tagIndexService: TagIndexService by projectInject() + private val projectSpellCheck: ProjectSpellCheckRepository by projectInject() override fun suggestTags(prefix: String, limit: Int): List = tagIndexService.suggest(prefix, limit).map { it.tag } + + /** Collects whether the project's language allows spell check; call from onCreate. [onChange] runs on the main dispatcher. */ + protected fun watchSpellCheckAllowed(onChange: (Boolean) -> Unit) { + scope.launch { + projectSpellCheck.spellCheckAllowed.collect { allowed -> + withContext(dispatcherMain) { + onChange(allowed) + } + } + } + } } abstract class SavableProjectComponentBase( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntry.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntry.kt index cebf3228c..f914bc6c6 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntry.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntry.kt @@ -12,7 +12,8 @@ interface CreateEntry : TagSuggesting { data class State( val projectDef: ProjectDef, - val showConfirmClose: Boolean = false + val showConfirmClose: Boolean = false, + val spellCheckAllowed: Boolean = true, ) suspend fun createEntry( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntryComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntryComponent.kt index 12b34f9ae..842b1dd91 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntryComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/CreateEntryComponent.kt @@ -28,6 +28,14 @@ class CreateEntryComponent( // Note: Back handler is disabled to allow predictive back animation. // The UI handles close confirmation via confirmClose()/dismissConfirmClose(). + override fun onCreate() { + super.onCreate() + + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } + } + override fun confirmClose() { _state.getAndUpdate { it.copy(showConfirmClose = true) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt index d62cded49..747cdbe00 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt @@ -27,6 +27,7 @@ interface ViewEntry : TagSuggesting { val showAliasAdd: Boolean = false, val menuItems: Set = emptySet(), val appearsIn: List = emptyList(), + val spellCheckAllowed: Boolean = true, ) data class Appearance( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt index 39116eb72..2f9a76638 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt @@ -62,6 +62,10 @@ class ViewEntryComponent( backButtonHandler.isEnabled = it.editName || it.editText } + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } + // Trigger an initial recompute so the index reflects current data scope.launch { referenceIndexService.loadIndex() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNote.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNote.kt index 1ccfd3627..d528a58fd 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNote.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNote.kt @@ -12,6 +12,7 @@ interface CreateNote : TagSuggesting { @Serializable data class State( val confirmDiscard: Boolean = false, + val spellCheckAllowed: Boolean = true, ) suspend fun createNote(noteText: String, tags: Set): NoteError diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNoteComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNoteComponent.kt index fa64d7642..46e53ebb6 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNoteComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/CreateNoteComponent.kt @@ -52,6 +52,10 @@ class CreateNoteComponent( noteText.subscribe(lifecycle) { backButtonHandler.isEnabled = it.isNotBlank() } + + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } } override fun onTextChanged(newText: String) { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNote.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNote.kt index 841cfe8f9..923fed1e3 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNote.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNote.kt @@ -19,6 +19,7 @@ interface ViewNote : TagSuggesting { val confirmDelete: Boolean = false, val isEditing: Boolean = false, val menuItems: Set = emptySet(), + val spellCheckAllowed: Boolean = true, ) fun discardEdit() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt index caa185145..babd56f25 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt @@ -76,6 +76,10 @@ class ViewNoteComponent( backButtonHandler.isEnabled = it.isEditing } + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } + loadInitialContent() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt index 18414f013..6d9dd409b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt @@ -98,8 +98,10 @@ class ExportStoryUseCase( } val projectData = if (options.format == ExportFormat.Markdown) null else projectDataDatasource.load().data - val language = - localeResolver.getCurrentLocale().language?.takeIf { it.isNotBlank() } ?: "en" + // The project's declared language wins; the device locale is only a fallback. + val language = projectData?.language?.takeIf { it.isNotBlank() } + ?: localeResolver.getCurrentLocale().language?.takeIf { it.isNotBlank() } + ?: "en" ExportSource(perNodeChapters, projectData, language) } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettings.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettings.kt index 99b8bb33d..b0aa6e2d9 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettings.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettings.kt @@ -13,11 +13,22 @@ interface ProjectSettings : HammerComponent { val spellCheckSettings: SpellCheckSettings val projectInfoState: Value + /** Every language the platform offers, sorted by display name, for the language picker. */ + val availableLanguages: List + fun setAuthorName(name: String?) fun setTheme(theme: ProjectTheme?) fun setWordCountGoal(goal: WordCountGoal?) fun setTags(tags: Set) + /** [tag] is a BCP-47 tag; null or blank clears the project language. */ + fun setProjectLanguage(tag: String?) + + data class LanguageOption( + val tag: String, + val displayName: String, + ) + /** Suggests from the account-level tag vocabulary (project + story-idea tags, via [com.darkrockstudios.apps.hammer.common.data.tagindex.AccountTagService]) — distinct from [com.darkrockstudios.apps.hammer.common.data.tagindex.TagSuggesting.suggestTags], which suggests in-project entity tags. */ fun suggestProjectTags(prefix: String): List diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettingsComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettingsComponent.kt index 69c56647f..642c33aec 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettingsComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectSettingsComponent.kt @@ -14,6 +14,9 @@ import com.darkrockstudios.apps.hammer.common.data.projectInject import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataRepository import com.darkrockstudios.apps.hammer.common.data.tagindex.AccountTagService import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectMainDispatcher +import com.darkrockstudios.apps.hammer.common.spellcheck.displayName +import com.darkrockstudios.apps.hammer.common.util.AvailableLocalesProvider +import com.darkrockstudios.apps.hammer.common.util.Locale import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.inject @@ -26,6 +29,7 @@ class ProjectSettingsComponent( private val mainDispatcher by injectMainDispatcher() private val projectDataRepository: ProjectDataRepository by projectInject() private val accountTagService: AccountTagService by inject() + private val availableLocalesProvider: AvailableLocalesProvider by inject() override val projectName: String = projectDef.name @@ -76,6 +80,21 @@ class ProjectSettingsComponent( } } + override val availableLanguages: List by lazy { + availableLocalesProvider.allLocales() + .map { ProjectSettings.LanguageOption(tag = it.toLanguageTag(), displayName = it.displayName()) } + .sortedBy { it.displayName.lowercase() } + } + + override fun setProjectLanguage(tag: String?) { + val cleaned = tag?.takeIf { it.isNotBlank() } + ?.let { Locale.forLanguageTag(it).toLanguageTag() } + ?.takeIf { it.isNotBlank() } + scope.launch { + projectDataRepository.updateData { it.copy(language = cleaned) } + } + } + override fun suggestProjectTags(prefix: String): List { // Exclude this project's own tags so it suggests from the *other* projects' / ideas' // vocabulary, rather than offering back tags already applied here. diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt index 5b60fb718..decaf54c9 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt @@ -4,7 +4,6 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.getAndUpdate import com.darkrockstudios.apps.hammer.Res -import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull import com.darkrockstudios.apps.hammer.common.components.ComponentToaster import com.darkrockstudios.apps.hammer.common.components.ComponentToasterImpl import com.darkrockstudios.apps.hammer.common.components.SavableComponent @@ -20,8 +19,7 @@ import com.darkrockstudios.apps.hammer.common.data.importer.ImportPreview import com.darkrockstudios.apps.hammer.common.data.importer.StoryImporterRegistry import com.darkrockstudios.apps.hammer.common.data.isSuccess import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataConflictBroker -import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataDatasource -import com.darkrockstudios.apps.hammer.common.data.projectdata.StoredProjectData +import com.darkrockstudios.apps.hammer.common.data.projectdata.readStoredProjectData import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.projectstatistics.ProjectStatisticsCacheReader @@ -251,16 +249,13 @@ class ProjectsListComponent( } /** - * Reads `project_data.toml` (author, theme, word-count goal) without - * opening a per-project Koin scope — the projects list previews many - * projects and a full scope per row would be wasteful. + * Reads `project_data.toml` (author, theme, word-count goal) via the datasource's + * scope-less helper — the projects list previews many projects and a full + * per-project Koin scope per row would be wasteful. Blocking variant because + * [loadProjectList]'s parallelMap workers are not a coroutine context. */ - private fun loadStoredProjectData(projectDef: ProjectDef): StoredData { - val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME - return fileSystem.readTomlOrNull(path, toml) { e -> - //Napier.d("Failed to read stored project data for ${projectDef.name}, using defaults", e) - }?.data ?: StoredData() - } + private fun loadStoredProjectData(projectDef: ProjectDef): StoredData = + readStoredProjectData(projectDef, fileSystem, toml).data private fun updateLastAccessed(projectDef: ProjectDef) { projectMetadataDatasource.updateMetadata(projectDef) { metadata -> @@ -289,7 +284,7 @@ class ProjectsListComponent( } override fun createProject(projectName: String) { - val result = projectsRepository.createProject(projectName) + val result = projectsRepository.createProject(projectName, seedDefaultLanguage = true) if (isSuccess(result)) { if (projectsSynchronizer.isServerSynchronized()) { projectsSynchronizer.createProject(projectName) @@ -483,7 +478,7 @@ class ProjectsListComponent( return } - val result = projectsRepository.createProject(projectName) + val result = projectsRepository.createProject(projectName, seedDefaultLanguage = true) if (!isSuccess(result)) { result.displayMessage?.let { msg -> showToast(scope, msg) } Napier.e("Import: failed to create project '$projectName'") diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/focusmode/FocusModeComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/focusmode/FocusModeComponent.kt index df3bdad2c..96e6c2038 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/focusmode/FocusModeComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/focusmode/FocusModeComponent.kt @@ -11,7 +11,7 @@ import com.darkrockstudios.apps.hammer.common.data.* import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService -import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import io.github.aakira.napier.Napier import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -27,7 +27,7 @@ class FocusModeComponent( ) : ProjectComponentBase(projectDef, componentContext), FocusMode { private val settingsRepository: GlobalSettingsStore by inject() - private val spellCheckRepository: SpellCheckRepository by inject() + private val spellCheckRepository: ProjectSpellCheckRepository by projectInject() private val focusModeService: FocusModeService by inject() private val sceneEditor: SceneEditorService by projectInject() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt index d98ccffcb..fc0a156f3 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt @@ -21,7 +21,7 @@ import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.references.AutoConfirmReferencesUseCase import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService -import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import io.github.aakira.napier.Napier import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -48,7 +48,7 @@ class SceneEditorComponent( private val draftsRepository: SceneDraftRepository by projectInject() private val autoConfirmReferences: AutoConfirmReferencesUseCase by projectInject() - private val spellCheckRepository: SpellCheckRepository by inject() + private val spellCheckRepository: ProjectSpellCheckRepository by projectInject() private val _state = MutableValue( SceneEditor.State( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEvent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEvent.kt index 8f0b80e33..1807c6ce5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEvent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEvent.kt @@ -24,5 +24,6 @@ interface CreateTimeLineEvent : TagSuggesting { data class State( val projectDef: ProjectDef, val confirmDiscard: Boolean = false, + val spellCheckAllowed: Boolean = true, ) } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEventComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEventComponent.kt index 092730718..8e579e87c 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEventComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/CreateTimeLineEventComponent.kt @@ -44,6 +44,10 @@ class CreateTimeLineEventComponent( contentText.subscribe(lifecycle) { backButtonHandler.isEnabled = it.isNotBlank() } + + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } } override fun onContentChanged(newText: String) { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEvent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEvent.kt index 8eb67ef73..c993e1d20 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEvent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEvent.kt @@ -29,7 +29,8 @@ interface ViewTimeLineEvent : TagSuggesting { val confirmDelete: Boolean = false, val confirmClose: Boolean = false, val confirmDiscard: Boolean = false, - val isEditing: Boolean = false + val isEditing: Boolean = false, + val spellCheckAllowed: Boolean = true, ) val dateText: Value diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt index 84f91c954..ad71c8a32 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt @@ -81,6 +81,10 @@ class ViewTimeLineEventComponent( loadInitialEvent() watchTimeLine() + + watchSpellCheckAllowed { allowed -> + _state.getAndUpdate { it.copy(spellCheckAllowed = allowed) } + } } private fun watchTimeLine() { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExampleProjectRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExampleProjectRepository.kt index d1e6e5e8c..0ac5d819b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExampleProjectRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExampleProjectRepository.kt @@ -1,12 +1,10 @@ package com.darkrockstudios.apps.hammer.common.data -import com.darkrockstudios.apps.hammer.base.http.writeToml import com.darkrockstudios.apps.hammer.base.http.writingactivity.DeviceLog import com.darkrockstudios.apps.hammer.base.http.writingactivity.WritingSession import com.darkrockstudios.apps.hammer.common.data.ExampleProjectRepository.Companion.EXAMPLE_DAYS import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore -import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneDatasource -import com.darkrockstudios.apps.hammer.common.data.writingactivity.WritingActivityDatasource +import com.darkrockstudios.apps.hammer.common.data.writingactivity.writeDeviceLog import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectDefaultDispatcher import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectMainDispatcher import io.github.aakira.napier.Napier @@ -65,13 +63,10 @@ abstract class ExampleProjectRepository( @Suppress("TooGenericExceptionCaught") // Best-effort fabrication; any failure is logged private fun fabricateExampleActivity() { - val activityDir = projectsDir() / PROJECT_NAME / - SceneDatasource.SCENE_DIRECTORY / WritingActivityDatasource.ACTIVITY_DIRECTORY try { - fileSystem.createDirectories(activityDir) val sessions = generateExampleSessions(clock.now(), TimeZone.currentSystemDefault()) val log = DeviceLog(deviceLabel = FABRICATED_DEVICE_LABEL, sessions = sessions) - fileSystem.writeToml(activityDir / "$FABRICATED_DEVICE_ID.toml", toml, log) + writeDeviceLog(projectsDir() / PROJECT_NAME, FABRICATED_DEVICE_ID, log, fileSystem, toml) } catch (e: Exception) { Napier.e("Failed to fabricate example writing activity", e) } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ideasrepository/PromoteIdeaUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ideasrepository/PromoteIdeaUseCase.kt index e2980e231..126b84f8e 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ideasrepository/PromoteIdeaUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ideasrepository/PromoteIdeaUseCase.kt @@ -1,8 +1,6 @@ package com.darkrockstudios.apps.hammer.common.data.ideasrepository import com.darkrockstudios.apps.hammer.base.IdeaId -import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData -import com.darkrockstudios.apps.hammer.base.http.writeToml import com.darkrockstudios.apps.hammer.common.data.CResult import com.darkrockstudios.apps.hammer.common.data.ClientResult import com.darkrockstudios.apps.hammer.common.data.ProjectDef @@ -10,11 +8,10 @@ import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea import com.darkrockstudios.apps.hammer.common.data.notesrepository.NotesDatasource import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContainer import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent -import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataDatasource -import com.darkrockstudios.apps.hammer.common.data.projectdata.StoredProjectData +import com.darkrockstudios.apps.hammer.common.data.projectdata.loadStoredProjectData +import com.darkrockstudios.apps.hammer.common.data.projectdata.saveStoredProjectData import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispatcherNow -import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath import kotlinx.coroutines.withContext import net.peanuuutz.tomlkt.Toml import okio.FileSystem @@ -61,14 +58,16 @@ class PromoteIdeaUseCase( ) if (idea.tags.isNotEmpty()) { - // Written directly rather than through ProjectDataDatasource to avoid opening a - // per-project Koin scope for a project that isn't loaded. The project is brand new, - // so there is no existing blob to merge with. + // The datasource's scope-less helpers, because the project isn't loaded (no + // per-project Koin scope yet). Read-modify-write, not a fresh blob: createProject + // already seeded the default language and it must survive. + val stored = loadStoredProjectData(projectDef, fileSystem, toml) withContext(ioDispatcher) { - fileSystem.writeToml( - projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME, + saveStoredProjectData( + projectDef, + fileSystem, toml, - StoredProjectData(data = ProjectData(tags = idea.tags)), + stored.copy(data = stored.data.copy(tags = idea.tags)), ) } } @@ -79,10 +78,10 @@ class PromoteIdeaUseCase( } private fun createWithUniqueName(baseName: String): CResult { - var result = projectsRepository.createProject(baseName) + var result = projectsRepository.createProject(baseName, seedDefaultLanguage = true) var suffix = 2 while (result.isFailure && suffix <= MAX_NAME_ATTEMPTS) { - result = projectsRepository.createProject("$baseName $suffix") + result = projectsRepository.createProject("$baseName $suffix", seedDefaultLanguage = true) suffix++ } return result diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectdata/ProjectDataDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectdata/ProjectDataDatasource.kt index d69e8968d..ae8fc9902 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectdata/ProjectDataDatasource.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectdata/ProjectDataDatasource.kt @@ -42,7 +42,7 @@ class ProjectDataDatasource( } suspend fun save(stored: StoredProjectData): Unit = withContext(dispatcherIo) { - fileSystem.writeToml(getPath(), toml, stored) + saveStoredProjectData(projectDef, fileSystem, toml, stored) } companion object { @@ -68,8 +68,31 @@ suspend fun loadStoredProjectData( fileSystem: FileSystem, toml: Toml, ): StoredProjectData = withContext(Dispatchers.IO) { + readStoredProjectData(projectDef, fileSystem, toml) +} + +/** Blocking twin of [loadStoredProjectData] for callers that already own their threading (parallel mappers, IO-dispatched code). */ +fun readStoredProjectData( + projectDef: ProjectDef, + fileSystem: FileSystem, + toml: Toml, +): StoredProjectData { val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME - fileSystem.readTomlOrNull(path, toml) { e -> + return fileSystem.readTomlOrNull(path, toml) { e -> Napier.d("No project_data.toml at $path (${e.message})") } ?: StoredProjectData() } + +/** + * The single writer for `project_data.toml`. [ProjectDataDatasource.save] delegates here; + * scope-less callers (project creation, before a per-project Koin scope exists) use it + * directly so the on-disk format has exactly one owner. + */ +fun saveStoredProjectData( + projectDef: ProjectDef, + fileSystem: FileSystem, + toml: Toml, + stored: StoredProjectData, +) { + fileSystem.writeToml(projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME, toml, stored) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectsrepository/ProjectsRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectsrepository/ProjectsRepository.kt index db1b95aa1..ed9600f05 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectsrepository/ProjectsRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectsrepository/ProjectsRepository.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.apps.hammer.common.data.projectsrepository import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.base.ProjectId +import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData import com.darkrockstudios.apps.hammer.base.validate.ProjectNameValidationResult import com.darkrockstudios.apps.hammer.base.validate.ProjectNameValidator import com.darkrockstudios.apps.hammer.base.validate.validateProjectName @@ -13,6 +14,8 @@ import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.isSuccess import com.darkrockstudios.apps.hammer.common.data.migrator.PROJECT_DATA_VERSION +import com.darkrockstudios.apps.hammer.common.data.projectdata.StoredProjectData +import com.darkrockstudios.apps.hammer.common.data.projectdata.saveStoredProjectData import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository.Companion.MAX_FILENAME_LENGTH import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository.Companion.RECOVERED_PROJECT_NAME @@ -21,6 +24,7 @@ import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRe import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository.Companion.validateFileName import com.darkrockstudios.apps.hammer.common.data.toMsg import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_DEFAULT +import com.darkrockstudios.apps.hammer.common.util.DeviceLocaleResolver import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath @@ -32,6 +36,7 @@ import com.darkrockstudios.apps.hammer.create_project_error_too_long import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import net.peanuuutz.tomlkt.Toml import okio.FileSystem import okio.IOException import okio.Path.Companion.toPath @@ -45,7 +50,9 @@ import kotlin.time.Clock class ProjectsRepository( private val fileSystem: FileSystem, globalSettingsStore: GlobalSettingsStore, - private val projectsMetadataDatasource: ProjectMetadataDatasource + private val projectsMetadataDatasource: ProjectMetadataDatasource, + private val toml: Toml, + private val deviceLocaleResolver: DeviceLocaleResolver, ) : KoinComponent { private val dispatcherDefault: CoroutineContext by inject(named(DISPATCHER_DEFAULT)) @@ -155,7 +162,13 @@ class ProjectsRepository( return ProjectDef(projectName, projectDir.toHPath()) } - fun createProject(projectName: String): CResult { + /** + * [seedDefaultLanguage] must be false when materializing a project that already exists + * on the server (account sync): a fresh local shell has to keep the never-synced + * ProjectData baseline (empty data, null lastSyncedHash), or the first project sync + * reads the seed as a phantom local edit and raises a spurious conflict. + */ + fun createProject(projectName: String, seedDefaultLanguage: Boolean): CResult { val strippedName = projectName.trim() val result = validateFileName(strippedName) return if (isSuccess(result)) { @@ -180,6 +193,20 @@ class ProjectsRepository( ) projectsMetadataDatasource.saveMetadata(metadata, newDef) + if (seedDefaultLanguage) { + // New projects start declared in the device's language, without a region: + // the auto-default must never gate spell check against a same-language + // dictionary of another region (en vs en-GB). Users can pick a full tag + // in project settings. + val languageTag = deviceLocaleResolver.getCurrentLocale().language + saveStoredProjectData( + newDef, + fileSystem, + toml, + StoredProjectData(data = ProjectData(language = languageTag)), + ) + } + CResult.success(newDef) } } else { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatisticsCacheReader.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatisticsCacheReader.kt index a808e796f..1bf83a645 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatisticsCacheReader.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatisticsCacheReader.kt @@ -1,25 +1,17 @@ package com.darkrockstudios.apps.hammer.common.data.projectstatistics -import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull import com.darkrockstudios.apps.hammer.common.data.ProjectDef -import io.github.aakira.napier.Napier import net.peanuuutz.tomlkt.Toml import okio.FileSystem -/** Reads the cached stats file directly, without opening a ProjectDefScope. */ +/** Reads the cached stats file via the datasource's scope-less helper, without opening a ProjectDefScope. */ class ProjectStatisticsCacheReader( private val fileSystem: FileSystem, private val toml: Toml, ) { fun loadStatistics(projectDef: ProjectDef): ProjectStatistics? { - val file = StatisticsCachePaths.statsFile(projectDef) - if (!fileSystem.exists(file)) return null - - val stats = fileSystem.readTomlOrNull(file, toml) { e -> - Napier.d("Failed to read statistics cache for ${projectDef.name}", e) - } ?: return null - + val stats = readProjectStatistics(projectDef, fileSystem, toml) ?: return null if (stats.schemaVersion != ProjectStatistics.CURRENT_SCHEMA_VERSION) return null return stats } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/StatisticsDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/StatisticsDatasource.kt index 77ce749bf..a249bb7b4 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/StatisticsDatasource.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/StatisticsDatasource.kt @@ -22,14 +22,7 @@ class StatisticsDatasource( private val dispatcherIo by injectIoDispatcher() suspend fun loadStatistics(): ProjectStatistics? = withContext(dispatcherIo) { - val file = StatisticsCachePaths.statsFile(projectDef) - return@withContext if (fileSystem.exists(file)) { - fileSystem.readTomlOrNull(file, toml) { e -> - Napier.e("Failed to load statistics cache", e) - } - } else { - null - } + readProjectStatistics(projectDef, fileSystem, toml) } suspend fun saveStatistics(stats: ProjectStatistics) = withContext(dispatcherIo) { @@ -68,3 +61,20 @@ class StatisticsDatasource( } } } + +/** + * Blocking, scope-less read of the statistics cache; callers own their threading. + * [StatisticsDatasource.loadStatistics] and the root-scoped + * [ProjectStatisticsCacheReader] both delegate here so the format has one reader. + */ +fun readProjectStatistics( + projectDef: ProjectDef, + fileSystem: FileSystem, + toml: Toml, +): ProjectStatistics? { + val file = StatisticsCachePaths.statsFile(projectDef) + if (!fileSystem.exists(file)) return null + return fileSystem.readTomlOrNull(file, toml) { e -> + Napier.d("Failed to read statistics cache for ${projectDef.name}", e) + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt index b0ec9155c..6cb44e1fc 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt @@ -451,7 +451,7 @@ class ClientAccountSynchronizer( ) ) } else { - val createResult = projectsRepository.createProject(localName) + val createResult = projectsRepository.createProject(localName, seedDefaultLanguage = false) if (isSuccess(createResult)) { val projectDef = createResult.data projectsRepository.setProjectId(projectDef, serverProject.uuid) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/writingactivity/WritingActivityDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/writingactivity/WritingActivityDatasource.kt index 5fbe283db..d58d409da 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/writingactivity/WritingActivityDatasource.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/writingactivity/WritingActivityDatasource.kt @@ -34,8 +34,7 @@ class WritingActivityDatasource( override val projectScope = ProjectDefScope(projectDef) private val dispatcherIo by injectIoDispatcher() - fun getDirectory(): Path = - projectDef.path.toOkioPath() / SceneDatasource.SCENE_DIRECTORY / ACTIVITY_DIRECTORY + fun getDirectory(): Path = activityDirectory(projectDef.path.toOkioPath()) fun getDeviceLogPath(deviceId: String): Path = getDirectory() / "$deviceId$FILE_SUFFIX" @@ -62,8 +61,7 @@ class WritingActivityDatasource( } suspend fun saveDeviceLog(deviceId: String, log: DeviceLog): Unit = withContext(dispatcherIo) { - fileSystem.createDirectories(getDirectory()) - fileSystem.writeToml(getDeviceLogPath(deviceId), toml, log) + writeDeviceLog(projectDef.path.toOkioPath(), deviceId, log, fileSystem, toml) } companion object { @@ -71,3 +69,24 @@ class WritingActivityDatasource( const val FILE_SUFFIX = ".toml" } } + +/** The `.activity` directory under [projectRoot]; the single owner of the path convention. */ +fun activityDirectory(projectRoot: Path): Path = + projectRoot / SceneDatasource.SCENE_DIRECTORY / WritingActivityDatasource.ACTIVITY_DIRECTORY + +/** + * Blocking, scope-less write of a device's activity log under [projectRoot]; callers own + * their threading. [WritingActivityDatasource.saveDeviceLog] delegates here; the + * example-project installer uses it directly because the project isn't loaded. + */ +fun writeDeviceLog( + projectRoot: Path, + deviceId: String, + log: DeviceLog, + fileSystem: FileSystem, + toml: Toml, +) { + val dir = activityDirectory(projectRoot) + fileSystem.createDirectories(dir) + fileSystem.writeToml(dir / "$deviceId${WritingActivityDatasource.FILE_SUFFIX}", toml, log) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt index 35cab0b64..9762c902c 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt @@ -115,6 +115,7 @@ import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import com.darkrockstudios.apps.hammer.common.server.ServerIdeasApi import com.darkrockstudios.apps.hammer.common.server.ServerProjectsApi import com.darkrockstudios.apps.hammer.common.server.WritingActivityApi +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository import com.russhwolf.settings.Settings import io.ktor.client.HttpClient @@ -281,6 +282,7 @@ val mainModule = module { scoped() scoped() scoped() + scoped() scoped() scoped() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/LanguageMatching.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/LanguageMatching.kt new file mode 100644 index 000000000..f335f86c9 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/LanguageMatching.kt @@ -0,0 +1,27 @@ +package com.darkrockstudios.apps.hammer.common.spellcheck + +import com.darkrockstudios.apps.hammer.common.util.Locale + +/** + * Lenient locale match: languages must be equal; regions are compared only when + * both sides have one. So "en" matches "en-US", but "en-US" does not match "en-GB". + * Scripts are ignored: spell-check dictionaries are keyed by language+region only, + * so a zh-Hant project must still match a zh dictionary. + */ +fun localesMatchLeniently(a: Locale, b: Locale): Boolean { + if (a.language == null || b.language == null) return false + if (a.language != b.language) return false + val aRegion = a.region + val bRegion = b.region + return aRegion == null || bRegion == null || aRegion == bRegion +} + +/** + * Whether spell checking may run for a project written in [projectLanguageTag] + * (BCP-47, null/blank = unset) given the user's [dictionary] locale. An unset + * project language never gates spell check. + */ +fun isSpellCheckAllowedForProject(projectLanguageTag: String?, dictionary: Locale): Boolean { + if (projectLanguageTag.isNullOrBlank()) return true + return localesMatchLeniently(Locale.forLanguageTag(projectLanguageTag), dictionary) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/ProjectSpellCheckRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/ProjectSpellCheckRepository.kt new file mode 100644 index 000000000..f7ff49fa8 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/spellcheck/ProjectSpellCheckRepository.kt @@ -0,0 +1,45 @@ +package com.darkrockstudios.apps.hammer.common.spellcheck + +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.ProjectScoped +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataRepository +import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onStart + +/** + * Project-aware view of the global [SpellCheckRepository]: the dictionary is withheld + * (null) while the project's declared language doesn't leniently match the user's + * spell-check dictionary locale. An unset project language never gates. + */ +class ProjectSpellCheckRepository( + spellCheckRepository: SpellCheckRepository, + globalSettingsStore: GlobalSettingsStore, + private val projectDataRepository: ProjectDataRepository, + val projectDef: ProjectDef, +) : ProjectScoped { + + override val projectScope = ProjectDefScope(projectDef) + + /** True while the project language allows the current dictionary locale. */ + val spellCheckAllowed: Flow = combine( + globalSettingsStore.globalSettingsUpdates, + projectDataRepository.state, + ) { settings, stored -> + isSpellCheckAllowedForProject(stored?.data?.language, settings.spellCheckSettings.locale) + } + .onStart { projectDataRepository.load() } + .distinctUntilChanged() + + /** [SpellCheckRepository.dictionaryFlow] gated on [spellCheckAllowed]. */ + val dictionaryFlow: Flow = combine( + spellCheckRepository.dictionaryFlow, + spellCheckAllowed, + ) { checker, allowed -> + if (allowed) checker else null + }.distinctUntilChanged() +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt new file mode 100644 index 000000000..84da0ed29 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt @@ -0,0 +1,6 @@ +package com.darkrockstudios.apps.hammer.common.util + +/** Enumerates every locale the platform knows about, for pickers that need the full list. */ +expect class AvailableLocalesProvider { + fun allLocales(): List +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/Locale.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/Locale.kt index 7e89688f3..669bd05f8 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/Locale.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/Locale.kt @@ -1,46 +1,58 @@ package com.darkrockstudios.apps.hammer.common.util /** - * Minimal locale value type: a language plus an optional region. Replaces + * Minimal locale value type: a language plus optional script and region. Replaces * io.fluidsonic.locale, which dropped its Kotlin/Native (iOS) targets in 0.14.0. - * We only ever model language + region, so script/variant subtags are ignored. + * Variant and extension subtags are ignored. The script matters for languages + * written in more than one (zh-Hans vs zh-Hant, sr-Latn vs sr-Cyrl): dropping it + * would collapse distinct platform locales into one tag. */ class Locale private constructor( val language: String?, + val script: String?, val region: String?, ) { - fun toLanguageTag(): String = listOfNotNull(language, region).joinToString("-") + fun toLanguageTag(): String = listOfNotNull(language, script, region).joinToString("-") override fun equals(other: Any?): Boolean = - other is Locale && other.language == language && other.region == region + other is Locale && other.language == language && other.script == script && other.region == region - override fun hashCode(): Int = 31 * (language?.hashCode() ?: 0) + (region?.hashCode() ?: 0) + override fun hashCode(): Int = + 31 * (31 * (language?.hashCode() ?: 0) + (script?.hashCode() ?: 0)) + (region?.hashCode() ?: 0) override fun toString(): String = toLanguageTag() companion object { - val root = Locale(null, null) + val root = Locale(null, null, null) - fun forLanguage(language: String?, region: String? = null): Locale = + fun forLanguage(language: String?, region: String? = null, script: String? = null): Locale = Locale( language = language?.lowercase()?.ifBlank { null }, + script = script?.normalizeScript(), region = region?.uppercase()?.ifBlank { null }, ) - // Subset BCP-47: split on '-'/'_', take the language; the first 2-alpha or - // 3-digit subtag is the region. Script (4-alpha) and variants are ignored. - // Scanning stops at a single-char subtag — the extension/private-use + // Subset BCP-47: split on '-'/'_', take the language; the first 4-alpha subtag + // is the script, the first 2-alpha or 3-digit subtag is the region. Variants + // are ignored. Scanning stops at a single-char subtag — the extension/private-use // singleton ('u', 't', 'x') — so e.g. "de-u-co-phonebk" yields no region. fun forLanguageTag(tag: String): Locale { val parts = tag.split('-', '_').filter { it.isNotBlank() } if (parts.isEmpty()) return root - val region = parts.drop(1) - .takeWhile { it.length > 1 } + val subtags = parts.drop(1).takeWhile { it.length > 1 } + val script = subtags + .firstOrNull { it.matches(SCRIPT_ALPHA) } + ?.normalizeScript() + val region = subtags .firstOrNull { it.matches(REGION_ALPHA) || it.matches(REGION_NUMERIC) } ?.uppercase() - return Locale(parts[0].lowercase(), region) + return Locale(parts[0].lowercase(), script, region) } + private fun String.normalizeScript(): String? = + lowercase().replaceFirstChar { it.uppercase() }.ifBlank { null } + + private val SCRIPT_ALPHA = Regex("[A-Za-z]{4}") private val REGION_ALPHA = Regex("[A-Za-z]{2}") private val REGION_NUMERIC = Regex("[0-9]{3}") } diff --git a/common/src/commonMain/resources/raw/alice_in_wonderland_zip b/common/src/commonMain/resources/raw/alice_in_wonderland_zip index 7d12b879a172e76b73d6755b2b68444bc493db2a..2652d6d067b373d860f668595a70f1c54514cf03 100644 GIT binary patch delta 677025 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

%E;YoQH+m01# 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
kg#)i(pV`mPv(qRgH2+W~a5Ds?geYf)lz+ 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%0D6@ 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

5$= z1j_n*1R}W&Rwq*momk}3<_hx;5(xG6Rvh^nap~hBe@CA;+z;ctq7U5#V~0d;74N_# z*Id(OBPAC`RA%4#n3pfJs6A-A$H*&lSD`TMfIluI^s(zZSv|H6bVTj|l10TLwQ#u! zuca~6F(2m-3G!mbHj6)g>$q`#@6>`re~q>^PW1iFr`)+`uuBll#zkt`l*1yvC)N7w zu=g#5e<+{aaJMkx#;7Ti#CS4FPBL3HAT|Dc$)uOQC2~L;O!7*uM~fm{Nh@)eARO~N z0v7I@F9@kZjA`ATqs^cl!GOk{Tk~ArHO7F>K4m|W>gDHd23af7?mBs;cB$1G)VKgm zn6vcbxkmre-}Vl`%gyuADZeebO+!dcOUj*Re{JJV!u5JA(V3Y&-u{bB52n#VBRKy| zFk7T3S!&jw-PETV@Y}gt7XHm7ST!`;G{*Sbui)Wv1L{~^k@)k_$T^48y$Y{YLbw46 zb2e^UVBHsrbG!s8Dm0X0*^2z7xf;VLa1 zK2Kiz<-WQxcdVT4F2QU6@^f$0v;WdK&jUv$WiCMs7K!uykrFL)njW|ndS#7cEg8f1 z<_Md|oln~nX7>p=83vgdrK;b0QdFZ+e-X|kjgu*2TC8O&No^1*j6h_5%jZ{yG8qz! z)9ezRr@K3AkLpcCLAT5IB`aP!7!$Ne6BO`Z#OkgNJ>m)w4qw*yeWGe*CowG@2g@JGR0?R#?-32fOma4fNhUYWneFaQ37o_~W2TD{4~VZK(14e;Dp8 z3n$QP%eJZDOirI^AakGwFEk-CdS`E~+XV8K=dJ6v)JbHH$|&*cI(2hE0M%c~_~V0E zAC^l{k~W0=lC+f?ygSFayZ|`f&$+s9|2b30Wr=-4zYVwI5 zaSL||N*!EyI(N2`)Matv2337jfBW%{_)Gm(i?d!heG0DGS*X9R(h&eVP2+xO8l0>J zN8qzHJK{1ydSAZJVc9LejSp}-=7V^3S!5}E7D*gEWN`=f*#?zN_8SGI&DswHbNjA& zZBQoL`u(Kc6mCj{23-AzjnYD$xLlN$Ot-+yTu8xNamSaSYglupUqCK?f3v#US5c4Q zw`Pr{0&mGMHss(U?0n3~RMv zBaNs`_$Eq~{eFJTMVe?-x7w}0gLyoC%%Wu3hFo6Q;mxFJ9O&g=e+sASTgzdyG!dPL zu7VRB=IW~bzo3Z+i7-D+e~rK@)ej9jPx_Y#t^av3jPw5+Y?Dgo9xW$jG*j(Rlu62H zO#u1$FnSu<2*>z#Kc)nG_igvF3~?RMlHk`mkt zlkAT5XiFf|iva)=@&78vQ1i8KHbpfEEI5f7iA$wE4((q~$!iCfr4$ z-t|8VK7}pQpL7a>UWwaYK?O zi(h4CzTdRKf9=WnKBm;5&1Z7#z1v16HufYIH9QSYwAH2qf?zN|qTTkgY-9M?VvXI; zp^%-RJ3edQ3Ev;Tsa}0g?G(~b4j2yz#t$W~DqQ(JdV&>N+G#6ThWNZ-RwW(epDd1> zg@A82(Psb0+yCi(3aA^0!j|15PGV~0c#rzpiq&Que=VI|A6 z-Eu-`-594QMEe3pjeg4tCMHKpdN+``Fa<7*fSY#IDV>R?+(&0Sr)rM3JQeU~+4>`~ zf(KbQ9_=&U)#7cag$E56D#TL2WE~=G^Br*>a~AhHzez3iIGhD+L*mXI{Un~Vx~JC` zRQ}Aqf3+t6+2Q&t5!oqCACmfEIE#`lspzyXllHeUYXj~V840cGW96{Zg9WrFju|c- zNq@6c;(f6O5?U7MzNpd`+qT(NryuSV_BV|$O+2?gcTnX$d^kD@VIK)&er}sYd!*{2 z7u=^ZIZWbPBdfPIlo+g`SP@nQjz2bAx>d2We=UTHw3kY{X!pJxojVCgIi|b+4*3Gs zbnrc?T~sB%Kc*7bdMQ+?jveuP-``gHoKkMgz$#@9ho+WkTNFCIZ#$MJ20Sm1dGxml zZfgjr!nDN@j>O?q%`g(!nz!w>OZtgB@gmMv^dODlan5F-$gSl^3@Jt{_$MW7!a(x5 ze>B43BF{5+u56uoB86q4a}%VbIm|7?6%K- zUCyBg^xSq-0AlnU{rvcwr(5jr`MF4;AAebH4=E(0I5nv%T>Cr%xv`0=&=f6Ezor<0~Fj<_=wyut)uUU=25gVP5-+?d(- zw%xSxGmQhooW`n~-dF4#d6Tn1G&Xk6=? z3oUHf{1EWD{M5Snd+QGfM}k{!t1^|P`^7AXb@muF(C|ycp`8?WW*aA0_xFH%e@WA5 z8aFb>P(l?n()=X7hdK_>75~5 zALIB}YhfW~290QIz2Uhi$Y+vgG#InEBzS~jpJ)jC^y0XxLh*}{sccTAvFOE|&_jV( ze=605M$+FiaKhh}y$k;*g5BNSe}iuV%MB_xh$&tn`t>CUf3uRri{>P)&qXqH78$54e^9`}aMo1J1nB1bmr7A4lh^Cv zp}0#>bS=Uov=ojSEKI2I8?C8a2Dr=xQP>JEEZ10j&QgJ>dw?T^`SoiE>Z-P|1>PB~ zN6Z{JV1j?~b~hWGQ_*lFP!q-vKSktKw7YS}o#T4xg@zu2>d)~ye|2~eC`&|6+Uej6;i|jF&(_s+^?a%Fipk;HckmXoD zfi!cK0#tL`sOoCle|UKsJG5eoatmJeWa?D|>k^6){&CUx(K}2>Xp)>x%l^QNz3Ywf zWd>dAm!Rr5QjmIMU9Cxri}#T%-jP&aKLZ2H>VP?kmjVZ$``i6Efy13TWP7~gVa9LV zq*rcv8e68HGUk=A>YP|!G^%9&Y8&h*h%R}jPvVPj=rZ z1emW{>OFj4co7AVK0=FSiRsP7d_=ZbiX_&_7!A`In2({!*U2mOcEU(2jw~I)sAE{6 zhqfnmfH>{|3{Gaf=Ot=26|_U~>V z^N~5%PPV2rfAvKSFu2S_YOO-cw_DD0 z0QH1}(k|%_wx{a?;$pg1MZ57JS{_nn3g#OX$Jcda_%dj?B*bmI$)Y>M?5PG$) zBwyy8_phu@#}Hf5?Cg|G2V!DxgsZk(>cH!^RD_P_`eZN;8JCUy^W*o6#SM+hoPSh# zts2<{)jd?0~`Zhf9&iuq#p%;Qda%(G~F&G!eMRChJH zc5%allWYy~#~PO)VK@@kmW1#L)c9SQsu}TWdVIg+c6?J)n~$^I)0*)|8fu!%wsCM< zfBchJV&b5~`%@IFrpB5-yW`Rb(1=)BP78Mt&P={7vJ}IB%=+PI6m$YR!CP!JT-A?~SpO$5EXVA83TE0c1 zBiviiqcv$#eq}X{ohgFqnJyo>t&Ny6CVVLEE|n#o?y0x9%#NV(-g`f2L1C zeUqlRo~ye$*AQk}8L@8V3p>1pHWs`^eoy8JS78kD;>;^u|CM&7=jLO#T4mx7-4&ao z`G?BsUIbkIuJjK)+KG(qVMSQ{zmYL2z|}-pIywEWRfzbAoltBsh=!zZFyk!qH8ZrR zN*H^XDO^A$ek%!OO#B#K4Np1be|Ok{M#wLG%oDX%L;u_^rnfes&ULkbH}#4L7S1Uo zx(IZUR3_sQJk?ASk;tUNiME72#Aa{dGM1$x-v=&Kegx4ip{34EC7BJ22m)%W>8|}q z8ih#6FMDTSc4WU9UQ2(60k+@BRJl;FH};-4RKvv}ru^ygwT(+q{~}lre<9vV;)_9Z z!(+{e`xy?q_N3vYEx_*``b!3~7_l#wIn(c%czsfL4c9n#H& zY3rXK!x*zS8S2fH9 z-_sfe0y6>=?w)*7x5(7-s?mBqHoU@378mz^{l<(nceR1ZyD&}g^_zEr9r^f8yBtDZ zKrK%|XvB219o99YR@3=hciSN{msGmBhMSKeELu&qeCwiPaLo4898T!RE{@$Es>v%1 z7J71hVbzOmSchG?e<7)dQyvjP(s&5GBU#{@Qv>N^VaGz>>I5m?{Xzx)a=J0>Jin8~ zMcF@OGJ8|+q&g#eQ7wM)b#S$l*B9?KWvc$1`8YlyA(Q3m{+>f`Iy0Ew^ZpoS!fbct zWnj*2kkYQ}XRkOkP2?n@0%3bm*5zCVgwvV+EmHj__xIm>e>(pg%UIn5G1RWml@-B5biGHqhW5;H>q*$M`I`1Qb;8@?to2{e)^DjNR(X&k z7oD|o5WeUReeQ+5h?=1~ zR!c-n`?^rie`#_McXfdtk^xR3;aDQ|A#oRIz_q@C9W0bTdXk)Hty}A^?_hG2W2R~z z0fwH_POe$-ZabU~S)__UfL7&qKap-_OM1MhkHb<-2~hYKHklth{1A?j$?un-Mg@#3 z6+(%a-Y}$t)v*2Y87K&@61w0$8WLO+`c>OsI;fC!f6%TN8gz71l>3g0%yf%CAamOP z-*F~~j{{pG!UAvB3EHmID*6)N4mr<M#X5w#`8?wdRXlO4_oRmf!BGad44qre z)Hb)%cJT?e{QUaWorb_B25O~Z)QS?WDKAX{e{Doc!Os*Dw5eyw=JI);pfUY&+aLeT zWd?=5+=wX;94g5%yCtA0H39*dBU;X7cfp*I8k8v!NuX)XmEM`GHr=cx)+O$NTY4zq zV*j>Y`=>;|$xe!5tMc;{16){dLsRDcBevKM@IZv*0E%N}SbfL$%y0-j1@}~GO%eC6 zfBY{yCMQvnbsVKLabC9s22qdbtQNo4UqYymP#Jz6N>CRiQbuF7YpcmU@BLz$do=H* z>_(3NHU6@lwQqQ@%|-csrqI`uDIzud3YFq8-ni=@RBKK=!yj^auwg4libRJtM7*EY zf=2$C-VxoTj9h}63I-HnFF~AWNg#8Of1=VguMCSM7lBPWX604rTcbVPuH~4@)Bopo zI1Rc+3;KTqU}z}zwEN*rZ_xi)c%^|KzVj~Nnd?9N2rpC$qVuPAPUwpHD~3lW1-}$n zzK+6Zmp0o3q=&kN+#S9j%uRK8G1PKypFdYCP8`OB@bR50`gF9Nw2Ve%{Cck^f1C9W zw#h>eapx81=NTrmU4mksk~N56jI)6esmg*M-e8Fi-$q#YWe$+Vu>7z`cXyq+B=Wf6KlET@`8P3oeH|D``nWd^UQW)zv7eWum?9s)TqB zG?0#Pl|#@lW!Ho`${oP;Zi{_=b8P41W+A)Z*&JE^;-IR<*xv6JAYrytZ4I*FPjWD_ z+#5`VFvGjD`AqZs1^+rW6H-(d=?tz_GGX40P1^{gi9i?+tq4Ri0%La5f2+VxqRhZ0 zh^=74RJP@S10Ft_4tY8@vGh*Ku;@RrBpQ$fwODWiAjMmaTM=G2joygV%uw2#kUmiV;y=R^ZwDR?Uw&a zbM9o6`3jg6bKSK4Ch_K`0R1NEK!h@jV&UuSNX*c_0;R|SkJrp5fAr1ZWQ|J@!+MVQ z*?uXHnI42O$FXiK(MkPwn@rcW7-eOhWY=cCbi_R*PJ9+&MQkwWY)hbAkiobD!xQL*2-2B!5{mY>?x4#>nk?BvaooeuJQ z)Yg^g{_Fco51Z(;e;h^ETVzW>jTnpZ8n}sb1*<#z6cob_V!Avkg;+eqKt%H) z)yY}wX)gn35k`&3g_G}&2T8~;Y?uQcz`kom$q@*CcCE)ff9(oYp}b*ezsl=(qz^`WWBOr0|l3 zh!%ZXSDg9!M1&fN%Wq{T0%dsvvmjYfOpTD70MI&-jusT@M)m_N@N99#^cScX&!;ps zRx^d+6*siy3(q_ne;k{_tZpO>TgHQ0+(bc~V`H(Ce-br)$d)cX95Q)Xqj`=#smE)v zD5PIXPVTQU<<}3hPH?k8p6R*y`=s6y^PR5}K`oCi($9nbsFRT`wuxk$wS)L#QJ{k8 zFl@~05Sd$QGL5+?yD9CSFCnw>)$`GoSj@+(I#U0X*3|YE8vq?lk;x#57945Fa1Hw=goq#C{4_V+YBo;k9_Ii@D1txUIuv zk~54;Ek_^<+as+gVN4%GwSsU;T{Y??o%fwx#GhFZ=j{VD^}q?WAE!_cv&#|x5h`pM zf83HwJUI%C+;jbw`i$t)!~nHbWRk-jB44IvutLnS&qrsYCoQ}MPXdw|2bIHJ`Gky) zTJmp)T!L8lHP{kd1-ev-YW=O3Ao@rZ1f;-jy7}T25VI*%RngXy5GH!)awEgos-=u0 zYe%=29(wK8rnVS7arH(G?tmR0+M6FKe>zof^|RUB%ciw$B{JuMaGigf3F5=m^8&>@ z*|^#i-4W1!Bt2!WaJaAA#+Z1TxPPEL;ZNZ-^dea#t7s(sy|6XE&Am|NV73|N%f&3h zDR6tqLgiE;6%7W&PqK*UYG!0h`PoqVjgQzSp90&$7bmx{{S@Cmxz0F+YkpJ;e`46t z5}q}cBZU+Nv@}APIN-{L^me84MskxG&m4wJfzkaiP$Q?2=6{6aS1}f{F#3H|NKbg##h)m7ykmI$ z%fD2I4Fd|)OAb3&N?pJ-Jhitle`0C27}emTYU)Na0ik$^2rY@P zUm;;WKX98v&#T0l@a0$SNnza7m(=jALyn}ftfuR{OPvkhPM1YzYE`=Zf5I2}OJaiD90@VARtO-$Co6~DgiUNtoxWp(;W1P^|Pv!X>vzo=4?GK;x zmmUAPb3p>dWW;aX>RQ)G!pjIINYwm6(E-UVlYCJ1VGJ@JyPu1rW-iqcfkXQl|B+g*x^z}iqtu3)K-f&zslKWj>;=omy z?D!3}DLTc)k|+6=Uz{`u^5)87|9J6Q)t_-&&|lDB9d3l!Ot4!R9vrtq-9k|6RgWxse_FVdm`;G%KAIV@ z_kpy^7r*c{`TZ6WTb!X7MJuP%t9^A|TktnfU_(5>tgt`}kFyJF#1I0f12Ow zDpUI9j$AB6Z3DZDjy8&-Zq;kxng!L;HJv&(3gQkl{R0EtPdmB^go^z#7!Gvf4ebKt zTN{^)35X>Qp1omh2>JS4GX7 z)$AR<@i1%x(RL1|4By%TGyK)Ez~9Rm#$N;tgizGyl&j^1C-6o$crBd`#URqnAN6m& z9Q}|AYv@X(<_0yt;XQ3gJGcaSH)QBA9;3LId|iZy^FydB+o4MTux-)740XrC@^`TM zq~F-iRu`?be@YrRe@L;^71s<0B!Qkd(dDy-qDR1pLuEKE*`?i=fd9amx?)SKN$P)^ zfL;jIn|QF;HIL-)NM*Dd^b#a;3G!V@;^WhnZ)xjv=E5{W`9f6Lc)Bw(E5|q=*mrF$ zGVfPB6zCedW0f842yUJyh!L6a^8-byYyC);mP{6Jf7|)GaD0)Q`B&b;j89(>hx(rn zyt%_rilH=HS$sY3$ew_01wspuT+QUHTdsVDw-8xg@g4cFw0t$~wRF4V%^Usl+X7@v z*TsGbt2cEt307BM{j78c>xEfdVf({nZ|dV8lIt|ykv?OUsg6|FwMuCl|2f3-=-Rd5 z`b*F%e_QW4ENCFh0MEt@6;bY(X%4FP##+Q3zoYT~yn`U<+se!0m~s%a^mo2p>892r z@kMJ3p;!SMC(^hpr^=t}&gxU<`kOmoM)A3FDt+UV>++!S*MvIgi8sL{UNfp9^?`o=V&1pSF1CSXHWD(K z%u=UXT@7Es^YvTM{5JKd*e!gpb$)k_PqDi%CCuGUzsBg3xTR{A>9yPg6o?`KXIncM ze@3x6{3eS5(Lt+B&Rpki4+Q5bRYrYfKSXtOhe;gvD-CW8U!yWO7`sx&v$@g?j&5#9 z2Q$`3iUVdAM74xqZ@(1+g_`J5)z&!;Gahxd&at;)PEetG9(B`GQj#W$O)>M{541R7 z(fg#WSctThq#0bJ>FIdRUvf*Y47(pUfA|b4@b3-nrahv?IxXy9awE|Zw-VFh$^vu_ zU>gRY2XTTCdpUOY7a!SL{&gU7r1IGwW&0_TgmKv4;!pW(o*};d&BP zDGKQJj$(me6ua+yw$B`giRf?QFtfa7|Dfokmy|9nDjRoCwR82$$=FvhuK4@~= z%kZmv@t2J)qH^r(Os$+d^6yyHNse5euF3Svm_2@X(aFqhwDdHLEFs)I+r2TIBpWOf zp00|kWb@(q%&9UU$8_cP6u%R@f8t@g=qCCLX|Say`R|3JDRHacluY~ll{YTao&;ei z)BC~Fe-qvLvO%-ycbG2^%5Pv)H-o=Bu%~&rx7z?i0TG8&6#hm?k6F&)ob|P=Jabko zXVZ?jV}5UcUGN)YtnGYm{weK~^SdfqM`yD^6vxw9itM(w#E)0q&%d8Qe}Ao^o%P*H zjXr-HqY332ILR(xic1YS)ThDTDsw#XBwm7&(aONu5Jh{s>sejp5SVq4wFZ~eP;7ob z{Hv&`h~2)s2ShYM08K!$zmY#o^@9)3UC7OUNR!!mWucYpJLdWfgbkng?yhAs<5wyD zYGTo#8aGWZ0Z`gz_sWSI@YA1oFn@$(*R-kynyjY>zyEhM39%XDt*dWuPak{}RC6nC z{M^Al$SHsE4tKL_h6vTqE@+8^539C}w;WE+L1X(8^pkL$gv-Lv?0m>j z?Z&>6h>8l}=O9iJZk|in!v$CRk#w&j!VTsTp)lspzd5@7>{E9!xTyie|YYdpn3o78i$Ey8B<^Nu<9OIHYw*`uc!4- z!fqkxpUxURW|EIGj%3^88-Eb=NQ%zOH9Je2W?gCgv?#EZCid#-7N{7cVbbvE zqNvMj?f*t^;y^=`D_u(sSoqfh#se=ba_CdLQ(KcQ? z)L{-i(IY)U;lY5*CFo21#ZY|Rpw=L|a<#~M_C@^*`8TkyZ5;O5D-q4GpcYly?fk8j)2#O^9*yRgRtl`ah)Exe{uIon`-P7?~q4Az{Xb&Kn z#Cl5MJh-;q!23seehl*_R+D$vz4#kc{_8!7hUt z$l^ZA-On`=bBvBQpm+2Ylf^yy->~#S^5mROvRC~RblBzmUu9BJCPmbN4{O4z>yEy~ zVsk2Qt$R&kPY0K$o^i%YkEUF^Qi7Nma$J=QItc_EtDUybyavH(J&cG)lKuo?{h@Kk zMJboa?|)A-Od--j{PIq#ECXB0RDb1jiGK4qwZS_r2OwXxHZe2qcsKvtACvrw@UnMA zEc$V=Be2%4CzXBbo@ZVTT7z<}3x*C(F8JeYlUvH0en#GeKE(cdXGe?}Xmu!l8$xVO z`Dk;?P5Ie>(FMOv1tfWl*Fp)xxJ)7MAOP8a34dyy9LVJMJ$obL&jnTevHeIEAGrFlkay5U3j8gLd^nZe@gBvc$l8qU;r#+?6yjqQ(MWpYWO(VMD?4;Q#w zpnqjgrOtei6exRDL;xrXB!>50g4iis@O;dJCizn<^FX(fU%mxF%O!_v31-z!NvlT8 z{U&BUKaj=kb0u z4NFVE{@^Du9ktd>(NBNm!wH+oXdlt158R-!yn3MM)^69e`MyQe!u+s^)O4gQ8-E|X z%yOpr*JA%!$^)^s9$otUc?Frt)tv*vlGnWd*1v4&YJbKFcGOSutiFD*byaWw8vkhXqODEmDf`kuFgnwI_Z!O%8 z7Y$QAG=y-hb2TR=xqPCd1%)FCigaxx`hFx^mrHPGHa=Z{-S~eTocBM~kK@I!?7jE6 zWF;%RjC-l9WJCyYm5^P88`rh>rjVVHy|N0q#hfsb}M+TGlGbrL6 zSJn-QmmsvVh$9dq%qQXO&wVfH=$QOzdxn@2_x9;GA;KTxw}$I)-0uTh5&*Ex#sK||D5`hNv}YRC0Up)%%= z&^?!+Bm(KMPS^U5qzoUq2}793@_<_-JS>2XZ2Z6f>X(Nm zIo_Obe;*aL0iH?jDStbix4*SS+p-6!LoAG2*EjCeku8WjNk|#^*u%L?P@A18LKgQ3 zBG9OnzxX{kvLvmc z2<_#S`^Uq+J`JBMk8P&X1>Zin@s4T6M4jrmWeB?7f;Zkr;eX8{+2G-$d6ytzil}3E zLAGAgd=GlnDp~4la?`3G4gw#wP%B=+P!QvR3>gVA|;e1IU(J9r8D7%(EI>vpb-xVKCW#n*n%*>U!Xkl`-Z=*t+v zlBE z`#&`u%D6!bLRR~f5zUW8$-z3Gb4W%d@vH0H~<5#8KVQD zNPx$rIVq8>FwL|p?X<-TWQ*xEK@4$~3BQts8K@_9R>cz3Z_fSG-SS=&2Zne!(L z;(0o{``<9Q6SvYTQWzbLXd}O_w%G^Y0*wxie#5@h2gkm^Oul3}E~k~Sn*B``W%6JQ zY4Xcz<$q`7LHZ?#rh19O^D7^s0NChVf>JveQuThMtJ0&|(&YSh?Zv`aM55l0(X&o~ zOC^x5Rq?Yy3w!#EHqK6Z#hXsrw=hiw#W%lGr)7|T{|uCq>0S$xdSKEm@wk3MKnGTQ zMU`ss4D@^c3uFwZ)Ij|0LUcb%>k!u2Gk5)hMt_1otw?jpO!~N<^4+# zA>DHDDP<3zmxyhNKlk5R2v+oQ34f%)kU zvVU?3^7z7x56$@Al^3Bij0BTdzs!_Xt&b08oL{RfF#4{_m$U02f(@+au~#PDi%hyf zz8yQ{cnR8{z@A40pxJ$bJ#l{QCJDE$wx`%;#rfRXsT{YM@W>`w@iKjq0=-u1eE(An z(zO&k%S(PX*O3l3&-tOpY%u?-_no72ihrQ>GWxo7qtbMT`c!<9heU>X01$hc(n;Eh z+E;H$`8SqEw#74BE2;Z&oAVgqM65a46r+FMpLM zZH>Sfe!9r7_`YYXh6mvmHY0}=1z4<^1qBgBUJrZe#mvf1lcj5^Q-Rk+!WfwMNY|{= zr`vHWQ?lz*qE+q)l9{-=d?MT%oi-xY^5Ut=bve7C&qg#+Y(ci2emWyPP6$JyUd>Q% zW{Oa9sjv(wcwVWu$&2q}+FzPM#eb1LwYSM#Z*?jpJ_f>ETtHxszN`rvJ&Gfof%W|3KrATFBYbD@4(*XyPRH6_lqhT*}m)?>!-$ z_fsKddobj!Z`M5~$#`Ld`+s!oTZG442m6fG|!7J_i+qJqFmpzSA2#L)+pJi!sxzCcO5s$~-2GDJ2O&pNVAX>Wc6>=~Z(imcWQDiG9EGe=VsUK$eEE0kc?W;->ukPRS8Mr^y(S8szn=Oe>GithcOZ zpuXTZjxz?01%>99b$@N`zsH6Pr<)C;+V{5LLvXw2+bd13Z~YG6&deWAN;NXz&aRTz zK&6C1l*epZB%dyQlaCEqrDyJ_Uji6O-|G-Ugkp+D(`FSM0ygy1KYzbwMA&Y*uLMs3 zKG!22kX90OElH93JtRu1cY4j;EM3QI{eDxn(tRdIFQ#)Wr+;|$zm*I9bHXXd>qZcF z##XeECFkM}z{-I3aLK(f^Ob$Y|4lu)ZxS7jQ&im<8D$8Wfo7mE_Wj>af^c9ACqfx0otWNxB{&>_d@_*$iIr z9Wa+SPrEX%xOi>Cm7s7Xc3^N6PxXhk;r{7Y4YU`}wtqp+&2K#oZdtN@zjmjlAmt!wd0umrFy>VI^g6lLG2qOA`RP9*=8}CCY3&Q zqP?6jA>=Hy^=Q}HvmKgjgSOP?oV?^yH722&V$Zhze$RnDIdg}NonfxdBTY6o_Io@162SRgzzO@sX~gAfpA##;S>o*H z;<~4F`cw}Cr>1Vli{uQ1ma}D~kictWr!{k^=avcoR#FJ9xx>dlSGq%bGSYR2@PEfs zx2I#(pZ8{$@U-3+GXQm|Iol7QGR3$qDiC}WIimZ>#&S-6po7|`!-Oxzn^r;dlc2zp z@$nr|10JfMZyp$eBw6YNN8t9ED7Q-x6MS++s4V5r5eG0fB&M<`?clkh4(8l2I%?$< z5_lGx_%!zT!vOt`%Yh*X5kQi(B!2}AqL{y3f`;}MUZFBkOnqpxjWB1vYbOyx+@J3+ ztY3dq%ELO9&6{mx!DcFEMlw4Uo$uprc0vSSls0WSF1UA@#o{d4chxZJb3N%R+I~}9 zSEQ&}T0gy#4gGH`u)l_6yzhr&=K zLRFnF_8(Sq5e_N;h7*+0-KE;mD z8S1c^2Cb9=$;6e@fr}!D0(=KAlZiX`@7(5qEH9=~)0_D5PIHds(hL(})Bq3i}$Lu}a6lJQyn;CD0ug zNTt9uOj165bx}zc3;-whO9Bm}P=1BO#_IXzMu@OaJ|BNrV8uZKR%RfdYUM?i`s+#e z)Igsd%apB7x_3AzMt{v>g|dY!OLq}ImNfUKY?(yp0x1evF{gq&qunnKg#E9kiEz$J zu~$QckxN}$0uW|+{TOZ~i6V>8TJG2zY%mTA_T7|gU&y(+DqVPwV@fi}QACMpAD_#UbcT`AIS+F%vyOTH?U?mYb^W!s9)FnA>-A@;5q~o{E=XAK zxCAAEnUO*c!JFmYx>@d?v9L9`%vJDDcZYQ7GH(WgkXr~gi(jWS4MfGp& zoEmQg3^2z+x~RoeFCTet0bE9LftDrHANeG&>tQQkIri%F3*TktwCW&m?!7i;si znat_B+<&DFeWS{$xE=D188Zt z4W2bV8JL?Hr#qHi+0MLDgN!Q2S+8SRLRA!oCHjLdsxLv<+(^zo>P8nBLaY4}6t^;z zHQ`A8;&`UzGpmX3>Qy%^ z-)tMXSuqY3doCj-5f-GQgX(f`mn#5$o(}PBDe*V+K{KQDl60yDix<9fk{|5TEMewGNU8_674USiG_Epzt zls}MtNn@EEWxPNrc4Z))-eRFMNx?6e^^OB7+NkKgmyt(;hRHuu$$~1Ts26R;fq~^> z66bf3Q`*uiak^U7WEk)pZyt%}bTp+)$A6W`o3tJKe^}dQ|LFDd&O0VX5NmO*;AYe_ zq%Ya^A|ELO@Z)JtVVplQ@AUU9fbC)*k}T?kQdcW%#jK~lEStp(+fUee(6~NxEn9`9 zV!ERu-JzW9oIguBK9ROYk3^=WGu{R46s21}$o2C3U@CA?x^$&BK!MhZ$1QEyzki5$ zXAmqv4tB3bg!lxAA)JJCeCUl03%m+xi$4l1UwcYFNgw35rL^<5Vr#$_L`}-ahK@E_ z;>>?Exc>XlQ_8e*^&Il0>UsXqjJXj=lR941SAY3niksrKwx)Rx#3QCZLFE5vjg++o#6WS+;j-Aw z1YI7C{jg=5R*EjHQ9<}nLpH%i^lify z0FfhQwAVTQTG@J<3>kJ~+l#nW5w97QD!>J@c+`b|%|k^R+eZ;CN47f8QhzlfS%7*! z=n#|!L0eyBN*2cP`h?4$qdlgOo?8Vk%%&yVOI9EIkDs^WclPnRexAe?+-dp;FKy!? zJvD27iugSCfxzdKwL2J$f<#D%rKZqrig@;g2SWKAb{%>OBa6j#W6#pIb#0weV z!)Wj4Ku52FXV~_f7p4OPvwyt5)Tw@-?c<1(=)6b{h%Ca8w3PY?Ugoq^c5k8SZDMvn zg`t8Q{Vv#oNtCmpIlER-_eE9k&MJ@+B2Fz<->hUqAsN6~J0a0^@78c+D zADIZ{{!=DMzP6~KehC7@dsfUSa}e5`?~WTAI)7ltV0#z^FcKd0{&JOk%~~SGE7Q9s%B4j zq61kAUvR&5!;r+p&I(CyaM@4vO0Z3D9rG%3AUPgN%6d*!Nkx76yR1!C}j1Qv}M}_b@mUgiwq< z2oYn70bf1bvB_Q|ED$$Oumxs`W9XH+^$)2yOJ3l!(+K2h zp?d;z@84+6uE2X1h8-Lg*#b0d4n4K?j%O*2C)2|J6E8^zU?buo$rfnu zL=92Pr7W<_-4U4imJqTj@bO{^i*O*xqs@qCXe2$+KDEhs*qKi_+^4AI>Y#e?is+2l zH_5h7pR?b=Sxl%HF{utN^rV)wegSysm>iMd?1N&2=YPx`&f0wSxcj+BD6q{SDA=Dj zhYo}_&9R@tW*IckpO2v!$<7UM{-G@<2*>K}C(Ly|s_>z|+n*Nw@hNuaSO04%YyW;R zKGgU9s-FNTD&pn21`^|qsZGX)jIWu*m^Y<3|C^0m&$9p%-a(KfG4dpv&GWGAgcG5!QZS=>*JnwT_%q8f_ zwH&TmJ}$JMEWK1PMHHBD_raU{jF#~uyzrty1oP8kj72(@oo-ON?DQpDg*^SI_)y2* zkJ(Z;vIZUE;yz!~SSAn>2QuScc=7+MUKxsT8-FcaS%0O>3?SuB{QM8d+g?_qhnFDF zjucHgo%mA0DU5v3ZQOA7!X&I56Kc(C@;Jct(9l}=$o^tm)nQu|DGL5aLJb3 z3<*$UGHzZ z&3{-Oj(O>u*ub6v=h$a1QhChmT)Vl5D}b8U_SP1h?i}{UTc}u3Cty*Gf&5eP!|Qi^ z^6oUf^0lK=D~a%;)2BcrZY#AAMV2ZRCRq+!#!w_W&C;nyg| zBT`SJ-`JfuIy>mA^qCUZ2QiUjW}?b=q<>8mNzH1}?aB3u|b1U63cqb)*?B)qS3tLIj@c9#Kx!_kG%(vV+ef@8@ zmhPSI|DJS5y)dzfC&B?Q=avE|yf?%0sGof{wmCC~D=XO8->3=Iv%D_ z|L2njp7JiruW?Y}=dF+7KKA_-bbt5KpYuqbh|~utlZyWIa<8WEGY22+GK{V_-r-su z-Df&AL8!R3z-9h9Nwt6NGaxbT&ffou;I4pu`q_RZ>Og zv&7-1*EmZixa9CL*@0M+QH3e()~u_Qve`Z33t{S}Q`P+Pp+v+lKJJf<825$InmWK}aJJQL_K;2ZtH%o42N$A4F;ylZLSIzM5jgTi*%2aKq&%cr}f0vxmTW_7s#T)%1Hqy zKMx3r50({_)1Dr(rc}t$P*s5{(o-%$!`&KxU`Q!+;J?A0m3ju zYwhLh*B>k;&UIUYul%*=ygO3v5G{rLep6s1SPQeVKaD+~OoFJuWpQ@tAH3~IUlJ0D z6sIrt_U!?0C;EDlb8dc;kJk_Nm%M3Hx?l|m^-}{{;p_te)PIP(=dd)t9}5}*wD4YI zvm)UWlPE8xn!LN`5I&pjX_k>^?tgis#{W*@B1dPFz^#8?kRd=5QQnxuiprIqDG_V> zFrbtT2@NrOlVPCyq=0Wi8YC%M4mvXgRL-@@Prqs?8skv=6D0PLwk@el&t{Cb%;>z^ z8--trBRB>l3V(0Kt?T-auNYH#{%`ysJywHfjugiAB*q|a@NQWWXEI+D_IGd)8|3R! zuJYiDeAHYysKmJsq4bpNj}d;09Suj+iM%lA-~k2czY4i zRx6y6mi_76qWfG4*u(SX*Bg==2&L8aa8thm(`vwFMt_+d^D*9U{_(ir#v*k|Zw#V5 zOJ6(+X}1ewLx8K-9{t>8Ts~2RS(-n=Eg@|15p;)>Can+GQ4u30i{oG0acq(^0wn;( zrwIrk&ag-K%PjcmLcNGir}3oG&&bwaVH$5QPHPly(iSkBj`${N<|(Rqir$=#;KU#C zZIN{V)qhh|J3BfvnIb>0-sN1_RV^Ba6r4X;*3Y@?-=gUDP5(M4=eVGP-QiJ!cUSkB+|!M6giP*WBXNr6?90z zcYkv)yid^kUz(3a%5x*8)@jmut+a!8gU0E1J%^lq@GHZp*gE!+OHdMpHWIvX{SxG< zL0J_~li8TmO_f51ztt^Rx?LRjcN)Gud$3K|5<)y#CWDEXGn`UWwO3wi$G_9|rpJyL zL$zBhylZd1U1QGNkOQ^;{WM5EjI{$4Nq@roLAx#-OMQ~KX_-0at(NEme~k#kcU7x~ z+Z+3JJZ5QrDxQ=Mt83?}?&br4AG^(k| z@9m$ZL9nLRr=#ep%juV06d@f}&6R8A+#&2o1vjF7RKqr8`tQXA}JWgLyk&f5;m4Q8*IP=sv{g zujN?LJ!Y+;`_gb~i~<5ku>{-Mq43$YZrs)y;+9{~MGan0W8q;ELyVNK%rl6`UuM>k zWWD}hEgw&m*KDx=eull>iAM4uw14|aqPeOV>FuZ)WE9iuGjCx0v%!a!MQ z10X?IWlpjJ;M3^*7*$jSgx-FBFt0$)xo9GIypD;gIlEp3_AO;TeV%)oztk^@xC;bC zlAP`@`t{w4pq;5E!1f9CT9^;XLgt!VQ?D{q#7y0fXXa7Ey*yNZzu=G7P&q%HW~2HAE58JF8^^rbiVe~185?z08v^rcdFxn^2dAt`+p;Ine1ww?#>uH zI3c}?=5DG~k?s4b9M7XD##;mQiUpE!!f}tseo9}~h4dI=6deD3E;xW9|)CH2R zRudUo5W)<*h<$9HY=50h7ecwk@uya1P;mA7?qSjgjQp}2i*+(*cZVxp-`3%t)_-?? z&qpbHK{@*#;PmVt_kIoTz2J-R*!0o41hq{)Ils#t*_b>8Vk-VKAQojuV;}v*q*UO0 z`m^7R80u1xd~fp&ZKQ@)lE(Th_g+4x>%Dm1>8VGl$3uqRf`9MvRv;^FS`Y{Xa2q;gu)3Nl`8TAQU`+AXD1`3tiOj@+Tzpx##@}~JKrl#Z(0l|G?@le!ZucC!&ytv zT_O7o8t9j5#)408C%E2V6esAt@NpU}rCz3p`^Z%Q;rIKNJAY-Y`&}Qxm^RB|^Dy=7 z8`QFTwbc6otA8yNrYHEw813oiSQ7ZbBH5lu9#SOOT@9bUS$jn@xgh3i)D1V;eq%}c zRg@Qam0tg%LSsXc03DoMio61^=kHzg`x7o9HD}g|nwRKOiXS#%$di^>ihXM*>j#IX z^o#w3w$BdSQit-wBW>Fj5r*4UEBU3=r3-c?r&S-F41dEz&S2aj`od%o8k9%hgQZm8GG03oE%rZvu|}2J~U!!ujol@<}(?`EfVN&ff7$&5NArx zuOsKWTqIX?Ih)DO#I02+z7zOWa?cBZm8X+p5psqy?k15(`shl$kh4M&d;)hGNOZWT&aBF z^7@FQ%ol8LopbDzZ9vmi=K(oYqr30Kzf+E=>2qEF+ag+n*XkA<5LRujVRfy2;+LTB zLLl-%8!)#;gJjsVhteP{8v)2#9QUsYJTLK}|9^cQrd{2-E#{`Fvz1@%4k=Sm*rsOOS^!jMK$f z3xB<#h6~8}V=4E&^Ycey;M&H7TGrho5D3)B5eGO?;kWl_erpSkG6WaN6hnzXlo7Ez}wp8458^Y#i^n`__EO^GpDaD{j9(K zL5Gf5CSC?dR>D0kQf5v0`NFf!H&%1h;D2oe8~2)q4hO}_oqy<)q1X~INAN7irn3VX z0?1acYy7@1^Hsw<)UxMB9#s6=e)i#=7~UId10&wXmmr!`QfX0@!=p90@^D@cvLrCS zD=#mSv(t2$(e~^pWpj7rAT_t_r1s&VPV_Vgfg&xSFF~wGZDX7=D?mLY^rNI=w|{Vo zFC;84ANy0G$ybY3hGCgEJlzN@Z&K~}41Dc+qD})lo<0WZc@(2%iSwDILcmDiv}d+M zd(&buISsX(AMYkhC1|~PAsG5H<0Y&DDsTyU0u>`$k@(|IQX?}d(hG-u8j>a24zKoJ z>b1UQnwpgU6s4XR&t~<6?pvtd8-EntCCH1`>>@S$v?|F)bUF_l;#VDys3`X*1z8 zX^8pvS1Rg9e-x+9Odc* zqmA@t^_U&M1SQ1J`9Xa1yNS9_3S47qUR7}fjgLKXaU!LjJ^-AsVD@1=duAk9_hp@- zl#A_B$&Ze%ys!k-`!^i!h+(6$lL#~4*kV&ae*;E@wuZ{q&d2Pv?2z(HP_M02`{gsf zM6*K92;5JZS4{PI8})oTU4PN~W^{CnRR$cD4C6(zJSGcwhRg?$ok_9ioJ)}B-Wf%z zjdL0oZqoIh&+hAfcP8~!UTaP4ktE38lDKu6b&-i+C-{^IQ+PB^juNFy&4T|&?*%iz zXG5COwnuMYf(C@cV!x4`Eys@5EJt8W9kUE%Pk^y$l5mu0$Cu->?te5Jz&kziHZ2yB ztn^T0qmFiQ6k9O*Fh)xV^xFrvrbQUZ98z<`vl^o(h~9*$QhDbqyR3eS4jy!}h4aE0 z+%FY2O#>NZy?%UI$DzpFmmqv%yl;RKk{&sqO1EbsqwN$?F|t%`=&IyuA1R*2rZv{* zH%hNDfSd|}@?3&^EPs>4WKe)!^G|3yDxUAF?}?uBFn_@L5I-brErDj-?E1UKyC4CY z)?0gjE@XtV+j9fJ2De|z1#}ZV&I!vce2p6E(1-mb*3{MY@f1E<34h`xNS@9u>l>81GrVR$ z_go7Y#z9boEfMlvl8sNP2C4cCe_?t6eRh-y zCC+bM>C+)m{pp;ST|@vs+U+;nCU|Cr{oE#OIyfxY_2gHt{Xg#hdmSH2=1v`h3Xx$1 znCLE)qXR|<ZNVIkl-Q%(xHl4l~OrL+(ZT{=L!*Y`%5$$q^u)!C(c4}tMOn~lB zVD&7`KB4)LTEyVJ=OM@A_1tG?pr`aNVB0}lZLTUELg%5ULl-r=(&t(Ljgxm+SDAY~ zY+%x}a>MWyWWyh&@b&KLUO{mkrWtPJF6EYetQ-{#j(>r5c*g#iSC7mT(*mqDm?f(> z0sc|#W0&;~*{A5MP8&b(xC`Ec9KzRAKubjBD6$H1^H=~vhp?{F&i1eYh zUH$pMU$OgRO4t`tU9Z(Xt3;UxQmBAI5@gI7s3sKoMBKqScmAl#ffo*&E=ZC@`*Z8W z*A&k3RDb(Itr#EN`I}9j*1Q?WAiDYPTod7OQ9@>;i2l#zoDeAhVeqOujrAh&V$cqE z%cI)v?OoMpd=lgE*5aF6At_Q2p7KBbqNoy1!1Zm)06HzZXUqQrl3sy@4V0QHHR6iL zLRlBX(Wkq%#uF038^^95QF_d$Nq{tVLu~WzMSlfE32~Psg87jzy_t$nSdhI0#f6&n zWvO8Dh=;BNtV5bi6>4ca+56D;fZ?K+we?fuMKDe_;}1Aly99NbpM)23Z~oaC<2Utk z;GxN3wUps`fz+s^IG1@SQkg`5AdM>%F{^cgLx;-vkRskazQTVZwErq7DR^+U6myx5 zDu1~JeR*?Z(*MD+o1*a$v>P>e#J*u#3Dn$OMzZa89lGYtqA}cSccq{@S0}kXidnoK z-9Ho*nWK-U452#mhg;T;utnV&8qN@p zZfC#+TgoFnTT-_=z@tY=R`5(15b=a~gn!e`dThv7)WD~W{s?Mf*%!FN|6)|LcKegA z(7!MpR^^AEI+h4A%e%p;7eaq+ozcE($EGV@^lgLF)}*m?^?*bO$Fi<@%jSo&ieH)^ zU7Wx5TjmR~D!M_qsaf(LO4(llu4M2c`Us8dXNW154}Z66gF`K%c# ztC(=>;?B+&kRs0meOexCEsIa(On?94)BBsA1bMzH_3K3YvDD>gNnUAG)icoejZD1+ z(HsJdxcojp@KAo3;mLTe`;W3_n88aXO}f<$8T#kq9Bi*ggIqjuWoc#Hm!N0hBr)9u zit4ZaYoxJZKQlYOJ9kJk@B9Ns-UM>caH9*JXFp_%W%_UZP5|#HJeE+iuYb}6bB1?6 zT%37d>l12iuKv8IrG&dU0CJo2dnNBi6i9043n+p*;#_>MkhQM{cL^E;CvEb)^IP7; z%vvqlJZep1Gd*s8P$Wgj!bTSdYv3RXfC|YTw}Tc99ogc6h`?=5V~7?_EYwzQ#Lc%{ zu=Pp*1|gVYBQ`%f>j@$GG=Il{;1@#Szeq#UQ|84O=MjFmNWg*&uH5x7^{eb$_HV?% zZPUCd_ornRBZ?NInCWRwMqZ#eq0hE3KG>JNB>KUl42T3Np(LO)cNi)D=J8_%xq%Gm ztwK4bW;i;$m5%XBl#ZU;sp7@EOOVe|ysSMCS^0}1@UDA(umK*jx_@Tr^&;*t;m2Z> z7M-}NcH-!b0oRousFBPy7=eG^lQcXWqAg9{aai2W0(>6N$*7oy*>69yo%x>2QjT7u zhl9F&wxAx0?4yPKx%kv+VZu%Z;ub0A+>Ud1QzUoT-o9dEkvIN?7c+We#MY4@Y$ci~ z75!N6ETjJ#auXwZ6n~BI!(W1~{OZ3&+^p!vfhNc3+iI+4ZkyM-PTK$4X#Ku`eafh(Q3e zG>)8s@U9Psuqq^ly|H?sK;0 zP~{S!m|pCMB7Zs3C208K3Of}!fOv5TD3j&}tFp zniy<(Fimf7eq{@uTk_{;+mK*2Zuy${&y;TXf3Jubjgl0_HfKNRIV8%;Q~o zj3SJxX=NJ{$LlPXQX<&E7xJ5+fsy|YAfpTIxfhv#5r6f3cI`JB$wxeF5B8}YE2=$s z^_L^^`81P`^qcB}>y_g+I+?Tvdh%Nip)9YC9aLkUx2t`zb&wlyK4@5LirEO*xXNtoY|x!TD<4&b1Z#ZQ@`2Nm|#Mg-=DvK zN9=QGK!1RRii+2XNf)Ek>{C!?=i#_<|W_oSE=r}wVOJmRc! zHx$H}-ehU&-Sx2g&)}yKD`LAA?ulDLR|XUL#>NU4eEN9`haWFVb}bg~av90~wpEgq zR6Y88=XW1nQf^LFR1oUp);@tmJ#+~YfJl&yfq&VEDxhpsyJ3CZODLh(Y|!1KexKwd zUk>LFiXTO~e!eA@yM~-ZQZ4sE@d0TRX_8CQzsO{bOOVj!YpsQATfx=0+o}`h8O0vm z2B<~yXKyv{7!!lfhRo^lN=?-p-V}AzlJXJ4(5q@|}M z*LQqWcv2w#?PZ=Uh&?deejL@=J*LbLuz%xM;uIjFz}9ex_I7oHWds(*Kcjx`9L88> zo4LOdc)bS0Q<>iHDj8N%bJLaG;iA0%20R~&z$mBK8<>aUrmmQfO-|*c3QM9q)9wWG zxFt*6M-`&rR|Lcubm=oLK{#6^213S9*6@KS}rseSb4Q zWTFsaQe<&UbZ-FRKsvV}T4u_Spk$d+S%4O=`26*m3qO!wJ>3Yv9u!>rc50ukvdYU8 zSuYOG@)OiJ-Wmx=5lS2t4xdfdE*mbReFOvyITrqMTuDJNHnECbf*u#3hi)EJ1v{MA>v#Vd?M)Es5=2V}WD`*FJ%4BMNMSHP*+j9b&yk>&+9@wQbli~Z_c9>JPiTBy zz>L!IT{Vp=+EM5|adtodT9f8eMRtkC4qq+X$?9iA`f;Bs?aBGi>5tbdN$X_TT& z0*|5uoDtR}+S0d;BE2n%2ks`=uJ+DHi?^SoFX6fkbEI7oEQ{rzset%I7ap}knNDT} zM?$N9uCSHqY#e0r8g@IyEhR&uB@P;u8L+3pCf!@ix4?U*vh^c{=i~k z=GgIWAh+3kk;13R`i@wZefQi+tksp>QbA%Dsu~ztA4Kw!WYU9i5r0Y%N{Uzgy7Cif zX%A1#0|WIf-(_DBeYJxI2?YLQBnM|a3h$O z<6X%;)u=oEQl4PECt4YdPz4TZerb1)%<{p*M;#U4b*~^y8-Khf>aunbv)3^?0XIek zo*?oOZG%~(jJ79R3H^dZTKpQ&j>}9X-*LmkShife!_LM6c%r>(oGpla;hNo{zx}yv zM!%jfajA*^1^x8iV%6wvC}T$4`P$D*mIjhN`EqxDLiiUHTYQyIf_>gPbjr4t7>>-b z{HJyAFq(~2vVT_27W9y0S8{IQrM89HD-#T6m-!)l-om|$3IC=LY84gVv1hLOZB&Ni z$V%o@#!YnEYY(a8Fhm$C`LKHBj}$N+PL#Lo?wRj%!W%BJtYE6VS#s3w?o4*477QBn z#nVd{rR<`7lv80HE$9XZGHW<)Ll!rhSmAYJ*KsUS7k}E|bVuan{Ta7&2QQ?}G52@R z_bd+&>Avuv>X8zM!5Eu+%Pq+3UWI?UTv$3{Ue?0b(Kxbp#_APtd({gkwBX{WSptbcvJsrmI85Zt)&oI*`hj>ksW*!L3;KJH?gKL-i9zJ2F(R$hDnYU;bkTXgeV zM8F7`?N#-Xt^wAZ;TKX7*d_?(ntxvGu;iI`clTJ~lD`Cbt~-nfWQ3bE&8yDen-sv= zTITWfl@Z26~ zT|V(zdOeck5=H7~SHA=i8gr;CO3XL>)HUaeUXaS&*zC#qZDO<#^a|@AsUVuV-NM|h z=eL}X3t$8G9El$?v6moWFKzoogz^i_M%gS~JDv_5)Yi}BpwRTDV71)(?#p=TD&Hn$ z27fQgt-k+(%49=ai_jw&F!rCbIbpZZ9H{|5oREr2B@qbh)^l`M?@f#)88U35zS7uOc-TMGfK(N2T zO^648S<7AkanG@DDcHFkupfQk-TKJMD`)-P4XjRHR^QM1;s0(vl)52hmq_MiC)m~C zFF_b_vRvnK^B-7ZzhKJDkhz{&6ROzO;ZO|1yl-#&`1=z^Yb~Mkpwk)P?Inmw78@GZ zM@@e`8gU$pL)^~eoT%FPC1mDu`?Ra|fMW*dT)~Za?_gW-Nl@5K2y|>ojW8mi_RmP% zRz#21PSb^Ouuy@=|izf4$IJdOeNSlIo(e;}Vp( z1@2dZmyK>QDY7RaIp8`*T_pOHSE**n>a~9#dO~iP_&p64b1JBeYkLU#D_bWCiN#wc zLupaZOTfcj{nt8xnh#{BnEwpF>KfwxtTDs;q4y|H<38t^i2FjO5K+ZZH^ww($)-!} z$y~p2`J?Qja8I0PMw72QBZ*nR^LG=%rurRwzRwHaZ9{_}??W6UpRUq-tCF$8NY;Og zW{4zmI`YaN_5`sTB>y6>26*n^^1s^FknIIf%hPC8>ru)X;U zLIqgj<56gHHM}J{{1U{xMYiZ(@Ne1Zms6^#(Jb>2Fp}!{q`&M=U$`D4E)EIP+y%xA zGxXdBQGB{i_yBfO5|{bSu@7eW)y{v_frV!!&5{|<4;+G|tZ${V#;+O-{_V=B_3EK3 ztK2g^zYDC4niDcv?^K&t!*yFrb=JKUvk!1DHFevCJ~kG|a9L=H%+dRhtS#fVI-0BT zP-eId?l2mA2}a9+c5LU75__s<6`zk&b*ulLQrppSmAp!eO!op*j@9sDQ9L-kWT@nD){OyMkJ zB)g{${EN?h^sS7g^GC50g%ibdTc9nu3Cwlp`hlXz*Te6s93XKm0&mNHBq`Ruon)Md zlwJeqZ9At=D{<2{`hB0~H%%Q7M8zeymvSD0kDiT9WL46Xd0!-;aQ1(2*+Cm;_$6rM zUqTVdX#n%Qy|qN&2?2H~hx+4AQ=}1u>4d`Vz6V(oi>ecyd@&h2__rN8HE*O^F z4_;7~Ztq&ug_7vbZ_j@}3uIPOXl`a12VHw1aw4mnQbU}@g$}o1!U^qUO(4MuxA`R? zFhG)sN<3n#FZ?iW5bWn-mYU#+d8bmVPxCBrS0I8ufEarEmIy`5L8RdKpz)h33&Bkq z3MVKw0B%-|@p&M3RZev~Yl_S0iq(LxbJdNTE#mVUmaIVPh|7Pq1>IZcU?6rdlK+() z->-6FS@LM^Yaf>K@=`g_ZAtOgi*J^K|50ZF}y7__)8GO)<7i9pG;b(=R&FX zg1;z_p00$})iY|!De49$SaUuKY=j(M!RC_2#w^z^L56)_S^;*flcJ73@UBiO5cUgW znHDV9A-*_(v1T&FCVjAM8b;1uSt!q?%;qr!ar^OOP)-jM1>Fr!QqxP7F6W z_A=;4fDDiILqF?re!npL{rqz%`4ym5y}PLjRFk~rWJ{^k3)w1lzWrLh=3(kz^RBz% zZ@=|;z5Vv>fgG$~%WYuoTx=H!A?-x>Ibk!!GC?kY>RQt)XRUKO%(?kTmTDK1rV0IK zdsmtq9`SzxUJTqG8=9<Et42%&9C$c3#A+1Qg=PJ|fAIQrEV&Bn&YSox-TjkDu!XxIBRFBy+5q2Hexqm&Nb zM^VLOUI9Lng75+R%0%mh!PCP07+uB|?<{j*l?}w+r|9e17>qGmK`&mdprj z>pvJ=KeIn@AUQ{RSgsX~h{?|74UPUbY0X$L>Hci}gqy4Fb~M!v=)3Z{E~$`$phzGd zlAt32oV$*fpwyGsWW#1y0eR#0Iji_X0o(f^PJM!RlwK`$Uk@+^xsHM7S_9?z6xn68 zs>^?!#!)Tj4HwaDr{R}6o)aC(_nWqFa|gMG@&5Lz>FOX|HQ-tAC^E~-V+eTs*f@ht z#3-cepkCNt4X{CIw7_GBNt3TDXMCVd~8e@`pSVzGfu%T1Z?OpxE| znhb)s-phXHW^d%CTlB`4hK-F`(-JHvYEpmBbjBsICtfz#Rn+3K*f z*nUPJ6CVfvk^yFP^wRu4iq89=>c@}bS2D7$y}8LMdskfdlB^^}*<3&|-FuDfdsV#Sa=(x7Kk@qEb)M&W{O>B(G?>gayM{fGEVK&`QyzbN zBjCDwbbV+5_KSq*hDRalaecQs#gLC0%~Ea2ZD*DlmB=u0*0K`Abt6Ze}k_4UchMvn+)-kY_t>1tm zD|#{yol*ki+IImlgd`KV%zqI2unV4fdHI>3Oo?CrX37`?*aldmk8+?^@6i)oEPJS+ zP6bkG7NpOl((B~X=w0Bw?Dncyta{od*B?zxXmcy2IOn$^OdYkLlf!t?Ytw%neAS-E zt2|R(9^mK*W(kv&J3fZSsRSWwx3C2z{qnc6mwer6-sjTgGIg_12wjj4=1(Xc;5DIu z4};*L+#sw39B7HajQ(y!zT;qjRc_Jdv7SBJ@|)#R`3Rqf^wn63`5eWW6lXBmAw*W~ zFO=~IwPcP!Sda(Pr_8qnMk{|i@Azi%J2yT{=d4_^4v_7JJ}CxH23Dme|01}WYN~fS zWnz4N)MYCKj|r!eE&@(VNm`0IeV$seQ|Y%pf-Oi^m@f)7jUt8?zGQ;XhJw^NMef}6Ti+DCgxoMWPha40O6$LtzH-%`6Pd>!-P=8u3c)C z`+wc8%P%gajKpPA?(hF9GgLYGX0zH8$!T#oo{s;UtxC+J4OBf&lVec7%2PhRV^7(D zt;=3pGqS$!Q!f6(x!)3aO*rJj0*N+_6YMKM^Z>c}u)`?t%uCM6Bjg>l+nST+>i59e zw3i?p85iBj1i%T|O-X+)L`5#3sMqqlikIf*`?5Qkra|-?OZsXF@1kEg3`u{uq+|xi zv+`NC05xSrv6(xz+ItV0Uj|=P1+tbl?UdxlzRONkceF#l0PXnOUGM-i-%vM59y3)G zWqgtu*Q-j02(Mk3fXVm+qXL_0UG@L|m{#hVXM7W7BhdM;Y5;##1c<2j0u~pq?gtnL zzq*6HUe-BVHC4Fs*vXJx+)F0ytq;iMBR95e(lrb#zTqBuO)ar&Q2@tjFD0Ejw+_qg9~* zovU`;2YWrQQFwnYAv1=mXZ9X2y6Fo#WQ1+v0jt&E$>|trd7Jem74&P~=-bBa7;)My zpS3In^d{fsg%wC0BDqtRB19TLj6w5IAf&ilZ}%-^)}YnPB}coeG5>m_1-^$DhbLhY zc7{LFU4?6#QSVjYRXTqC$VrS$-*tSjwD8cG*%&3204h zuN+d)%~2i9#gw~b(?4`oYE=G zEQ=)QK(zfMSBVDxh!rpTpb5nQwAIgiivLC*Lx93`yuo&G^P|esy*j-FJf`OjkN&x+v zkc%;+vqpX%znJ?(xjyC0XF18OD;DNM1-qxlc%pIJ8u9< z4!KsO|MG)*3KObquoAW^%U?MKr?J)7e4lU(4-&@|IkGzFjH0*WcHN%>i&2(N} z{xySyn`UkYzYe)S<$(D*6&AAd)iuW|Bi-Pd(2=gai~1ibXb(ruAHZFPhLPD$*5aa3 z4n#!css37)!(=e<#7~W2gGuo%K4EyG_n|`4>f|dU3p8+TVqZBdxO}^iZdxsW7tVhU zlsw6!f-tM{Q*xg=$f8yLa+w@H&#Y-cP9w#yN9wgmx8TMUAK;u=vpBJ~gETXlMQM|y zu5--jke`2YN}F>q9vJD}=P@3)(r@6=XeL69pm77gzir z8o;qbc}g|6n>D@buShnb{8aFhFwA1b4Tq*5Xc>ul_gpYv%y)58z~%PRZ#vAXk)MAL&1bvn z{!GLP{!PYiyveC7>)}+5-uzYHN6ar2wNt&X@reXZLK7?gYQR;?GMc zlNsCTlSd0dmue&2e{;lc5^#SnbZDH$LURV_XPS9V%BIqa$%iK)`u~Ynux7kseWXmMH_MO!~ zron-g9;06pifPZyzX@M;Nx`sWOyzcs74dX3Enmzb>YI;$G-a=W6R3Y6_UG#GI#29L zT<)37Encq1dE)*h^D|eA&Xw<#m7t~Bv%&r1wFqvME})4uiwkNmK+n|_=UPt%?VJAD z-96)x80KjpZg9GL`$S|%Wi0RzRt2b_D1U$n0tQ?#R1jN-A(?jVBwp~kZ|S=ohW(bT ztxVZ>@3L(|zUxItJ_>&jVz?^Gv354jU^4U9FLqVw2f1dir`mDb0=r|2E-rBC6?|iE zbSfp&)r8ZheX(vS#Y`v^kLqGMiah-1<2<(&F(5H*Q{C zK6aa>)csZsf}}WT7Kh++uFvgV)tLN?YjPh-op_W|7C?V*#Bs2au9@uWv>6GVIrckm zhVh`MY_q#!DPm!P-ZlE=>g#RtEBQLdwF5(`?+1Dxw=34BMXkOEzuEihS|p0c-fH>I@fm<-FDZRt4;?4h!F_ z3|vT;*#LiqDfUA6kOz?1wz7#d$`yC~4|hFJr!+ItTz7l)!`SQ5ayb=|LqczjmNFt$ z9rZp^K|S|7`H;~B=~xtxUm3DOeZ@&4aH%u<_x4Rz*}Fb9>@Z;6k~U;=qfHMTmp?82uQ2Z{3)#Z zX6t`p`((2$LS@OuruT$vY_ZoM(dHd-@=^p7`Tt`7&FoKrssZ_CT*HdRpeX%(v#l8c zK|9Rz2W!{zNVp2@5bZD7dVPdeY^WwUnDd6EfF=Lcg;6Sqg9>sluvptlP`tQ7;ZpIm zBIt$@6fa?`)ebo@WqD6CXwKSuE0;uG2tRrS!hJqIyG&$vK<=?@RGEo$4?yUmkz` zx(i{Um?Q2t%yFmWUeE6e_Io*c;&}7ldqe-c-43@iJ_?k)@cNHp8m_(u!?jlBlel9! zwfk}aFr5}RmhgdAx+bpiygFN)-A5%0PcnQ#MqDuI5+dlgn@_=s5$;8TcDpd1+9(nC zGqKAgy9ZOR+4d9<21F;1RrG?@DHVjVQ}rb4tz8t5h=is%mQw=I{CZ{>kKo{+aOk z%D1W#XHI^Q@Dl4qxIaz~=To{W*J5SB3 z)bG_J<1h(@_Ox1VxbNFADr}1k%MASj?E|qittYcV#>T# z&htj?Cxh?K`w-rNLYCuml`2-F179$+5HsNtP!95gtv>XXrYV1lVlW}8NWj5NzHzlk zE@SoANz;RT^u)1-rQA>ZgRN>1KYcZY16ZVjnA97)xR#Izo!LY6x2+A|GiBd9%?4fR z$a!42*vFgV4;O0oJF0Er83#oeG&COZQMkH@^yNgwn5H~Otf2DG?-FG((}qpUVyiHYZm8$+wGO-q%>-he-HHSol}dY5rttyoh9q#zUo#G5)P=mF{)~V8>dw*8^Swk#foHca7k#{3 zI7g&{ggPRK%tf)b5KK|D)jV3hVP-MdyMX>O7npr}><#qA5RJ$BLlj2lwkuZmtd#Z} z$#0)^txK5lfb8Bks;yrIb-}k%+N-2aRavG7YlY1?i~eRnlnwBI2NDKlf;=+GvigXqQeR7NvwDVMuhZ*CJqdFi^ z<^f?*(^e!b^vTB@R^iEVhgFB1a+4Fz=!t9f#gesR63H}2AHcDlG8fCXC%x|_NgBn@ z;bhH`YlK&{#L!r0&D50ZOkAA(QqnAy7}e`nU4%Jo3KfCZBzJ<uM zSj2y6I9Q~AFIh*ay1=uP2Xh0L*CTu`qo&hZfsy zQvLj`MzsPVbMuDw@IzXe^mrbfVN)tdiGlfMS||5Uip11|jB1o&5-$QH=`V~s5Nzoy_XK~;bE%s8s6zB=f49${Ap_bNOdW(a2TSQ_>` zwXvcV-@JY6Z$aSk_qyp0@E71_4~`iI0YU`0ZK|W+B=C-@{v5;G9d!{aHr}2N0g};`|F#I|(s~`T599#Sa@V+-$YNs~yXHhB# zEH%Qv>7E`nV4Qt7^ACvODfbIy2lq&daSaen7!$poN3J>$%iYh*so~8KA$T4y8_Ewi zF@LZ0peTSCJos-_;)Ic6m3e0vTh^H0NG1o{SE~2s6vbtai{viROpyP%2}FOMm30#n zLG7g!^SWlB0AIw7Lq46^vo&t`{pW|&rk!}$`WKdcn4^Bq9cH!Y%~8%4qG-~qGXp@# zlmu;paRUgSGGNraaLubwX~A#Frjy;sj{fJ!*Y9JOlRKZSbYBzB8D)VdVNqzV`3^TX z;>rzo^52cvY{{6W%z@R>yEcE9B7mv2wjh6SFGTvIs=m^|Tu2rL11=9Oc)3@Cda8%p6WF#-HO0&RWxtw?`GpAN%no5p(N z8AfoeWB&ayd#3SOLB5OhdIHm@NF{n%{I{fN=nKUx;b-bt8yb4OmJiL^VT1f@;Hn_U zD+xZ0-ud1|;PEQQRnOmg&Yq32QaRVe^LxV@PV##!&r9V+c0er0e}rME!5@Udfq`XHk6w#=Bk=lcycR~EM~AnmihpIGXv%07lkU9SwyxT;XRelb4U8_ z8pG&FR=sdWlyY;l7000JU|0}zAvh-Zj`$h(gB@OD#!#5E_=k+_&nWo$hfZ;1hP?VY zZC{h-#{Lwi2Niz=?lAcV)F5URY?4GWGv$vnT2}@O(v6qjNqwcTo~NmwiI`X~piD_8 z1oy}e)#Luz25oJHhHs-y*;kd==b+V4e3?n+{yB9a6SJ`1u-?TY8~g16{GwpHZP!Ha=_6p z1}j>Ex66NbZO!>xH9AbU{EKCqDZbF?{{1h5FP=R$5-#dDOAw_eH}4gZ#qopv#U)4~ zLTId1d(QlhiFAXh_rsqIXFbim ztAqMLWK1wCmsyO%j<6jnvrLz~_*lqCR9jV~f}(tZ<})q)NirP&l%m(x9bTUnTlW7TL5A^a2v^6v+vx*{GR={PvLk2A(~ z7Nx7bnD(%FaEwKT!CaCy`_5opraywUCbkBqj*KACzhCQ$^dXGjIUr3)r=j50bsooj z#*Nwg;=Gf(f^q>e?KD&x$QlDOU1u(#=*d` zyz!N%LL06Qi_cSC&vU4t=MaYfZz{6z8_b#ckT#5@Z%YI8A~=gE07e~vXX?@vnv{+E1Dg7fZly|I8p zWhIjaK6|+LH117@Fla+@CCie~nsa|0-)y#;UdnAH z(L}GyGl&9rk;ZT|J{=sz=*hy%Gns_di{A*+W_?LqDi`#9&XjXYYph8)GGgwHY*vo% z8VLM8;u%B)sn^#=1*M?)@Ni*@SIcD1wiIde+Zxg>V>98cs-20`qd7_bz|CX^#zE$8 z_;d7sqLP3rX_rt~9QuFNyHL|C;Xc_BY{qmXwtiC5oDr@^fq<*%*=Fv}kKI3W0VVXrf&X%X#?ww>@ z3f$8VTXJuzlOFYC8mdj|T|G~pAV9c1GqEBMOhQ=hDufztIjMir@Q&|P#WIgb$ z4-N0xlDZhj$$EN4y@i5B14XQdzEz)68bx4QLZ$7Q{r5f%gKpkEaAnwZ#qy_q`r(cs zKWUELoatXzWj4u4H$`hCg{v7A>PZ}oX^1U0+=Q!MC*Ju)eqIywl$aE z8V(8vtgzJ!lQ7PaP~@i%H6wfZCfBuxmRnB~Pvb6_UK6gP^v}G2e;;kKpI;0?!U?BYS%FchuP0-ksT9kwlPxo*4=lf60 zM~ByViB;%#7ZY8zxacm|l?Q!}Fw26ximM>32Z8 z6xk}I^l{yEa0H;&sR*W<(3=M;ZI(?Cy=hQogVhl%{(Pc`JY(`lr))4&(Yd8rk6 z1jit$Yt6DTq8u<6N;187Fi#i|QRqG5Upn#U$epn^u#6;F)B#jiqycOi@yS{Ji< z=v;q7MW--e(v23KwlsF0i>RyY(0aC4U7VZ@sIVIvjNpYtelxtG_!GcbRmToe;kPw7 zZIV2cS!+DJ=YnU(bltg(BevPSkl&%yDdf9!_2ZX8kUGUTC_8ch(TXf`*kgiAwJ<>y zxsny*gU{mSug!8FNUcL(OGQ7McXNBjaw&i3UvC`6`#k@^w^JUug+T~WL3j>8LX3o; zQ+vhNCu$#Z+CP1Fs-N{!N>Bcn{%`*rp#_ldGDthCF9Hl>ycoQIYWgOS^Dz)MihD%) zVAzXJHBwnlnXo1P1EW2^p1wFSgi~L_XQhx1^^fQ$pnqoRofjAVyfF>*YgJ@&lka~R zeXX7JHcxWn;E30`X+$gKv%$aAjUH@y^wC1ZneeX&I+#2~ISNPAjxF=Wihi_QWfju- zA#N_I8|J6zL&&so{TIb}^6AsH^ea!j1_!~|a=c+LL_jDa^0-r)w34#lA!wBdg_r;A zFV+2`>pT61IZ)0okYzE%%1y_?TBLs`ClVS??CM6uwl()3vU(su)s2Uuzp|_=v292+ z_Lc3?cr?MhHtdnFmhM$v(8=QJL?TLyxTEGa#l*d&+Kd=?S zdGJpDYZkTSPgVwHHTw_CZbvXK5VtV@f^dvT@7_XXAefXh{BzWOO`d;yLA8HGW$2gh zfvfq^{_`tR`IoQXk{}cjaabH4vA3%}5rgKWTuB`r^5ka^r$+5fQxh%F*WYYCr#)TI~J$dRGG*9;dPH_P!K3s_|$0 zb>WO5;C*)2dPlQn04(eP1mZUi<2$~HlXFuYnYF&r4XhorgKYU0MdvvBH|fqIUGp~4 zSCI=AfIcZ_2SSSqAa#B#LB79Z;*76SFJ4eQVzP&o$tAicJg|-l%lChGZe(}&V7R^Z zZ5NV41+h1LE70pMq?_!J#$OjSH8K9NKp&O&A=OAKe9?Mb?!OVX*)5LmPwev!(Bf#E zqc%{mwj1##kAWga1u>(f%0A`o_I?V#B}ixREG|sqjr8rGABP^?W(g!f>ZzdaFth@Y zNb{|TeY+5nk*C%*g}Q$k{B0Dva`{oI(~ajut%4We!2VYd!XYzC889OYZbP};$@v%@ z-+WKj-lOdo9;2>DJsaW;o-{e?|FW3A!mMb~FC$SuD(sO(W&os29Jr7o%?*^Ogz!vg4uYSoHs)Jo zictSJvnGdWm1)89*fgbfSc3a&t}FrGj#CZq)W;1G^Z5^A8m;{r{#8lTbq0*hf32e& zHmIOB3~ZKY`K5RLAUMU>e-xQ1Qh?{08LbD7D_F69yE#W{n$^$2>am}S$>^}f-h7tt zs;vae5SvZOQY3%PuBaqn0ikdck6lQJ$^~eVWbr@#r2oo0OzxCHx!FITy5Lwf6SlO|vEhGqlyLX&c^1k*_nImGRyARU zXt>WnNO8xY*{4zLPzTa);~B}hU~M>|#LjE=PO03XkK}&KLwjMLl;0{h*&fqIg45I> zLMX0jlr(&?QvgUJfRkA(&FyhOiDl6DW~Yj+p{w;b#GYtH<-R3#H#=L~`XzPo&yB?5hfzxg5Vga)tZm^ z0M7X(nV|g5ho7~r#FTEa zaC>d~o7rEG`L?r%r4P+dH--@nZS@+HM>2lhN3~HPdDJ};6_g0~sM71xtj_=cqRm#% zyqs?h)RSz5Q61#@V{ROEWZ!P+_7M^Lti*kBJ5pC_5RMK2 z_fDC*mF-^eO|OphD&7pTdbIs$->UWURn|Z;^DQ8O=eOU!E?Jh4+poxJx822wG9k!1 zXnM!{s>#DKd8n5`ml(8IG3f>&y*x%e*!2pyhOYEBcv1FPk(@hl-Pt=LEw`}XwRyuH?JS}HQhZw=oShGbzyX1pU>kvnE;cuG)xV|!xl2I*eL;I zA3N-}h1Er>XgNt_Efb6ddgNocTon4#3%!c^;Mh@ubTk#@Q=qq|h?yzf)i}*mYvZji zvrY26^v_QX9prUD4@|X4@W_;>&*guf-M$#?R0M3udKli95raH4Fd@Lt(ykqT1qj&D z$TKogD|9T&7UKV+Gya>;VY!^=076S)Cf)3(g7~1}WUFqAC1lBfsu%0$owlE?ncF_k ztV_VG?eLMWj}^y74cI1^&<|<;NdDh`KY`$fdrZW4F?BroYO6=$!xSo!#*}}cO)pPk z|1v6=_Q~BlbHvdQgc8gjmQXNafB08?k`7xQ^uw!qQ!L?2pvKKP4bF#O8y%l>un zc{$57=8&|&fts`LA7vs{bY!~(&W67DmZ<;dgmI~-Edzc^6sxbJXhc1D>BZqi#rTk2r`CNbH#Ef)fs=DFq z%K;(ZY)*j~Fcd(`b>m;)cJ!d_DR?j%0b`!0g77?f12{~V%iHZbuf%b$W+Ag2H>SY) z5Y<$Fgd{TJxDJ?ED}b1i8B#h$enHqf=;X8xCwaPOuB7Q4pc||U=CfB(|i(%CF=a|OEoi77A zaKq5(nZk2f0zAG@6fh=#J8o^Ars%P?uN5VqK6!Jj$>sg9FR*`nXa4h*Yz29I)>y0? zLV5jXNRvfIy3Ri;2(P%$K|&DA6QTF0AP)63J`^ob|MD_l+gx0dVwK$Lnv(HluZcPq zl^aSU?K(p%N9*BniCxYSf+&4bYJp$z#Z+Egg52JmpW2EZ#@1f2VEo6>So*m4E1}Bo zko+9Fgv&2$Enj$8#3p?0)C+{DGAx|4(d+I{7*y!i~Xf^B=sZq zZhE(bWMAiW{(F#93C{umCb;iwpFx32fVtlm_xjVcpA@IZg)+IIu@CF5u#btr@}50j z*38~q^VNF!A#f6P_xy9G>c!Y9H-dqJ0(?GEOi5MiV5ffz<;EW#&ku>%jZbZ6l5@|3 zXr2gI1hSRqh$@n{j+@8}2~b*mT1)^%bOv^Va+_@8Wr?NF4y$}!FRM_nCtRsr-I&``e2F#VBA1Sx6h?cb<^8c*3;GeH)J>oq7Pf`X|A#O z+84GEgSvkJnexbBBSJj_MA3m=>kw*KVZ#~OB{cH<6jqZLj;C?#?j$i6clx5$3&VTE_jv9084knyros@71c5ym~^tS1{w1>Nv#cR*k!7^jQI}M4P-)oXH<~PEIR6dsVd|>Dri9EVq(Ud&j7n~Xr!OGiuqa(Rhf|N&bxnZ zOmpuFKWE?7H73wHU$^7+$>$XRvYm)4ij6nZOq*M8X7mJ1;QzYXl(Z`HK9oz3vc9L( zeIVrT*iS@2kU~A3wBMU+Ysp=)jY&Uf_i{Bge^l4wSs%Ekm_W74wYZxnj@(L6PO@Sn zw!Z`$BKQ)TD*S?PC}`|u7f&!PW9!}ItHbt*5L4-3_9H@JXAZ9L16T>1by z&#GVXiZ@z_EH_+0J0L26bWaSn#O{Xqx?YhQOKx~x_iAPN=s!e~BMXo~Qnh~|9b)87 zFjk$)lU4qBQ(a=>_TztV8o;+P-)LnaEAV$;9;>LVmjsH6kkD0U=BRtGw0hBelO4(^ zA3^+{nvriz(@^&^L1bpg?y{=^81x}n-K6idIj)lf^(bc))5e=d7T243Gk^1jbD+OR zvsNAIr-Ft=!nK<#A!hi2>Fa;AEr2|6bl-vyQWQucL&Zsb7!54T_b_bL)R|}rvbK)F zpj9E~gp>Xs+lKy&2OA8uhR5;qFbU89<2Vp}5f^~+%;9ia-f6oVQuBFdYN;m6i9lG) zlU6pEF+55yZQ4`KamgZR^G2lQRTo6yO=Wykdj*4hj(@m3oRKg1v-N*e&|ucR?v3iw zw~Qu&^=qtMbZm_oAWjO$bcr@}iF#nABTXVN>QzMwX#G|{+lhwgE5y2`as zvASC|om7z1D=VV5I&UA*c(c6}L?yLGJy&Xu2rVV=c<#gfTn zUAcd{fiip)H_iJ`A_fGJS4x^Upv)yZf@N`iUwolLpBy}_=*5T-X5-LrxZSqg%yI4) z+;FI=tiGsAGUL!nYnyr+;D{6G1i{KlB?Pt-LILDnh~K`vrqBm%NU&LwUy_vq`JzGt3#IYNWCUx3F7?7AEnjU3>~sIx51wi=#Ob6-bPTLvxZ47zo=g_Q^P9l`nI|=iY8#M*YnU7QPxL zD(JHgB(S?$`0J&SwJH!Qq72A6ePv?TQh0F;-Z$}_|KmAQn-po3Zm^^DN&J6W2}d$( z3GHiJE|fVL_I;J#8^=Bt-K#My8qJr}>F{|N)o(pzX$;gjB?b_NIxb z&wukkx#5fAF4(8<7Ald|F_Wgjfx#;AYt2m!k1=Rh{47HVU4ZrH)$qeNR1nPt&#wr2 zKd3(uPPb#ld-`S}%hR?dTt|TaUBrY!8)u zQvt<3S3a=1$x?D6&f0%6pn~2K=$&uh%>(yRK^$OqK-{r)K3Mzaqn!B>Q!^8Ul!^ay zu6^&;RQ{oZM~us_U7m)%&;}1ezQZvH##K9y)9^*0ynnQm3VMz}`WbN!2l|212R1dX zi?<#vUyhLc`K_Sk)_9m=VyC#?SAefi6r7}IsxYCKxM_Smc0Ygj<(vPYqZP16)3*rm zhC>hIKGc3>F>!DSX+8h;^{4IbVX&WuyvaW=3FQWly;`5ECCf7^%nB|Ej?38Z{qp5N%Z!kSQ8h?NfCdEP&nQ%{XDE5 z{W>OLGymf+g9ZM93Tywl{=4b)HL?Vra+*Dh-Ke^#S88f#J40`zE<9B=z7YlR ziqsJcpb-~`KSt3~K~XsuBdSH^VAh#P!`S)|}1 z#48xmK2vN+hkc@27ZX&_o3y@D-}dvOTWt*&(~ZWI1Vcm9wSD-#qngE{<1xVh?9s1l zTYZ{e7kAUY=1+ikSj2xcOVf%4o?(V*U%Cd+^bmfmMyjHJbSqdHbY|6QnD5K&m$n?G zuXSSN8(zBeQ@q!-+H2w?8W;K;hXoRFbA0l|;#vrc^EEID5KNh_THNwm?C zxSe5Z_8m~spUJ#))?I|s#cC2cWN>XFF&GhtmYznbmw28^j<1&Ah+Xq49gL%b?&fJQ z3&@lO7#Ns;X?HW^T+AZsi(>wErQsL`3Mw2?8bAS2pe6>F+#xakd}h@9dA(+XR;0Z6 zO-@zU%S@r`9A^EZH_pFOLC?Av|9{uKd#E6h4psbO^A3mo(zs|N>-#5eoN`{=Swe9x z35-mGQXT)-BGpF>A`&iC0kH}%DoDT%<0#Fm0+if;&GFYLDfbL{A*Xn`&MX@zH)AaP zT*=NW)8YH4&WesXn8L+)7cE)?s2e0cnXbpB)I@0-xcm0y~38MD~gNEhS+Dxh`st4gZCt;?>F zNHbo4)Z_DaFb$7R;ik0yA}$!4!=hKzMepxNnOhH=W)9wFKdn9*d4yvMl?9d2-0JxX zZ;0EYf(VdAI6FnW6i7P$)Igrz6?`?ASd-~-mp9Kc|FWpJgPl%2|IfNurlgG=<`(>u z_b({2XZGHG)BTLr96oqd;7L$Xgx&c?k|CT?kODNoM`tK9&T-mNuK5)%AMLX=pr8wvblXWv=#HpoJ*l@Yo1A zA;pq?q57mP`@DI$#E?(b197!PCDQ<&&EJ;;AM~A+s{ib8L=c+o;`F!G88gQL@-i8K7LqLQ?fx4zJ?9; zsH?NHGi$H5pO8?T!@rsF{mDk#5dT%@_mKkK{h%Nm+l96xN*g$ns==Q#7y@(zA1l3A z(iR9#y)fPsKhzzt3bRqV(2JjzOG-I^Y5~@3Pk(6joW8Tv>7zq>+Ba!cq7nQjO-X zU;o5n(OUBW6@)d|dO)VjO@i@L?nY^OsyQIcewf@+PYQR{2S0s5-ju_C2!L$(ywa7k z9(OoUL6^sY@Ji%j;{*idKrT-V%11heHQ?C2Io=B-ZmfGo(mjkBW=c9S93PPVeM$wb znWaF5&hC2Hf(KSi5P1t$p1uufywj*Gt#Rq;Lp`f?+>d%AAAHS@+lS6 zAJJgD&1dzpUY7u-aYRbk*=*OAkIHvE6S*)6E2~vTsZhpl-mo}-jqwZQS>_mLuBi*kmZW{}J`& zHMbMXi?0lL9mw>5#NMXz8H}PV0w?57Q$c?+W&WtB_S7UrtmQ@$VJoq1K$)o7ocYdn9VcafRN36UPp~Tm{l{p$8^^2JHH-GT2%FIg3|=u;`cX zaAL2;9A&N>q3VW;ZL(i-r8fS}=BD|*wa@VX7zKU#{GD%yB#GrGU}9I7DC8rvdgEF% z?BNTyM83B?T$%@c&5{XPb%6+JJBsglp8CFmA`ME9B#klS1dc@R+*W6mbu<~bvr0X^ zb$sRW)cAmZ0sS>BKi$X++5Il8#jzi$YyURsFGZDbH%GuJcmJht$@Bhab!|*{jLKWK z=>Esix%e~vcz=9y%{BMCDMGoGTncTK+>$1hJ0rO_b2phw?suUGF>=2yxifRwP%gQ~ z$KB>qZrLckW#;$z{{Dy8c|2aP$2qU_gqgwgq9A&I@WcrIGtepq%HAxB)~7&wX6NPd zeOi*q`&^z!|BR=jcde3b-p_knmUZdi6Zqg1Y{s0PfDy#sTv~#oP6+b-XCMx`*sxRk z%3zMRMrw1uqx2Q=!OJ}=nrg?d-*;*`Fr$zCDXnYeon+PXw163@ef?(2_U&V;VgHiF z`8r;I*&Ky&tA4((e3j38**B8Uy?(?T1m~1HEj-lrz{H6g{X1NLP}d1T={8AKm$ZFyNkIvH*-4{pWAWkW zXM@B4#=PrGHNnFdui1h=0IkUo(M9@DaQ44W4QjV^+Ebz)E1Y@o)I1s7p*WeBxVCyy7qkqJkQOfD9E z`}VuxuB%S&j4OCi+LejDz25~E2S;Bu_KbUS$cBR^ZMr0p)3uhYQd5}=D>pAzcys%) zc|!zGYoR2Ed9-!+Cm=M!pR8kI@x~K>BG>jp|NFl6@Tj`kK%&HTMZToUYxmaq*sw+2 zURVi~<_^hpTJtmdHYKjHwz+9$nHlgcS+HyJ-FRW#ENS^X+r_{~Cf0J(Dbs-D@R*`{ zWKTg7q0Gy`$^b_2w<;sbVra;qJ;~&~w?;(M#tc-YU!v2%N<|A1%+QkJRCx}68G8m2 zQ+bIOj!B7n4@QEjvyV^KeDlqKVQAl;jUfQ5 zMn3`~R&7Tg3)_7U)aeYG-dA~Au$?O5>-5fKQ%3RJg7qg~hlZ2e^!^B5nz3^|fV~5x zR-ttOTfaiIl=62adVjU&xFH9BiTv6{Mds>@)o<0q(PKz~CJbqAdLz2$6*RqhkN**6 zrc%ReXQt5*b0tH`-8lmx%@lQFU3&2->iky_wa(umxgWz@-)z0F@dO!GB|5)-uxh^& z<8h;FU5DxO+J}Oo*1v|Ps*-^IE>h(WLU!4~BjTmI?%$XX@vt7R&)|n67aN9w@&NV-rh8V#cwo$oli_zD zHci)=0y_$%2!yKvK6EaBiZ0*6)+7+LZ)++@(^qsG=`48uIMC-@N2j8?Z~gZ#Sxw!| z*uAY1V2w~iK^}#Bsj>sAUow?V?AY>N@PNJn%Cj4Dyx@??6}h0w^%ISIy*3hk>S4X^ou8ZdN0(s`PrEb2e`HL5DQJyThH$!zStffg zYAiQW8EXG@Sx;a6l&L78Zjr&?rn0)xew?NT67CDXLV-s@nA16TFhaVpwNUZz1w|Cr zsM?CD-!+{TFD~hHew8tJ|BH=y={m!`D$#RYJa(t~zyBw&b2vfP{uN;xATD&+iZ;eb z$RejQj%F+gn=s^m%if5~K0kinD9d{mG+T&Lg(2t7ErGR zd(J`a*OBHYwbjyCONAn>k^Z>(KlVIp^1VF&wGzJuQY>v}AXjORX6l6c_AO?;&8PG& z-o2xb@5_3gL_3$n&W~9CWL!rkI52(&c-MwcyzHGc4w)-|H3??pd6OuY3_rfj0s5u8 zY}^mx&8*qm>(h`i;0tsW^gRl`U^V{)ny5WwJ^zNz)#&AQTQX5qg73k_IECChi*^@^ zva%PsZ|5Hs0{Q;blSdN)&o&Nu4u!5)6`vv7+@YWNf!rd2ptvid&{OUa(3q$95A?uR1&_S&jj~y;&qDDWNd!= z%fcI^YjWf;uAZ+G!RKiiaV`*?7Qr`5^tX1{$~k9S2Zen$6La-o*G2fe;HtUPV*Z$<0IJtTJ5apPMsSd7!mcbe zx7ll8!Z|0f?_Q^I@Z*9HYH!4uqTjI9x7_D5`0`4NbqjtT4?4(EIkTaSn0UEK zWc?3!n(as6vq*Vc>c+_9F_n+>=LKDs;giD$l^`?LWdX~vl}6a!}(gR>>KNs7M?~e#ttqbTWb305EE+hy;)sVGAu5+0*Kb1 zD!f071N&8R4OH;IWk34M)n_cS*lYZMOn1GI85C{@p3kzPq^A}GORHxfQJPjdJW4=H zfl`=T0e5c|n|?VFE%3&)cFFjH7O#XnKXeu0tsu$?Yc3!jT7L((hJ;?(6>|Dr9t24yL(c774V z9T@sKr{$mPfNT4tO0R|1b^py2myE|btd}Bh*Q~h_d6#R?V7@`p691qBFZF9{tEr8bedygO zw7yDmXfbh4zpL;m*M{xhz}67rQR;y2FYWI?l4<&@81ZefJ#1nz;8MD!X9IYVF%mX| zLhZ=vuj=MDHuG?#Ya92&T>D)yjinfA_JjR-j^0ASxhgL>+v;bhx~=Pf*koO3gX*_`$Ub-iU1jpOwep`ZadKe|rE3)jV><8L zePZzLhMumUt@{1ur3U2|qra@lHx~{iLXo?O6LXaRkp~b@{dWd>2EgiVY+6bLay0S0 zttLQ|Mw0cU(Tj{Z_WsT5^RHZdU%$Qse&zs2X&vG1pRBqKV%QgdP_}DavH9xecanqx znfAISy=|2y-Y~u&yW4JcjVnC^!cXtJM!QAr9^hdI25t)z=Lchd{|v0LzhPw&HEcPt zGRhOkNsYdZ+iH&6u+;Z&eh0`@oK`?z!eYQJuIVmbBrqqoQAz3OwT8C z!~gt&yc^r1WRHt~5tCHW5&p_)Dg8G6E1Vy#7(x{%M^RnKiZ_>6k z=7NNsY{QLzdh`fS?%T*ax{e^UaD}Z$p#pV8i1+rlMAA;z-5B1lmlkiAs{ALaG@xLjoK{)35uFCQ0QgrL#3m)A&QH=Lf4%@3$0xW?MdI)_$^G*5bYO^!WwGf)OL> znIDmKzQlSJynOGfS2{0EZt4$ls;((4*g`1h6=A5~xhcu$UQ;7z=$n%rX8>aos2})R z<@xF7E6eyg^j)kSz-rWeN7g`6Rgxir`XdOWr_yInZOftWo_--@5M7VTF}e>}C9zG#dxvs@g3mH!x*EEzb%q zM}0GT%AxQU1XGXu;kt$K6WjD)w#l;te55uNiNsizFif%KZ|3 z%fA@Nw*aP`I`)aeQq12f5TpeI|BbSLSFX-~G9j5S`rIqp_HSeCgWLw>4FOfE8bY-% zuN=#@mlaO28)aFaoakuCQYV+qF%l`4W8UE*+tz7Z{@<_U zIOolVTdv|=j|~01%uo7k!gJ&*Tl3y-3C)Jdodz6RteE|`o0>|pXSiw^G!56pQ!TnlKXs8sJKBN2a=o0N$8D`ni}Utm?FNQgb6ohjn9aCh!TgC~CtSUO zq7@$iy@0|RP~d5}%hjIqO8#@6g}gJ;)9PNb*;-NeGdHC>$@$DKiG4U^SI9~8uZYjk z-3ThjY8N&NFE1Y_OD)Lmm=Q}m1F->rDRZ8$9)>$1(%P>rEqqD&Xmx=4!fbTw*#F2E zXeNW12=#y$4Ct+}_Fk}&<*sZ zGCnyigGhRaNzA@-i>y;WIfXlP#x2?Ce6@U`d6X`5PB@qIJ^cNye5BfKy*|9+lrEBF ze}kxfd(y!}te`eHyHz3F`cj6TVyI7uWIOglx3lGUh%9P>{CB__i)4+X!D}za$JfeY z&`_sOT;~=mC3rMweb;9#z$^<_dFH|yYw}4H`60rcCwU&z zzY_B=0Ix+&|KH8_x!ab1Uh}rV?OF|#*+tGq`vkH@Du>`)L)xF60k14-a8i>9UdF@E z^9;mIQ>{WF`zVC6ZEa{wpbP?8YZqevJg6;2Jr-g%6@mSLbdIeCR3}kvBhMD_ zGtM+pH)Y=pa1QoWXj3|ID%(VRmf6U4GYtb0O;5QfO?(ABe5IZ=iL%|C*%xpePR=La9f8vzwsaWvkexl5C}eE$k7u-lqG#l9Tk zI#zSe$^}VwL~*4%z3YtM5fD|tt9ON89q|gBCM=~GW-xXlV!S%cf#fkQ85C98e3t!n zQt|Ocv>KX!ZguC`C(s6617+C?#+h@9}JQ zb&4_~-(6hv-?+s2qjuu=iU8VwdXtidsX%2;kt(7^e`&CVO9AW)MxkBite}M~^$laI zsP@AJ->ZgiM+9a^XI4aXub@}>Z2N5+MZ+h27X>GZ5w)d21l_ zf%eC0X3%eGsZeS%36q3oq>91M@BAI#pgdfneacGFG%?0$I#Udz zRMpRaKnW{zYSwe8ywl0yhm%f)a#~_#OFOOqww`=Tl0{??-z#?gmp5|y5zoEo{!cX) zNs}ajcRQ`tTVrW@#m2!b?`oEAUg4N8TR2X=+H@oQpR`QIos4I!vW%}O$TVd%L#=zT zYKe!1!*wr%iM4p5nEX4J5^vrF4YQGNuK99*mPoZr!s2hiZ&fF>2f9f4P!SIYNy=FA z?*Zx=C{ee|84koF$o7U-jw3}~U0ro`O`x%B$M**6q%GaQKJ><3p`3yCl*!n{6>jvi z)fFMy-RXQHL?(0SyDL?sG3!C$H`#+BW8F_pEmK_87Ec{(gM}}~t^Em)KH&#!^#MJ9 zB$-QZb#EZ2a?9uPBv5i6NIxh3I@YEA#4hck+0aJZm#5y(K(90JKwbh%uo3cf0k->I6vQfv+=%N{6@181JnZ!$d8W2 z2Ap68U~2`zaH?w~zo)Mr*;I|gIMBBIV&RaQii5Q(dv;i|xXOj7o_9=)F%;o{M4a*( zj1A~mAk6P(hig)ozK_R_IvOMB`Ea_d2KOY0njjK$MsqKO^a zsO^IjOA3teRi$(aZC&T(AV1w4f;RT$bnM^kXHxia?zwIYsw8WK&Dv=F95ku$BG9~Q zIr|%v(6N~nOcf>zeav&M@l{QF_I&c=6W#AIrN>#Xa`T^FJXvl>IoV zss2J;s(j?5MDNT+6r7;h@oLFoYbHJMF=V@~WdQzBSEjjN&}xC>gbtJMUlk4WCpTf9`qSwjhbLpwOFHN<2aW zovY_>Ubh>Rksbe>a<1;X(ZKBn_(!@5kP6*ZB)ijvQNhOJG?TS|u%OV;g;Br1#MxK4 zmf8E*{rQQ#m}~Z52!3j{wQ8NMDeP39)IuIAa*vC8cyGKJ9w#MD(Mk^gR$+gl9Fx~P zWqVzjYn|FJHY~erJ-=(j`RraTO7RX@od%iaN>6PH>3>&PtTk_@hpdFHFNhiw5i-Bb zs_UO6DhYEO`sQta|IxAMCvuTR(RyUX&@pjO2L%qfs`JjSEyR&o%NNz4p#yc7dnqLG z4N=hnAd!;p00fhjoga3zWbdhWU#YKmj6GUNyp{5hZA9gj$O>mId!fDyz|-%8k|$oN zF)j93md<8dKTvLd8X}$cyv;bpv&ZQ1r(UoE(dk{h0D}X6!DLo_Csph5RB?CWkAeYA z2tp!Z@~fzL%0m_t6-%8EQNe%uC(>u2UOTKhRFQxYUq%@8%5Wgb$vTL?S0X{S30bF( zJ@O-uyKn2c$&mL{$(rpH)_yn_&9DgQHlhapiOZaSSC@R>yH4G@o;Gp0)8Ie&Ej?C5 zci?;O8oM!nVwWnR#TRA!_|x>R_a9Te`>putrc-TMp_x|5%Zz0IhEF2B5^CMD`|n<} z8Gu-S-=z#5#Q?t4EYrA?-sS6)fnQ1^6bzY%65mk^Qu&<-&L4QE3g4{^O9b=c%&&eG zzhYqkiel?!dS!XY`tferSm};-Q%Al>N8Q_TW3LK-eeufn*u~l!2|{r8Pxdz=j`eiE zV%uFzJhWMUuzRl6f>M}Eg7QL%D?B?bsYg5XmDbZCE*~0Rz?ddp_;3%Jm)(_6 zVBJeC_#hTmYccc55Q$`uNfR-h18_G|+T!vDA z>;;2x!sSbLHXm(w#56zr70Y?w5b3a#txzBf`yh0Y?$pK~X~fzItn|YhJJF7m?$jTr zWx99CJbb8E2HjICBnnPz;F?6cFrT3(-d99kMT_B!;tUSC=0;fI_!)8G4@?w@?hj`L zz80Z2e=jU919At-T4v+*W#%)j4o1&^->gUDjN3(OST9n(JL`4fX8u&-WKPUHrQ`vp zO8bRaN_Ub5oa4WyPaos#mh5kLEaF#|s=r_dZz)_e`~*bwccdqlOh>pO49Ts8mBb*( z)v3}+v?^BlL-0KdGfDm5nqdSVn<9 z%9z|Akm8%FE5=Wzl(2gz#+GM)La)`Uy0FOQ|06E7iqHJo5MuC*D(*N z_{P=!8ZG@Sv#-X_nB<@}Je> z09}|;_q){+BHM|~zU9Dwz@*a`FMFZ$wOvE+D&uA-n9qIr0Jko_@dulg>cAXnhlV`93MmiI1r&8sKQUbSDl&m@nnb zXq_*{XYtC$eqpD6y513cS%4g=_B%at`3e9IsX>iB10+N00Q}D5ynmBm4tI-={h0l_ zQuvb1pm+V0P1W&#lJ|vZsSReU3njC7E>FC2^JxuEkhOOuimn47D7}uM5&U{&cW-4S z74p@fp_aoNvgY@F1WLReeN($o5p&R^NNRX;J=}eT{in8 zqJSm2Jm#N&Rx%D+fl4>=6W{H4*D>-so~mLv6gI#Gins}9pzENzkNkl|w>2=!a#?v5 zySiGxPyT9qY(vMDCo+C~4BKp1*`y=A{_&pX(yyS>*W`Qa82iJOC$g;P?L5G zcn49bQi6qTjk7q;CzJvkqhC7BI|IEq1O3+xZvm=*>b6?&lBoam$_Zh?x7SVln-krY z|7-VP8y__fqn znm*C?P-RLf{t_FtC)Hq;l@fsM*5yBuMDqg?C8=6QfHMi(c<#K{>1|b)=Q>SU@qXdu zk&vfUEt(JqLF&?*pDXNiDOAaTlpHSECAN8g^z%U7t()u<#C;hB2^y~OLtu5d;A#FZ z^kb|CM(_;u@(h%a_fpuvCFddKQB`HHN1631i75#_{4%YfXg$5)L$=MyaNik-ohG~* zTIG$?B1v)(W^X7M%S|@@hp?ZP*mF(2EA!v%<{&Yh8`wX(2-K0WNoODd^kve1YB z%pWD&9pOo(j`HkZ`)|MIh)QPxd05^`=C6>J3M?e^UBADqoL>pdD~ zL!%(0A!-UKB74eT-yWdmO$y5rWv9D;#i5>c&Sai6ycAk6`Q9|-JPm$Fjc$ThDk zPtQQ853?WXL*1<5*B3mx`E_A+TfVe&$^BY?#b!F!QjJ?Zja?^oQ$5_6V#0DCr=&~` zX9iyZtvZuOwMZIcd$&&>c^-9tv(Qwlfph{QOd@Gnx;^7d^oB(nzU|uvpLSPJwVni)1PqD&)Fdy7@Gb3In;UoVLc!V?f;f@OT zgS3htE4(m&VImJN5AVvh4JKVfXIKu`IDQF-oBpN#v*ay(^B-B?QlTN^UTpv3&p0_^7ct zsjMluE7TJrKZ6&TCi_aI#NUh47gVK=gyu-}1|{@=Fh{LLUVqp@iHO5%)W@>MsY6BV zZLMrg<*;NE1W4B^={EV>x0!P?sk|m&HS+VpsF5Pa!)|un>mO-opg`jcM1&=1;)N)W zG82`TX4)$+t=#_L>}hLFtU39bB+c;nTw0fp2&3zm-gk%w+K|dh40Ahy8d83ha(+fN zS|>JtnL-ABmO2&AD~L25++T>&;|)eD-aPsKeFrY6i=djUKEhU@^?@bX74Cql4SW6v zc}AtP&0EuI^EuJMxkrveqNTY*#Y`$kjFJwytMIv%wM$#ts&UXJU5&Ea>rs?Y(a$JLbTQfm;37Cu-J|vl^l6`d5v@0c z*b6+roAmB&=~aw-uycye>kpg^(;zgMBD^La*Zl%2VA|1as@1pHpmp zwq{*(7GW>jyxx-{yAFcKfy92Rf@mhErQw=rTk6UHhQ-v2(+Bkmp}F6$)pB6#(ednm z<3Og7a;D*%+DM{LrR?$hf|6TOgcA>xVh{9_?wVo$d1sT3;RLDx6;| z8G*?U^9l3DJZ@5i_=RXjxI)>(Coqzwl>KCyaA*aMA8;b&Ur}G-Muq>o@~ckedEmu$ zlr778!9vQx1+7T4ux(J?iK=IWM7LXi#Ch6ns^CB_sm|~z&`56k*fI6THnf#0Rl)zu zwY_l7mv@lQJEAS9##{QA#=mBGe#B>FVtnKo=w%g}nH(Xw)h&VZs+O!f$n zP2-iZRmciZ$mG)tVSZ%9^C1>o+5~#6F97=LV&v>08*)xi;uX$mV;RR2(m9YVj1l-_ z4>O_cC%!DS&N8QJ5qdKBK)FIIc9YNW=--FqO5>l@SYI;0CB~@LL&Jl3VXE+JM>wku zV7c39TQ_}e2~K?2kn>tJZlO6&PlhF4jarwtI6`BbN0zyC;S zQ#=I?sD*th@ra8`|O(g`CNCT_Ny1sk15}B$=JCphkSi%c23PDuRNAwmW-Ef*#m|l z@@bR3g_m#0G#YP|Mp?~)hxs^}Hv()UQo2QGrV*=4nlK*J8Hmh(N?<vuUhTv#;CZFk^hI;RGQHYj*&7(z;jMuAwqy3Txv@z=x-Jf-G{%X zWg+(cbH>3a=cBRn8Em&KQ=?x*mMFr_Fl>MeUJ(c$bPxa<$lw^L;O{na!p{}a=&#-_ zuA0U&aa&Wuys?LWf_^`IB?OP%8P}9;2ZSTeMYvo4!Nx``U^oJ~APlNUmI-iSA%79Q zMQhF9=dAv2IP(r85oQ5)XyGQWTE>q*0sh5T}7gpg6n0cRktikR9nP=is9DdKKv$glj7w;ziYoQc*!jF%fDy$%QMD5qT? zW<1Ik?LEDeQ-aNnj^ZweJ9*qYXB=>^R`=VDhBQ!p773P&Gp!i*FGjt(t>#rTDc721 zVI*R0EON*>uKr^<&YWM$@%Y=hs;|AEUmBH8r$rEdInU2%6~H+xu+~efswTp9YhSs~ zD#t=ga{dnOU z(@nKc?;KC87v2a$Y*qk`KwoE6il&qHVZ-cL=qvc1!#W7xPeuaSc~n3x--?R5gp8TuEBDZd&BI1P_cGsIlgu99AAs zDxzt($)lmx6F6JPj?!(2zIzCiYZgFWzt=E5-0Jq+}_&!C9pf$~8yjE3ira6^= z(Ku5jXGkuELAHN z2P4NcB%9=lk926iQmjPXddbR#;MI30-EU zHJDZEN`wKvW{!z_-~yB}(lI$v0csva6EPT_3mO}BYCGI^9Z7q46M;_ zRNE(5d?Dt`Lcu~hlWQczfD+VyO&L6DqXrX_v599O9yF{Nm1T98WDgt2E@sKET@v?~ z2sR&jrrX(+YnHOQf{BhQKfR-4Qm$h9~wt) zJv?zoXCO8RJD}RzGEwV)RkGz{`?V>$1M#}d?+z$Cqad;8>#cgdL9wHPHH0NeJ_ct? z5=Y!Y735oG3$?o?!`eT$rfprjBjtVB1=PZ@@a0zjE1qP?6(G15qC`oL3)E>fMfO{o z&u<0!5GQp<1x%IdeIQTu?Xq>b2s4q=Km+v(UKsGuxQSLJ^9V10j$UhxH=!)$%zN7> zHNIG{{uo)Y>B@Lh(g+)e*9xDBXih6$lUeO%L<_)~?Y=(mc#WT~NCJAjjX7j=4GbBas4gkEI<4zeeD6i`)Ww6Ok&BpNtMPJs-T!(x`;&y^eK*na z)O%+j(n?=`7mRQQ5=88x-N;?~A7@_f1yto*v^KGwfpoTim2wwkPJe!USukW(_|ytW zrq0!!f!xA`S4(YN3&%a?Kjcl%f6ECAs`1q=OP0-^l=W&C_?QwUpkkRVhvVbAM_!&G zFW<73ZezcEcjD+n$|;XJLrpd6kZAM0J_iw2x3Xlr6=YgrMYv25XlZ$w9n0cV8LW9{ zC+ovzVursb7{m9WCP zqX`k3uBCJASmKqb&vH%9Bb&mQ)8m^eGJ@=&^M9=4bCf1Taf4QGujv>}zr+YrOCjed zW2rB7c}a??$Y&IKbWKPDDoOPDH0BA;ea*ms#ORI*%KTn&+Myy>^rmAcY(gv|4RXPF zH6J5|DnHSugqM+x>zmocybyZ*^QC_oU0+;^Q0Y@F(ro=!PZ(iUKw&RZps#6)t8g}x z&^1-gL9Aw-PwO%hB_~nW`Uu&)Fmi`~jQKNH?j7T7Yo~j{>^w}7FrGf0GZ4#Q7?t&Z z0d8EH5{6rG4HM$HrBR^1-6xl{JTP;mbf7Fy%0&0S180?y+O>^(leO^X5+K3`2ts>> zkyjF=)DOCwaV}-R$IVa?$3i=%#``GZgkUOvXn@Q4ja4Z~nW-oqY=?$YBa;6I9(5!B zi?I}176+UujTTeCYZMjA6Z`w8^HL^%zr0Dry^%uAVJ<(Dcmp~n5VYl@NzmkF*=aO_ zY=6T}N^(~Dmy#2OV()k2(CyU0p&*M1+6fqtva~S2BjWeej5$|Vu^33Un^0Fx`uDA? z0`Ssriho4us?S(ms0o8T{sdF=aBWU(8Qkb>%+7lv8}kkkt=u{mz{3H!*~JroH*kSu z3=2)^aQyC3zR{oa-mcum7NJ)4GN8!2oQ#|)p|d}EdLx8A&pWB;TSbQ+})= z2O*77`FvN#$3ZPL?^KiGM&s8qn)bGQ3M4}E!_LirCDhSaNxr@n zsGw#2GmwSt-#Cx7@ZYI%&x0lMj?)h5=f|izB)g7aqAA#PJis(mKhOAyaci7u(3M|O zhEB5AMS=ze9$#aVche!8j~;S@&7Ld|iUC~|d=8yNFNmV2ZJN04o2 zGS-qz(yv6v9F~1aEj3z`m$DupBvWS1fnw7B{8g}&*Ze@iDkdo>JLs>re_K4(dslrJ zVQU(5Kef!xpqolkdh;2*ELuM;6!64dmD9l>s{gGiM-HnqbkksbpGy&Cfl- zy}U1?YrbACUc&R2`#v_kH&=sfzfpAkx7KI*Y6O3kD%V_z>Lfsf;&iN@U!TvRqx6n;Hmbz*A-u|D1uifSf)Df7xj)eT>u=VDB(6 z^NW0%?}yGZ-kcxglgLq6E4S93HKlal2Yyhr1~TkW!epUXEHFz{v-8v=w7lBGmnl!A zUEyrC4880Xab2K6Kg2%w!!A>l-YTF`hpGqj+Rn^swZ;X zY;7@ktAX>B@sLnBf0P|YqS+5lS^NH35@3QTq#3n4t}Zb73;A>=LLOOte8|+mHnuE0?wAaqI&YIt~~57Qh@7H zALV0}Yl`&4gD}G~)DHc8V+Dcawa<%IY-^=ne{MQvqlmxIC%lYttt%Tm zN2IX8zBn(HR8>Gs9^H_8Llb~{OKM?&uhbM7Bc4Lm9Bef z0<(*l&lS7-+%?rGo;Ac7C}E?m1IZPZ|8P=?cEhWPz}&PJ^z~!1KZIqesD(xCJWC|> zChiJlc6D}-f1%jc{S_<*2U~?EymDFk-c%?;p{Z6c!hd*J*Q=;3E9^O^zD|jkVU?X7 z!z%%IE76L;r$XMDKRjH^+hU=Emv>ak<)+py>fzKzV$eej7EdoX9@N_k{yRPKjObNI z-#9UVdxoo1n%4lg!O1%|`cI#@RX?Z>Iv(;dH|WE@f9KW~vKa6>Os5N|U{|5>Qzn%t zZKMbaY%+bZlbT&q)^n7Hj}BqS35z*r$;iog#6+3K+FQeYMA;eMeGDH4^J2rr0Ta9U z`M4g*lR^Ym3D9FzEI<^l6wup{~W(krVMD{=ENPQnZLVFPK;5VN0LA+vJACj zG_8!|Ap1(pW?OzwQ%)+MZ+Ome~CV>xbF8FPuM&v#vjz)()JF z+*82~wtU~KH*I~}VmREH_<2(gvWwJ<{qHHd%t%-4FDw>+AtI`q50==ZD!R=Z;f!(s z3(Wx~cUuiL!#%IoY6Yb%q#)I+yoWn)e`Z+K&*+5b1Mkj2OjQ7tH8udwsNbBlYA3&gGoF#J|c?)FI4I_kgy5o@UqLsP_@WgZTte=`uP zQh@E~BU4*n-fdx}VbL@VHmbVLFhfK1g;&S;e9DhiYz$p~JBKb$p9exnFQ^)6jURvF zbts4l`s62e)6q8pPvpORGdp(z=FOIHZR-U1iC|tFtcoV5%@Y%dWI#O+O)d!4$LDb% zp4*B#^Eqr4Irb~PLHJ73-Us#s%NI&hP9dSQ@e5 z{kpf~up;lez_vi;Yadya5glbvAYBruIl1Nm71a%sDj+3u!TN+7J^t;rV8}2Y)2jS8h{0HVQPDuw~F|-6VVz<+`h-Lt!Q~&lk@K6hC{jB?Mr^nlxO$XLo z<3AN!zmmL%zIrA2VQSHF$t|#^-iagH40ug-CI+2>7@I_Og{y#HilMekV1g57v?Jwn zej5Yh?yoO*+(hOXhJ%Z^e_P3uw3~nymFE!!PK0t|3F!&iOg9Xkrwo2_4eCrdYNkZo-uRIx~uB`B0)b{ z;daSTuMS=2RmcWsHxm*0YF(_vuD~BUu+HK!ryAj1F5?z6fA}MiiG8)VAVk#Zv#q%J zu42*!h-3-oMqF!{c|fR`$RKOr$H!NEPNKOW;NaY$2^tTc7cu*$>c`gXM2YY>5-|uO zD(Wk=CI1+m^HCuKKN`Wz`e2W{tyrS?MX7(eH|}?qIxn%%OULBkn~J6I8gV`_GDBs;=ti+3-!Iyh`>8?35;3rjS00#mp9o&4d>qj^{(-G3?N3tR0 zyzE-0S{yQ)J{f$tC7!usIf4b{H4N&P#`TPTg_3Mgf2pN%Ks3sA%>kj*x*-1@=4TQZ znxgt`*qvY#XY7%0lWDphpZ)}68~D^;&CJ0+U^8)N-6}=2h_)=tpfVm7GT$rcSZmWCR<}MJ;6Uv@c%9yMy#dy<_z)mE^0o1Zk1$jFeVpRsDY;!;gwFlFi zmpn7YXBWwlu-SYMNRqupZ0YrmYu=*-HeQ8ImgRs_kt~6b>;Qh{aN_>)N!9Tr zAddQaZc9{mEKFC``A^|#O^eC87#6B^^!UYEqIlhHM#ugn>g6_)=M9Txhz-#}f5BU1e7rLU;byu1Tv@<%#1+Hs7XwJM0iJ!A0K=%sI zG^i7y>}qY56-L5a(=?J z%_NV5tm73VtK+>qeGkbMu-wNzJ&Ch3y`Lqm@e?ZJpr~m$@9vK;n@c;E9@+3kxk&Qmo`x!)TQf61cC)n)=e|@Ov zm!mR!?$W_S8F36WvU7)Q;&`@144tk*)QlYsL6b1fRq zMBl@<_|t-c`ZWwp7bWczOr7qut!BiA?`^$Upxl^@@aNjmwcju0@_B4v^0zfMR|`h~ zLE27I&TG(O2=_S@gwKzx(!Z`ke}d%QEy&(^@yy=*<&wp5gWj!;Zm>*4Knzg|V*>R8 zFChn!P0&7BbA4;_bvZ^s!Y6^mvEy72Gpe{2Ed<|LOd zzgl9Y82kxwfTvA8*Ox!y)AjGo4ncfQ3Rl>#8asE0fUT&L7=h-EWg?cUMY794NLKi& zz+Y~uu01i+;wQMUGc?XehERBxo!Ie@a^HJJA&+p>upbc)yUbUdN5x%E2*7kXLg}B@Rs`j+~Ht zl4KQYK!YTWqpD5+nV)aD&G}#M;rfi;0N)~rhk4j=;zuu7rqtMyhAd6t<|MpG*@S|A zt+UKD`^&*AahXzcq1Sw_b#mM8zq%ROb$FQkRqGcg&E9uMm-xU7e-Is+l#$p|0VleW zT{9jxrCH8Xgvx&v94`6rSmWY(PUY%O8k*=c@(~>#m$|NkZvC}|^7|oMHgMj@sB~S` zx!UJC-s|ROF0HeY-R5HB_W2JJuj@K_8a<@T_<)o%m4+Hn0;Z{S@T0ze&{!%6fb~;v zVtP7Tkgnu^u$@Iye;@Yz_->N_`Jbe`9AS{=gRU5wEnNxHL<^zT!e$;4ijQXhAJ!rv z-uKTlO9AQslP@0_A9b6@*rk1e}O8DP@apr4HTLs?G@L3 zR<r+ux`PI%JU^Rn=NzdxX1!<4NB3f% zt0Rr(ZC;DAfBqBuDpUnTk}SNl(WeAAb_9+#Hxkg-+GoprcDva=n1@7$v7~gbn5nW~ zg6ZVvI#B;)bUpjEg<->ZBGXU)p%Pv7Nzuq!pjcd?=+g!aW)ZxA_2J3j+B`26S}~Xu z(Yya5Z#*)T>~?YkusP$Wj>iuoId)N0QTUma_wMHGfBNxWjX0*5=Lxb?r$`*z5&X@K z7q?1?fd~&0dHOFv%Xx#=2u@OA)^QH$$g$&avu#dZuI6h3NM2%krN&PX7Mt_Yz#wx% zUwRM9gAi>3?rdIw7)=R^2^XNI(+siM+V3;_^7OkeJ=O?=@qrRN8W~OD);~-(f+oi# zw7vp%eh-z*k5vg#MT!6vQNHQVk#b>N3`E+viIi}< zcl!&_hb6Egdv6OE%*h|vdd|9;m4vr}roK5kf2xv4c)$@lSKhW+v$axOfK$BvpBvp4 zc($vQM?h1EOrseA#p}DAQINmQqa%K6Iox}P*29Ybmaf&L+P`b0v=b`;jK2Ue{aTkI zv0>K)?sd+q9``iNsWFR(hFmVCsn1bSVardnWLH5tE%tA)L)ge>*ky|%a^T5W8lGCW ze;(Qef6~j6E}Cj=@1hf-WvBZ@rn4$tV~lI-bt(wJ*pn_m36-&VRu`aGX;$RPccqOu3|=LGl(D(!z3*&C{Zjk>geR?p&xH^JTvFvh83 z*jLw0={xRphOgd8ym1%q){`{!4HOepe@3P~2_;7lpt$^a{J9b!Btm$ z(K=O`f4itp zq8_qXkG~jUXRqP?TO@Nl1~Td8$h zm*12aVcF(jP++F%3JAUoRs^6YLO>Zg{%h;HFn}UU^qpiIw`t+D-%l7j^!tyAg7&7I9P zJ4V-q?A`ZgiPaJ)$;A2gGk4^sQ-Nk(as^ z@o6C*x=YRDzB>_qn)oLN;<83{1Ebd#=Z>cAY8RQv(7{qElJj5^fwUc?f2JtNYH%>z zo!fV~{Ovi=*imptrS71o4|NL5!RbMyH!b!U$q_hY^LDB(AN;_O6qUu8@W=ZAm6;G4 z^z3LfXiFekzJhkkMiH~MGdhV{Q2S2RJV!RKMG$N-{XGoSXjX)N^GxN;$GH!e%p%@6 z!$vcAX#1;juRo)){TtMEfBi>D)Re0rl@Hd-m>EU40dFB9N81;ogj|io=yD1~U345w z-l}VV{(6wj1_GP=CX%18lvO61pT6GkIx{+u9b0~Jurjeq)ZFG9Ks+oKLhVI<@pWMX zYTy6HFrn4qo?7NTr%5eLd+#w#t0PU0WBR6>@**rX%nC2lv)R&-e{BP6i`d)Va4=R_ zla$pjEA7TmLV(gat(Ir-X(f!gf8^Oc%UlhX#Wq7Tk04WyQki2Wh_Tre{u+v&*`K7gO)sA<@~bP?EP;e5u!tK`|1k9c)PMW{O&umZna>A zBky%E%=|WQZknk2JfD7k0TQH$0#(Ix z>ZM}Sip`6yxh8jYT)e*#yd_0#?ubHrY_T__#mCT^@I3r~e~TMj6v_8+atreE&*he+ z7YNF8R0-y`1~2}IU(Cr z4%fGL1p-Wpf3=jJ)(su-9jPZfICBYyCS42N#`py?LrVo4+hAz* zE)C_)E31^E{8!3Dm-gQ731o&Tj%Ph-C!vzNrO%*1>mtz@+vWTMd#4$xH%&2p)F5Ps zec7yoNHZH2(?L%>j4Q!@xLN{ZxkYo7*OQ1O1%%fif9^z7_!WPuKJmA}`l!aO{~K^~ z*sHs8YQM!&7KF&Yv8P#Y!U_;Z~B>6&LzAn^PJ`F8G#)8I4W_E^yV`O=UG zQ6ePuM3D=w;{Zf&x+WDD4xpHS&NwXP=MGxT9=F#}Orzc80_0;4DdPoXvul5gAYW&4 zO0lnjf0)k6j01>#4ID`@OiD$-q629fj*jiOGI0ZRHB&o0{|%8Q%Egk0B;(u0DnT*f z*SBBo`%~;y1^i0P9r2$`#F6HOlZn<#y+&3;+q1Xfb$w^Hq}B{@f&?isugU>lH?i!k z_LE|K0vlzjT(%oakkI3D3;!mTTieDc!XK7|f0p#7GLu@)C*mIm`4PvB_B`TkTfh~8w!MAnxE>`q{)F&&RK*8I1Xa`0bVzHCwD7Q^>DG!@uYk@$>8TM_xiM@ ze+nge%veisTL3?q_ynZ6A2q`DT*Si% z4Ou|^H(m};>d!U(vC52=fBSl;Kf8cc;-0z;K@6if66KT%S|A-H%SCYj zl{Ry`yJ}|dtk;M4npb`XNX@!Wx7gaO-MC?<74!GY`^J4yC(;$9^Z8p(G8;DQn(0Ya zawjy_Nuu~P`S}Kq&*6UB@PFTiw=`|hMG4czH`p6V)hBy(B^S})4S4aQf8jh^ zeDg~3?p9A5{hl}`=OJh%lU9>fQ_zFaKz6Pfny@ZDGnpyYND4ct{S^D5wld>b{^iaD z{LgUr)cA=&Hcs=COp2hJvS}9_$7D$}6E&p1!HJeY+$HA^B&93-IiTIpUPI_FDk>Jt z@a-(KJm-t7o+8=7?XjttX1A_Nj`!POS6vInQD@dy21<3?e>w^u&OWti zYMt2G=XG}%CE6%`+{{kO?|px@lAwr|6=q>S+Nu-3P#ZfG{~-Iq=FVaE3IjFF7cs*t@ONm zv65vSljxAIl;W|KW1**zmAgXBSOa zhLNJOs61BdFrQ<~3eoCm$tFU3ykSIUK%xwvo$%y1M-~&zO#A(he+-ocRRgrEuWIVP z|0A+E1a2)~*O`Opkog%}x{~@DE$|tPrcE}coDYhL))@y9*EVyXP4!jA9}W+SRyUGR z8P8plbQ$KOpcI>_0P1z%W2Bf6)O9vMk=vxsg|m~#geU=-fcZxAGCl^&TP{#loPd(P z3lPn7OmY-YLn~Rae;>-BA07B{Fhu5^GV_zg?0fyUyb5j?d}zPyyduLmqIVl>2}DPR zlbiI&AJ=i60VVH!`OTKzt*pb+(o8w@DVoerv5P-=4_w?xm3L=!RS;2RV;ofbkO00~ zl11-*YgLTd!`}S@WZwO%y7fq+;v-u^U%%1SU`@<&*GU;Ue=9EPmJgU7!dEsJL~s@K z&WV2FJKCkrqwH+Tb!2wLmaBPf%eqW7uT(pt<A)HK)&uld7zOTd|bT<&>~I{Wqg zeco4bqEkEtUQP`=&TmfNv+)9D^pkt^TmLUHv_`Z^4 z0a?-0sEJ5)e{a7ZW##%7D$wkj2;~h8n6kC57ZtlF%-yu4+K6SO=6Epe@N^aZ?xO8Z?uPP`&=NRIhO$I z`dT*eZbGomL~k`zA|^QcS~G_6SCkCd9E~KryMvPr$R2oHBj@rktPLuSlnsA{$-SH{ z74<@ZX;7^Du*Rn|5?5OVb8y6Yc6Q`HPE|^8YNEHA{4Ca$F!lTZgm@T#7f7lc4)Xc9 zdR}NdfBVNTxxmhS0zhny1h5`9Wi0jLp53^l7+=afPw^l`V9S2r#D8mkCv#?3@-R2# zO0OWhN=`Ssen_k2pl!bK%YWdLuq4A7jOPXiG(L)RlAK1^Z+gBEVxf`fF}DX%@@Y<9 z<<7{SxB%%WtiKI_DlGiP;jmamJciGC$1#5`f3t<9UMj_{685*CFyfOgJl9t1uE|%~ zxdz$62d-7F4M?0K4|M|5Ze>Q+Ze6CNU(2iK zpy3^2++M{nv}|1RElxx#CUrlElBejLT!6S8oV=qw3hyOQp9lTm!3S~vO=myfWY9uI zf1k%=yl-<3r1_8n3C6L{*^)=(x8r-GSElbuwsyRZJ249qYs(jWBWWL8^T8-g0eUB( zoAK-p!gMO&W99*VqxZMG=axcX+4P^Iy7h4Z5ssAdeCu`Fs3fJgRkUG_l(zGx3(zCX zb?R{AH=sb?Q;`E6{BPd`SLWuqkmYexe*)KtW3tDxbv`7@=>1ftIbKYMdO=yK(hu zRr<;Ej}bfCWAGdMGI$Nxwgxg->k7;J^7cpjzD(p8$rJPMw&$F3`^Uyyk2r}ufA8)N z*@RvhCT-(UeFuyiK(T;ySLDl^?lETmtZgN7ZI`@kbMIaG#HGMT3znR!6U zP{Yuqa1(po1_qv=^BcYW8-Z`MWS!OiUVg9rO3g%XjM@VtLm}B{!--^`ybIjyaThC9Yp?^nAqi>Aar)$qPQypENsB@_-7V$3SNVBe+8XA_xgJ zC%DKv1ax=CC!o(GHm0GuIc4Fd{N7md$bdVWM4~!4(wr?umPY6ppX=LVJC<6kr&QCT@ zEC&K1ZH1i`2Zd6oFJq0$j{2WgCDKunTQegJb|8ZZRIAu`+TUi-e`U9Umu!32$;@9n z_w(JVef}vVRqiaPcXC`WzIG!-$7yv^%pm83Re}FoEr$QF8ERK9!DE*)_jU?nTs0^?*6glejM z2>t?;{P5TMt@&+qe?-J};pn39O4yEyYVLn&)X)1i4n<+vT=~~$ep;X?CTJiBes!;DciH)5eXTFJFB4ypn5c=F!!o!Jj@|r29fl6#@Dxe{96ougDE|!<7c6L9Quc z#lQHrep)y44>X4L_&ysO%urt*sD1o8FPAv}8dN^UU>HVKy#T#R!fzxtJ4@q{eWMoO z%EVfaYp~yihd}26)>J1>2;b^j4{IK9j;l5k(MgfHwEY%HA#?UaS=>ok{fg&uRpVzL!B){X`xkbde|)_rlUL8t&n*I0{YEh&t@X4eb*m7OiRylp zK*+dqT^i=U`GfC4?+U}L|6*>)xn7b>r+2PSe<|I20ZP=){7l9;B`)5b?N%yJlb%<2 zo|eUDTIuueKY`dxo!_xnSDjZp z5I+i|e+~3-<8>e|H5BCfZ2YhV33_SUfg2H}v@ldk)D1G@*RI7y#bQzXn%l#k;)S`Q zE5j_T%mv|G8MQg$va%fTCkWzTq04mu97u2osG;&8jLGk2Gx$e-W9{r*ACq>Y90Uqh zR|dHbPBAZMhE^K7KCJl}0=*AUAarx7L7zR+@WF9D~OlN;ep zY3N8(?!UC@7KHProuO!u2JzCd$Nt9suFaLNx}8bOgErwcfA(|?XZGcq1P;C#O9~d=~?v{LNBdZ}3RvcPqJQyr)!R(uV zt6GV%)+(jfvy`)z_k_AKx=mpMT+{hciI7P0RVW{v5lc2!PeDm$WF@L;h4Q7+&^^OJ zhQ(<-a5@Rnxi@HkW7rXl0PIYBSaIODf5kPRx^Foug*I(Z{kA7;CDI1G1#_bx)!p{G z!7hGtEvu^b?0(e9DTeF){|3>&8u)>)xn6+0R{f~F==NKV@Y02ayTv*>6^}-8GfHTQ zU?n?Azkz0(^R#GBOsZLB?ePUDjbc|mbYu}8D!I@RSm7^IGn2qTr-UpCxAmU5f3N=) z)f$NL&PwQXdQlrn=IN`Kob``II<#H7ld+1RzMW#+g5oBVnxbY;m3FLZCvwj8;Ftmb zvK?KzH)bBXg%!r2Gi8W7jeNIkAQvk5Ap?Z9BFKt5gdtgd#&|@eMDYWqi&kVP_MPsI zeNCY5ShlZYl&eCb{FVc4p$@aVe^=t*2-FBIOSwHuRq`w5>7Pt+xjFqE$lE+FkY#=o z$W6fglje(H-niMc1+~;4QDRXUf-(ZglaM)x`3J!4KKTOl)dK7d=1@I%**UW$d&T}) zx$Vj7H<4=!8BQB%PzZ2vb&pBNFyXc9i-}y>j0SEf?p}b1dvT)#mz>Ixf8Ldmg`z(v zG%vR&Y-m?XBkWp#32TxLl65D~i|T%LW-UYK&I=B;5R-+`qb-?Ht|ghPi-b>2XP1En z1W$xd4MMe@w3u~ptR>wvujb12tMf&Xs_vr;ka1hn5nr*9wXP*#jb#MUMhv>vhov-! zci*Y{iJ?bJ``9Wiy`L3re^^xA(4sf#W)tqXF;doC*v(i~S| z&xhJ>7<`FioUr<<$(W9qtpvJ=(s2kY{5uU8P(idvY_m$C&Q`%Ie_BlZ_uR8Y`ndQI zyEm@1XBgSj#va*y{uxcOL@v|z=?W#ztPsKyD8`jerV;d|WwkJ)<<~w821So)$1=)! zpC8$@amKB(gVfX}mj;~y+Rfudv}SbQT_oMYjsZdk4`!E~KS>Eurq3i;PRnuZj~Z+# zoi65T*07~*G+lr)f4Y6}7a$&lJ~=4~Px^12i&Xn+ou?GHELp$sl;Ho#xFhzP`SO6A zf^JC1>zFQ{E%E_7P(~QsDs7JVv@<`$S=0eX;FXL$fAL8#{(Bto*rz)5#-Ij9 zPkj)u(w7YwU4Z&@Z)OpibwylL!WI=^|A{G~26vzcBn~H2M zztN)efBvfeViPJ7r-ex zB$#cA6j~;`Lta>MVn$_*{@UN9iQD8J2O$hU!_#ol4VXcphq9!9!&SzK`jTK$j@WEYBem|+R*b5(=)AgwSoTSm(|XzfYFZLy$xgTEoEaqo6&u` ze`@V^X_l{Gjc?RdOiT2uKu*gJvRBF#9HOWAQ*N$-1KRRRx2Cjo*2|^~5S!z9Ip*i{ z#tTrdP~ingpo$8P?lsm`qj2V~V8;ogU@#DN|M}e{yQDoIiN{#H0|bDX710b(-&T>CME;?0&b@e?0g}3U?>=%*39ePojn0 zZB*8+z*kReyS<>fc8^A!@4M-w;FW(nqnpUx0dnj5@_`wt5{u$6WN~@wU*79$e}LOg z6cc=Q?*hcW>oNQm+>f46! z1<3cE!EKJ;Fm#00TdpaD1}#i4e^^hTK-p1`NpNV4M(t#w=mJD%Kw8yK=-L5aZ!xQ; z&*ppIHvKWql(VxY(e_H@2(lt_kCe43jlBTHzPvFDxrWcs(ND>@b*TL)9EqC(DUU>j zLBejE-u_8@2*$foI0A_l1G4Y5j|PUzDUcaGm=E`N+4ajr%MSzHP+6rxf4fk*;K4eJ zC1KVJP@Ebq)_f>8BI z*43Rl`GXI^&mP_sF`*F&NU#6?9AYO#2n23!?{4Fd%}aHt-?B%ZDEYkfADbwrJqqE^ z=nkbj20LznPa=TpXJPh!Xpt;DDjC8V9(W^jb|Cs&rD5q`;aoi+(QXWa5Z*PDHA%Ack@Qkjejul1!+7NG8I%T0JpD!1Y*BzUWv z^qPIIB_6jJ7!t(y_^A43TMqbbe!!JD2F>xOK0KJVwpVKUe&L@eI^>>r-7a>^>p}h} z1z*qc%WjWCO4X}=f8&(&z@fQZ?j+fxCh8T*GZQ}yJH@P$I(`9SIIy!OCw9)P_+5b9 z^B;_+jmn=WswLZa(WJjkzj4K4%rN|5uj~vSHSNksftCZZP9$6!>1d#{MryBRoc1B? zKzTzUJ*R&M$6*RqdJ^UInX$6s0@N!T?Zw6jc=dS^B2PL#e*u*Qb(BC#F7r>+)f@qn z%}Vm0r;1+16>bb?{X}m6zH}%S5t^fC?V(J9WUuMI^RwDdM`nolH zhmVOh3TH4~HuKdJszF&mgd6p6V1@JO%jgVKgXckRcIoK~`RT-Ar z5`LnhCEqr6(2Zt0e~s~o&e~u)KH%~muS@wct$O>(f1sf~>NqiXUAiMV{s2o~iCcGhKl2W%4Z-ptOIP4VxFBq|T@d5If*%yzr@g&VT=hO2X<)@{yQI z{|xtKf4U4|UUD4GH;T;pM=8MOObuS4}T|#{KJ}v)!g)=dVNQ1K&MP+2`WC zv=5)}pi`2$!p@iy-Kz)ny8wX@cY(kQ&}FYaf8>>w6?mZif_wKI!ek+6rL`=GiDjHW z{>R(qFaPCM$xp)>IxaxpP(%zaD;>!WaX@7-4!CH={8Ddi`)84;76=&s`9^n*x6H4r zLVWlVR4FDSyD8)=?|U{Iz<(2(32kNE)A~ZOo=1q{-^m)!uKrpm&g>Ez_>mtdcy&aq ze=Wr;-T82AU`UBaZ$lnIK6^=>S(gWfiL$86F7P7*a`Y>R(lg8xr2eeH{kD#xJij6F zurN=HB4xq%IJ*PS&Udbx2YZTVcfE7qZP-p16dseLou_Q<7DALwc@E&?=~la3oI1Qy z1iE`)OPbwaPCdjV{jUSUOr|ApxA0`3e?`5ioKqCtxTAjU8CS`<&XT6R&>3Q?d@k#j z-dD|4*biP1ZTZv%C@_l?AlfHx5N}T=Q~dF~A5$f!5zrb|1_=+5aMX7rmI1r$iq`?4 zJeS@`5-oml?{W1n$L-zOO%w+ZV%yrD@o64%r`pi(Q+IuSI5Vwkp}B%OSXsEHfB!AJ zV^a+x@=SRJC^IJ^``8$VAB!9Fb&$7HR2^D=pXOX1vvmxq$?2Y``Q~%Yl3v>Fv3Vq4 zcvTcOy4mo@=M!85iM!*YLBWyB5DH(CFuJE{)u{0Z>vyTVZa==Bfw7-e8sFBtGNc&o zy%ASTEsKnn{&oRsF2v2iGZ%gYe+LO*SqEk&68h@t7iDPZiU}8>r>Im{hA4jE79a-x z_F@*xKk>nD>(|S~9{Sq13!75dAd!}wZ5`%IwDs#o!9l4l@2SI>MxHEPB`jVDOt4Ew zczki`ez2lmbhD?i&0w6Gxft}dHzems>F+b$N9Lg~6?d8O(U+)m!lKj7f3EB;nH{?M z+_js>_Hjm%(gHEECG)|n6o6scVeseS8)e0h% z?RPO3&lJq5XixR*A$-B8-J{?^`zsJbs6lm?h!^b)MU%}OTZN!~Trj$2IciGy*1Rqkb<#yB;}nK_h`}4$v6jYC;bzTDs?+uSwrGlSswm z4SrhbL2pV%JyT4bHU_d7A@_RN`)1*y`Al(%Ar{BdKugE1hw+(_A@Q-bt>xynfq zkWvN&V~O(>a~h5_kx9L}bY|bMFWp7K=cd% zC)o{_WHe0)3H13gf7yJ;uNo_vlb_7!+Z=N3vQ#taHikQZqYmZ`Z13G@m2F~IdbiY)m`afI_T zHb5o}q$k$>f47^H^lx&YRH^g4L=D5b3@2#S$svqM7{~0vPk!S9ZN(Wr3Ja-Lx#Go2 zTaPy5YKsRKnpXl7(2N?TGNgctx~TDD;$Qxac!YkytPA|=p2UqASbRZ*WbiXLsE7>t zT_qjuH|SG~EN&_-aHkANUBQndaf(UW6<@Pp-qL2tfBw5eL+>obYtpc+{Rwm|Id8KP z70_2!{Yq(*hzGkH_=wxexIxRnJ&VLsNwu4VLz}XkH``-Y694tV)FFvOtcS#z8aaf| z|2WFjZ5xtT%5zj&H0bz7`GP>-jH{8Fjv0@r`8+yUBAHKo{pS_+;Gyz;l*odbzs^a- z^ntEcf7Cej#vGnxg!$Yj-CS%i<$Zc({D;rKchwAQTqQ?bU&1OyxLJf_AS(SE+})A{ zi^~AftP6N>HSaNMIU~An(Y8PiY|zCL_e%tUDsmGyzA-4KjNOvpKV{xn<~M(^iA+QI zpa54BvhJC=G*(xMq=ZrOncYh_FZZ2N>+*zLe;!jds9|`>g3>2mfVk00s{j$l-I*2h zykt*i^uI)lYsPrj#$i#?TCbtM5pP2?Gt+2zO_^-mx4WZW?-mMiXgQK}0!0>YLBn== z#n0BUGFhT?=Eq^4^DJI@h^~dLIZb|_Md6$29m)JEo8=w7Uz4THuGxj3Kd70 zWi@>{&Z}CM&fgz~feO|r#^Q8u^Q?cJPQiYfhYDk&{ZN#XI4*0kGjOi{9TEQxE&o!N zwXQqvhxE_U+VkCtidZncPg37q}=#khx&}+VSKh<5)l3Iuad8>)I8*-MhZo-;tRG)#;NBERsx@Uf63#& ze1GgSUXeYK^ar}!7FB~pKOJXji)l51QL^Og7wckXA>N37*#e)D@~0~K*^T)k?1mus zZ@*o?X-DpLE7@-d-CHs^bcQiWwDf<-dTsw$a2Kq6)RH_jjCx3limyXlpK4Z2>C{Mu zsD_@U?5n<^+=Ppx=Q59rnbu)ae;N7WxCf`NbnPEKihqb;BvB)50+V0bBBN^nojzrU z=sv3-E--s1x!eZR$ML6`yEPgzBVSA=?xow};>jWX(5n!`S^$Mt_yFP0Y$_X7Z%=o zs&1yhXArOo(w}|1JpTcRKfC8`xl`;Fx8H4C2Be*s>IAB^ud-8APkC|!VZOcDY)`xZ_OyxpvqJzDAdJ zd$_d#aF+wgB-w55JKH=6nuK%Ki0nFm``G4Q5`U=andH^4&qqdn=pAy6Ex@ZK5R3M3p9< zwBa)2wkSP^FjD3R$+28Gp&y2GaN>$?WuFyvrw@khJKZY2+Cw)&XvPe6w>=5$^ zf`7EdLO0LZoyZTN4hStj4~C+OU;k`+8i!d%Wk5dc-#nCdX?1cP*ufCoOpVp+u=5IFA% zjr;(oSA(V1%HE#~rSH!Dr(>af0Gn^&e)KSW5oA#FB7wz7z0P78%+ww7fjZ~f;+a5P z16#JvsRl9te}Pt+3j$_&kRbEWDziYA?=f`2X8updx9a7tFW#m>4P(A7_EFW&-$NoF zlZd@+k(KWZ#(ZWcPX^)5d$-=54*H0M2buw7CvET!stqG_+DWbyPUFqUy6Vry1iuRqGw^<8W2|Y3 z2~`_ve=7gFJEXHcQ}xl8FG|@Xf5Uck1KZEvqq%+8>7C86RGC?dEO~IyEP`-5Y|zSO z&EW#nr_FD<_lOS~@ytLBE7T@)@9(ShIz5SV6hx8Ce4p%<8pXd#@gVgq`@S@9ttGNu z^X+{8Qg!`5v&Na_a5pcXVuMPLZWH4%-l|pef2crTUD3H?A;tSMb#+^jP;(CX+G;@w z!_}s2%=(jXw_zHiXYbkcoFDXSD-<#gXpr}+$dOxr>S2z2=3}ZpHvj>|#`Sv|;@VB0D390bT^i!{S#TMpsgHeZBk%on* ze`=AU)<0NpbGYYv6yqQ8gP$+!=TU#{flVLSXv!`918nX7~b@62rx8%j~jLSKcDOiU@N)W*x1CO3Hldk zI~xT7i1?m;6fruvlWmtA=%>~(XOnkTjm&*`Anj;i;BTnN* zr$ws2uAr0g*?(rln-5GEAy$N1A zuS<2FmqmNSvQk_)Nn0Zh4&G>i@Q`X()f?`9a5ox`bt$>hXM=Bsd-?vhQl;TXUw?I# z0ps%FO(-7_hHqx~`I2yq;`7r^N*1#p`X=}yTL&uu6%U-SvCz>0g}3dQpQ(Z4FF>UF z&VH!S?}Uva!Qar7#=zPKpnt@yt(81p zaXdBE?2&c9wGGq$a6Zs2*v)UaO*OSDvrjeY$!AOD*mqg5T7);?N4~PHPnOTjr~_mP zpO9X1)t`MFx4$(XwV!ZJfOwyWr!&_zH-C$NJ>06LdlzT=U^qR=LJq7$`xu}G#@g|prrQ~jz{#o@ z!4(%j^?$;7p}u=wQWcQlju-ktjdyME4sYpL^jLyfK`$#~Ap*Y0tChesagv8((%zW} zm163K()LY{41A1HuY7)HFUane;QmM@8zd14-egO&+n|lQwgK-N_eu4R?6}^b*tWEKR_~=?ZxZGcF!OIURsIw$J0S>pZ#c)5HRY zc=MYPT5QGB#zE@6a;ew9ifE_%??61HW1BPBdN)|mV!--=0qHcUh8yq!v)px@(`ft8 zbx+yZS+${Z#bNIKa+aVkXl?GTMLe>x2QzRG=b{7*5TZGJOMhj0n%lZ>!6UF|`z=}u z1cTlKkI+CV#rCcGfkH@pOa$^9RS1mzNE~owYG&i#fR|uxhq?0!_Nscth?OTQE@oAi zR$O$F3U`0AM@}20Ntup=f=V^c+q*3);UN!6&=~1C2-Bw4QzHL8Lk^!dCAzS%*UXwz z+(=1*@E}~6SAQ{SjqU3{^`^~cGV5Yy^B=O|M2(z0L<1r(f$CH0XfHOh|CpqhF$R-R zH)aIC*JYed>@9f=tW4x>hp`&ain?VB8$ z*Y6LSwsiuJI;Hy#6446e`z4=UIRxS3>tuHn z87X4q7k^23aI)h;e}>mRvU(D+C-U*Ef9N>avS|_WWcrqOiT`^ZtCe@L-ANgXS71-w z{N^q|0%$ghJ>Z2`Pxi(ypS$KnJQ4|iN9CTTbgPrT z*n#Cih;Jy$e8m8N~3{+Rgr27%ydTnNt<}4#nf1+Bw_#A0w@i)xB>kC*w+56? z6|O4*&wJPeDB`Q__>BrT*P<=wj)jcyo5?qe*vlR@e*6%fe}7&s_-i(s5jiUcVSgG< zIJg`&_D_T)ohaAB9AN^15iIC|C;}|a_F2rz`f8rk_KSpIn`wtPj500JC-US$EL;4t zYkwf!3PpB7R}U4XZX)jS%(vMIZ-S2W0 z(+Ke3StE=|7od~3>i=p7;~|EhU4QS-sNSRRogFR97?Suuj?Vj^>c@-YZ<~y3Z!S@k zl`Z6wOUJbx=YjWI;X!mLMHS8%fCLPX~HwQtu8jJkh}`%ByMdJhxh zlMfq<{(58`6+y*<&y#kNNk@2eitbJCkcc`k&;e_?3ki7NV{LQe5647)&@k|)R@zI) z(KAkOfOL-vu%0Q_2B5e>79s@4R06}{o3*^6IEQL*qZ#j%7mhx9v`i$s=#wrcGUj+o zu_#LaxL2{JuYZhn5-fU{U7{rx*JzlpUZi1S^Lc=tD=!07!oxH4)De5Xhq@e+u~*HR zGITGK%RL4vxhY~n3}3jLE(nab7lbS<-WB&`+%f2LK5jWggMGf_vX6l^#x;MA8)~b^ zPs{5p@&7KA!7YCo`P;JGGggsU>b)}hN$S5a+WyI53V-N9$~g=L!C9z4crT9!5;CV5 z=q^63_Rm{budt@Pe}{+O2>SHCH_n8vg83J+hFsfM*}T+9@NWyDMZ(F9#lCfF`8dBC ze2Jpu6m0^%?dX2pW5Jq*Hpi^Z?#HF4zvpyeZxChh7g#J+mK;iA4(Gx`^+LI5fY669= z%dS-?;mZc1ggH_bb9d|Hn^a^LatrO5TckdfKij1*{nM6%VbLe&snz?1iF0Uzu>QEkh2CmpO{M%Y=3sz29}VR@JB=2!c@ST?2*)3?xrNk zX&$Qnk~P_e*|vb@gHV`7EYLuN`xu5V4 zs(<=K%T@){@d24mXea^TP8aJUBJB5dl68HU*ukmI>ri9i#1bRpq@2Dx9j?mPLi4Xs zfxEk0$n)P&TK<)!j&Pp3KMqQ~`~{Y>E|ziymy=6ZS!EG%oB8@1b}50!P|RM>lfB4) zF(yBs4l55UFY?k1P}o6b{PF@7z-Ongpnuu)C;dzB86y{>iwO2QuS*SomZ!@!t*`+M zQMUaB!;_<#=dVZ>rWi0KD8paI>3M>9DnS1mlp%Lr_3a=Hx(m!!YJEp))%q)1Uk#@l z%Oz7R0*Q?ol_aB|*JrObphPM983&2{IdHNVCVvsp|Brl+|K!KhnQ+mtz=MnofPW%a zcE)0SVmuxpzy92>2gy-7io~}I*9=?WJw7GiC z^};9bTOY@0A$XTkjshycATisR)qm4fe)y!DHTpQI)F?Dxd2iJ&+d5W9_|#`}HtTsf zFU>zw!bCipWP=v<+w*ts&C+xn8%+1DcUownWtOawgpE~#RDws#^J2bva{-bQ^+B*nOU*ZGd_|5|=Ah0>BCq9YS zao#w-D_qnkDWKaTDtbA=THA(k?R}ji7-P{rbD%S-cB=jLVg+Gi!$8z#_?7(DApSN@ zz7;A4HCair8&?fK;{v%}FMlECh90WrHq;-=#5hL&3Q3V*b%j33`*`EdY9}AQvQ=i= zfT#q@&JESetW5NKS6YyazjRmSZwZ$Ey(qL3obaoT>%j4!^h&PdyT(7uTQw(_5SE8_ zU<`lq3nL}9W55R0C!@PyfB9r(D!E6P&-KytsRn z?&|+@B>xDN;01V2WPdl};bAuUrh@V!M_n+pqcuWFY~;(pst}Imcl;C<4W^%*qnLxx z6)_8sG!IT)rwF``m++?uyb$VVwbOdvp@rg|iZ!MsLrF)XsVn2BYfQ6`CMdjRx508k zSuY!E0?n$PlRkIT@)tV9jj?s=d)la{8NJdH6>yyV9U{b}9)BNtB6l`>raf0j@g+br zD)`#wycH+^>@SXA%SnCxqgHI_iG-kgsNj8_hT9l6$6Wv_G(n28B$qcPZ3_@1JMoQ) zM>lYGz3Dwc*v}KTevX=jUe&KOW>U5MFM3;1c9>T6yiVLu6J6+!r%?mu_}%nU^bmw& zL;FDi{6ll6Lw_kw{p#M*g($8UN}X5cuLjdFV*ANQ1HB9gR+}j-6v_dr5_hv&d)t42 zIga1kTs~-DQ}Bt*k0~3gRNW%wpTy-vJ*_fPf$LrKs&PYDLwOJmD~-chO8uc54qO4w zZ}RTe88aqV4ZgFt>MF-uW>&)_7DITD-G!NPvUH<8H-AaRj_*%P!lB#<^uUtHT==CU zcq1aw^vUw_I)+76J}}IQccE7L?Mw9^*|$P-^)#m{qEv6KAs**Nw z`5PF4*?MSEzvp4@#ZQ{&i_dtHF4KrQ1d%N9rdz#a+u?^~Q-a+{JkrHWpx~{`dQ@J0 z*54&$<$t=e>u7Vj<;AJb-FD_8`l9R}P}Dc))UAPN-#2IDy+Uohydd<(6UR^0VET5* zo%~-}{W`LTEaDPkB_%gGuh_=>Ef{{+U8X4f3+ZY!<^ zx}-Pgx2HX^`jX*scX&#r?KkW_W^1z^X_id|27eVum2j~9nH_;aWeCE&Cft4tBX zG=G!ONmKyuf?)hqe|W8T;9@KGN|y#p-hkQUCyd|kx4%Sx&3Td!%6|`si%nD@V0V#7^vFia$rn}7bb!WqH;c7z zxQ&V%hsT9eFZ^E_gduxXR7|Kqp7MxCSuSL1ImrV;fu0DUzLff=B5xAJ^p;H~72arP zw}oW}6hq{CvLk=xk3@c>k?%8ML^O_t@SaVThGZhIzM`;z5kXXd#pqC6I!~8}_(1a|oZGT8?Oz!1ELJ3(GFbJZ1K)Y2tIFGO$W374q*XwM> zS9d>l*xBhf$4`{xk1zhvyM&rDJPac<^-+OyV5h*KX2q4Rv(e7)fB)`@DaNH65x#|7 z)bRZsi2Tqp^`W4z<P>SIHNWvJ~RT)^C;0x5O@Fz4}Lo+I^Yg$z;n4_|dJubC!L6 z4wsKJuXa;7?w?iQVVPZ_6Ut$FfFxstz9NRAC;A`&MTR6^5FJFqSdq4q3l}rX3ZCnR ztnEnG>d0|B6yaDt3bDq!XMeu)k1U-ni>h8;9EtLZ;^_+wQAsiP^1eL!@qnJ2`Ka;S zX$jghB(<`+qNZ`fo#X6#{*mK_3k5bG{Q0%$ip}hRbJu-o<^?wQ!@_8G=UKmb_OKql zBV&elQ%g~W1V|E=dkXom+W9pVSQJ*6jOYpG9lFqIFB13fNMcO1fq%*QaG7&+R$Sm) zgqYXu4*t^M%8(1+$NOyiwel==?$BBTGI4Oe{|e*hVFPH9a1HQ1iFMdvNtA4LY+38< z>fhD6eCNCOlOoT?UX7i>=2d2!Fd^?1p1zJ6XPhl^B*Qft;uid@#DYWtY z;)f6YqT8uAuV-fq(tpzZyO;Z%R-f;CAnL*-74U+k2`^QW4V}JI0!fvG!afAjxDkH% z=kI2t{Hq05Bi=-|JYG3jx+IC3bIw>saDf1Wn!iO@5DAq@6xDh}|FPCq_=Lz;?$0rn zqi#m+k4m*Prh0DRy`2c3OiZ0<+lk*0`vmod`V+McEN$1fXMaDP0^Z{`#Ob~(IIWEIPAj(yIVsb_1LoImFa~?#ehl9x_Kh(u;3o;BU090jYARE4(t` zL@nREdH;qJ?}`!0!rAB(+j)+R-;eA$GFM__4qd13>;4B?wJD{Q`ey5Ds+A5nyPv+g zL1wRhqA1z%UVqO7^lcMFmk71DaKED2)Wk0*DF*l0=+RLuv`6~v$%^@(RySBhyVP*` zX2Pr;FP^EG{+95=nTeqhdL}fVIYtg2flF(rrYKjThmW1_ zOWs=!Q~1PVPWSxY>BNuh>%*G%5P&fd)NvMQxln-249f{SSWdiv}#I5$8ASOiA6{e`WwV&9BB-{fz+_PvgS zdB)QZA10Zh;SZJ0d&1Cc9k}ksWc#fRp5Yt9)GD`nFqT6IehI%yZv13WHJ34V{Se>VHoafCyPO zvvY+Hi)9|*I#-CBp>Htcb1{L7n_ABUms`}AN`F{IZW{;1zc9fedjLD@CFQB^UMABj z1ZtmY3*lx`< zEL3LDP3p$JL|1DUwi}ZzKtHn9+LZQ#?&1|u$PQ8W8`CSwB@!#5ail0pQ(TGkJ5|6i z!+%Q2Vip9u*udZYXCjF= zvgO*Pkj`aejQ==w^AfR!k2#ZSw19u^OG3dH&F&gXH(#-{6Oyg~pyjawq-Cp~84ARg zM8v-gD4>|nzV1Nt|2ZnI@eM*V^lTo+YG{?mm-pSeH2--}Ei-{PZ_|F6xyPKKlz-_i zJRM#xcY_Kf&(-@ChXq7Buf9pA0?emFn0)@LYVU*TO^Cq{gu;ZJp2iordywTmy0eiS zGJ4eUN-4saOpzh`=2qN(K&i@5%^&T@MiORk;^XCd-?EMN%CWG>0Kytn;GC||I|Pr* zoUROU+p=HVA+=RiDJ??5#3Vt?!+)(%h&WZJU?|6#_=w-f=-FSNYhO=g3YgWLSUz?RJ{-zAJ!okNHs7F}zF?nTJ>&(o~zANdrX@PMlJL4TU?(@I^3 zhrU5oncYEXLP8*$b-m7TBS%5dFSE3hbht9{W-Fkn6Kny1i&kLN3M*91nW8}MaYm;e zxbfJRPCJp|DLXp{fgdbAE$rnS9j*lXBi|-$L5hTih2J3*$!81{dn=UF`R0!IGry;T z{^kh#!-9(~F{bGZ!>{~OWq&8r6D7`>7l|_h>VHi$bYb3P=i_54aD&7KV?L7w6)mqR z!6(C~!WW_Ei~yqxuzlPMXp;lFZw5o2*$RPFLvXdU%M9GziUGWPcYY(?-gr?+1a7 z5m^t-^NB_s{msIqJJGG-7D{ZVBxHsCHYKZ1 zucsfT>ta-f+oJ^XS$tJE+-=S!d&^s{$TBJ=BP|K+bbVOmIg}V^Xixe*7{d0$!;~yA zh!y|tnP^sK#bJk!m$JiTWpbZ)HLyfI%fN}f$(|!hRp-2%g`JVV+*KM>h;`-gF%_W5&XMhlNmQV}m#ya#VKFZv zkn~m7<>t(pWNS0S-cQ<)cwx>jjl%iXg1Jv8A~7wJ zR%AdNl6VrNHm-d5Y&$WeTsKvALRQ2;WQ&A6=0RT-{VUv{)mGOD5W?HkK?x) z#csh<7H#JuM3Omwytav#vbwy3lh!p|Z4qG8+Wia5f`4)Kc>niSY)>anr0R*G#f)V# zkp2V{^P%a z6wPY?z4c@Fc+YsFAem>DBbOGW^T*_Jxo#<%N$Tr*5K>cv1qgsLsiV|2NHZhcsZl3Oo`s_ICd`KZ!h!GI3z>8-tcp_`F4d?Lxh9h ze2dX^`gx4o3)gKLy@TkeK4_}D#CNPT;@pXT2Y(evvcTI01tHA|%rpKZ)=SbwE>Aw^ zNr%c?oWrN*ImHm-u1-Ll5n=ly&KHXbv8p!T)XJDG0+gKwr#yaq&TDCo`*`19npN;Z zsBn|C#&ls5>8z7X1qjXl6&$2zFx;cEJa7D|&~=l%5&lR?udh;%^S3OO*#`5i!rkR& z8h>=}k{a#FkB|A*8VNFFlZc!&hq=Az{7<^Jtr~d)xq99i=1!yXS{{WD*n1Zs11Ju% z=7cxmA4wEc?{W__3H2G=y1m@hU1~?$J%%%20W^fs5yKGFd zuX*7YRUFw)27R}^e`orPDoSJ%^bKflmw)gZ?mcFUqoL~r>rccf)~C-VMH8@B)m%qleyZqlCfVgT)TVdc}HW ziUp19+tnI*)zRWkDOY9YeR)`_AGTj{v>4x4<*}pikVOc-+t*em*ufA2V@r_c0Dp{O zv{Age?vH~*-Vaw9(Tx|bmQ%2+lU`73!*N1cvuG3 zAYKi+vy$mEgYM+jq}<}xZ*nnX@qhNGy-yRyJP6uy60`H7^Jp5qmPHvZV4t#8KWNY*|B~^yt(lB)ylu7cb)WaHI=1Shm16d+1!9 z)ngorUk{gSYrA3H^mA0QRO`o%NfG0d-1)^keW)(w9=PhSlp5Zc>@G-xCV#ZNfE9RC!1kzzlMom+|3`?egY2PJ)dQ}fPw zE=oV6gu*6e=%!tMDhlG&F!O35%*>lq5TS1D( ziK=WlI+pFryS>rpX3baa^wT>^k1Erav`N+d9uOSpwbGVSPk*8U<{6e&nuEewbN!1Y zJFVSO52U+#Iyo8uA^ep_dt zH@MBjE;RThK|OYNb(dC!mz?VuzOL(lhtdZ2ww#p^f0`1i2nfi>{#d#`VfKn5g@%KA z3;FIJJ+>*?FlF4u$eS`d-;w>z+;T${CPJ2_`E;=QE`J2keobVkE4p*GEriEu-dqiP z?=#zj>tiBN9X+3QRbId5E1!PlLT*o^{dRd#6JvodD14-AMJ?6FjPpIF1W4-nX&2L_ z6Ml2Ayuq!w1nD~}jtL#2|0ouV1nLcrom;g032iWJ3}?B#P?kVh3)N?%CS3{ zJf1fia)BYh{N*Q%HF13r9DwnCOw9Cx5pQ`R#}4n@`+Xw{A}r`F(F!*fh&; zZZh)CBGM-h;~&$La{7A#1jKA+2c?Gt0|^4dStdIhd+SK!h`>t~Ut9~#w_bF4n<>}= z4U77VtD_u*=MHv_=sqp9FFdW}``;XPD&Ps1FA+H7|9$~;tCi2&&ff1MY^cp(baS=` zPJi^J@a*%yt4Y)Q*`v#C%v+m$8=QV>MY6eYyS<1 zAPqoM63RskJ?LDVTz}x_0w<*o^g{kbQhxzlX;OiO(M@-cKEZuO+Mwum@5)ey=6TpV z=sERWX0R5&lvs=4tMll0D5_N~KP_WUq2VG13TTGsPxyNH7ap9gPvz<>~F-KKow%PIZlNIaU6IrmVUtFNT z5p)i(5LDF0=G%2rl(~J z&q>XHP$Cyk9LZ|MKF2zdMYd!op?^Ffh8at#_hu!{&5BNDSGQpIp$oSN;sqpCyiYkX zN8B%JiJMe>%s`l5=<&5^y=@e@s$${OWsdvxRo{ryQf&2ngIohx|Hpn#lPyghIw`mVNZgq?sUDk57JnK3Z?kZb zJcbkP*=yZKUTtd3s`%efNT{ldEPf!_>U(GB>$IX%B7l%tQ+j+n(nkdn^<+tHBU?2@ zxR5(y@6x^29&B81fwXrdL3g-rYdfdV;0E0?-_rV$5E-%^_|DA_CazwtgQo(+(uq+i zjq3F@e``AjE!RFN5YzIclYgEwC%wNX8~NhX^K+%uL%jvvl~2%BQB4qG`(Adp78xFrLIofZVgh*kTF3yL)m+VE z@mTSxNn7+ASL3pd7YXaD=O<^qRw>C3(gsAdfP>w5XI2U#Bn=leP$}$xT$~vmXn8e! zH`CcY<=E3b{aL}AjeqtR_Hx5q!+MdXfZhY42hngSqJ;?Es^S~2eoHw|q5?@21FX&( zxjG>tXKNc`eM88xGW<}{d(x&s@{T7N zk^;kPWi0J%8NDFvy|OH>aLRl-_sRR5fe59Eb?!Yv6Q9!$!++7?8L*w?oWsmn-Czmj z@tUfUv|{*Fz2d{pm)R;AVs_6YP|?s!t}B4|**IEUPdY%HpUkEW9T4f|-lPJY>MrF` z@adhqen^owxJuQu242{N+N61>$k?q*KZeXNV9`^0tRNLQ#DP_{v5EaSnZA;`1G$7f zf0R(U{MpZ7zJH5MH`5`zQNf=>?7lKHl&Ry8Ft9U*R7|W+L-C^~#4pZ5@eEs`ff!+I z<;!xP6HBO%eW?qg`m&gw4SaMkaj!O)<|ymdp(S}_;KM^Wxwk(j-Cb)|PXx@^3S5#V z9cDgce^VbhJt(wOpZU(jX)#dE$mAn;%~t@}rvjN&;D0@IfC?n7KM(BX*V9B4Tg|nN zMN7^gjcb3fp6F~o&4(#2;P35Ar(L?wsCD_NmbAkx6=;HF$&fKyyI#tq)1uU>`3(Vk zVYlIuDzuMtKznHKPNQeL{0)?k^@{me>b1>%9RhzUQ3;nZH=?bXhzzO&6Z^WvHkLEt z8SXNe_kT0>iZfR_p+3IA+ocs9{B5bYs8A1m)OcmvNHXOD$)#WWcNP`!w{rh|doqaM zgv5?5^huuORlZQ5z1W(+gS)cinJ>iBNB@m!gPm{RVe91N2BO!U6kMz{w48c8k`PgU z{Y)n?W-|r`yYbBRi}B0Xd7q>%;chzpv{Oy&2!E!<7CxtKqIj>Avl1XF2kcCPkE|Nn zUM(Aa`{A@?B7T)OL0devg1L_=*P#BhEFCH5@Cy}42t!OCB4&Eb2@?=UDsUgEh8R-r2>6cDNoeUDINk!5^dAt-RDIUz>_8z+|-BG;$RtSIh;e% zrS8?fHPSJbZRPzp1RjijvJK7KbS|X)+J9JP!Cpgs7XDfEA@l{})yJp|VZR6+{Y`NJ zofdhcYPxst|3nL_Z|TudfrNG>npFS0q&A=wmbs!(DPuS7l|$z*$R4u#%$KQi^zU=X zPiMJ3B0hw||M~OA=^Ze2g{i4sZu}@Um~ABLG_*JbYAREDa;wh+!}PIH3=zrhIe$h- zP1(9zEU~z8oD?a*l{ceyi|_8iFvQc3?#G|H5C~#IpByfym|pLH+!G}HMSOVUYM;rf z%c@M`{88gT9IlyL6hrKL$C5*GSBi1l zjP$;^hPGAJ(8eWOnlY2)2Qi#xT7O!Nl~`4DpAUuo)t{&b6m`;B6@fh})J(Ch8{Iq~ zC&<2PR1uiaohE4b%$Jwu_-Y47HJ|&5ZQIn=T6a%p#_TMwily#XZnc{;E#iRqn_i&D zp9(N2p1wr-sx4wAT>aV`VU=R-nStzYGZSd#-bE*yJW_=|TAtKnKAY`r_kSsQ6`(mm z@Zdy*9cVfS2%lB|a^$7YxgvLkP0#0-q%Id~0cH>Y3o;01?e) zq`_|YlnQtoUfkjoPQqJLL%Q5+=i@`_rr5Alo#!uh&Z^DyYiR_T5HFfOcJbmy;M z$y#*&LKA-&bMUcJ10+L^lB$LENkhRD*=>a>J|jUIURgmKU?p@2K$)Y)s6gA-LK2gK zw!lE+g2UiL@ZCYaW>4U!;6|kxNhPdDGb5mqaNc;~D2M*x#uX~ir+?oYu$fLwhxv%< zt7UT`$K`Bq=08vw1emVD`HtOMW6r`moX#)f}M?;=~x)Wb+*U9&H{3Lu`taO^A+XzD_7(D*XcGX zgTBpYbASA7wApOGF@KG~SgC+FEW@MuKI+nY(2#H{HR#Sd^0ct$ZAl z(7(|$(OhBdc4JO&P-Oy23oId00lI+6#Z7S~(5l){1yS6meSfj3>;93iPLXSofi~&+ zu$$SuMqXzc0QMfnf(s|w2-cN~N_6Adg!Avm6o2-j#qC=*ldpQ)XYT#PI#JeHjBFM= z7dkHADHspaJAeAV0qr+tt|ttN;1x?&AZ$y61Q!US-=%i}N9Qd5<@H}5e!nxujW^Nu zo%1++15yFf#(&auKMuAv*N^kdlW! zW)~n9C6YV_=JLQqJT$Y1f!u4KV43N`XHY->QsEayB}7xDpI*6+_Il)?l;3v;Atrf1 zMVFO?#%m6IxaCat7=b2J0f8Q6JiJ{2TkyJE{cNIX_yLiT~6y}Nguf9DyOpgw85g#RQg%E%0uTzAp>rj zMBo0}Sck)c)7ty$Kc!6evX)aRN)1jTvT4%8816v`?&2MYBr79Rp!&lK`O`|25HfIz zqOR)>n}6vMAi+%18(?^8a&-odKm~5o)2E2&T=KpYLR)uFt|w4ZhK89oPA_~$mnoFI zPZUpAm>7a_Qtqj`p#5B3o&19$9$0A18DmQAT>SW+$eD-Tv8fZ3x-=96A5=>1We7qy zZRuU`n2&89zl%xiL($GKb`qRc2QC}PljS4ZCx0LB_}xk4Hq)8U8oy=njS6_3`i}4* z9t{i@p}4{5=bw0skH`Pc*#<1hmj&|B+Y*oX1DM%v+P%Ji2cQ$eUOk&$k}l|9y3ls| z6w5!p0!?)n$(B{G6!OQ>F1nBM2Dy&j3b$;VY<_r!y)z>rjHZkLS(GCr_olx9;UuY* zL4O$YZY%h@O@qt-CDA#9XJ6G~?>T3KppI%{;wL2AToFa&v=l3OHoq+1c9)pL@4E3w zF?)z&NtBtfJ!HLo&e526_3`~d%8T3EHbU5q{b3|4nD@fTAGbX+XnE=|#2>}2dx&Lg zDm5w?d{lNi+*`t&_uSUts^^yUvJH)$XLr|5}|5a=q|EbZ7 z^tDpgsKm)g@O~%VuC?;VYgs_7!OAjl)(>sjO`V&V?O`F*T<}TXjp&|_7%RRXe3Bt_ zEBWK)%=KplrE}=k!R6M%>>7OULh;J?inr&7GQq_`4gT3jk;RfH4ewP{uW}`P=6_)R zybAfL$8w?#>a0LBdM>?nrKp2U1X#wS;X=QujYpX5gFqJr?a!vGp80U)yAll2DsOgJ zb{*X{POEgeR&=1Oo6F}=e2yS|WQmVVyNoD;GX#XW`VUw=e$Htbb?%?-$OBi&QOO&o zLZ5%p9he>lf|>#!JrcW^l1eROvVSZDal~6)ZAr#r<6Cv^(R}&TITWMn!i z2ag#F!ReTiQU@#3&#ry%k@Qt-Kb|dHK{J{k%K5DfgG0YYRBwDeS7h~3mRO^8yc2g$ z_GR2T3ujrn;d+z?0%HTNN3ST83I_AQ;29sdh^zTI|1CC`#PbCujKr3J{C|NO8@XRU zEe~D50m!x{0m_FIp5|HEP5BEd+R7=o+}c+gci*$ihqd+vhJ5gJ5Wm%afX}m@T@C!} z+_@cyKT9M)c>kcJu+9GJ^}J(;B5!vtq&?4z>}r0+kOy~b5TvC6#Qo^V7$SPh>cj?V z6!g%AoRi$x<3tpxecHyNu799v+sqGVdm!k>&>Exq;JRVLt0yr4{*r^FZG^N*&@ZWIyZMK?FeFL_q2x*~DhtMn zlH03S>TSREN3EQ`lcWc*|tz-kp^~6BFI60rh{sPi~?{1%F_it(5|Z+F=Yo znwjG58v;4RWYqhFfW|!z&{D38C7jVkbBu=kHwW(EQ%jDX(CsIP1s^VtED5=J+(wHa z&;~^;&r|TPd)i%S&VMHVlQ+-0;(~O&Q^YjedsnSD=@3d6tlpc`OM9S$7DiawPb0^D z2aZb9pSk*zE=iqeZ-1xnmGZIC+XlNz=-&byepmM?apx=u9A=Rw20fq%AJz$0^J-V; z#U;emm9D0lqmMyf{wz{j%D+qq${G+o_uBrV+K8}(V@!No86>G!P!Y@HSD~D@CE1?; zbGv@)cZa=y&`y2juFGdu`tCIJtNhWa`?AkzE4OmHl=@*QD1XJ-zf=ISftj^+2?jGs zn=bKH-DjUm&83^Z-=BHT&5+>DQ7&Y7<+A=ya`NxE)*!kSbTZOARxw?Drz-Z{lBrj; z`YujgR;p-e;k;Gi=UXDJ&A*!8i!IUwZo@cH^GjUt1ZTI?k8PyhZB|nIq*~6_LjB`* z?eUJgf!Fd11%K-f_l)dA6AzA?FV`8^K8_eV)=I8&po*-{Rz zK{$>X*(2Fa8S&?BJ-Mfn&>^HoGfXidvFWjPyr1@Nw{#gB{W~5SVd;M%CPv3{^LDA$ z1!->QYwP#Zo&KxOZZTV5nsV^v-Tpz|FV~OP8rJ!H_d6=&V&+nK^GzGZEC6S0`0-Mh?zBPzyWvdt{n`G^g*5FSV8M-Suzqc)m zZdP!7SC@M_54tXoRO|hbQg-}oIU&{xB+RboZhuMT1eAAN9f}aJSwvo)bZ39GGhq&< z;`;)l@%WbuP63=7wEoBu0gUqKpK;-Wq`GWazqZR zWjG0e6;rZI*LA{M4%12(;mk!;U52jQ_(W%R#T@ss^bbm$B3`;k(2TD;6Vt095zRV! z3V*CJiR~3#d97rlTh9zxFeTTG<(gQIl7h~Q`pUU`kp>(;Od=!RQf`wEzLtlQU9NYw zXjHNz4e*96b6dY2n?Ci*xL}kkMVn-T+SwmbKSY0o;taO_vZiDCez*kWZ$uW){Z|pK zxFhU{l+C;PF#PC^+7*MDGPnAoLVoV&+<(u-omOBAIrzB&KkOCL`t@0qI+-uI7O6X_ zc5+j`WwVR&E%s&LQ?FbUe(r0!N~L@tZ%#Lp%;rQqmT6X34nM()upY5!nQPD!>3i)L z5+t@2*&g}mb^EZD$-lxVq1V}RPZMg{@^>5p`+LxTCP2Sw3DzLm;6VjltR)kjinJ~I+*`5U)Uyju2j^ z{fEZX#m_dr*^ZcK{-!#!jhiJ6EH+hnBu$wAF53b^pdSjrd_veic}2^lt8dz4W8Ebm zC@O4wyJVL9eq8x%Tx0DH>faK!Z3Z=&H|9&O9S|1*4XO+yh@*e63Jg9{bAM3st+v8H zG3VC_%uzr&Ih*cnm+IV9n` zL;m?#>jRwRZ5O}S%cAvyPXt^Uw0~d-g_||J^k{`5FL8sDyZ(erUDKp+g#x6?&p7I8xRK*TSYD z=!P-1g4Y6(L$4lREIF53OCQ))l6v`yEZwKxySwaQt=L=!km{CcpIO{3ByN`S-U* zQSr(4N;9XbiWf3RWPhlsNdNUS-rxOalH)5m3BBxFXkRMuxDt0_z}L{aoV2`vl>wvCjsDsI<%8DlTkbfn|~i*(I!E$oC>gb0IzU!xUuy4xKB5vVVcKFWA1F!lOo(Pg=Dy{m}zaCGvS8i{>3uCcY8V`6Y`03WNa zI7_r99SrDg&S!lIIx7r&mzG%+Zd~d?7>X^Y0`w=O;Fc9gQZMh0-eosf>H&+=bK$}9 zhY^spf_tGNfqy>4i<+6Vk}L``r8gg#$kMG*fyKkv^AJ8nw_!6`F$=$s_h9?eMSm=l zvR$aqrZdu0y1wmYLb)m^7e>OWybhkxNlu0dU~T7<*MaZxuzkA`-S z`Tf$!LWX>1%C}b+{dFfau0LFT?Wba{PJG~hCINc+lEwzN3A59`J6y8Rrfu0*FP7cN z7b=l_Iv~LnuU52Scd0kk2trQ1MXb|2wG9v{Vu-7j=`!;m? zti|n#)qhgVwOXB8-$>QGwR^UwnJDg!Gd-|avHMO$rVEU9QKKdRLqNR0^c{${$-wPe zdk=p~XuV~AJ^}to^v3uo9}cW`B*_d1-5Oa^ApIWrU^sYoX?phetr-W~^850yris&@ zDQ+eNtrCOs-N(hWYIlobt0Ny=4OxKVb5eDUqhbg!E{cEYG?FLC^PZ!^b{l3oBHf#_ zeM0drTP?4=D`OZ(WG)k9oL&j|keo?~>Wj6X>nHW$A!31#W{S1n)DtZmIX36CpK-n< zj(6?X6JnBY5VXEKh4G=>*%1@{y}0QSXft|vB7+5APInSzM-H0$XYWwzHe4o9I#t4b zZ@O>kb=`ksE=@K@u}2QIOQ@;R-+jI3z}k-}T5$EW^M%KsD-PMG}PObVlwkfp+ z-w!UdSA=G5rbw>rp;**8))j7Gw<8XO}tA5C4_CdAx{7H3h53FjCO1dwx!J_Us%NG$*0b;@P`%uB=Ud5 z{OGoOna>8O0Bet&LQTb@EBRVm)*Nn2jS6sYVjg7`mT}ma21z|_dYxZY_>?=k5qWoS zu9$M^wCW5>x%WLp4Gr1YHztS(+QxifvTrUOu!$GCz2kS$&hu4R!dT&_P4`#&&>||p zryjziIjEH39Fo~1L$-jW3pcdq`aORdN!lL&Z25|%@^i~{^*t7!Dm$UyePNcgkS}sy zkWS>X74%04Uton$CuukHOiE27A71MWR^F;uV?7OX?5Qz&ye%4GG?! z(;}SQ2o(rWig!-krXe+K_LrKb^Yj-#ba(mL_Nz2J>irkV*iGK|Ft+3b<==lG@Qa(7 ziAV+dE8}~)!%rm#6m)0eqNatNZs-d_b3rXVWdhrUM8W~>F+6wd zMf}|l%MwOs>4_tC$f?=LOXPn@;?T6Kh>-5Bz=z&JZ(<8?W43GnWzhUUIZG zKjzN63_)6vMu;ipq>z=yp+YumCgQ$U1LE0a6B|bH0Rt_`j=3npRBa1 zkKDWeXchPNyAmo zoJ2Dc;KE>cKNV=zNFUry;!z}E`l;X;93dU;v4Us*1M7gmPmB$5u5-UPck6{4iCZ{{*I%Stv*<;93De&lP2Qzl>NG55365;=;6{^jb*;qR$B!~oQP$SE8cwv zeBV7B7dIjw;@p1?_`cWunY5%I6_Yj*^!C=}G>}M~Q{Owy*^+_`ERL<8@gVKVm;sT% z8r9<)Yo>D6gf4qF8ow=@TMi*xRXO5x-=`=mv|8n+chr0m>HN_# z+Iar74XcLK1Ph-BoQZ>8?~vEQk_dv6bee&0-MC&M-;e88SzMxTuH{}%4tu}-m{0Ip zU?w>w=OMY^@MF`|8GyX@4a*j&$J1SG(be&xyRCoi#JR*fy;{STfxEGIjmd1v{__0& ztTv|YpcOIA74`3D9|-AK=|5C}S6#5G?OWTn#2;L6iORtH$X0%p=yai5xz=-%#h1<0 zXeN;tz@QhXFU!DRSQ<)w3dOhH)m|O2Z}%1P%$FpK*Dk+ct zPD{v_W)_Zjnp0!rd>^&$eO1=G!nk8U*oA+LUIU+h)3y8ztaniuh@IhTFouuxiPdD6 zP{nrjmNiMQaoWo3(Y{yjW6NLpzvte`zkXf`k%E75r*I&ciIcGiCQn=f?mlc#;6V-D zVN&TbdmG}_FvmbTdz)zNv-&T|$9f{%SnDuG__FN+Mvn4*ouhy>nIJK%7*KJ*dSgb%;7R9Cg zu6~nUJp0FDRk178unEDM?^f#w9GtNchw5z;o97T4B~o z+lxi!y72*5bbSrBCTLmko8_~iNwX@0GkrKyS7R_lcLu}iK@pr*IFxUAiD#Ou?Tmj% zEuOb{Fy>cBwWSuK^R4%?=jU$#j=%`H<}RWJRN)Uav97%~%vm{_;eUV1pWe5#ew!Eh zX1cq*h+b0OOF4!)Wc1CGVjME6v!Obs)Wa$$Oa#a*+Rlki^7OmA(oZ+?)VPJ7{>$bA z%h+jsz5-SVahdD_ygyUfcJ0n->g{CS6R5b=n9~&ZtW7(xI(4LXtRdEIE_x(aLqf(P zY9{fYT<`09ORRAL9U6al3c)geVqPvp>2OHZzE<>+eDuqf_?4F{ITQ2k-Z(VfZobI( zKf+H&?{?0Yc;3G@V8!tFK|*tp>H1|zRN{67eW@C#-r^33%i<2mNWDFnUG1SET98@g z`jEu;)IYtzBv!K*75{b|oPc1#ox6?GFXq^FXz;!>8eXdPUMYW+%xCCUU$f8ye_Fe7 z0g99*>&&A!TZn_H>PIX^nX2bGy9{W2s*2X_;S}KJVs7fqy`Pf_7a*dcSpr2c75}q) zP4YY0!CM5MHIrS_`m${kq_I7`JbXH+%e=6^$wfupE#3D&GlZKxYHe%)o)Jt<->>@0 z=LEj1Of7mAo+W>7IU8w28_9?+MDSjKXuHn~DJs^Bntf0%blmTu#xJ%&)RSC`mIQ8V z#UYE2m#%~87O0wkqZdbPL4Xo2jm$oYJ&Lxy(Qa9`mw)4vqKI5lT8$lNENyh$75Zy_ z&RTK{pR8uVtQQ~`R5Xf7iDf+|Oawq0b;xiY`28rn2cmz22hqjq-{}6SyVdb95p?=L za?A)DJmB2Y3}d+f^?<7$oGm{lE;GHDFwyzkoluwmpzF0iOnQ|pd|nF0<{tQ$leK3F zKpt^$Oi3eazV`kG>xa_}I~t70X~v~%@YSVGD_MR6W&sdDbK-UZifn;{@qu$C2_*6U zFz{X`yTE@g#P?(I-5*C1i0>O0Afy~+HT0--_epEjC(WQ>b_d(BNaK=Nz3p8?{U@}M z42N=rqh%TjV*}P~iRMn*9748qhr&$ywPoGFWZj-?H_&~mXhFp%O|iXp#77b95A&f+ zKv_H8(&)&JB(v}^FwhQ|4q^mfWSL!F_4vX)oA`eUFSEjTwI|w3ng7X?ZAK8dW_6P&JYxcTJY z`h$O&1M|_FbF&p(7NWajV|p|z3pu;%4OTtgnm9@Yfsc&Dof{a=R6!Qytar^#Wgk`! zMczDJJ2QT)B}szOZ@&U^DVJH`>ueYWU}*t|+TY~)DmtU9tWhXjHkU6RW+Elu;)NbN z$js|exA!SvEj;SyU+Or^i0_)KU2%I(Ty??8KRd-!Zp^)=Ao|AJyo(zjG$* z_L@Fgt;2D`&blDQD*(yRqZDoaZKOqJ*>FE9qyIx=$gujBqwWPLG6rEY*ZzRQ0{DNP zX#r*Md`o{{>BQ%5kLh}bw$udgO4K6Og06>1h=)TaiJHAI2C@S2JSznk%4GIkt&Oj} z>{(rm(~o9Q#(GFX*Ql~&zR6XL{y7HfbrcP|>}6$~*_+P~6q7EgX9;>k{w*>62*{E+ zT2Ynr8udP(emmZjSMZ|6z4Q46Xz72^$-6oyQX9bl?rYfWWr~9__Xvo8m=OAGJiJ2B zvK6TNoBOwBH}VG!7S(ks-ad^GyHZnSsCd2gLTwuhD=<1FHUob}tK@b6 zq>#|Ip1_=;_;cC^*VK6LStC1pdaeFXMJ>^Axx-FDGB=U6uQ(lHi5;i!6M4~fP_{F5 zZ~WV){_~cu;lZ1G-R<5NAle1M0PO(t5aEfl23#su4b4{p9gB?d8#hFIGjdIq--DQC z23XaoaGebc-Oz9pgFIOW+ZlgL;m9=wc0%hY?4~fL3X{+*`N%g}?`qWyoLn|^2AE{| zvFU|57)KA#_czpK|O3;(5daABvda2Oss#56~=S_FN05wQQ1t25Abw}YxWH8Yd?h|YZRQiu#!oPB`BeYH zq?#|3SowgEp+A3wp6UE?)ZNKO1`}QHnk9&+w~%5h-Z1k9?Y${Uy7BvmEyD$9%9$eV z?6sLouST^S#zC@*Q{M}&`>sB&V|7L?`Kl^qKbVEBo2k=Dh_n9BUX%l!AMA1%n5;>3 zs4j>0R`K?>I&^G~O8v%wW4UcylWS{-rv3y-MbN)B*>Qh3_}}TxWnrSnWWBVIqQ-h< zP2ctFUXIVlVBLq4T)f#Ydwv=5T{5zKLHm`OiM?x5tHL(6&8c{pR+HDfc)ZKW*T>mH ze$&u6#bgxxY!CJc)&w44jY0A1LUC#AKm{%pjX$FPF|7EpY)`|g);;Ga=BUVvued(_ zW!z&rQ*wXmXgAge^xJF+N{~aS15*V6>dyRC;*%7-j#gMNVWVBUG^U}%;sPX+lhT@O zhn2BD{Ok?9&DVQ+9dnKHLm<0v&xok@S|qm+UQHPrpfHf^-%fr3dIlcVH|Bno6F-gf z47t}Qx53gzIf{W_`q_u1Ry$Y6FwmbDVQ7G{)7O7#QR+;V`Gk8Ps4qaSIqIt$#^#(I zg$&C#9^R@sT{ubhjm#JQj!^oF&?dPi1UM+Two|?nMJm%?D#iqg*OfQ0G%!m|IU#Py z#Xyxu!dYJd=rd_I5zh1UtH66=Tc3e_(~Qd-BF1L1QCrpOeSnjusCU+SO711<4ur^W@s>bsPMOa;coT2ey zB7gzumJ&O#hnofpadZ9c{|vwWU3%7G3g3T7XLuL)h#$Ua&PJV49QRO$<~KKn1)x9i zC0<2ikz8&Vjt(MyvRXxzT<-;l-Nuq;{Ol~rF4SV+(HPxu;w}VZ{;2+d`!tF8vquS! zVg;&a-LS?7P6 z6wsLjW%6~(`_ma?+rC*;pZe4k={lm9HE)I)sgd*+|RK+e5p*KwOiKN0nC5>mh8s-ERMdR#A;z`1yemzSUR2oq7GZvzZpY* zJ&@mer{eBzzpoH#Wi-pXyXX=Bax-%}>^7MWpvNK;{;_tU8SLN)k19NhXD>{0eoa=h zURym=z4B#47sCPnQ|eE$^5efEyR5!YW&~~!S$kk?fF|yqZ5P1ril1`FGkAab_GN1} zaUusNvWp9E`P1HO2~zy=wplRpItZ*_S_;r$!O2(KNVh!KbbznvYF66X9-b$%zFV2O zLxHApQ4+EbyLxuov!9NoBtP4QQNwVZ(Pr`O_lwuvv%{{Z8tbb1y0{AO^UTy&2`z^7-eCnj!e%H=jM}nR{?SDDV_%$`agfN-5l!d|xKhmFmvS*y#y@VeY{Z^*}R?#tD z9JM($6Z`c}yZN+{=+`BDp4JQF0JPi%nhqEVzB~wZpv~27h72X;SCoHuhZea-cCO+?x{-C95W{8m;~+_c3S9 z*YYr=CT=E)O2(5SS}}i>=MB0{6DDZD;#w=WKnM@dw4v zm!GPX+bc7dXB~5uj>*u>w#yMq9{{j3on?f^JlVb29n41&bJrkxSPA6e2LrqxN}AxJ z3r6~VU4?e|!J>gLhKH+}UrLY%k5zNso)26OzbhpZ$}Q>wUS;>l2o@l=TH7Q21A4pcT-m zCxv0tw`>ne8)&2SdA2IxT-BlVO^AS=x4Nu!;!4|l=V5>NJ<&^67oe;OhDo!!Fesd- z7f;`tn|t$NsJXj_rpCXH&D1n{6r_;5vck_>_ZySe+W}q=TpGxne$(v0;EZb!djX1V z;@RNG#(+NV zC|clVb;5s{p7o@9Y@SCG8!s_usk@f4baw5m>Lf3*ILJ6iJ2PgrnAm1SLLImeP?r#| zTV^T)(wG`seAGI4&EMMFTf1jP{x*}lq{rc2-Cy@y7kewZhJk86sn6D`*O!J2JFig7 z6A&DS*}s>X`La-Asp=k7{g$rjJ(Y6R$nEKZ(};hoD*8g^^sxwaV$AyB`D>UE-05iCd&ak&=W`hR)cQpb}|=~><5JpvE8U^Y zzw-?G8W%M1S`W&RAvcSQl$RHGh;;+EQ5qt5ylOJPDDMBGPl8Y(OaZ$ycCtQ@_6+}K zof&_=(EoPL(^2vJVX?D7i3=st-dgf0 zfFy$VyLnrqgae;KJ5r@|w)!+roJTE#-!CKa7?=Aa)&vQ&lf%YlVrTbyELg}3;a*}w zfHDr3ddLd@NRcO-$)+ITq7OBPB5j9Q$%=yIrxOe^aG!>^-${7#~h}73hhIauk@D6OhCjG{{hOxT7s3adnS3yJ=YHu=2WQx;DAs>76U|)EzJ^Z}$KRIdB1reC^Pb;DvCg>@HAj zE7wQgRZ>g2GAH6*7_rEA@Cz?1mV$q=kpFB4`i8!A0g9$O#IO#2U3Q=6wemY{ZJ7Uj zsbDRz>g`AQdD()eyb3y(ZWYxC)?Qb+>3?z;(0@i!C77j%&IFEjXyWqIiX0vHn)06} zdgck=J!)F(@^bgPgug`H7|K8|MtIRq(m;{43BCC;b3SCbFB4%4F9UU-HFSS#l-*Og zTgHYl<;OfdvnyFi{NwwhLVp(FPRt&}2og&ZDdGnBl)HxvFNmEH`{LHsRf*i3_G_aJ z;`cssRhWnvfwUBw#xMf5KM?nb+5LZK|EOY*7R=~4zxPgTO984W9K+ze$-Cn!)d!qRHA3c8vm(_Ts9^oWe zVEnbsSt6xdPvRx%{1N^!n3i(Q{dHWp+N2QTdM&ZCDN9K+`E!zE?7+^a)3iIwGJ)}70?K3^Y9 zn^+bwJAiR(H8^O>nlOLSC&mv#Pd1!Zke()6p*;nPg8q5kAF^w6Pq?ZoZ+#Z+^36$Z z7#$XcHyX6-WNwOs@a;P4%pp8W#(b32`P-4jdpUBqN_FrVw~wVburVnsPsxQZM;0te zd)6~yLV&3?X?3y>#p|}txFtwZ9~3KAIM5SqNAchFjf}sZw=rs9hPc6STcvozNVqT+argZ{2QX z9Ypci@7bm$|L27K&S_W6qn#Z`roruLsdX4DuGX#e2TG=^r83EJCBQ8=e#yN97>Q*o<*(TE$!$j( z(%$l4IRV@pK(KN5c|}_Ah^pTTczIKW^Lyy;<*u{y4%% zBf;%p!ldx%e~3mt3vX44dl-NI*2y8owD_5Pc2h9T*vDb-v3-;0T9(7;R(c_;bTZ3% z1%{V0C4fX>(cHE$2RZJ{d(F@2_TiG$-*2!)Qi+D^i=5Id$WXa`Nd)02vIq{M=D;{) zU~~||KSO`M@9Ct~(wIt7SbO}T`N z@-W&%JFBg9(3fT9wkcy{!{Y}gR?d9}W2fuxeBI~V=NL;h_l)9h^M^S?!o7X~ciRx3+hA znIvRV^}bE;-;!&4{>01sGQt0O2lPaf*l_{sV~r);BWm`UvA{L)>oH+dS5ZYk)NS?$ zwyvQw27bpX{JM>zqbL57&)AJdW1(eWdZZIL3Q6q)S-^p5+)r|{y$z28H%C&IztxV| zymNmA2Mf~|+X3Te;$%sHEMO)8S2CORU3{NfOr7gF(^R0^XE}1lVWdWNI(0?|^qK(D zXLg>p(1D<~*)Sy_$7N=}{r104+C}Vj#OEQ;(=R}8nJyECM5)5nLlN z%)pC2VEW(M(t01S!JYb8{NkZRzWd|wT5;-o@H4Uq3nJJ3OmBC+F11bMV@Ua4n3^w( zT9!a(?|LkfRbcSWG|d;4p}L`n#4-*ov7uLk(flKnx!0x9oiC87twt#bBsTOBz9fGe z&oqb9%iptqGg>8RZjc>2-=imP?Kd=9pS5-!87C5T_AqQYpOL89gTCT1>aLm8&WJy1 zaDV-NE3Qf*zwlXn_AAM!3A$M4%e(vn5_x?>mqDk}18XvX1Ziu}G5-j^o(fS1mdzVe z5WKjgQ0-8t`Hb&ydh9Se&+16FP%VG%iN?Tw;yzjD%(T1M2Jpm99*7aCmDqs4eQ$p( z>sQJLsWvvEcAsatJNt3K=e{9B-j4ZiX4)U^_Y$h;Pwo&|6WSkDBSc7CkKjE!XtHz# z+|xMCf^FwlSBVU#d4>stgjA#U2KV#jXD@9n4%;F(jK%eudMCgqX6&Jlp7TLQ8NvcAjCKtytX zXDyG3rZt?WL>XIZWWP9luP1*G5@p}L0M%Ia-`6oA@iY)2%L7k3IYfz@i5{)cBzdKr z3(&z6j*-!gRg;eS8~Ep3;(5gyZ|U`y5ckhBOXeX`dteTXFnsA4*OV6cMzUsBonc>e z*84D>>zb;;^^sd5|GrrK$dp>_6}V5x9Y1%X8c%g z6nWTlA6x%aV_x!4wdoxIMJ&aq#a8R8|L40`3N^KFYIJ|Fvy9n5s!UupkF?ICUy_35 zZr)kWj0NSKbPAD8Nw8#HP24&oS>rqlVaJnHYUHX|6{-*ge{rH2dpj_7;5k$HJtO~5 zHCCeG58P;0AXJnU0q_Bo^c=>tjbbL7_+CklJtWxAS78%%XzITnKI~6ljWVSEsw)HzJ(um^A%2mdZiAKbl81U+G~l6WuU6l<5;W zkH65mODITHm~K`_Jt?KOKvY#aN4It;@e1U{t})-L1Mhwy4w~9Z_cJvRoYWK))EZP% z7TRe{-1T5BH!aR~RT)s^&t><^m00Q4$5u0lg71G>+~ETK|Bcm8Gcp%1P9DY_rUbkp zbcT~*nsFGpAyRQ;T0pXQU`zy$W~8jfzDO-?_}?iY80MD ze9-QjO<^-nYfP&dUpk)(WS-XN2`Floy&n`3w?BDi0Czg61|GJa$DkHVpLnvk-oiNi zvax@UI2#gTkF^(yO4Z+>X5jB^&`2V(ngK|sU{b{c;xG6cGo)hI?sc#-*auPZjPICH zEX4TV0l(1YUrOMxAs8owi|j|D@Aq9?hkdV9YPakNJYt4_cBV{*@F&M^se*O>{^Cky zYGF3ut^hM4pL$z8BF)96U4YzJg(gY9ee-{)9UkrbJMVUk&`v)0|y5htANT2v+y>53}M(gbUN=DqTrikX-DuzUr zwlLo>x+WJOS~MVyXT7or4_hqO8g z3Wp+dIbmUaSsDlUO=5mo?dkmz;H!T`=~#!~tG)q!8eNFqfZkAb6h*M<1bG4a(tt}N zvs{4otU~ce;p;3IQIGMja8wtBpTtu%B;h&!$T$-Ju<%QEfMkSyO7^}3QO;~$sX+_F z1vg)HWWHOwlRnJr+r~+qFata`r)MRY0Wz>&2hEXsqP+HC|G^8(HMI*6&5wVM?U>+e zTkd`w%2|JJs(?fncmx0IMrX3N^nd#m;)BC@3B|!=uDXj*4AUYb4a4isP0oOUQH^(Zm-fmpN zX2v_v;o2`%?(z23$X-Fi7fXqRnfiL5Isv6e~lX`=m zQ*_)-)AFB>eq;GeES1K91mkA1!L$^xqv!m;6$%4!f2qHf57&PgdW}Zc!~eb2;#=DW6k z&dEyy1@jg0&+?x}d`Z3lag^z4vZ?roUy|DvkSU-}lqtB}WLun<(qGA|>1Q_?w50>z zOt&m~TAWxQEq{M2(oculGn3@nH%Y(qU)LRD8N6}y4#}~@SHgWB>VCUDUGJKs)Kf#; zuzgfUDd<3b4B-J7xDY16WEoQ6PJ^{Bd)582p_%&Jkzp^A_yA|*T1XQzH`y>O?tlLG z{h#kpJ5NGb zo{Y+!rKNOUsUNa>c*zUW;>p~W@b0Sa@4i=gMLs50GQA-RhsZ|g!MhyIo(m8Su#A0C zTmp}>@0fo}y6^8S-t1qxq%So-UcZ;J#{*J|EDp-xb5;L|1Vb2ss9tF8diU}HXJ1b2 zds3Zc#@0O3InTx&k#fZ{m(JF2?u4c07Ydus*l$aWtIeP=AF{@Ly8|vy&jWV(&k=_W zkfN}LKUp_Obt^J{2GcfSOi2YExk0rE?vhtje`|ksL3wQv*8v=MJ^lj3sjH2FT!5mN z;66(7e^^9Wo{ao@!RlRRcc+PSP{JjhfFthe1=vrYCsKqEMns=-l5bxteeA*`l2TN_ zZ0#JGx6)Soe%X)Jkn%td4TIim^3Mj=tf?=(qB=Iaw7h!(VykLbE&%HAm-yIsE zW<7tdPg>lcHRRt&WKz}6o7YK}%Dw@L*3p0JMzcS;RQebQ*6abtYj5zZaSH@BR2WJ3 z&VP@FpM|llP3V?=X|8LwOoghuzDU+sqerGy|1nGLtb-+0HFUD?=1YFhbV!*zNvnl`;;t?(fsL z8r{YojtLL@U){K6Ji*<#pi~4v)=xrX+FdYv4vEu@YMv=dkEgtjP`x=ba*UNn0cTlD z;_n9!51&7KFMq?U7-U0=jjDzdOp!?!AeI0mU|5>kzLbznjD`>)n5J*Y4c7V?M8UgfM? ztYZ-M^GwRs6$C%9SqPtB8@aDv?`eO!7aJ=xnpZO7A|0s_(af=O0BRKE69v0a`Y2%1 zrR*ZI!8}>|T4|D?g>P&=y;)s&BfE*e)cD-mK4ObuvE=!p^k^^Vd+vYFe9v^?tQ79j zI5JCUft|aCVGDDy0b2;C#7$BEc9q@>$xd#52%Q}y8v;A#*p?1v`9Jx&D92;Z0UIQwu&;f zaY2Kow?>M^p*qpAbkDE1NM;OvsSp5>(`PYi2!&~=`w=I6rt2kO)+N9i`TMv8?$b?H z`Yq=_(OFtJ%DmZogAv zV{L!_b+%8XWXo!qyDvOJC780~$2i=zk5LL6X+MA^v6=x9#Oz)qh%Eeh$58>8;+j8sf+WUG zxK96ORB*9Odt_Q{wn}GdCDS4!>FhtNnNHROBG(xp(w;E>K!*p&lumxKO~4u-vGJoj zt=}M0?TuJ_yXBp$I_7^vVlj<{+C_05u~NEcSm*!)LX~ioutWTNd%6fHCfypC80`M% z8gr{EHcEKm!*S1{CAVY*tzBcJrmQ4!?k{-1>g*CgbMgQ)6`0~`-avBgKihu6!?E^2 zJZ+pU`@Mq14P8*znEumBv(ZoHm(2G4&v=1JW1t@oPKJu0c^!X&*d{x*mnXe1xohh~ zLae?GesH=CzD9?*@_1n$D$>qahPVgF5zH9f6`tl~)fBB*FEy6)=K9oXb3Y`|hrbCo z*3!~KtHBa&OM90mMG$5{!)FuXQLg|b&(p={>VjcGwj{@|_Um_k$-3Jc7DdQ^;I{-( zZRgXU7qrum1P6b$QBn1*Y(}AYZ%wntImO>cS8v0se%^+!WSJKoWaySjR+HYU>hVEY z5b6bC!_c}bnWY{X%Qo(gY~R~gAMUPjn@AatuH0>Fk=jr+43P>ysWhX3bL%3BfBP}Y z#Dn>kA`d9n{MxUd6b|bprVho~uD2n|l5pjB@y5f)>>Yo9ol{$p^>@~z+h3N$&9F@g z6U7%G?z0t@5>$=l!Tv3Wdl_|6@#`Ljx5#}rx`omY*m|-^k}to05ncLP2%`Ep~D& zXq<1oF+zWp7}e#;I32K9$)#VpyXLs+a9$C3pxTS9J4!xk zKyi7jG0$*JJ^X3=z3^D6AZ7EmIMw2cW|kljd`RF&8xYgt0xm!d0POBmz=vc3@0iM< zc~WMEgeBFhH=md@LL@Wwzzkt)jSxxT4tAZ*ZQXx8aT&Yb$Ku!@$YMHn)t6=6=$}?eML`6{FU}XDY6^loQa!^`C}GkTa5-M2DJHW)Zn1(xAqjW>I#RYv-Ws} z)E-^pFzh3Y^GO-2QT;-ZxtRJeJb?69xEXFCPN9DE?EXoZG_f>Z(cU4ASzl_Dzv_#< zR@Pjy#ZXe(ds7+aHa*g0611*T;OF20XHS2S_NRPsO=j1s`2XejeblUIGmbYJZ1D~o@w zOS{WATj&WLqOiWKfGM^qK5!M*{V%nncrRzX8ihj(%hz^$(r1ch97;E?6z3I=Y)0@~ zF@!nHWnxzi`d`zBW3GGTU!KKIkp;Dyrqyk1$mNGsKUFV`c|Q-!4(^z9xUzq~R^ll=SdL_Cb?(s}!?kRjzZw-(rp%QgaKVNwhvgT_|xu>g|{9%;~JB(Ph4YuquCLcNpyc{eHS2> zy{w$ZJX|Mx_tVzum_Rj?m$LB_-279L?85>haj$54FF6ik043t_y(S>$>-Ou?RLJrd z*0{d{lktT!qoYERg2;xFPtG)w5=I`AP2p3uSKiN3M)JWZ0&0JcU=}4H)ey6ua`fj6 z%I0P<0*Eg6Cw=rWtq_fH>XCo3=*YKcd#nr+KFPx(IS{vBkhbcu?(P01o)h_3iRhkY z-QY4uhsuZcnt1`wQ)T`6gB*_JdPmYT2E7f4s9vaWyF2Jg<$Sb>#vt zeZBxOtX58C+1V_)s9gX+7ZNb>2foh^X5-I8c^sR=oQ+A3Uc9 z<%Pdm{XJy6=8$0|T(5tkre6zPxXS;2f5OzyX)#){uRsiay|R+|d$7ARg^g_9Ns5gKEk%Dm2kLqRvQJ*1OHTK+ zsfV}pb$vRVvQ&heFFk589@z*F3nVL_=Tfdy7V`OtnuFd-kBQm6C{Fiz%9G4cA*3h1D1B(Bq)XD^` z*US{RbdsE1_L`p_m>1Nslpkej_Y|NWvxzqcZDg30OD;@; z>s|LB;X0Rs@#sihBZA;*De-44Hb;|4zFztKbagK7qn5ZHN`{WUlI8ZyQsPlet;3{z$9n|czz#gO{^PLQyr|> z=8RYJUb=t5J(w*uS^>>3xS z2wVP#dGP3ITFrI=a~dOHW6Ce}Xtd%IfFdim?-?8bjpG+RrI#X&}fW$k+It0sL8DAk7KSqIh&~b|2e0b0qGjsNmzoaYxPT z`p^j6Mf9yc`aSvC1&Fp6kieH$-tUNQz@6F2^>;*>O*gpTDe`SrixA6vU6AR z3t+B!t~2YJdKRn$+4q{>Y`5!UlbU}zrhKq;kiE{aWX;re&h%gDiF;%k;t}CR4KCE_ z7Jw>)yQs6)EiKx>+q!wJc(gOcfBhQ9h)0)8MLUQ1jCH@29sWHTiDLc<_~Qiayq`l^ zSK+y%>@ZJ(D`_G*P2DCxjs#p;qK4>wOhiF1^+aM!|1Ue?|JNC|%u(TuJ;;9{|Czb~ zd6^}OXiT?T61D&0e*e1w4Mpd zTy^PJge&o93Z8U-wIsXP-6|(wP57}B-SPyfc4B-BbU6eL`Ye6ZP=;oMHL|ldxaR815BH(2Gsf56W(oy6O>nTtEx zSHL>lx&g(B<@UYyIyM<%JC6#?ER32s_yyAM7UW~kr(elu10cF`q(VGv+CU-mV1HJQ zLwQZ&*ueMm6e))zr4MVK(A-pRjcAMGKs5CN^l2V)04ze5CaLzVbBlk^OA|5QvX>kR zh}c-;%K2ZO=`W_M=I6J}BMn6K^arno>rPS_Gp{yb!JLSv)4DIec5IT8?$1>>ct5nV zVrt!5bi1OmItL{^WTJe)Yxh{Im_IwM1 zhS#+USVvav@Vu!2NIdGH#2AH!NUDs068uU#82;A@?y=&zpjC=IVc`gnnI;>st)MJpY=( z_|#q~P~t@5r0fC|84SER$s+Z@zY2%wN|dX&UtbH&eW0pX)WSQ)pMd*DkA1oq~))Sng>$hZQDx|`lI%{~^6fc`j4Knw^ zc^=dBT!0u5cglgJo>ni-Cpn0GshQ$g@sHIHe#mD}a16iv#wieg9r;8wd7poP1*!k~ zXV>y1JAAo6OuU1{EPg+@yBr6-Dy)UhFqk;-Gx=~h`rxGqFV)NDKj;tb-2eR=h_K8$ zoe-0wATD_2umgG%c(9Q zle<+XP$Hm!_y|=LaIU>>GILPrEpDx#@WuLh!fSCEs>g3YAhZ|}x_=A}M+lOxjhc}p zUjc;!OC^P0$I&b9i@zkB>7G5}7-6bZJ}}p;-sk-FlZ$G96I$1>(OExvz>POc9T^2j z)ib?Gy9BqLempwac>7HS#m`B8F7sXuV1X{1}45U}vD_4W)|2de`Yw~1iC zRcr5HZ{NmtU7zQhGv`}2Ea+7zEB0s4xC9o}a{*!jzy>fN+pW>vBZGTsMr+H&8X2-u zhYC`UAY6cdqA8N33s6j$%(kY8O62rHe{0B0z+jw_`9IzJKF>4#84c{SUbynhHfhla z>p>O5E>XUB(qpKApEzVv!z%SJ;pc=q^W;0_j_$7|vxd(C2 zg{}$tXFyFcJmec`gCbhBz$%`5PGbHKi-=Arr@`!hl!OSl?wwrw8@9KuZ?VH-k`d8- zpr4DihMcNmR6S?d1p*lpD&Hw!#Mg&1kMD-#C z;cWwd8a&gE@QS{_)2r*An*Gi#zt&$YKlUrAWDwV@IZo*?K^uJuhr9+-h%`0}ogR}Q z^0Vwgjn*d-I8Xk7jqh8v%r87sNJEINx7V^&Zg&PlWoXLKpJg+b`vdt*3T-&W8k=dI zg`(1)Q<1OT1)V}qEpa$EA!2^MH?pRG{SM(=WAu@YLQY7Km}m600>V?aA=u6E zgPeca58VkfinjB{&zsqAq?*3HXZ%CQ6dP49TcT>7AyH$Oy*hP4CAT8wN zj1hdJ2{i(5@9*BLwc*Vl^OV46_e`=PoQal4F}fDMF9W|ga;bKE2P@P;=9#(L?^V@* zr;q%k^>d@arubt~aqX6*Uu$b;139pPv}6&bwwS;l3O~=TrWCoIc=NR$S5<97o-LJg zy}W1V|EczErRQ$i(%zjJvhRJcGXJEvA?H9R!C7zA{Li6%dirr0Ig_AYPMNc=FZ9^nOY;p{{ zwJf5V)YH%GC=-r~=Ottg%9R<2L^sx#WtCf#)c@t?>iF7BI)C^V9%GDW8@^l;fY$3sASNPg;K`55jaoH?Ri>5@YERrc>bX1R{#7(fD{D=& z)l1E1{;;|a_1i_*x&ajS91-?^eKH9`4fiMT#y=63?hBKjzm)z3{^o&6Va>Ox-+N9p z@}^==X{w_%TUz|xH=f>rMMH!rtAGVTSkvLmB?}ekE%i4xgKt{vPl|`E_rWxcm|%$; zq3u-HbKwFZyM$a5vu>>@YGuGLnhI zL^q&-HBJphwY|jJ6AJEIMCz&7l&zR%Y4#gzESqB!cH`3V8W_q z&;$#m0jzMy9&pOPb_k7s=D)vD2&ce86TD2|g30f3ZQ~8=lS(V|%Kh5Dr10^vw#4T9 zaR)<4Y_4qSHta`haG%mQcTo@S2SO9qeqWwGv;oWmonjFK2u>#0c8p+jVqhmsemi^7jRZ|2`q~j`wDh-C6u^ zR#sb>aM`h`cd02m;Uj-6`ICS0!j9SgxpWsYDxY-@in~I8%8s>nPnk1v!#Ic?qKh}1a{?F*l@NcMT+!IH#VF+anyF{mj0t{)4}LYdmjF;L;z9 zM6Z737tXx2WJ8vHj+pY3+*7^0iw+B~$SZht@Cl4EguyhVocs5$pOp$3DI#37hWrc6YdeBlz@X zZ+Ay96C6QxoVccHZ+9oAVJ`gn_V?=duem~x@%jYVA$o$0EKv7oSO*!jrg^TbamRuQ z?o~GqbI-oRe{u6b5~Gm=3*4wQWC^8#@^Lsg275cPbl~)Fq_lwR-gNBx8GEU^Vd<9V z3EdZS*G-S{%!ltfN(0wD8NvMom$YSvQ#8|*R&dqNt}(g7V47J-qS(ml0rWVaIIB2l zJ8P}0H-GC2@)W(V*77WM!P0pm?)tT?rJ%Ry(6V{Jf1nnI6E*XUm;%n&g{FOn!>^wY z0fjRLHP8~4T@`UVRJA*+=*i=hQ{rdwH#!~eyyyp8bj;P3#PGA~~OO8h`e ztbh++i1yqA+V5h@6aWAK2mmpiJy!dXf#f}l0RT6S0hiI<2sD?PB?}CHoV#^YRMFe_ zedzA)29@qEkrI)R5D-vlM!E(VTDn0%K~hpmx;uuD?m=P(K>>joa*zR@^}D;?ciqo( zt@HWsoU_h%pS7=RukW?@{p$TTK>1WlR|~+x!UA3cuL0nG9nb)9u(AJf;o#ul;^5%o z5#r(D;^Gk#5a1J%5|fgDk`R-SkdaeUl95x9laNr-Q&Q2;($Ue8QZO*m(=t-i($W6o zC0IDPxOljDM0j{av}7b?wEx#}-w#j`V(Vdj!@=SPu&Jq8yDvvUkw0QAz0Wr04^0CHJ7-5IzElj8v<@GT8W7C zVnUwB4Fhz>b4Xsvx89LN#Pke|Ow4@z0uLTOl9HB@m6KP{c%rGLt)u(&g^8(|`AZ8+ zJ9`I5CubK|A74NJ0B~SX)cfd|*tqzFjLZ*N**PCS<(7OYEi136toqv6)ZEhA*51)M zI5a#m3LP7tm|s|bTv}dPU4y|9J3n{#e*NA*K%JhQUtC_HumAo-7Z!l?&$9ka*?-YR z1z=-gqnsB~rIMohwFKOL!M{2KnlZyP_1UyHf}x!$!j70*Rh4~?xe7edFNN+_WPO{5|W3= zoVF{HeY*^QO^A+0YT>{2sM9CAa29cLn^cbikX?Gck(1Ud70B8j6Baxu@_et?a&kP7 zI0nv}S%Stni%=GnR_YEJKiiAPOYbi76+A8i{3UDVWamDlTYdnHG->}wjE(2i@Mw+x zGXlz0&PCV>5B`}<)?Yos|MS!T0W7wWVgGNsQu9K8hNESh+MJInnRqj2v1~;SEQhz6 z%S&@xi|Kw8;uq%^H_gxOwCelq@G4I&*6rNViVbR!ze*ma8gYiWqJ#?BAbtVJV6_4# zRz8#RA%V`zt87EW73F@44UpVpcWuxzebVODkvf z!Qt?KD6&pGoZVzS)>DijK`}&fv3dQ3bcj6DrF4H;raeYbnR!osJ6a9y!yHYp>U3xX ze!qUm+;W$oWEb$oHjwH^B1n?dM}wY`!T9%j1n1AQ$S-An{{CivLprTs18mKNV(E_~ z=(LM)A}k6-kc;e(W{+#^H&~}*$J-M$XupkrFIn^2e$tWqX-E(&)3YNQb5!kWrb_L5 z5~pCAOVX9kV->;E5raMALpRm9eXbfLId){tFjNR5fCn7$u${!#_z8=?Sb7_8^GoN` z&gW>QV~vS1tON66)(9H`6wNl0P`QvVO$l}H++M}DJtgQB^IpT`Y-O;cie6lA&^^thH z{o8Pr9+dW<8^qT8wi$$I3^jjhjiwBWRps4ZhaZ~p;A7zkiHAzS;U3WvzhttF8#&tU1DJM)nFyd&qNo&RTl1y;XTr?o#qxw~Iz-|PGvXDGz=yX|q^b-kO z@FIbqv-r_;XFXR(7J+rHD`qMb$PaDHNKZd9Ei*#{8_sxk%`w)%0^9ixXH^#u77$4# zU+rBqe(^gt)uulf<>0EPjSt3uwJ5*qyvCAy-HdgSOowWXz0ro4FE@|-S<9Lrqjzfr z#@emJhG2E^q#|NW-%s6bh{j)}C@{~)UVcM`!Pvha*isx{5bl_ZuP1A!H+rSbm40Mp z*ExQGbC@eQc)kVGGj537G@u4E7~LpkF#haA4j00{4HBSq8BOvDZX4x)>a~#+BdG)! z!zNjF1{7IJL)+~;u?bvc;qf@tP%(QFRuOsYzs~RfFEX+dfCnZ2jDDYtchKAHf8@r5 z2SB|dx&!4+5eq8YXAa3bERa~qFPb~oVHNz|@38C7`947`K(erCT)G7%iQvye_6?d_ zZkDh+8h?YWX?$PFA@?7D!L{q^q9phQ>#%*VN_N4bIGM_ZUVGDlj;bEy$#WzPa%c_U z`^@H9UHGXlxzWHCSU~e3Q--$hfhco>IBlO=o3RzxbS1-8&QOV`%Sby6`MAH`D{MJK?+g7d4|zk zuJ=13TL;nSzVjTy4lJ?w(n;dK(3c}U97m+lVRFhWyQ<6BPjH~j2YDER8oI1NKZhls z#H+B3BWww4UbOTag~x5FS?cfeqIfgj7zJ>d3hX}eNuP#Bscx5tvMkk#!k4=Rt1LiA zV5AGotdQF}q?pEk+22K-gt?ma*t4h#*9kvdWwOUgXUV;8_)+V%U+z0B4n|&@`Ce6Y zKI-K8{Kb*w$>2SJ)6z$U?r6~qQGJ@dCl#||*lr->T<5lC(e`yuJKz;7Rr_D>UYXlc z0rV6+IG;fu#a>)CRk_g`i_P|E86kJ_GTKNe-gB-vNwvq z0dFhekMupj^xNFrQo&+5Bxmcf!a1u_E%bfj;tXx6;Ei@H%uE z1_E_{m<;%OU?whg4>&f(Vy`@J4L{*p`}FCf6O$ZYinc9E?~c+oiJdU9--f@vZ}uK& z<_lMjexK)m)M&5r?y;lVIOVX#BaB_bcDCMto2Z z4pi(w2mLR2Xa|28d=gBYiVcqR&%pL6JO{OI@Cq=04YvN=ztRknjlbj2u6<=`V`2Ns zWhL9uEOydvH~De~zrTkr)ZSgONO}})i+ElSFQm3W8;nY7@|_O7p|KrS4dBhNm^h^d z_n!&#|GI5ZV5(N$sUn*Fc$;Z0|WKvN8Cx7 zw&WN~lw86MQmHZalB2`wco`&JNiILutl4*?_`BGD z-?V?s=ANEk04Y)`l!933`v+N9TF?~31&X}|JYW$F3H;?K`duz;=6Nn$_hw!@CWBvY zn!)S3L%!yg!@L_EPy#kLSYo-+uviqxZ|(|KG>Ht6)r=1d@s3F03AA;Q7+a4VBjT;9 zo76!^_<_r}`U{v72W3c=mX}%t9WoGqF9We%G&%#HuKq}t%<+_3CB3!mBL0@n=NCqs&$E{knsSUmgsiyPHKnNI<)FZtg!{D5a$ z)c&A{K1xls0ZsNflR25r-SPcaFi%!u-2025P`#re;~#eEOzv*T(6x$tRW=5bee<-f z#;GjWHc3fvCoH;GG-XF?SxNPO>;9jbQ)pmm*jt;g-UeA=#3A_~y-uHov)?tJu-kw_ zAD~11)}*dm^cCMG9tl{q5$^3tnb8c7jS$J>Ikb6=$(i32Vci3SVb1F_gl)0!AT9_! zReCVTa4;8gB~>_9Se#3<42?r11EzkXL8(ss!+a_0S_2_yi#=6}jv0}ECYcg_oWIu6 zgMGZhITH>3N%=)kLu8hBi)b>U!}Ob-ZK(0GB!Uzm)#} zKkD=l1GhW%H6u8!FAoOok#X7Y!1(UI4f2RLJ)%=;O4vm6ZWcMn`k4A4)y zWxZ6g;L0kGpZVyq&=wjAF7QQha~#(NFXtS+K{4M9hLZFwG~Wb5#@(0^d+?G-ux9(~ zM*Qf{I%qNsPM!Q-kBH-JC@Dr(2QH)VJhsFluEZ)Yx;5HV3vgk7i5@>W2ws`wAQy5A zb3REm%Y7fp+%*ePg}=E_Y$4pz^@*{k1TWpRG+xccTMaiI`V{zc4>%gE*=o)G zc`>>vzK~;yqPvm$8`4_sUel@H;5}FB)$lrlQbT;4^RrXJ-)NIZ;rPHdmZmU3H-w(t z4hs+ERM4$Q>pfk6bkY)PBYRgR#{2pX<+-7J*j^H@V$*mJMEYX*=ev#gTPjrJYDga1 zHfU6X7T-VtqVTTQ(JCk`l#wr$(e32O7$TG=RnmlgVmGaaMF z)d(;dSIqa;mV~RQwW73-#F2$IR>AiGuKJEGsis)cE9ablNgv*HyipYG_a-f9)#Mn~ zHu1Tz?mM1ZP3okk&zQh`?oekbumGlPXmzYD#cWm%bd3Qux$Q1-t=Mv^2?L9!QWZUG| zTLoW{LB-QTq7k+^K*W`QZsuh$`UVk3wtlEe-YZ&>hr$^=UF-iFT`zv%pF>W~Qlx_u zmB01;(-fdtjxk&8_Cc;dFIBjreb+g!f~4r(zw)MkwUm9YVfnmc_k@CpG`Zm7K(!Gf zNbqL{IY6)RfStYZIw4E-Ag7@|u|+iz_HL5`#kZO1!Z(xTrz~PI41+v7sFgAo;d2Cl zt^JATwPGHd7*i?Sy$hu-l?*E!!wmH;Qnhfl&G1bYTCFG@zLRKzUs(5fGi9fP;c=dbw%Ph zY!{MfhLvcIXMqy4jg+>pxR?xsAd6V*cawd%Ee0{6EK7bT%&#=RLbOq)XCUTX1Z14F zrptJf=N?c(7^H<5)CSu(@S>W%xjV%;t4Sk&DR5u>E+S_c-ol3nB5L1Wne{Eu@7}H{^Q5u!fzC%ayR*v_S0-cD?6rYW+?rPE`W1<#~$N%ZJ_>EoJz z?{6O)9IwU0Z|qUO0gW4VD~m$~<>c?|oXagUjZjIAV#3^sms_}!<6`j^*S{PUP&P+S z^%k4DAj-^%j`r|ZeBB=@ns=1tc%44e*m8k=;IEO3{y_}Me)_JW3^tmqISevq2j<9{ zn*L*IEmKoS9%Jf0Z*?KK} z_tj1~g&H~nZ{zDd;JkjQ(3?kUl=*0F4H}3A+ykt9dY^xx6^T!ULBlZHI2GoD<+GQ+ zVV^CZecKwU{CyQUN+DlLa`q+CtxWOmXZRQF09JZvJ9uF@CzNjXZVNxY5;ReNgEy>T zHl*aWeVlIe(F1yAF67fMeP%wK%Bq|b<5x(j&Q?%-+fX*+RDQ^Aq8Fw*al_RS5pI#C zS4Y(LDp9N7edMTL`7vcC&1A5zmb3EcK3v>eg6l90J za&x{tkM04(g_YqTDQEcFvu{(5IuB)GQn@tV(PgXt}O`oh)}Pu9WKIA<-?c?hry_o4KqawxT47QXIGk=(;6xXxJ2Ht$IGMaM6Q|516MKz0Qct z#J0|sw*;zq&QIXbVQUgZTa@n!4chy{qq*znkJ`DN*Pf`(3v*+_M}fG81p1n^hFJd_ zY&1~9M@kN;O!!q4e-mhb_53nt#QYhby;T^`mH$XMquczL&Wn3MY5d5h^E;iMct2|f z2&w{nMGu*w>W9l+cvFWhW7nicGfVcD^NQmtZba^U3vLFA$Q3W=(Vt4uRzv;@N=?7d zTwf4$-H=zY%RUZw7Zg`~{g%jZ)oGHfdNHN#QWx#yhdhMieSo!p&%{I21wldewi{D2 zOmCQey>n>*?g9J1E79o9GlSdmKIdAJ$EkV^OAtvn$hh!bG3b|E!Wz9xHa@{SuFr#A zc_M4d4A{Nvzyp93Yt|5Z?!VYd=D$vkst_N$32vTQZcLT4Vq@h4HqEtj@XQFhk?CS=)iCY*(DxjXpl;PPLsTtY7E zn5}00%u{uLcJJ>Y0243q&wIc@buF7>?H(9xleL^WK&K+;TM@r(P&kp6fnL!m5`iyg zAw^z?e*k`T){UtP2XQxKlX*o+yuOtE@f3SK*|!Xsv9-A~OqT#JFHLgyTm38KO8j{t zlXEMqH?~s!t4jdqNB5&wO5VWJ42n?o`oys9QrKpH*LI>c!APfLy>MNj3+ttiWz6ej zsdU*7&~PjQp_-9!hi#lB_}!!}y>u!q_%U>tgW^K5N5iBQ1cvCsS9V+ zBD!OLKnc!#fs*M$=u`1n#WnYlZIM{zOW^T-w%D$Pvb}8GNy6hVVdq|G(W@s%XU`u@ z+RmQj`>|N7zfq_E;+{fU@a`pxd+j~2EErBi)z0}k;f!LP$0~2wMrH{j>Q}1VT3Eg> zzR4Ew+)4jAg?s{0?I)d0Zu{?Pts3PPo)emXK7C{`_#N|Mw#?tsE1%zOK?aFZ`{in^ zKc2I*R1$xUXnmaF;5GC4`cRES2cmUbT(3flFzl3BcROo|2$<-;cP`>V{VhfWCU?t_y*hPWv zVl&Z$#sGfgK6zud&*CDk@QhhUYcrK4&|M4oF3^<=oE~|MGWWe#QrzyNL(8D-+dsQp zq1->Lm(JNdRDXBe!RaPzbOObOu^$M3Qcv8Bgi%Rx#N~!o$m9p?gs2>BTFI&NZuBfw ztk&0V7_jWFlY5INllWnHXmw|6piD|`1NkUuI+|f>utxI{H>l0mbZ#DTnCbB>`cgi5I+Md zjLcHEG+FZE-{hJ1Erjl5KUg#ZXQh{#hP&-cThA)JrtNt+Q8q0|h8_Qg8#&11m=)u2 zUAVq0f`8FIj;--ntu`RDKTF5LiX-%)2D@23`gr;tfL|RkZKo|W(2MO$9#~4zjKx(+ z=l;0;ia)?95q(D6aK!c3oBe~kqT7L>ln75_jn;vBRBDu>h@9)2WOg1xfX92wS0(=c`~=8H~v@U zfFLt;VUu^!H!PYVS1Vd9cDUd{d*Tqx>Uco6&gxBCSO>=^0StDlyOMH$1w$`}}{wP@bEo zR}bk!<~R6MFJav?m)qzm{?xJq8{)a{o)^}+BLWJ-ttIPuhDqCU6=|H&!!aRqMbht|y=TJy@;|27COnNPwYvWR5R)1`|L z@}U=t=jEbyegW={y6>P&4|J{#aMJg!&i_OG)VncIz*X8pz4fiN1#@Xyt zsIJ&Yx%h86MRC);HIf^|_Gz{&VvDvIU(eoI4p#x)miIOK_A$FK3_yBp4I8tE-i~x{ zeq8z$ZO$=&oRajO)xllo3Y^Bi|i#+F$p(qOfA zUG)?;78@UbG}$Np(}o>wC7ZZsOL>qHsBOLEC!_Nwr1zP`vvKytQ7f<4MCZ1yo438) zE>#%u#GyN$1$G!qbv4To|Aa;1uN%nM$PFVgJyxlTi1mV{Imkmd16@==)VaP(fAhB% zs@oEO=V3>^#^gBYv!=Fuer4_%FE#cTH+o>#X#;l9U%tPMzhN=?x0zwwNr7)o+p>w; zB2L}1A9C;%MfbRFMPS^D_xng7PG1ZH*(!!VHn*Voi>syb_IeYhbs+I)qv}tdytzI9 zz#eq7lGrWLD}l<1qb(IJ2dS{Nb+u+c8L&!!4CFWav7xd%n9}WIi;uoLG;fK0x^Q0k zCGd5u6>Ji(vlGF666xQ1#IU;!-eYD$0 z%8kSOmC&4Zhe-}mjC>{m;-I}CsUCWyNwVLT+Z-TahmZI%ePiI2m$Snrxh_3*w`@ay zVDO$@Y{3qByI0|+8e2Pmx#6VFo5^d8>uB75yErZdIlHTk_zg0E+VD0-u8got%scfo zE^&4(6j+VT3W>g5{_T5xc`oh|{`hG9h?L@)A;CVwpOO;QwQ6|o!=(4KZZucI9a=m< z(v2O#mh{2JY2P0o6#FvNun+AeIKnD_OYef%A|uS9nk=_&2zc^~qwCX0Hx6Gpw(5g_ zZ+EzgrE8FXIQaKw2(A)JjYSQo>_7BDP4%rz+U9(_rnFAg>B>q)y`*AGN*+3@hU3?+ zWZo@izS2b+SkL|0b@sqKo^_TvVZzF)+tneVcqP~r)~^q|xXo2!2q;3^TBAaLzmb5W zM*n^;;Ow#?N)W(uE84k96S8CPs@|Jn0l%HxF+UpE-lDKYTNy?)H;KlJwvf5P1@dc4 zAXbCvU#L8Y(y%Ep-&uIg*Mnj*`BI~{o-Gsy$8)tXXH44>5&(upJsMQqd4EUU6XsDD{4MmWuhd_>qPIr_lEx? z>LG#jg-*(g(05{5^Btp}UnRoSUn~=(Dw&pnIR+mHk)NYCbgmerQ zGw=Wi4^q*n8_l!xJ5&Tj0)pQI@9&hCJl5Qc+RblNVH_d#>vUR6*XgZ)otbTcW{0-L zGFMdAR!}7gp>Jcw^H1c>j3o$_2k1_J{aLw@+LG<>@fh5qYeu^_hxizSZahdoB|7|VF`yg$ z(8gR_&i|QlWU%)9=|u8>Y!u@sRAJ})m7ZmS6d{bNY|M#=lAeQ8VA(!$Jm6t>y#321 zszkA|?W2vcKl%VfbGt)HV@DtK)J+DdS>cjvy-{16Us_a|XOu1O#1fAGO#N5dujY`y zD{UASBiM=jI;6*rekG~wuyuYM5ZJ|m zBxZmSJz+LTzolP)Lli_d8etv*HKGa4M>cF~$<_RaEoMAEtoYFzYBO3RkR8;-me!(Q zUSHd*X3Mr%sY(b3d~|-Uj`wc0lk76XoDoZxutM3W2QP%H5)6Y5-cccKV#C}R)_p^` zq}L}&-9ay3CpvU~`*S3o%&_>yt_*Ml|FZBPK(hyf0$VqK*7pk@fC2>`_iNB!S`Vdu z;1HsCMU0l%LeNyCSUu}X8?PLEGuI4vFQ&P9W?^+~CU4!5_M~HaW7sLm8hS*Bf?1tl zq2cFx#ll&w@fUoq`AHRv&!w=A?g0)Z0nsBe7W zXY5*d-_|)3c_DA5y{;;`9^=ux%D$SVYlt;^=8;mma_A9(0hL0YAPYVr^ofr=d~3C+ zq$KKpiw{cuUwacOMH1Fe*sk3J@VmSUh#oq#d|#pBQYEkQ;O+6~TvMOEzm9wDdW!h& z)B~l9))yG*tCc^o3Tw_PlC#u|M>`8G%N;3%Sf_BScJ5AlTSMMlL*+`GE$4qpwEvk* zHiKF~_+PV2pun)1EiJ?y?FCORe<=m`Lx5U;*0b1+4zYDEt6M zzAt#+_o*0M^T{>LJ87Q%7@!-DlM6R+o@}L{VzZ^yv@r}HsH)<;yVPw zNf{x!du^dJRL14JkF*G3V$L|b_W*g=fL16SXm)M$#~Qfl@$3mx_SUNAIre4#zbu``v@ z#Q>Q)+A9=BjP|}UaeJi^Ad^{tk;a|=N^UcK8_GfuAHgSfvuMxWOR`hX2&PeQxCh?9 z#~3e3DcIxP`pM{t_b@BnJANz}2xN3yts;vNSdy-%0I#SoTv(hppOr33MW!lh^fYkA z28{3FIU4&kXg^r}iF4vAc=mVejyPJvx#7UPfF#($240&|$Tvpq!HMhONUYQ|e4)5b zfTbo^T^%-{E&ax)T2mx{qS|>S$V49`oVVV8rqmyP8rMB&C9s)?ux1sTBRd}pFn z$dKAGQYgOL@~leHI1iN-k!w?GlSqM|ZaClDSxf*Rl^7oeoGZUDO<@u~DNFr>oIx?O ziPLY8Lq6Kcpz0?voW%l4SedY1GvrA!S2Dz5Cs96E@Fhe$%w^k6#<_d!_yi~J9%#1Z zb(G^t(ZWknG53If(;fFwc57CFWwlW@>C$Pk;-K8tt}=ag&wc5vo&Mo_0Ic5;(pb=P zMgZ04_n?0Hdy{mwR%No(g%XB-Q=Y=L_mn%C`lout8Iy>m+)wx7$0-^ufFqc|s&^rj z-T+l_rhg{Y<<#dv?7U+9$>ts~lz_TjuGz|OTDlea%zWH`(5W|E?v|_q=Z)NP&3SZd zdfoQPl;-pbbO2AiJEcDbhw8`Qy;ZSI{_ITe3T12r;rdFJos=Mar7M z4D#{sf!Fxn>y+f5nPZ6hLwbdaf=raZHU6=(=x`2$*_~W-UpuchZ?%t)9Yp?5r4COk z)`=Rc`JdE(;&s28%jG;Z_l=hB4lpgcrTIMb(T&!8z~>c7c!QF%julPgl1 z<5ak<4FVBcWJK`Z?zU@(#cpZu`C*ZT@_nI=LVwlwg5LO`SgvJ*mobNK<4Gbrq#*AgOkkgJsI)^agi zD4m6=nG4BZ@Vlu@ZBH?=s`&$Y*Y8p&mf9BL}85>qisdS#AMmw01eL-x1B6* z8Y1Zo(~LuB2YI~`w_3V+)o^`?AIutQOv=lU2QBF(8sbl6&N zc$tHLyaHJf_hO=>P^%z}!j*o3iFm+6qUSt|NQ*CCuP0PVIFJ?*Stwb8=k}{@$ch^c|V4On*5qfkubD%SW0hNbcD2CLF z{O6292o=6W(>b?hbmZ5>-^;SCe2waX7EkH1^ytSAZA)^Gt| zj#aX^te#sit&fJl!b$xS;gK4L*}r??=Dmx;&0|kie4rjwzDY{S5gi{NCH>9jq^Jdd zUjEZ=)&C3@Q)HDu(O$RqrB&BG@ct_lS|CT=rHSyM{xRjj8eXl5OPoDdjWdg&);aoY z-nJ4KI_@Q%8l_}c-VI_}z&O=fg;yxClzWB=&|>{mZNY>Jqhg_{QJc7nQaz+-0TYyT z*=<2-8!7C)w`EnXsW_!f6_DJY9@#N}P~QalshVuV+2*k>uq~4Ukqxexh80u<+*W!1 zHrpoAekQ{7@u3_iTaU_7h(CGIfA{8>R|&A??~H?mM!V|Sod1rL_mrZCh6r=oWGdZS*yQXKqBGf6lba%h+|J5>!Sa4P z{@Pig^Bg4SsmZoXp&Ve<(atOcnA%O%urE_H=I4ONSL?wyuuQrEE}j5S36yZi=Omphr!^&q6>?|@C>7+W$| zkWYz?)+uabeVX6IoVQRoMT<5w+$SZ!L5&^1S9ZBu_697HSPAYjSoC#&13jT{%~ctj zN6dxiZrfM=SQVEcVvX0XknQCI`(KT;DLii|wpz0~=#>aq(5%TGe@_M*p@-E{2fYIT%aSp2c$TfOBRzwR-RR}-|%WgenI5ILX>t))Q|1? zZgJY=?RNT?l6o~(@^Eu3gh%p9XJK-;wv$WAvz4o5*-pGhw@34T2MlKMaSO`blg~0| z@p%g#sje$RafVdrRya&P?zh4E-Zv*ZX?G+~5J{7;lxOIqcgg84rdk4MTU|7$3LF~Z zdQA3~Wd`4B-9>VS{0skuOI?x6$FE{A?PGx?c0xXFAM+L4f*s2yq*iwu^RKvWo1SLEY-?ixt^G|1?YHDS^d{y`=*xc<#78BHhy6WGM9X9h|Ty5uyUEG22ElJ&~A_xW(=_Zx#uyRkizlu4uXET-h|4QC`@%e8qP*o3( z$AsL-{ATBW>&qsSIT#(*uC&&T5u5Y~z}E4a*1*lIaAI#c6^*sK2j0tCq1{-^K|{6R z6u0L;U+Le~!CTTY!k_CH<_3EAe9v#N)q|K=1*$M(G?3ldx}_~k{q&cuja+tE-`YCc z{nUJ#$EnNJ4tAEi^YpvsE>y`!FFDr!I<|q}wLa5-%NIIp$=#U;^9mn0&q?rhNWF)H zn1T7*CMj*C@?C|`TQ~glssTYump`$RKU&&fvaDa#0=Q=%XKKObL!f*RVk!C8&^-{Z zF!8&yc|TCNY$Z4CxymBW0}HilphK9oatI{U!Si#ZKly@(LB!{GQz$;#{ZOd0YzX;v#mmo?sOtHy_FXfqYx)R<5$PG$EL?0 z6<d}bn>pq4qFXeg;=krKL-+9XTBlw}ZfT{y~4m(!oy7JZR88BtMQ z1}Cv7ITObGuJeq1h~)-#q1ujAO~t{HKDMRM+$-KOV8jTZbUNoD;s|T0-QOyFSS7)EL3hs(#ZO)TuEyoulW#JqWfUq~hN|LoR1vKhJI zxmz-TpTrTt(GICMW~a(9<)zxYJ)UXDAj$>}m(jg!#4&(Kan4tBG^1wK`Of&6ni zr;9~0gPo*X%9gIuES?w4@ye`_LBG50J~0=l@m8%@kmVq|P$f7tRwqyB^~)bQ(=XOn zxwLqrztTFkw$InQ{_%4dwY_f<#rhU0I%9Gqm#wbq_4n89&#t{i*0?updWJ0*Z8L5 zqccqk#zmE+EAuu~^?Mh8ske?^;|4mZsW>{fico%*6x&7>Hu;8_ty|B(YY~*kT@Fa& z8j>`#*V|+FnZkINmbdFv6S@U>E)sUsp9+*XV~808v1$zckQ8h=zNd z^sCWajK)Jo&O2JHGlC-`)IV&^Bo(?fj23bx-dB~<8qNP#*qxAmU$;FGfB)gpB{j>{ zx3L2(*4DWxuy#Vj!je828(pBaCV}3{0ft;|0G50N3Res{I_x=(4W5RPQ9-l+{;+@2hAc(chzv z?LH%FF!GIa7!5zw0afP1e?nvqI@V-fq?T*>U4igZ7U%a*u$`Q#bw1aBr)|PEIa6bg zMv+{GeM5r~Zaj##Az{ghOuhCSn+o_Of%y6sOpZGsTKh+uU|=4@Zdwtp6Mmx#=lX#v zF-F!8B?(uw1XshSyM!!bI*PRH#n+2H?H7e0nH?`lo6{r4jaSFye~<2HF#hvOOz=rj zT!`Uv^9&JqMe_7u_j`#)bSSziRq-vbh z2%aRTqP(%J214oPg)nv%hdEbX-y6ZPmMF63h9mLoFeg3JH={aRa>Ux+pqSgL3t>To zIfiCtP(^;xZL0N4S+dOO91MqNNX!d2>J8_&N4A_7*quxwQhVrYL_PW1q1YB1!_x)d z3d%8RId^yIe>X~F3^zAV>2b=J*z~>o$Sz3wm|ual2ZB!T0T7tNK=-6=W0bC<;D^j5>gjtviG zF>rww#`!qE(2!}XcA-{ITA?3=^S4F2?c|aTVevYG?WVn-)c76YkHQ#+ zrYzUZe-`{ef@BO2yg>&DI1etyg|2;UlvK`yu0LCSx3cL)3Z+w1?7#hfKlupT$9=!*e zZCuG{y{tD>RVA-eHeU*GVui~WQM|d$x(DnFDMRO5J*~lQHeTEM3V01&eGM$`epYeo zcgi~-GC#fSvm z3`nn_1qM3Jhqeg!*;(d}SI!eM7 zRCGUF*KRZ-S))=$vdn*Hgnv3Bq?Lp%fsd~pn63zC16}2f&bgR2q7Q& z+WiCBko#GdS$$XNL+~WDtzmTtdH4jBXM84o+#;1;qD3k3Rg(qZg+z`+?6FK#(l4HY zi_{p7W{=@o8yK0#hzfbLhTr8_e@%q+Bf$cngYycL{r)MesS=d6z7k_pS2!9bFoUp$kfZDFE>Pg8bB6qc^tfM7Hbw@AK>PhLtm9F3gc?>Dp1~~fFR?f^x3#otd-|C)Wb)`nJ_?&5a z=1G1|M%C8|`sgmzUnIYHe}WRJuGri!;6_e&*p+~@N8wE*+C-9nLp zp@p$3%y}auI~=4xt_z4)iVKdpXug(Gr=@ZNtfSV9loc$F$+6gFb1fFqGfA+}h~TQn zs&IR-|MI3@#US*yq)NEdJ1t1EJGjj&<5wr70I+W$Nf9JZopG+p-AM+O( zWVNSHt_W0rSYNJykM7b!Kylnl)5t9rDerZu!dpBrqq|Ip?!FEy8B{(mpG-fA1e}muB^C^0l`~RPGmq zuseu@9{l2Y{3YM{3cl&g=F4qI=ac@S*d6a-#$%x&y03WABtP675cc z>WMICThV9p5DtW_Db8b*N}=bOET$>Jf~-0A?z&wH z>*qayG$JEkgs|v?fA0rnGbfk+?U`TI_Tde3?V$1D6c>5nbX|$ zc!*l?>m=j5<7tzK6g8Ef|AQL`lqA?UmQpomC_tH$n#B8Y7IOh}&Ge^@F)tRy#_ECF zA~%SBe>vnCIz)76{Z>^M724XCtEXG_vSq4mjhg;*6W*KV>aW{p`9A|{>p4PK`v`hj zY^77`cS2P3bpt5A?cdbx3H6x2Q}1;bkD3Mzws@>(4)!Xb%#wE~@WGana5iPx&^K!)#>7DaptF3_vFaLO& z#=D)`)VlcSt2(Ys&`M%!*;j}x+GnRLEdC#-9(cBAXurHVV3zJ&%?nXEom&Zh?C!Rd zmH*tNQoKPi)b&!8GIYa60$Gsn5mtRSqaqeLu`?JOr@q>wZ4Gp`6UaZzqnRpC4dj-#v)}HpDywN9Wcne@sz& znhO}o9SRCC&tsJ7&z%3m*IPeD;eg@3iztYcbayG;-AD-tBAqJD0t+I&bax3T2n*6u z(!F$- zQj{SVPZOc>JDBe)@*)*VgME#ze=Bk|2_+M^l)lQ&UelcuY_$ky;QcE<)g*OZ7$X)r zW*C~#EFVyej9e+5e$f<643 zoNHNGx-{CnnD?((#hwR!?D}&PgiOg2Pp(})Hgvh6*IX6X6Pu3X$*mu)*OpB^|J53= zvMWcMLhP%=*u0Dt+CF8nu=MS)v>3Vk3^+ioLG7^9A~Um~XnESX*7!bHf#{ zard~?+H=ZCIV$;++;uhNj0Mc`An#&&;CdP}ql*Gpz#uw1CrsINQfcp3508B;Cvu)hR+7-SuyRP(RmR#hRhL;eRD$ z1dMOB{QA?gHTTXwYdhceFq*#2tiCyzRHh@3OJ4s|Zd@2Y%{(M3p(-DsvHg zJBoi~Q;IP#^Iz+;CB<^=xU1B5`R%%s6V7ED@+m^xW{YxN7?e0Z^-|1dk4(%I`CH5k zn%uMGy+&q7I{-^ML|dAFT6d9eYKmfz z#VVkft!akoe;t#nnoE=XG9fQ7Acns(y``S@IGd!HNbmSXc=y(Q^_f|d@i`WHk1j^S zjFDI|JC7>A@{JAiRgF$H>DJGQu6vhjms-b3&#neg-rJf9O$}~}0?Yn^Ch=xpJxc0v zQ@?0G-rx34$7+w%YB=Mb{lp&KwLAbEI)iBjJ4TYvf51w1)knEC4d&~|)V{YS?m(!< zACHf@lx@_yt}cA}BSi_pYyX=^y@J@;faZy7K0(*2QLB8!I`@gPQ7h#J|SNm@X){t3<3* zs48&xf4aGRSfb7f$k6#t_*nMUPBp(Wpf7@H&~PM^dPiQbd43{WS5nHuXtWog21h(Q zfMfbPz)PX;bNVx^T~K<(XBh|L3ENh~y_vC|b&7r8vy!z=h=Ko66QAR|aCQQ)T;;ah z?Qm{$w$^5sHH0LbgO4H4g)1kmM|O&^K@#>Df6oGyO1SJ_)u+fB6LBK7odCet4YE+4VFLuBZ{x&`a|RVj5)mDdE+d~Uh9b3wffHWe zsh=S@UPzm>zx@rHQ^GmFVo3}kTYyrZ%uxPojjdTa9SCeH7uh`a19UeIt2}`-_)?v5 zf4m(K)Et34DVoE6U~1`h@}Vps>gc?Tfq=50NNA(*4qRp=U1a^S``0ZtEqu;EX#Vb4 z2-DW`hJ}l^3!D_knrrgebJl|Ck0x;1u>G2HhVk1JxI)RJZ3F!E&_-BNvp$*s>r+h> zrRbVJWZ+hy{De%`k1^$KR!{QlE4zs&e`9;FN|@MAFoRyENe{{~pM5mT_!sCCV-ta_xQd(0-*VD_ul>Vbv@&Ad@9zZ$Kf-G@R z929t#~ z2LZ?U9@(e0Zo74A+IW=e(Y ztIZdUw$@c)J59fj;r;pJ6Ss}Fg-!pLeaFaKkEuyikFVNGqjY0=n>Rc|`>K$HXIE<2 zmIO(-PI`wkSE)Q6 zkO;fNaQ|ye)H`L`f9%kPx`DM&^sA(QuQ!#V7h}F1a5|@pu>OrEGdAx7#IUAm1j0(E zcBVwSawIDWBD_D>5s0ve(el4e{~kH*uW+g~1bxvqdoGR;(rKbu-7J|D*iFky;8Z5c zd_C|(aDTUo`kCyW_dONbvclXI)=?LaedhxYTB{@sF3>XJe|y2fn#wD!{hsoX%=B)G z_Vf$fhQ;OycOIQwR0eQ#*SeqKMbc$Yp^;T)Cfbz8C!h?hj9iHvT&1qrSp)Xo95AU2 z@~cI(ImT$bO;JXcov4wXO48c%JL@|e-SMx zicu*fWox^Ge_ZMD=*%}Egft@G&Y28d)oAK1)Y-i1Xcu_KSa$gGp-n-h&iO}}B0l>10r2>A z_F9jo?W2sa73AKE2RMakc!s75d6zdP+y;{0vK$Ugx3UPIZiLiC_0dG{TlUa0om zt)f1q}$_xRtB6XrMj@ikH3)oBy)jC4iM zPm~KumzWsz?`M0^t%AA9lrSllk808zbl>=kC!Wk~Z18X-r3C{AS&>=$u)eK$n5=J} zQ>d8Bl7)KC8mC{Yzu}69eI`%Z<5t5oxd2x=(`l9`4}k5m0&b*W9%X^p{5GmK5B$T$ zf1`TGl5Y>UBYEh!`f!;YA z(|SRvBNr@3TSRB9i`NWm8|;S~vjSIJg#I76` z@Roeg4M5}q?o81jI1F91o!1A~BOw-h+uGJP*PP6(ZE`@FE!GV46+Rj_j?v`ZWrqr& z&mr|A3F0mStH4S+AD_7x$o7_`M9@GCS={Jz1ec~fZb27pp84fG_$9nXe@(YzL6Pl* z`AIv9(bl0Ii72RK`f);@9>c(uq{Z2f*vvDFW?tOQ4DI7pBsZV>`8xMD-mPgZF+BkE z5mYRB(})xGiHS6?DKk~LfPm=;_rNwb6xX8+CSi^>Mdl62hQtyDUFGSiPH)a(RVT+2 zy_;WGeHUc>qUyhqdju5-e;NO0n<`J}RAJ_1tlA@i;r1887^nyW|2-btaM$ zWo3hAofRnDWpsg%Go!FBnJO3An{VYSV{M@&E>hIw??dj_RUJ|fX#cQZVp|EveI$nc zJK8EaW?IBT6bmgP9IQ&BPJa&IiS*Hme+HIvhpou zz8OnW?4M%}CsS{ee`!U_Ckvm9@Zc&oBH!Imp#$Lgyo6Igc@fN1t=-fJtQ1^l%29y=Lw6llGLKc;=~fabx3v^IrxAS^^;)|% z0Rp!Jp^v}}3+F!?k$!Q_d~p(z8B8DSQ||Z$*2x~ndyF-o6D8Pv_IY2-`RR#LKU999 zO9Its*o?B?6SZ;mop+&W?Qm#oZB3hEC~eei-RJADr)3m+Bm4sQI`t-CK?NO35;yJO zPCWG8FngW5f8|AkNaT^NrrYGM^Iw5NrI#}t{Gp7C-&)qN-YKdnQ%bSm`qnhb4U(SQ z4U-I!Qh)tnSZfB~kYuI!ihNk%2rWy@`0Qd`#ti%*f0m9XcskX$DA;%1(i;uBbH>d> zW>q(uVQptGt#eE}L_7}T&vw#C%MK;Rz&g;32Y?llX*7pyVn@MnyJbDXcSSNdl=q4h zH>`(J@rA`_(}Mo(qz6FAtN{cYZRJr^dfYK}8@=jMuu+4hsO0&AKb@ZGT3UZ1`5V6+ zY|xGoe+q9Vh)Shiii%04djMD!G+2< z@7dENhd&lx$E_UUHA-ndGTw#IC^Dd9V5X4aO+Y%#He=n=HCJ^#fS^jXtH_k~r$=?r|zSi1S#YhpG z#cd@9XC*p$KFMzt15DSG&c9A_ZHgXM{~8By%j_K5MMjtwos=XTU#Ai+0|^{Ttd4yB zEI8jv4}K*i9kqKJhp(D)5(tpKP^J%*e{3yBC+ajWTve?F>%8eu;iU0xs7}`^FXqFI zP|OO1zWS}?Fy*xUX2WkAG>HFe?m1;%-#^$tjc*l`4Z>`+Ek{;K6)(Rxq^wZ&xMO_$ zL;E}O%Q8`F1F|y%8>%OZAhvdAz3Zr7jqW7eQREP*$FdQmcwvfprM3FKEY$K0f2enF zXg0}ElHcGAZXVpj`}Phj2thv~_!FQ1<-IhWwr!0aip*xSnt86&gS{Z9KpSkGzh0c59!`@(1u#5R9*sx{fTzAkN)tUSh_K7ayk_+ylB`o8J=fK>OB8TtKe}(7xo-VXL z{#nU!FcmEH-#?)<61$^6SoiGo-!uo(qm(FDV6(6nG4El$xRaqVO)c~63%}-L9#;D6 ziam2XQw>uMK-J4p)+^RUYfG(q*i~nZlwniM13%ZK(hZTTNEcWe7^JfHo>pG{`xU~lAY{QiRH z@pmU&WVvaS3xqy|0hl|TxMNFs@~j(NjD(0Yvg52q~+Dmz~FOznj*g z{RU;<)R1PCpJsP-7RiXLbEe?E!67`JFYqrU{`r#NndqA*UhlxKe}dwDxaOPq>d4G3 zWumN(S|$JRjY|73i^mx+AT_105z0)>#?S@C=Vh*3xI;3#^~^#Ra|wx;m1N=$s+^sN2rSc7NRb3>XR(lD2y-(Xl>PpRI_B2k zmFQqz>(ehk>PAVGf4$Ma@rE;N(cW?@wkLdEUco^nQ4oz} zU)C*g7S3#J{ol7*A2J5%|C$ccXEjfKmz7&vb0r^`|MiFp6WICG|q~L64?%vtPK!a#y~9_ z866dNzI}U}Z~wk82QV^BY75NUj>(;D_05*e8j0fgf7P13hIu&`gu$pyO>n--Qq=P0 zFBXcB0w{jyZSAkYTir64Q*#wnv-=FonYZ<~a6X9Wo4To&9OT6|oeYC*cCiQf#SNWev;? z$w*&5e+~>jbLXTkc=I>ugD-;rQ71(~R4{K$a;XmT&)_UN;ZP^2M(WKM^9{2TYJlQR zxP>wby2r`iX;AQ#*T3$klEcN5%?Cg~53fqOg(X_bcJ5kFNVO?HdEo#(^vrsbBTT+w zmb}Di|Hbk)J>XiO?g7vzd@V!m)Fw-E%@0@ky$99eq_tkj9 zO8EeAghWd+^LO-gpu3Sd7AWlDFtJNet>xq&iNvS%@?s@#m|CE)9WCkiG7>jAgQUyv z`dCPz^e1Fs1&mX4t!`D2Po<_W@j#QVe|>x|+?Vh1+ZMAg)`Ol3Sf?8=)W_Cg{WwMH zIUhpv3wf2AWuPQA`~z!>=C4j6HXjDF7_D2f^_wApA*v-A89UGc8`SEOL5997Ol>1sJPDG_Ie^wBb zs1MK$n&9{ue8w>$>IL~yxht`Zb&VJq+FHvIwh~4;=2g0wYD{nL7qzt{*BBqr;&SjXUCKlW0WVd`OREmi&4yn8R{*TSzM0hBU;! z{tdRRo;pBVU$IbV2RYQNrV1Jpf3?7LFYy?(@V0A78BM2rm7Z2N*XUTI5I-$P3Quya z3Rq`rGC!v}PI$jaku>5mxZlr9!2`&{78JSBrj=k&ebbV7X2oh*PmQZ&POg0piCf;A zh*g=7SsdFRz1*EiR2~54;R%U-TX;s|GE@e-(oX&ZnLR zTKc(6125VdX+dAs~b*Fthwoc9?u$a`R!o64|(R z_9n1KC7wg(0q{D_7m;omc=d@iVUyyE(0K}^Vt6E_v*s%)_Vc~*#OV^E{C+xcuAhn% zL%fi9WM1MsqgZm^4D%m;e+LXLUO(qWjYxq>;`EQo!bHUx!tqDyXjVU{K-A_1H7f^O zwe*kp(vWUER_J;^citRHC{^yv!k_*;-TVplfL?5w zQoa!~vd;^n?*CUHW~hGlb5+1wgD2$jIo{0qqn`RpVeF`=2f!+%e{r$;XQ~WFA!gr( z;gdee#b_nAs%hT7BM;8q*$q20ST-*KhUf2>JIefXuj50`tX-3ag|N==hAN(q*gzYS z+K%FXO8)jf`ihMXV$LHMin}gzsX*Li5a16PX4~ zTkLU`3ylf%Z$>wkYwzU6dI70R?HF@R=o87tqSI*9B5yeXNsJ!25(tTJaDAd1 zFo_EOTrxlLB2eZ@|Ilk-P@WQUt?lAN6s|gI>P_}Y#74Q^IlI=M|}xO zW^1kK;=*ggf@`{H8vP*fTiF^W%L-L6; zl9Fmf89LPcyU&cnF@24FbhU5vW0jgr`)3Fzs*VaIat3o=mKL)RTeG|B+-$$u)8@Y1 zzqH)%`$H%sFGU4&VQDrlkK>ofi>o^m4_F&SqJ4)6fBzc33I_;q4c5kbFmZgZSl1lw zx&+Q&*(T+3w{(17x_v$Ys&aj2c3i{oM^?4g;cJp-;gFDmaB+b%?tvQSJvbN%t|{>^ z-d0+ZkPDFib}h#+#@-;Xe=)wp#pN60zB2OIQ-d#gv1hxGh1O?+I&a_}7kQ9L)>hx_ z1kF7-e=#fA+a^zV&L}jKv{xs604+x6p&(|LK8ZrHiDjE79n4==56ZcJ)wZBi?VMuB z)n1zB**zbLHs;slkh~&g(B2h_RAfRyVz2TPa0fR<8tR1!cFg4~ISq`UlHaJg1bdx+ z{Kg;4wT%oBzW>caeO1L0PxHyug5^ec^~doee>Un^&&`iR3~K!9*_!IUnQpN%D$5ay z*D-WWofBr_r_$T=0wd}2j-s@+zwMkyF};itq?K2PeP9}lp^Ggk_U*3HWAGbTQOlw@ zld)aP!aKbTT8D;jA4POtChL0b?2BY`K2nx7oQZcp+HICXR8gXI?Z4u_rk|2 zT26QK?3ixDZ zj~D%MgBvyd%-e<(4k9~N#L2^0f9}K%rPd3a(#@+b3X*6|(-|_Pk&WO-*o7!9F@M21 z<1s?R;6ik-m!Ql|znjeL^k!XGzc>;sGh0xH6ZL-ob3jC72MWAVz+-yD;AH(4v8lJB z&)WDtklM}uaM{cD1={px62-Y1U1AZ7WzDV4uBYzkX8M((j>da18?$^CaAjiGNr}`CAV1_ypr~A#Br{Q&tLRunT$M63IoQWsIG_O zi^q5peAXBkeATGB_Y)<)$81M75nob{;YQm<0Q>Iwz@O1S9{^5yeKrhj?Q<8!#(pWQ zsy6~{A!uE@XJbtwFY9MGf2jITAWZVs3|7ZbDQ4y_)61Rv)j6NUMEh*Xu}AQg9cS!h z80o}61N`tIUYqB!hXxB$aMKh;vnv%V zFT~jqeFO2Ub{Vrr1}vF0Oz<_xHW5pDQ#On=?%$o%6ZI0&qw*C%e^_%+(9IS(`lAww z0QRE-i`F7XRGf8-nCN)A(RDtZ)?Z!YBc^1ehNZ7mC*TL$-&LiiOiZ&Yoy69 z-JfYFfzshMuhvPuimH6{L6|g%(qD1crfc@B1}o!Gz6_*~9*O6fojcJ6iLcqd{DD!J zYEtU@^IV4kPkD;Ie{t7SneTwukfACs^y$$jwK^O0>MF}wP`fw=*trJGSA1!GKjsHm zooRmTCf^tlx4D$d)6~A?p11J)HRbIQn{g!4Au@)$CUK=1{Q$6g03<=kyTsg!pz#$h zlC84gFro8ie^Ylw>GSX3`E}>DWzaz1x-Ok@w|X2PG|oVzFEKN*$qE82fXoy{f zr86_@ELbzM?;x zke&R%e`d4lj3V436To+R3`QSm8+(;(eyu}HphH33~gT*j@ z{vDVd(m?^5L3#yfDQEV-l>DDulfI+^&BY@en!Ev1U-XbVLZe~tcjCtiNe$4u$nxO= z(gk^^T^5|RBU6jvI<~~I#eor`mbn3thx^3Ke+Pj10iL}_q&IHza9b+z>92cOwyYzh z1g$ejFPZE%=3TRTB3Y3gm^-Y~N2uMcHf*CW96kncVuh{V`YzqG&b{Mp7-H3i3;Y&l zo9%gogXQ~%)T>dmBpPx_hX@V{UN5 z2SmiK{`Sup%gTBcdGAYvt@*r(xQ3&Vv7^jKe4bWuz=^6SL21P~gL_wOEhS_wO#Who zV#*6iVVci@u}2ik7jY_q{|2>>TvaKNfA0|jOV`~tSR*dpbM7iR`t~v1Ujz=Lww)gU z4hYY(D+gp#tu$|M;>S>2ivmfORT~E6e*f^Ccp{sLjcQIkBqLbn^g@}+{2NsEI8z*1 zNFRLlh=*q~xPM%wmGS+DMu*R$X*!bKVYjFfBrKQtTue<8d*L$+K+#?IVnx3E( z8_G01oGx&^7QgbgrJj9tNo^|de?H&80sLz5J?-J^rko4=?J?p*AU> z?&%?$?}^s8k*nJ2HroiNdjZ5x2mPD?Fyuuq{PIJ>dBy*!XlA5%Ba{i%xdZ@|Z`A=* zyIagX4lJ>dCcPZ2(Z6?=rvwb@cR??d_oeYx~U@7Kc4!jE|n2aqPYIEnr14FT)cWn|YtW@e_1SS_03Cj2hHsiIkl z=16v_vSh%^Td=Xx+(x-?&g618UXW^4U6olETN#tp{x`mEnSL*X-~Wl?3$WZ$m+-A) z@7CZCIS9WNM>>-s#C zOq2qjSenY??_UCGEJ!1u+-PrQ69>9=6&b5t ziB1gdsfGT4HP28E&FXz7Co?CknJs(3&uh=YL87UJ<5zfH0T8UP~XK=7yUgEC2RDaF@(u47sm z!b`0@zQwuK^sj#Ze;490+g&`$uertCoR3jF35+Sl==%?tEkwEH*2UB;g61~dmrk?< zZ71c_Bqif_4H$^~9^2E;pn=GNev9F5j5a#7KanBX1pz`Fs&W#_4G6+CKV&Axg^^)GMC|c4kd#Mcrn4#%D{&PSYv#juCQ*O?qOUJJUJu=Wr zc0_GM-`LWWe~VGWV_1tK7H!_jY|l{6c$D*WAUs3Z+!^WDV-3*hG~po7hfWk%?KgpgBBq~Yv~M|8 z2^ut?%>D`Ul)0*l#d2W`)T>T%1rYi9OGR*L!$}uEf7JSr^kNexo|0G+RZMH%(sUvf z&AP^6CsS9fWDk1Q=d)wY0)NviKLv+8cNrJIU0}bwpKv72dQ#-D^Xm~KMcbi51@6h~ zJw-vVOcS~t2^&--g|-Fh1Y0Z?VqUq@4*n%dZ9=Y@8*L-K5;l;C>w}^a74BlU?A(Zb zLALl?fA)^=Oy!vJiWzARiNj4E^iV*Bf5ZczA3}1~*7X){Dg0!A{qpP8lt`+2+cy>N zH}{5*?!=lHf8#^^o!;CPb}r^USwfQIscR6MNm10bqvAQ%o}P$N^R z_D_75W6%+Aws%G@V%9e{yvX$C1@7_^ZLSEW^qJ-cFJBrewlDON|38}4L1ywf3CRzs zf3iBSD17WrvAEpISCF`U)r~ta8cvEB+npd&Vbw7?_Sa!21D!tON6sW!{tJv)K9A{!)*; z{_C2D`ME-|2uT|fB9m7&j&=7_eqMzyLYh^_OU17r|L#)F@Gbp z7MDa>YLL@ra56qo2ZUsC{cn#&jzLS*AZLMuXa>iV*r}AZLP9E}5|)x-kUIDKCf8iu z=QSCg7UaJ^7q4GkvttTpZ@*8ZrSHG-{WR#^%MJa8x*c8%GF}izP)kC;_M-;8e~9~s znRUT)1f&y;WDfvFM6VL>xd-1L9{8H;QmwcK>I*YhiY>*!OZSi4>YQo4Y<5!F##-7> zeMPl9UsQ!@j{)vte)ESmTro+`$Y2zjJP+>{asL_r!=5dLZr4L;|Ilv@^pG^WtM8@? zNbFx3ysB12j>r7ftQ@h}zIp(le_zH9_axCz-REqBh|soL39B6PIc^u8vl?^!u7jN~ z+-p%Wyfv-*$ztM46FjiuxYVC47AV$(o2i{q+%kx9uY(MV@T2+}xxa{;>$f3iJPW)6b$ zX$vVix|^(WZ}1mT=1Q<03fyQgH?`74L+Q}Z&Mwx+oHdr#CRQ8%JOF~0YYaQ;`d!^>lwZd&+B&wKK{73r1=7ajmkTd_HB&eBHiGC^J6#r8CrHErHp{Fi!t z3fLllvbiPbE_~do+ZX4ie+{fqRqXmjm0sqW1QkU(Tn^gH{owJHGWN=Je@h%s`+$Tk{gnPoSEhmM?L`hWf<z2;t>NuSPnx*jandR05{$|-Z#rw=Cyw?Et84csmz7OJ957BXW> zDizV}T$1hp3?(~#e$E{6@1xkoRG?3QW^dk?*56=I+-_^i)=+NSwrA)ZA+_RPJDa-y zUl}5$#0IV|N+Zjwe+)<;+)`O?B{)_CVmG(*`fqyz8h`?wITat>>QrL+BGr(-B^7_kpUkT5k}@r!1b5%Jo0iy6ehD765)g_XIkK>L8@Y*z4`Ev2`2Q3N zx4&yt|F`vkfAZ+TP?je)$q0g9h6d~~@fft2Utsm&GKVcWLxHhhnK-Ov-k7Yn@};o| z^eWtcM&~0hMid#)H5JY|EH22{BtZ`*0e%Ok2)Q`>q!Cta6-%Em`%C=|FnOTq@$GP3 z%I0&l@eW@d)p2fC)G1M&lNUT$b*DVbE9}_$kxW)&f7mNmj&}=tAE9JRA)Es!OQRNK zYw{H_!>jpgxt6w;1)rC7j!U%y2T8Ob`I*hec+p3_VfgRyBr6L^CD9J3`TOVYo_aOM zCYJ+tRc2dE`U9S4tpPPPn_A+>A=a^CMA>69k1N@s=kqYf7RcA#&;bMt`0`2RrW6=QIeB^rhN|pit6U0GasP2L*`H@sk^&Zncj^`@m-<^ zHD9}c5%oqkFK*KF!LV-44;XRtQ)XOr|e}eKFGu z*~)=og)i{wfpqp2SA@Tl+nLxcPSrv@oJ-Dd2?MN!$0Ds7|z#dk%lDje{7?N z-de*`8{w5Y5UM=M$DL52Uy5APFiPOI;?y}?|vR=+X$q8Va@tH!T0Af>+vD&W7xiF^aH?yC1sNy zY2Dpio-D4Qsi_nHp}u3mSkUKXvB}-K+kdh^d+az-9EmAsxV0%l0po5RGI!IlHLyjb z=TzvAC-9n2PshA1b1eI0Yl^Th$Ju~nNm@Zh-UM_ugdsR(VYt$hvR)W>UZtPnP^(rb zt*-o(KraFGM{=@8n91jv^2k{x0QM#H(JxGgI%gPMO#VSq;4Sm#bs~OFrNi}{B7ctv zVka>&@5eRf#x2=mNa^Hq^|6$e!z~t#QgJ?Xr7gMtOjFNba@q+z%vK(cieBLDUb=6k z_sl_mW!@8*B5ih6EaejGp6mG0pWXk)xViSV|4n#$Q2+`zwornAeWX4TbVIm($~YWG~Q zk?xSM<7sgUH)mDwMrG4W%=rc{FctkGH5EJcdUIjJ+B!fa)T!j3?oq4h4o;gn{a$(aDUNu)sw{X z^Bsm#^IHhT5%>GYtX}FE&IAE}Omy&>c(Q#g`Z}VxPsc@j7^>LI;@OAs0>8?|7|iy& z(l7XSfMuf*8;hkw_0Tq-ts74hma8BSuggM5CVRRm3+P=(my1$d`RwW+y^de*FMasd zI$w2)+K%EWj<{9fnQ+M%8h_be>UNLx`HH$)a7Q#~f~tb^j98cT9yN0Hm;Zy~iB6;n zu{wG1)i%uf)X^U03Y#!9wY0Vb#VklpDSc^_Pm1c|DqvdkB6^8Tca7 zHKIJyW%!0U&$G#nH`3=CpFQ(oj(|UY-=3aC#5LvEPu5{5jy~(K9UXIij}u~ z8ZL1C6grn&sm?-v|Hqf!$b>kFxc6h2e9kaEPlOx)P?93`;$?QLgI-zre z{-*{F!{Z`p^*w;x0VICx^#cHJWVeS2tc0@v2WF;$wzgQyy^FTXJ{i8$C5V&DWPm3L zA9a7P8c+-B7wM8J;MQ>8LP_}Ah=lzfT)qR^ z8849JIDf)4xU*kOJFMJh$+gJsR^Ci~$|a32)sLq?i1#k!Tn!KS(3g zL|B$1){OSK7d@~0sM?$Ig7u$8rHwE5T#M|%j ze~pZmPamBo27HEcq82BmNNZd^ZhUs`;wsua;W6YAv-c1rxBc$qmcFMbGp1TzqgHhc zwSO;o0-Y~UHJGO`G%IdPzDy||B%@+$oTh5S0%s)FDix_*6)ByoAFm456>5X(T!O+< zrrw#x+jpea8ry$utmE*|8rHK#GF{K~VyqdI7d;G5dvEEp5F5>dqg8i_oU-e@sCv$i z>-*0jJB}RELx3Qu_#cpa^3+x8aUb^a(|>^*DYEooKa2HO4}gFCrlBQuDXlL77R!Mh)2*l7E9t z9P;z5G*79lU9bBpl8vz*CYbqc`qM>91M|9}UBNM$b9Y0fknrR#Rv* zF8vNjbfG74Oe>(YkG8tDOJby-f`4`C?Oa4`5=;$ds=2sPGki97ITsRS%&#AA-{7x3 zlxEVg-~A4-$_^z4^B}R*vLO&q*{ViQ+x!*UL_V6aMH&E*@nt1B=)#A;K2dsJea&Py z?DwZNS^&}4h@Ac=rs-ax$AR-06#Yj~r+`GY+%REz%jB91H4=>_g)7C`2!H=>nn=uw zva;|G{PXtXWM6DX4g)*8b|MuaJw$*2@aWSu>H#pKMD2zSQ`!+iP1nP>NLOvep*)c) z>2AZR731lJag&fw3mkV}<*T*s&VRr8Pgq|Hed#=g?6LWM)+`5{p?A()(GIW!aeA5e zxlg+nYuv35vPr9xq=!8v>VNx=o!{9o?e~=(Np@^*4QTq{aubZR!d>8)9ygfC(ctd< z*4h}AJX-D$C{UYMjn=dohPan>BTC4R$C#ksZ%r-A^2I;tF&Dqg3cq~ipExPa#t?nP zCpNg%VAD(!f9|$5NS{)=)2%qdn9c#r-hZz8oZ5lzQJ6F+ zu0V1wds4kjy>$H~JLp56aqeO*$cHb0Qt6xgozyen!dh1_yb^hYZyw1tc6s#x|3v9ZFRMJuWiViRF$qwu*HYW ziV4TbrL&O<9xxr@)*^)v0i@ABdau{s&I^2YS0vV$-W=C^=GfEHnt}%sb#}M^mO1rw zAI>8Znb|5Wfd~X46iTqxi)=`zsP_vYbYP~GfOp2QC%@VkC4Yieq~D`Py3QRr8zohy zEyJkW_3`e9{)6^4i^qM4eQ$VTq|Fb2C?ExE>ZAAM15a^i1-?t-*v*K4xJ$dIu(TCu z$*J`3dE*p~Fmd6ptt&FIo2=yO!qIjkyKULNe1UsvZMO~S-=grZ>SHEE+D0#WbhSwY zdz<7Ee9Tpllz;L2X_d2Tp}qGXec)~wa#WY2%s%T**3AGE<#=i9z8ElP)s$yHq#blsgswFIM~o(uOueVS}y8H zzl^ZxN`KY9YwQZzzKD|A#6!m3_{KZ^)aL)!()8;N^0uwbcfD8KJu0d8`ExuxBRc>r z$6v|HmT>cgrnN)4EvQr6pW^eqb>!c?s3K~0ek1a62G2c~KmaE8Mh_jjQw5=Pxn+Jq zlVQG5l6g#T|4YY3tVGT}_vTXfY1A`2>vpad#D6GH`7AU4U5&gYTtLd|0TBHGIbi|o zQgTn_v1+i-UHY?c@akEkCU2qiVBFKw%Uh*<`cMQ#p!k*Vrjf$1bd>-`8T~(qI?Jdg9I)*VQIIa_7)mMKNTWzeD&0u;=p5Z$ z5`WU&U8B20kQ$8K=-w1zlJEIH=REK8_5OZ;x#D+88mb0}j0N&^EL^>QmF*qzH$V!< ztRv5o?Ix9@G7|sQ7b@doc$?S%p#8}Sz8e4^$+u&V_NEFPA zd;X>9dp0T6+CZ3Y-!+v;%zHYo z$C3AJcck6GDFSwqw(d*g)YIk2-tJV8rGW8$9EUN~(4v$1ez*?c0o~$9-rw|Lfq#fu zf3VEvw^Y(j$6Y~u^*WxH#t`SlVw+FlTdD0(dT%jUFWN1j)m8f-p|IZ${&#=BD_Mb@ zD1N)sDzW0vxcTVHnFv=frp)-HnRY|Mk0!mp`RRU%(sPLO#ZQj(S^92;TRloh-e*!6 zGH!7EqU~>enb&2>mSNkKW-l~*UVl-4l8xd-_bt|rqGqcFy)WQedhHM{5-V-{1fViL zf=O=Wm2E3nJ5e}nDz1mI?p$yoYh}N=edKwU?3aBvHxPtWaVT9TIE<6(uu+4;VH#`*CRO;(L0e{NIUmI6y zuZ8eY{(jUsJYWu+XxQvutJ!5Xr4J-J>SKGiXYa3CUg0Q@FOmXot`~n6`0Z>cSeyvs zUK)8hOoX?F_8x-C)yI?=aIe-mY1-d4N$MwgiL&*22!4?P3Isxd&BmFd4TR$e5Z3Xv znCZwY;kQaVNEOZabtmz~?|(8gQ8u*Rm7~`z*c7$Cvz4=xL<#0X|ADLWJBfTmvDSTa zt=32k>wmBJ`vHA^(f5Fh zQ|Nk-*=0x{4XPq4kn=+|%jnOv+JGFDe21LYyzH}4v6-T@{hiMpCaz-+rB8s++BMdk zQm`0`&RallW7yl%@`jj$Z}q6YD42V>8IAAGc!IG(-w#(XuH?%^{99K<#4N zfphG{caUFkylS|TF@G18_#NY)5-RQ5Yo*@?NNX=efC{L)?kt_`a*{gvDIbd0@zP3R zC)X(@*`km+ELkqF^l>8pMH2k-tOEIHN`%AK8#;2LD|{5&;Iok7`;F;tO`1pj2X<9G z-XpGW@iDX?|6a7#1U9t2+SS(6ywNsnsB~>tFp0%PxL!j;eE5$j5*=jyCcY`7V2wU>7yy5up!$ z?x@t)1f{VAI}Edx!oG-qGi_TfAK6nz_Z^@K#nA# z&ng|0vbl6*F(p1s!}-EXeF9*iPh7W{=BOEBW5Ye z{4D|Cdw+P(tecOy0)D+yK=)6qJgU-;<}&;$!Ak@raP!Mxl1@Ycz~S-bt+epp32fF{ zbjF-6GBCos6dk&H(#0}*0I@4kV$V$d^=8S*=C`xb-N`W-ucKessjdVYf*IXV>0>zb zD6ohK8{>h`n%jA-YPGd3_uP7r)JAWLT!bfdPJf6p#nTKR^Xkh=&^+T$0Q`%cKFB;{ z=L+=G3ip9;#gFyR`uSKX&7;tuDgWSMw7iT|&7#LELQ-0V{Lr$W!_hfIh|i&$&Yakd}o)8;bLEGeJ{Nj*96C443y ztDm&Tq1MB*TQ>?BxumfLzTC~gIj{fjAoaQ})hXX9Tt&r&&Jv-oh50xoQvdP=Pk&U` zN)>`jOwu9fIw)4cl+LfOgljw%XS1*^MZWH$wiYSN%rG09JBn=9ua*Dv>ZTV_9 zl=*fD zTQP2yFYqA5GZl2NoyoEM?QPqn{*frCCbobb`-IUfH0E+xZwQEY^z<&aAaAiHTz5Zd<*}2Y7tcoyBmH2;5!L6yo41l^+9dy%Jqr~;-}xFV}Jj8#eFTkDPKs$ zg_I5!)Iq6E8PWQ4F%7O@Z9mWtYIo@PI`bG2>&_^dd|^-^IB4fD?ls++3v*&bg0mAg zOPlGPK@gVqk~=5_GRCzl-W@(n_oq*RE!82s3+-L%!!fu8M9bP$ZJcD!MPH;k*MY6zNcGDG99&A_4K;Ck(d_>*90MrRgJ|-)cJ|Ds@0M?C!wMU4&?(53 z=^ci$YV>AZA&t<)c2(Q?S}be&qtU1jQGxS*G=|lmH==`9Y(iHvTex>zi^jIFvV$}M z;hQMmeAq{I@qc1B*Czmg-9hFURCnZd{K(ke(oI&QAmLiYJ5h@I4PlD++qo$un*G;D z>w~Q?TcsyUQ9#k7`*q&)xONlPXNXX#FU9-tBu0&DG+zqX^GIyYyqD%(5QlocJ3A)B@e z1Sh^k1dHWqYDJB}ewH=#?g_tb`ru){c+~>JKYyux^NyDBVP&7IBOTL4(q~Sx^5}Y3 zcg4Br&N`+dozm<2hBSuTYvMx`PsAT7&AcgrMAwztU4+~>9!lmD|iUjd2Q(+SJ4(}nZcCj@=AY*`^XHrz4X>KS_E6N8fP zPk&`7TBQk}03bU7;gIqzmdED6SqlqVl{uH-G{J%BsUEYjyGX6DunDZ@ot9o?+_AUn8JvL4Szih&O|IC7)=*bxYA^*c$;}Mhd#$81C?g z5LA1pAcyk)f)Ii5!LDX^Pxi(8H+dFGH(y5dW#t(Eecx|fQ{#UT@ZkyY{OX~wzK{I= z8}CV1TQ{0^Ec2Zi!bwJzt8Cu59ZDMNR{pV$mMpQ_1q+65pQ3LcLX2xa{!(7DYkzPw z;IHF!?KHP!VpT zC5sOJm*(o4cINdM8O)=tey;3Voqr%6bgLh};W&=O!pnV7wSuAZ>T3n3iySAoFKL!D znf3-#yaR2e#fDRFS*bsL&dCv9EBC8!RRH1PE=M1xe*0Yt-|9 zD|=>Yb|%+aHo=*+or3o}9bM;hp(~jS2Ag&b68wd|tqb)|#W7_A$wTG=BY%lmW>P;I z5~X*_8z*bQ7HE+&IXca0W^ehoR@$#O%OYe%kN7d}IZkF}Wr6Dy8sh>I@|U`lB+VdF z1!jACek?8gP`;%~`XkJ|hYFq|bkyBDbk|?U75a~`QIKlw1gnD`JcShy|1EQGf-SU^ zA+qQ45oZ{|C430K9FZi1>VGssxwycp?vk|`DZYHFuaM*1q)Z9bINyT#M!kZlTJ1P{ z*meRGWco7#_|Df=R=5QJVW(HFbLB=W=Rax?4nv)Z9Kp*^fam9w?hadgNR{l-5t{g0 zW^aSQ(sm9q{W&Mj3`ZvOZmZQYOcC*BSq-kl=(x z{!Vn&H_5gvQh?<3?i|uG=!Hi=uEd8LpBDJ_T&%JKsQx&{yW%ACB$U=6YdKpqeGsnp zOuFcWz7I2@IvhIUj>3c2Ly$K{iQ4GQol~1cQ6~z;i1X#<0&ag@D{AwP7awx5$6r@R zjezJ4WGjyY8Btapt$*l%n!(bBXpi-2U45>4XHxA%UHZ)0uVM4-`AZlBTMlCB2?a5a zv_6$_bNKE3MaOQZ8RefG@Pg-Ggnq1Wi;AUMVZi{$@1n z!KRDQ%{p&vmcO;hu~2cZBoCNxMMbR+R1w5(LWS;}Oibf%v47giOK+tbYF6#q|EQ3E zOF2J3&;Dfn)wS>Sf&9pRqr!9CiG|F;N1}&f30-8txSO%EMYWhn6j;tv`tqwz=%OW- z)xVN#X?DrxrWbKQk|*6k${rJ!MpjKsSdRhi4o)Bi^$y~ z>XIEI9HY~WJ}e~92yKF_B1((PGYAeOKJJV}C~L=_b+r!MFEAH#9S!4CyAi5d9|h7b z_t?Ro0I@4H;V@8GAg66tVxI4H&HMXfie~oxW)Y)QG*Z| zAq1gp%)YJCt3+`@p2f@Et522Z%ok!S02!4|WH-1Wd#x-CnPr z#kG=_HmGfKWTV9`Z>>8~{3|l#NwF-Q81hp3PJeY9z$NInopn*cCjjBP;Y6fJLMiR% z^W7Oo*Q-&0)Bmm&Et{RDzmBS`_@X3(_x~$+#S0N=enIvasHImr+>&4SJK1;==Iyu5o)}J8*_z8UQ9N4OGpO9p4vK5ooXpV~ z8=q2oZBZ{)I9PX@N`LK&Prkl3OCEfA^qZtfT|)$dgofQ~A|FoBe}>Y~q|<7V0e>G6 zKVRK>DXg87PW|1|R__t^ZEF7&pIkVP9da+yj!yD*hX=(lu=FkVsQX$w|5f&E^txls zbcXpxzOlf(fqwnGPVU~3HSruj`&MKq2&2C(kPxjo1ku>C%@7bSko;RWY<{U>`-3HG z)6FGIwTAdT^~?2NfE9-d>~eO}x=cB>Q6|48Qj0 zyW}`$@ejsUC?qr-?!${3m8NAvsh;PS|E9HCqQB7NYsY8cQ1&p1xoZ(@NK_WvLvn05 zD@gHs_6(>P%zABP$hQ;f+xcNdpEt9bWZF+Lyvm@Z#@EUirynJ^HJ-pw0Kx zpoOapQv?yM_z3M?$0LTRF7)+ojdFkq_WWNv6WFlUI(^wan_~u4W}kO6hmjxnC2^M# zNPcgRHgrI_K}Srfq<Xmt_w}BAuuu6Sj=8fY&fLRdTKc;&h9zsr{x;dym!FZ5z`*p*l`Ne`3=4W9T+Z;D0uab3X{xJ(|5=vpdA_ zhDa#$+Q@#My3pUOWik=BB}=b!?sg_ooe}t2AMHl)@u#oUT6uYy?JtoumPNtEdc?~Z zUhRpUbgo=Nap(uhC23OUUmg#b3JbeYs#zO?-u$xonM~f7tC{SnbWU2#uXiveYQ!K> zKrKiQO6&Id&wu81W?1OZ^fLo>p+$D(XFP{3j&qBjJB$$L3ER~RiEB$)wPnv7hytS*)qkR>2L_+xYi%*=w6!dqIb8^@ zSoIdn1hCtr-6#&~@b29s6N0@Mqc^l>O#AO3#ujoi)V1&S!-1VZWjJaOszZ zr<8al_I>ofoSi%rFFa~y4Sd;JIY7B}qa(7cY&LJD7J@5>ePf5y=8nZU87NUp(ga}t z8e<>jp*P5F15+ad*-G|IjN(*MGJAB05%12k8Eho-8wR3q*O$e zkpqm?s=r96)v_Q^kl%N@-?KO606Rdld2k=`J$1b}pCB3*jl- z*6-UcPvo!28xPP~$(kj*$ed@FaaB(vpML-dL7w&_LkEZmgPyr?x=#hB=q@%^7chDj z-1iPSeH3TG7EOUSY@NA3;tU9%_+i@JDKFw4`eATS1AFWV&=vtlC*0Y9Uo4()tSNyw z+*3kV1a_Z6JEw z(3K*BbQKJHxu4L~8b~!f503Nh4&^}qvS7n6S2W~^C!WFC)2(qZ!EQwB7TvSW=O{jI zqSd{tjmQ11n148N>%Yn6tk&Bbz*L;+oFaeWgXok_+M@17``;;oIhGRBt$%8^Vbk_V zhZ~Vl@gggNxzyuGrWtqo8ULKL=$83xz+a;04jBv*DBTz^b?s)51sVZ87ru@6vav9n zF;U{WtfRYMSG3byveNFhcEF=8phlNG5*N@ay~>2#a^;s)s3bQ+mS$Xxm}A}4aC>TA zYc96i)ghsBD4sEO8g!UiB7c>qVce-s8}ZQPGzd7MiN3_z0JaBc&G{d ztshC)S#~1U9_LwP8{WI0Vrvb_-a1*cN=W|ukAD5R8-S?}V1BQ$;>?WpMFEGD;3Zbq zM}plCV%Kup7%ArMb_@JL`?a`u+&=7Cj}&#LSeqX_?-YC*;M=3?w14yp2FrqbG|o-< zADo1!OsIa9h*Br+#hD72NjR~ne^#e3wfCzSkWR>_MiH%f6p;Bi|H~e*YXNyGDDJi8 zV*VVrqj!;^mF2N8dIDT?ZI8^s#)luV(J7V{BQz%8g?!7KWa-=HBSqnnzp)8AO~DiE zhTpBs_^}kRSfj3=0Dtu8Ab7(t5!swKJcwk~;rF(W=7>&;Z<43Y%TA5axo28d@t-R$ zu{6Wd4F3C-(R?QKFp6zN3OMh`75LV6Ts{&Zy2rb|wbs^?viY$tDVsV~(CVjfyBN7B zD7r_k0<48LM4qZ7>2xnhSiqkE(G)ADuv6zoqal+MgtQdNjem)#aJux6%Rqd*Zi%dW z5aswKt;33@F_8SYkGM6-V7?f5bYsQb0YinwHyH_9(z$8ncK{B-JjM1>+(BXad`OBG zhIB+67wN9jlB2bpEw^Rp+i3$6C)yDFGFcmrK|o?xAnl^3cml)tsJ8)2U&wApAF~nf zqILS`{#@`6bASH$J=&l=J}&>r`1G3)=&52&b=_2nLR@FuTLG@2yJ zQk(;2S;Wu3nBtgnetc_7!(HKSBa&1G72<6sI;*4jDc)Hk?GboEwHbn4z6-6hKYA2E z5ms4SE`Rp$)T)7dIMBhv6*Z0TC-FK5C4JSFFpdOufmJ3x=l7CK$JY!D5UA)og3Yrz zXE6)TCM?NY+{^(nNGy+_Pugf#V1ZL@Ftp$>SKxl=& z{N9r~F{u3tKUL`n&=A9OB~AReZAzZ;NN>J7ynl@;L0fg+nmXHV>;OTLbZJWulkQbI z_vGT|CRQMl690l={)pbg!G0{tCqOupvWFBOimN9}M(iPxm5zehEdK9LpRYvP@FxI~ z!Q*5AfrnMkqpXTx5ILd+pBW1Q3u5;bltOjqAu+HEofF;@7=TS6hH~QYA zYj(5lZ(mV`tk3)hW|Hr=NYO+YLl@T#{wiuZ??QqEO?AjZ>v`ZK^Rt5k$_zWI-|H5y z<^2KJ>h7l|Jn#aH<2sb49EuG_8X&(l27gtKBu|-3?O$sazz2S(z>i`|ePx-8`*)l6 zWU>ra@|haJYb;RsJW@MkbuSQD=ko*@*nYXSZ5lMB;LQqZ;Hmw)#<_7OY$u#MAKiWp zi@@TQIu^t@n-(+JY4)g0a7bF7AK0kz5!SngVr+>I{ z&bF^3D)HOR_iyzRz**IFY}SVMB1tjC>W6~5m{B;rSh$WflFvj5HLTZ-su8bzhGA5D@`vB>P#drb$DK(8JR)6>N6}Z(;lZNVG2E!H(1Z_PI1U+w?#%b``-%}KVc+F ztr05VmRH`ZK*6TFPk@+hWWznPvqu2K!h{lnhzy?{hNH-VPYo125OO4T34c``XR~ei z^2>vkM|ceJ(tuV{N&sZxs8w8eSI4^j6$|5v?QRJpXt)bsif&n|R`?p2tw!}ry7s*-mn)Ov2UFQ1c4Fw#V6qatI9 zHo599DnKuG*|OTZZ&apFh<~l3Z${n4#>e^z?EJpdxNWaZSE{@!tIeF3eLW|AvNpU& z=-M7EYD(iH6v*roq=n>(b=>-6)ZFHKRcFr2wjuHu5BInf6>d; z#`cui{SllJOhWK%?=+33pex&2njMz-<4DI+dF|#r-4xme<`NFzFMl37uo6vPkB-LC zjoa8v^Jt~MsT_Xe=L+^I78q^H;(I1WlnLA2Coq>ZZ7Y|8-aMzHM^e0>Q%GYcq*MCn zw(p0F1VsOtGk%tMn47uB;CpID;P*hHc}`DLpsf_Jc}0ke=0fzMZ#uy1qf<6t->+xw za0(k8`SB??A`7F)bbnXLx|OQR6S7UaMLEtz*r&;GJL*-%_#HrP#leo*`L|JzKnWy6 z6#SCzM5oL6XPxtPV=Zs#)Tc)K=Wm6Rf@$A;>QvojgcJE4pOy6d0<)MkZk_lwgkK%? zw+hjV8?~%CBU@u=!w2}^-pS5H+!7Lfl>@kBLn7KF*+wYEjDJ>iMPPr+dPlnIa^>*V zsm!O@`N2QSfPubD7%hnjXzyyHS1kakh-QWlXIcGZAWu(N{ja8gqL|RWcfc2ONS`gxNGH z^+;B1C|H+_kbfBwwHO&Gl4_+WZpE3v$Dh+FfU1Hv>s!<6cg>i7v@j;uyAA%xRhuU%lxLb+n$ zIiI6bO)N+ib^XTQ#_#v(y2!co5&e*$bq(0*j~3JHA%C@Z(?6XJcuaUDFeO`>Y%9#wH-3ttlvxdFu=xk5sSXjT4G&p zJ6HR?szgyHAezB%CH+o4H#7N%DD_YEn@*u??~qHd2+9F|5C%^m(pUnE!*f*m{dq`E zgh{7yr_Db1r!hNrYUtg{dgl`>sB&HmyvC(>Sc(R!o#{R@2!UlBz#^%q{Y zi+{9RO+=kj57{p8<>tYzX=CEWZ{~JL{G_*2j|shV?gkX? zBQ6WdMq?qLgQJF4+1Elp`Mbwx>hJ+kPbXJu53zzI$8jgu_(*>vO!dWOSdR+WLsvU? z#Vk|IX(?Md%C=N9jkY;b=jg=u`rr7qJ%3b)y5DviMmfGkn=$-_0(RyF@p8ZFPgK6v zN~E5|plzxtg<)XvV?TC=D)qbpB?(ADNDuDcRjB(ty=?jgx0q;gydr_ zU*Tjbt*noyJOOM}g8=SMZXoVg%vI2O?O;iPFr%Au{0tyTZ6BwJ@!FaZdVDaSVZqti zW4$s?mUh^aWhRU*BT_CN==m)l5r6#FHi)4RZ4pr9u;L}6YrvM&!(`PkTUjhLrEd)T z#-d5}b9MjYM#H8fL?z|agu6RqZUjgm;R;`BPewwhp31dgmw#C;CYhLarw};*?D3lR64$3&0r{O{ z@51)dqh_Rwl(T6f=KL)iBnpcE|kJpQsfptGNb*?66HXG^xC<;YI_xpIw;H9k$&n^ z{j0F=W<-^RM69ik!{{GK{;_TKjB7%4 zGicH`NcHUVfy+g|M}Id{ol}*4iQ~ul+aKz3NwGEDWoQdt)}QAL^#k@JwL18Z{uqw) zCxpx!aF9~YXDvZ7%h5JQ$ivagcoCQ?I&%n`&$4*X-;{fPN54=O9w(>={5>xiJ4~-l z=m+&ij*MbQN|An?#fN0;Gz65+R6PMWnf18SlSAf%f(8{v1PMxD8KY11 zf5hC+0=)jGet(er_^MR{i?!dv*O$|p%UPBEmjy^z-1bX(@@^<~m<2N#t>b^Xx^?!HVxSb3@+8+9#l4z^)^QdtY4JK5R_8d9^yTj2J4Wz|Ut)-Toqu=DkrYm|H?7%XU2@tuyJZ@# znt$}k?9ViKo}WJKW&}zTTMbGjU!M6={C7i#RRfDCaM??pUUhG177ip9nBcd#lVGlR z|1T*gX<~_zL9hd|#1hK$>~wLwh0Fbw&LC~b@B2k8HsrYs`I!VC@^Fanq5Q3fAtR-0 zN5>DG^?!KF?xy#He;eLDR7r`h-%BHzAF0vaFZ)sb(XFR3wZKm?R)%<<>RYB$n(sdW zJ^o~jiP2o~(1T4Ez_dH`-XjLvk!`dbPEyOIhQ{(MLU$^8q~x!&D6H8_PK^%Fnvlza z1-K?Y+lCqA+Nh*>qIDtK8a)(wkK~)CXka%T$A9nifoUve>tN=GL=RI!AKe{L)RiQ; zqs20p)3;0mL#6OKmc6FhB!Pb2KO)#VM_Tb7qkcRib+QUkLpNRq8x$?aH%$`!LAL8X z?Z((->CfNf{(b+zanL{u=0SO%Eh6sbsP9D~%1;3NYU=%8`yGm!7N5&%V?c10xR1Lm zw102qSE$bJ1$LEz2{i9fNf96+3`{GY9J!pc+q#G$`UZm860F#O3Z`;J6HM!1iS42O z7RkOG#NCfW)X!dv@{?fJ1bkSxy;wkNh@u|jk4as%tk_SwyL%R~f4A4B)t3+YR(L#> z?`UgIeb?1`Iu^4ad7Pp#@D6-$P{Mb9(SLvOZq=!-*#nEMcyV)NGQr2&HcMGiBr;A{ z4J0G#m!yhns%`h5obg2+fgj0|ABuWh5kYHg2y>sz!7T={n+UM31=m=o1+8Ev-6U}dP}+x4fdpvK@U9;^bM)&f+G~qyk7T;ftQh_s6`CAVAlV)ZIF`cP zR7C(^ZlT-ZtO@t3m8#Vr(||v?Vt+{1@x2&STyuk39j5x8op^7PO4}2~^>K8xk=Pk1 z%M~4`@lNJ3Su41>FV4h%izn*MzA}a3Aqd6?3PNrpE8q{Lf4=1Zq&jolX@b~ARHrnm zGMapHt=v$*WAv^!yyg++K;|_RFP+yXmUuwF$XA(wdq0G-Qw-aG-W;|stx&47#k^Iot+a7 zP>h?2rCl{}pcDoRNbWU}n+g4~_ntLqe~pNlGBAtEezmQmypjIXepxXapd&1=hH3x5co(OX80zy>`^v3zBsnKjO3rg?$|y58MU}n3-%PoE z?yhUlcPM3zWykUO#eX4)uyUU%KH7YKTu_6fdX^LNBIchV`?Dr)_mD(e%&V?-_*|p} zla0SSLiJl4b0Cn0ZKUm45EWWl@rR;1hZ4y& zGsDmp-RiOjQ!^@)K0?|-J_LxgZ)T+iKC9+Bze8a_vZtD zt(kk%+JA^WF0ur>+!nI}f`+)7FP^{x;_<3sc|eWS6Atw^ueuh0rD~-cJ^u0Izrd_w zOH}oPhTAOO%TdM)=Dk_`u=1BcYUPh%4-(ADy68#uy>HE_!ZB-&s{sI4K&Zb*wS(zw zgv3O(KFAg<(}%pMD?V!e53n4kgztBL2{YBqTM_J%UvGaSS1A<{H;P$&(MVte7J*7g zSNUWPEjUn|7Y*9RR2a#^A3m*`G-L!ibjrpH_~IW4|DL77=l2CTckcfC%i*5@9Bek4 zD=|Het2jZP#&heeVV09CPV*anoo#uGO z2RlU8pAvt2&DzgK3a;^nGXr_eMH(j)GxZKLo;;Xqr)21~Ptf9S5(Q2#1Ch8MN=I%) zS4S+C4@>^5!<*~%4=JJ>sg?|Ky7QN8ORWLi+anMfwNALCxN2+L5{oveeKDp$oc>-& zr>_(1j7qP8$a-ik0l-E2l>`f-t!2UU+#0XWnoEC5WTBq>{Cq{!#KDfv@kcsQF216d zqAx5;+9ftYl-L+~85j}VryF;9uwJvjG<8=w=w z91njn-tY$lW1Zips&@qGXJ2~Tix~gBCSaBFiTw^62M|xDB+5wA?y85W#|au@AlKEp z?C!a~y9*`{ z2P0Jka{M(Zb7d_GShdrZ5+ym{K@pGa(5rtuJk4fgWEyu={ZU3jh0oD+C)2|Hk0IGQ zxj#{4AGFXO2(qUIU@SLP-CE zD#Gnm*GqMsR~Yk;Baj3*(KE0HN~Q%we4H7`xj=*B(g>)r>m(hsP>c5#+ZW5bXW)Mg zEJ2mGm7*QSVK<4jp08SvpG0o4s%pl}rj(g$r??U9coZ-TU8=!<{V@(x($?bdd+?By zM$mI+<#SGS_Snhe;=X=)9c>KV1c$-3afbuZRX61uSn26OZyh*uQJ0FCc_QZ z>PepO1K|1TL0nOt%zE0y97OoaO;G#ZBMqMCi;8G4)iq6gN~LfEbJ=dVnshWTe()0j z&++k^U_1DaF&b}twh*1b=_JTeXEVY9-*16%fXKTx1yf;@WAjJzoHZx)t9O5`X+l3U z@3id1it%O@`?!&L^|o8sDeRT)SZ zu97Hxa&`&R5cFN3M2ZEdcAVZK3lB5uA*Dq5^xN1xDtsHLl7Y>H)lC%Hm76^m*GDS3 z!IvOEJ`By~E|2`e!%BZ^Xa4P^xm0fOnyFF{V{Y4aMT#6Piz*rIA~UjY&xCdj+lvL% zZdsCX@Nr0(DVg?k?j*OZ`Lv9A>vm-h29fwkutJKAH#W-l^EyUSD^@M1>s^#9 zGNv1MG8kQB53Zg71k=dffgtK00T6ku31v@?j+5}#>ktbL0~~*qucW^Em=vZLyC7F% zg&6P$yPb30ou6f%{OiZCm7-VtZ-&zis~oLy|6%SE>OJ$beCC%8Xh22{wr`Q6LCB4N zjnn=Q?FR@1ukM^1M6q_tm(+>9RD{Wy=(BfB>p{|J5Re{7ARJe2lrTN;?)mN$z;2m< zO|1)A-;Fr;)%zq@Q4%LL;yTQLiMHCZQYBV6IBUOdKZkChlV6$lCz!$lHdKD)9f5@i%0Q4 z$1>e@VAA>PGD8-FhO~ELC^Q(m7oEQH{oO%)g@uS%(U!;o{@;zd-7>$65_zCZ7=dydcq({Ni5D-}l86AXI{jE?cs! z)YTgLbt!S!)dSOpgM{I$=RZg;6oV+bA;>~K`X-QJ*+eOj(r4^^f8NvPu^ZVp}5d0m!^x%HagC> z+MJd=$fP4>Mom?(f6K-v6RUEJ*1H_oe3gF)_yXO$7eFgd z)(hp!LUXtZuNYix|0)_LO8Mi+JimUvd)1J4?{p`)eJbX;(qdJ*sam=AliS%N?+sG6 z@)%Mke>y9r!(J{Mtgl7^ckL#9rgk}W30qWL5xT@eJB%AFxt^Z-OU4^?8@APoG}C%L z6Gb>X)%#);JL3#~9GJ%@K*IhEouhxA1;zS#)}?2LOXvnUU2lG#%uuT3cA{YQN@rrb z;a6nhJp691?6THS6ZrO?sTuvl7rsq)3{8Qd3uOMV{%T`o)zh5SVE*@Ee5M3roFg~> zO8WidcpndB9!$9toxLUQ#HQWnB*n?N&Xx=EN_+fc@Rp&op-95D932Lij^uw4QU@5W znHb6p$s@N)MADpJrFEbEwzyGIqS6tU-u!8cIVaQrYrorBDa`1nk`M?eI?5#VcMoBCO(k13t8LMKXaqGdb9@`*cdIPvoaiN3skwSlalg&Otvu~6f_ns#Qn$_Z>pf7ysvVU*WgFPD|D6c4g zWR1>(?y!MWO{IO)aFHP2`UypQwsueIddJtocl@6_L0zNj_JG?++9yD0KSM3+JiG&0 z5LXWOjE;k1%;ER=n8OZ;-hK{&-IuCdXHD;5oH>#KiF-c39gs*HVwF13lz@y4ykKGP zWD-&y0g^m2`6y`hE=Y)=Fvqi{BU4vq&js~8-?1#)C_uu0 zY-j=R%C$L>7zy8N0#SbpDXaZfES#OQS-L;7Z^Ki@_BDo^$PaW)+n{H6cVcLrOJ$_$ zOdv}aB;~6Ekf5k5AL0E^`=G-j%G#0p_l%pvj<_+;7jy#rGOBAlNx}xf6PwQk6?)IL zIJg2y@^zpSPw%P8B&?wu<63a!4rl+fRaG(AUDNA!ieO$bpa_52+0+~bA`{kOlNAbr zD`UJ)TX4to5CtuB*C=+QD)B@dz^&}q69B0RiW9*Lz>eypL3#fx?-1Q9!MFh2o$ z2HD;l4={8zWJ|GvgU~go%n_&oWvqn37V}CT$$5`ByqRrbUr9K$^dnqt zFoWgBeQ;N(VwZmi!-sHu=Vuavh_bH)UO;MmN|TpZM3_TkIES4s=vq3z77vIZjuFln z#>l=hT?1Wzfl64xd5F>3f4;0p2q8mn-$7c1k%p%R_qh6RBHum3O2jh$@f^N-btwZB zH3f6`cK5e*8q);f+UY`4_3U)>-*ob>$$92AOJsf-V*-Eka<$2C5DNsmUt zoejtF+A(SrRipNvRkQZ0U89Q9nlW0Nm{k=k)ZViwYS$_eo0=uDsl8$*R_#QMdqqs{)E(5&9z zPCI7p&||VXMb>UO?_#|N-FU(I@Gr7M(RSY~;2B;nmoOuYEtq;VTwebassDR{p<$B$ zPwfad_vgO>y><2gh|Hdfz-_lOUD?3`QacNEN)Ilx?D(eXUFjxk+>Q8wvCx_!eMtQv zSM7hv@!|$W2>VZssdXGsw<|v8p|zzBm$E|%X^VadPP{*i+T%oUM*0OWymfkTQabx{ z`bw3Wr2Z44FG>H@I9cyjHXh4#Yr>J9>m}Y5V9i&N{m7P!yBu_)E6kZn816|JH)Bw$ zy}rcNIt7e)2{8i8j+sy4twlb`dyC8Ho5_D&f7BsC?{e>wD_4^+TX9)a(nZM2Tm~E$ zRD(&;Vwvb^fZbxnmO@|r@0G8<`s6vT-J!OUS zd)qXBJ~CZOtIubi-o>qX;yY|o!%fj=y`?G&2)09rli+f0R7U?S4t)Ncr#=h4V6}e~ z6BYFsm+eR@_ad$>^N52rq?IUXiV+?wg)|N2AdRA-%XsmyVnp`m1&D|J>EzwQ^KU;^ zFHe$N#%Ra$vA-PIl3Xegs2sQ(2&6@N77hC4=BA;FY{HaiQTO zG=b4e>ZW!{cuw4DK&Xw*YWu^0^DlT1wKOx&oji^r21DC(q z(~IRbm{oSdQoPNCA3{NH9I&76ql5ca0T#CYaXM^V+J`M)b!~J^|1+?_Kw5t|qwl!E zVFP(#F?BG-qQi8A>f?^?}<$_6k1=e~GXwd%(47@L#Ko$DCQPx;~Q zPlr>YgGirR=2%v1dUI^byAREa-zk%Sgv!JUDp)POM2}0`UHtw1+i7Vx zdiVfL9LGZfzojKyZw}`8}V_e6tc1Af0${Q{>ANt0hX=OVEJ|uqt>)A&&i4$dc&vzdp31&y(C2=-T3aQHym8@G<; zk-(oLRR<#V3}z3-1So$ux`aCg0pAIOSuPVNG^k@0TeHe=iu$1+g<0KXK0LFsHW2G% zC7U@kC@Ae8^JZNcT^U(!@y!FAPcF-$+ae*fcsi_TPJ+Min5ccIp@yf)j6R~fzHp8( z-(fVO@xfQZ-!VyN1TvS5QFl}!Cb(|Yt@)rVayPZijYGCM#!`P-4qZU2$Ff?on#nUH zab~$}_BsT?Mym(Q=zSj+bq}C~^~nCj>qann`Frw+8fi~m(AGF8Hz}p{sjpU#2Z=i*T0X^7?r zba1i>MY1F?5lnx(5d1mD-Z;HYS3jHL_okl>X`FP}6C7~sD#~dZ&mLHXdy3>QxUBRJ zq#*nDlFxO5=w$e9JxzVw^?c1*7&pg=M7>e_pNLmD##kL|Fy00!UM-V97sTkW^?L4SBEo`G3V zWM7}!q}1YGlnHi&Iq~##eL*d-p-FlWgspSs!D1v-+4ysX`13-2VVIb>P*aw|rxDxb zV;dv5_X>Xq4Lhd6()DpLr=oge4I^u8G;-=Uv={v-{IxRBJ8fw+vhv8!={ZDvQ=~Bb z^L+T_23rqzEz5lB()lgPAac++kV5;A=6k=rQje4DN!4wS$qOpMeme%-ha(6BuK(1Q z;rb+}V#494YtW0Xjz~ib#r~r9M{-&=^?|5~;MRZq8#%oEREU<$8g+lG3g`UC^1Yc- zhP(I7|5=*vJX7aN`Y5bvJBtr2vt`DMipQQd_hsIuhk5hYR1FabCe^du1A?T~I83pT z15Mz>z8Y)JeQQ zvF3jlUa>(Gd0^FD(9HPC(~9v!%l3P9rHFLgVu1KyFQZPa&X2VaWgC(+on?CD?k`H& z*))Z-LYd64sc3KaQ|WGpfn;f+YTV}mUJvg)_}hOP_kiEF(gNWs#+gGb5GZQxEov{> zLxx=talMgPTQ?YqrjUt;9SzKP$YpVTPVs->vvk&;WJU__AM5uUcysh7Mk=nkY7Fh( z11wgQkDkB4rBPn-bcoY|b+oWq;f;%*@?Y3aN%$w6JxLROlq4z7JLhN3-rE;y4$nyz zjPBtAb(qGbjwb5P5nCmH*W|;On*Jk^`u$G=F!Ty=*v0PT9g4DhxIV`0wmFM(&Fz0V zJBikWWX&uJZn7&4TG`a5ft$>yFFq#JP$F%M_)FvuY^-!RBR0(~|4#zV7E_M0AraL+ zxiamtlii7m8n;h$CzL3Oh(S_U3kpI%An{UDM;FO1Z!tfvkj~XLJhx>gO>NW52jhW8C=?^vr%?-ZYdW=2pDcg8YBN+=5R=L zFNm^;x?`uU_pOo|2Liq50kWi?C4QNskM#aKtSSr@ev`G^inU(&sQhi2ecOLgn5O?I zjm<}WC82>d#-%+;tfUHFhbX)93~*ygrIXu{3Aq? zQ(ZUT-nN!Jmh?4G(ziqEmr+9>OtfRZr5CVnYlCakYQWqBR!&~FmIScIe_{_z0PsVg zPQss7x7@gd8UE8fzcuQI2;F~8tBDL2vY2^WnP`8fIq@3&6 z{#1sQ+cv|FFEDAdv{t@mGwXi7KOsx3Zlp@BUwP9E0RBCUQ(nz$>L#+;qKbf!FWgjo zZo{&Fj6{CwI)0e)y*Pj4M7nJDe(AE=I*mbY#Dyq1`OEyN5l9z3O^Z>R1?q1%?ZujY zBBCVwZ}Qc7@PvZ}&H%*Lfw(Hd`>oQ;tG_jA??seN`k+R9%L$H) zgsVK9|HQ7rENW-doS`)QYJ^osQtzR>$N8G%W1Wj)>evu*7`T7z5;W(G3)m9n*y$2N zsnWPf{=MTqn}V}(B55T8KNn?4W{gDQiQiv5x#q?i{`Udrf~Kg-bu_j=Wd+y(ld72x zSIPq!5y!HsPjmu;1#(2v4%eC6^}ks{%+nq8W&}*glvi}ijjBl%>X1g!Gn$*71*-km z69Xxc!EAIdE3ALTk+f?>WnDf}?89{lW6dWIfx;EUtT?J zdg`OEC)q}NI{+ICLw0V)ez}i5$%|*sznMboc$@tbB(RQrKK1e2 z&Vx+eg39Lms<9V`Mvw7kpku|YW2?}On9V$jxbM;?)Xjfm->Hq$|J!N_N!_B3z4kwa z7sV)wsIbH$7-bo+@+!(1%0IQ<13V>{HzI|Hk9v9ZnMX6j=_wuP39uZI-fYOQ*yc5j ze|qy7jdMs^Y0oEh9}1j7#fRCyb|l7r`;dOHv@$|aEPXQVO|(@^hUPs z(2akp(T#tw8qY!Y&MsLLc2G?TRMXyq)rOVhSjNm|E9cHTjUEaAwOd)Bpnt2_#D}$j zGkLdsA5Kc2A0zK!zP!vz$BC(5lb) zKZVvJ7@XcFy&-Wqx(C2yp+CWbsEfK}|E*+28$*8^lfAxw>bH3dZK)t%y8qe|)R%ZI z*1j+y5iXHMt*Z9k5iABnN0U{DX7%u5P$5rew}h>*^t2nM{IJ=0B^KpiX0S@ozFD}0 zZs819rMY|Tc2&sRg)0JihoX8Z*4-Dg7xJV(HX34Nx?b~%cIYu1kLd|~T;?+O#JF7Q zVGDnYFjw#(ui`t#gnqu1!%-|Tqt(QCaLT1Rs{wmgsBe^s)TA^){*AZQfO|byUvtLn z1UN~xVP&a8@7DeB&;_NV^H^78`f%cXd3X69W(wHUf zCQq7TkpOAq6jCh9e>Zne9<^*4C7X3vv|xYcTdS_eucNbnxm30-XTV>RGcHfm`%KrY z>H4)AeH+>z2(TlA5bgJf-IjJ~!a!C9b>)IzYTskdWv?GZ?whq_Gy~BTWHSf*bxu?dAg|`V$2|u++IwQa3N3}k#LaBs{ zga*4P6CHtojMv8c`eepGk;5ZHr$|@Ib!|HORxwU zBwCT_P?_fGaeN%ds*_1uM_NhP4ok7+Zo#y{LxbOSVwJ%5NDQKaxc;uTTW7F>( zdH?{40RUnOhX1dT=#mSpIADL&qg?{_L{@-Z^x8mf7tSpic8chCF3zSd!TqC3KQoui zC+(gnixqOr%lJ_s-DLehd0%A}JwCpeKk`!kuF2QF4Z%F%4^+AZE$8%g7$t`2{cuBy zNHEFhhgx0WjefqA7*Aiyl`s@U(&a^MLT@H%iI5@0TC;k^k3z^3J)N@P*(T ztaCmpv4C}SUYU-gOfY|@HY$f2KBLDTm(N5vY ztD-?OTYYyV6jA+^N?Nt&VzAv=??;tT_aN_hG1&W184ggc zI0p@LJdY2;;o;8`30>jzW9z*H0sU%G&(aLbkbL{gh0O;k0`h+%vR}RwRDOvt5`+c~ z4Y1n9_0UY6clJI;F1dYH{0Tg01%b+H^Fp_$2D!x9Mp+~$7_8LDh%0$ZU?LruPNeO= zd0uT!re57M8!Mas=*jF^Ne18V4gHzX${bVxP4CScPXp2Xn~&>HD_XNn*7b*2bnA1q zNFxI1vZ#`~C3JuP2)W*IdfFMy3(R`JV0*JrlQT;wnd|WfGeSK+RwL$5Z`7Gy1TFrh zhTBo^7mCd(b5`%$vZfrqYr??Yg9K{RoR`L^e*a=9(a{*B(x8;X9COSM;wp78rxD7! zd9@s*d7M`Q#Helo_O?95$v4T^*FG?vGDV zOoDVkMpT~7b$Q-M;bj&s62G0w=yyL_t`kf#xLJP^kTm-IoB3)20-QuyY+*dhw=9t0 zHhm1xPOAf`RX-V8I>Fj@2|e1wHEi>7`~A~NB4Hx!vqJ`>RgYBBQJANxceM8aiXpxL zgR&D6xfqn0R^Ea^XWB@Yg!Wd0i(uB_X8BXh(9ziA5By&pzhouF-Fe*u#EW4*x0Na! zZi_0a%jQKv)W3f_RAwLjxI~TzoA)Ez`ZgHwMA#C{A{-VM zbQxL0T4iv);qC^V(Q!&0FOaNs7?Ek9ec&74^S<0rO%+%L`_ua+4D(hC^8~sV1yKds zS%C9jcV&#U=mXWcK5IW4k>o~wO(}k#0y-=rfh^dOg#^3*+gE)oZPL(SuHTvNaiD+m zMk_xw_zmgLHOszGB27QafXDT4oo6I(FGzmn!(vXN!x!};2lMAKjOU2Wa1US^;`Gc8q(N6*#(_HGcN=FXl_u>)68|Mr}aYytHy z82((%$CCOg{IGA^tQW~6Nrs4XKQRYq6ioQB z+^x1$`3>P{=JS!N3sKn(i^hzZ6DiTIZ>wJt_J}!(Bj?`Mo!{7KJV`h}3V(n58bdBg z3P_11?FKm7iAUqrCzHw7g~N>Di<2hUiW=bv{T`xDmk?pUD}HaM1|^%v}eMtJZ81~bytpt zlPVXvTuP(~%r1g5|_&Lb`XR_A1mugj625vaz z%ez9bXcsvstFbCWGR2z?z8k{Y>l_I zn&L_*;(DIZZJshN-vj!eVBczoDN+Q-m^h|0a)O!_ZjVGW$k>kg zSPq)QXu!TL#B*jw)XO#}?|Tee)wuTfD&~nZ3(4x_(2EgxRjk*#=yg%-(M6}3UpY8} z$~W&u{~PRjSfUrqM*DxOkKL7M9xsHhz7W}0yy#L4h(o>Ivl2c@@n;L1%DgI5iOu@M zx)Fto#1xs&bO}~l;#J!kK!H2JzT)-QQ@`7UBwshGb5ZR$Wquu;G1Ol7`*&M3WZ;Kd z5s&J1HCQxbIJ*ZR^3gf5bA0Z7Kq_8=*B?LVDZfvOF_9`NRc3$OI;D|G&O*>Fg}lbh z_i>oVb*W10`^1GMxXO9@9oT!^1L~g=tg~ZZ=EU(O>drS;_Ka)N3nsT`swD?!aF1Sc8t5{|Q@1^1#L8J9(m?eh^TkGooz1$rI4 z96M+A1=z((ll^#HMhwU2egH`Za3C0R*Xf7X?vnpu>g8r zdiT_CO2Jpc8k_xlAc%92IauW6)MkhFh5yQ18BMYy|Q`Vt&VOv3!% zu-&&SE;G5l(#I7X=InXq`35j(2q1q7FMdOsh_^PydJl*O_e+g7T7j%$ zV~@nRvz^pL-`~7E{zJga^|Lhn9$@ewlo#r*sW;_)T52b~0vwSn-P(QjBV45&7PX5} z>Aca0AlIepI~rSOm6598Rl^Lid0=jSlbSJtI|W2<`Ggp=58}bon%@zG^Y-iemIdy4ndBhW&Z=LjM|rOa(1Mw*f%yHP%s-p1M0ImL;$Uj@1L#Kd zc`uDvjsH)e9^pN}v5$WkIjZjYw_L67z~bRH-T<552cf~r){wN+=uzz!X41!8{;_{R z?#>cVdgZD$9<6js7^?q10UPw0-H`NzTF}k^*hb0ZE*NKx-ngREK7*!f&imHRWiJo+HH?MJL=4iWf ztZ|BV2B(OK*H|ZaMBT!R%ZQ-GP{Du1=BL(P%VFu@*+~B6 zli0F}S_2Pwf9nb?@3|BZ8P~UK<|e)GgdECPK8K0poLX=0Xnd)oFE)Q3M?-7G&hOk&~WW5b=cbOyl((eu6pQVX{U#r!QUz%m8B>+TT(^Z`41v zy?M!T#vw#(V)H<7_t~hzAK|~8%X>q%PjF%AHS$xM4fr)@LW`%|9nH$29$!yH{~6ux zZwJ<>gGRD5^5JJgyHtNee~OyTBmFre!suI)SuK7wU?0`+=dBzpz7fx5QHW8^=AnNo zbfv98#bGFwaP82451_Vt*FGzgZ!45>Y;thnReN07M)*bz`0d+35~g1- zkeCIq2jK=y25C83TJ~7H8R7jGI+liZINeNf(P>`t?GaT*M%EZ6-ajcy>0IS8bF-47EX7a z373d^K{965==tcT4*cL;|(u(2|t9oZ_NRef~Z!Fns;T2fHQAjdR=8Bu22F_TqI7EHJ>by=ES|*+x^u|LfCVP%@&u5~+yh8Hc9s?r5;>&GnErx2 zZ@De>_fD{TqD=i0d}DqOh%(p4rWNTl&WrHsc^!XOwk2=sQ*3T<{mPJm%ir-c?S}Kr z+8N<2VXGlVbIyz$EQ`*Jaw-hn_WiM;x@qm~KqvmLu}g1|ed`>7xN zE#!Z}EGEc=Jx(NF7(>XJ=TP_sxOwtMc(n@5EGd{#7cvJjO@uUFDON^#H$xp>&St*E zisiGgtG;ZT6||F_;dimIi6qhaGh@M_^7@>f;H?H$pvISvg6hAs4^sRsxz%b)9v zE54S7OhERBcW29uLW>{P z{;sml5oF%}ehka-ltyue`$KiKn%YyyMI9~k1}&}sv$U8%tYqnAiXc!*kL94|^# zjLCNrjQ=$Ud2bQd|ADk%P7#xlId+r~{)4gsmbs2tc(S)qrta)LpjnL2UdS|Hh4p_v zWMAeK+2|ii);i5;?^v!6HmtF`4W7)2a79S?F|Tlek@3G}I#*;Lw+YksS?7>p*SsBl z$cqKMf5}8CIMRIj^c*aTy*mQ}nU zFzlkyEk{+zl3-iAk+70!bbD(rQ9L1alao=?$Em4%#y>HilNOh#-`*TC%~v?{ceFu+ z$DUS0>MZ03!!z>|E`}aHoE;Fy%@^3xJHrqhFKuxWqJSo+%9_||Mu`cyt~yriQZ#4T zz$5#f9QQs93BWSGTl5dZY2kl0u?dH2v&ugxP&e`yBzfWmPax%f-3_e`KHjGMN9H6~EZW833`bjlnCfp$VzOArpWWt5cXf;fLoyyKrg-+1K4 zm>gP$16t2TUBTpS8b*BU$rfD&!wDuo{0URz%@wej?r6Vwn};ED=hUhOgJ*SMn#wJ-vfl_v<`o80^r(K zKd#hYCyQpKvPeP`PODZMr8cSJI4lj z!UEmZh^GKz31YU$mmw?FdRS`Bg%%3n8b~cw15|B$_;%`Kdv<@r(4KyXcz9njd^jMm z-8PKS6$qItq@LX{!K_ERgl@s;fnDy%(DL(+(ly=R={wedVIKk!6%z$?xJuTX43#38 zrMMTgG``2oa@&+t`Th3IJe9cK^m(Vkaq|f6AE6wkwxXylKrvtRP(6$&bASaQ!uj_0 zJFUK6Twt9UP(gookB`C3W|@ikoX`9%?C*kAYot?XIbLA4Zby&Sr7e$DPS1YmPduOD z5+q-bX>@Z-q4-fjm#Q!3a`E`8msZC6sDTHDH(Wrv=5YOt@9*X54CH{KZEmsU!zvmo;7kGgyNX#)BnFd)Y_IHIVAzx6l*J*{FXLaqdk=70@>X#JN@LGJaB_$G-Mm z8UN_RA~($S-S_t;G}*5Y89msXHxw0Ohq;00g&yQO&eVDy*HNuB?H$t?JVWt4^U)qO zyHrk^5JzwjFLoJ|MqhBUt=HS$|>fI+|Vb+Zf8%_wLQuY_%S6-K=Tk~jdNVdU1T z*&#ZW;k)CP!1l$jLmHdD_*Z3lXQz+BXx7i|&D|YXfk=lBKGVPhjwgW&3;SPvQbA8G zN!jdaNBs)czsokDdXIvc?s)K04OqdLkhgz6)6ju|!n`g0`L8r*>&WquwilG)+3W!v z%>)}EHcj5K5RueD2wTdA@$3bOjp3j5nV0VhUHfI`+JSpmQ_~>vEto!LYxVnHR2t^kZG<+Dfv||iB4E_e=$Qj5_N?wm-dct~^c4%V zj}P11M%$mN*tA;#&L?TW=62YW8WdE@RN@}+!GbR+rreht*>=^CW*?<9r^OanDOga| z_0POHEJ}rGCPZtUJ5-kSltv}My-j~@On&4K&1w_R|K8~dJ6P@L-58f8++}B;dgez( zy1n;NdqGZ2^JU-cSccU=jSl2bHT*W>Lz=8AuNnuzjrgi;IhJBe=Aeec53N^X_I|Zv z>{*-L``#GgzZ5rzp<;vRvlFMbmbT>bY;*Z{rp$ad0O5a(mud4oGLVTd`YwOV+f4L- zcwv+{11P!LEfcwq)njY?^`JyDONqJBuRUA>SOIFg{pN)0y)g_C%14~uXt`T)6pIT) zP)JW7TdA(5|5HGArZ)|r7gN_k$o~ek&4Ocxkp21$mFSx23?$Mwrg^sXfv5eD{k!H` z)ovm7nz@>OWzy;p$c*$a>3e^`yi9l8v5>e_fPEJYbE2kn zr)&bJoDOu%qX!3#j*A|z{b^ExN3ss^Jhkn5!o2mxjv23nvq7iE204G_#C1t+v4@Jc zjDb%LCVJyO;T*IbB3U$F%shYdu`=lU<{Q!j?P`<_uT^^t`o^%W<5$$K&~_3tuj*0n zI_DRt70$(=07oaa-C#WwIk} zmpFR%+n2u^77ML=?ZSWcp(LuMe@l>WVD_w-dPcFPTF-?&|F(Nm-x<|2^-d6`fbNw% zzLSGY2jt{j(uO!;@Hq?vZOi*e&!6jo`G)qodo&rpx5#doZG$Vg1X`4Zyz7r*1SHuVd|X7)RMt6olufzd25kPHi;zB2s^2AgtK=ip&a7UR|y7 zi`r!)j*{ZlYLKsc!)9(QmhQ#=C{e6%9wzxMdN2#^9i{TDAr-MMWMj55&ow^IVMXqhr9r#m9J$RevK2@_g;U{s*xRY614An`QQ?)G^BC4X(o^rH2;SGOoXp&vp*hzUy^WHq@iLyCC z`RV|Q`Z2u5{W^3nR+$@RV86?{{3+C3wx^zv^5FoQKoXCiSd-inNICnG0mA0%o;az02LElnoxY9=a2AfDrbqkEb z2FE)XR1|*?S#v{!WI`20y-uWuL#mm@=AMiAvC>Ee@Sn*^s08?;Z-GO|#Z+fZ7@YGc zUKv=SUOv7!^>Z1{ex6Vrklhbgp3#srzjE9vVTQTPhUlR{2<%QXBMW=MVhjtr!|&^h zg-WLOxldRI$5{c-f%=8h?da1c&OigFgD^Hxo*93T<%{ zh0jTAVKIG`sAW5K$Ay{6S+q|eTv-lfcXU|gt|BmX(gN&^xXwY=@`K)7ZrHnI2aB)M zG{+=tBTq~uOnk@&{jT=D+yQWO=C?_o4r|08xz>ZnjAnQ<_Kdq4lVP;iqjr)O+acb3 zdUSu4g5@C!o2p5iOe#u;40r69lSqzksC(N)Xq9Y24dF8*uX-Eua(|~7?K9r5y1Im~ z<%zR~<~T{ioF)_R=o+xTa}XMw>f5=Nnw$J8e@+H)?rCLPiWAX?6}nibokTVUzVl*g z2(Ab_)CVEQ6=5m|%dBSirdueK);KnoX#9U9$e*}D-jQq(m@2aKF5?f7l{Kv=>#r@vZT=nQej$HP z2UZpq3JX!g#*}<)(nGU$_4PfVPbCbL*c)>v(1T&9ti1;~;KF}wT_>Q& z7rn?#!=4AmYgv|rLxKu>6w!jpi>s=HcO-Zvj9@<#o@&cLuD|d(klC@ZZT`bd)U2J= z>A1Fsw(eK2XFj_BsZW5t2gH|1HyPu^#p_f!@kXr}feN-hl|mWf&M%yTPlqRWbtZ|u zeiM}gLT`WIAXg*eRk)acW^R8fPOkH!TMvUC(1iP5IJ~g~OTne|9&R9-Mwt3T>rQmM zt??6+hAmj5{-R~o%Nz`G1I}x93z`HWVloAOT$Jg>XqsKa4|9WZ#+LK!wiQ-PmMs|$G zC6pGAoKBM28}%sd9vyi>V;|PHG@fv31E~M}3QOQ5Q5MC5es`qC_`^u8mvnq6fa^d$ zQU0%ehRdbTwrhy?lg@t>Spaw!j9t9)iz;xF-fWk;p9`wfw{}>&t%})c-b2i6+94s( zvX9D3AVuzMsdZfPeEZhs9zdKs-}S+v8h6uw{L>1!=I#SL{2aiT&RVq>&7uGKld1pj zn{dh$#tLD?``d3`UH;fvJDfR5gA3;0Kq1?#7yo=qvhDsv&(D9J_ofAplgjkyi`&N4 zsSfjg9jC^WnX*v|B_yw%#n$n<+A`jK*7KN)c)El>AB9^LT25~ zDbnLEMWZ;;?LaZS5gkIUU#u`aT6r&H2*#Q9w#yZ+8#{RDsiG&y@XJm;qUcY3EnW^Y zxNt$^>Cr+lHA=tQoRiUiF!(NFCmz*tvk*5O!pgsm!qcz}!?LrQA8V!$bGQ1bu^VQV zhZe3}T|s{;Rjo-kAmoBk^bcD+D=e2s&sLD?#Hm+>^7Hzkx@>bnHmh_i+M>r5t%+$3 zBVN;5U3y><`iaE;SYy!7pn?ygyy?KA#}j_?ouNofV_AW)H5*8DRNbS&WOn zFvT)dyH%{diabJ}t@hXYkS$BwJ>62)Tg94;sN|w4;uAW}JZ_{sn zku7a4d6A|(B`TVKQVv8H5$`h`qgfU3z%uREMm26eQtk{(_VhMZ$CN`%V}ma)9rBJu z&qH|!rji5TLGuGqIb6>>XZcAK$nF7!=@L(k9I~iREvMFOmJg{InrQd0C$k%XxQM4i zOQ*96Gk6%P*kkw>|KP!DH+Q$~5By;z^8TVLQpL$3w= z)L1*RQ?aBsvKG>(w14ff-#ofHrni;M+n+Xt8})L&`nT~oHSVAF`Vy|kj&lF<)u?n4 z&3=Y~3*Sfiha|ghvU;63%U2Rif-U~*%qo)YU7ZQDgB>cpo}M@HTfH+$=Gn9U@c&Gn zo%i*r7Z#&`b1ZOSTNA#{k|El_$A8~gExVfSuHFNB1SF-1rjp^QuK;H@n&`V;P?Z~m z0M~%oL%eklcw}ybjg7KrWN3QwM^HOCDowB1v6!^?0k{?npS{EYqqh~$3^gi=9 z&p`$`4GVYGmTWZoFuVaCfc>W3O-V=5&ta~(^v!~Qm*>57I65#ZZgrqjsoyDy99smk3C5RdGK zS9A)-{fMz-7(F{dzw4wGAW!h9|KJlI(iiR}mC~|v4`5cc4UEb8S|^9%UeLs z$=(&e;v)a`3$DdKE?6=%+2Z9oE`G$Rep}LiWm$&Th}2NwYnpNFj_PiU6mM2K972Al zJ+~Ime5G8SkX>cOKmJaKP{9FP9a}L>2hP-pm9BZG3*4ankwU<4of1BVY;qaUp0=A5yBb4 z)gW8W-P;zW{fMSTnAstnL@H8|Xi&<3^cZh|KDL7!REzbZgU19~TzT8NrvwYvhIv0< z(X6Fy5r>SW56htK$nCKKvC6OS0j_h+HY}iID^O>$u)!=$`taNP{v!!JlA`u%J}H+g z$JU3{#gG2=hmlMsNDphNNY5%$a`PeWz+w7~3+@zbZAh&%fFauOtWx0$YQzSA849Bj z!Lu@ypg2>IcfEuoyJP&((XhhA@24?ZP9BdA*fPjB&#TW*69N0=1pdYmUA zew(V)?i5wakGGHVuPBzjbP+;hRwHHRz&iaZ0BpzVK_LyMG7-W%;Uj0`&e+4l;Kree!DnDmvAv|2j+Z3 z&A`eHFJj{46GtZLswO(b*;s~LFJ4GNdKptO#WmIW zr5qI~@W(pX*7`BAmss=O9-u7s*EzRJOpoL3HxYg_W%k({r1Gs6ix;k+m)Xg?{xg>&G{&QekaWu;^K4%=-&efR$cwn{VqL) zJOlSD#wu5JR;imUf4xzuP7%()+C{aza9RL?#uERQ3222n=(k|me`tMt&gUeS7JLTU z&qK_0F_#|XlwNv&Dn6Ne@)>%>J6fPqobIy$1%%jOSvUW*VFlk5*|zq~l61SBwJc;l zJY;Cpg5R3%WQ`_%jl=dge>*7lhfN5dKG<-#N=bJ>tyr91bmnh=@hJVGGO=5+z#aQ+ z`b($MWj#gCpI+SG4E-#JY{ zXXcxWv0@H5)T2lck^5ESs`X@ujQqom+n*ZTU}pPFzbEfc_+mwfNdId^hbu=8z5(Ug z#p8`j`$ByyLHae&^1*brQrQmY|5%=G#WBU{ku$AB%w9hdX4upIC+fL9-vSNO$=fos zQ9Zhn{W#`-O`5%-dTM^!aVlv4^u6BJ_rTuPtyhKy8dzv#E2t zd2lA{gg@(#{4WU>XPb{-W%!0GE4}NgZ!?|88+mT^Qm>`bTaWRN%Tf4xgZx?bdY)2z zZ~m*C)G6!p#AY`T5v*BZlc|zp9(N;T>B^e38nsq zza33$R(fuH8HuHhFKTCBQ&znx?ZFR2-ZQZ|87e|b?g4$gT@M6fRRD+U-0jIB$HCU$ zUP>Z=-i}AClBL%N0_rm(XTlY{iSGe@vONO(vVwYZJtD5MR=l+uuFXYM+D>1GSOxIf&wvScvS5~L4#nfD>DE^B~K|D{MPIMRX&UgJPol5BKp_twS zJ+hyA+b81Zb*TopcMBcib7FWKbg~A%j(vMB$wzL6n1W@CJ*QUzI+n`jR?Sd+34zpq zExeRo? zFmaEZ@8OGA6@i-x*iO}yjVCSQRQpzcIdPxhn{i^E<5!G1LJm%DG>4JWi*)i#$J<_e z#V|>%__6v{H{+m{(_Pk<^Sm;9HTUeXGiEP#XOTDfR05DOCKT??s}xd9zg77Pa8*P7 zW&PQWgGz&^L$ya*v$>q&{N0bEhj_%0NQ3q-z9fM5b3;x5UcJmM;OwteVF}xR+3Q78 z9nkQRC;u>Dh;AhrbHyuinDMcv@WT&01lRQAP*Z=dOWkSL&d3YtT!K!0XzeDOQoG{V z@{N}AZGiUr=sh4HW-#cK@^0}?XePwC&b3elBO(j?(R!5+zcR7be*t6ej6rIdW%KU^ zWRD*v9S=zpyzNsZ3lTyTnO%+g{ner0Zi$5_4?N79-iAWb!hec8ZSUjRC+E zRRnO1bvSQ?8B0+#ga-^m9Y-0KUg?4Ug_Vy-;z|Q&?4I680B-zYVN(~Wlp*ghe~O^{ z=CVPTsULg%p+}TVK~5GQYai*@lMT4Lb%b=dw$pA*1X9TUUD$JbPWiEaGn_c}sxkC0 z(ZKndzEwkyaH*{Y?sDFdrYk!@93;8z!e5f1dk>I)HIHRPBy0B3S#e=tN#24T{>k$R z`ks&UMx>GtR5!IAeNV_AWZCNrd*ZGM@A%V+3Of$YHIq&e)nM|%umAk{w=dFnR{7d& zVkjcyL#4HrbP?Usx?3NArKnTw{=m|}A9{WI_>{7YA6PXGqOH{EJ2ouipaC^HC&BLB z^u_VhT85^-(ErENUB5NqFo4=0(jukOF&ZVMyF^+X@|7-;9HVo=^Z$mW7Z+RxiOWB6ZWziWq^glh|NkL=gQrM*6RfU?J{7ZwnefdZ+m!2wtwY~bF1VVe*_&tU3{9};_K z{MuMsW+bxIk24v6_`xSI(n?JEtOiQ5b@>Tk2S#anRrf*Qcm!%OxHKE8-6;%&1Abn4 zho2ApX=V#*ryDK6jumzZlhm*BSx!iL{t65;cyrD)^JCu|qdjZ$)ZEIU>ZjaA$)l>( z!bcVOFw*ufg?S20PXKc4s6(p-S>{#ME=$xCAiPSe)akN+!_?Y8V96+;#s_d*#ZNq! z{I)3@i>4d=1Mqkh9AK=IF@QQ*=yKVsZYj}U5`>>-51y15;Ni0g+kV-T?clcie@#fk z1{QXpkEo7$H`>P(|DHSi2@nd-gPN+<2)B*Eq#(?H7QTIT|3^r@ODGuq1OOoXj@rr> z7qraYQ~K(ESNAd;CNq7dc4uV`k!Lg$JfLk7&JxmxSf`JwA_R;ogyJ`n2(h_>B|~qv zJu0(|w9%x2Fu5{QJ4zSKUGKu1dc)=Y@}r90FERh@P9mSh{Ng^PX3Y+nx*{ce0$AHU z=ND#BU4CD-EmBm2pz^g8BTrHm-wv9a)5g@z8rR}~fnFTxk&_v--MYjEF-|Dp&jd<4 z^*sUT?)ht=QNu^H38=w(Uq?P+6VG!2yG6=lS^ZNi*)PqGvbtYC$!_V@W8?<)Xp8zY ztSg(qWgqeuc6&XN5GNq?(1_KI?!n*RyX^r3K%y&x#=#a;V^KJQ(y0-I^mF;(Vn;K$ z8*H9`RRynBm=JC585iqm9SWB=C=&~nHrhx-0i!}r8yXjR@*$ckw)h_P6~dM=u8Ga& zF>!CO51wDFDk2>^!#5!{6j0^S!9JrlE6Lcleo6)fWpB2CiZq-crlaW)l5{=bbN2yw zsHF+yy&EgJT<8^~bi;UycqiJsNwNG^mB6X|qArDX z$GO9N!*fSjeFPqPG+H@T?p;eFUvQ7 z<8Vbp8irWW0zCuKt(zM3QVRwH9u8#C!xS|S9a~3L@23=zGF%hg@Y@e*h#tZ6CqP|d zZ+A|G7QRRYG+ALmB*H#ZKL@!OxA(D7O#V%?W2mar$|^swR|9LRtVSF?bbPeoXDHzT zq=AXey2q0}RB@P5K{Y@IbFIOY;BS9_4fK*bXiU6!;y>dLfDp@?sKGqZl&zX@Dzt4X zJQ`fVe$ws*c`YdV_w+}e`3_@Cq81qmo$HD0Byt^BZKHyQ*FCx-o*eof^|IAu`w^B} zx>fkHWlSmLu`06rt^~*nfF*8$mN*UIzo0|Nt9u7a$M&(z04%$-4Bw9}V^T(c-n(M< zK^?5ND^CEzNs4`1k{q%AF^{I`!A{-~t%f+MoVu#vqzk3yPcMFJuo$M!n5uOQ~QGjooGB;PXc?DmD&o~ySVGk1hBlS@tqe*MzzwJV5@1W3?UlG%6?D1@4wO7{%v5@{H@}(pPui3#SmSb5=?`1 zAcyYW8MEZYu-K<|k6Q`l)M|yJ8J++^j=9bAjvJ}0z@*YU|0yxtZ-1TN5dfy}%VF;^ zedx@Ms><85aG1vb9z@xAQ1;e18Qph_3%Kupo*5+~IE-S9cy>2^{7ayoTg^ZIOsF}@hFFny zE#v(s068yvmuC6$4a?+(A~V}gLdqlxZCbU(zch)g>93+i4D~2Jkfy>30~y1pGJB#?vDn#G@L!g;F%w^Xv`z4tv=S1C z#ZgqAXVhHPplL#XIdib$WME+HIY@cLbQ(26BWp>*1QSj z)Jd?YjUmWc49Y3eMv+Dcx0%JSG0iqC@tLlhPJf+~n`xhBF*Z^)PoY*9!>%*(m%+7$ z+H9^zEV*mQIQ1}c%~X3Ru-C?aF>-29dZW3ly-6e5HCji1rhY1AHyK}3b1V%(P4-VZ zmGzNiR(7ViFD?vIFRyVX&}8XXOCz!HX|8ffJ@eB$+BdQdmf*71%0k&ck3hMk$#NU{ zrms+)d8pJaLo|N0h#8Rv*oo~kUkv@YUlM6jkHR1CZFIag_r=k*`m}JC&8C0j=371O zX<=YvpHm5cw5LcnSy5LbJWj=mNqI+?sXJIgVi0l(zusOXiAT9^=e#;xqmLdBQ=z5& z>3F)UlP##cl@bfsiGJaGEeG`P|BXrRHGKuIYeX3vUZ-pU$tH!l*%5sdhs%a)I8#z& zI{Moq7tYJT;y2je(P@3Y(I>#3X*J|9%%)r}VhsX+S>@X2U61I~l#>2V?`^FN%A$Az zxGT%||CGh@c0%S5Lw!HYRkbT_5lE}Z)>QOWK>Zy4jF~$%=`U#ZJ9vA(GaykL+#1C7 z(}XzMUz&wcnKGk1ix?W#m|I)w7B*Ek+ru`6q=RRaBl|R{evbn zOFRMegVjXCq+v&Yzjc{D0qS^S|F()wH}?Ljj1km}amoJWqlF{J$ToB0aB7qExIPLW zNl)psA2JSFvDAC#F9KC^#Nl{6G06N@J9FAZc}cuu0k860(>}4nwsmZSKI;6z)H+dr zX|@DID)xD;&36uIwVwcmhV|~@F1!R$nju=uf z@|uR`36NU<4?O27(zA46M(xjO+q9UmmYj!C%A(E^(&}nyvHG^+owzKiJQl-Hd2$Mw z5jJ~uc*_v@1o$33I)7a78E!UHz|Mw$U7_tfRv64Wvz`+4?%>4&yC#A-oPpeo%1t4HT@rdq18cP-g4q4noLB{@a( zV9UlCDb$dc_VoIqUo2d6dGl@4>Jz|Rjbct$?3#)^f3JlJHQ(Z{ z@|oz9PyX+5Dr)N!S}sHualQ9NbOHxX`5{KSyjX%#f8<-a&fFFk*0x@MG*U(kS;u%$1<+l@6X3(5C8lrl2|%%`MDHw{Ts7um?n}sjWtGio8}i{-)}h5W ztnT3fSq7?sv>bAg2ko=ob+SihwOm9*s>c4Y`>K;Vo?t|;Fptx3JXa`_rH#mmEK&HD zCOphRgf9K`ikmwrv39OOQ}B?S&{*!LL#9vmA1{}<;6QN`j=r8X9+YVrs?|2tgjLO* z%C11aqvN-8vjVGsU#P|R4QE(8`qyHv0-9SJqk&5G)g;}bs0UwHe?LU*AncM3_Nj=7 z978!{)c{t=WXAkl6o23oz?S&GJ2RcBlXe33iz4`pw8BFP@4$G2ugmX`l_pzyH0LF2 zdh9+}MzN$razHhQ0+=$&ruV=x4=g*4S5l}6T^#=T{CWs~sxrwN*pnvy<_&fNO68;g z{i=_V2`UmDZAJ_AA;QV3cI2@P^IeM5;Qe(yMV*69t)?p~L2=0!a`zSd$c1tkY$>=G z!-RTCJ9}4@sO2_jk1q8n@hqz`=5Dr+&LSsjAulreCQ&y%91>p|78Vhm33p)>x~eg? zp<6)-eZ^dVqmL7oi%DBC*JrsOTqf{vLaG1|%_jgZ*3{*4 zo0O(m4s-bsbY?7Mwii{^uWJD3;nl#$-*7Kw~^&JbW*Z8U2W z9Aa1dSn3e*hsjEyYQm*K`TK~@6r>!v37N3k=0Eqb;$aV4@eAIcC(IjTdH7uKJ^^G? zXJKlEN3e>3pJnR_^RF8$-TarC55H)+ENHQaJAG z0C!1U`>;YiT(HY z?|y4`1VCUC=atAGIere+ko!KFSS&YC?J3spi*I%{jO7gvlXS8T>@z_m8#-5G7}Wt+A|Xr?xEizK3Qvd zfapK2PCE&MB0F*C=+walaf8?53s-yJT1a&N)+=!Ry6?jdRKVG6YUC&0mrSgG9G$(2 zDB!Ew+jpI`2xB1ZW1+Y{Sg0_Ds`UGRT#6aO^wwCXENto7uXa|mHihGqP|G_XQH0~y*{DEu_C+in`fjEg%=`!W6^A4 z@u`tu;|Yq-we!g9!Oz~9@!*ryXJD)7n^1uE>F?UB& zQv$qZnfDica&=C9eg>6#`tv6&V1AxcG?AGjt+eqa)oj9e#$N!GTsf+;DREvXDs3nY@je-kQp zRrfek0VE;PSn`alxsU6nfVuWBFtIY*HBQuH%Qk!O(%M1j>U10)j{VSjiTY(}060Dr zE-JeC8M>#!vMrW^pYxc1p#F=@{0X2>ApN!4&G-p0u)cKDXZkh+MD-V}yJtEpJQh&} z`x&;!q`F_KEc(vVQN`pB0TcRs{ric#69Fds?(tQXMn6IuOskyb=3cw3d>cb@{?l^R z+2T&+$DiqU=UH>llzjkLS7yCAWMD(N>zD-}xrjy+MdQ{?ojH+zoa4y+Fcmo$ilQ%4 zI?q_%;0K**x~w;*3Zv z4FbiL5F-#no~Zngl^d{Rr9LQ6@QumOBFDD*4+2|+Z zgxc$C@~kyPfOkh1*z^m6XS~g0b$s4@`3j(zF8{7o5Qv9=3xFLUCzsSgG|*@@blSkl z7fgJ=E)}7#Rl&ij`K&z$0hy>gsS5`HPeoiDDZkltHM!)$(-^&~hg^^K$O-d;&s4&- z4&L?1{QAq|{56FD=+{aJKiZho(DgA2G#0?oAaoOfzEd~d5E`fRCq&53y5pBH60 zTw4+bQ}PDe7Dyx1mFDmt|K&K*!y1o;vCj8S9?(>eWc;C?$1o4hkOjz?>ooYf!o}N* zI$gS(qCO%;&02grl%}U==Mn#&*Npg{0`*0Vsx@UTr&8ge!lA4^`}3@aQFmQ958eiw zjTMQ1_PNRhkhGjq4pPEyyH zV$6`uvcP*e)MBVe?58~g)L>ijvZ2v9^v}4H`&X<@zE3)qtZ(E(&kJZi%LBQ_H_qsw z>7DepkWFGat$AS+jnAp;%5&!A(i%1lqur)|n@ld!A6z|i(v#)5pa1jKh1?NJOL4>( zff0Bmkd0OCCqVzrAVq6S49NKpJH#5x!ug|J-HS~Lo%-__Kfk>-$~vMeTPtsmC~L0(@)kU#<5{A2G6`7vtp`iWV-HIpcc~xEH0*7k=jp z_q(LZn(M5uTlv-LzhV1@HMf}kIQay4Ue)>h9*CO!&O0N(8(W^)!aBj;5|9m+c)X~m zJrUXK)TF#TX!^aj{*Q+V-jZyoEh|oc@>krR!Jwq2WKxnpC;b4zY}`KqL4&RiXEuBy&y7Ci*%v~uOsw-OiAY$ zQV=xOwSf;!Gv@t?!_4M_b#NNv;CBj4Q}_j|Q0oPa$tELdgdg&52GKe71UM^Mb?Sd* zEHc!0Q04Kqk(dQR&2O2|07vkS{7%xF*!FCKYF-*Pau%25Y#erde$bsbGuQXGNxhgh%%{e}EQ86lkVFm^UK2~ccu|J+ z9l&r|4vg=yUf$(RTT4^h%h8>eda*w$l%(yok}3j+|IzF9=QJ#ay4Qps6=PKNf6qBJ ztV-$5<7=e$+SQbbn{6*G|oaa?TKP_zaXWYTK;lZyf*!sByo>_@dZu7;o_M&Jp`4zJxzlW)yVPXe^vo1gE0LevIGXR6zwF~! z2z*8J*#ldE8N(Mn#@I~;jsG$YlR@&69%gtAoInO1shfx`N->_0(5A&o`rj)F>Ur55 zu?qjN-Obt+hS%y_Fd8;_L%th1F|7sFEj#Oy<6CJ>^ZogMGDV!h2IN5LJz}~SB%0=b z5B#NO?kyg&P$fvu_~N6clWWh}M~(ppiq)OeUa~y)7kNJTfM!62(2M6s?xZ28HB;FT zs=f%=+byZLT@AE{eSMy~X^h}8RR?z8hZ$wJpoSCE?<7C4dZ6Wt8&pvuZ-pP+22F)g zdeN1Zaf;4=BHUSq6A!(i|NihYkKkD1UHC#a%R0G48*NcEY-o*(ZfupICFh0$S+8@>m{XnWfJRLf-ZP2%@k$}>fE*8g=7hFZJg z;Q^U87)gZm8Q)<3)xg9sdYj7{lk}RK6bqYSvwhKjVv%`s{PZ7-7z!ivxTeeY#? z=NN6V>t~!~NnsLX23i_e9jeRx?{N&3Xy7#+rN=Y-_odVui;xd zXzZnb>L&ot$P4WeTDB70rW&grCXYM9V_8l^#KFKNSo&j4BwqpCglqI&zw0xx^qjjh zbS^1lZT&-fp2qx?ET@s-{^IB1iBx~?=CI9W?o@(UW&^D!fXk0;kd}*)DFbRg=A@uF zy<-1=hgM%B-Kb@q`gx)eHny)SzF(8doA3aC&>snUvid9y9%pfeC4uc`HpQ4^Bwm#p zpf3CNAfVp9B;kFs} zi2u|Hc!u)*XeJ>dWA)Uf?xxqmZ-lYNO>m0_5B--x?l{0UZ8_(^lHe?Q0tTc|(TCc9 zTX05QQafg7q?<$8;%!*oW2rFqdtHlkV*-#vfsXuz$?7xylOk=u2J@jhgeEOI_JDUj zrPXb{owOjrgMhE`T5}$6`syoNvqqYKSnfGqOyJv3;SKzcO69t{MG3v+h?;dX;ga}$a#%n1_359$e0mo-jiTu@RptrAU(>aYFoVWWuWBtniJlj zyw;F zKhX0{438BiIG`4L^6ZEi|bN0;3@=C@Qdq|r5&Kl~}zb3vQ&A0X%?V0IU z2)vy2D9TykQ@xVK~wK9H(^j;jh)G43(D@is`DqWpvFdrNYS4L_$ZD7k3P%wE(X zZD){Qddaj!gC4xuuV2jt?D;cBT`rF1gZ*Dlx)vrNNKXt+H|TsXgHo>DBrLd7sD*=B znn)@UuQUdch4(75Ot_hbD+=o zUiTqM^#)fVp?+&uDss2gSEUx-k~H{N@%1#zFseV#v*P9J1Q}2GjviOL=w}5F7w3YT z`jfSxX=T8z$Nf0KCBE{1NDugmuj)sGq|(pEAeJrr6RwZv65lCHk@#wQrTxSYsY`w22r$LDu~7p8yUuVW-H&E2*PZ<(X3r1BWZTC>1!DYp!~f zbhm}=#8&d8;icVwxBkTXy8H@>F|v31TQsumSpT$e{oWh!PJ!rzcqG6qIwKxK;%A9- zK3f#qH^7Fb$U;nhpW=dp<@Bi&+Y+6rxRT2rL4TD0f$~~RiS+%b^H|8SM<29p<1Fmn z9D<{#t6EIM>CqcZV~wLp_e^%{RU1RPP8h*!tQAYfAC238>m-Z%(=Y>()DDOT#nm~r z=COh34akl{+aBvXn8jn56Q|#0ro&Lg!&vG}ws`>HoGpW~78zG3CELFc36D6z5*RJS z;vb8le^@xU@WLieoVn`ED*K2~Zejl&Z}^=EvJ?BB=qKpEm|qT@P#km^jl|yX{6J}6 z&|G-v6Tr@Y#7r%*sb{l`RIyB#p%wWV4G#nw1fbZrjMNf|Szn zT6~n zP})xbEIU)Chs=A$8rv~nw}8TBa*?c#F*eubh6odXgg}2Rjn>hM+hz4xH+K;P2)M@w zL+iT2+coo-21N7dGZ`07^5RE+xSXfD+zhEb^V#h%+r*2v+TsuQXP)Jw;rKo0J{Ccf zGB^|e>P6Uu)Se9YNWeFKGEzqWkw?5cTZ8O{>C2Wh|3`-%s4}ROyk=`AP+I6ZW`VC= z5t-h9Npp0Yfw9>Hg>8WwIC`RH2b@QBnO@=VUc7D{BiNR}apsQ4*f`0ZVBJ|@to;;K z9tYACeY1QGXHOinp^UahqfNh(L}7mhsDPq>TTV=qp8&X&vscRjVXeaNOl{L%efkgd zp$m`hD9tyb`<#>2PE@p3`W?amWccR^5NvjTFO?L41I^rIpRY7jI<56(%p{mC(BDj5 z8~JMFpy)es;)>pEPxxO6uC9C2Ps&HsV=KP%iPg{Gp4f+h$F$9Lc>|rEFf={z6qH{JzU*Rn5s&yqiUT zbyeTx%!nN0rq?-p<{nS}1h9doeaukY8I$xDq^?h>W74kdyBmW`J97(t#z2tkq$4s9 z@!`i?3XyF3?!(nYR%5OrXXNG37_QeLN}7xJO0VvFGuLdJFZku3HbVKuJvJnM<_6;D)A4LIAfETi6Sv*$Ip&u4QpKP%=b7*Z(kj?k+9ol_2%iPyV5eCL42R;uC z3%irYi#?SEYC6yH|IQqLlLRTOyd%N&!$k{R1^`Wk4Zn9vF0{+0CLV&Tp`2#FzgZy@ zx0_z%6>1ONM3t`dbFa1WM`ncOp-FpN{Sp@Li8e`D+;hg~NXmN>^~IL9-NVJmEZykS zIkDLiGY+JwNZ`m!37hxb2K00f$ zpZJ-6JE;0-{k1R%p3Y-F*AB8NJR-Bl`sjp}@J52>kkna)Vn2;h7$w$EO0aC3WVa^k zOGh)GM83r!a>i`s$U5>%J*F~GVein*bjgK1tkIdTT9yS9Hld%7dwhIZ`hl+L3itB< zSK;tMZ zSC(=elld9_+RJPSehnnM@}sq?`xwHklx48vPZX+dFsl=G%C0U(rXFFff7;^TXwA6NjU8@9_0+S~1WI*cM(bn8>c?}bKQD|e!z z_IUMZqvrNdtB(n64Jj*P{xtq6Wu>q$Xt`h~_B)syc!s9mv&JuP74a+i#?ThFgUvuw zpde4r8RSoZS^k3JHZ+F4N84dqCmq((sty;lVRitd3fTfF#>FHcw?vCprGKqpd=3eSj1 ziq+D8wfDnC)9uR^(jQ)aIhJ>e0$e-+!q*v{o&YxMl!h1|qZ5^ZfETpp*lMMcBc&K` zHsy?0oM>ULXkq)R)c>$pnWFBZ77po-^c@{`)J}Glk|Zo7^t1)388<@AG8jc>P9KF~ zAJUNP!40W!G-JI5V|q0twP}z@^>Q2P$kV8Qcu{w|_g~QRltHf)pZ-=*Q1D{Vh<8VBG z3948_ak4`=JHFb{hP6RhN7#eMY-srE4(h#=5~uJYe>l=_pV*@Z6gLAx{VCCLslq2l z8dQ|;UpX?zd4DtGiLw7j?GiU13)!GM3(JrU=mmP0n&}^0hfzz5`FZUQu!JPAX2{QQ zZ;-Y9tPyAO0MG*mElYT+%rZUKHodDv|)5d zbcMB(mqTdz5;+>dn}j$SC#UHg=`K=onaoZCskqB;w1-|T|4x*rSq$xs zwkJU4gisGAvxkw}L{x@zK(f1kqvKA?JIXW}Vc2=N7Z#|#u#-a)B zK^;tb5yoG0m{Ga*CR~#;d9qA(ybK(szdr136Jb>#{sb^g5@aY$F_i~gnuT=%FV~}L z%`}M)4b84y?nJh)-6A!AG}BOt&hV!bQr`_c_xKm<(Fru8TZK@GeTGhdYM}AM**$#K z?2lP(Vg-g1Dgy;LF=>zgYN8$inB>8m3~5HD&P8_6YYye~Dm(JN($!TqdG^(Y=;sB- zio2}NG)3~G@7*oVBta)wNU5@uYM27jM57n(_TijA=AIeb6dXqtZj(X5^HbuF7kO}@ ztJD$gzg;l#bFKVZRetOCs0-SPVbz}^=! zCJpg85$eCDtjfPPi0@ZQ8#l;H>eY4UjQJkpCrOerWdh~=GFS6rQC46+>2H}?-QPmW zLs!W(^0eV=b9p*SeavykG3u_FHkIkmVjyjCb5i|s@24e$8!lCMG}=&`x<8F0JE?->5L>Hlkj zKPR#9jHMy{ne&b~TanCFAdsX)4Q&-xSqqpkn0(8XdQN=9)fMECz5e-nIT+BD05Q>lp~)PL0eu-Z23B^x7zfIIPs z5VAx%G%8|$*Q|N-&fh%JTcC1@x!&UGD1U-(FZSm&+?+JJc(6|fKE9?O^Y9}HFNeuz zx*lY(CCjC%tHsu-wr_`oif?DX!KT{8KVjJFdg;F)%WC%B6gPR3zLRR^34o*vwoVu@ zhXn9cu>VO`>wZot0%{lhayyMSyOw(>2(TmOKn!hv>h(VXi1u#<)!~W?)0gm6wgf(a zHM@odXVPG28ipnx_73xQqYa3QG3qzuZfEP5>p}>~jGxR}jAxtuO!agGm0&me9JUVU zrG&iQR~XJEod8CdVm*`uSn>yDpnjuo!tZTRXX>+sajMl-AHAC12|4^R3UNfxoz}nW zVP!afI_Ua~-RFj}xmLz_Q~V)xa`CpljUJ*+q0ZJzQN!|5< z6jrA2hr3Y!8C6MXh4dKewv;n)4V1JcA*3XKa0kX$jvW6)IgC>1Tu2vPXjo}DOVpHg z;@tE9e{(Gu9kR{gy&|~i0E}~e^3wm>lWseqN$i=)&#N;ttjBph-8HF)#&%Mq%;z(j z-Gzo6UPt#~UKV{?>%=Xv{DuCnY6+Yh>Ykc8L%_tsW!eI-?&~=x;T6%3sUOR?eXM?e zpf4*Ov^DX&Tj|4f7K>)9tes`&OYK0rLVKaQo)e%Oz4MBUp)f~}$m*cQD{d)`i1vCX z+i&q>2dQeJPXC>?by1gRfO?+*M7FJ=oq10A%bf*|Vd6E8ZqkQ@BmHq2mdPU%ngEyU zb+XH&k?LdxOr({IWS(Wi0&fTlj6yvwcCDHP1f2Vxe-Ds=KBcs6n$Rq(hD{C;38!ZC*R8G}JyOjM zw5OsAt@6M-8Zu9S!$G3A=1D%h0Ix^9g@#x`^WrDKxBYCI1qX7&Jrzkh-rg|aTZ2CW z%KNIwKDVeVWiD4Dvs=-Z!BwgDxCtaIoX9T59gqK}5Z&h?@R@=TO^Nbz%;sSz*rPvF21cQTA`= z3FRZHBzlfgQ@D7UDrl=1G6QJ+rpf(9*}22<6~q@3`986^bo5o6&_4Ief17SEAJb|l z<$AQCSe(|8Q@|G8n>?I1|J3V$#bEVJ8ZM*EVq|GBAw#!%5m%r{FHd2we{w`EkK@jD)+VP@ zwzfDhZsX-t0`_4{sC#?9rZG~)9D(if<--GQr4eRXYq7dLma1=YOZcL2N>qh)b1B}0ZK-y1a* z2R(h13y{l=U15H#e`+jhMLL#m*^)l~nHH>lk@W!i-R4B2zuxy+_AP34kj}m7CY6-F zNMab*X6x|FqGlP?!^1biaQE?MIM#XLx5kbN^W%jBR{X4|Oa-vtlrCDSHs>>HJybYL z#gyl{_Z<@t2sgvzJ4#N@5U|w{5O4VUk)s2Z6V=d{4~=36e`_zW-@(LE|4t%Up+ArU zbK=@E#Na2uHyAG(9(GohrZIEHK>Gv$f*a$ez9$6xMnMP)e(KLK7~-urRM)czY%o!TKdEPZAz|MP1_(A-vu z-JvwyR+Z3@?&5z3*-#?`Sh_AWifmcY;+&1o)y+6pN zUuei714sMjuPm<`Nd3OxMaOO+gBX62Dd7K_!Ylf}f2TN_*1=>*03o<)!CJBTMM8k0 z4xcn#@rueDvqnTiRR?0>+BVLWaBzY8z$m8eN1*z)1#4+_fHoX0(?qAyI_vbB(R_{t z`Zvb?f-FJe3Gj6amyYJ8)K~>Ca6rvBfZ$)l-9K$NplNe|h-_hYicDwMCfyr@D<_f) zhr?UVe~ldJE5s4a%@#-do*^nH!sCow(`$Kje~-ww#Ki8$>Gdlcpz5l8fmmyf21&jA)P_H7_!4{^ZS~ex7ktz?UL~c z^BN>&iIz-lm5xz-NF>Awtq5xFSiC$V#0d3tQC!hcKQjTZ0UK%kb{9s2SSG?)O0uOV z(z`{1kQz?_>cfopt9A%rl$^~^mSz?W8IlRjPk3;IGKdrhyK^UkJZK!?um%IRpbN!r ze?9Yxz^#x~&Fe^a6W{=qZW4V=N)jQnfa*_i^WWSy8AY4%`1@VYYutGi9L;AhZ+!hE zAJ>h$QYjif*a&EXrAg0U^Y!H8@D1@c;11}+7Ym*M-}xvP+9%_)DeGdl)AXhq_4m5S zLlW7V6wUc0fxKJZ3_mO0E&SOc&T@S?%yWEaj!E5>FeLiv9?P-o*^uZ{xb^is z^C7M0y290eijkz=-Y(fLxEdyh}He=s7Sd=4Fe_o|t52bdWOVI*a zfCEnef>qVTCA*`t`nN6*nK*Do>>7azEX=}n+NOz^ejEq9rBA(W;ca4mGR@G6Gqu}^ z4nOCDO%kCn)ePrs)cu;9tcK+tghN^Gd51{){8wL-PosoiZ3oXXeUf})$Ovwya7U{0 znz^hZ*|=QAO2uUxe?zuF5vGh7UkirC2$5)Bb`Ex?JIf=ol?AzdpPQXZXq$cz`Js;`Eht>}-F_FjQ-&Icub*6>8N$mGJ?NUu z+-Rl0;w->N5hpLB1aLNM;yg+5^KjT`V_;QW*uuC}kTyaje>FRrD zfv(O}Os@F=j7}$bmnmQS&$?Y{rMkj(9G)P<(eHN$kfbgRR9 zpA(gl2J!)-e+{KlL5D@+)!2Ythwmb6mW7ovm}zK+*|hKno&Yc9zI8=Ck~*C{sPw=E z4az^&aUU(flTQA+G@Q~Y>%$r6yJ1|I19UhtAUZF+=aFUJJ?k4783?A*MCL&-oC_xG< z$}O%D>Xl=1?{A>7K)PSgZ~LwZF$MXyPXN+;j}6_$V}FLSi2}bZH78ivX{SdNw{0%7 zWp_-=^J<`%Jv|wL90Wu)f^xd|Ur-Ewm- zP7DdI|91@V`WU0M6(3HU6Jx$uILq76oN9C;_8D$lWBx*(I=V814K|W^W@>}ly0*iB zp8!NJrL-KSH8wT$S=uEhBTrtOS5~%Vh<}4F;3n|R{DBP_$822f1VBia#I(_mMc#g5 ze}Bh2`UUcr8(!IE^sjx!;r&%XU?j@)ZqQIrb(p~xyBZVu1i+=JLwPT9Hlltdfm7WS zoxr{U!oQ1VHGt_fqwf#%!dG#&Q7%#clJy?C+FiC7K#%0(N{ynmt#wpl|1A-0g)MNJ z3AumQm%@$o+(~2FCQmfuhP}t&R%28Ke=~B=&noXZi`SU4_XdJ^svT~WX}&c_nhfJh zY0DQ8xw9t705{@~yGmdhXud4Du%bo&(0fAm;=IzO#?|j6@l6_#epO3c^#rUHe4JY? z>_=AUY(FDkjvmS^QaF=)w=Jem#hCjRK4X}6Ar+(J#s1~-64&% zlr)I62tQOhq=Yd6=^>-L8w3QTe*~l?#)#3~4LWigA-y3n$@jSL_jupqc|M$f#Cd%< zuag8uL%b)d+Q4|{0&)+4A@oUqyKN;}O6MjYef{^h*!$ur77C3M|*^+E!BNn|V|6eOw!0bRdEHN>Niq5!K)D|d zrv2fX2PNH>#wdy~{wM9FElI6&%*p%F#Aq;#z7{?GyJ?Vv9hYcBh*(N)W50+ivpl%U zYue`qT2_ZS$#j1?;0~!af9jDN<8xB;A^~__1WV)7FzlqvH+<-gM>XSa9F;zqX@@fG z3YYqP0+ucjPLApH}8~Taiwa6h!b`yr*^a!lns_le+|JYK z^{Dzi%2SP#1FD7hMjvVP`>|ZciE66RTJgF8JvQL~=hRv`GNkv-1`@c0)N4+n}v<*$DeKg;_!-4o2M z@VyNfDXQeuxO8_4sjE3b$lvA!ICzog$X=GbOpJ;g+3RcUc|Bwx6Ib`Ae|ybUjcJM(h`851NR!263R_)9sZio=H{k-t8d}1h zXtM4Lry0Nj)OAoqfcW_cOhrPovbN4FQ~+oD-+}{nfAQ`~_^LJ=<3(ctT*^i|KU4SV zP(tgg-_N83nb;0M-4B$MOZKh^TbimN;68sTYgbuDTGzn4!RnrBna)+!4sLU2SUL@t zqMTi{cRa4s)@Hej3$JBJ_3O_C%ZKV9_H~3s9}|WGT!ewA!HF=2hdUXveg$-Vq%SnPAHJf6PPcW$7=L7dVN# zH%m4i-AP%?X4B+_3q`y3g1GE#GFI*rYPIqjTJJq z>S2e|rtL#v-3q+F_M&tC+u4uC%+3pv!eygU9g<0xVm+aP1peeW+q#XW`Xfn{I8wOJ ze=i3qTkhC?iYy;0EgtmTF??^920JBCN_3_pI9QzgjR)#CB|{>tb?N?&dWyIy3i|tv zio#htNe?$Yt@-E(3U0$3*dr_~Ux?T{{cfN5_Zo2K)XV-99tq_v!!v3m=*+s26r?Mq zAFWSK^ECMTec)}9lH|=uH*ouZZS{YRf7RkL&ZmfKC1>GWF-X~Rn?fYalGhJdbt4=V zGgwHm$dkms3^NGeUiE6QQsA^+e`RNhoBn*Je<0Upxcb_SfBI@G$-H5jQ$i-+?fXGX zqr00c>%!gk;FmY|fFK^KZ8Z)VjCj22BPC(q3ReoW8@PY@l9tTOP5Rd{2DEl9f74O? zin1eDOBrl{kn@0WJ$BuA40~{7ou@c*{s*ml8him=q!ItFCA_>O*9DVs^^=f*mVUDn z(ezw6CW@G6sxO31Q{c0FlgxvuZ=Of=!9A_Gb%W4?M@P-oGfXi~Ve~sZoC76FxY2Kv zr2`xfXU^T&^Q|n8w7m{Pjzm8of91my9WEPARY_~uIhH&q_C5^1Je zVW0qk)F0u942%5!V1C?EwXYyRe`hue;ePe`#l2H#x<*c2qozAwS>;tHY;NHVxji$jr^%ZJAk+ie$!szNbeG zK18Ph@%M#Tk!SVFsveFleCuhdJjOlXoQ_}+VyJmrQk?vz22#-@r=u&;2wHwdBQ>Pg z>`?KouHLv->oTH2P;awzo6jVk0~*%8H4}2w6TGOtH7ljH90_ zN8JPH!0miVTM0aW{Fc*3-!rilJr$xxeAAUT8;dB+5}5}tT`her|`bct1Gdmvjiz z8sr1G?ar*mw*n4gRf!XjY1+Ki?@My==_a#w*+EXYU2~-IT5@sZ^E|g53zhZ})s=CR zRs2$O{L$Xauiu|4gI`&XLn{ayRQ)1ekO)(cbU+)%f82?!BJsA`*mUq!VV18?)4mE9 zB3~Cir#Bqoxy_iYpSk_+2&Arf8jGJtLX@Y2U-(H+^*6V^z9G4+nJH( z58R@9Kc}UEgm+YSKr_*y%^u+^vtP)8BZ)3|v!nmI=hI}7keU4;(eumSsP|=9)`G5Y zv}#OOyWx|&7l@3ewO`g&WWSqNd>>spaVHrw*sCF5L6Zqah%xeir(+9Qv5bQT53Has zf7%~!-aTDPohXrwHmA-k=qX8;-F*%u-dz-hf+`O3M9+6phm9e|_AR)_ zygB3kiJ8Dkan>mJ7<%*ut&%4uBx-{Mp-~M&I>$iWO<$+qO|1(WB%WG6&CXii_?RHf z-5m1tXj-03jguB{X*D0rj<+3jR+pR(f2sbZD;JH47sxYrqZse?0EtL`=hbyi+XB4m zi8Qzl-@Hi+!0ue@E{e%adfhGe=PjVG{{sgel%?;iTB=?Rj;gooXE|xU$%y89?|lJ} z7C94ER{1+DdsVwwuzitUYO{F5>5?Z}@>@qcjlKY}gfwO(f!J;?8J!N8ndy77w`YxOV_q_EBZ{@o{0?UM7XC^tfqmpf`i>Fb9sTx*`NWdMKS%Xp@RrqL$^ z@6<+l3nS?N1zeMZWY@*8lhdIHe`WTaBn-QN+sz&C+#z(50srrj=s*)!vYF#s#ZRm| zB1eDSC&9sUbHQ^B_`qwq0S?ARk7lwUu^l1qxns%-Cx5>Q2KjQyrv}?4G-&yVa4kv~ zqBj?1w^gdnD(0- z;m(8g;j+QDk#6r75$v>ma0}}*V|^!*fkX|aRkv%2G&nVrBGAsNv*t5nl+2{Uz9{V= z3tvIhTEXIEhavg8W-~QiTN);ti@2T#nyW_t`qMy$4Ge34jS`QNcAl%dk#wkhxBSF@t{+}m*>F@aw?oS$|Aq) zD|jSdmH^9Fnn7vzGy~?RxLpxL&i$U}BQn^+98|!a7+!UNlZJai`hf^n%xF7yK;mOI zUp$kY+yS9ZWu)S_zXYP$qRol%+g;L7-!}X>&iLwvhVXmL(SV7*waBS|>tCv_1(CZ>bzl()_niohDaxaSZZ)tUfhs zCU_zQcwR&YeH)_{ez5^E^I zf48JXlPT+)7QN0eZ_IL52%79M%R@RPQYrAKJxag+qDY?XC4c({Ete9^V}A1nYZ~p# zF8m)ZC+6fx%%@FB5hC&-|8>B-EPfI5w^8P3yHs@mCKViwt&g$`A%^OKk7QM2bN)N9 zaE>RwVw*z-fBlf369~wDkZh&NYw$$9m9|xkmCHw!K}E?WyGwPUxmKh9zr9Q2RV2iF zK&mfVI2CSGvjjJ}^!$!#w9PC%>#<9e0T`mEqg2Q{aP>(7U5g$vMf6>zq*bC>14nND zrLo1gYg;CN#^4Pi@hO*|KH*14>c#Nh7!PKMDcV0qf5w*nz`sj+{#>U5F>l+eK>n=u z{A-_s=-06+wx?sP&Xfek1UBBqH8xPkg>xO4IJM^YQM-x_<*4tao@I!3*loICx~;gw zgKg7S&toM8C>?^vNW6l9OE)~F_ynC!EHD;E)=i=13-+5;aso+zS_Pes!3mDag?BVr zMiRT+e+{$8m_x@|09#O9+`+4+eAM|xMj3v7u#2?Q{H!BM%Cs@;L>%3mCyyljqe0c! z;Fqi(Ue4~NrLzkSa*zwXacn2ez>ozRa48=6FMl5rs&SL4HF-$%Yh+68l;leWfMTWV zzwjO^McA@$O2o&}K;BuGd9dk02r56<;;+$cf2w;DjEJR@j8(&j@g(gD0h}%Vyn5p? zM0=QpDh_6O`)C3m{#So~`R?&v?Y4s*z!~JLpGqsD*FNF+$dBjH*jtUm4l_ylcff+9 zsoDm#w6rd`B(p;Cxc>)m9*teRvLH1S78~PEfBZ1@2zkJ1BrI%!z$WJe*@d90#6`q> ze>xRrtQ#$4x{8(NN0u%bTa2Ha?m!l)b$EFb9p3|GRzZI4Ara@IZ*-em-86>sC=2|( zD`TBTQ~HfsjFn2$;1g0poG_?8J8(yS>n&<4DJj4f-HOoM^flpUx}-u+m& z<2)job@Top4yn%75v-nszjG}BdgSEHe;DqQUu$DPX5JV*ZFJY))D2%`nN#N9(OI1A z+upcr6igj|NMPjIF<-YVR*s!q$}h&tSuQFu+j;pKXzl7EAf-W4AB7$&Nx$%uW+eA*7>S0^4>vEpPULzPj*JBZ+ z-{0cdST{<^t;?2#ax~tr&SDxthpH6c=KfD0hVddBuU&?p*Z$9g7c<)Re^ZRP=J+iI zj&{}SfpRZtr%AU&$Yx;?6M`LEj}cE)Vc%0Att$VcF!eLLrM;!Nif@d!%lw+JiaIH5 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

245KVjAoB@Xqi#QDPkj88wCy`>fL*Ix@l55 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 z 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 TEdy7=IXwiZj_slXP!R(F64(Qk delta 677082 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

  • TEVm@{0Oe}fOxyu!RAX=zZ^vJ&uc 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

    d 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%0DU9)Iao)68 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

    tY?8uyp+73Oce~Npz;Dw2>F-QRymPj|1STm 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�j&g-w0DMpmq`mf9V-E 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%0D#{5c_K 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�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 B`j`L! diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/LocaleTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/LocaleTest.kt index 9e0641e11..e3969d752 100644 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/LocaleTest.kt +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/LocaleTest.kt @@ -36,10 +36,30 @@ class LocaleTest { } @Test - fun forLanguageTag_skipsScriptSubtag() { + fun forLanguageTag_retainsScriptSubtag() { val locale = Locale.forLanguageTag("zh-Hant-TW") assertEquals("zh", locale.language) + assertEquals("Hant", locale.script) assertEquals("TW", locale.region) + assertEquals("zh-Hant-TW", locale.toLanguageTag()) + } + + @Test + fun forLanguageTag_normalizesScriptCase() { + assertEquals("Hans", Locale.forLanguageTag("zh-hans").script) + assertEquals("Hans", Locale.forLanguageTag("zh-HANS").script) + } + + @Test + fun forLanguageTag_scriptDistinguishesLocales() { + assertTrue(Locale.forLanguageTag("zh-Hans-HK") != Locale.forLanguageTag("zh-Hant-HK")) + } + + @Test + fun forLanguageTag_underscoreScriptSeparator() { + // iOS availableLocaleIdentifiers uses underscores: "zh_Hant_HK". + val locale = Locale.forLanguageTag("zh_Hant_HK") + assertEquals("zh-Hant-HK", locale.toLanguageTag()) } @Test diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt index 37b45e4a6..4e58047f6 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt @@ -18,6 +18,7 @@ actual val platformModule = module { single() single() bind StrRes::class single() + single() single() bind UrlLauncher::class single() factory { params -> DesktopPlatformSettingsComponent(componentContext = params.get()) } bind PlatformSettings::class diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt new file mode 100644 index 000000000..648861890 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt @@ -0,0 +1,11 @@ +package com.darkrockstudios.apps.hammer.common.util + +actual class AvailableLocalesProvider { + actual fun allLocales(): List = + java.util.Locale.getAvailableLocales() + // Parse the full tag rather than language+country so script-distinct + // locales (zh-Hans vs zh-Hant) stay separate entries. + .map { Locale.forLanguageTag(it.toLanguageTag()) } + .filter { it.language != null } + .distinctBy { it.toLanguageTag() } +} diff --git a/common/src/desktopTest/kotlin/architecture/PersistedFormatOwnershipTest.kt b/common/src/desktopTest/kotlin/architecture/PersistedFormatOwnershipTest.kt new file mode 100644 index 000000000..e86cd0052 --- /dev/null +++ b/common/src/desktopTest/kotlin/architecture/PersistedFormatOwnershipTest.kt @@ -0,0 +1,49 @@ +package architecture + +import org.junit.jupiter.api.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Structural guard for the "one owner per persisted format" rule (ARCHITECTURE.md, + * hard constraint 7): raw TOML file I/O may only appear inside a format's Data Source + * file. A second inline writer forks the on-disk format and clobbers sibling fields on + * write (a language-seed write and a tags write to `project_data.toml` once destroyed + * each other exactly this way). Code that cannot reach a datasource instance must use + * or add a scope-less helper in the datasource's own file, like + * `loadStoredProjectData`/`saveStoredProjectData` in `ProjectDataDatasource.kt`. + * + * The only exemption is the migrator package: migrations transform historical on-disk + * layouts that predate the current datasources, so raw access to old formats is their job. + */ +class PersistedFormatOwnershipTest { + + private val tomlIo = Regex("""\.(writeToml|readToml|readTomlOrNull)\s*[<(]""") + + @Test + fun `raw TOML file IO stays inside datasource files`() { + val root = File("src/commonMain/kotlin") + assertTrue( + root.isDirectory, + "Expected the test working directory to be the :common module (got ${File("").absolutePath})", + ) + + val offenders = root.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { tomlIo.containsMatchIn(it.readText()) } + .map { it.relativeTo(root).invariantSeparatorsPath } + .filterNot { it.substringAfterLast('/').contains("Datasource") } + .filterNot { it.startsWith("com/darkrockstudios/apps/hammer/common/data/migrator/") } + .sorted() + .toList() + + assertEquals( + emptyList(), + offenders, + "These files serialize a persisted format outside its owning Data Source. " + + "Route through the datasource, or add a scope-less helper in the datasource's " + + "file (see loadStoredProjectData/saveStoredProjectData).", + ) + } +} diff --git a/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt b/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt index 15522664c..8cd1e3e25 100644 --- a/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt @@ -9,10 +9,12 @@ import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteCont import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndex import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -56,6 +58,11 @@ class NotesComponentTest : ComponentTest() { scoped { notesRepository } scoped { tagIndexService } } + single { + mockk(relaxed = true) { + every { spellCheckAllowed } returns emptyFlow() + } + } }) shouldCloseUpdates = 0 diff --git a/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt b/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt index cabbe6311..ed16cf453 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt @@ -201,6 +201,51 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { assertTrue(bytes.size > 100, "Single-chapter PDF should still produce a real file") } + @Test + fun `epub export uses the project language when set`() = runTest { + initRepo() + storedProjectData = StoredProjectData(data = ProjectData(language = "fr")) + + val exportPath = useCase().execute( + exportDir = projectPath, + options = ExportOptions(format = ExportFormat.Epub, treatTopLevelAsChapters = true), + ) + + val bytes = ffs.read(exportPath.toOkioPath()) { readByteArray() } + assertTrue( + zipEntryText(bytes, ".opf").contains(">fr<"), + "EPUB metadata should carry the project language, not the device locale", + ) + } + + @Test + fun `epub export falls back to the device locale when the project language is unset`() = runTest { + initRepo() + storedProjectData = StoredProjectData(data = ProjectData()) + + val exportPath = useCase().execute( + exportDir = projectPath, + options = ExportOptions(format = ExportFormat.Epub, treatTopLevelAsChapters = true), + ) + + val bytes = ffs.read(exportPath.toOkioPath()) { readByteArray() } + assertTrue( + zipEntryText(bytes, ".opf").contains(">en<"), + "EPUB metadata should fall back to the device locale's language", + ) + } + + private fun zipEntryText(bytes: ByteArray, nameSuffix: String): String { + java.util.zip.ZipInputStream(bytes.inputStream()).use { zin -> + var entry = zin.nextEntry + while (entry != null) { + if (entry.name.endsWith(nameSuffix)) return zin.readBytes().decodeToString() + entry = zin.nextEntry + } + } + error("No zip entry ending with $nameSuffix") + } + @Test fun `epub export still produces a valid file when treatTopLevelAsChapters is false`() = runTest { initRepo() diff --git a/common/src/desktopTest/kotlin/components/projecthome/ProjectSettingsComponentTest.kt b/common/src/desktopTest/kotlin/components/projecthome/ProjectSettingsComponentTest.kt index 9bc025c91..55241b8f8 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/ProjectSettingsComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/ProjectSettingsComponentTest.kt @@ -18,6 +18,8 @@ import com.darkrockstudios.apps.hammer.common.data.tagindex.AccountTagService import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath +import com.darkrockstudios.apps.hammer.common.util.AvailableLocalesProvider +import com.darkrockstudios.apps.hammer.common.util.Locale import com.darkrockstudios.libs.platformspellchecker.PlatformSpellCheckerFactory import io.mockk.every import io.mockk.mockk @@ -70,6 +72,14 @@ class ProjectSettingsComponentTest : ComponentTest() { single { mockk { every { availableLocales() } returns emptyList() } } + single { + mockk { + every { allLocales() } returns listOf( + Locale.forLanguage("fr", "FR"), + Locale.forLanguage("en", "US"), + ) + } + } scope { scoped { ProjectDataRepository(datasource, projectDef) } scoped { mockk(relaxed = true) } @@ -188,6 +198,45 @@ class ProjectSettingsComponentTest : ComponentTest() { assertEquals(setOf("gothic", "haunted"), datasource.load().data.tags) } + @Test + fun `setProjectLanguage persists a normalized tag`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.setProjectLanguage("EN-us") + advanceUntilIdle() + + assertEquals("en-US", comp.projectInfoState.value.data.language) + assertEquals("en-US", datasource.load().data.language) + } + + @Test + fun `setProjectLanguage with null or blank clears the language`() = runTest(mainTestDispatcher) { + datasource.save(StoredProjectData(data = ProjectData(language = "fr"))) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.setProjectLanguage(" ") + advanceUntilIdle() + + assertNull(comp.projectInfoState.value.data.language) + assertNull(datasource.load().data.language) + } + + @Test + fun `availableLanguages are sorted by display name`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + val names = comp.availableLanguages.map { it.displayName } + assertEquals(names.sortedBy { it.lowercase() }, names) + assertEquals(setOf("fr-FR", "en-US"), comp.availableLanguages.map { it.tag }.toSet()) + } + @Test fun `Project tag suggestions come from the account tag vocabulary`() = runTest(mainTestDispatcher) { ideasFlow.value = listOf(idea("gothic", "ghost"), idea("gothic")) diff --git a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt index be94a0421..6b5be128e 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt @@ -189,14 +189,14 @@ class ProjectsListComponentTest : ComponentTest() { fun `createProject success on a synced account creates locally and on the server then clears the dialog`() = runTest(mainTestDispatcher) { every { synchronizer.isServerSynchronized() } returns true - every { projectsRepository.createProject("Novel") } returns CResult.success(projectDefA) + every { projectsRepository.createProject("Novel", any()) } returns CResult.success(projectDefA) val comp = newComponent() comp.onProjectNameUpdate("Novel") comp.createProject("Novel") advanceUntilIdle() - verify { projectsRepository.createProject("Novel") } + verify { projectsRepository.createProject("Novel", any()) } verify { synchronizer.createProject("Novel") } assertEquals("", comp.state.value.createDialogProjectName) } @@ -205,7 +205,7 @@ class ProjectsListComponentTest : ComponentTest() { fun `createProject success on a non-synced account does not touch the synchronizer`() = runTest(mainTestDispatcher) { every { synchronizer.isServerSynchronized() } returns false - every { projectsRepository.createProject("Novel") } returns CResult.success(projectDefA) + every { projectsRepository.createProject("Novel", any()) } returns CResult.success(projectDefA) val comp = newComponent() comp.createProject("Novel") @@ -217,7 +217,7 @@ class ProjectsListComponentTest : ComponentTest() { @Test fun `createProject failure leaves the dialog open and does not reload or sync`() = runTest(mainTestDispatcher) { - every { projectsRepository.createProject("bad/name") } returns + every { projectsRepository.createProject("bad/name", any()) } returns CResult.failure(error = "invalid", displayMessage = "Bad name".toMsg()) val comp = newComponent() comp.onProjectNameUpdate("bad/name") @@ -325,7 +325,7 @@ class ProjectsListComponentTest : ComponentTest() { comp.confirmImportDialog() advanceUntilIdle() - verify(exactly = 0) { projectsRepository.createProject(any()) } + verify(exactly = 0) { projectsRepository.createProject(any(), any()) } } @Test @@ -355,7 +355,7 @@ class ProjectsListComponentTest : ComponentTest() { fun `confirmImportDialog leaves the dialog open and does not sync when project creation fails`() = runTest(mainTestDispatcher) { every { synchronizer.isServerSynchronized() } returns true - every { projectsRepository.createProject("Draft") } returns + every { projectsRepository.createProject("Draft", any()) } returns CResult.failure(error = "exists", displayMessage = "Project already exists".toMsg()) val comp = newComponent() comp.selectImportFile("Draft.md", "# One\n\nText\n\n# Two\n\nMore".encodeToByteArray()) diff --git a/common/src/desktopTest/kotlin/components/storyeditor/focusmode/FocusModeComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/focusmode/FocusModeComponentTest.kt index a9cc24c40..cfd82d0c2 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/focusmode/FocusModeComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/focusmode/FocusModeComponentTest.kt @@ -10,7 +10,7 @@ import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope -import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -70,7 +70,7 @@ class FocusModeComponentTest : ComponentTest() { sceneEditor.subscribeToBufferUpdates(any(), any(), capture(bufferCallback)) } returns mockk(relaxed = true) - val spellCheckRepository = mockk(relaxed = true) + val spellCheckRepository = mockk(relaxed = true) every { spellCheckRepository.dictionaryFlow } returns MutableSharedFlow() setupComponentKoin(module { diff --git a/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt index c72a7aee1..fc20fb9d1 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt @@ -12,7 +12,7 @@ import com.darkrockstudios.apps.hammer.common.data.references.AutoConfirmReferen import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService import com.darkrockstudios.apps.hammer.common.data.tree.ImmutableTree import com.darkrockstudios.apps.hammer.common.data.tree.TreeValue -import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker import io.mockk.* import kotlinx.collections.immutable.persistentListOf @@ -43,7 +43,7 @@ class SceneEditorComponentTest : ComponentTest() { private lateinit var sceneEditor: SceneEditorService private lateinit var draftsRepository: SceneDraftRepository private lateinit var autoConfirm: AutoConfirmReferencesUseCase - private lateinit var spellCheck: SpellCheckRepository + private lateinit var spellCheck: ProjectSpellCheckRepository private lateinit var settingsUpdates: MutableSharedFlow private lateinit var dictionaryFlow: MutableSharedFlow @@ -98,7 +98,7 @@ class SceneEditorComponentTest : ComponentTest() { setupComponentKoin(module { single { settingsStore } - single { spellCheck } + single { spellCheck } single { sceneEditor } bind SceneEditorService::class single { draftsRepository } bind SceneDraftRepository::class single { autoConfirm } bind AutoConfirmReferencesUseCase::class diff --git a/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt b/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt index 8c708b0fa..928634acc 100644 --- a/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt @@ -47,7 +47,7 @@ class TimeLineComponentTest : ComponentTest() { every { tagIndexService.tagIndex } returns MutableStateFlow(mockk(relaxed = true)) every { tagIndexService.getRankedTags(any(), any()) } returns emptyList() - setupKoin(module { + setupComponentKoin(module { single { timelineRepo } bind TimeLineRepository::class single { tagIndexService } bind TagIndexService::class }) diff --git a/common/src/desktopTest/kotlin/repositories/ideas/PromoteIdeaUseCaseTest.kt b/common/src/desktopTest/kotlin/repositories/ideas/PromoteIdeaUseCaseTest.kt index 52589e685..c54df88f5 100644 --- a/common/src/desktopTest/kotlin/repositories/ideas/PromoteIdeaUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/repositories/ideas/PromoteIdeaUseCaseTest.kt @@ -17,6 +17,8 @@ import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetada import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath +import com.darkrockstudios.apps.hammer.common.util.DeviceLocaleResolver +import com.darkrockstudios.apps.hammer.common.util.Locale import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableSharedFlow @@ -58,10 +60,14 @@ class PromoteIdeaUseCaseTest : BaseTest() { setupKoin() + val deviceLocaleResolver = mockk() + every { deviceLocaleResolver.getCurrentLocale() } returns Locale.forLanguage("en", "US") projectsRepository = ProjectsRepository( fileSystem = ffs, globalSettingsStore = globalSettingsStore, projectsMetadataDatasource = ProjectMetadataDatasource(ffs, toml), + toml = toml, + deviceLocaleResolver = deviceLocaleResolver, ) val ideasDatasource = IdeasDatasource(ffs, StoryIdeaCodec(toml), globalSettingsStore) ideasRepository = IdeasRepository( @@ -110,6 +116,8 @@ class PromoteIdeaUseCaseTest : BaseTest() { ffs.read(projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME) { readUtf8() }, ) assertEquals(setOf("gothic", "coastal"), storedProjectData.data.tags) + // The tags write must merge onto the seeded default language, not replace the file. + assertEquals("en", storedProjectData.data.language) val promoted = ideasRepository.getIdeaById(idea.id) assertNotNull(promoted?.promoted) @@ -131,7 +139,7 @@ class PromoteIdeaUseCaseTest : BaseTest() { @Test fun `Promotion suffixes the project name when it already exists`() = runTest { advanceUntilIdle() - projectsRepository.createProject("Tides").let { assertTrue(isSuccess(it)) } + projectsRepository.createProject("Tides", seedDefaultLanguage = true).let { assertTrue(isSuccess(it)) } val idea = ideasRepository.createIdea(content = "waves", title = "Tides") .let { assertTrue(isSuccess(it)); it.data } diff --git a/common/src/desktopTest/kotlin/repositories/projectdata/ProjectDataRepositoryTest.kt b/common/src/desktopTest/kotlin/repositories/projectdata/ProjectDataRepositoryTest.kt index 0e93483e5..de982f054 100644 --- a/common/src/desktopTest/kotlin/repositories/projectdata/ProjectDataRepositoryTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectdata/ProjectDataRepositoryTest.kt @@ -122,6 +122,33 @@ class ProjectDataRepositoryTest : BaseTest() { coVerify(exactly = 1) { syncDataDatasource.invalidateProjectHash() } } + @Test + fun `updateData persists the language and it round-trips through TOML`() = runTest { + datasource.save(StoredProjectData(ProjectData(authorName = "Pat"), "synced-hash")) + + repository.updateData { it.copy(language = "pt-BR") } + + val onDisk = datasource.load() + assertEquals("pt-BR", onDisk.data.language) + assertEquals("Pat", onDisk.data.authorName) + } + + @Test + fun `pre-language project_data toml loads with null language`() = runTest { + val legacyToml = """ + |[data] + |authorName = "Legacy" + | + """.trimMargin() + fileSystem.write("/projects/Test Project/project_data.toml".toPath()) { + writeUtf8(legacyToml) + } + + val loaded = repository.load() + + assertNull(loaded.data.language) + } + @Test fun `pre-tags project_data toml loads with empty tags`() = runTest { // A file written before the tags field existed. diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBaseTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBaseTest.kt index f5d47a0b8..151e5d862 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBaseTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBaseTest.kt @@ -3,7 +3,10 @@ package repositories.projectsrepository import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource +import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer +import com.darkrockstudios.apps.hammer.common.util.DeviceLocaleResolver +import com.darkrockstudios.apps.hammer.common.util.Locale import createRootDirectory import getProjectsDirectory import io.mockk.coEvery @@ -12,6 +15,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.SharedFlow import net.peanuuutz.tomlkt.Toml +import okio.FileSystem import okio.fakefilesystem.FakeFileSystem import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -23,6 +27,10 @@ abstract class ProjectsRepositoryBaseTest : BaseTest() { protected lateinit var projectsMetaDatasource: ProjectMetadataDatasource protected lateinit var settings: GlobalSettings protected lateinit var toml: Toml + protected lateinit var deviceLocaleResolver: DeviceLocaleResolver + + protected fun projectsRepository(fs: FileSystem = ffs) = + ProjectsRepository(fs, settingsRepo, projectsMetaDatasource, toml, deviceLocaleResolver) @BeforeEach override fun setup() { @@ -30,6 +38,8 @@ abstract class ProjectsRepositoryBaseTest : BaseTest() { ffs = FakeFileSystem() toml = createTomlSerializer() + deviceLocaleResolver = mockk() + every { deviceLocaleResolver.getCurrentLocale() } returns Locale.forLanguage("en", "US") projectsMetaDatasource = ProjectMetadataDatasource(ffs, toml) diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBasicTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBasicTest.kt index c8738dc2d..4c56062eb 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBasicTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryBasicTest.kt @@ -5,6 +5,7 @@ import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Pr import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.ProjectDefinition import com.darkrockstudios.apps.hammer.common.data.isFailure +import com.darkrockstudios.apps.hammer.common.data.projectdata.loadStoredProjectData import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectCreationFailedException import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository @@ -31,7 +32,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { fun `ProjectsRepository init`() = scope.runTest { val projDir = getProjectsDirectory() assertFalse(ffs.exists(projDir), "Dir should not have existed already") - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() assertTrue(ffs.exists(projDir), "Init did not create project dir") } @@ -93,7 +94,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { @Test fun `Get Projects Directory`() = scope.runTest { val actualProjDir = getProjectsDirectory().toHPath() - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDir = repo.getProjectsDirectory() assertEquals(actualProjDir, projectDir) } @@ -101,7 +102,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { @Test fun `Ensure Projects Directory`() = scope.runTest { val actualProjDir = getProjectsDirectory() - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() ffs.deleteRecursively(actualProjDir) assertFalse(ffs.exists(actualProjDir)) @@ -113,7 +114,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { fun `Get Projects`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projects = repo.getProjects() assertEquals(projectNames.size, projects.size) @@ -140,7 +141,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { // Stray file at the top level ffs.write(projDir.div("stray.txt")) { writeUtf8("") } - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projects = repo.getProjects() assertEquals(projectNames.toSet(), projects.map { it.name }.toSet()) @@ -153,7 +154,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { ffs.createDirectories(projDir.div(encodedName)) ffs.write(projDir.div(encodedName).div(ProjectMetadata.FILENAME)) { writeUtf8("") } - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projects = repo.getProjects() assertEquals(listOf("Chapter 3: The Fall?"), projects.map { it.name }) @@ -162,7 +163,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { @Test fun `Get Project Directory`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] val projectDir = repo.getProjectDirectory(projectName) @@ -173,16 +174,16 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { @Test fun `Create Project`() = scope.runTest { - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] - val result = repo.createProject(projectName) + val result = repo.createProject(projectName, seedDefaultLanguage = true) assertTrue(result.isSuccess) val actualProjDir = getProjectsDirectory().div(projectName) assertTrue(ffs.exists(actualProjDir)) - val result2 = repo.createProject(projectName) + val result2 = repo.createProject(projectName, seedDefaultLanguage = true) assertFalse(result2.isSuccess) val newDef = ProjectDef(projectName, actualProjDir.toHPath()) @@ -194,12 +195,38 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { ) } + @Test + fun `Create Project defaults the project language to the device language without region`() = scope.runTest { + val repo = projectsRepository() + + val projectName = projectNames[0] + assertTrue(repo.createProject(projectName, seedDefaultLanguage = true).isSuccess) + + val newDef = ProjectDef(projectName, getProjectsDirectory().div(projectName).toHPath()) + val stored = loadStoredProjectData(newDef, ffs, toml) + // Region deliberately dropped from the en-US device locale: the auto-default must + // never gate spell check against a same-language dictionary of another region. + assertEquals("en", stored.data.language) + assertEquals(null, stored.lastSyncedHash) + } + + @Test + fun `Create Project without seeding leaves no project_data file`() = scope.runTest { + val repo = projectsRepository() + + val projectName = projectNames[0] + assertTrue(repo.createProject(projectName, seedDefaultLanguage = false).isSuccess) + + // Server-materialized shells must keep the never-synced baseline: no file at all. + assertFalse(ffs.exists(getProjectsDirectory().div(projectName).div("project_data.toml"))) + } + @Test fun `Create Project failure with invalid name`() = scope.runTest { - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = "!@/Invalid Name" - val result = repo.createProject(projectName) + val result = repo.createProject(projectName, seedDefaultLanguage = true) assertTrue(isFailure(result)) assertTrue(result.exception is ProjectCreationFailedException) assertTrue( @@ -211,7 +238,7 @@ class ProjectsRepositoryBasicTest : ProjectsRepositoryBaseTest() { @Test fun `Delete Project`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] val projPath = getProjectsDirectory().div(projectName) diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryConcurrencyTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryConcurrencyTest.kt index b03fa5603..c9ddf1166 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryConcurrencyTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryConcurrencyTest.kt @@ -28,7 +28,7 @@ class ProjectsRepositoryConcurrencyTest : ProjectsRepositoryBaseTest() { if (dir == getProjectsDirectory()) super.list(dir) + phantom else super.list(dir) } - val repo = ProjectsRepository(racingFs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository(racingFs) val projects = repo.getProjects() assertEquals(projectNames.size, projects.size) diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryFindTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryFindTest.kt index bb16f6857..c8893ef1f 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryFindTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryFindTest.kt @@ -21,7 +21,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_2_NAME) createProject(ffs, OUT_OF_ORDER_PROJECT_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDef = repo.findProject(PROJECT_2_NAME) @@ -33,7 +33,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_1_NAME) createProject(ffs, OUT_OF_ORDER_PROJECT_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDef = repo.findProject(PROJECT_2_NAME) @@ -49,7 +49,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_2_NAME) createProject(ffs, OUT_OF_ORDER_PROJECT_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDef = repo.findProject(proj1Id) @@ -64,7 +64,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_2_NAME) createProject(ffs, OUT_OF_ORDER_PROJECT_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDef = repo.findProject(fakeId) @@ -78,7 +78,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_2_NAME) createProject(ffs, OUT_OF_ORDER_PROJECT_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectDef = repo.getProjectDefinition(PROJECT_1_NAME) @@ -93,7 +93,7 @@ class ProjectsRepositoryFindTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_1_NAME) createProject(ffs, PROJECT_2_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val known = repo.getProjects().firstOrNull { it.name == PROJECT_1_NAME } val forged = repo.getProjects().firstOrNull { it.name == "../../attacker/controlled" } diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryIdTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryIdTest.kt index b16f0e51a..c988a85b8 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryIdTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryIdTest.kt @@ -21,7 +21,7 @@ class ProjectsRepositoryIdTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_1_NAME) createProject(ffs, PROJECT_2_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() assertEquals(ProjectId("5f1d7446-1f08-4909-a81e-cdc7470a2f63"), repo.getProjectId(proj1Def)) assertNull(repo.getProjectId(proj2Def)) @@ -35,7 +35,7 @@ class ProjectsRepositoryIdTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_1_NAME) createProject(ffs, PROJECT_2_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() assertNull(repo.getProjectId(proj2Def)) @@ -52,7 +52,7 @@ class ProjectsRepositoryIdTest : ProjectsRepositoryBaseTest() { createProject(ffs, PROJECT_1_NAME) createProject(ffs, PROJECT_2_NAME) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() assertNotNull(repo.getProjectId(proj1Def)) diff --git a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryRenameTest.kt b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryRenameTest.kt index 98ed774be..04886b88e 100644 --- a/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryRenameTest.kt +++ b/common/src/desktopTest/kotlin/repositories/projectsrepository/ProjectsRepositoryRenameTest.kt @@ -22,7 +22,7 @@ class ProjectsRepositoryRenameTest : ProjectsRepositoryBaseTest() { @Test fun `Rename Project, success`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] val newProjectName = "New Project Name" @@ -40,7 +40,7 @@ class ProjectsRepositoryRenameTest : ProjectsRepositoryBaseTest() { @Test fun `Rename Project, invalid name`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] val newProjectName = "@invalid Project Name" @@ -65,7 +65,7 @@ class ProjectsRepositoryRenameTest : ProjectsRepositoryBaseTest() { @Test fun `Rename Project, project does not exist`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = "None Existent Project" val newProjectName = "New Project Name" @@ -90,7 +90,7 @@ class ProjectsRepositoryRenameTest : ProjectsRepositoryBaseTest() { @Test fun `Rename Project, already exists`() = scope.runTest { createProjectDirectories(ffs) - val repo = ProjectsRepository(ffs, settingsRepo, projectsMetaDatasource) + val repo = projectsRepository() val projectName = projectNames[0] val newProjectName = projectNames[1] diff --git a/common/src/desktopTest/kotlin/repositories/spellcheck/LanguageMatchingTest.kt b/common/src/desktopTest/kotlin/repositories/spellcheck/LanguageMatchingTest.kt new file mode 100644 index 000000000..c6eb7b6ab --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/spellcheck/LanguageMatchingTest.kt @@ -0,0 +1,70 @@ +package repositories.spellcheck + +import com.darkrockstudios.apps.hammer.common.spellcheck.isSpellCheckAllowedForProject +import com.darkrockstudios.apps.hammer.common.spellcheck.localesMatchLeniently +import com.darkrockstudios.apps.hammer.common.util.Locale +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LanguageMatchingTest { + + private fun locale(tag: String) = Locale.forLanguageTag(tag) + + @Test + fun `same language without regions matches`() { + assertTrue(localesMatchLeniently(locale("en"), locale("en"))) + } + + @Test + fun `region-less side matches any region of the same language`() { + assertTrue(localesMatchLeniently(locale("en"), locale("en-US"))) + assertTrue(localesMatchLeniently(locale("en-US"), locale("en"))) + } + + @Test + fun `matching language and region match`() { + assertTrue(localesMatchLeniently(locale("pt-BR"), locale("pt-BR"))) + } + + @Test + fun `differing regions of the same language do not match`() { + assertFalse(localesMatchLeniently(locale("en-US"), locale("en-GB"))) + } + + @Test + fun `different languages do not match`() { + assertFalse(localesMatchLeniently(locale("fr"), locale("en"))) + assertFalse(localesMatchLeniently(locale("fr-FR"), locale("en-US"))) + } + + @Test + fun `language-less locales never match`() { + assertFalse(localesMatchLeniently(Locale.root, Locale.root)) + assertFalse(localesMatchLeniently(Locale.root, locale("en"))) + } + + @Test + fun `matching is case-insensitive via tag normalization`() { + assertTrue(localesMatchLeniently(locale("EN-us"), locale("en-US"))) + } + + @Test + fun `unset project language always allows spell check`() { + assertTrue(isSpellCheckAllowedForProject(null, locale("en-US"))) + assertTrue(isSpellCheckAllowedForProject("", locale("en-US"))) + assertTrue(isSpellCheckAllowedForProject(" ", locale("en-US"))) + } + + @Test + fun `matching project language allows spell check`() { + assertTrue(isSpellCheckAllowedForProject("en", locale("en-US"))) + assertTrue(isSpellCheckAllowedForProject("en-US", locale("en-US"))) + } + + @Test + fun `mismatched project language blocks spell check`() { + assertFalse(isSpellCheckAllowedForProject("fr", locale("en-US"))) + assertFalse(isSpellCheckAllowedForProject("en-GB", locale("en-US"))) + } +} diff --git a/common/src/desktopTest/kotlin/repositories/spellcheck/ProjectSpellCheckRepositoryTest.kt b/common/src/desktopTest/kotlin/repositories/spellcheck/ProjectSpellCheckRepositoryTest.kt new file mode 100644 index 000000000..05f2d46db --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/spellcheck/ProjectSpellCheckRepositoryTest.kt @@ -0,0 +1,183 @@ +package repositories.spellcheck + +import app.cash.turbine.test +import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.GlobalSettingsDatasource +import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.ServerSettingsDatasource +import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataDatasource +import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataRepository +import com.darkrockstudios.apps.hammer.common.data.projectdata.StoredProjectData +import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.SyncDataDatasource +import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer +import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository +import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository +import com.darkrockstudios.apps.hammer.common.util.Locale +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellCheckerFactory +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import utils.BaseTest +import kotlin.test.assertEquals +import kotlin.test.assertSame + +/** + * Classical-style: a real [ProjectSpellCheckRepository] over a real [SpellCheckRepository], + * [GlobalSettingsStore] and [ProjectDataRepository] (FakeFileSystem-backed). Only the + * platform spell checker factory and settings persistence are mocked. + */ +class ProjectSpellCheckRepositoryTest : BaseTest() { + + private val projectDef = ProjectDef( + name = "Test Project", + path = "/projects/Test Project".toPath().toHPath(), + ) + + private lateinit var globalSettingsDatasource: GlobalSettingsDatasource + private lateinit var serverSettingsDatasource: ServerSettingsDatasource + private lateinit var factory: PlatformSpellCheckerFactory + private lateinit var checker: PlatformSpellChecker + private lateinit var fileSystem: FakeFileSystem + private lateinit var projectDataRepository: ProjectDataRepository + private lateinit var projectDataDatasource: ProjectDataDatasource + + @BeforeEach + override fun setup() { + super.setup() + globalSettingsDatasource = mockk() + serverSettingsDatasource = mockk() + factory = mockk() + checker = mockk() + + every { factory.hasLanguage(any()) } returns true + coEvery { factory.createSpellChecker(any()) } returns checker + coEvery { globalSettingsDatasource.storeSettings(any()) } just Runs + coEvery { serverSettingsDatasource.loadServerSettings(any()) } returns null + + fileSystem = FakeFileSystem() + fileSystem.createDirectories("/projects/Test Project".toPath()) + + val syncDataDatasource = mockk(relaxed = true) + setupKoin(module { + scope { + scoped { projectDef } + scoped { syncDataDatasource } + } + }) + + projectDataDatasource = ProjectDataDatasource(fileSystem, createTomlSerializer(), projectDef) + projectDataRepository = ProjectDataRepository(projectDataDatasource, projectDef) + } + + private fun settingsStore(dictionary: Locale): GlobalSettingsStore { + coEvery { globalSettingsDatasource.loadSettings() } returns GlobalSettings( + projectsDirectory = "/projects", + spellCheckSettings = SpellCheckerSettings(enabled = true, locale = dictionary), + ) + return GlobalSettingsStore(globalSettingsDatasource, serverSettingsDatasource) + } + + private fun repository(store: GlobalSettingsStore) = ProjectSpellCheckRepository( + spellCheckRepository = SpellCheckRepository(store, factory), + globalSettingsStore = store, + projectDataRepository = projectDataRepository, + projectDef = projectDef, + ) + + @Test + fun `unset project language passes the global dictionary through`() = scope.runTest { + val repo = repository(settingsStore(Locale.forLanguageTag("en-US"))) + + repo.dictionaryFlow.test { + assertSame(checker, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `matching project language passes the dictionary through`() = scope.runTest { + projectDataDatasource.save(StoredProjectData(ProjectData(language = "en"), null)) + val repo = repository(settingsStore(Locale.forLanguageTag("en-US"))) + + repo.dictionaryFlow.test { + assertSame(checker, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `mismatched project language withholds the dictionary`() = scope.runTest { + projectDataDatasource.save(StoredProjectData(ProjectData(language = "fr"), null)) + val repo = repository(settingsStore(Locale.forLanguageTag("en-US"))) + + repo.dictionaryFlow.test { + assertEquals(null, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `changing the dictionary to a matching language re-enables spell check`() = scope.runTest { + projectDataDatasource.save(StoredProjectData(ProjectData(language = "fr"), null)) + val store = settingsStore(Locale.forLanguageTag("en-US")) + val repo = repository(store) + + repo.dictionaryFlow.test { + assertEquals(null, awaitItem()) + + store.updateSettings { settings -> + settings.copy( + spellCheckSettings = settings.spellCheckSettings.copy( + locale = Locale.forLanguageTag("fr") + ) + ) + } + + assertSame(checker, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `setting the project language to a mismatch disables spell check`() = scope.runTest { + val repo = repository(settingsStore(Locale.forLanguageTag("en-US"))) + + repo.dictionaryFlow.test { + assertSame(checker, awaitItem()) + + projectDataRepository.updateData { it.copy(language = "de") } + + assertEquals(null, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `spellCheckAllowed reflects the language gate`() = scope.runTest { + projectDataDatasource.save(StoredProjectData(ProjectData(language = "fr"), null)) + val repo = repository(settingsStore(Locale.forLanguageTag("en-US"))) + + repo.spellCheckAllowed.test { + assertEquals(false, awaitItem()) + + projectDataRepository.updateData { it.copy(language = "en") } + + assertEquals(true, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } +} diff --git a/common/src/desktopTest/kotlin/repositories/timeline/TimeLineTestBase.kt b/common/src/desktopTest/kotlin/repositories/timeline/TimeLineTestBase.kt index 8315bc4c1..b3a6dc1d1 100644 --- a/common/src/desktopTest/kotlin/repositories/timeline/TimeLineTestBase.kt +++ b/common/src/desktopTest/kotlin/repositories/timeline/TimeLineTestBase.kt @@ -13,8 +13,10 @@ import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEv import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository import io.mockk.* import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -119,6 +121,11 @@ abstract class TimeLineTestBase : BaseTest() { scoped { timelineRepo } bind TimeLineRepository::class scoped { idRepo } bind IdAllocator::class } + single { + mockk(relaxed = true) { + every { spellCheckAllowed } returns emptyFlow() + } + } } setupKoin(testModule) } diff --git a/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt b/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt index 3b9acd8a6..baa1376a4 100644 --- a/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt +++ b/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt @@ -496,13 +496,13 @@ class ClientAccountSynchronizerTest { coEvery { serverProjectsApi.deleteProject(id, "sync-1") } returns Result.success("ok") every { projectsRepository.getProjects(any()) } returns emptyList() every { projectsRepository.findProject(any()) } returns null - every { projectsRepository.createProject(any()) } returns CResult.success(projectDef("DeadNovel")) + every { projectsRepository.createProject(any(), any()) } returns CResult.success(projectDef("DeadNovel")) val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) assertTrue(result) coVerify { serverProjectsApi.deleteProject(id, "sync-1") } - verify(exactly = 0) { projectsRepository.createProject(any()) } + verify(exactly = 0) { projectsRepository.createProject(any(), any()) } verify(exactly = 0) { projectsRepository.setProjectId(any(), any()) } } @@ -517,12 +517,12 @@ class ClientAccountSynchronizerTest { Result.success(emptyServerResponse().copy(projects = setOf(serverProject))) every { projectsRepository.getProjects(any()) } returns emptyList() every { projectsRepository.findProject("ServerNovel") } returns null - every { projectsRepository.createProject("ServerNovel") } returns CResult.success(createdDef) + every { projectsRepository.createProject("ServerNovel", any()) } returns CResult.success(createdDef) val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) assertTrue(result) - verify { projectsRepository.createProject("ServerNovel") } + verify { projectsRepository.createProject("ServerNovel", any()) } verify { projectsRepository.setProjectId(createdDef, serverId) } } @@ -539,13 +539,13 @@ class ClientAccountSynchronizerTest { Result.success(emptyServerResponse().copy(projects = setOf(serverProject))) every { projectsRepository.getProjects(any()) } returns emptyList() every { projectsRepository.findProject(safeName) } returns null - every { projectsRepository.createProject(safeName) } returns CResult.success(createdDef) + every { projectsRepository.createProject(safeName, any()) } returns CResult.success(createdDef) val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) assertTrue(result) - verify { projectsRepository.createProject(safeName) } - verify(exactly = 0) { projectsRepository.createProject(mangledName) } + verify { projectsRepository.createProject(safeName, any()) } + verify(exactly = 0) { projectsRepository.createProject(mangledName, any()) } verify { projectsRepository.setProjectId(createdDef, serverId) } } } diff --git a/common/src/desktopTest/kotlin/utils/ComponentTest.kt b/common/src/desktopTest/kotlin/utils/ComponentTest.kt index e0285c972..142775dc9 100644 --- a/common/src/desktopTest/kotlin/utils/ComponentTest.kt +++ b/common/src/desktopTest/kotlin/utils/ComponentTest.kt @@ -10,7 +10,10 @@ import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectSpellCheckRepository +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.koin.core.module.Module @@ -93,12 +96,21 @@ open class ComponentTest : BaseTest() { /** * Like [setupKoin] but also registers the dependencies every project-scoped component injects * via [com.darkrockstudios.apps.hammer.common.components.ProjectComponentBase] — currently a - * relaxed [TagIndexService] (lazy, so harmless when a component never uses it). A test that - * needs to control TagIndexService should register its own via plain [setupKoin] instead. + * relaxed [TagIndexService] and a [ProjectSpellCheckRepository] whose flows never emit (both + * lazy, so harmless when a component never uses them). A test that needs to control either + * should register its own via plain [setupKoin] instead. */ protected fun setupComponentKoin(module: Module) { setupKoin( - module { single { mockk(relaxed = true) } }, + module { + single { mockk(relaxed = true) } + single { + mockk(relaxed = true) { + every { spellCheckAllowed } returns emptyFlow() + every { dictionaryFlow } returns emptyFlow() + } + } + }, module, ) } diff --git a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt index 3939f209b..f73f28adb 100644 --- a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt +++ b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt @@ -18,6 +18,7 @@ actual val platformModule = module { single() single() bind StrRes::class single() + single() single() bind UrlLauncher::class single() factory { params -> IosSettingsComponent(componentContext = params.get()) } bind PlatformSettings::class diff --git a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt new file mode 100644 index 000000000..1e5711e93 --- /dev/null +++ b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/util/AvailableLocalesProvider.kt @@ -0,0 +1,13 @@ +package com.darkrockstudios.apps.hammer.common.util + +import platform.Foundation.NSLocale +import platform.Foundation.availableLocaleIdentifiers + +actual class AvailableLocalesProvider { + actual fun allLocales(): List = + NSLocale.availableLocaleIdentifiers + .filterIsInstance() + .map { Locale.forLanguageTag(it) } + .filter { it.language != null } + .distinctBy { it.toLanguageTag() } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md index 3f017236e..d59263325 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/DESIGN_README.md @@ -306,6 +306,12 @@ The handwriting of the system. Reach for these instead of styling `DropdownMenu`. Same signature as `HdHairlineSegmentedPicker`, so swapping is one-for-one. Use for enums of 4+ values where a segmented row would crowd. +- **[`HdSearchableListDialog`](HdSearchableListDialog.kt)** — hairline + dialog with an `HdSearchField` over a lazy row list, each row a label + plus optional mono greeble (`English (United States) EN-US`). + Use where a dropdown would be unwieldy: dozens to hundreds of + options, e.g. every platform locale. Optional muted clear row above + the list resets the selection. ### Categorization diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdSearchableListDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdSearchableListDialog.kt new file mode 100644 index 000000000..95f74182f --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdSearchableListDialog.kt @@ -0,0 +1,225 @@ +package com.darkrockstudios.apps.hammer.common.compose.designsystem + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialog +import com.darkrockstudios.apps.hammer.common.compose.SpacerL +import com.darkrockstudios.apps.hammer.common.compose.Ui + +/** + * Hairline dialog for picking one item from a long list, narrowed by a search field. + * Use it when a [HdHairlineDropdown] would be unwieldy (dozens to hundreds of options, + * e.g. every locale the platform knows). + * + * ┌───────────────────────────────────────────┐ + * │ Choose a language × │ + * │ ┌───────────────────────────────────────┐ │ + * │ │ ⌕ search │ │ + * │ └───────────────────────────────────────┘ │ + * │ Clear selection │ + * │ ──────────────────────────────────────────│ + * │ English (United States) EN-US │ + * │ ──────────────────────────────────────────│ + * │ Français (France) FR-FR │ + * └───────────────────────────────────────────┘ + * + * The search matches [itemLabel] and [itemTrailing] case-insensitively. [itemTrailing] + * renders as a mono greeble on the right; anchor it to a real value (a locale code, + * a count), never decoration. [onClear], when provided, shows a muted row above the + * list that clears the current selection. Selection and clear both dismiss. + */ +@Composable +fun HdSearchableListDialog( + visible: Boolean, + title: String, + items: List, + itemLabel: (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, + closeContentDescription: String, + searchPlaceholder: String = "", + itemTrailing: ((T) -> String)? = null, + itemKey: ((T) -> Any)? = null, + clearLabel: String? = null, + onClear: (() -> Unit)? = null, + emptyLabel: String = "", +) { + var query by rememberSaveable { mutableStateOf("") } + + AnimatedDialog( + visible = visible, + onCloseRequest = onDismiss, + dismissOnTapOutside = true, + onDismissed = { query = "" }, + ) { + HdSearchableListDialogContent( + title = title, + query = query, + onQueryChange = { query = it }, + items = items, + itemLabel = itemLabel, + onSelect = onSelect, + onDismissRequest = { requestDismiss() }, + closeContentDescription = closeContentDescription, + searchPlaceholder = searchPlaceholder, + itemTrailing = itemTrailing, + itemKey = itemKey, + clearLabel = clearLabel, + onClear = onClear, + emptyLabel = emptyLabel, + ) + } +} + +/** The dialog body without the [AnimatedDialog] host, so previews can render it directly. */ +@Composable +internal fun HdSearchableListDialogContent( + title: String, + query: String, + onQueryChange: (String) -> Unit, + items: List, + itemLabel: (T) -> String, + onSelect: (T) -> Unit, + onDismissRequest: () -> Unit, + closeContentDescription: String, + searchPlaceholder: String = "", + itemTrailing: ((T) -> String)? = null, + itemKey: ((T) -> Any)? = null, + clearLabel: String? = null, + onClear: (() -> Unit)? = null, + emptyLabel: String = "", +) { + val filtered = remember(items, query) { + if (query.isBlank()) items + else items.filter { + itemLabel(it).contains(query, ignoreCase = true) || + itemTrailing?.invoke(it)?.contains(query, ignoreCase = true) == true + } + } + + HdHairlineDialogShell( + title = title, + onClose = onDismissRequest, + closeContentDescription = closeContentDescription, + ) { + HdSearchField( + value = query, + onValueChange = onQueryChange, + placeholder = searchPlaceholder, + onClear = { onQueryChange("") }, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerL() + + // Pinned above the scrolling list so it stays reachable even when the + // search matches nothing. + if (clearLabel != null && onClear != null) { + PickerRow( + label = clearLabel, + trailing = null, + muted = true, + onClick = { + onClear() + onDismissRequest() + }, + ) + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 360.dp), + ) { + if (filtered.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Ui.Padding.XL), + contentAlignment = Alignment.Center, + ) { + Text( + text = emptyLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(filtered, key = itemKey) { item -> + PickerRow( + label = itemLabel(item), + trailing = itemTrailing?.invoke(item), + muted = false, + onClick = { + onSelect(item) + onDismissRequest() + }, + ) + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + } + } + } +} + +@Composable +private fun PickerRow( + label: String, + trailing: String?, + muted: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = Ui.Padding.M, vertical = Ui.Padding.L), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = if (muted) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + if (trailing != null) { + HdMonoLabel( + text = trailing, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/CreateEntryUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/CreateEntryUi.kt index 184515481..d2ec4ea50 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/CreateEntryUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/CreateEntryUi.kt @@ -207,6 +207,7 @@ internal fun CreateEntryUi( MarkdownEditField( initialMarkdown = description, onMarkdownChanged = { description = it }, + enableSpellChecking = state.spellCheckAllowed, contentPadding = PaddingValues(Ui.Padding.XL), modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp), ) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt index 18e95df7c..8220b1f56 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt @@ -617,6 +617,7 @@ private fun WideBody( entryText = entryText, setEntryText = setEntryText, onStartTextEdit = onStartTextEdit, + enableSpellChecking = state.spellCheckAllowed, modifier = Modifier.weight(1f).fillMaxHeight(), sharedKey = "encyclopedia-text-${state.entryDef.id}", animatedVisibilityScope = animatedVisibilityScope, @@ -685,6 +686,7 @@ private fun CompactBody( entryText = entryText, setEntryText = setEntryText, onStartTextEdit = onStartTextEdit, + enableSpellChecking = state.spellCheckAllowed, modifier = if (editing) Modifier.fillMaxWidth().weight(1f) else Modifier.fillMaxWidth(), sharedKey = "encyclopedia-text-${state.entryDef.id}", animatedVisibilityScope = animatedVisibilityScope, @@ -700,6 +702,7 @@ private fun BodyTextZone( entryText: String, setEntryText: (String) -> Unit, onStartTextEdit: () -> Unit, + enableSpellChecking: Boolean, modifier: Modifier, sharedKey: String, animatedVisibilityScope: AnimatedVisibilityScope, @@ -716,6 +719,7 @@ private fun BodyTextZone( MarkdownEditField( initialMarkdown = entryText, onMarkdownChanged = setEntryText, + enableSpellChecking = enableSpellChecking, contentPadding = PaddingValues(Ui.Padding.XL), modifier = Modifier .fillMaxWidth() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/CreateNoteUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/CreateNoteUi.kt index 30274dd04..c02bab3c7 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/CreateNoteUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/CreateNoteUi.kt @@ -115,6 +115,7 @@ fun CreateNoteUi( MarkdownEditField( initialMarkdown = noteText, onMarkdownChanged = { component.onTextChanged(it) }, + enableSpellChecking = state.spellCheckAllowed, contentPadding = PaddingValues(Ui.Padding.XL), testTag = NOTES_CREATE_BODY_TAG, modifier = Modifier diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt index 14836f3d8..e9ea4c67c 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/notes/ViewNoteUi.kt @@ -128,6 +128,7 @@ fun ViewNoteUi( noteText = noteText, onNoteTextChanged = { component.onContentChanged(it) }, suggestTags = component::suggestTags, + enableSpellChecking = state.spellCheckAllowed, modifier = Modifier.weight(1f), ) @@ -377,6 +378,7 @@ private fun EditBody( noteText: String, onNoteTextChanged: (String) -> Unit, suggestTags: (prefix: String) -> List, + enableSpellChecking: Boolean, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxWidth()) { @@ -403,6 +405,7 @@ private fun EditBody( MarkdownEditField( initialMarkdown = noteText, onMarkdownChanged = onNoteTextChanged, + enableSpellChecking = enableSpellChecking, contentPadding = PaddingValues(Ui.Padding.XL), modifier = Modifier .fillMaxWidth() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt index 182062c25..bb760b5e2 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -29,6 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -45,6 +47,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineSeg import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineTagField import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineToggleRow import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchableListDialog import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.theme.parseHexColor import com.darkrockstudios.apps.hammer.common.compose.theme.toArgbHex @@ -52,6 +55,13 @@ import com.darkrockstudios.apps.hammer.project_info_author_label import com.darkrockstudios.apps.hammer.project_info_color_picker_cancel import com.darkrockstudios.apps.hammer.project_info_color_picker_confirm import com.darkrockstudios.apps.hammer.project_info_color_picker_edit +import com.darkrockstudios.apps.hammer.project_info_language_clear +import com.darkrockstudios.apps.hammer.project_info_language_dialog_close +import com.darkrockstudios.apps.hammer.project_info_language_dialog_title +import com.darkrockstudios.apps.hammer.project_info_language_empty +import com.darkrockstudios.apps.hammer.project_info_language_label +import com.darkrockstudios.apps.hammer.project_info_language_search_hint +import com.darkrockstudios.apps.hammer.project_info_language_unset import com.darkrockstudios.apps.hammer.project_info_tags_hint import com.darkrockstudios.apps.hammer.project_info_tags_label import com.darkrockstudios.apps.hammer.project_info_tags_placeholder @@ -97,6 +107,78 @@ internal fun AuthorField( ) } +@Composable +internal fun LanguageField( + languageTag: String?, + options: List, + onChange: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + var showPicker by remember { mutableStateOf(false) } + val selected = options.firstOrNull { it.tag == languageTag } + val displayName = selected?.displayName ?: languageTag + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HdMonoLabel(text = Res.string.project_info_language_label.get()) + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 36.dp) + .border( + width = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RectangleShape, + ) + .clickable { showPicker = true } + .padding(horizontal = Ui.Padding.M, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.S), + ) { + Text( + text = displayName ?: Res.string.project_info_language_unset.get(), + style = MaterialTheme.typography.bodyMedium, + color = if (displayName != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (languageTag != null) { + HdMonoLabel( + text = languageTag.uppercase(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HdMonoLabel( + text = "▾", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HdSearchableListDialog( + visible = showPicker, + title = Res.string.project_info_language_dialog_title.get(), + items = options, + itemLabel = { it.displayName }, + itemTrailing = { it.tag.uppercase() }, + itemKey = { it.tag }, + onSelect = { onChange(it.tag) }, + onDismiss = { showPicker = false }, + closeContentDescription = Res.string.project_info_language_dialog_close.get(), + searchPlaceholder = Res.string.project_info_language_search_hint.get(), + clearLabel = Res.string.project_info_language_clear.get(), + onClear = { onChange(null) }, + emptyLabel = Res.string.project_info_language_empty.get(), + ) +} + @Composable internal fun CustomThemeSection( section: Int, @@ -430,6 +512,11 @@ fun ProjectInfoSettingsUi(component: ProjectSettings) { initial = state.data.authorName.orEmpty(), onChange = component::setAuthorName, ) + LanguageField( + languageTag = state.data.language, + options = component.availableLanguages, + onChange = component::setProjectLanguage, + ) CustomThemeSection( section = 1, theme = state.data.theme, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt index 7c29d5903..1ca906dd0 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectSettingsUi.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.projecthome.ProjectSettings +import com.darkrockstudios.apps.hammer.common.components.spellchecksettings.SpellCheckSettings import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarColumn import com.darkrockstudios.apps.hammer.common.compose.Ui @@ -40,6 +41,9 @@ import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.projectselection.settings.SpellCheckSettingsContent +import com.darkrockstudios.apps.hammer.common.spellcheck.displayName +import com.darkrockstudios.apps.hammer.common.spellcheck.isSpellCheckAllowedForProject +import com.darkrockstudios.apps.hammer.common.util.Locale import com.darkrockstudios.apps.hammer.project_settings_autosaved import com.darkrockstudios.apps.hammer.project_settings_breadcrumb_home import com.darkrockstudios.apps.hammer.project_settings_breadcrumb_root @@ -48,6 +52,7 @@ import com.darkrockstudios.apps.hammer.project_settings_folio_section_count import com.darkrockstudios.apps.hammer.project_settings_hero_by import com.darkrockstudios.apps.hammer.project_settings_hero_marker import com.darkrockstudios.apps.hammer.project_settings_hero_no_author +import com.darkrockstudios.apps.hammer.project_settings_spellcheck_mismatch_caption import com.darkrockstudios.apps.hammer.project_settings_spellcheck_section_title private val MaxColumnWidth = 880.dp @@ -111,6 +116,10 @@ fun ProjectSettingsUi( contentSpacing = 18.dp, ) { SpellCheckSettingsContent(component.spellCheckSettings) + SpellCheckMismatchCaption( + projectLanguageTag = state.data.language, + spellCheckSettings = component.spellCheckSettings, + ) } } @@ -133,6 +142,27 @@ fun ProjectSettingsUi( } } +/** Explains why spell check isn't running when the project's language gates it off. */ +@Composable +private fun SpellCheckMismatchCaption( + projectLanguageTag: String?, + spellCheckSettings: SpellCheckSettings, +) { + val spellCheckState by spellCheckSettings.state.subscribeAsState() + if (!spellCheckState.spellCheckingEnabled) return + if (projectLanguageTag == null) return + if (isSpellCheckAllowedForProject(projectLanguageTag, spellCheckState.spellCheckingLanguage)) return + + Text( + text = Res.string.project_settings_spellcheck_mismatch_caption.get( + Locale.forLanguageTag(projectLanguageTag).displayName(), + spellCheckState.spellCheckingLanguage.displayName(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + @Composable private fun Breadcrumb( projectName: String, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectDataConflict.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectDataConflict.kt index 16fdd12b5..0e52e4e4b 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectDataConflict.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectDataConflict.kt @@ -39,7 +39,10 @@ import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.sync_conflict_project_data_cadence_day import com.darkrockstudios.apps.hammer.sync_conflict_project_data_cadence_week import com.darkrockstudios.apps.hammer.sync_conflict_project_data_explanation +import com.darkrockstudios.apps.hammer.common.spellcheck.displayName +import com.darkrockstudios.apps.hammer.common.util.Locale import com.darkrockstudios.apps.hammer.sync_conflict_project_data_field_author +import com.darkrockstudios.apps.hammer.sync_conflict_project_data_field_language import com.darkrockstudios.apps.hammer.sync_conflict_project_data_field_tags import com.darkrockstudios.apps.hammer.sync_conflict_project_data_field_theme import com.darkrockstudios.apps.hammer.sync_conflict_project_data_field_word_goal @@ -60,11 +63,13 @@ internal fun ProjectDataConflict( var themeChoice by remember { mutableStateOf(DataChoice.LOCAL) } var goalChoice by remember { mutableStateOf(DataChoice.LOCAL) } var tagsChoice by remember { mutableStateOf(DataChoice.LOCAL) } + var languageChoice by remember { mutableStateOf(DataChoice.LOCAL) } val authorConflict = conflictState.local.authorName != conflictState.server.authorName val themeConflict = conflictState.local.theme != conflictState.server.theme val goalConflict = conflictState.local.wordCountGoal != conflictState.server.wordCountGoal val tagsConflict = conflictState.local.tags != conflictState.server.tags + val languageConflict = conflictState.local.language != conflictState.server.language val unsetLabel = Res.string.sync_conflict_project_data_value_unset.get() val localLabel = Res.string.sync_conflict_tab_local.get() @@ -134,6 +139,18 @@ internal fun ProjectDataConflict( stackVertical = stackVertical, ) + PerFieldChoice( + label = Res.string.sync_conflict_project_data_field_language.get(), + conflict = languageConflict, + localValue = displayLanguage(conflictState.local.language, unsetLabel), + serverValue = displayLanguage(conflictState.server.language, unsetLabel), + selected = languageChoice, + onSelect = { languageChoice = it }, + localLabel = localLabel, + remoteLabel = remoteLabel, + stackVertical = stackVertical, + ) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -149,6 +166,7 @@ internal fun ProjectDataConflict( themeChoice = themeChoice, goalChoice = goalChoice, tagsChoice = tagsChoice, + languageChoice = languageChoice, ) ) }, @@ -251,11 +269,13 @@ private fun buildResolved( themeChoice: DataChoice, goalChoice: DataChoice, tagsChoice: DataChoice, + languageChoice: DataChoice, ): ProjectData = ProjectData( authorName = if (authorChoice == DataChoice.LOCAL) local.authorName else server.authorName, theme = if (themeChoice == DataChoice.LOCAL) local.theme else server.theme, wordCountGoal = if (goalChoice == DataChoice.LOCAL) local.wordCountGoal else server.wordCountGoal, tags = if (tagsChoice == DataChoice.LOCAL) local.tags else server.tags, + language = if (languageChoice == DataChoice.LOCAL) local.language else server.language, ) private fun displayString(value: String?, unsetLabel: String): String = @@ -267,6 +287,9 @@ private fun displayTheme(theme: ProjectTheme?, unsetLabel: String): String = private fun displayTags(tags: Set, unsetLabel: String): String = if (tags.isEmpty()) unsetLabel else tags.sorted().joinToString(" ") { "#$it" } +private fun displayLanguage(tag: String?, unsetLabel: String): String = + if (tag.isNullOrBlank()) unsetLabel else Locale.forLanguageTag(tag).displayName() + private fun displayWordGoal(goal: WordCountGoal?, unsetLabel: String, dayLabel: String, weekLabel: String): String { if (goal == null) return unsetLabel val cadence = when (goal.cadence) { diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/CreateTimeLineEventUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/CreateTimeLineEventUi.kt index ee118cfc5..d33cab107 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/CreateTimeLineEventUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/CreateTimeLineEventUi.kt @@ -137,6 +137,7 @@ fun CreateTimeLineEventUi( MarkdownEditField( initialMarkdown = contentText, onMarkdownChanged = { component.onContentChanged(it) }, + enableSpellChecking = state.spellCheckAllowed, contentPadding = PaddingValues(Ui.Padding.XL), testTag = TIME_LINE_CREATE_CONTENT_TAG, modifier = Modifier diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt index 3098eb42e..b8eebc694 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/timeline/ViewTimeLineEventUi.kt @@ -135,6 +135,7 @@ fun ViewTimeLineEventUi( eventText = eventText, onEventTextChanged = { component.onEventTextChanged(it) }, suggestTags = component::suggestTags, + enableSpellChecking = state.spellCheckAllowed, modifier = Modifier.weight(1f), ) @@ -386,6 +387,7 @@ private fun EditBody( eventText: String, onEventTextChanged: (String) -> Unit, suggestTags: (prefix: String) -> List, + enableSpellChecking: Boolean, modifier: Modifier = Modifier, ) { // Date hides while the body editor has focus, like the tags; kept visible while it holds focus @@ -431,6 +433,7 @@ private fun EditBody( MarkdownEditField( initialMarkdown = eventText, onMarkdownChanged = onEventTextChanged, + enableSpellChecking = enableSpellChecking, contentPadding = PaddingValues(Ui.Padding.XL), modifier = Modifier .fillMaxWidth() diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/HdSearchableListDialog.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/HdSearchableListDialog.Preview.kt new file mode 100644 index 000000000..4690510ca --- /dev/null +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/HdSearchableListDialog.Preview.kt @@ -0,0 +1,72 @@ +package com.darkrockstudios.apps.hammer.common.preview.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchableListDialogContent +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview + +private val languages = listOf( + "de-DE" to "German (Germany)", + "en-GB" to "English (United Kingdom)", + "en-US" to "English (United States)", + "fr-FR" to "French (France)", + "pt-BR" to "Portuguese (Brazil)", + "uk-UA" to "Ukrainian (Ukraine)", +) + +@Composable +private fun DialogPreviewSurface(query: String) { + AppTheme(globalSettingsPreview) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + HdSearchableListDialogContent( + title = "Choose a language", + query = query, + onQueryChange = {}, + items = languages, + itemLabel = { it.second }, + itemTrailing = { it.first.uppercase() }, + itemKey = { it.first }, + onSelect = {}, + onDismissRequest = {}, + closeContentDescription = "Close", + searchPlaceholder = "Search languages", + clearLabel = "Clear (not set)", + onClear = {}, + emptyLabel = "No languages match", + ) + } + } +} + +@Preview(heightDp = 620) +@Composable +fun HdSearchableListDialogPreview() { + DialogPreviewSurface(query = "") +} + +@Preview(heightDp = 420) +@Composable +fun HdSearchableListDialogFilteredPreview() { + DialogPreviewSurface(query = "english") +} + +@Preview(heightDp = 360) +@Composable +fun HdSearchableListDialogEmptyPreview() { + DialogPreviewSurface(query = "klingon") +} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectSettingsUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectSettingsUi.Preview.kt index bd1d339ef..7133500a8 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectSettingsUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectSettingsUi.Preview.kt @@ -62,8 +62,8 @@ private val fakeSpellCheckSettings = object : SpellCheckSettings { SpellCheckSettings.State( spellCheckingEnabled = true, spellCheckingInFocusEnabled = false, - spellCheckingLanguage = Locale.root, - spellCheckLanguages = listOf(Locale.root), + spellCheckingLanguage = Locale.forLanguage("en", "US"), + spellCheckLanguages = listOf(Locale.forLanguage("en", "US")), ) ) @@ -72,20 +72,47 @@ private val fakeSpellCheckSettings = object : SpellCheckSettings { override suspend fun setSpellCheckLanguage(language: Locale) {} } -private val component = object : ProjectSettings { +private fun fakeComponent(language: String?) = object : ProjectSettings { override val projectName: String = "The Lighthouse" override val spellCheckSettings: SpellCheckSettings = fakeSpellCheckSettings override val projectInfoState: Value = MutableValue( ProjectSettings.ProjectInfoState( - data = ProjectData(authorName = "A. Writer", tags = setOf("fantasy", "draft")), + data = ProjectData( + authorName = "A. Writer", + tags = setOf("fantasy", "draft"), + language = language, + ), isLoaded = true, ) ) + override val availableLanguages: List = listOf( + ProjectSettings.LanguageOption("en-US", "English (United States)"), + ProjectSettings.LanguageOption("fr-FR", "French (France)"), + ProjectSettings.LanguageOption("pt-BR", "Portuguese (Brazil)"), + ) override fun setAuthorName(name: String?) {} override fun setTheme(theme: ProjectTheme?) {} override fun setWordCountGoal(goal: WordCountGoal?) {} override fun setTags(tags: Set) {} + override fun setProjectLanguage(tag: String?) {} override fun suggestProjectTags(prefix: String): List = listOf("fantasy", "sci-fi", "nanowrimo").filter { it.startsWith(prefix, ignoreCase = true) } } + +private val component = fakeComponent(language = "en-US") + +/** Project language (fr-FR) mismatched against the en-US dictionary: the caption shows. Tall enough to reach the spell-check section. */ +@Preview(widthDp = TABLET_WIDTH_DP, heightDp = 2600) +@Composable +fun ScreenProjectSettingsUiLanguageMismatchPreview() { + KoinApplicationPreview { + TabletPreviewSurface { + ProjectSettingsUi( + modifier = Modifier, + component = fakeComponent(language = "fr-FR"), + onClose = {}, + ) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 691207c62..28f482dab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -234,6 +234,14 @@ ViewModels/Components and renders them. that use them. They are the only stateful components that may reference their own tier, and only acyclically (see Foundation primitives). A foundation primitive must never reference a Repository or higher. +7. **One owner per persisted format.** Every on-disk file format is owned by exactly one Data + Source; only that file serializes or deserializes it. Code that cannot reach the datasource + instance (no per-project Koin scope yet, wrong lifecycle) does not fall back to raw + `fileSystem.writeToml`/`readToml` — it uses or adds a **scope-less helper in the datasource's + own file** (pattern: `loadStoredProjectData`/`saveStoredProjectData` in + `ProjectDataDatasource.kt`). A second inline writer forks the format and clobbers sibling + fields on write: two independent writers of `project_data.toml` once each erased the other's + field. Enforced by `PersistedFormatOwnershipTest` in `common/src/desktopTest`. ## DI conventions diff --git a/integrationTests/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/integration/HeadlessClient.kt b/integrationTests/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/integration/HeadlessClient.kt index ce9d40b5f..af07064da 100644 --- a/integrationTests/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/integration/HeadlessClient.kt +++ b/integrationTests/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/integration/HeadlessClient.kt @@ -82,7 +82,12 @@ class HeadlessClient private constructor( val projectsRepository: ProjectsRepository = koin.get() val globalSettings: GlobalSettingsStore = koin.get() - val createResult = projectsRepository.createProject(projectName) + // A second device opening an existing server project must keep the never-synced + // ProjectData baseline; only a genuinely new project gets the language seed. + val createResult = projectsRepository.createProject( + projectName, + seedDefaultLanguage = serverProjectId == null, + ) check(isSuccess(createResult)) { "Failed to create local project: $createResult" } val projectDef: ProjectDef = createResult.data diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ProjectDataDao.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ProjectDataDao.kt index e7d3ca6d5..8394cbee0 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ProjectDataDao.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/ProjectDataDao.kt @@ -19,6 +19,11 @@ class ProjectDataDao( ?.let { ProjectDataRow(content = it.content, hash = it.hash, updatedAt = it.updated_at) } } + suspend fun getHash(userId: Long, projectId: Long): String? = + withContext(ioDispatcher) { + queries.getHashForProject(userId, projectId).executeAsOneOrNull() + } + suspend fun upsert( userId: Long, projectId: Long, diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt index e12f8e6dd..c8a2dfe4c 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt @@ -97,6 +97,7 @@ fun Route.frontend() { val userActivityRepository: UserActivityRepository by inject() val storyReaderRepository: StoryReaderRepository by inject() val storyReaderCollector: StoryReaderCollector by inject() + val serverProjectDataRepository: com.darkrockstudios.apps.hammer.project.ServerProjectDataRepository by inject() val recurringTaskRegistry: com.darkrockstudios.apps.hammer.scheduling.RecurringTaskRegistry by inject() val clock: kotlin.time.Clock by inject() val projectsSyncManager: SyncSessionManager by inject(named(PROJECTS_SYNC_MANAGER)) @@ -166,6 +167,7 @@ fun Route.frontend() { storyReaderCollector, accountsRepository, projectsRepository, + serverProjectDataRepository, serverConfig, ) if (serverConfig.richLinkPreviews) { diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/PublicStoryPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/PublicStoryPage.kt index aa8299a16..9110c5e30 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/PublicStoryPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/PublicStoryPage.kt @@ -15,6 +15,8 @@ import com.darkrockstudios.apps.hammer.frontend.utils.pageETag import com.darkrockstudios.apps.hammer.frontend.utils.resolveByPenName import com.darkrockstudios.apps.hammer.frontend.utils.storyArticleJsonLd import com.darkrockstudios.apps.hammer.monitoring.StoryReaderCollector +import com.darkrockstudios.apps.hammer.project.ProjectDefinition +import com.darkrockstudios.apps.hammer.project.ServerProjectDataRepository import com.darkrockstudios.apps.hammer.project.access.ProjectAccessRepository import com.darkrockstudios.apps.hammer.project.access.PublicProjectResult import com.darkrockstudios.apps.hammer.projects.ProjectsRepository @@ -45,6 +47,7 @@ fun Route.publicStoryPage( storyReaderCollector: StoryReaderCollector, accountsRepository: AccountsRepository, projectsRepository: ProjectsRepository, + serverProjectDataRepository: ServerProjectDataRepository, serverConfig: ServerConfig, ) { route("/a/{penName}/{projectName}") { @@ -117,6 +120,8 @@ fun Route.publicStoryPage( "" } + val projectDef = ProjectDefinition(name = projectName, uuid = resolved.projectUuid) + // The page shell — everything not derived from the story render. Built first so // it can be hashed into the validator, then filled in with the render below. val model = call.withDefaults( @@ -146,10 +151,17 @@ fun Route.publicStoryPage( val prepared = storyRendererService.prepareExport(resolved.userId, resolved.projectUuid) + // Stands in for everything project-data carries (notably the declared story + // language rendered below): a hash-only lookup, so the revalidation path never + // transfers or parses the data blob. + val projectDataHash = serverProjectDataRepository.loadDataHash(resolved.userId, projectDef) + // A reader who already holds this exact page is answered without rendering it. // `indexable` is an explicit input: it gates the JSON-LD block but never reaches // the shell model. - val etag = prepared?.let { pageETag(model, it.version, page, passwordParam, indexable) } + val etag = prepared?.let { + pageETag(model, it.version, page, passwordParam, indexable, projectDataHash) + } if (etag != null && call.matchesETag(etag)) { call.applyRevalidationHeaders(etag) call.respond(HttpStatusCode.NotModified) @@ -178,6 +190,19 @@ fun Route.publicStoryPage( is PaginatedExportResult.Success -> { val data = exportResult.data + // The story's declared language, when set, becomes the page's content + // language: via the `locale` model key and the JSON-LD + // `inLanguage` below. Overridden only now, after withDefaults, so the + // header/footer extra links stay localized to the viewer's UI locale; + // the ETag covers it through projectDataHash above. + val storyLanguage = serverProjectDataRepository.loadProjectLanguage( + userId = resolved.userId, + projectDef = projectDef, + ) + if (storyLanguage != null) { + model["locale"] = storyLanguage + } + model.putAll( mapOf( "storyHtml" to data.pageHtml, @@ -205,6 +230,7 @@ fun Route.publicStoryPage( authorName = resolved.penName, authorUrl = call.canonicalUrl("/a/${ProjectName.penNameForUrl(resolved.penName)}"), wordCount = data.totalWordCount.toLong(), + inLanguage = storyLanguage, ) } etag?.let { call.applyRevalidationHeaders(it) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/SeoJsonLd.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/SeoJsonLd.kt index ccde3e729..ce36e1ccb 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/SeoJsonLd.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/SeoJsonLd.kt @@ -37,6 +37,7 @@ private data class ArticleLd( val url: String, val author: PersonLd, val wordCount: Long? = null, + val inLanguage: String? = null, ) @Serializable @@ -73,6 +74,7 @@ fun storyArticleJsonLd( authorName: String, authorUrl: String, wordCount: Long? = null, + inLanguage: String? = null, ): String = jsonLdEncoder.encodeToString( ArticleLd( @@ -80,6 +82,7 @@ fun storyArticleJsonLd( url = url, author = PersonLd(name = authorName, url = authorUrl), wordCount = wordCount, + inLanguage = inLanguage, ), ).escapeForScript() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/project/ServerProjectDataRepository.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/project/ServerProjectDataRepository.kt index 4b6c9f8a2..b2640a417 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/project/ServerProjectDataRepository.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/project/ServerProjectDataRepository.kt @@ -42,6 +42,28 @@ class ServerProjectDataRepository( return SResult.success(RawProjectDataDto(data = data, hash = row.hash)) } + /** + * The stored project-data hash, for HTTP validators: it changes with any project-data + * edit (including the declared language) without transferring or parsing the content. + * Null when the project has no data row. + */ + suspend fun loadDataHash(userId: Long, projectDef: ProjectDefinition): String? { + val projectId = projectDao.getProjectIdOrNull(userId, projectDef.uuid) ?: return null + return projectDataDao.getHash(userId, projectId) + } + + /** The project's declared BCP-47 language for public-page metadata; null when unset or unreadable. */ + suspend fun loadProjectLanguage(userId: Long, projectDef: ProjectDefinition): String? { + val projectId = projectDao.getProjectIdOrNull(userId, projectDef.uuid) ?: return null + val row = projectDataDao.get(userId, projectId) ?: return null + val element = try { + json.parseToJsonElement(row.content) + } catch (e: SerializationException) { + return null + } + return decodeAsProjectData(element)?.language?.takeIf { it.isNotBlank() } + } + suspend fun save( userId: Long, projectDef: ProjectDefinition, diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq index 3f13d52f7..15334e004 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq @@ -20,6 +20,12 @@ SELECT content, hash, updated_at FROM project_data WHERE user_id = :userId AND project_id = :projectId; +-- Hash-only lookup for HTTP validators: no content transfer, no JSON parse. +getHashForProject: +SELECT hash +FROM project_data +WHERE user_id = :userId AND project_id = :projectId; + upsert: INSERT INTO project_data(user_id, project_id, content, hash, updated_at) VALUES (:userId, :projectId, :content, :hash, :updatedAt) diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/PublicStoryPageTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/PublicStoryPageTest.kt index 3515ba919..2a91cd4df 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/PublicStoryPageTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/PublicStoryPageTest.kt @@ -60,6 +60,59 @@ class PublicStoryPageTest : EndToEndTest() { ) } + private fun seedProjectData(content: String) { + database().serverDatabase.projectDataQueries.upsert( + userId = userId, + projectId = projectRowId(), + content = content, + hash = "test-hash", + updatedAt = kotlin.time.Clock.System.now(), + ) + } + + @Test + fun `a story with a declared language is served with that language`(): Unit = runBlocking { + doStartServer() + seedStory() + grantAccess(password = null) + seedProjectData("""{"language":"fr"}""") + // JSON-LD is only emitted for indexable pages, which require a community author. + database().serverDatabase.accountQueries.updateCommunityMember(community_member = true, id = userId) + + val body = client().get(storyPath()).bodyAsText() + + assertTrue(body.contains(" Date: Mon, 3 Aug 2026 22:26:31 -0700 Subject: [PATCH 026/213] Match search on rendered prose, with a scan that does not allocate (#841) * Match global search against projected Markdown prose Search compared queries against raw Markdown, so backslash escapes and emphasis markers sitting between words caused misses, and snippets rendered storage syntax. Flatten stored Markdown to the prose a reader sees before matching: escapes resolve to their literal character, and paired emphasis or code delimiters are dropped. Pairing follows CommonMark flanking rules, so literal markers in imported or hand-edited content survive; user_name and a bare *** divider are left alone. Timeline dates are a plain-text field and are not projected. Derived titles go through the same projection as the note list so the two views agree, and blank projections fall back to the raw source so marker-only content still gets a title and still appears in tag searches. Fixes #811 * Scope Markdown pairing to paragraphs and narrow the title change Delimiter pairing used a single document-wide stack, so an unpaired asterisk or a backtick used as an apostrophe paired with an unrelated one paragraphs away. Both characters were deleted, and runs between a bogus code span were marked inert, which left real emphasis in place and defeated the cross-markup matching this is for. Pair within a paragraph only. Emphasis still spans a soft line break. Strip leading blockquote, heading and bullet markers from every line rather than only from derived titles, so a title and its snippet agree. Ordered-list markers are left alone: one line cannot tell "1. Draft" from "1984. The year everything changed". Titles now take the first non-blank line of the whole projection instead of projecting a line in isolation, so emphasis closing on the next line still pairs. Revert firstNonBlankLine to its verbatim behavior. Routing it through the projection reached into Browse Notes, Story Ideas and sync conflict labels and unescaped backslashes there, corrupting stored paths. Skip the raw fallback scan when the query holds no character the projection can remove, which makes it provably redundant. * Project Markdown in a reusable scan workspace Global search re-projected every document on every keystroke, allocating about 36x the source in garbage each pass: one object per delimiter run, two lists, a StringBuilder and an output String, per scene, per search. MarkdownProjector holds that work in buffers it keeps. Delimiter runs move into parallel primitive arrays, the projected prose lands in a char buffer that is matched in place rather than turned into a String, and every buffer grows to the widest document seen and is then reused. A full scan of a 1.25M word project drops from 31.7MB of garbage to none, at the same wall time. Two smaller wins came with it: the source is copied into a flat array so the scan and render index an array instead of paying a CharSequence call per character on both passes, and the ASCII punctuation test became four range checks instead of a scan over a 32 character string. The projector cannot be a field on the use case. Cancelling a search is cooperative, so the outgoing scan can still be running when the next one starts, and the two would share a buffer. MarkdownProjectorPool lends one per scan and keeps it afterwards, so the buffers survive to the next keystroke without forcing the four scans to run one at a time. projectMarkdownToPlainText stays as the one-shot convenience over the same code, so the behaviour is defined in one place. MarkdownProjectionTest passes unchanged, which is the point: the rewrite is internal. * Read scenes straight into the scan buffer Search re-read every scene from disk on each keystroke and took a String back for each one, about 11.5MB of garbage per pass over a 300k word project before any matching happened. SceneDatasource can now decode a scene into buffers the caller owns and return the char count, so a scan reuses one pair of buffers instead of taking a string per file. ScanBuffers is the contract; MarkdownProjector implements it, so the bytes land in the same workspace that projects them. The same scan now costs 0.42MB at the same wall time. The bytes are pulled in bulk and decoded from an array. Reading a byte at a time off a BufferedSource was measurably slower than Okio's own readUtf8, and the decode was never the expensive part. Matching follows the text into the buffer: findProjectedMatch works over whatever the projector holds, and the raw-markup fallback searches the source buffer, so neither path needs the document as a string. Only the snippet window is copied out, and only on a hit. SearchProjectUseCaseTest stubbed loadSceneMarkdownRaw, which the scene path no longer calls. The stub now fills the buffer it is handed, which is what the collaborator actually does. * Carry the projection across every search surface Rebasing onto develop put #831's unification and #821's projection in the same tree, and they disagreed. #831 made four surfaces share one rule, markdownContains, which resolves escapes only. Global search now projects, so leaving markdownContains alone would have re-split the surfaces it had just joined: Notes, Timeline and Story Ideas would still miss a phrase spanning "**emphasis**". It projects now too, and mirrors the same fallback. The two PRs also pulled opposite ways on the raw-source fallback. #831 pinned that searching the storage form of prose must not work, and in the same breath that literal "**Chapter**" must still be found. Under the projection those need different answers, and #821's gate, "the query holds any character the projection could remove", cannot give them: a backslash and an emphasis marker are both removable, so honouring one broke the other. containsInlineMarkup replaces it. Only emphasis and code markers open the fallback, because spelling those out is someone hunting for markup. Escapes and block markers do not, because "well\-known" is the storage form of prose and nobody types it. Both of #831's assertions hold. matchOrPreview keeps the fallback argument and the empty-snippet chain from #833, which the projection commits had dropped, so a tag-only search still returns bodiless items. * Match the query as literal text and nothing else Global search resolved the query against the prose on screen, then, for a query containing an emphasis or code marker, searched the raw storage form as well. That second pass was the query being read as markup: typing "**Chapter**" found a document whose prose reads "Chapter", because the asterisks were matched against the source rather than against what the document displays. The rule is now one line. A query is literal text, matched against the prose the document renders as, and there is no second interpretation. The asterisks are not on screen, so typing them finds nothing. Nothing about escapes changes, and this is easiest to see in the case that motivated it. Text stored as "well\-known" renders as "well-known" and is found by typing that; the storage form is not. Text stored as "well\-known" renders as "well\-known", backslash and all, and typing that backslash finds it. Markers the projection leaves alone, "5*4" and "user_name", are matched where they sit, because there they are prose. Three assertions pinned the behaviour that has gone, one per surface, and each now states the opposite. containsInlineMarkup and the projector's source-side accessors existed only to serve the fallback and go with it. * Drop a comma from a test name so iOS can compile it Kotlin/Native rejects a comma in a backticked identifier, so the whole iosTest compilation failed on one test name. Desktop accepts it, and compileIosMainKotlinMetadata only covers main, so nothing local caught it. :common:compileTestKotlinIosSimulatorArm64 does, and runs on a non-Mac host. --- .../data/globalsearch/SearchProjectUseCase.kt | 285 +++++++---- .../sceneeditorrepository/SceneDatasource.kt | 24 + .../sceneeditorrepository/SceneRepository.kt | 12 + .../common/data/search/MarkdownProjection.kt | 58 +++ .../common/data/search/MarkdownProjector.kt | 471 ++++++++++++++++++ .../data/search/MarkdownProjectorPool.kt | 31 ++ .../hammer/common/data/search/SearchQuery.kt | 17 +- .../apps/hammer/common/util/CharBuffers.kt | 100 ++++ .../data/search/MarkdownContainsTest.kt | 12 +- .../data/search/MarkdownProjectionTest.kt | 168 +++++++ .../data/search/MarkdownProjectorTest.kt | 132 +++++ .../hammer/common/util/CharBuffersTest.kt | 91 ++++ .../data/search/LegacyMarkdownProjection.kt | 283 +++++++++++ .../kotlin/data/search/ProjectorBenchmark.kt | 111 +++++ .../data/search/ScanLoadPathBenchmark.kt | 128 +++++ .../globalsearch/SearchProjectUseCaseTest.kt | 214 +++++++- .../desktopTest/kotlin/SearchFilterTest.kt | 4 +- 17 files changed, 2039 insertions(+), 102 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjection.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjector.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorPool.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffers.kt create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectionTest.kt create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorTest.kt create mode 100644 common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffersTest.kt create mode 100644 common/src/desktopTest/kotlin/data/search/LegacyMarkdownProjection.kt create mode 100644 common/src/desktopTest/kotlin/data/search/ProjectorBenchmark.kt create mode 100644 common/src/desktopTest/kotlin/data/search/ScanLoadPathBenchmark.kt diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt index 1680831c4..2fa0f3695 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsearch/SearchProjectUseCase.kt @@ -10,10 +10,11 @@ import com.darkrockstudios.apps.hammer.common.data.notesrepository.NotesReposito import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneContentRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository +import com.darkrockstudios.apps.hammer.common.data.search.MarkdownProjector +import com.darkrockstudios.apps.hammer.common.data.search.MarkdownProjectorPool import com.darkrockstudios.apps.hammer.common.data.search.ParsedQuery import com.darkrockstudios.apps.hammer.common.data.search.matchesAllTags import com.darkrockstudios.apps.hammer.common.data.search.parseQuery -import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_DEFAULT import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_IO @@ -45,6 +46,9 @@ class SearchProjectUseCase( private val dispatcherDefault: CoroutineContext by inject(named(DISPATCHER_DEFAULT)) private val dispatcherIo: CoroutineContext by inject(named(DISPATCHER_IO)) + // Outlives a single search so the buffers one scan grew are still sized for the next keystroke. + private val projectors = MarkdownProjectorPool() + suspend fun search(query: String, filter: GlobalSearchFilter): List { val parsed = parseQuery(query) if (!parsed.isUsable()) return emptyList() @@ -79,46 +83,51 @@ class SearchProjectUseCase( private val GlobalSearchFilter.includesTimeline: Boolean get() = this == GlobalSearchFilter.All || this == GlobalSearchFilter.Timeline - private fun searchNotes(parsed: ParsedQuery): List { - return notes.getNotes() - .filter { it.note.tags.matchesAllTags(parsed.tags) } - .mapNotNull { container -> - val snippet = matchOrPreview(container.note.content, parsed.text) - ?: return@mapNotNull null - SearchResult.Note( - noteId = container.note.id, - title = firstLineTitle(container.note.content, fallback = "(empty note)"), - snippet = snippet, - ) - } - .take(PER_SOURCE_CAP) - } + private suspend fun searchNotes(parsed: ParsedQuery): List = + projectors.borrow { projector -> + notes.getNotes() + .filter { it.note.tags.matchesAllTags(parsed.tags) } + .mapNotNull { container -> + val snippet = matchOrPreview(projector, container.note.content, parsed.text) + ?: return@mapNotNull null + SearchResult.Note( + noteId = container.note.id, + title = firstLineTitle(projector, container.note.content, fallback = "(empty note)"), + snippet = snippet, + ) + } + .take(PER_SOURCE_CAP) + } - private suspend fun searchTimeline(parsed: ParsedQuery): List { + private suspend fun searchTimeline(parsed: ParsedQuery): List = projectors.borrow { projector -> val timeline = timeLine.loadTimeline() - return timeline.events + timeline.events .filter { it.tags.matchesAllTags(parsed.tags) } .mapNotNull { event -> - val snippet = matchTimelineEvent(event.date, event.content, parsed.text) + val snippet = matchTimelineEvent(projector, event.date, event.content, parsed.text) ?: return@mapNotNull null SearchResult.TimelineEvent( eventId = event.id, title = event.date?.takeIf { it.isNotBlank() } - ?: firstLineTitle(event.content, fallback = "(empty event)"), + ?: firstLineTitle(projector, event.content, fallback = "(empty event)"), snippet = snippet, ) } .take(PER_SOURCE_CAP) } - private suspend fun searchEncyclopedia(parsed: ParsedQuery): List { - val entries = collectEntryDefs() - return entries - .mapNotNull { def -> matchEncyclopediaEntry(def, parsed) } - .take(PER_SOURCE_CAP) - } + private suspend fun searchEncyclopedia(parsed: ParsedQuery): List = + projectors.borrow { projector -> + collectEntryDefs() + .mapNotNull { def -> matchEncyclopediaEntry(projector, def, parsed) } + .take(PER_SOURCE_CAP) + } - private suspend fun matchEncyclopediaEntry(def: EntryDef, parsed: ParsedQuery): SearchResult? { + private suspend fun matchEncyclopediaEntry( + projector: MarkdownProjector, + def: EntryDef, + parsed: ParsedQuery, + ): SearchResult? { val needTags = parsed.tags.isNotEmpty() val freeText = parsed.text @@ -146,7 +155,7 @@ class SearchProjectUseCase( } val title = if (matchedTag != null) "${def.name} • #$matchedTag" else def.name val snippet = findMatch(def.name, freeText) - ?: matchOrPreview(entry.text, freeText, fallback = def.name) + ?: matchOrPreview(projector, entry.text, freeText, fallback = def.name) ?: return null return SearchResult.EncyclopediaEntry( entryDef = def, @@ -167,7 +176,7 @@ class SearchProjectUseCase( ) } - val textMatch = findMarkdownMatch(entry.text, freeText) ?: return null + val textMatch = findMarkdownMatch(projector, entry.text, freeText) ?: return null return SearchResult.EncyclopediaEntry( entryDef = def, title = def.name, @@ -177,16 +186,18 @@ class SearchProjectUseCase( private suspend fun collectEntryDefs(): List = encyclopedia.ensureEntriesLoaded() - private suspend fun searchScenes(parsed: ParsedQuery): List { - val query = parsed.text - val needTags = parsed.tags.isNotEmpty() - val scenes = sceneEditor.getScenes().filter { it.type == SceneItem.Type.Scene } - return scenes - .mapNotNull { scene -> matchScene(scene, query, needTags, parsed.tags) } - .take(PER_SOURCE_CAP) - } + private suspend fun searchScenes(parsed: ParsedQuery): List = + projectors.borrow { projector -> + val query = parsed.text + val needTags = parsed.tags.isNotEmpty() + sceneEditor.getScenes() + .filter { it.type == SceneItem.Type.Scene } + .mapNotNull { scene -> matchScene(projector, scene, query, needTags, parsed.tags) } + .take(PER_SOURCE_CAP) + } private suspend fun matchScene( + projector: MarkdownProjector, scene: SceneItem, query: String, needTags: Boolean, @@ -198,8 +209,9 @@ class SearchProjectUseCase( if (nameMatch != null) { return SearchResult.Scene(sceneItem = scene, title = scene.name, snippet = nameMatch) } - val text = withContext(dispatcherIo) { loadSceneText(scene) } ?: return null - val bodyMatch = findMarkdownMatch(text, query) ?: return null + val loaded = withContext(dispatcherIo) { projectScene(projector, scene) } + if (!loaded) return null + val bodyMatch = findProjectedMatch(projector, query) ?: return null return SearchResult.Scene(sceneItem = scene, title = scene.name, snippet = bodyMatch) } @@ -221,53 +233,89 @@ class SearchProjectUseCase( if (nameMatch != null) { return SearchResult.Scene(sceneItem = scene, title = title, snippet = nameMatch) } - val text = withContext(dispatcherIo) { loadSceneText(scene) } ?: return null - val bodyMatch = findMarkdownMatch(text, query) ?: return null + val loaded = withContext(dispatcherIo) { projectScene(projector, scene) } + if (!loaded) return null + val bodyMatch = findProjectedMatch(projector, query) ?: return null return SearchResult.Scene(sceneItem = scene, title = title, snippet = bodyMatch) } /** - * Both callers pass stored Markdown, so escapes are resolved before matching. With no free text - * the item already matched on its tags, so a blank body falls back to [fallback] and then to an - * empty snippet rather than discarding the result. + * Both callers pass stored Markdown, so both paths go through the prose projection. With no free + * text the item already matched on its tags, so a blank body falls back to [fallback] and then to + * an empty snippet rather than discarding the result. */ - private fun matchOrPreview(content: String, query: String, fallback: String = ""): AnnotatedSnippet? { + private fun matchOrPreview( + projector: MarkdownProjector, + content: String, + query: String, + fallback: String = "", + ): AnnotatedSnippet? { if (query.isEmpty()) { - return previewSnippet(unescapeMarkdown(content)) + return markdownPreviewSnippet(projector, content) ?: previewSnippet(fallback) ?: EMPTY_SNIPPET } - return findMarkdownMatch(content, query) + return findMarkdownMatch(projector, content, query) } - /** The date is a plain-text field, so only the event body is unescaped. */ - private fun matchTimelineEvent(date: String?, content: String, query: String): AnnotatedSnippet? { - val resolved = withDate(date, unescapeMarkdown(content)) - if (query.isEmpty()) return previewSnippet(resolved) ?: EMPTY_SNIPPET - return findMatch(resolved, query) + /** + * The date is a plain-text field, so only the body is projected. Both halves are searched as one + * string so a query can span the separator. + */ + private fun matchTimelineEvent( + projector: MarkdownProjector, + date: String?, + content: String, + query: String, + ): AnnotatedSnippet? { + projector.project(content) + val projected = withDate(date, projector.projected()) + if (query.isEmpty()) { + return previewSnippet(projected) + ?: previewSnippet(withDate(date, content)) + ?: EMPTY_SNIPPET + } + findMatch(projected, query)?.let { return it } + return findMatch(withDate(date, content), query) } private fun withDate(date: String?, content: String): String = if (date.isNullOrBlank()) content else "$date — $content" - private fun loadSceneText(scene: SceneItem): String? { - val buffer = sceneContentRepository.getSceneBuffer(scene) - val bufferText = buffer?.content?.markdown - if (bufferText != null) return bufferText - return runCatching { sceneEditor.loadSceneMarkdownRaw(scene) }.getOrNull() + /** + * Loads the scene into [projector] and returns whether there is anything to match. An unsaved + * scene is already in memory as a string; a saved one is decoded straight off disk into the + * projector's own buffer, so scanning a project takes no string per file. + */ + private fun projectScene(projector: MarkdownProjector, scene: SceneItem): Boolean { + val bufferText = sceneContentRepository.getSceneBuffer(scene)?.content?.markdown + if (bufferText != null) { + if (bufferText.isEmpty()) return false + projector.project(bufferText) + return true + } + val chars = runCatching { + sceneEditor.readSceneMarkdownInto(scene, projector) + }.getOrDefault(0) + if (chars <= 0) return false + projector.projectSource(chars) + return true } - /** [content] is stored Markdown, so the title resolves escapes the way its snippet does. */ - private fun firstLineTitle(content: String, fallback: String): String { - val firstLine = content.lineSequence() - .firstOrNull { it.isNotBlank() } - ?.let { unescapeMarkdown(it) } - ?.trim() - .orEmpty() + /** [content] is stored Markdown, so the derived title is projected the same way the UI does. */ + private fun firstLineTitle( + projector: MarkdownProjector, + content: String, + fallback: String, + ): String { + projector.project(content) + val title = projector.firstNonBlankLine().ifEmpty { + content.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + } return when { - firstLine.isEmpty() -> fallback - firstLine.length > TITLE_MAX -> firstLine.take(TITLE_MAX).trimEnd() + "…" - else -> firstLine + title.isEmpty() -> fallback + title.length > TITLE_MAX -> title.take(TITLE_MAX).trimEnd() + "…" + else -> title } } @@ -277,9 +325,12 @@ class SearchProjectUseCase( const val SNIPPET_BEFORE = 40 const val SNIPPET_AFTER = 80 + /** Compiled once: snippet assembly runs per result, and `Regex(…)` rebuilds the pattern. */ + private val WHITESPACE_RUN = Regex("\\s+") + internal fun previewSnippet(content: String): AnnotatedSnippet? { if (content.isEmpty()) return null - val flattened = content.replace(Regex("\\s+"), " ").trim() + val flattened = content.replace(WHITESPACE_RUN, " ").trim() if (flattened.isEmpty()) return null val maxLen = SNIPPET_BEFORE + SNIPPET_AFTER val truncated = if (flattened.length > maxLen) { @@ -290,16 +341,6 @@ class SearchProjectUseCase( return AnnotatedSnippet(text = truncated, matchStart = 0, matchEnd = 0) } - /** - * Matches stored Markdown with its escapes resolved, so a query matches the prose on screen - * and the snippet renders the same way. The resolved text is the only thing searched, so a - * snippet can never disagree with the title derived from it. - */ - internal fun findMarkdownMatch(markdown: String, query: String): AnnotatedSnippet? { - if (markdown.isEmpty() || query.isEmpty()) return null - return findMatch(unescapeMarkdown(markdown), query) - } - internal fun findMatch(text: String, query: String): AnnotatedSnippet? { if (text.isEmpty() || query.isEmpty()) return null val pos = text.indexOf(query, ignoreCase = true) @@ -307,17 +348,91 @@ class SearchProjectUseCase( return buildSnippet(text, pos, query.length) } - internal fun buildSnippet(text: String, matchPos: Int, queryLen: Int): AnnotatedSnippet { - val windowStart = (matchPos - SNIPPET_BEFORE).coerceAtLeast(0) - val windowEnd = (matchPos + queryLen + SNIPPET_AFTER).coerceAtMost(text.length) - val rawWindow = text.substring(windowStart, windowEnd) - val flattened = rawWindow.replace(Regex("\\s+"), " ").trim() + /** + * Matches against the prose projection of stored Markdown, so queries spanning storage + * syntax still hit and the snippet reads the way the document does. The raw source is + * searched as a fallback, which keeps queries for literal markup working. + * + * The projection is matched inside [projector]'s buffer, so a document that does not match + * costs nothing but the scan. + */ + internal fun findMarkdownMatch( + projector: MarkdownProjector, + markdown: String, + query: String, + ): AnnotatedSnippet? { + if (markdown.isEmpty() || query.isEmpty()) return null + projector.project(markdown) + return findProjectedMatch(projector, query) + } - val matchedTerm = text.substring(matchPos, matchPos + queryLen) + /** + * Matches a document [projector] already holds, whether it was copied in or read straight + * off disk. The raw fallback searches the source buffer, so neither path needs the document + * as a string. + */ + internal fun findProjectedMatch( + projector: MarkdownProjector, + query: String, + ): AnnotatedSnippet? { + if (query.isEmpty()) return null + val pos = projector.indexOf(query) + if (pos >= 0) { + return buildSnippetFrom(projector.length, pos, query.length, projector::substring) + } + return null + } + + /** Falls back to the raw source so a document made only of markup still previews. */ + internal fun markdownPreviewSnippet( + projector: MarkdownProjector, + markdown: String, + ): AnnotatedSnippet? { + if (markdown.isEmpty()) return null + projector.project(markdown) + val preview = projector.collapsedPreview(SNIPPET_BEFORE + SNIPPET_AFTER) + if (preview.isNotEmpty()) { + return AnnotatedSnippet(text = preview, matchStart = 0, matchEnd = 0) + } + return previewSnippet(markdown) + } + + internal fun buildSnippet(text: String, matchPos: Int, queryLen: Int): AnnotatedSnippet = + buildSnippetFrom(text.length, matchPos, queryLen, text::substring) + + /** + * Only the snippet window is copied out of [slice], so a hit costs a line rather than a + * document, whether the text lives in a String or in a scan buffer. + */ + private fun buildSnippetFrom( + totalLength: Int, + matchPos: Int, + queryLen: Int, + slice: (Int, Int) -> String, + ): AnnotatedSnippet { + val windowStart = (matchPos - SNIPPET_BEFORE).coerceAtLeast(0) + val windowEnd = (matchPos + queryLen + SNIPPET_AFTER).coerceAtMost(totalLength) + return assembleSnippet( + rawWindow = slice(windowStart, windowEnd), + matchedTerm = slice(matchPos, matchPos + queryLen), + hasPrefix = windowStart > 0, + hasSuffix = windowEnd < totalLength, + queryLen = queryLen, + ) + } + + private fun assembleSnippet( + rawWindow: String, + matchedTerm: String, + hasPrefix: Boolean, + hasSuffix: Boolean, + queryLen: Int, + ): AnnotatedSnippet { + val flattened = rawWindow.replace(WHITESPACE_RUN, " ").trim() val matchInFlattened = flattened.indexOf(matchedTerm, ignoreCase = true) - val prefix = if (windowStart > 0) "…" else "" - val suffix = if (windowEnd < text.length) "…" else "" + val prefix = if (hasPrefix) "…" else "" + val suffix = if (hasSuffix) "…" else "" val snippet = prefix + flattened + suffix val highlightStart = if (matchInFlattened >= 0) prefix.length + matchInFlattened else prefix.length diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt index 704781ddb..743d0c219 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneDatasource.kt @@ -10,6 +10,8 @@ import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath +import com.darkrockstudios.apps.hammer.common.util.ScanBuffers +import com.darkrockstudios.apps.hammer.common.util.readUtf8Into import io.github.aakira.napier.Napier import okio.FileSystem import okio.IOException @@ -239,6 +241,28 @@ class SceneDatasource( return content } + /** + * Decodes the scene's Markdown into a buffer from [sink] and returns how many chars it holds, + * so a caller scanning every scene can reuse one buffer instead of taking a string per file. + * Returns 0 for a missing, empty or unreadable scene, and for anything that is not a scene. + */ + fun readSceneMarkdownInto( + sceneItem: SceneItem, + scenePath: HPath, + sink: ScanBuffers, + ): Int { + if (sceneItem.type != SceneItem.Type.Scene) return 0 + val path = scenePath.toOkioPath() + return try { + val byteCount = fileSystem.metadata(path).size ?: return 0 + if (byteCount <= 0L || byteCount > Int.MAX_VALUE) return 0 + fileSystem.read(path) { readUtf8Into(sink, byteCount.toInt()) } + } catch (e: IOException) { + Napier.e("Failed to read Scene markdown (${sceneItem.name})") + 0 + } + } + suspend fun storeSceneMarkdownRaw(sceneItem: SceneContent, scenePath: HPath): Boolean { sceneItem.markdown ?: return false diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt index b46f7e4ce..6a10ca7b1 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt @@ -11,6 +11,7 @@ import com.darkrockstudios.apps.hammer.common.data.tree.Tree import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.util.ScanBuffers import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath import com.darkrockstudios.apps.hammer.common.util.numDigits @@ -758,6 +759,17 @@ class SceneRepository( ): String = sceneDatasource.loadSceneMarkdownRaw(sceneItem, scenePath) + /** + * Reads the scene's Markdown into a caller-owned buffer and returns its length in chars. Global + * search scans every scene on each keystroke, so it reuses one buffer rather than taking a + * string per file. + */ + fun readSceneMarkdownInto( + sceneItem: SceneItem, + sink: ScanBuffers, + scenePath: HPath = getSceneFilePath(sceneItem), + ): Int = sceneDatasource.readSceneMarkdownInto(sceneItem, scenePath, sink) + /** * This should only be used for server syncing */ diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjection.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjection.kt new file mode 100644 index 000000000..140e16b8f --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjection.kt @@ -0,0 +1,58 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +/** + * Flattens stored Markdown into the prose a reader sees. Backslash escapes resolve to their literal + * character (`well\-known` becomes `well-known`), paired emphasis and code delimiters are dropped + * (`**Chapter** One` becomes `Chapter One`), and leading blockquote, heading and bullet markers are + * removed from each line. + * + * Delimiters pair only within a paragraph, and only where CommonMark's flanking rules allow, so + * literal markers survive: `user_name`, `2 * 3`, a bare `***` divider and a stray apostrophe-backtick + * are all left intact. + * + * Link and image syntax is left as written. Punctuation is tested against ASCII only, and escapes + * resolve inside code spans as well as outside them. + * + * Scanning a whole project repeatedly wants [MarkdownProjector] instead: this convenience builds a + * workspace per call, where the projector reuses one across every document in a scan. + */ +fun projectMarkdownToPlainText(markdown: String): String { + if (!containsMarkdownSyntax(markdown)) return markdown + val projector = MarkdownProjector(markdown.length) + projector.project(markdown) + return projector.projected() +} + +/** + * The prose title for [markdown]: the first non-blank line of its projection. Falls back to the raw + * first line when the projection of it is blank, so a marker-only line still yields a title rather + * than reading as an empty document. + */ +fun markdownTitleLine(markdown: String): String { + if (!containsMarkdownSyntax(markdown)) { + return markdown.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + } + val projector = MarkdownProjector(markdown.length) + projector.project(markdown) + val title = projector.firstNonBlankLine() + if (title.isNotEmpty()) return title + return markdown.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() +} + +/** + * True when [text] holds a character the projection can remove. Text without one projects to itself, + * so the scan can be skipped entirely. + */ +internal fun containsMarkdownSyntax(text: CharSequence): Boolean { + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '*' || c == '_' || c == '`' || + c == '\\' || c == '#' || c == '>' || c == '-' || c == '+' + ) { + return true + } + i++ + } + return false +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjector.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjector.kt new file mode 100644 index 000000000..926243999 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjector.kt @@ -0,0 +1,471 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import com.darkrockstudios.apps.hammer.common.util.ScanBuffers + +private const val FLAG_CAN_OPEN = 1 +private const val FLAG_CAN_CLOSE = 2 +private const val FLAG_INERT = 4 + +private const val INITIAL_OUT = 8 * 1024 +private const val INITIAL_RUNS = 256 +private const val MIN_RUNS = 16 + +/** + * CommonMark's ASCII punctuation set, as the four contiguous ranges it actually is. Tested once per + * character of every document scanned, so the ranges beat a membership scan over a 32-char string. + */ +internal fun Char.isAsciiPunctuation(): Boolean = + this in '!'..'/' || + this in ':'..'@' || + this in '['..'`' || + this in '{'..'~' + +private fun Char.isDelimiterChar(): Boolean = this == '*' || this == '_' || this == '`' + +private fun Char.isSpaceOrTabChar(): Boolean = this == ' ' || this == '\t' + +/** + * A reusable workspace for [projectMarkdownToPlainText]'s scan. Every buffer grows to the largest + * document it has seen and is then reused, so scanning a project a second time allocates nothing: + * the delimiter runs live in parallel primitive arrays rather than objects, and the projected prose + * lands in a char buffer that is matched against in place rather than being turned into a String. + * + * Not thread safe. A concurrent scan needs its own instance. + */ +class MarkdownProjector(initialCapacity: Int = INITIAL_OUT) : ScanBuffers { + + /** + * The source is copied in and then indexed as a flat array: the scan and the render each walk + * every character, and going through `CharSequence` would pay an interface call on both passes. + */ + private var src = CharArray(initialCapacity) + private var srcLength = 0 + + private var rawBytes = ByteArray(0) + + /** The projection only ever deletes, so it can never outgrow the source it came from. */ + private var out = CharArray(initialCapacity) + private var outLength = 0 + + /** + * Sized from the document rather than a fixed floor, so projecting one short note does not pay + * for a novel's worth of run slots. Grows to whatever the widest document actually needs. + */ + private val initialRuns = (initialCapacity / 32).coerceIn(MIN_RUNS, INITIAL_RUNS) + + private var runChar = CharArray(initialRuns) + private var runStart = IntArray(initialRuns) + private var runEnd = IntArray(initialRuns) + private var runParagraph = IntArray(initialRuns) + private var runDropFront = IntArray(initialRuns) + private var runDropBack = IntArray(initialRuns) + private var runFlags = IntArray(initialRuns) + private var runCount = 0 + + private var openStack = IntArray(initialRuns) + private var openCount = 0 + + /** Length of the projected prose currently held in the buffer. */ + val length: Int get() = outLength + + operator fun get(index: Int): Char = out[index] + + /** + * Projects [source] into this workspace and returns the projected length. The result stays valid + * until the next call. + */ + fun project(source: CharSequence): Int { + val length = source.length + ensureSource(length) + var i = 0 + while (i < length) { + src[i] = source[i] + i++ + } + return projectSource(length) + } + + /** + * The source buffer, grown to hold at least [minCapacity] characters. Fill it directly and then + * call [projectSource] to skip copying the document in at all. + */ + fun sourceBuffer(minCapacity: Int): CharArray { + ensureSource(minCapacity) + return src + } + + override fun charBuffer(minCapacity: Int): CharArray = sourceBuffer(minCapacity) + + /** Scratch for the raw bytes of a document being read in, before they are decoded into [src]. */ + override fun byteBuffer(minCapacity: Int): ByteArray { + if (rawBytes.size < minCapacity) rawBytes = ByteArray(minCapacity) + return rawBytes + } + + /** Projects the first [length] characters already sitting in [sourceBuffer]. */ + fun projectSource(length: Int): Int { + srcLength = length + outLength = 0 + ensureOut(length) + scanDelimiterRuns() + resolveCodeSpans() + resolveEmphasis() + render() + return outLength + } + + /** + * Index of [query] in the projected prose, or -1. Compares against the buffer directly so a scan + * that finds nothing never builds a string. + */ + fun indexOf(query: String, ignoreCase: Boolean = true): Int { + val queryLength = query.length + if (queryLength == 0 || queryLength > outLength) return -1 + val buffer = out + val first = query[0] + val last = outLength - queryLength + var i = 0 + outer@ while (i <= last) { + if (!buffer[i].equals(first, ignoreCase)) { + i++ + continue + } + var j = 1 + while (j < queryLength) { + if (!buffer[i + j].equals(query[j], ignoreCase)) { + i++ + continue@outer + } + j++ + } + return i + } + return -1 + } + + /** The projected prose as a String. Allocates, so reserve it for a hit. */ + fun substring(startIndex: Int, endIndex: Int): String { + val from = startIndex.coerceIn(0, outLength) + val to = endIndex.coerceIn(from, outLength) + return out.concatToString(from, to) + } + + fun projected(): String = substring(0, outLength) + + /** + * Up to [maxChars] of the projection with runs of whitespace collapsed to a single space, and an + * ellipsis when prose remained. Walks the buffer, so previewing never materializes the whole + * document just to throw most of it away. + */ + fun collapsedPreview(maxChars: Int): String { + val sb = StringBuilder(maxChars + 1) + var i = 0 + var pendingSpace = false + while (i < outLength && sb.length <= maxChars) { + val c = out[i] + if (c.isWhitespace()) { + if (sb.isNotEmpty()) pendingSpace = true + } else { + if (pendingSpace) { + sb.append(' ') + pendingSpace = false + } + sb.append(c) + } + i++ + } + // Anything left that is not whitespace means the preview is a truncation, not the whole text. + var more = false + while (i < outLength) { + if (!out[i].isWhitespace()) { + more = true + break + } + i++ + } + if (sb.length > maxChars || more) { + while (sb.length > maxChars) sb.deleteAt(sb.length - 1) + while (sb.isNotEmpty() && sb.last().isWhitespace()) sb.deleteAt(sb.length - 1) + sb.append('…') + } + return sb.toString() + } + + /** The first non-blank line of the projection, or "" when every line is blank. */ + fun firstNonBlankLine(): String { + var i = 0 + while (i < outLength) { + var end = i + while (end < outLength && out[end] != '\n') end++ + var start = i + while (start < end && out[start].isWhitespace()) start++ + var stop = end + while (stop > start && out[stop - 1].isWhitespace()) stop-- + if (stop > start) return out.concatToString(start, stop) + i = end + 1 + } + return "" + } + + private fun ensureSource(needed: Int) { + if (src.size < needed) src = CharArray(needed) + } + + private fun ensureOut(needed: Int) { + if (out.size < needed) out = CharArray(needed) + } + + private fun ensureRuns(needed: Int) { + if (runChar.size >= needed) return + val size = needed * 2 + runChar = runChar.copyOf(size) + runStart = runStart.copyOf(size) + runEnd = runEnd.copyOf(size) + runParagraph = runParagraph.copyOf(size) + runDropFront = runDropFront.copyOf(size) + runDropBack = runDropBack.copyOf(size) + runFlags = runFlags.copyOf(size) + openStack = openStack.copyOf(size) + } + + private fun available(run: Int): Int = + runEnd[run] - runStart[run] - runDropFront[run] - runDropBack[run] + + private fun addRun(char: Char, start: Int, end: Int, paragraph: Int, flags: Int) { + ensureRuns(runCount + 1) + runChar[runCount] = char + runStart[runCount] = start + runEnd[runCount] = end + runParagraph[runCount] = paragraph + runDropFront[runCount] = 0 + runDropBack[runCount] = 0 + runFlags[runCount] = flags + runCount++ + } + + private fun isBlankOrEdge(index: Int): Boolean = + index < 0 || index >= srcLength || src[index].isWhitespace() + + private fun isPunctuationAt(index: Int): Boolean = + index >= 0 && index < srcLength && src[index].isAsciiPunctuation() + + private fun scanDelimiterRuns() { + runCount = 0 + val length = srcLength + var i = 0 + var paragraph = 0 + while (i < length) { + val c = src[i] + if (c == '\\' && i + 1 < length && src[i + 1].isAsciiPunctuation()) { + i += 2 + continue + } + if (c == '\n') { + var k = i + 1 + while (k < length && (src[k].isSpaceOrTabChar() || src[k] == '\r')) k++ + if (k >= length || src[k] == '\n') paragraph++ + i++ + continue + } + if (!c.isDelimiterChar()) { + i++ + continue + } + + val start = i + var end = start + 1 + while (end < length && src[end] == c) end++ + i = end + + var canOpen = true + var canClose = true + if (c != '`') { + val prevBlank = isBlankOrEdge(start - 1) + val nextBlank = isBlankOrEdge(end) + val prevPunct = isPunctuationAt(start - 1) + val nextPunct = isPunctuationAt(end) + val leftFlanking = !nextBlank && (!nextPunct || prevBlank || prevPunct) + val rightFlanking = !prevBlank && (!prevPunct || nextBlank || nextPunct) + if (c == '*') { + canOpen = leftFlanking + canClose = rightFlanking + } else { + // Underscores cannot delimit emphasis inside a word, which is what saves `user_name`. + canOpen = leftFlanking && (!rightFlanking || prevPunct) + canClose = rightFlanking && (!leftFlanking || nextPunct) + } + // A run that can do neither is literal text; leaving it out keeps it out of the render. + if (!canOpen && !canClose) continue + } + + var flags = 0 + if (canOpen) flags = flags or FLAG_CAN_OPEN + if (canClose) flags = flags or FLAG_CAN_CLOSE + addRun(c, start, end, paragraph, flags) + } + } + + /** Code spans bind tighter than emphasis, so they claim their delimiters first. */ + private fun resolveCodeSpans() { + var i = 0 + while (i < runCount) { + if (runChar[i] != '`') { + i++ + continue + } + + val width = runEnd[i] - runStart[i] + var j = i + 1 + var closer = -1 + while (j < runCount && runParagraph[j] == runParagraph[i]) { + if (runChar[j] == '`' && runEnd[j] - runStart[j] == width) { + closer = j + break + } + j++ + } + if (closer < 0) { + i++ + continue + } + + runDropBack[i] = width + runDropFront[closer] = width + for (k in i + 1 until closer) runFlags[k] = runFlags[k] or FLAG_INERT + i = closer + 1 + } + } + + private fun resolveEmphasis() { + openCount = 0 + var paragraph = -1 + for (run in 0 until runCount) { + if (runParagraph[run] != paragraph) { + openCount = 0 + paragraph = runParagraph[run] + } + if (runChar[run] == '`' || runFlags[run] and FLAG_INERT != 0) continue + + if (runFlags[run] and FLAG_CAN_CLOSE != 0) { + while (available(run) > 0) { + var openerSlot = -1 + var s = openCount - 1 + while (s >= 0) { + if (runChar[openStack[s]] == runChar[run]) { + openerSlot = s + break + } + s-- + } + if (openerSlot < 0) break + + val opener = openStack[openerSlot] + val consumed = minOf(available(run), available(opener)) + runDropBack[opener] += consumed + runDropFront[run] += consumed + + // Anything stacked above the matched opener can never close now. + openCount = openerSlot + if (available(opener) > 0) pushOpen(opener) + } + } + if (available(run) > 0 && runFlags[run] and FLAG_CAN_OPEN != 0) pushOpen(run) + } + } + + private fun pushOpen(run: Int) { + ensureRuns(openCount + 1) + openStack[openCount] = run + openCount++ + } + + /** + * The offset after any blockquote, heading or bullet marker opening the line at [start], or + * [start] itself when the line opens with prose. Ordered-list markers are left alone: a single + * line cannot distinguish "1. Draft opening" from "1984. The year everything changed". + */ + private fun skipBlockPrefix(start: Int): Int { + val length = srcLength + var i = start + var found = false + + while (i < length && src[i].isSpaceOrTabChar()) i++ + + while (i < length && src[i] == '>') { + i++ + found = true + while (i < length && src[i].isSpaceOrTabChar()) i++ + } + + if (i < length && src[i] == '#') { + var j = i + var hashes = 0 + while (j < length && src[j] == '#') { + j++ + hashes++ + } + if (hashes <= 6 && j < length && src[j].isSpaceOrTabChar()) { + i = j + found = true + } + } else if (i < length && (src[i] == '-' || src[i] == '*' || src[i] == '+')) { + if (i + 1 < length && src[i + 1].isSpaceOrTabChar()) { + i++ + found = true + } + } + + if (!found) return start + while (i < length && src[i].isSpaceOrTabChar()) i++ + return i + } + + private fun appendOut(c: Char) { + out[outLength] = c + outLength++ + } + + private fun appendOut(from: Int, to: Int) { + var i = from + while (i < to) { + out[outLength] = src[i] + outLength++ + i++ + } + } + + private fun render() { + val length = srcLength + var i = 0 + var r = 0 + var atLineStart = true + while (i < length) { + if (atLineStart) { + atLineStart = false + val afterPrefix = skipBlockPrefix(i) + if (afterPrefix > i) { + i = afterPrefix + while (r < runCount && runStart[r] < i) r++ + continue + } + } + + if (r < runCount && i == runStart[r]) { + appendOut(runStart[r] + runDropFront[r], runEnd[r] - runDropBack[r]) + i = runEnd[r] + r++ + continue + } + + val c = src[i] + if (c == '\n') atLineStart = true + if (c == '\\' && i + 1 < length && src[i + 1].isAsciiPunctuation()) { + appendOut(src[i + 1]) + i += 2 + } else { + appendOut(c) + i++ + } + } + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorPool.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorPool.kt new file mode 100644 index 000000000..95f93fcee --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorPool.kt @@ -0,0 +1,31 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val MAX_RETAINED = 8 + +/** + * Lends [MarkdownProjector]s to concurrent scans and keeps them afterwards, so the buffers a scan + * grew are still there for the next keystroke rather than being rebuilt from scratch. + * + * A projector cannot be a plain field on the use case: cancelling a search is cooperative, so the + * outgoing scan can still be running when the next one starts, and the two would share a buffer. + * Borrowing serializes that without forcing the scans themselves to run one at a time. + */ +class MarkdownProjectorPool { + + private val mutex = Mutex() + private val available = mutableListOf() + + suspend fun borrow(block: suspend (MarkdownProjector) -> T): T { + val projector = mutex.withLock { available.removeLastOrNull() } ?: MarkdownProjector() + try { + return block(projector) + } finally { + mutex.withLock { + if (available.size < MAX_RETAINED) available.add(projector) + } + } + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt index 6844d7e7d..816fd3356 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt @@ -39,18 +39,21 @@ fun parseQuery(query: String): ParsedQuery { } /** - * True when [query] appears in [content] once the stored backslash escapes are resolved, so - * `well-known` finds text stored as `well\-known`. + * True when [query] appears, as literal text, in the prose [content] displays as: escapes resolved + * and paired emphasis, code and block markers dropped. So `well-known` finds `well\-known`, and + * `Chapter One` finds `**Chapter** One`. * - * Only escapes are resolved. Emphasis, code and link markers are compared as stored, so a phrase - * spanning them does not match; see #811. The query is taken literally, so the escaped storage form - * of a phrase does not match either. + * The query is never read as Markdown, and the storage form is never searched. `**Chapter**` finds + * nothing, because those asterisks are not on screen to be found; `well\-known` finds nothing for + * the same reason, while text stored as `well\\-known` renders a real backslash and is found by + * typing one. Markers the projection leaves alone, `5*4` and `user_name`, match where they sit, + * because there they are prose. * - * Global search needs offsets rather than a boolean, so it resolves and matches separately in + * Global search needs offsets rather than a boolean, so it projects and matches separately in * `SearchProjectUseCase.findMarkdownMatch`. `MarkdownContainsTest` pins the two to the same answer. */ fun markdownContains(content: String, query: String): Boolean = - unescapeMarkdown(content).contains(query, ignoreCase = true) + projectMarkdownToPlainText(content).contains(query, ignoreCase = true) /** True when every needle is contained (case-insensitively) in at least one tag. */ fun Set.matchesAllTags(needles: List): Boolean { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffers.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffers.kt new file mode 100644 index 000000000..e5e9c24a2 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffers.kt @@ -0,0 +1,100 @@ +package com.darkrockstudios.apps.hammer.common.util + +import okio.BufferedSource + +private const val REPLACEMENT = '�' + +/** + * Supplies the scratch a text scan reads into. Lets a reader fill buffers the caller owns and + * reuses, instead of handing back a freshly allocated string for every file. + */ +interface ScanBuffers { + /** A char buffer of at least [minCapacity]. May be larger, and may be the one from last time. */ + fun charBuffer(minCapacity: Int): CharArray + + /** A byte buffer of at least [minCapacity]. May be larger, and may be the one from last time. */ + fun byteBuffer(minCapacity: Int): ByteArray +} + +/** + * Reads up to [byteCount] bytes, decodes them as UTF-8 into [buffers]' char buffer, and returns how + * many chars landed there. + * + * The bytes are pulled in bulk and decoded from an array rather than a byte at a time: reading one + * byte at a time off a source costs more than the decode itself. + */ +fun BufferedSource.readUtf8Into(buffers: ScanBuffers, byteCount: Int): Int { + if (byteCount <= 0) return 0 + val bytes = buffers.byteBuffer(byteCount) + var read = 0 + while (read < byteCount) { + val n = read(bytes, read, byteCount - read) + if (n == -1) break + read += n + } + // UTF-8 never decodes to more UTF-16 chars than it has bytes, so this always fits. + return decodeUtf8(bytes, read, buffers.charBuffer(read)) +} + +/** + * Decodes the first [length] bytes of [bytes] as UTF-8 into [sink], returning the char count. + * Malformed input decodes to U+FFFD rather than throwing, matching how the rest of the app treats a + * damaged file: degraded, not fatal. + */ +internal fun decodeUtf8(bytes: ByteArray, length: Int, sink: CharArray): Int { + var i = 0 + var out = 0 + while (i < length && out < sink.size) { + val b0 = bytes[i].toInt() and 0xFF + i++ + + if (b0 < 0x80) { + sink[out++] = b0.toChar() + continue + } + + val needed = when { + b0 and 0xE0 == 0xC0 -> 1 + b0 and 0xF0 == 0xE0 -> 2 + b0 and 0xF8 == 0xF0 -> 3 + else -> -1 + } + if (needed < 0 || i + needed > length) { + sink[out++] = REPLACEMENT + continue + } + + var codePoint = when (needed) { + 1 -> b0 and 0x1F + 2 -> b0 and 0x0F + else -> b0 and 0x07 + } + var malformed = false + for (k in 0 until needed) { + val b = bytes[i + k].toInt() and 0xFF + if (b and 0xC0 != 0x80) { + malformed = true + break + } + codePoint = (codePoint shl 6) or (b and 0x3F) + } + if (malformed) { + sink[out++] = REPLACEMENT + continue + } + i += needed + + if (codePoint > 0x10FFFF) { + sink[out++] = REPLACEMENT + } else if (codePoint < 0x10000) { + sink[out++] = codePoint.toChar() + } else { + // Astral planes need a surrogate pair, the one case where a sequence yields two chars. + if (out + 1 >= sink.size) break + val v = codePoint - 0x10000 + sink[out++] = (0xD800 + (v shr 10)).toChar() + sink[out++] = (0xDC00 + (v and 0x3FF)).toChar() + } + } + return out +} diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt index c9c997b02..a15f98212 100644 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownContainsTest.kt @@ -29,9 +29,17 @@ class MarkdownContainsTest { } @Test - fun `unescaped markup is matched as it is stored`() { + fun `markers that survive the projection are matched where they sit`() { assertTrue(markdownContains("The cost is 5*4 today", "5*4")) - assertTrue(markdownContains("a **Chapter** heading", "**Chapter**")) + assertTrue(markdownContains("the user_name field", "user_name")) + } + + @Test + fun `markers the projection removes are not findable by typing them`() { + // The query is literal text matched against the prose on screen, which has no asterisks in it. + assertFalse(markdownContains("a **Chapter** heading", "**Chapter**")) + assertFalse(markdownContains("a `code` span", "`code`")) + assertTrue(markdownContains("a **Chapter** heading", "Chapter heading")) } @Test diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectionTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectionTest.kt new file mode 100644 index 000000000..1ba6be91b --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectionTest.kt @@ -0,0 +1,168 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class MarkdownProjectionTest { + + @Test + fun `plain prose is returned unchanged and unallocated`() { + val input = "Alice went down the rabbit hole." + assertSame(input, projectMarkdownToPlainText(input)) + } + + @Test + fun `empty input is returned unchanged`() { + assertEquals("", projectMarkdownToPlainText("")) + } + + @Test + fun `backslash escapes resolve to their literal character`() { + assertEquals("well-known", projectMarkdownToPlainText("well\\-known")) + assertEquals("1. not a list", projectMarkdownToPlainText("1\\. not a list")) + assertEquals("[brackets]", projectMarkdownToPlainText("\\[brackets\\]")) + } + + @Test + fun `escaped backslash collapses to one backslash`() { + assertEquals("C:\\path", projectMarkdownToPlainText("C:\\\\path")) + } + + @Test + fun `a backslash before a non-punctuation character is kept`() { + assertEquals("\\a", projectMarkdownToPlainText("\\a")) + } + + @Test + fun `a trailing backslash is kept`() { + assertEquals("end\\", projectMarkdownToPlainText("end\\")) + } + + @Test + fun `paired emphasis markers are dropped so phrases join up`() { + assertEquals("Chapter One", projectMarkdownToPlainText("**Chapter** One")) + assertEquals("quick brown", projectMarkdownToPlainText("_quick_ brown")) + assertEquals("run build now", projectMarkdownToPlainText("run `build` now")) + } + + @Test + fun `nested emphasis is unwrapped`() { + assertEquals("very loud indeed", projectMarkdownToPlainText("**very _loud_ indeed**")) + assertEquals("shout", projectMarkdownToPlainText("***shout***")) + } + + @Test + fun `escaped markers survive as literal characters`() { + assertEquals("_shape_", projectMarkdownToPlainText("\\_shape\\_")) + assertEquals("2 * 3", projectMarkdownToPlainText("2 \\* 3")) + } + + @Test + fun `escaping is resolved before markers are dropped`() { + assertEquals("a_b and c", projectMarkdownToPlainText("a\\_b and _c_")) + } + + @Test + fun `underscores inside a word are literal`() { + assertEquals("user_name", projectMarkdownToPlainText("user_name")) + assertEquals("my_table_name", projectMarkdownToPlainText("my_table_name")) + assertEquals("snake_case and emphasis", projectMarkdownToPlainText("snake_case and _emphasis_")) + } + + @Test + fun `markers that cannot pair off are literal`() { + assertEquals("2 * 3 = 6", projectMarkdownToPlainText("2 * 3 = 6")) + assertEquals("***", projectMarkdownToPlainText("***")) + assertEquals("an *unclosed aside", projectMarkdownToPlainText("an *unclosed aside")) + assertEquals("a ` stray tick", projectMarkdownToPlainText("a ` stray tick")) + } + + @Test + fun `emphasis markers inside a code span are literal`() { + assertEquals("call my_func now", projectMarkdownToPlainText("call `my_func` now")) + } + + @Test + fun `leading block markers are stripped from each line`() { + assertEquals("Chapter One", projectMarkdownToPlainText("# Chapter One")) + assertEquals("a quote", projectMarkdownToPlainText("> a quote")) + assertEquals("a bullet", projectMarkdownToPlainText("- a bullet")) + assertEquals("Chapter One\n\nThe body", projectMarkdownToPlainText("# Chapter One\n\nThe body")) + } + + @Test + fun `ordered list markers are left alone so years survive`() { + assertEquals("1984. The year everything changed", projectMarkdownToPlainText("1984. The year everything changed")) + assertEquals("3) Meet at the docks", projectMarkdownToPlainText("3) Meet at the docks")) + } + + @Test + fun `a marker that is not followed by a space is prose`() { + assertEquals("-15 degrees", projectMarkdownToPlainText("-15 degrees")) + assertEquals("#hashtag here", projectMarkdownToPlainText("#hashtag here")) + } + + @Test + fun `code delimiters do not pair across a paragraph break`() { + assertEquals( + "It`s here.\n\nThe Chapter One part.\n\nDon`t stop.", + projectMarkdownToPlainText("It`s here.\n\nThe **Chapter** One part.\n\nDon`t stop."), + ) + } + + @Test + fun `emphasis does not pair across a paragraph break`() { + assertEquals( + "See note *below\n\nThe rate is 5* higher", + projectMarkdownToPlainText("See note *below\n\nThe rate is 5* higher"), + ) + } + + @Test + fun `emphasis still pairs across a soft line break`() { + assertEquals("The long\ntitle here", projectMarkdownToPlainText("The **long\ntitle** here")) + } + + @Test + fun `a document with escapes but no emphasis still resolves them`() { + assertEquals( + "The well-known garden! (and its gate)", + projectMarkdownToPlainText("The well\\-known garden\\! \\(and its gate\\)"), + ) + } + + @Test + fun `title line strips block markers and inline markup`() { + assertEquals("Chapter One", markdownTitleLine("# **Chapter** One\n\nbody")) + assertEquals("A quoted thought", markdownTitleLine("> A quoted thought")) + assertEquals("First item", markdownTitleLine("- First item\n- Second")) + } + + @Test + fun `title line keeps a leading number`() { + assertEquals("1984. The year everything changed", markdownTitleLine("1984. The year everything changed")) + } + + @Test + fun `title line pairs emphasis that closes on the next line`() { + assertEquals("The long", markdownTitleLine("The **long\ntitle** here")) + } + + @Test + fun `title line skips leading blank lines`() { + assertEquals("Actual title", markdownTitleLine("\n \nActual title\nmore")) + assertEquals("", markdownTitleLine(" \n\n")) + } + + @Test + fun `a title line of pure markup falls back to the raw line`() { + assertEquals("***", markdownTitleLine("***\nbody")) + assertEquals("#", markdownTitleLine("#\nbody")) + } + + @Test + fun `an emphasized title keeps its words`() { + assertEquals("Once upon a time", markdownTitleLine("*Once upon a time*")) + } +} diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorTest.kt new file mode 100644 index 000000000..379aa72f1 --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/data/search/MarkdownProjectorTest.kt @@ -0,0 +1,132 @@ +package com.darkrockstudios.apps.hammer.common.data.search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MarkdownProjectorTest { + + private val projector = MarkdownProjector() + + @Test + fun `a reused projector gives the same answer as a fresh one`() { + val documents = listOf( + "The **big** dog barked.", + "a much longer document ".repeat(200) + "with *emphasis* at the end", + "short", + "# Heading\n\nBody text.", + ) + documents.forEach { document -> + projector.project(document) + assertEquals(MarkdownProjector().also { it.project(document) }.projected(), projector.projected()) + } + } + + @Test + fun `a long document followed by a short one does not leak the tail of the long one`() { + projector.project("the quick brown fox ".repeat(100)) + projector.project("short") + assertEquals("short", projector.projected()) + } + + @Test + fun `indexOf finds a phrase that spans dropped emphasis`() { + projector.project("the **big** dog") + assertEquals("the big dog", projector.projected()) + assertEquals(4, projector.indexOf("big dog")) + } + + @Test + fun `indexOf is case insensitive by default and reports a miss as -1`() { + projector.project("The Harbour At Dawn") + assertEquals(4, projector.indexOf("harbour")) + assertEquals(-1, projector.indexOf("mountain")) + } + + @Test + fun `indexOf respects case when asked`() { + projector.project("The Harbour") + assertEquals(-1, projector.indexOf("harbour", ignoreCase = false)) + assertEquals(4, projector.indexOf("Harbour", ignoreCase = false)) + } + + @Test + fun `an empty query never matches`() { + projector.project("anything") + assertEquals(-1, projector.indexOf("")) + } + + @Test + fun `a query longer than the document never matches`() { + projector.project("hi") + assertEquals(-1, projector.indexOf("hello there")) + } + + @Test + fun `substring returns the projected prose rather than the source`() { + projector.project("the **big** dog") + assertEquals("big", projector.substring(4, 7)) + } + + @Test + fun `firstNonBlankLine skips blank lines and strips markers`() { + projector.project("\n\n# **Chapter** One\n\nBody") + assertEquals("Chapter One", projector.firstNonBlankLine()) + } + + @Test + fun `firstNonBlankLine is empty when every line is blank`() { + projector.project("\n \n\t\n") + assertEquals("", projector.firstNonBlankLine()) + } + + @Test + fun `collapsedPreview flattens whitespace runs`() { + projector.project("the quick\n\nbrown\tfox") + assertEquals("the quick brown fox", projector.collapsedPreview(100)) + } + + @Test + fun `collapsedPreview truncates with an ellipsis and stays within the cap`() { + projector.project("word ".repeat(100)) + val preview = projector.collapsedPreview(20) + assertTrue(preview.endsWith("…"), "expected an ellipsis, got: $preview") + // The cap bounds the prose; trimming a trailing space before the ellipsis can leave it shorter. + assertTrue(preview.length <= 21, "expected at most 21 chars, got ${preview.length}: $preview") + assertEquals("word word word word…", preview) + } + + @Test + fun `collapsedPreview does not add an ellipsis when the whole text fits`() { + projector.project("just this") + assertEquals("just this", projector.collapsedPreview(50)) + } + + @Test + fun `trailing whitespace does not count as more text to preview`() { + projector.project("just this \n\n ") + assertEquals("just this", projector.collapsedPreview(50)) + } + + @Test + fun `filling the source buffer directly projects the same as passing a string`() { + val document = "the **big** dog barked at the well\\-known man" + projector.project(document) + val viaString = projector.projected() + + val buffer = projector.sourceBuffer(document.length) + document.forEachIndexed { i, c -> buffer[i] = c } + projector.projectSource(document.length) + + assertEquals(viaString, projector.projected()) + } + + @Test + fun `the source buffer survives being grown for a longer document`() { + val long = "a *lot* of text ".repeat(50) + val buffer = projector.sourceBuffer(long.length) + long.forEachIndexed { i, c -> buffer[i] = c } + projector.projectSource(long.length) + assertEquals(projectMarkdownToPlainText(long), projector.projected()) + } +} diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffersTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffersTest.kt new file mode 100644 index 000000000..fa1e4b83e --- /dev/null +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/util/CharBuffersTest.kt @@ -0,0 +1,91 @@ +package com.darkrockstudios.apps.hammer.common.util + +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertEquals + +class CharBuffersTest { + + /** A fixed-size workspace, so a test can pin what happens when the sink runs out. */ + private class FixedBuffers(sinkSize: Int) : ScanBuffers { + val chars = CharArray(sinkSize) + private var bytes = ByteArray(0) + override fun charBuffer(minCapacity: Int) = chars + override fun byteBuffer(minCapacity: Int): ByteArray { + if (bytes.size < minCapacity) bytes = ByteArray(minCapacity) + return bytes + } + } + + private fun decode(text: String, sinkSize: Int = 256): String { + val source = Buffer().writeUtf8(text) + val byteCount = source.size.toInt() + val buffers = FixedBuffers(sinkSize) + val count = source.readUtf8Into(buffers, byteCount) + return buffers.chars.concatToString(0, count) + } + + private fun decodeBytes(bytes: ByteArray, byteCount: Int = bytes.size, sinkSize: Int = 16): String { + val source = Buffer().write(bytes) + val buffers = FixedBuffers(sinkSize) + val count = source.readUtf8Into(buffers, byteCount) + return buffers.chars.concatToString(0, count) + } + + @Test + fun `ascii decodes unchanged`() { + assertEquals("The quick brown fox", decode("The quick brown fox")) + } + + @Test + fun `two byte sequences decode`() { + assertEquals("café naïve", decode("café naïve")) + } + + @Test + fun `three byte sequences decode`() { + assertEquals("日本語のテキスト", decode("日本語のテキスト")) + } + + @Test + fun `four byte sequences decode to a surrogate pair`() { + val emoji = "😀" + val decoded = decode("a${emoji}b") + assertEquals("a${emoji}b", decoded) + assertEquals(4, decoded.length) + } + + @Test + fun `an empty source decodes to nothing`() { + assertEquals("", decode("")) + } + + @Test + fun `decoding stops when the sink is full`() { + assertEquals("The q", decode("The quick brown fox", sinkSize = 5)) + } + + @Test + fun `a surrogate pair is not split across the end of the sink`() { + // Room for "a" plus one char: the pair needs two, so it is left out rather than half-written. + assertEquals("a", decode("a😀", sinkSize = 2)) + } + + @Test + fun `a truncated sequence decodes to the replacement character`() { + assertEquals("a�", decodeBytes(byteArrayOf(0x61, 0xC3.toByte()))) + } + + @Test + fun `a stray continuation byte decodes to the replacement character`() { + assertEquals("a�b", decodeBytes(byteArrayOf(0x61, 0x80.toByte(), 0x62))) + } + + @Test + fun `reading fewer bytes than the source holds stops at the limit`() { + val source = Buffer().writeUtf8("abcdef") + val buffers = FixedBuffers(16) + val count = source.readUtf8Into(buffers, 3) + assertEquals("abc", buffers.chars.concatToString(0, count)) + } +} diff --git a/common/src/desktopTest/kotlin/data/search/LegacyMarkdownProjection.kt b/common/src/desktopTest/kotlin/data/search/LegacyMarkdownProjection.kt new file mode 100644 index 000000000..9c24555de --- /dev/null +++ b/common/src/desktopTest/kotlin/data/search/LegacyMarkdownProjection.kt @@ -0,0 +1,283 @@ +// PR #821 implementation, kept verbatim in the test source set as a benchmark baseline. +package data.search + +private const val ASCII_PUNCTUATION = "!\"#\$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +/** + * Compared directly rather than via set or string membership: this runs once per character of every + * document scanned, and `in` on a CharSequence costs a call per character. + */ +private fun Char.isDelimiter(): Boolean = this == '*' || this == '_' || this == '`' + +private fun Char.isSpaceOrTab(): Boolean = this == ' ' || this == '\t' + +private fun Char?.isBlankOrEdge(): Boolean = this == null || isWhitespace() + +private fun Char?.isPunctuation(): Boolean = this != null && this in ASCII_PUNCTUATION + +/** + * A maximal run of one delimiter character, plus how much of it the projection consumes. Emphasis + * pairs off from the end of the opener and the start of the closer, so the surviving characters are + * always the middle of the run. + */ +private class DelimiterRun(val char: Char, val start: Int, val end: Int, val paragraph: Int) { + var canOpen = false + var canClose = false + + /** Set for runs sitting inside a resolved code span, where emphasis does not apply. */ + var inert = false + var dropFront = 0 + var dropBack = 0 + + val available: Int get() = end - start - dropFront - dropBack +} + +/** + * Flattens stored Markdown into the prose a reader sees. Backslash escapes resolve to their literal + * character (`well\-known` becomes `well-known`), paired emphasis and code delimiters are dropped + * (`**Chapter** One` becomes `Chapter One`), and leading blockquote, heading and bullet markers are + * removed from each line. + * + * Delimiters pair only within a paragraph, and only where CommonMark's flanking rules allow, so + * literal markers survive: `user_name`, `2 * 3`, a bare `***` divider and a stray apostrophe-backtick + * are all left intact. + * + * Link and image syntax is left as written. Punctuation is tested against ASCII only, and escapes + * resolve inside code spans as well as outside them. + */ +fun legacyProjectMarkdownToPlainText(markdown: String): String { + if (!legacyContainsMarkdownSyntax(markdown)) return markdown + + val runs = scanDelimiterRuns(markdown) + resolveCodeSpans(runs) + resolveEmphasis(runs) + return render(markdown, runs) +} + +/** + * The prose title for [markdown]: the first non-blank line of its projection. Falls back to the raw + * first line when the projection of it is blank, so a marker-only line still yields a title rather + * than reading as an empty document. + */ +fun legacyMarkdownTitleLine(markdown: String): String { + val projected = legacyProjectMarkdownToPlainText(markdown) + val title = projected.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + if (title.isNotEmpty()) return title + return markdown.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() +} + +/** + * True when [text] holds a character the projection can remove. A query without one that misses the + * projection cannot match the raw source either, because the projection only ever deletes. + */ +fun legacyContainsMarkdownSyntax(text: String): Boolean { + var i = 0 + while (i < text.length) { + val c = text[i] + if (c.isDelimiter() || c == '\\' || c == '#' || c == '>' || c == '-' || c == '+') return true + i++ + } + return false +} + +/** + * The offset after any blockquote, heading or bullet marker opening the line at [start], or [start] + * itself when the line opens with prose. Ordered-list markers are left alone: a single line cannot + * distinguish "1. Draft opening" from "1984. The year everything changed". + */ +private fun skipBlockPrefix(markdown: String, start: Int): Int { + val length = markdown.length + var i = start + var found = false + + while (i < length && markdown[i].isSpaceOrTab()) i++ + + while (i < length && markdown[i] == '>') { + i++ + found = true + while (i < length && markdown[i].isSpaceOrTab()) i++ + } + + if (i < length && markdown[i] == '#') { + var j = i + var hashes = 0 + while (j < length && markdown[j] == '#') { + j++ + hashes++ + } + if (hashes <= 6 && j < length && markdown[j].isSpaceOrTab()) { + i = j + found = true + } + } else if (i < length && (markdown[i] == '-' || markdown[i] == '*' || markdown[i] == '+')) { + if (i + 1 < length && markdown[i + 1].isSpaceOrTab()) { + i++ + found = true + } + } + + if (!found) return start + while (i < length && markdown[i].isSpaceOrTab()) i++ + return i +} + +/** Appends the character at [i], resolving a backslash escape, and returns the next index. */ +private fun StringBuilder.appendResolved(markdown: String, i: Int): Int { + val c = markdown[i] + if (c == '\\' && i + 1 < markdown.length && markdown[i + 1] in ASCII_PUNCTUATION) { + append(markdown[i + 1]) + return i + 2 + } + append(c) + return i + 1 +} + +private fun scanDelimiterRuns(markdown: String): List { + val runs = mutableListOf() + val length = markdown.length + var i = 0 + var paragraph = 0 + while (i < length) { + val c = markdown[i] + if (c == '\\' && i + 1 < length && markdown[i + 1] in ASCII_PUNCTUATION) { + i += 2 + continue + } + if (c == '\n') { + var k = i + 1 + while (k < length && (markdown[k].isSpaceOrTab() || markdown[k] == '\r')) k++ + if (k >= length || markdown[k] == '\n') paragraph++ + i++ + continue + } + if (!c.isDelimiter()) { + i++ + continue + } + + val start = i + var end = start + 1 + while (end < length && markdown[end] == c) end++ + i = end + + var canOpen = true + var canClose = true + if (c != '`') { + val prev = markdown.getOrNull(start - 1) + val next = markdown.getOrNull(end) + val leftFlanking = !next.isBlankOrEdge() && + (!next.isPunctuation() || prev.isBlankOrEdge() || prev.isPunctuation()) + val rightFlanking = !prev.isBlankOrEdge() && + (!prev.isPunctuation() || next.isBlankOrEdge() || next.isPunctuation()) + if (c == '*') { + canOpen = leftFlanking + canClose = rightFlanking + } else { + // Underscores cannot delimit emphasis inside a word, which is what saves `user_name`. + canOpen = leftFlanking && (!rightFlanking || prev.isPunctuation()) + canClose = rightFlanking && (!leftFlanking || next.isPunctuation()) + } + // A run that can do neither is literal text; leaving it out keeps it out of the render. + if (!canOpen && !canClose) continue + } + + val run = DelimiterRun(c, start, end, paragraph) + run.canOpen = canOpen + run.canClose = canClose + runs.add(run) + } + return runs +} + +/** Code spans bind tighter than emphasis, so they claim their delimiters first. */ +private fun resolveCodeSpans(runs: List) { + var i = 0 + while (i < runs.size) { + val opener = runs[i] + if (opener.char != '`') { + i++ + continue + } + + val width = opener.end - opener.start + var j = i + 1 + var closer = -1 + while (j < runs.size && runs[j].paragraph == opener.paragraph) { + val candidate = runs[j] + if (candidate.char == '`' && candidate.end - candidate.start == width) { + closer = j + break + } + j++ + } + if (closer < 0) { + i++ + continue + } + + opener.dropBack = width + runs[closer].dropFront = width + for (k in i + 1 until closer) runs[k].inert = true + i = closer + 1 + } +} + +private fun resolveEmphasis(runs: List) { + val open = mutableListOf() + var paragraph = -1 + for (run in runs) { + if (run.paragraph != paragraph) { + open.clear() + paragraph = run.paragraph + } + if (run.char == '`' || run.inert) continue + + if (run.canClose) { + while (run.available > 0) { + val openerIndex = open.indexOfLast { it.char == run.char } + if (openerIndex < 0) break + + val opener = open[openerIndex] + val consumed = minOf(run.available, opener.available) + opener.dropBack += consumed + run.dropFront += consumed + + // Anything stacked above the matched opener can never close now. + while (open.size > openerIndex) open.removeAt(open.size - 1) + if (opener.available > 0) open.add(opener) + } + } + if (run.available > 0 && run.canOpen) open.add(run) + } +} + +private fun render(markdown: String, runs: List): String { + val sb = StringBuilder(markdown.length) + val length = markdown.length + var i = 0 + var r = 0 + var atLineStart = true + while (i < length) { + if (atLineStart) { + atLineStart = false + val afterPrefix = skipBlockPrefix(markdown, i) + if (afterPrefix > i) { + i = afterPrefix + while (r < runs.size && runs[r].start < i) r++ + continue + } + } + + if (r < runs.size && i == runs[r].start) { + val run = runs[r] + sb.append(markdown, run.start + run.dropFront, run.end - run.dropBack) + i = run.end + r++ + continue + } + + if (markdown[i] == '\n') atLineStart = true + i = sb.appendResolved(markdown, i) + } + return sb.toString() +} diff --git a/common/src/desktopTest/kotlin/data/search/ProjectorBenchmark.kt b/common/src/desktopTest/kotlin/data/search/ProjectorBenchmark.kt new file mode 100644 index 000000000..09c20ae8e --- /dev/null +++ b/common/src/desktopTest/kotlin/data/search/ProjectorBenchmark.kt @@ -0,0 +1,111 @@ +package data.search + +import com.darkrockstudios.apps.hammer.common.data.search.MarkdownProjector +import com.darkrockstudios.apps.hammer.common.data.search.projectMarkdownToPlainText +import com.sun.management.ThreadMXBean +import java.lang.management.ManagementFactory +import kotlin.system.measureTimeMillis +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Cost of a full-project scan: PR #821's implementation against the reusable workspace. */ +class ProjectorBenchmark { + + private val threads = ManagementFactory.getThreadMXBean() as ThreadMXBean + + private inline fun measureAlloc(block: () -> Unit): Long { + val before = threads.getCurrentThreadAllocatedBytes() + block() + return threads.getCurrentThreadAllocatedBytes() - before + } + + @Test + fun `the rewrite agrees with the implementation it replaces`() { + val corpus = buildCorpus(20_000) + val projector = MarkdownProjector() + corpus.forEach { scene -> + projector.project(scene) + assertEquals(legacyProjectMarkdownToPlainText(scene), projector.projected()) + } + } + + @Test + fun `full-project scan, legacy versus reusable workspace`() { + for (words in listOf(300_000, 1_250_000)) { + val corpus = buildCorpus(words) + val query = "gulls argue" + val projector = MarkdownProjector() + + repeat(3) { + corpus.forEach { legacyProjectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + corpus.forEach { projectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + corpus.forEach { projector.project(it); projector.indexOf(query) } + } + + val legacyTime = (0 until 3).minOf { + measureTimeMillis { + corpus.forEach { legacyProjectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + } + } + val wrapperTime = (0 until 3).minOf { + measureTimeMillis { + corpus.forEach { projectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + } + } + val reusedTime = (0 until 3).minOf { + measureTimeMillis { + corpus.forEach { projector.project(it); projector.indexOf(query) } + } + } + + val legacyAlloc = measureAlloc { + corpus.forEach { legacyProjectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + } + val wrapperAlloc = measureAlloc { + corpus.forEach { projectMarkdownToPlainText(it).indexOf(query, ignoreCase = true) } + } + val reusedAlloc = measureAlloc { + corpus.forEach { projector.project(it); projector.indexOf(query) } + } + + println("=== ${words / 1000}k words, ${corpus.size} scenes ===") + println(" legacy (#821) : ${legacyTime}ms, ${legacyAlloc.mb()}") + println(" new, workspace/call: ${wrapperTime}ms, ${wrapperAlloc.mb()}") + println(" new, reused : ${reusedTime}ms, ${reusedAlloc.mb()}") + } + } + + private fun Long.mb(): String = + "${(this / 1024.0 / 1024.0).let { kotlin.math.round(it * 100) / 100 }} MB" + + private fun buildCorpus(totalWords: Int): List { + val wordsPerScene = 2_000 + val scenes = totalWords / wordsPerScene + val words = ("the quick brown fox jumps over a lazy dog while morning light spills across " + + "the harbour and gulls argue about nothing at all she thought of him again").split(" ") + return (0 until scenes).map { s -> + val sb = StringBuilder() + sb.append("# Scene $s\n\n") + var w = 0 + while (w < wordsPerScene) { + val paragraph = StringBuilder() + repeat(120) { + val word = words[(w * 7 + it * 13 + s) % words.size] + if ((w + it) % 5 == 0) { + when ((w + it) % 15) { + 0 -> paragraph.append("**").append(word).append("**") + 5 -> paragraph.append("*").append(word).append("*") + else -> paragraph.append("well\\-known") + } + } else { + paragraph.append(word) + } + paragraph.append(' ') + } + w += 120 + sb.append(paragraph.trimEnd()).append(".\n\n") + } + sb.toString() + } + } +} diff --git a/common/src/desktopTest/kotlin/data/search/ScanLoadPathBenchmark.kt b/common/src/desktopTest/kotlin/data/search/ScanLoadPathBenchmark.kt new file mode 100644 index 000000000..a1e7280a8 --- /dev/null +++ b/common/src/desktopTest/kotlin/data/search/ScanLoadPathBenchmark.kt @@ -0,0 +1,128 @@ +package data.search + +import com.darkrockstudios.apps.hammer.common.data.search.MarkdownProjector +import com.darkrockstudios.apps.hammer.common.util.readUtf8Into +import com.sun.management.ThreadMXBean +import okio.FileSystem +import okio.Path.Companion.toOkioPath +import java.lang.management.ManagementFactory +import kotlin.io.path.createTempDirectory +import kotlin.system.measureTimeMillis +import kotlin.test.Test +import kotlin.test.assertEquals + +/** End-to-end scan cost from disk: a string per scene against decoding into the scan buffer. */ +class ScanLoadPathBenchmark { + + private val threads = ManagementFactory.getThreadMXBean() as ThreadMXBean + + private inline fun measureAlloc(block: () -> Unit): Long { + val before = threads.getCurrentThreadAllocatedBytes() + block() + return threads.getCurrentThreadAllocatedBytes() - before + } + + @Test + fun `both load paths read the same text`() { + val dir = createTempDirectory("scan-load-agree") + val fs = FileSystem.SYSTEM + val document = "The **big** dog barked at the well\\-known café 😀 日本語" + val path = dir.resolve("scene.md").toOkioPath() + fs.write(path) { writeUtf8(document) } + + val viaString = fs.read(path) { readUtf8() } + assertEquals(document, viaString) + + val projector = MarkdownProjector() + val byteCount = fs.metadata(path).size!!.toInt() + val chars = fs.read(path) { readUtf8Into(projector, byteCount) } + projector.projectSource(chars) + + val expected = MarkdownProjector().also { it.project(document) }.projected() + assertEquals(expected, projector.projected()) + + fs.deleteRecursively(dir.toOkioPath()) + } + + @Test + fun `full-project scan from disk`() { + val dir = createTempDirectory("scan-load-bench") + val fs = FileSystem.SYSTEM + val corpus = buildCorpus(300_000) + val paths = corpus.mapIndexed { i, text -> + val p = dir.resolve("scene-$i.md").toOkioPath() + fs.write(p) { writeUtf8(text) } + p + } + val query = "gulls argue" + val projector = MarkdownProjector() + + fun stringPerScene(): Int { + var hits = 0 + paths.forEach { p -> + val text = fs.read(p) { readUtf8() } + if (legacyProjectMarkdownToPlainText(text).indexOf(query, ignoreCase = true) >= 0) hits++ + } + return hits + } + + fun intoScanBuffer(): Int { + var hits = 0 + paths.forEach { p -> + val byteCount = (fs.metadata(p).size ?: 0L).toInt() + val chars = fs.read(p) { readUtf8Into(projector, byteCount) } + projector.projectSource(chars) + if (projector.indexOf(query) >= 0) hits++ + } + return hits + } + + repeat(3) { stringPerScene(); intoScanBuffer() } + assertEquals(stringPerScene(), intoScanBuffer()) + + val oldTime = (0 until 3).minOf { measureTimeMillis { stringPerScene() } } + val newTime = (0 until 3).minOf { measureTimeMillis { intoScanBuffer() } } + val oldAlloc = measureAlloc { stringPerScene() } + val newAlloc = measureAlloc { intoScanBuffer() } + + println("=== 300k words, ${paths.size} scenes, read from disk ===") + println(" string per scene : ${oldTime}ms, ${oldAlloc.mb()}") + println(" into scan buffer : ${newTime}ms, ${newAlloc.mb()}") + + fs.deleteRecursively(dir.toOkioPath()) + } + + private fun Long.mb(): String = + "${(this / 1024.0 / 1024.0).let { kotlin.math.round(it * 100) / 100 }} MB" + + private fun buildCorpus(totalWords: Int): List { + val wordsPerScene = 2_000 + val scenes = totalWords / wordsPerScene + val words = ("the quick brown fox jumps over a lazy dog while morning light spills across " + + "the harbour and gulls argue about nothing at all she thought of him again").split(" ") + return (0 until scenes).map { s -> + val sb = StringBuilder() + sb.append("# Scene $s\n\n") + var w = 0 + while (w < wordsPerScene) { + val paragraph = StringBuilder() + repeat(120) { + val word = words[(w * 7 + it * 13 + s) % words.size] + if ((w + it) % 5 == 0) { + when ((w + it) % 15) { + 0 -> paragraph.append("**").append(word).append("**") + 5 -> paragraph.append("*").append(word).append("*") + else -> paragraph.append("well\\-known") + } + } else { + paragraph.append(word) + } + paragraph.append(' ') + } + w += 120 + sb.append(paragraph.trimEnd()).append(".\n\n") + } + sb.toString() + } + } +} diff --git a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt index cf57b0ef3..509ec509d 100644 --- a/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/usecases/globalsearch/SearchProjectUseCaseTest.kt @@ -20,11 +20,13 @@ import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneCo import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata +import com.darkrockstudios.apps.hammer.common.data.search.MarkdownProjector import com.darkrockstudios.apps.hammer.common.data.search.markdownContains import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineContainer import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineEvent import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.util.ScanBuffers import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -96,7 +98,7 @@ class SearchProjectUseCaseTest : BaseTest() { for ((content, query) in cases) { assertEquals( markdownContains(content, query), - SearchProjectUseCase.findMarkdownMatch(content, query) != null, + SearchProjectUseCase.findMarkdownMatch(MarkdownProjector(), content, query) != null, "disagreement on content=<$content> query=<$query>", ) } @@ -183,8 +185,8 @@ class SearchProjectUseCaseTest : BaseTest() { content = SceneContent(scene = scene, markdown = "Unsaved dragon attack"), source = UpdateSource.Editor, ) - // loadSceneMarkdownRaw should not be consulted because buffer wins - every { sceneEditor.loadSceneMarkdownRaw(scene, any()) } returns "On-disk text" + // The on-disk read should not be consulted because the buffer wins + stubSceneMarkdown(scene, "On-disk text") val results = createUseCase().search("dragon", GlobalSearchFilter.All) .filterIsInstance() @@ -212,6 +214,19 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals(9, results.first().sceneItem.id) } + /** + * Stubs the on-disk read for [scene]. Search decodes a scene into a buffer it owns rather than + * taking a string back, so the stub fills the buffer the use case handed in. + */ + private fun stubSceneMarkdown(scene: SceneItem, markdown: String) { + every { sceneEditor.readSceneMarkdownInto(eq(scene), any(), any()) } answers { + val sink = secondArg() + val buffer = sink.charBuffer(markdown.length) + markdown.forEachIndexed { index, char -> buffer[index] = char } + markdown.length + } + } + @Test fun `search matches encyclopedia entry name`() = runTest { val def = EntryDef(projectDef = projectDef, id = 11, type = EntryType.PERSON, name = "Aragorn") @@ -380,6 +395,9 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals(7, results.first().sceneItem.id) } + private fun note(id: Int, content: String) = + NoteContainer(NoteContent(id = id, created = Clock.System.now(), content = content)) + @Test fun `search matches across a backslash escape`() = runTest { every { notes.getNotes() } returns listOf(note(1, "A well\\-known secret")) @@ -390,6 +408,191 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals(1, results.size) } + @Test + fun `search matches a phrase spanning an emphasis boundary`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "**Chapter** One begins")) + + val results = createUseCase().search("Chapter One", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + } + + @Test + fun `search matches a phrase spanning italics and code spans`() = runTest { + every { notes.getNotes() } returns listOf( + note(1, "the _quick_ brown fox"), + note(2, "run the `build` script"), + ) + + val italic = createUseCase().search("quick brown", GlobalSearchFilter.All) + .filterIsInstance() + val code = createUseCase().search("the build script", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, italic.size) + assertEquals(1, code.size) + } + + @Test + fun `snippets render prose without markdown syntax`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "A well\\-known **Chapter** One secret")) + + val results = createUseCase().search("well-known", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + val snippet = results.first().snippet + assertEquals("A well-known Chapter One secret", snippet.text) + assertEquals("well-known", snippet.text.substring(snippet.matchStart, snippet.matchEnd)) + } + + @Test + fun `escaped literals survive the projection`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "the \\_shape\\_ of it")) + + val results = createUseCase().search("_shape_", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertTrue(results.first().snippet.text.contains("_shape_")) + } + + @Test + fun `typing markdown syntax does not find the markup it renders away`() = runTest { + // The query is literal text matched against the prose on screen; that prose reads "raw bold + // marker", so the asterisks are not there to be found. + every { notes.getNotes() } returns listOf(note(1, "raw **bold** marker")) + + val asTyped = createUseCase().search("**bold**", GlobalSearchFilter.All) + .filterIsInstance() + val asRead = createUseCase().search("raw bold marker", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(0, asTyped.size) + assertEquals(1, asRead.size) + } + + @Test + fun `scene bodies match across an emphasis boundary`() = runTest { + val scene = SceneItem( + projectDef = projectDef, + type = SceneItem.Type.Scene, + id = 7, + name = "Opening", + order = 0, + ) + every { sceneEditor.getScenes() } returns listOf(scene) + every { sceneContentRepository.getSceneBuffer(scene) } returns null + stubSceneMarkdown(scene, "**Chapter** One begins") + + val results = createUseCase().search("Chapter One", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("Chapter One begins", results.first().snippet.text) + } + + @Test + fun `literal underscores in imported content are left alone`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "the user_name field and my_table_name")) + + val results = createUseCase().search("user_name", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertTrue(results.first().snippet.text.contains("my_table_name")) + } + + @Test + fun `a body of only markdown markers still previews in a tag search`() = runTest { + every { notes.getNotes() } returns listOf( + NoteContainer( + NoteContent( + id = 1, + created = Clock.System.now(), + content = "***", + tags = setOf("fantasy"), + ) + ), + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("***", results.first().snippet.text) + } + + @Test + fun `a heading does not leak into the title or the snippet`() = runTest { + every { notes.getNotes() } returns listOf( + NoteContainer( + NoteContent( + id = 1, + created = Clock.System.now(), + content = "# Chapter One\n\nThe body text", + tags = setOf("fantasy"), + ) + ), + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("Chapter One", results.first().title) + assertEquals("Chapter One The body text", results.first().snippet.text) + } + + @Test + fun `a note whose first line is only markers is not titled empty`() = runTest { + every { notes.getNotes() } returns listOf(note(1, "***\nThe real body text")) + + val results = createUseCase().search("real body", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("***", results.first().title) + } + + @Test + fun `timeline dates are not projected`() = runTest { + coEvery { timeLine.loadTimeline() } returns TimeLineContainer( + listOf(TimeLineEvent(id = 21, order = 0, date = "1990 _to_ 2000", content = "A long war")), + ) + + val verbatim = createUseCase().search("1990 _to_ 2000", GlobalSearchFilter.All) + .filterIsInstance() + val projectedForm = createUseCase().search("1990 to 2000", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, verbatim.size) + assertTrue(verbatim.first().snippet.text.contains("1990 _to_ 2000")) + // Projecting the date would make this hit and would show a date the event does not have. + assertEquals(0, projectedForm.size) + } + + @Test + fun `tag-only search previews prose without markdown syntax`() = runTest { + every { notes.getNotes() } returns listOf( + NoteContainer( + NoteContent( + id = 1, + created = Clock.System.now(), + content = "A well\\-known **Chapter** One secret", + tags = setOf("fantasy"), + ) + ), + ) + + val results = createUseCase().search("#fantasy", GlobalSearchFilter.All) + .filterIsInstance() + + assertEquals(1, results.size) + assertEquals("A well-known Chapter One secret", results.first().snippet.text) + } + @Test fun `snippets render escaped text the way it reads on screen`() = runTest { every { notes.getNotes() } returns listOf(note(1, "A well\\-known garden\\! at dusk")) @@ -480,7 +683,7 @@ class SearchProjectUseCaseTest : BaseTest() { ) every { sceneEditor.getScenes() } returns listOf(scene) every { sceneContentRepository.getSceneBuffer(scene) } returns null - every { sceneEditor.loadSceneMarkdownRaw(scene, any()) } returns "A well\\-known road" + stubSceneMarkdown(scene, "A well\\-known road") val results = createUseCase().search("well-known", GlobalSearchFilter.All) .filterIsInstance() @@ -666,9 +869,6 @@ class SearchProjectUseCaseTest : BaseTest() { assertEquals(7, results.first().sceneItem.id) } - private fun note(id: Int, content: String) = - NoteContainer(NoteContent(id = id, created = Clock.System.now(), content = content)) - @Test fun `search honors filter to a single source`() = runTest { every { notes.getNotes() } returns listOf( diff --git a/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt b/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt index b92149630..c5a6982cd 100644 --- a/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt +++ b/composeUi/src/desktopTest/kotlin/SearchFilterTest.kt @@ -68,6 +68,8 @@ class SearchFilterTest { fun `literal markup is not rewritten by any screen filter`() { assertTrue(noteMatchesQuery(note("The cost is 5*4"), "5*4")) assertTrue(eventMatchesQuery(event("the user_name field"), "user_name")) - assertTrue(ideaMatchesQuery(idea("a **Chapter** heading"), "**Chapter**")) + // Paired markers are gone from the prose on screen, so typing them finds nothing. + assertFalse(ideaMatchesQuery(idea("a **Chapter** heading"), "**Chapter**")) + assertTrue(ideaMatchesQuery(idea("a **Chapter** heading"), "Chapter heading")) } } From 3509321b24e0c9c1adc6b277504364b12584f286 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 22:34:19 -0700 Subject: [PATCH 027/213] Resolve drop targets against drawn row geometry (#842) * Resolve drop targets against drawn row geometry * Report row geometry in display order and keep the insert line live --- .../scenelist/scenetree/SceneTree.kt | 40 ++++++------- .../scenelist/scenetree/SceneTreeState.kt | 57 ++++++++++++++++++- .../scenelist/scenetree/SceneTreeUtils.kt | 14 +++-- .../desktopTest/kotlin/SceneTreeDragTest.kt | 45 +++++++++++++++ .../kotlin/SceneTreeInsertPositionTest.kt | 26 +++------ 5 files changed, 138 insertions(+), 44 deletions(-) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt index 522097d6d..71db15990 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt @@ -18,6 +18,7 @@ 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.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset @@ -26,6 +27,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -58,6 +60,7 @@ fun SceneTree( LazyColumn( state = state.listState, modifier = modifier.reorderableModifier(state) + .onGloballyPositioned { state.setListCoordinates(it) } .weight(1f), contentPadding = contentPadding ) { @@ -68,6 +71,9 @@ fun SceneTree( ) { node -> val nodeCollapsesChildren = state.collapsedNodes[node.value.id] ?: false + val id = node.value.id + + DisposableEffect(id) { onDispose { state.removeRowCoordinates(id) } } SceneTreeNode( node = node, @@ -77,7 +83,8 @@ fun SceneTree( modifier = Modifier.wrapContentHeight() .fillMaxWidth() .animateItem() - .pressCandidate(state, node.value.id), + .onGloballyPositioned { state.setRowCoordinates(id, it) } + .pressCandidate(state, id), itemUi = itemUi ) } @@ -90,12 +97,11 @@ fun SceneTree( } /** - * Records this row as the drag candidate while it is pressed. + * Claims this row as the drag target for as long as it is pressed. * - * Thar be dragons: resolving the row from [LazyListLayoutInfo] instead looks equivalent but is - * not. Its offsets are where items are headed, not where they are drawn, so a press landing - * during a placement animation grabs whichever row now owns that target slot. Compose's own hit - * test uses the drawn position, so it stays right mid-animation. + * The row is settled at touch-down, not when the long press expires, so a list still settling + * under a stationary finger hands back the row the user aimed at rather than whichever row has + * since slid beneath them. */ private fun Modifier.pressCandidate(state: SceneTreeState, id: Int): Modifier = pointerInput(id) { @@ -140,7 +146,7 @@ private fun Modifier.reorderableModifier(state: SceneTreeState): Modifier { val layoutInfo: LazyListLayoutInfo = listState.layoutInfo val insertPosition = findInsertPosition( dragOffset = change.position, - layouts = layoutInfo.visibleItemsInfo, + layouts = rowLayouts, collapsedGroups = collapsedNodes, tree = summary.sceneTree, visibleNodes = visibleNodes, @@ -196,17 +202,13 @@ private fun drawInsertLine( return@Canvas } - val visibleItems = state.listState.layoutInfo.visibleItemsInfo - val anchorLayout = visibleItems.find { it.key == node.value.id } + val visibleItems = state.rowLayouts + val anchorLayout = visibleItems.find { it.id == node.value.id } - val lineY: Int + val lineY: Float val nestingDept: Int if (anchorLayout != null) { - lineY = if (insertPos.before) { - anchorLayout.offset - } else { - anchorLayout.offset + anchorLayout.size - } + lineY = if (insertPos.before) anchorLayout.top else anchorLayout.bottom val isGroup = node.value.type.isCollection val isCollapsed = (state.collapsedNodes[node.value.id] == true) @@ -224,8 +226,8 @@ private fun drawInsertLine( .any { state.collapsedNodes[it.value.id] == true } if (!hiddenByCollapse) return@Canvas val parentNode = tree[node.parent] - val parentLayout = visibleItems.find { it.key == parentNode.value.id } ?: return@Canvas - lineY = parentLayout.offset + parentLayout.size + val parentLayout = visibleItems.find { it.id == parentNode.value.id } ?: return@Canvas + lineY = parentLayout.bottom nestingDept = parentNode.depth + 1 } @@ -233,8 +235,8 @@ private fun drawInsertLine( val endX = size.width - NESTING_INSET.toPx() drawLine( - start = Offset(x = insetSize, y = lineY.toFloat()), - end = Offset(x = endX, y = lineY.toFloat()), + start = Offset(x = insetSize, y = lineY), + end = Offset(x = endX, y = lineY), color = color, strokeWidth = 5f.dp.toPx(), cap = StrokeCap.Round diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt index 3bd0378d7..ce228ab24 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt @@ -14,7 +14,9 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.layout.LayoutCoordinates import com.darkrockstudios.apps.hammer.common.data.InsertPosition import com.darkrockstudios.apps.hammer.common.data.MoveRequest import com.darkrockstudios.apps.hammer.common.data.SceneItem @@ -77,8 +79,7 @@ class SceneTreeState( } /** - * Row this press started on, recorded by the rows themselves so it reflects the drawn - * position rather than a layout target. + * Row this press started on, claimed by the row itself so it is the row the user aimed at. * Deliberately not snapshot state: it changes on every press and must not recompose the tree. */ internal var pressedRowId: Int = NO_SELECTION @@ -105,6 +106,58 @@ class SceneTreeState( pressedRowId = NO_SELECTION } + private data class RowEntry(val coordinates: LayoutCoordinates, val placement: Int) + + private val rows = mutableStateMapOf() + private var placementCount = 0 + private var listCoordinates: LayoutCoordinates? = null + + /** + * Where the rows are actually drawn, in display order, which is what every drag decision has + * to be made against. + * + * Thar be dragons: [LazyListLayoutInfo] looks like the obvious source and is not. Its offsets + * are where items are headed, so while a placement animation runs it reports a row at a slot + * it has not reached, and drags land on the neighbour instead (issue #837). + */ + internal val rowLayouts: List + get() { + val list = listCoordinates?.takeIf { it.isAttached } ?: return emptyList() + + return rows.entries.mapNotNull { (id, entry) -> + if (!entry.coordinates.isAttached) return@mapNotNull null + + RowLayout( + id = id, + top = list.localPositionOf(entry.coordinates, Offset.Zero).y, + height = entry.coordinates.size.height.toFloat(), + ) + }.sortedBy { it.top } + } + + internal fun setListCoordinates(coordinates: LayoutCoordinates) { + listCoordinates = coordinates + } + + /** + * Coordinates are held live rather than sampled: item placement animations move a row by + * translating its layer every frame, without a new layout pass, so a position read during + * [androidx.compose.ui.layout.onGloballyPositioned] is the row's destination rather than + * where it is drawn. Resolving them against the list at drag time sees through that. + * + * [RowEntry.placement] makes every call a distinct map value. Re-putting equal coordinates + * is a no-op the snapshot system does not report, which would leave the insert line with + * nothing to invalidate it as rows scroll. + */ + internal fun setRowCoordinates(id: Int, coordinates: LayoutCoordinates) { + rows[id] = RowEntry(coordinates, placementCount++) + } + + internal fun removeRowCoordinates(id: Int) { + rows.remove(id) + } + + private var scrollJob by mutableStateOf(null) private var treeHash by mutableStateOf(sceneSummary.sceneTree.hashCode()) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeUtils.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeUtils.kt index af4400c40..9b53c7e89 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeUtils.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeUtils.kt @@ -1,6 +1,5 @@ package com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.scenetree -import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.geometry.Offset import com.darkrockstudios.apps.hammer.common.data.InsertPosition @@ -31,9 +30,14 @@ internal fun visibleSceneNodes( return visible } +/** A row as it is drawn, in coordinates shared with the drag gesture. */ +internal data class RowLayout(val id: Int, val top: Float, val height: Float) { + val bottom: Float get() = top + height +} + internal fun findInsertPosition( dragOffset: Offset, - layouts: List, + layouts: Collection, collapsedGroups: SnapshotStateMap, tree: ImmutableTree, visibleNodes: List>, @@ -45,9 +49,9 @@ internal fun findInsertPosition( val selectedId = selectedNode.value.id var foundItemId: InsertPosition? = null for (layout in layouts) { - val id = layout.key as Int - val size = layout.size - val itemPos = layout.offset + val id = layout.id + val size = layout.height + val itemPos = layout.top if (id != selectedId && dragY >= itemPos diff --git a/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt index ce2c42a33..4959d08ce 100644 --- a/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt +++ b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt @@ -146,6 +146,51 @@ class SceneTreeDragTest { assertEquals(Ids.VERIFICATION, treeState.selectedId, "Grabbed the wrong row") } + @Test + fun `rows are reported in display order`() { + // A tree whose display order is not ascending by id: "More Cameras" (9) sits near the + // bottom, so anything ordered by id or by hash disagrees with what is on screen. + summary = buildSummary(camerasAtRoot = true) + showTree() + + // findInsertPosition takes the first row whose bounds contain the pointer, and row + // bounds share seams, so anything but display order makes a drop on a seam a coin toss. + assertEquals( + treeState.visibleNodes.map { it.value.id }, + treeState.rowLayouts.map { it.id }, + "Row order does not match the rendered tree", + ) + } + + @Test + fun `dropping onto a settling list anchors to the row the user sees`() { + showTree() + compose.mainClock.autoAdvance = false + + compose.onNodeWithTag(TREE_TAG) + .performMouseInput { moveTo(visualCenterOf(Ids.PREFACE)); press() } + compose.mainClock.advanceTimeBy(1_000) + + // A sync re-flows the list while the drag is already under way. + summary = buildSummary(camerasAtRoot = true) + compose.mainClock.advanceTimeByFrame() + compose.mainClock.advanceTimeByFrame() + + compose.onNodeWithTag(TREE_TAG) + .performMouseInput { moveTo(visualCenterOf(Ids.VERIFICATION)) } + compose.mainClock.advanceTimeByFrame() + compose.onNodeWithTag(TREE_TAG).performMouseInput { release() } + compose.mainClock.advanceTimeBy(1_000) + + val request = moveRequest + assertNotNull(request, "No move was requested") + assertEquals( + Ids.VERIFICATION, + treeState.getTree()[request.toPosition.coords.globalIndex].value.id, + "Anchored to a row's destination instead of where it is drawn", + ) + } + @Test fun `a row disposed while pressed does not stay grabbable`() { showTree() diff --git a/composeUi/src/desktopTest/kotlin/SceneTreeInsertPositionTest.kt b/composeUi/src/desktopTest/kotlin/SceneTreeInsertPositionTest.kt index 5e26a4a2a..6c47bc8a9 100644 --- a/composeUi/src/desktopTest/kotlin/SceneTreeInsertPositionTest.kt +++ b/composeUi/src/desktopTest/kotlin/SceneTreeInsertPositionTest.kt @@ -1,6 +1,5 @@ package com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.scenetree -import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.geometry.Offset @@ -25,15 +24,7 @@ import kotlin.test.assertNotNull */ class SceneTreeInsertPositionTest { - private val rowHeight = 30 - - private class FakeItemInfo( - override val index: Int, - override val key: Any, - override val offset: Int, - override val size: Int, - override val contentType: Any? = null, - ) : LazyListItemInfo + private val rowHeight = 30f private fun dropAt( tree: ImmutableTree, @@ -43,16 +34,15 @@ class SceneTreeInsertPositionTest { fractionIntoRow: Float, ): InsertPosition? { val visibleNodes = visibleSceneNodes(tree, collapsed) - val layouts: List = visibleNodes.mapIndexed { index, node -> - FakeItemInfo( - index = index, - key = node.value.id, - offset = index * rowHeight, - size = rowHeight, + val layouts = visibleNodes.mapIndexed { index, node -> + RowLayout( + id = node.value.id, + top = index * rowHeight, + height = rowHeight, ) } - val targetLayout = layouts.first { it.key == targetId } - val dragY = targetLayout.offset + (rowHeight * fractionIntoRow) + val targetLayout = layouts.first { it.id == targetId } + val dragY = targetLayout.top + (rowHeight * fractionIntoRow) return findInsertPosition( dragOffset = Offset(0f, dragY), layouts = layouts, From eadb412805353fd4896086328a1cc954f2e262a9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 22:58:46 -0700 Subject: [PATCH 028/213] Update org.jetbrains:markdown to 0.7.8 (#846) Patch release: nested code fences parse correctly, reference link labels must immediately follow the link text, and username@host is recognized as part of a URL. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 604f6e2eb..1c5a79b9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ asm = "9.10.1" clikt = "5.1.0" kotlinx_atomicfu = "0.33.0" kotlinx_collections_immutable = "0.5.1" -markdown = "0.7.7" +markdown = "0.7.8" rtfParserKmp = "0.2.2" epub4kmp = "0.3.0" pdfkmp = "1.2.0" From c8eb4b874df34dfacb160eb58e2eccdde781a8f2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 23:17:09 -0700 Subject: [PATCH 029/213] New Crowdin updates (#847) * Update source file strings.xml [ci skip] * Update source file strings_about_app.xml [ci skip] * Update source file strings_account_settings.xml [ci skip] * Update source file strings_desktop.xml [ci skip] * Update source file strings_drafts.xml [ci skip] * Update source file strings_encyclopedia.xml [ci skip] * Update source file strings_globalsearch.xml [ci skip] * Update source file strings_ideas.xml [ci skip] * Update source file strings_notes.xml [ci skip] * Update source file strings_project_home.xml [ci skip] * Update source file strings_project_navigation.xml [ci skip] * Update source file strings_project_select_navigation.xml [ci skip] * Update source file strings_projects_list.xml [ci skip] * Update source file strings_scene_editor.xml [ci skip] * Update source file strings_scene_list.xml [ci skip] * Update source file strings_sync.xml [ci skip] * Update source file strings_timeline.xml [ci skip] * Update source file strings_android.xml [ci skip] * Update source file full_description.txt [ci skip] * Update source file Messages_en.properties [ci skip] * New translations messages_en.properties (French) [ci skip] * New translations messages_en.properties (Spanish) [ci skip] * New translations messages_en.properties (German) [ci skip] * New translations messages_en.properties (Italian) [ci skip] * New translations messages_en.properties (Ukrainian) [ci skip] * New translations messages_en.properties (Portuguese, Brazilian) [ci skip] * New translations strings_account_settings.xml (French) [ci skip] * New translations strings_account_settings.xml (Spanish) [ci skip] * New translations strings_account_settings.xml (German) [ci skip] * New translations strings_account_settings.xml (Italian) [ci skip] * New translations strings_account_settings.xml (Ukrainian) [ci skip] * New translations strings_account_settings.xml (Portuguese, Brazilian) [ci skip] * New translations strings_project_home.xml (French) [ci skip] * New translations strings_project_home.xml (Spanish) [ci skip] * New translations strings_project_home.xml (German) [ci skip] * New translations strings_project_home.xml (Italian) [ci skip] * New translations strings_project_home.xml (Ukrainian) [ci skip] * New translations strings_project_home.xml (Portuguese, Brazilian) [ci skip] * New translations strings_sync.xml (French) [ci skip] * New translations strings_sync.xml (Spanish) [ci skip] * New translations strings_sync.xml (German) [ci skip] * New translations strings_sync.xml (Italian) [ci skip] * New translations strings_sync.xml (Ukrainian) [ci skip] * New translations strings_sync.xml (Portuguese, Brazilian) [ci skip] * New translations full_description.txt (French) [ci skip] * New translations full_description.txt (Spanish) [ci skip] * New translations full_description.txt (German) [ci skip] * New translations full_description.txt (Italian) [ci skip] * New translations full_description.txt (Ukrainian) [ci skip] * New translations full_description.txt (Portuguese, Brazilian) [ci skip] --- .../src/main/res/values/strings_android.xml | 94 +- .../values-de/strings_account_settings.xml | 1 - .../values-de/strings_project_home.xml | 5 - .../values-de/strings_sync.xml | 9 + .../values-es/strings_account_settings.xml | 1 - .../values-es/strings_project_home.xml | 5 - .../values-es/strings_sync.xml | 9 + .../values-fr/strings_account_settings.xml | 1 - .../values-fr/strings_project_home.xml | 5 - .../values-fr/strings_sync.xml | 9 + .../values-it/strings_account_settings.xml | 1 - .../values-it/strings_project_home.xml | 5 - .../values-it/strings_sync.xml | 9 + .../strings_account_settings.xml | 1 - .../values-pt-rBR/strings_project_home.xml | 5 - .../values-pt-rBR/strings_sync.xml | 9 + .../values-uk/strings_account_settings.xml | 1 - .../values-uk/strings_project_home.xml | 5 - .../values-uk/strings_sync.xml | 9 + .../composeResources/values/strings.xml | 108 +- .../values/strings_about_app.xml | 62 +- .../values/strings_account_settings.xml | 297 +-- .../values/strings_desktop.xml | 48 +- .../values/strings_drafts.xml | 32 +- .../values/strings_encyclopedia.xml | 174 +- .../values/strings_globalsearch.xml | 38 +- .../composeResources/values/strings_ideas.xml | 188 +- .../composeResources/values/strings_notes.xml | 118 +- .../values/strings_project_home.xml | 267 +-- .../values/strings_project_navigation.xml | 28 +- .../strings_project_select_navigation.xml | 18 +- .../values/strings_projects_list.xml | 160 +- .../values/strings_scene_editor.xml | 186 +- .../values/strings_scene_list.xml | 136 +- .../composeResources/values/strings_sync.xml | 511 +++-- .../values/strings_timeline.xml | 114 +- .../android/de-DE/full_description.txt | 40 +- .../android/en-US/full_description.txt | 40 +- .../android/es-ES/full_description.txt | 40 +- .../android/fr-FR/full_description.txt | 40 +- .../android/it-IT/full_description.txt | 40 +- .../android/pt-BR/full_description.txt | 40 +- .../metadata/android/uk/full_description.txt | 40 +- .../resources/i18n/Messages_de.properties | 172 +- .../resources/i18n/Messages_en.properties | 1828 +++++++++-------- .../resources/i18n/Messages_es.properties | 172 +- .../resources/i18n/Messages_fr.properties | 172 +- .../resources/i18n/Messages_it.properties | 172 +- .../resources/i18n/Messages_pt_BR.properties | 172 +- .../resources/i18n/Messages_uk.properties | 172 +- 50 files changed, 2632 insertions(+), 3177 deletions(-) diff --git a/android/src/main/res/values/strings_android.xml b/android/src/main/res/values/strings_android.xml index 99dc8ede9..5e3ec4f64 100644 --- a/android/src/main/res/values/strings_android.xml +++ b/android/src/main/res/values/strings_android.xml @@ -1,48 +1,48 @@ - - - Hammer - Add Note - Loading… - Configure Widget - More Menu - Navigation Drawer - Add Note to: - Save - Cancel - Projects - Project not found: %1$s - Discard Note? - Discard - Cancel - Add Note - No projects - Note Added - Add Note - Add Story Note - Adding notes is disabled right now - Configure Widget - Configure Note Widget: - Add notes to specified project - If no project is selected, you will be asked to choose a project each time. - Hammer · Library — open a story - Hammer · Story — at-a-glance stats for one project - Story Info Widget: - Configure Story Info Widget - Tap to configure - § %1$s · STORY - by %1$s - WORDS - %1$d%% OF %2$s %3$s - TODAY - THIS WEEK - +%1$d TODAY - NO SESSION TODAY - %1$dD STREAK - SESSIONS · 7D - %1$s W - OPEN - NOTE - § %1$s - W - CONTINUE · + + + Hammer + Add Note + Loading… + Configure Widget + More Menu + Navigation Drawer + Add Note to: + Save + Cancel + Projects + Project not found: %1$s + Discard Note? + Discard + Cancel + Add Note + No projects + Note Added + Add Note + Add Story Note + Adding notes is disabled right now + Configure Widget + Configure Note Widget: + Add notes to specified project + If no project is selected, you will be asked to choose a project each time. + Hammer · Library — open a story + Hammer · Story — at-a-glance stats for one project + Story Info Widget: + Configure Story Info Widget + Tap to configure + § %1$s · STORY + by %1$s + WORDS + %1$d%% OF %2$s %3$s + TODAY + THIS WEEK + +%1$d TODAY + NO SESSION TODAY + %1$dD STREAK + SESSIONS · 7D + %1$s W + OPEN + NOTE + § %1$s + W + CONTINUE · \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml index 41ac50011..c98b12fb1 100644 --- a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml @@ -110,5 +110,4 @@ Der Server reagiert nicht. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut. Server nicht gefunden. Überprüfen Sie die Serveradresse und Ihre Internetverbindung. Netzwerkfehler beim Verbinden mit %1$s. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-de/strings_project_home.xml b/common/src/commonMain/composeResources/values-de/strings_project_home.xml index 754ecfcb3..f02f65c29 100644 --- a/common/src/commonMain/composeResources/values-de/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-de/strings_project_home.xml @@ -104,12 +104,7 @@ Kapitelgruppen erstellen Vorschau Nichts zu importieren - Reading your manuscript Erstellt %1$d Szene(n) - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W Abbrechen Importieren Einstellungen diff --git a/common/src/commonMain/composeResources/values-de/strings_sync.xml b/common/src/commonMain/composeResources/values-de/strings_sync.xml index 40b6920ad..4d51fda9c 100644 --- a/common/src/commonMain/composeResources/values-de/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-de/strings_sync.xml @@ -34,6 +34,13 @@ Tags ↵ zum Hinzufügen Fantasy, Entwurf, NaNoWriMo… + Language + Not set + Choose a language + Schließen + Search languages + Clear (not set) + No languages match Bearbeiten Abbrechen OK @@ -44,6 +51,7 @@ von %1$s von (kein Autor festgelegt) Rechtschreibprüfung + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Projekteinstellungen · %1$s §§ %1$d Projekteinstellungen-Konflikt @@ -52,6 +60,7 @@ Projekt-Farbschema Wortzahlziel Tags + Language (nicht festgelegt) Tag Woche diff --git a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml index 4a0bae9a1..39d9b7fe0 100644 --- a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml @@ -110,5 +110,4 @@ El servidor no responde. Comprueba tu conexión a internet e inténtalo de nuevo. Servidor no encontrado. Comprueba la dirección del servidor y tu conexión a internet. Error de red al conectar con %1$s. Comprueba tu conexión e inténtalo de nuevo. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-es/strings_project_home.xml b/common/src/commonMain/composeResources/values-es/strings_project_home.xml index 223deca28..9bfeca1e8 100644 --- a/common/src/commonMain/composeResources/values-es/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-es/strings_project_home.xml @@ -104,12 +104,7 @@ Crear grupos de capítulos Vista previa Nada que importar - Reading your manuscript Creará %1$d escena(s) - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W Cancelar Importar Ajustes diff --git a/common/src/commonMain/composeResources/values-es/strings_sync.xml b/common/src/commonMain/composeResources/values-es/strings_sync.xml index a57676194..18ac8dfa5 100644 --- a/common/src/commonMain/composeResources/values-es/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-es/strings_sync.xml @@ -34,6 +34,13 @@ Etiquetas ↵ para añadir fantasía, borrador, nanowrimo… + Language + Not set + Choose a language + Cerrar + Search languages + Clear (not set) + No languages match Editar Cancelar OK @@ -44,6 +51,7 @@ por %1$s por (sin autor definido) Corrección ortográfica + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Ajustes del proyecto · %1$s §§ %1$d Conflicto de ajustes del proyecto @@ -52,6 +60,7 @@ Tema del proyecto Meta de recuento de palabras Etiquetas + Language (sin definir) día semana diff --git a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml index 3ba0e81ab..421e6664d 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml @@ -111,5 +111,4 @@ Des conflits peuvent se déclarer et causer des erreurs si les contenus ne corre Le serveur ne répond pas. Vérifiez votre connexion Internet et réessayez. Serveur introuvable. Vérifiez l'adresse du serveur et votre connexion Internet. Erreur réseau lors de la connexion à %1$s. Vérifiez votre connexion et réessayez. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml index ffc0cffde..f42aa4cf7 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml @@ -104,12 +104,7 @@ Créer des groupes de chapitres Aperçu Rien à importer - Reading your manuscript Créera %1$d scène(s) - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s S Annuler Importer Paramètres diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index 3c6d9af7b..e2eaa0a84 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -34,6 +34,13 @@ Étiquettes ↵ ajouter fantaisie, brouillon, nanowrimo… + Language + Not set + Choose a language + Fermer + Search languages + Clear (not set) + No languages match Modifier Annuler OK @@ -44,6 +51,7 @@ par %1$s par (aucun auteur défini) Correction orthographique + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Paramètres du projet · %1$s §§ %1$d Conflit de paramètres du projet @@ -52,6 +60,7 @@ Thème du projet Objectif de nombre de mots Étiquettes + Language (non défini) jour semaine diff --git a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml index df9c5851c..eca45ef27 100644 --- a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml @@ -110,5 +110,4 @@ Il server non risponde. Controlla la tua connessione a internet e riprova. Server non trovato. Controlla l’indirizzo del server e la tua connessione a internet. Errore di rete durante la connessione a %1$s. Controlla la tua connessione e riprova. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-it/strings_project_home.xml b/common/src/commonMain/composeResources/values-it/strings_project_home.xml index 4d3f70efa..35548e43e 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_home.xml @@ -104,12 +104,7 @@ Crea gruppi di capitoli Anteprima Niente da importare - Reading your manuscript Creerà %1$d scene - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W Annulla Importa Impostazioni diff --git a/common/src/commonMain/composeResources/values-it/strings_sync.xml b/common/src/commonMain/composeResources/values-it/strings_sync.xml index 8a56721ae..626aecacd 100644 --- a/common/src/commonMain/composeResources/values-it/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-it/strings_sync.xml @@ -34,6 +34,13 @@ Tag ↵ per aggiungere fantasy, bozza, nanowrimo… + Language + Not set + Choose a language + Chiudi + Search languages + Clear (not set) + No languages match Modifica Annulla OK @@ -44,6 +51,7 @@ di %1$s di (nessun autore impostato) Controllo ortografico + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Impostazioni del progetto · %1$s §§ %1$d Conflitto di impostazioni del progetto @@ -52,6 +60,7 @@ Tema del progetto Obiettivo di conteggio parole Tag + Language (non impostato) giorno settimana diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml index 63e7efb06..f4104e4da 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml @@ -110,5 +110,4 @@ O servidor não está respondendo. Verifique sua conexão com a 'internet' e tente novamente. Servidor não encontrado. Verifique o endereço do servidor e sua conexão de 'internet'. Erro de rede ao conectar-se a %1$s. Verifique sua conexão e tente novamente. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml index 50a49180e..53ee366f6 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml @@ -104,12 +104,7 @@ Criar grupos de capítulos Prévia Nada para importar - Reading your manuscript Criará %1$d cena(s) - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W Cancelar Importar Configurações diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml index 11d5d580b..fb77f771a 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml @@ -34,6 +34,13 @@ Etiquetas ↵ para adicionar fantasia, rascunho, nanowrimo… + Language + Not set + Choose a language + Fechar + Search languages + Clear (not set) + No languages match Editar Cancelar OK @@ -44,6 +51,7 @@ por %1$s por (nenhum autor definido) Corretor ortográfico + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Configurações do Projeto · %1$s §§ %1$d Conflito de Configurações do Projeto @@ -52,6 +60,7 @@ Tema do Projeto Meta de Contagem de Palavras Etiquetas + Language (não definido) dia semana diff --git a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml index e6a0a0902..999f1d972 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml @@ -110,5 +110,4 @@ Сервер не відповідає. Перевірте підключення до Інтернету та спробуйте ще раз. Сервер не знайдено. Перевірте адресу сервера та підключення до Інтернету. Помилка мережі під час під'єднання до %1$s. Перевірте з'єднання та спробуйте ще раз. - Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml index 51b275181..8fa48da49 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml @@ -104,12 +104,7 @@ Створювати групи розділів Попередній перегляд Немає що імпортувати - Reading your manuscript Буде створено %1$d сцен - Large scene - One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. - %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W Скасувати Імпортувати Налаштування diff --git a/common/src/commonMain/composeResources/values-uk/strings_sync.xml b/common/src/commonMain/composeResources/values-uk/strings_sync.xml index bc8b92fe2..db38fd497 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_sync.xml @@ -34,6 +34,13 @@ Теги ↵ щоб додати фентезі, чернетка, nanowrimo… + Language + Not set + Choose a language + Закрити + Search languages + Clear (not set) + No languages match Редагувати Скасувати OK @@ -44,6 +51,7 @@ автор: %1$s автор: (не вказано) Перевірка правопису + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Налаштування проєкту · %1$s §§ %1$d Конфлікт налаштувань проєкту @@ -52,6 +60,7 @@ Тема проєкту Ціль за кількістю слів Теги + Language (не встановлено) день тиждень diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index 8a5827fe2..f76a6b2a6 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -1,54 +1,54 @@ - - - Hammer - - Saved all Scenes - - Unsaved Scenes - What should be done with unsaved scenes? - - Unsaved Encyclopedia Entry - Discard entry? - - Unsaved Note - Discard note? - - Unsaved Timeline Event - Discard event? - - Cancel - Discard and close - - Discard - Cancel - Save - - Close - More - - Failed to parse response body: %1$s - - Yes - No - - - Expand - Collapse - - Decrease text size - Increase text size - Reset text size - Find and replace - - Bold - Italic - Strikethrough - Heading - Quote - Bulleted list - Numbered list - Horizontal rule - Undo - Redo - ESC cancel - + + + Hammer + + Saved all Scenes + + Unsaved Scenes + What should be done with unsaved scenes? + + Unsaved Encyclopedia Entry + Discard entry? + + Unsaved Note + Discard note? + + Unsaved Timeline Event + Discard event? + + Cancel + Discard and close + + Discard + Cancel + Save + + Close + More + + Failed to parse response body: %1$s + + Yes + No + + + Expand + Collapse + + Decrease text size + Increase text size + Reset text size + Find and replace + + Bold + Italic + Strikethrough + Heading + Quote + Bulleted list + Numbered list + Horizontal rule + Undo + Redo + ESC cancel + diff --git a/common/src/commonMain/composeResources/values/strings_about_app.xml b/common/src/commonMain/composeResources/values/strings_about_app.xml index 8cfee963a..8985c3be2 100644 --- a/common/src/commonMain/composeResources/values/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values/strings_about_app.xml @@ -1,31 +1,31 @@ - - - A simple tool for building stories. - A free and open source project. - - Community - Discord - Reddit - Github - - Attribution - Libraries - - Version %s is now available - Up to date! - View Update - Release Notes - - A new version of Hammer is available - Release notes for %s - Release notes - No release notes provided. - Open Release - Dismiss - - Application Logs - Log Directory: - Export Logs - No log files found - Open log directory - + + + A simple tool for building stories. + A free and open source project. + + Community + Discord + Reddit + Github + + Attribution + Libraries + + Version %s is now available + Up to date! + View Update + Release Notes + + A new version of Hammer is available + Release notes for %s + Release notes + No release notes provided. + Open Release + Dismiss + + Application Logs + Log Directory: + Export Logs + No log files found + Open log directory + diff --git a/common/src/commonMain/composeResources/values/strings_account_settings.xml b/common/src/commonMain/composeResources/values/strings_account_settings.xml index d384fe86f..5420d5752 100644 --- a/common/src/commonMain/composeResources/values/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values/strings_account_settings.xml @@ -1,148 +1,149 @@ - - - ACCOUNT - LOCAL INSTALL - ACCOUNT SETTINGS - Backups - This backup system is meant to protect against data loss and corruption. It is NOT intended as a way to keep older revisions of your stories. Please use the Drafts system for that. - - Manage Backups - UI Theme - LIGHT - DARK - SYSTEM - Initial Project Screen - Home - Scene Editor - Projects Directory - Set the directory Hammer will use to store project data in. - - Select Dir - Example Project - Install an example project to see how different features are used. - - Reinstall - Example project created. - CONNECTED - NOT CONNECTED - URL - HELP ↗ - Configure data syncing - Set Up - Auto-Sync - Backup on sync - Close Sync Dialogs on Success - Max Backups per Project - Must be between 1 and %1$d - Remove Server - Remove Server - Are you sure you want to remove the server? - Set Up Server - Server URL: hammer.ink - E-Mail: user@example.com - Password - Show password - Hide password - Log In - Create - Cancel - Remove Local Content? - Should we remove your current local content before syncing? If not, we will attempt to merge your local content with the server content. If the content is unrelated, it could cause problems. - - Platform Settings - No platform-specific settings. - Keep screen on - Enable Do Not Disturb when in Focus Mode - Grant DND Permission - Hammer needs permission to enable Do Not Disturb mode when you are in Focus Mode. - - Store projects in public storage - Keep your projects in the shared Documents folder so other apps and your file manager can reach them. - - Projects Location - Hammer needs file access permission to store projects in public storage. - - Grant File Access - - Experimental - Spell Checking - If it is causing problems just turn it off. - Enable spell checking - Enable in focus mode - Dictionary - Spell check language - On Android, the spell check language is chosen by your device, not the app. To change it, update your system and keyboard languages in Android Settings. - Set up a server to sync your projects across devices and create secure backups. - - Sync & Backup Preferences - Danger Zone - - - Failed to load backups: %1$s - Failed to load backups for project: %1$s - Failed to delete backup: %1$s - Restore Backup - Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. - - Restore - Cancel - Manage Backups - No backups found - Select a project - No backups for this project - Restore - Delete backup - Show in Folder - Export - Cancel Sync? - Are you sure you want to cancel the sync? - Sync canceled - - - An unexpected error occurred. Please try again. - Invalid server address. Enter a URL like "hammer.ink" or an IP address. - - Please enter a valid email address. - Password must be at least 8 characters. - Password must be 64 characters or less. - Password must contain at least one uppercase letter. - - Password must contain at least one lowercase letter. - - Password must contain at least one number. - Password must contain at least one special character. - - - Server setup successful! - Server setup failed: %1$s - Server setup failed. Please check your connection and try again. - - - - Terms of Service - Accept - Decline - You must accept the Terms of Service to create an account. - - - Could not connect to server. Make sure the server is running and the address is correct. - - The server is not responding. Check your internet connection and try again. - - Server not found. Check the server address and your internet connection. - Network error connecting to %1$s. Check your connection and try again. - - - + + + ACCOUNT + LOCAL INSTALL + ACCOUNT SETTINGS + Backups + This backup system is meant to protect against data loss and corruption. It is NOT intended as a way to keep older revisions of your stories. Please use the Drafts system for that. + + Manage Backups + UI Theme + LIGHT + DARK + SYSTEM + Initial Project Screen + Home + Scene Editor + Projects Directory + Set the directory Hammer will use to store project data in. + + Select Dir + Example Project + Install an example project to see how different features are used. + + Reinstall + Example project created. + CONNECTED + NOT CONNECTED + URL + HELP ↗ + Configure data syncing + Set Up + Auto-Sync + Backup on sync + Close Sync Dialogs on Success + Max Backups per Project + Must be between 1 and %1$d + Remove Server + Remove Server + Are you sure you want to remove the server? + Set Up Server + Server URL: hammer.ink + E-Mail: user@example.com + Password + Show password + Hide password + Log In + Create + Cancel + Remove Local Content? + Should we remove your current local content before syncing? If not, we will attempt to merge your local content with the server content. If the content is unrelated, it could cause problems. + + Platform Settings + No platform-specific settings. + Keep screen on + Enable Do Not Disturb when in Focus Mode + Grant DND Permission + Hammer needs permission to enable Do Not Disturb mode when you are in Focus Mode. + + Store projects in public storage + Keep your projects in the shared Documents folder so other apps and your file manager can reach them. + + Projects Location + Hammer needs file access permission to store projects in public storage. + + Grant File Access + + Experimental + Spell Checking + If it is causing problems just turn it off. + Enable spell checking + Enable in focus mode + Dictionary + Spell check language + On Android, the spell check language is chosen by your device, not the app. To change it, update your system and keyboard languages in Android Settings. + Set up a server to sync your projects across devices and create secure backups. + + Sync & Backup Preferences + Danger Zone + + + Failed to load backups: %1$s + Failed to load backups for project: %1$s + Failed to delete backup: %1$s + Restore Backup + Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. + + Restore + Cancel + Manage Backups + No backups found + Select a project + No backups for this project + Restore + Delete backup + Show in Folder + Export + Cancel Sync? + Are you sure you want to cancel the sync? + Sync canceled + + + An unexpected error occurred. Please try again. + Invalid server address. Enter a URL like "hammer.ink" or an IP address. + + Please enter a valid email address. + Password must be at least 8 characters. + Password must be 64 characters or less. + Password must contain at least one uppercase letter. + + Password must contain at least one lowercase letter. + + Password must contain at least one number. + Password must contain at least one special character. + + + Server setup successful! + Server setup failed: %1$s + Server setup failed. Please check your connection and try again. + + + + Terms of Service + Accept + Decline + You must accept the Terms of Service to create an account. + + + Could not connect to server. Make sure the server is running and the address is correct. + + The server is not responding. Check your internet connection and try again. + + Server not found. Check the server address and your internet connection. + Network error connecting to %1$s. Check your connection and try again. + + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. + + diff --git a/common/src/commonMain/composeResources/values/strings_desktop.xml b/common/src/commonMain/composeResources/values/strings_desktop.xml index 2b1867a7f..e60657985 100644 --- a/common/src/commonMain/composeResources/values/strings_desktop.xml +++ b/common/src/commonMain/composeResources/values/strings_desktop.xml @@ -1,24 +1,24 @@ - - - Hammer - Project Selection - - Hammer - %1$s - File - Close Project - Exit - - Recent Projects - - - Welcome to Hammer - Hammer stores your projects in a folder you choose, so your writing stays in - your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change - this later in Settings. - - Choose where to store your Hammer projects - Choose Folder - Quit - Hammer needs a folder to store your projects.\nChoose a folder now, or quit. - - - + + + Hammer - Project Selection + + Hammer - %1$s + File + Close Project + Exit + + Recent Projects + + + Welcome to Hammer + Hammer stores your projects in a folder you choose, so your writing stays in + your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change + this later in Settings. + + Choose where to store your Hammer projects + Choose Folder + Quit + Hammer needs a folder to store your projects.\nChoose a folder now, or quit. + + + diff --git a/common/src/commonMain/composeResources/values/strings_drafts.xml b/common/src/commonMain/composeResources/values/strings_drafts.xml index ef56c03bd..248b08a57 100644 --- a/common/src/commonMain/composeResources/values/strings_drafts.xml +++ b/common/src/commonMain/composeResources/values/strings_drafts.xml @@ -1,16 +1,16 @@ - - - Merge with Current - Copy parts of the draft into here - Take Merged - Draft: %1$s - Copy desired parts into current, or accept all of this. - Take this whole draft - Draft - Current - - %1$s Drafts: - No Drafts Found - - Invalid name - + + + Merge with Current + Copy parts of the draft into here + Take Merged + Draft: %1$s + Copy desired parts into current, or accept all of this. + Take this whole draft + Draft + Current + + %1$s Drafts: + No Drafts Found + + Invalid name + diff --git a/common/src/commonMain/composeResources/values/strings_encyclopedia.xml b/common/src/commonMain/composeResources/values/strings_encyclopedia.xml index 423aa634b..d41a73dfd 100644 --- a/common/src/commonMain/composeResources/values/strings_encyclopedia.xml +++ b/common/src/commonMain/composeResources/values/strings_encyclopedia.xml @@ -1,87 +1,87 @@ - - - Encyclopedia - - Search - Clear - - Person - Place - Thing - Event - Idea - - No Entries Found - Create Entry - Create entry - New - Draft - Name - e.g. Cheshire Cat - Tags - ↵ to add - ritual, magical, recurring… - Description - Cover Art - Drop image · or - Browse files - 1 attached - Replace - Image is too large. Maximum size is %1$d MB. - Could not read the selected image. - Create entry - Cancel - Entry Created - Entry Name was too long. Max %1$d - The entry name must be alphanumeric - Tag is too long. Max %1$d - Alias is too long. Max %1$d - Aliases - Add Alias - Add - Nickname or other name - Appears in scenes - Not yet referenced in any scene - Entry name cannot be empty - Discard New Entry? - Error: Failed to load entry! - Click to enter description - Cancel - Save - Entry Saved - Discard Changes? - You will lose any changes you have made. - - Delete Entry? - This cannot be undone! - Entry Deleted - Delete Image? - This cannot be undone! - Name - Close Entry - Entry - Delete Entry - Remove Image - Add Image - Add Tags - Add Tags - - Encyclopedia - Edit - Particulars - Type - Tags - Aliases - Scenes - Tags - tag - alias - Fig. I · Entry - Fig. I - + Add - Fol. %1$s · Ent-%2$s - Sc.%1$s - Entry · Catalogue card - Edited - End of entry ✦ - + + + Encyclopedia + + Search + Clear + + Person + Place + Thing + Event + Idea + + No Entries Found + Create Entry + Create entry + New + Draft + Name + e.g. Cheshire Cat + Tags + ↵ to add + ritual, magical, recurring… + Description + Cover Art + Drop image · or + Browse files + 1 attached + Replace + Image is too large. Maximum size is %1$d MB. + Could not read the selected image. + Create entry + Cancel + Entry Created + Entry Name was too long. Max %1$d + The entry name must be alphanumeric + Tag is too long. Max %1$d + Alias is too long. Max %1$d + Aliases + Add Alias + Add + Nickname or other name + Appears in scenes + Not yet referenced in any scene + Entry name cannot be empty + Discard New Entry? + Error: Failed to load entry! + Click to enter description + Cancel + Save + Entry Saved + Discard Changes? + You will lose any changes you have made. + + Delete Entry? + This cannot be undone! + Entry Deleted + Delete Image? + This cannot be undone! + Name + Close Entry + Entry + Delete Entry + Remove Image + Add Image + Add Tags + Add Tags + + Encyclopedia + Edit + Particulars + Type + Tags + Aliases + Scenes + Tags + tag + alias + Fig. I · Entry + Fig. I + + Add + Fol. %1$s · Ent-%2$s + Sc.%1$s + Entry · Catalogue card + Edited + End of entry ✦ + diff --git a/common/src/commonMain/composeResources/values/strings_globalsearch.xml b/common/src/commonMain/composeResources/values/strings_globalsearch.xml index 459a6c232..bc78f6853 100644 --- a/common/src/commonMain/composeResources/values/strings_globalsearch.xml +++ b/common/src/commonMain/composeResources/values/strings_globalsearch.xml @@ -1,19 +1,19 @@ - - - Search project - Search project - Type to search, use #tag to filter… - Clear search - No results - Type at least 2 characters to search - %1$d results - Scene - Note - Encyclopedia - Timeline - All - Scenes - Notes - Encyclopedia - Timeline - + + + Search project + Search project + Type to search, use #tag to filter… + Clear search + No results + Type at least 2 characters to search + %1$d results + Scene + Note + Encyclopedia + Timeline + All + Scenes + Notes + Encyclopedia + Timeline + diff --git a/common/src/commonMain/composeResources/values/strings_ideas.xml b/common/src/commonMain/composeResources/values/strings_ideas.xml index 3911d12e7..8f0eb8f28 100644 --- a/common/src/commonMain/composeResources/values/strings_ideas.xml +++ b/common/src/commonMain/composeResources/values/strings_ideas.xml @@ -1,94 +1,94 @@ - - - Story Ideas - IDEA - - Story Ideas - %1$d IDEAS · %2$d TAGS - No story ideas yet. Capture your first spark. - No archived ideas. - %1$d W - %1$d TAGS - - Search ideas - Search ideas - Clear search - Close search - - All - Archived - FILTERED %1$d / %2$d - Clear All - - SORT - Newest - Oldest - Title A–Z - ↓ DATE - ↑ DATE - ↓ A–Z - - PROMOTED · %1$s - ARCHIVED · %1$s - - Capture a new idea - New Idea - Story Idea - Editing - UNSAVED - STORY IDEAS - Close idea - Edit - Title - Optional title - Tags - Enter or comma adds a tag - Add tag… - %1$d / %2$d CH - Create - Save - Cancel - Delete - Archive - Unarchive - - Idea captured - Idea saved - Idea deleted - Idea archived - Idea unarchived - Idea cannot be empty - Idea is too long: %1$d characters max - Tags can be at most %1$d characters - - Create Project - Create Project from Idea - A new project will be created and this idea will be added to it as its first note. - Project created: %1$s - Failed to create project - - Delete Idea - This idea will be permanently deleted. This cannot be undone. - Discard Changes - You have unsaved changes. Discard them? - - Syncing story ideas - Story ideas unchanged — skipped - Story ideas sync complete - Failed to fetch story ideas state: %1$s - Removed deleted idea: %1$s - Propagated idea deletion to server: %1$s - Failed to delete idea on server: %1$s - Uploaded idea: %1$s - Failed to upload idea "%1$s": %2$s - Downloaded idea: %1$s - Failed to download idea %1$s: %2$s - Conflict detected for idea: %1$s - Resolved conflict for idea: %1$s - Conflict left unresolved for idea: %1$s - - Story Idea Conflict - Title - Tags - Content - + + + Story Ideas + IDEA + + Story Ideas + %1$d IDEAS · %2$d TAGS + No story ideas yet. Capture your first spark. + No archived ideas. + %1$d W + %1$d TAGS + + Search ideas + Search ideas + Clear search + Close search + + All + Archived + FILTERED %1$d / %2$d + Clear All + + SORT + Newest + Oldest + Title A–Z + ↓ DATE + ↑ DATE + ↓ A–Z + + PROMOTED · %1$s + ARCHIVED · %1$s + + Capture a new idea + New Idea + Story Idea + Editing + UNSAVED + STORY IDEAS + Close idea + Edit + Title + Optional title + Tags + Enter or comma adds a tag + Add tag… + %1$d / %2$d CH + Create + Save + Cancel + Delete + Archive + Unarchive + + Idea captured + Idea saved + Idea deleted + Idea archived + Idea unarchived + Idea cannot be empty + Idea is too long: %1$d characters max + Tags can be at most %1$d characters + + Create Project + Create Project from Idea + A new project will be created and this idea will be added to it as its first note. + Project created: %1$s + Failed to create project + + Delete Idea + This idea will be permanently deleted. This cannot be undone. + Discard Changes + You have unsaved changes. Discard them? + + Syncing story ideas + Story ideas unchanged — skipped + Story ideas sync complete + Failed to fetch story ideas state: %1$s + Removed deleted idea: %1$s + Propagated idea deletion to server: %1$s + Failed to delete idea on server: %1$s + Uploaded idea: %1$s + Failed to upload idea "%1$s": %2$s + Downloaded idea: %1$s + Failed to download idea %1$s: %2$s + Conflict detected for idea: %1$s + Resolved conflict for idea: %1$s + Conflict left unresolved for idea: %1$s + + Story Idea Conflict + Title + Tags + Content + diff --git a/common/src/commonMain/composeResources/values/strings_notes.xml b/common/src/commonMain/composeResources/values/strings_notes.xml index 228820d47..0a18cfe1b 100644 --- a/common/src/commonMain/composeResources/values/strings_notes.xml +++ b/common/src/commonMain/composeResources/values/strings_notes.xml @@ -1,59 +1,59 @@ - - - Notes - No Notes Found - Create Note - Cancel - Discard Changes? - You will lose any changes you have made. - Create Note - III · NEW - DRAFT - Create - Cancel - Note Created - Note was too long - Note was empty - Tag was too long (max %1$d) - Tags - ↵ to add - idea, todo, follow-up… - Delete Note? - This action cannot be undone! - Note Deleted - - Note - Delete - - Note - Editing - Close Note - Notes - Edit - Save - UNSAVED - %1$d W · %2$d CH - ESC TO CANCEL - - Search notes - Search notes - Close search - Clear search - - %1$d NOTES · %2$d TAGS - %1$d W - ALL - FILTERED · %1$d OF %2$d - CLEAR ALL - SORT - Newest - Oldest - Longest - Shortest - Title A–Z - ↓ DATE - ↑ DATE - ↓ WORDS - ↑ WORDS - ↓ A–Z - + + + Notes + No Notes Found + Create Note + Cancel + Discard Changes? + You will lose any changes you have made. + Create Note + III · NEW + DRAFT + Create + Cancel + Note Created + Note was too long + Note was empty + Tag was too long (max %1$d) + Tags + ↵ to add + idea, todo, follow-up… + Delete Note? + This action cannot be undone! + Note Deleted + + Note + Delete + + Note + Editing + Close Note + Notes + Edit + Save + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + + Search notes + Search notes + Close search + Clear search + + %1$d NOTES · %2$d TAGS + %1$d W + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + SORT + Newest + Oldest + Longest + Shortest + Title A–Z + ↓ DATE + ↑ DATE + ↓ WORDS + ↑ WORDS + ↓ A–Z + diff --git a/common/src/commonMain/composeResources/values/strings_project_home.xml b/common/src/commonMain/composeResources/values/strings_project_home.xml index 5831024a3..694f4a16d 100644 --- a/common/src/commonMain/composeResources/values/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values/strings_project_home.xml @@ -1,131 +1,136 @@ - - - Created: %1$s - Calculating statistics... - Refresh statistics - Statistics may be out of date - Scenes - Total Words - Encyclopedia Entries - - Words in Chapters - Chapter - %1$s: %2$s words - - Avg Words / Scene - Notes - Timeline Events - Longest Scene - %1$s words - No scenes yet - Last Edited - Median - - ≈ %1$d min reading - %1$d pages - - Daily goal - Weekly goal - - This week - no change vs last week - no data last week - Today - Daily avg - - Streak - %1$d days - Longest: %1$d - Days written - Best day - %1$s · %2$s - - Characters by Appearances - %1$d connections - - Words by Device - - Activity · %1$d weeks - %1$s · %2$s words - %1$s · no writing - Avg weekday - Avg weekend - - σ %1$d · min %2$d · max %3$d - entries - %1$s · %2$s entries - - Themes - %1$d tags · %2$d uses - %1$d/4 - Most connective - Appears in all four types · %1$d uses - Appears in %1$d of 4 types · %2$d uses - All 4 types - %1$d of 4 types - Distribution - Showing %1$d of %2$d - Rank · Tag · Breadth · Uses - Scenes - Notes - Encyclopedia - Events - - Export Story - Sync Story - Backup - Backup Created: %1$s - Backup Failed - - Story Exported - Export failed - - Export Story - EXPORT - × CLOSE - Treat top-level scenes as chapters - Format - Markdown (.md) - EPub (.epub) - PDF (.pdf) - Word (.docx) - RTF (.rtf) - Cancel - Export - - Learn about export formats - Choosing an Export Format - Each format suits a different purpose. Pick the one that matches how you plan to use the exported story. - Plain text with light formatting; ideal for version control, re-importing, or other writing tools. - The standard e-book format; best for beta readers reading on e-readers and phones with reflowable text. - A fixed page layout that looks the same everywhere; best for printing or sharing a final, read-only copy. - Opens in Word and Google Docs; best for editors and beta readers who leave comments or track changes. - A broadly compatible rich-text format; a safe choice when you are unsure which word processor the recipient uses. - Got it - - Contents - by %1$s - - Story Imported - Import failed - Import Story - Chapter detection - Auto - H1 (#) - H2 (##) - Split into scenes - Formatting - Pattern - Single scene - Chapter pattern (regex) - Create chapter groups - Preview - Nothing to import - Will create %1$d scene(s) - Cancel - Import - - Settings - - Project menu - + + + Created: %1$s + Calculating statistics... + Refresh statistics + Statistics may be out of date + Scenes + Total Words + Encyclopedia Entries + + Words in Chapters + Chapter + %1$s: %2$s words + + Avg Words / Scene + Notes + Timeline Events + Longest Scene + %1$s words + No scenes yet + Last Edited + Median + + ≈ %1$d min reading + %1$d pages + + Daily goal + Weekly goal + + This week + no change vs last week + no data last week + Today + Daily avg + + Streak + %1$d days + Longest: %1$d + Days written + Best day + %1$s · %2$s + + Characters by Appearances + %1$d connections + + Words by Device + + Activity · %1$d weeks + %1$s · %2$s words + %1$s · no writing + Avg weekday + Avg weekend + + σ %1$d · min %2$d · max %3$d + entries + %1$s · %2$s entries + + Themes + %1$d tags · %2$d uses + %1$d/4 + Most connective + Appears in all four types · %1$d uses + Appears in %1$d of 4 types · %2$d uses + All 4 types + %1$d of 4 types + Distribution + Showing %1$d of %2$d + Rank · Tag · Breadth · Uses + Scenes + Notes + Encyclopedia + Events + + Export Story + Sync Story + Backup + Backup Created: %1$s + Backup Failed + + Story Exported + Export failed + + Export Story + EXPORT + × CLOSE + Treat top-level scenes as chapters + Format + Markdown (.md) + EPub (.epub) + PDF (.pdf) + Word (.docx) + RTF (.rtf) + Cancel + Export + + Learn about export formats + Choosing an Export Format + Each format suits a different purpose. Pick the one that matches how you plan to use the exported story. + Plain text with light formatting; ideal for version control, re-importing, or other writing tools. + The standard e-book format; best for beta readers reading on e-readers and phones with reflowable text. + A fixed page layout that looks the same everywhere; best for printing or sharing a final, read-only copy. + Opens in Word and Google Docs; best for editors and beta readers who leave comments or track changes. + A broadly compatible rich-text format; a safe choice when you are unsure which word processor the recipient uses. + Got it + + Contents + by %1$s + + Story Imported + Import failed + Import Story + Chapter detection + Auto + H1 (#) + H2 (##) + Split into scenes + Formatting + Pattern + Single scene + Chapter pattern (regex) + Create chapter groups + Preview + Nothing to import + Reading your manuscript + Will create %1$d scene(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W + Cancel + Import + + Settings + + Project menu + diff --git a/common/src/commonMain/composeResources/values/strings_project_navigation.xml b/common/src/commonMain/composeResources/values/strings_project_navigation.xml index 0e4b081d8..456df9eb4 100644 --- a/common/src/commonMain/composeResources/values/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values/strings_project_navigation.xml @@ -1,15 +1,15 @@ - - - Home - Story - Notes - Encyclopedia - Timeline - HOME - STRY - NOTE - ENCY - TIME - Expand navigation - Collapse navigation + + + Home + Story + Notes + Encyclopedia + Timeline + HOME + STRY + NOTE + ENCY + TIME + Expand navigation + Collapse navigation \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml b/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml index 77dec77e5..50ed7f191 100644 --- a/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml +++ b/common/src/commonMain/composeResources/values/strings_project_select_navigation.xml @@ -1,10 +1,10 @@ - - - Projects - Settings - About - PROJ - SETT - INFO - Libraries + + + Projects + Settings + About + PROJ + SETT + INFO + Libraries \ No newline at end of file diff --git a/common/src/commonMain/composeResources/values/strings_projects_list.xml b/common/src/commonMain/composeResources/values/strings_projects_list.xml index fa89daab0..a26dc0f55 100644 --- a/common/src/commonMain/composeResources/values/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values/strings_projects_list.xml @@ -1,80 +1,80 @@ - - - No Projects Found - Delete - Rename - Sync Projects - Create Project - Projects synced - Failed to sync projects - More - %1$s words - by %1$s - Created %1$s - Project %1$s - Search projects - Close search - filter by name or #tag - Clear filter - No projects match your filter - Hammer · Library - Title - Last Open - — Empty Index — - Library - Sync · On - Local - 0 entries - 1 entry - %1$d entries - SORT - Recently Opened - Least Recent - Newest - Oldest - Longest - Shortest - ↓ OPENED - ↑ OPENED - ↓ CREATED - ↑ CREATED - ↓ WORDS - ↑ WORDS - Account Sync - Sync Log - Project - All - Pending - Error - Conflict - Sync Complete - Canceled - Delete Project - This action cannot be undone. All project data will be permanently deleted. - Type "%1$s" to confirm - Delete - Cancel - Rename Project - New Project Name - Rename Project - Cancel - Create Project - New - Project - New Project Name - Create Project - Cancel - Import - Learn about importing a story - Create from an Existing Story - Already have a manuscript? Import it to create a new project. Hammer names the project after your file and automatically splits the text into scenes. - Supported Formats - Markdown (.md) - Rich Text (.rtf) - Got it - Project already exists - Missing Project Name - Project name too long - The project name contains invalid characters - The project name was left blank - + + + No Projects Found + Delete + Rename + Sync Projects + Create Project + Projects synced + Failed to sync projects + More + %1$s words + by %1$s + Created %1$s + Project %1$s + Search projects + Close search + filter by name or #tag + Clear filter + No projects match your filter + Hammer · Library + Title + Last Open + — Empty Index — + Library + Sync · On + Local + 0 entries + 1 entry + %1$d entries + SORT + Recently Opened + Least Recent + Newest + Oldest + Longest + Shortest + ↓ OPENED + ↑ OPENED + ↓ CREATED + ↑ CREATED + ↓ WORDS + ↑ WORDS + Account Sync + Sync Log + Project + All + Pending + Error + Conflict + Sync Complete + Canceled + Delete Project + This action cannot be undone. All project data will be permanently deleted. + Type "%1$s" to confirm + Delete + Cancel + Rename Project + New Project Name + Rename Project + Cancel + Create Project + New + Project + New Project Name + Create Project + Cancel + Import + Learn about importing a story + Create from an Existing Story + Already have a manuscript? Import it to create a new project. Hammer names the project after your file and automatically splits the text into scenes. + Supported Formats + Markdown (.md) + Rich Text (.rtf) + Got it + Project already exists + Missing Project Name + Project name too long + The project name contains invalid characters + The project name was left blank + diff --git a/common/src/commonMain/composeResources/values/strings_scene_editor.xml b/common/src/commonMain/composeResources/values/strings_scene_editor.xml index 69e11dc01..d4ecabcfd 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_editor.xml @@ -1,93 +1,93 @@ - - - Start New Draft: - Draft name - Save - Cancel - Draft Saved - Saving current draft as: - Current draft name is invalid! - Rename Scene - Scene Name - Rename - Cancel - Unsaved - Save - Scene Info - Saved - Scene - New Draft - View Drafts - Rename - Discard - Delete - Archive - Save - Close - - Archive Scene - Are you sure you want to archive '%1$s'? The scene will be hidden from the scene list but can be restored later. - Archive - Cancel - - Discard Changes - Are you sure you want to discard all unsaved changes to '%1$s'? This will reload the last saved version from disk. - Discard - Cancel - - Archived Scenes - No archived scenes - Restore - "%1$s" has been restored - "%1$s" has been archived - View Archived - - Enter Focus Mode - - Metadata - Hide Info - Words: - Advanced - Entity ID: - Filename: - Path: - Created - Edited - — - - Outline - A short description of what happens in the scene. - - Notes - Notes to yourself. - Draft Name - - References - Dismissed - No references yet - Dismiss - Restore - (dismissed) - Characters - Locations - Other - + Add reference - Add reference - Done - Search characters… - Search locations… - Search entries… - No matching entries. - Close dialog - - Tags - No tags yet - + Add tag - Add tags - tag1 tag2 … - Done - - Select a Scene to edit - - New Draft - + + + Start New Draft: + Draft name + Save + Cancel + Draft Saved + Saving current draft as: + Current draft name is invalid! + Rename Scene + Scene Name + Rename + Cancel + Unsaved + Save + Scene Info + Saved + Scene + New Draft + View Drafts + Rename + Discard + Delete + Archive + Save + Close + + Archive Scene + Are you sure you want to archive '%1$s'? The scene will be hidden from the scene list but can be restored later. + Archive + Cancel + + Discard Changes + Are you sure you want to discard all unsaved changes to '%1$s'? This will reload the last saved version from disk. + Discard + Cancel + + Archived Scenes + No archived scenes + Restore + "%1$s" has been restored + "%1$s" has been archived + View Archived + + Enter Focus Mode + + Metadata + Hide Info + Words: + Advanced + Entity ID: + Filename: + Path: + Created + Edited + — + + Outline + A short description of what happens in the scene. + + Notes + Notes to yourself. + Draft Name + + References + Dismissed + No references yet + Dismiss + Restore + (dismissed) + Characters + Locations + Other + + Add reference + Add reference + Done + Search characters… + Search locations… + Search entries… + No matching entries. + Close dialog + + Tags + No tags yet + + Add tag + Add tags + tag1 tag2 … + Done + + Select a Scene to edit + + New Draft + diff --git a/common/src/commonMain/composeResources/values/strings_scene_list.xml b/common/src/commonMain/composeResources/values/strings_scene_list.xml index 784b20dcf..b61709de8 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_list.xml @@ -1,68 +1,68 @@ - - - Scenes - Create Group - Group Name - Create Scene - Scene Name - Group Collapsed - Group Expanded - Delete Scene - Are you sure you want to delete this scene: "%1$s" - Delete - Dismiss - Cannot Delete Group - You cannot delete a group while it still has children: "%1$s" - - Create - Cancel - - Story Outline - Outline Overview - <no outline> - Dismiss - Manuscript - An outline of - Jump to - Chapters - Scenes - Chapter %1$s · %2$d Scenes - %1$d SCN - End of outline - %1$d/%2$d - Untitled chapter - - Options - Delete - Rename - Archive - Archive Scene - Are you sure you want to archive this scene: "%1$s" - Archive - Cancel - Rename Scene - Scene Name - Rename - - Create Scene - Create Group - - Move To - Destination - Search groups - Top Level - No matching groups - Position (1–%1$d) - Enter a position between 1 and %1$d - Move - Cancel - - %1$d · %2$d GR - New Scene - New Group - Add - Move - Scene - Group - Root - + + + Scenes + Create Group + Group Name + Create Scene + Scene Name + Group Collapsed + Group Expanded + Delete Scene + Are you sure you want to delete this scene: "%1$s" + Delete + Dismiss + Cannot Delete Group + You cannot delete a group while it still has children: "%1$s" + + Create + Cancel + + Story Outline + Outline Overview + <no outline> + Dismiss + Manuscript + An outline of + Jump to + Chapters + Scenes + Chapter %1$s · %2$d Scenes + %1$d SCN + End of outline + %1$d/%2$d + Untitled chapter + + Options + Delete + Rename + Archive + Archive Scene + Are you sure you want to archive this scene: "%1$s" + Archive + Cancel + Rename Scene + Scene Name + Rename + + Create Scene + Create Group + + Move To + Destination + Search groups + Top Level + No matching groups + Position (1–%1$d) + Enter a position between 1 and %1$d + Move + Cancel + + %1$d · %2$d GR + New Scene + New Group + Add + Move + Scene + Group + Root + diff --git a/common/src/commonMain/composeResources/values/strings_sync.xml b/common/src/commonMain/composeResources/values/strings_sync.xml index 045031529..4fa3410fb 100644 --- a/common/src/commonMain/composeResources/values/strings_sync.xml +++ b/common/src/commonMain/composeResources/values/strings_sync.xml @@ -1,260 +1,251 @@ - - - Project Synchronization - - Scene Conflict - Scene Draft Conflict - Note Conflict - Timeline Event Conflict - - No Date - - Local - Remote - - %1$s Conflict - Scene - Note - Timeline Event - Encyclopedia Entry - Scene Draft - Project Settings - - - You can take either the local or remote copy as is, or you can manually merge the two by copying parts of the remote into your local copy, and then taking that. - - - - Encyclopedia Entry Conflict - - Author - Custom theme - Use a custom theme for this project - Primary - Secondary - Word-count goal - Track a writing goal - Words - Per - Day - Week - Effective - W / day - Tags - Tags - ↵ to add - fantasy, draft, nanowrimo… - Language - Not set - Choose a language - Close - Search languages - Clear (not set) - No languages match - Edit - Cancel - OK - Home - Project Settings - Autosaved - § 0 · Project - by %1$s - by (no author set) - Spell checking - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. - Project Settings · %1$s - §§ %1$d - - Project Settings Conflict - Pick a side for each field. Local is what you have on this device; Server is what was synced from another device. - Author Name - Project Theme - Word Count Goal - Tags - Language - (not set) - day - week - Resolve - - - Name: - Type: - Image: - Content: - Tags: - - Has Image - - Name - Scene Content - Outline - Notes - Content - Metadata - References - Confirmed - Dismissed - None - Aliases: - - Name - Scene Content - - Note Content - - Date - Event Content - - Sync - Start Sync - - Synchronizing... - Sync Failed - Sync Complete - - - Project Sync Complete - Project Sync Failed - - Begin Sync - Server data loaded - Account Sync complete - Failed to sync projects: %1$s - Deleting server project: - ID(%1$s) - - Failed to delete project on server: %1$s - Deleting local project: %1$s - - Renaming server project: - ID(%1$s) - - Failed to rename project on - server: %1$s - - - Rename project from - server: %1$s - - Failed to rename - project from server: %1$s - - - Created project on server: %1$s - Failed to create project on server: %1$s - Created local project: %1$s - Setting local project Server ID: - %1$s - - Failed to create server project: - %1$s - - - Syncing Account... - Syncing Projects... - Projects Sync Complete - Syncing Project: %1$s - - There is a conflict in project: %1$s, open that project and sync in order to resolve it - - - Client Entity data calculated - Server data received - Client data loaded - Local Backup made: %1$s - Entity %1$d downloaded - Failed to download entity %1$d - Entity %1$d not found on server - Entity %1$d not modified - Rejected download for entity %1$d: server returned a mismatched id or type - Failed to upload entity %1$d: type not owned by anything, probably deleted - Failed to delete ID %1$d on server: %2$s - Deleted ID %1$d on server - Encountered conflict for new Entity, this should not be possible! ID: %1$d TYPE: %2$s - - Sync failed: %1$s - Server cached hash is stale for entity %1$d. Healing by force uploading. - Successfully healed server cache for entity %1$d by force uploading - Failed to heal server cache for entity %1$d - force upload failed - Entities transferred - Sync finalized - Sync data saved - Sync data not saved due to errors - Failed to end sync - - Rejected draft %1$d from server: unsafe draft name - Skipped image for encyclopedia entry %1$d from server: unsafe file extension - - Deleted Encyclopedia ID %1$d from client - Deleted note ID %1$d from client - Deleted draft %1$d from client - Deleted scene %1$d from client - Failed to delete scene %1$d from client - Failed find scene to delete: %1$d - Deleted event %1$d from client - Failed to delete event %1$d from client - Failed find event to delete: %1$d - - Creating new scene %1$d - Downloaded scene content for: %1$d - Moved scene %1$d to new parent %2$d - Failed to save downloaded scene content for: %1$d - - Creating new group %1$d - Downloaded scene content for: %1$d - Moved scene %1$d to new parent %2$d - - Unauthorized - Unhandled server error - - UPDATE REQUIRED - Your app is out of date - The sync server is out of date - This version of Hammer can no longer sync with the server. Update to the latest version to keep syncing your work. - The sync server is running an older protocol than this app. The server needs to be updated before syncing will work again. - Latest version: %s - You have: %s - Get the Update - Dismiss - - Reauthorize - Your token has expired, please reauthorize with your sync server. - Token Expired - Address - E-Mail - - About Sync Servers - Sync Servers for Hammer allow you to work on your projects from multiple devices. They will sync your work across whatever devices you configure with the same server. - - Official Server - You can run your own Sync server, but if that is too technical, or you just want to support the project, we run an official server at hammer.ink. - - If you subscribe to our Patreon, you get access to the official server. This is the best way to support this project. - - Patreon - Hammer.Ink - Registration - By default, servers have closed registration, meaning you need to get added to that server's whitelist to be able to create an account. - - SetUp - Enter the server's URL, such as "hammer.ink" for the official server. - - Enter your email and password, then hit "Create" to create your account. - - In the future, and on other devices, you will simply hit "Log In". - - Important Note - Syncing in Hammer is different from online-first applications like Google Docs. The syncing is not real-time. It usually happens when you open or close the app. Make sure you're synced with the latest versions of your projects before going offline. Hammer does automatic syncing, but it's always good to check if you're going to be offline for a while. - - Got It - + + + Project Synchronization + + Scene Conflict + Scene Draft Conflict + Note Conflict + Timeline Event Conflict + + No Date + + Local + Remote + + %1$s Conflict + Scene + Note + Timeline Event + Encyclopedia Entry + Scene Draft + Project Settings + + + You can take either the local or remote copy as is, or you can manually merge the two by copying parts of the remote into your local copy, and then taking that. + + + + Encyclopedia Entry Conflict + + Author + Custom theme + Use a custom theme for this project + Primary + Secondary + Word-count goal + Track a writing goal + Words + Per + Day + Week + Effective + W / day + Tags + Tags + ↵ to add + fantasy, draft, nanowrimo… + Edit + Cancel + OK + Home + Project Settings + Autosaved + § 0 · Project + by %1$s + by (no author set) + Spell checking + Project Settings · %1$s + §§ %1$d + + Project Settings Conflict + Pick a side for each field. Local is what you have on this device; Server is what was synced from another device. + Author Name + Project Theme + Word Count Goal + Tags + (not set) + day + week + Resolve + + + Name: + Type: + Image: + Content: + Tags: + + Has Image + + Name + Scene Content + Outline + Notes + Content + Metadata + References + Confirmed + Dismissed + None + Aliases: + + Name + Scene Content + + Note Content + + Date + Event Content + + Sync + Start Sync + + Synchronizing... + Sync Failed + Sync Complete + + + Project Sync Complete + Project Sync Failed + + Begin Sync + Server data loaded + Account Sync complete + Failed to sync projects: %1$s + Deleting server project: + ID(%1$s) + + Failed to delete project on server: %1$s + Deleting local project: %1$s + + Renaming server project: + ID(%1$s) + + Failed to rename project on + server: %1$s + + + Rename project from + server: %1$s + + Failed to rename + project from server: %1$s + + + Created project on server: %1$s + Failed to create project on server: %1$s + Created local project: %1$s + Setting local project Server ID: + %1$s + + Failed to create server project: + %1$s + + + Syncing Account... + Syncing Projects... + Projects Sync Complete + Syncing Project: %1$s + + There is a conflict in project: %1$s, open that project and sync in order to resolve it + + + Client Entity data calculated + Server data received + Client data loaded + Local Backup made: %1$s + Entity %1$d downloaded + Failed to download entity %1$d + Entity %1$d not found on server + Entity %1$d not modified + Rejected download for entity %1$d: server returned a mismatched id or type + Failed to upload entity %1$d: type not owned by anything, probably deleted + Failed to delete ID %1$d on server: %2$s + Deleted ID %1$d on server + Encountered conflict for new Entity, this should not be possible! ID: %1$d TYPE: %2$s + + Sync failed: %1$s + Server cached hash is stale for entity %1$d. Healing by force uploading. + Successfully healed server cache for entity %1$d by force uploading + Failed to heal server cache for entity %1$d - force upload failed + Entities transferred + Sync finalized + Sync data saved + Sync data not saved due to errors + Failed to end sync + + Rejected draft %1$d from server: unsafe draft name + Skipped image for encyclopedia entry %1$d from server: unsafe file extension + + Deleted Encyclopedia ID %1$d from client + Deleted note ID %1$d from client + Deleted draft %1$d from client + Deleted scene %1$d from client + Failed to delete scene %1$d from client + Failed find scene to delete: %1$d + Deleted event %1$d from client + Failed to delete event %1$d from client + Failed find event to delete: %1$d + + Creating new scene %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + Failed to save downloaded scene content for: %1$d + + Creating new group %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + + Unauthorized + Unhandled server error + + UPDATE REQUIRED + Your app is out of date + The sync server is out of date + This version of Hammer can no longer sync with the server. Update to the latest version to keep syncing your work. + The sync server is running an older protocol than this app. The server needs to be updated before syncing will work again. + Latest version: %s + You have: %s + Get the Update + Dismiss + + Reauthorize + Your token has expired, please reauthorize with your sync server. + Token Expired + Address + E-Mail + + About Sync Servers + Sync Servers for Hammer allow you to work on your projects from multiple devices. They will sync your work across whatever devices you configure with the same server. + + Official Server + You can run your own Sync server, but if that is too technical, or you just want to support the project, we run an official server at hammer.ink. + + If you subscribe to our Patreon, you get access to the official server. This is the best way to support this project. + + Patreon + Hammer.Ink + Registration + By default, servers have closed registration, meaning you need to get added to that server's whitelist to be able to create an account. + + SetUp + Enter the server's URL, such as "hammer.ink" for the official server. + + Enter your email and password, then hit "Create" to create your account. + + In the future, and on other devices, you will simply hit "Log In". + + Important Note + Syncing in Hammer is different from online-first applications like Google Docs. The syncing is not real-time. It usually happens when you open or close the app. Make sure you're synced with the latest versions of your projects before going offline. Hammer does automatic syncing, but it's always good to check if you're going to be offline for a while. + + Got It + diff --git a/common/src/commonMain/composeResources/values/strings_timeline.xml b/common/src/commonMain/composeResources/values/strings_timeline.xml index 28e4dc5d0..5a389c0bf 100644 --- a/common/src/commonMain/composeResources/values/strings_timeline.xml +++ b/common/src/commonMain/composeResources/values/strings_timeline.xml @@ -1,57 +1,57 @@ - - - No Events - Create Event - - Event Created - Tag was too long (max %1$d) - Tags - ↵ to add - arc, foreshadow, conflict… - - Event - Editing - Timeline - Save - Cancel - Edit - Close Entry - Date - optional - e.g. Spring of Y3 - UNDATED - UNSAVED - %1$d W · %2$d CH - ESC TO CANCEL - %1$d W - %1$d TAGS - Saved - Failed to save - Create Event - III · NEW - DRAFT - %1$d W · %2$d CH - Cancel - Create - optional - e.g. Spring of Y3 - - Discard Changes? - You will lose any changes you have made. - - Event - Delete - - Delete Timeline Event? - This cannot be undone! - - Timeline - %1$d ENTRIES · %2$d TAGS - Search timeline - Search timeline - Close search - Clear search - ALL - FILTERED · %1$d OF %2$d - CLEAR ALL - + + + No Events + Create Event + + Event Created + Tag was too long (max %1$d) + Tags + ↵ to add + arc, foreshadow, conflict… + + Event + Editing + Timeline + Save + Cancel + Edit + Close Entry + Date + optional + e.g. Spring of Y3 + UNDATED + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + %1$d W + %1$d TAGS + Saved + Failed to save + Create Event + III · NEW + DRAFT + %1$d W · %2$d CH + Cancel + Create + optional + e.g. Spring of Y3 + + Discard Changes? + You will lose any changes you have made. + + Event + Delete + + Delete Timeline Event? + This cannot be undone! + + Timeline + %1$d ENTRIES · %2$d TAGS + Search timeline + Search timeline + Close search + Clear search + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 86866e0b7..e21b7a24d 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,21 +1,21 @@ -Bitte trete unserem Discord bei, um Probleme zu melden! https://discord.gg/GTmgjZcupk - -Plattformübergreifend - -Diese App ist wo immer du bist. Überall verfügbar – auf deinem Smartphone, Tablet, PC oder Laptop. Es ist nicht einfach nur eine "website-in-a-box". Es werden native Technologien des Endgerätes verwendet, um die bestmögliche Erfahrung zu bieten. - -Offline first - -Ich war frustriert über die meisten Tools zum Schreiben von Geschichten, die ich gefunden habe, da sie Web-Technologien verwenden (aka: "webpage in a box"), die immer in Schwierigkeiten zu geraten scheinen, während sie für längere Zeit offline verwendet werden. Hammer ist von Grund für die lokale Nutzung konzipiert, ohne, dass jemals eine Internetverbindung erforderlich ist. - -Datenhoheit - -Deine Daten gehören dir. Es wird nicht in der Cloud oder einer undurchsichtigen Datenbank gespeichert. Deine Geschichte wird in einfachen und lesbaren Dateien gespeichert (nur Dateien und Ordnern), um die Projektstruktur zu definieren. Du kannst deinem Betriebssystem-Dateimanager öffnen und dir die Daten selbst anschauen. Falls Hammer nicht mehr verfügbar ist, kannst du trotzdem einfach mit deinen Daten ingeragieren. - -Intelligente Synchronisierung zwischen Geräten - -Deine Daten können zwischen Geräten synchronisiert werden, sodass du von überall aus an deiner Geschichte weiterarbeiten kannst und keine Angst vor einer Änderung auf einem anderen Gerät haben musst. - -Synchronisierung: optional oder selbstbetrieben - +Bitte trete unserem Discord bei, um Probleme zu melden! https://discord.gg/GTmgjZcupk + +Plattformübergreifend + +Diese App ist wo immer du bist. Überall verfügbar – auf deinem Smartphone, Tablet, PC oder Laptop. Es ist nicht einfach nur eine "website-in-a-box". Es werden native Technologien des Endgerätes verwendet, um die bestmögliche Erfahrung zu bieten. + +Offline first + +Ich war frustriert über die meisten Tools zum Schreiben von Geschichten, die ich gefunden habe, da sie Web-Technologien verwenden (aka: "webpage in a box"), die immer in Schwierigkeiten zu geraten scheinen, während sie für längere Zeit offline verwendet werden. Hammer ist von Grund für die lokale Nutzung konzipiert, ohne, dass jemals eine Internetverbindung erforderlich ist. + +Datenhoheit + +Deine Daten gehören dir. Es wird nicht in der Cloud oder einer undurchsichtigen Datenbank gespeichert. Deine Geschichte wird in einfachen und lesbaren Dateien gespeichert (nur Dateien und Ordnern), um die Projektstruktur zu definieren. Du kannst deinem Betriebssystem-Dateimanager öffnen und dir die Daten selbst anschauen. Falls Hammer nicht mehr verfügbar ist, kannst du trotzdem einfach mit deinen Daten ingeragieren. + +Intelligente Synchronisierung zwischen Geräten + +Deine Daten können zwischen Geräten synchronisiert werden, sodass du von überall aus an deiner Geschichte weiterarbeiten kannst und keine Angst vor einer Änderung auf einem anderen Gerät haben musst. + +Synchronisierung: optional oder selbstbetrieben + Synchronisierung ist vollständig optional, du kannst den Client installieren und auf einem Gerät verwenden, oder du kannst den Server installieren und deine Daten zwischen den Geräten synchronisieren. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 38b979da3..331f7a35f 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multi-platform - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Transparent Data - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multi-platform + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Transparent Data + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 38256b092..ac119a6da 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multiplataforma - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Datos transparentes - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multiplataforma + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Datos transparentes + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index fff9b8f79..0f0c21734 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1,21 +1,21 @@ -Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk - -Multiplateforme - -Cette application est utilisable peu importe l'endroit où vous vous trouvez. Votre smartphone, tablette, ordinateur de bureau et ordinateur portable sera en mesure de pouvoir installer et utiliser ce programme, ce n'est pas une application web de poche, mais une véritable application client afin de fournir la meilleure expérience possible. - -Hors ligne avant tout - -J'ai été frustré par la plupart des logiciels de rédaction d'histoire que je trouvais puisqu'ils utilisaient tous des technologies web (ex : page web déguisé en application) qui semblent toujours rencontrer des problèmes lorsqu'ils sont utilisés hors ligne trop longtemps. Hammer a été conçu dans le but d'être entièrement fonctionnel en local, sans aucune connexion internet nécessaire, pour toujours. - -Transparence des données - -Vos données vous appartiennent. Ils ne sont pas stockés dans le cloud, ou dans une obscure base de données. Tout est stocké dans des fichiers humainement lisibles, utilisant uniquement des fichiers et des dossiers pour définir la structure du projet. Vous pouvez donc y jeter un œil par vous-même en naviguant dans vos fichiers. Si ce programme disparaissait aujourd'hui, vous serez en mesure d'interagir facilement avec vos données. - -Synchronisation intelligente entre vos appareils - -Vos données peuvent être synchronisées entre plusieurs appareils vous permettant de poursuivre l'écriture de votre histoire là où vous le souhaitez, sans avoir la crainte qu'une édition faite sur l'un de vos appareils n'en écrase une faite sur un autre. - -Synchronisation optionnelle et auto-hébergée - +Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk + +Multiplateforme + +Cette application est utilisable peu importe l'endroit où vous vous trouvez. Votre smartphone, tablette, ordinateur de bureau et ordinateur portable sera en mesure de pouvoir installer et utiliser ce programme, ce n'est pas une application web de poche, mais une véritable application client afin de fournir la meilleure expérience possible. + +Hors ligne avant tout + +J'ai été frustré par la plupart des logiciels de rédaction d'histoire que je trouvais puisqu'ils utilisaient tous des technologies web (ex : page web déguisé en application) qui semblent toujours rencontrer des problèmes lorsqu'ils sont utilisés hors ligne trop longtemps. Hammer a été conçu dans le but d'être entièrement fonctionnel en local, sans aucune connexion internet nécessaire, pour toujours. + +Transparence des données + +Vos données vous appartiennent. Ils ne sont pas stockés dans le cloud, ou dans une obscure base de données. Tout est stocké dans des fichiers humainement lisibles, utilisant uniquement des fichiers et des dossiers pour définir la structure du projet. Vous pouvez donc y jeter un œil par vous-même en naviguant dans vos fichiers. Si ce programme disparaissait aujourd'hui, vous serez en mesure d'interagir facilement avec vos données. + +Synchronisation intelligente entre vos appareils + +Vos données peuvent être synchronisées entre plusieurs appareils vous permettant de poursuivre l'écriture de votre histoire là où vous le souhaitez, sans avoir la crainte qu'une édition faite sur l'un de vos appareils n'en écrase une faite sur un autre. + +Synchronisation optionnelle et auto-hébergée + La synchronisation est entièrement optionnelle, vous pouvez installer le client et l'utiliser sur votre appareil sans aucune action supplémentaire, ou vous pouvez installer un serveur pour la synchronisation entre vos appareils. \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 70819e6e8..d4c12dafa 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multipiattaforma - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Dati trasparenti - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multipiattaforma + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Dati trasparenti + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt index f3cef1b81..dcf150a55 100644 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -1,21 +1,21 @@ -Por favor, junte-se ao nosso servidor de Discord para relatar problemas! https://discord.gg/GTmgjZcupk - -Multi-plataforma - -Este aplicativo está sempre ao seu lado. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. O Hammer é projetado do zero para ser completamente local, sem necessidade de conexão com a internet. - -Dados Transparentes - -Os seus dados são seus. Eles não são armazenados na nuvem ou algum banco de dados obscuro. Eles são armazenados em arquivos simples e legíveis por humanos, usando apenas arquivos e pastas para definir a estrutura do projeto. Você pode abrir o explorador de arquivos do seu sistema operacional e verificar por si mesmo. Se esse programa sumisse hoje, você ainda poderia facilmente interagir com seus dados. - -Sincronização inteligente entre aparelhos - -Seus dados podem ser sincronizados entre aparelhos, permitindo que você trabalhe na sua história em qualquer lugar, e sem temer que uma alteração em um aparelho sobrescreva a feita em outro. - -Sincronização Opcional e Auto-hospedada - +Por favor, junte-se ao nosso servidor de Discord para relatar problemas! https://discord.gg/GTmgjZcupk + +Multi-plataforma + +Este aplicativo está sempre ao seu lado. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. O Hammer é projetado do zero para ser completamente local, sem necessidade de conexão com a internet. + +Dados Transparentes + +Os seus dados são seus. Eles não são armazenados na nuvem ou algum banco de dados obscuro. Eles são armazenados em arquivos simples e legíveis por humanos, usando apenas arquivos e pastas para definir a estrutura do projeto. Você pode abrir o explorador de arquivos do seu sistema operacional e verificar por si mesmo. Se esse programa sumisse hoje, você ainda poderia facilmente interagir com seus dados. + +Sincronização inteligente entre aparelhos + +Seus dados podem ser sincronizados entre aparelhos, permitindo que você trabalhe na sua história em qualquer lugar, e sem temer que uma alteração em um aparelho sobrescreva a feita em outro. + +Sincronização Opcional e Auto-hospedada + Você pode instalar o cliente e usá-lo em um dispositivo, ou instalar o servidor e sincronizar seus dados com outros dispositivos. \ No newline at end of file diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index ba77097fb..29b6fd2c0 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Багатоплатформність - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Прозорі дані - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Багатоплатформність + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Прозорі дані + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 8cc99f94b..11a017188 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -1,122 +1,18 @@ language=Deutsch page_title=Hammer -home_servermessage=Server notice +home_servermessage=Server-Nachricht home_servermessage_whitelist=Für diesen Server benötigst du eine Freigabe, bevor du ein Konto erstellen kannst. Bitte kontaktiere {0} um auf die Whitelist zu gelangen. -home_meta_description=Hammer is an open source tool for writing stories, and the worlds in which they exist. -home_meta_tagline=Own your book, build your world -# Masthead -home_masthead_title_first=Own your book, -home_masthead_title_second=build your world -home_masthead_subtitle=Free and open source. Works offline, on every device you own, and every chapter stays a plain text file on your own disk. -home_hero_download=Download free -home_hero_platforms=Windows, macOS, Linux, Android and iOS. Free and open source. -# Writing features -home_features_eyebrow=Writing in Hammer -home_features_title=Built for the way a novel actually gets written -home_features_lede=Scenes you can move, an encyclopedia that knows who is in them, a timeline, notes, and a record of what you actually wrote. -home_feature_editor_title=An editor that stays out of the way -home_feature_editor_body1=Your scene, the outline you wrote for it, and your notes about it, all on one screen. Word count and metadata sit to the side when you want them and disappear when you don't. -home_feature_editor_body2=Break the book into scenes as small as you like, then drag them into a different order when two characters need to meet earlier. -home_feature_editor_alt=The Hammer editor showing a scene, with the scene list on the left and outline, notes and references on the right -home_feature_encyclopedia_title=An encyclopedia that looks like your world -home_feature_encyclopedia_body1=Characters, places, events and anything else you invent, with pictures. Tag them, search them, keep them a keystroke from the page you are on. -home_feature_encyclopedia_body2=Hammer reads your scenes and works out who is in them. Open a character and it lists every scene they appear in; open a scene and it shows the characters and locations it found, which you confirm or dismiss. Nobody has to remember to tag anything. -home_feature_encyclopedia_body3=Which means you can answer the question every draft eventually raises\: where did she go? Three chapters without your antagonist show up as an absence you can see. -home_feature_encyclopedia_alt=The Hammer encyclopedia showing illustrated entries for characters, places and events -home_feature_timeline_title=See the plot laid out in order -home_feature_timeline_body1=Put the events of your story, or the history of your world, on a dated and tagged timeline, so you can see that the potion comes before the trial, and that three chapters pass without your antagonist. -home_feature_timeline_alt=The Hammer timeline listing dated, tagged story events -home_feature_notes_title=Somewhere to put the thought you just had -home_feature_notes_body1=The observation about your own book that arrives at the wrong moment. Tag it, find it later, keep it next to the manuscript instead of in a file on the desktop called notes2-final.txt. -home_feature_notes_alt=The Hammer notes view with tagged notes about a story -home_feature_activity_title=It counts what you wrote, not what survived -home_feature_activity_body1=Hammer watches each save and counts the words you added, ignoring deletions and rearrangements. Spend a day cutting a chapter to pieces and it still records the writing you did, because you did do it. -home_feature_activity_body2=Daily and weekly goals, today's count, this week against last, your current and longest streak, your best day, and a twelve-week heat map. It also knows your weekday average against your weekend one, which turns out to be more useful than it sounds. -home_feature_activity_body3=Sync, and sessions merge across devices\: the four hundred words you got at a bus stop land in the same total as the ones at your desk. Hammer can even tell you which device you write best on. -home_feature_activity_body4=And the log is a plain text file in your project folder, like everything else. Your writing history is yours to read, or to delete. -home_feature_activity_alt=The Hammer project home showing total words, a weekly goal bar, this week's total against last week, and a writing streak -# After the draft -home_afterdraft_eyebrow=After the draft -home_afterdraft_title=When the book has to leave your desk -home_afterdraft_lede=Writing is only half of it. At some point a draft has to reach an editor, a beta reader, an agent, or a stranger. That is usually where a writing app stops and the copy-paste starts. -home_feature_review_title=Send it to your editor. They don't need an account. -home_feature_review_body1=Choose the scenes, type an email address, and Hammer sends a link. Your editor opens it in a browser and marks your prose up with suggested edits. No download, no signup, no copy of your project handed over. -home_feature_review_body2=You see when they opened it, how many of the scenes they have read, and how many suggestions are waiting for you. Label each request for your own benefit\: my agent, beta read 2. Links expire, and you can revoke one at any point. -home_feature_review_alt=A review request open in a browser, with the editor's suggested edits marked on the manuscript and accept or reject controls beside each one -home_feature_private_title=Show it to three people -home_feature_private_body1=Private links are password protected, can carry an expiry date, and can be revoked whenever you like. Make a separate one per reader if you want to know who is still reading. -home_feature_private_alt=The create private access dialog asking for a password and an optional expiry date, over a story's sharing panel -home_feature_publish_title=Or publish it to everyone -home_feature_publish_body1=A public story page under your pen name, an author page collecting your published work, and a place in the community feed. -home_feature_publish_body2=You can see how many people actually read it, all time and day by day. -home_feature_publish_body3=And it tells you what publishing publicly can cost you before you do it, not after. -home_feature_publish_alt=A published story showing its public sharing link and a readers panel with a total count and a daily chart -home_feature_export_title=Five formats, and it tells you which to pick -home_feature_export_markdown_name=Markdown -home_feature_export_markdown_body=For version control, or moving into another tool. -home_feature_export_epub_name=EPUB -home_feature_export_epub_body=For beta readers on e-readers and phones, where the text has to reflow. -home_feature_export_pdf_name=PDF -home_feature_export_pdf_body=For when the layout must not move. -home_feature_export_word_name=Word -home_feature_export_word_body=For anyone who will leave comments or track changes. -home_feature_export_rtf_name=RTF -home_feature_export_rtf_body=For when you don't know what they use. -home_feature_export_body1=Hammer explains each one at the moment you are choosing, instead of leaving you to guess from a row of file extensions. -home_feature_export_alt=The export dialog with a format picker and an option to treat top-level scenes as chapters -# The three rules -home_rules_eyebrow=The three rules -home_rules_title=Now the part that matters in ten years -home_rules_lede=Everything you just saw is built on three rules. Every one of those features writes to plain files in a folder you own, which is the whole reason to pick this over the alternatives. -home_rule_offline_name=Offline first -home_rule_offline_title=It works with no internet, forever -home_rule_offline_body=Hammer has never required a connection and never will. Write in a cabin, on a plane, in a van parked somewhere with no signal. Sync between your devices if you want it, through a server like this one or one you run yourself, but nothing waits on a server and nothing breaks because a service is down. -home_rule_files_name=Transparent data -home_rule_files_title=Open the folder and it makes sense -home_rule_files_body=Not a database, not a proprietary bundle. Open the project folder with Hammer shut and the shape of your book is already there\: a file per scene, named the way you named it. Search it, back it up, mail it to yourself. Reading your own work should never require this program, or a computer science degree. -home_rule_safety_name=Never lose data -home_rule_safety_title=It is paranoid about losing your work -home_rule_safety_body=Losing your work is the one bug this program is not allowed to have. Sync starts by backing up the whole project. A draft saved before a big rewrite is always there to go back to. Unsaved edits are written to disk as you type, so a crash costs you a sentence at worst. When two devices disagree, Hammer asks which one is right instead of quietly picking. -# Origin story -home_origin_eyebrow=Why it works this way -home_origin_title=I wrote these rules after losing a manuscript -home_origin_body1=I once lived in a van, traveling and writing in often very remote places. I was not working at the time, and was using free writing software that claimed to support offline work. As I came to learn, that claim didn't hold up. After about a week offline, it stopped working. When I got back to civilization, it started working again, but my manuscript was gone. -home_origin_body2=I browsed the app's community forum and saw many other authors asking the same question I was\: Where did my manuscript go? -home_origin_body3=The seeds of the idea for Hammer were born out of this experience. Why was everything a webpage these days? I just wanted to use my own computer that was right in my hands to write and store my stories. -home_origin_caption=Where the three rules came from. September 2020 -home_origin_alt=A camper van parked on red dirt at sunset, its back doors open to show a bed and storage bins, a black dog and climbing gear on the ground beside it -home_origin_link=Read the whole story -# Price -home_price_eyebrow=What it costs -home_price_amount=Nothing -home_price_body=Hammer is free and open source under the MIT license. No trial, no card, no account needed to write, and no paid tier of the app. If this server vanished tomorrow, the copy on your machine would carry on working exactly as it does today. -# FAQ -home_faq_title=Reasonable questions -home_faq_account_q=Do I need an account? -home_faq_account_a=No. Download it and write. An account only exists so you can sync between devices, and it is entirely optional. -home_faq_where_q=Where does my writing live? -home_faq_where_a=In a folder you choose on your own device, as plain text files, one per scene, alongside small readable files for your notes and encyclopedia entries. -home_faq_export_q=Can I get my manuscript out? -home_faq_export_a=It was never in. Your scenes are already ordinary files you can open, copy, or hand to anyone, whether or not Hammer is installed. When you want one file instead of a folder, export the whole book as Markdown, EPUB, PDF, Word or RTF. -home_faq_editor_q=Does my editor need to buy anything? -home_faq_editor_a=No, and they don't need an account either. You send a link, they mark up the scenes you chose in their browser, and you get the suggestions back. The link expires and you can revoke it. -home_faq_goesaway_q=What happens if Hammer goes away? -home_faq_goesaway_a=Nothing happens to your book. It is already in a format every text editor on earth can open. The source is MIT licensed, so anyone who wants to keep the app alive can. -home_faq_privacy_q=Is my writing sent anywhere? -home_faq_privacy_a=Only if you switch sync on, and then only to the sync server you chose, whether that is this one or one you run yourself. -home_faq_platforms_q=Which platforms? -home_faq_platforms_a=Windows, macOS, Linux, Android and iOS. The same app and the same files on each, not a website in a box. -# Under the hood -home_hood_eyebrow=Under the hood -home_hood_title=For the technically minded -home_hood_native_header=Native, not a web page -home_hood_native_body=Built in Kotlin with Compose Multiplatform. One codebase, genuinely native clients on five platforms, not a website wrapped in an app shell. -home_hood_screens_header=Fits the screen you have -home_hood_screens_body=Phone, tablet, laptop or a large desktop display\: the layout expands to use the space you have. Light and dark themes throughout. -home_hood_license_header=MIT licensed, self-hostable -home_hood_license_body=Read the source, build it yourself, run your own sync server. No telemetry in the app, and nothing phoning home for a license check. -home_download_header=Get your Hammer -home_download_lede=Free, and free to keep. +home_whatishammer_title=What is Hammer? +home_whatishammer_subtitle=Hammer is an open source tool for writing stories, and the worlds in which they exist. +home_feature_multiplatform_header=Plattformübergreifend +home_feature_multiplatform_body=Bring this app anywhere — on your phone, tablet, desktop, or laptop. It is not simply a website-in-a-box. It uses native client side technologies to provide the best experience possible. +home_feature_multiscreen_header=Multi-Screen +home_feature_multiscreen_body=Whether you are on a phone, tablet, or desktop, Hammer will make the best use of your screen space, expanding to give you space to work. Also supporting both light and dark modes. +home_feature_offlinefirst_header=Offline First +home_feature_offlinefirst_body=Hammer is designed from the ground up to be entirely local, no internet connection required, ever. You can sync to a server, either self-hosted or one hosted by a third party such as hammer.ink. But it's entirely optional. +home_feature_transparentdata_header=Datenhoheit +home_feature_transparentdata_body=Your data is yours. It's not stored in the cloud or some opaque database. It is stored in simple, human-readable files, just using files and folders to define the project structure. You can open your system's file browser and take a look for your self. If this program went away today, you would be able to easily interact with your data. +home_download_header=Get your Hammer\: home_download_windows=Windows home_download_linux=Linux home_download_macos=macOS @@ -131,6 +27,24 @@ home_download_link_macos=Download Hammer for macOS home_download_link_linux=Download Hammer for Linux home_download_link_android=Download Hammer for Android home_download_link_ios=Download Hammer for iOS +# Section Labels +home_section_technical=Technical Features +home_section_writing=Writing Features +# Detailed Features +home_detail_scenes_title=Write in Scenes +home_detail_scenes_body=Break your story down into smaller scenes. Take notes alongside, write a short summary to help view your story in outline mode. +home_detail_organize_title=Organize your scenes +home_detail_organize_body=Need two characters to meet earlier? Simply drag and drop your scenes to change their order. +home_detail_drafts_title=Use Drafts of your scenes +home_detail_drafts_body=Save a Draft before a major rewrite of your scene. This makes major changes to your story less scary, because you can always go back. +home_detail_timeline_title=Write out your timeline +home_detail_timeline_body=Use the timeline feature to visualize the timeline of your plot or even your world. +home_detail_encyclopedia_title=Write your Encyclopedia +home_detail_encyclopedia_body=Make entries for your characters, places, events, anything you can think of. Add pictures to capture a thousand words. +home_detail_notes_title=Notizen +home_detail_notes_body=Need to capture a quick thought about your story or world? Throw it into the notes section. +home_masthead_title=Hammer\: +home_masthead_subtitle=A simple tool for building stories home_about_callout_title=Über diesen Server home_about_callout_text=Erfahren Sie mehr über diesen Hammer-Server home_header_title=Hammer @@ -154,7 +68,6 @@ footer_about=Info footer_terms=Nutzungsbedingungen terms_page_title=Nutzungsbedingungen footer_privacy=Datenschutz -footer_studio=Darkrock Studios privacy_page_title=Datenschutzerklärung admin_signin_header=Login admin_signin_email=E-Mail\: @@ -192,7 +105,6 @@ api_accounts_login_error_notfound=Kein Konto gefunden api_accounts_login_error_badpassword=Falsches Passwort api_accounts_login_error_invalid=Ungültige E-Mail oder Passwort api_accounts_login_error_notoken=Kein gültiges Token gefunden -api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. api_accounts_tokenrefresh_error=Fehler beim Aktualisieren des Authentifizierungs-Tokens api_accounts_testauth_error=Authentifiziert als ${0,number} api_project_sync_begin_error_session=Benutzer {0} hat bereits eine Synchronisationssitzung @@ -514,25 +426,6 @@ bio_toast_cleared=Biografie gelöscht bio_validation_too_long=Die Biografie darf höchstens {0} Zeichen lang sein bio_error_no_penname=Sie müssen einen Autorennamen festlegen, bevor Sie eine Biografie hinzufügen bio_clear_confirm=Möchten Sie Ihre Biografie wirklich entfernen? -# Account Deletion -account_delete_title=Gefahrenzone -account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. -account_delete_button=Delete Account -account_delete_dialog_title=Delete Your Account -account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. -account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. -account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. -account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. -account_delete_dialog_project_count_label=Projects that will be deleted from this server\: -account_delete_confirm_label=Type your email address to confirm\: -account_delete_confirm_button=Delete My Account -account_delete_cancel=Abbrechen -account_delete_error_email_mismatch=The email address you entered does not match your account -account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. -account_delete_error_generic=Account deletion failed -admin_users_pending_deletion=Pending Deletion -admin_users_restore=Wiederherstellen -admin_users_restore_failed=Restore failed. That account no longer exists on this server. # Public Story Page public_story_by=Geschrieben von public_story_powered_by=Erstellt mit @@ -665,11 +558,6 @@ community_callout_sharing=teilen community_callout_stories=Geschichten community_callout_authors_only=Authors are ready to share their stories\! community_callout_empty=Be the first to join our writing community\! -# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". -community_badge_authors={0,choice,0\#authors|1\#author|1Test Email from Hammer Server

    If you received this email, your SMTP configuration is working correctly.

    -admin_email_test_email_text=Test Email from Hammer Server\n\nIf you received this email, your SMTP configuration is working correctly. -# Admin - User Management -admin_users_title=User Management -admin_users_subtitle=View and manage registered users -admin_users_list_title=Registered Users -admin_users_description=All users registered on this server. -admin_users_projects=Projects -admin_users_created=Created -admin_users_last_sync=Last Sync -admin_users_empty=No users registered yet. -admin_users_sort_controls=Sort Controls -admin_users_sort_by=Sort By -admin_users_sort_direction=Direction -admin_users_sort_created=Created Date -admin_users_sort_lastsync=Last Sync -admin_users_sort_projectcount=Project Count -admin_users_sort_desc=Newest First -admin_users_sort_asc=Oldest First -admin_whitelist_add_title=Add to Whitelist -admin_whitelist_date_added_unknown=Unknown -admin_whitelist_edit_button=Edit -admin_whitelist_edit_reason_title=Edit Reason -admin_whitelist_reason_label=Reason -admin_whitelist_reason_placeholder=e.g., Beta tester, Friend of the project -admin_whitelist_reason_help=Brief note about why this user was whitelisted (max 32 characters) -admin_whitelist_save_reason=Save -admin_whitelist_edit_expiry_title=Edit Expiry -admin_whitelist_expiry_label=Expires -admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. -admin_whitelist_expiry_never=Never -admin_whitelist_expiry_7days=In 7 days -admin_whitelist_expiry_30days=In 30 days -admin_whitelist_expiry_90days=In 90 days -admin_whitelist_expiry_custom=On a specific date -admin_whitelist_expiry_date_label=Expiry date -admin_whitelist_expires_prefix=Expires: -admin_whitelist_edit_expiry_button=Edit expiry -admin_whitelist_expiry_current=Current expiry: -admin_whitelist_expiry_change_label=Change to -admin_whitelist_expiry_extend_7days=Extend 7 days -admin_whitelist_expiry_extend_30days=Extend 30 days -admin_whitelist_expiry_extend_90days=Extend 90 days -admin_whitelist_save_expiry=Save -admin_whitelist_cannot_disable_patreon=Cannot disable whitelist while Patreon integration is active -admin_whitelist_sort_newest_first=Sort: Newest First -admin_whitelist_sort_oldest_first=Sort: Oldest First -admin_whitelist_account_created=Account Created -admin_whitelist_no_account=No Account -admin_dialog_cancel=Cancel -# Error Pages - Common -error_button_home=Go Home -error_button_signin=Sign In -error_button_back=Go Back -error_button_retry=Try Again -# Error Pages - 403 Unauthorized -error_unauthorized_title=Unauthorized Access -error_unauthorized_message=You don't have permission to access this page. This area is restricted to authorized users only. -error_unauthorized_note=If you believe you should have access, please contact the server administrator. -# Error Pages - 404 Not Found -error_notfound_title=Page Not Found -error_notfound_message=Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed in the first place. -error_notfound_note=If you think this is a mistake, please let us know! -# Error Pages - 500 Server Error -error_servererror_title=Server Error -error_servererror_message=Oops! Something went wrong on our end. Our server encountered an error and couldn't complete your request. -error_servererror_note=We've been notified and are working to fix the issue. Please try again in a few moments. -# Security -security_access_denied=Access denied -security_please_login=Please log in -# Story Page -story_back_to_dashboard=Back to Dashboard -story_no_scenes=This story doesn't have any scenes yet. Add scenes in the Hammer Editor app to see them here. -story_error_title=Error Loading Story -story_info_title=Story Info -story_readers_title=Readers -story_readers_total_label=total readers, all time -story_readers_graph_caption=Readers per day · last 30 days -story_info_scenes=Scenes -story_info_last_sync=Last Synced -story_settings_title=Sharing -story_publish_title=Public Access -story_publish_description=Make this story publicly accessible to anyone with the link. No login required. -story_private_share_title=Private Access -story_status_published=Published -story_status_private=Private -story_public_url=Sharing URL -story_copy_url=Copy URL -story_toast_published=Story published! Anyone with the link can now read it. -story_toast_unpublished=Story unpublished. It is now private. -# Pen Name Feature -penname_label=Pen Name: -penname_release_button=Release -penname_claim_button=Claim Your Pen Name -penname_explainer=Pen names allow you to share your work publicly. After setting one, you can share stories under that name. -penname_edit_label=Choose your pen name: -penname_edit_placeholder=e.g., Virginia Woolf -penname_set_button=Set Pen Name -penname_cancel_button=Cancel -penname_release_title=Release Your Pen Name -penname_release_warning1=Once released, your pen name will become available for others to claim. You won''t be able to get it back unless you claim it again before someone else does. -penname_release_warning2=Any links you''ve shared using this pen name will no longer work. -penname_release_current_label=Current pen name: -penname_release_confirm_label=Type your pen name to confirm: -penname_release_confirm_placeholder=Type pen name here -penname_release_confirm_button=Release Pen Name -penname_toast_saved=Pen name saved! -penname_toast_released=Pen name released -penname_validation_valid=Pen name is valid -penname_validation_too_short=Pen name must be at least {0} characters -penname_validation_too_long=Pen name must be at most {0} characters -penname_validation_invalid_chars=Pen name can only contain letters, numbers, spaces, hyphens, and underscores -penname_validation_taken=Pen name is already taken -penname_validation_available=Pen name is available -penname_checking_availability=Checking availability... -penname_check_failed=Could not check availability -penname_release_confirm_message=Are you sure you want to release "{0}"? This action cannot be undone. -# Bio -bio_label=Bio: -bio_add_button=Add Bio -bio_edit_button=Edit -bio_clear_button=Clear -bio_edit_label=Your author biography: -bio_edit_placeholder=Write a brief biography in Markdown. You can use **bold**, *italic*, [links](url), lists, and more. -bio_markdown_help=Markdown formatting supported: **bold**, *italic*, [links](url), headings, lists, etc. -bio_save_button=Save Bio -bio_cancel_button=Cancel -bio_toast_saved=Bio saved! -bio_toast_cleared=Bio cleared -bio_validation_too_long=Bio must be at most {0} characters -bio_error_no_penname=You must set a pen name before adding a bio -bio_clear_confirm=Are you sure you want to remove your bio? -# Public Story Page -public_story_by=A story by -public_story_powered_by=Written with -public_story_meta_description={0} by {1}. Read the full story on Hammer. -# OpenGraph share cards -og_attribution=Written with Hammer -og_author_subtitle=An author on {0} -# Story Access Management -story_share_private=Share with Password -story_share_private_title=Create Private Access -story_access_password=Password -story_access_password_placeholder=Enter a password -story_access_expires=Expiration Date -story_access_optional=optional -story_access_no_expiry=No expiration -story_access_expired=Expired -story_access_list_title=Private Access Links -story_access_passworded=Password Protected -story_access_delete_confirm=Are you sure you want to remove this access? -story_access_delete=Delete access -story_access_show_password=Show password -story_create_access=Create Access -story_cancel=Cancel -story_toast_access_created=Private access created successfully -story_toast_access_deleted=Access removed -# Public Story Password -public_story_password_required=Password Required -public_story_password_description=This story requires a password to view. -public_story_password_label=Password -public_story_password_placeholder=Enter password -public_story_password_submit=View Story -public_story_password_incorrect=Incorrect password. Please try again. -# Publish Warning Dialog -story_publish_warning_title=Publishing Publicly -story_publish_warning_content=Publishing the story here publicly, however briefly, could jeopardize your ability to sell it later. Many publishers demand "First Publication Rights", meaning it has never been publicly available anywhere before. -story_publish_warning_recommendation=If you intend to submit this project for publication, it is recommended to use Private Access sharing instead. -story_publish_publicly=Publish Publicly -# Author Page -author_published_works=Published Works -author_read_story=Read story -author_story_count=published stories -author_no_stories_title=No Published Stories Yet -author_no_stories_subtitle=This author hasn't published any stories yet. Check back later! -author_story_published_on=Published on -# Public Story Pagination -public_story_words=words -public_story_min_read=min read -public_story_prev_page=Previous -public_story_next_page=Next -story_info_words=Words -# Scene Selector -story_scene_selector_label=Scene -story_view_all=View All -# About Page -about_page_title=About This Server -# Password Reset -password_reset_forgot_title=Forgot Your Password? -password_reset_forgot_subtitle=Enter your email address and we'll send you a link to reset your password. -password_reset_forgot_link=Forgot your password? -password_reset_enter_email_help=Enter the email address associated with your account -password_reset_send_link=Send Reset Link -password_reset_email_sent=Reset Link Sent! -password_reset_check_email=If an account exists for -password_reset_link_expires=The reset link will expire in 15 minutes. -password_reset_back_to_login=Back to Login -password_reset_title=Reset Your Password -password_reset_subtitle=Enter your new password below -password_reset_new_password=New Password -password_reset_confirm_password=Confirm Password -password_reset_requirements=Password must be 8-64 characters long -password_reset_submit=Reset Password -password_reset_success_title=Password Reset Successful -password_reset_success_subtitle=Your password has been changed -password_reset_success_message=You can now log in with your new password. All other sessions have been logged out for security. -password_reset_go_to_login=Go to Login -password_reset_error_title=Reset Link Invalid -password_reset_error_invalid_token=This password reset link is invalid or has already been used. -password_reset_error_expired=This password reset link has expired. Please request a new one. -password_reset_error_already_used=This password reset link has already been used. Please request a new one if needed. -password_reset_request_new=Request New Reset Link -password_reset_error_email_required=Please enter your email address -password_reset_error_fields_required=Please fill in all fields -password_reset_error_mismatch=Passwords do not match -password_reset_error_generic=Failed to reset password. Please try again. -# Password Reset Email Template -password_reset_email_title=Reset Your Hammer Password -password_reset_email_intro=You requested to reset your password for your Hammer account. -password_reset_email_cta=Click the button below to reset your password: -password_reset_email_button=Reset Password -password_reset_email_or_copy=Or copy and paste this link into your browser: -password_reset_email_expires=This link will expire in 15 minutes. -password_reset_email_ignore=If you didn't request a password reset, you can safely ignore this email. -password_reset_email_footer=This is an automated message from Hammer Server. Please do not reply to this email. -# Community -community_label=Community: -community_promo_title=Join this server's community! -community_promo_text=Share your stories and discover other's work. -community_toggle_label=Join Community -community_status_joined=Community Member -community_status_not_joined=Not a member -community_explanation=Joining the community means your pen name will be listed on the authors page of this server, and any stories that you publish publicly will be discoverable by the rest of the community. This is entirely optional, and if you do join, will still only list stories that you explicitly publish publicly. -community_toast_joined=Welcome to the community! -community_toast_left=You have left the community -community_error_no_penname=You must set a pen name before joining the community -community_error_disabled=Community features are not enabled on this server -# Authors Page -authors_page_title=Community Authors -authors_page_subtitle=Discover writers sharing their stories on this server -authors_count_label=authors -authors_view_profile=View Profile -authors_no_bio=No bio yet -authors_empty_title=No Authors Yet -authors_empty_text=Be the first to join the community! Set a pen name and opt in to appear here. -# Feed Page -feed_page_title=Story Feed -feed_page_subtitle=The latest stories from our community of writers -feed_count_label=stories -feed_latest_stories=Latest Publications -feed_story_by=by -feed_read_story=Read Story -feed_empty_title=No Stories Yet -feed_empty_text=When community members publish their stories, they'll appear here. Check back soon! -# Community Navigation -community_nav_authors=Authors -community_nav_feed=Story Feed -# Community Callout -community_callout_title=Explore Our Community -community_callout_authors=authors -community_callout_sharing=sharing -community_callout_stories=stories -community_callout_authors_only=Authors are ready to share their stories! -community_callout_empty=Be the first to join our writing community! -# Setup Mode -setup_icon=\uD83D\uDEE0\uFE0F -setup_title=Setup Mode -setup_message=Welcome! This server needs to be configured before use. -setup_step1=Open the Hammer app on your device -setup_step2=Go to Settings and enter this server address: -setup_step3=Create your account to complete setup -setup_admin_note=The first account created will become the administrator -setup_return_note=After creating your account, refresh this page to continue. -# Monitoring dashboard -admin_nav_monitoring=Monitoring -admin_mon_nav_overview=Overview -admin_mon_nav_performance=Performance -admin_mon_overview_title=Monitoring -admin_mon_overview_subtitle=How your server is being used, at a glance -admin_mon_disabled_notice=Monitoring collection is currently disabled. Enable it in Server Settings to start gathering data. -admin_mon_alerts_title=Alerts -admin_mon_alerts_nominal=All systems nominal — no alerts right now. -admin_mon_alerts_investigate=Investigate -admin_mon_stat_requests=Requests -admin_mon_errors=Errors -admin_mon_stat_error_rate=Error rate -admin_mon_stat_p95=p95 latency -admin_mon_stat_active_syncs=Active syncs -admin_mon_window_24h=last 24h -admin_mon_window_7d=last 7d -admin_mon_window_30d=last 30d -admin_mon_live=live -admin_mon_active_users_title=Active users -admin_mon_active_users_sync=Syncing -admin_mon_active_users_web=Web -admin_mon_active_users_trend=Daily active users (last 30d) -admin_mon_readers_title=Story readers -admin_mon_readers_subtitle=Best-effort unique readers of published & shared stories. -admin_mon_readers_unique=Unique -admin_mon_readers_trend=Daily readers (last 30d) -admin_mon_traffic_title=API Traffic (last 30d) -admin_mon_slowest_title=Slowest endpoints -admin_mon_view_all=View all -admin_mon_no_data=No data collected yet. -admin_mon_perf_title=Performance -admin_mon_perf_subtitle=Per-endpoint request volume, errors, and latency -admin_mon_perf_endpoints=Endpoints -admin_mon_perf_error_rate_title=Error Rate -admin_mon_perf_error_rate_label=Error Rate % -admin_mon_perf_latency_title=p95 Latency -admin_mon_perf_latency_label=p95 Latency -admin_mon_range_24h=24h -admin_mon_range_7d=7d -admin_mon_range_30d=30d -admin_mon_col_endpoint=Endpoint -admin_mon_col_requests=Requests -admin_mon_col_errors=Error rate -admin_mon_col_p50=p50 -admin_mon_col_p95=p95 -admin_mon_col_p99=p99 -admin_mon_settings_title=Monitoring -admin_mon_settings_help=Control in-app server monitoring. Collection runs in the background; data is rolled up and purged per the retention windows below. -admin_mon_settings_enabled=Enable monitoring (master switch) -admin_mon_settings_track_api=Track API usage & performance -admin_mon_settings_track_errors=Track errors -admin_mon_settings_track_logins=Track login attempts -admin_mon_settings_track_readers=Count unique story readers (cookieless) -admin_mon_settings_store_ip=Store source IP with login attempts -admin_mon_settings_metrics_retention=Metrics retention (days) -admin_mon_settings_error_retention=Error retention (days) -admin_mon_settings_login_retention=Login attempt retention (days) -admin_mon_settings_alert_email_enabled=Email the admin on persistent errors -admin_mon_settings_alert_email=Alert email address -admin_mon_nav_errors=Errors -admin_mon_errors_title=Errors -admin_mon_errors_subtitle=Grouped server-side errors and their stack traces -admin_mon_errors_grouped=Recent errors -admin_mon_errors_none=No errors recorded. Nice and quiet. -admin_mon_errors_filtered_by=Filtered by endpoint: -admin_mon_errors_filter_clear=Clear filter -admin_mon_errors_export=Export -admin_mon_errors_copy_stack=Copy -admin_mon_errors_copied=Copied -admin_mon_errors_ignore_type=Ignore this type -admin_mon_errors_ignore_route=Ignore only on this route -admin_mon_errors_ignored_section=Ignored errors -admin_mon_errors_ignored_help=Errors matching these rules are still recorded here, but never emailed. -admin_mon_errors_rules_label=Rules -admin_mon_errors_rule_remove=Stop ignoring -admin_mon_errors_rule_add=Add rule -admin_mon_errors_rule_type_placeholder=Exception type -admin_mon_errors_rule_route_placeholder=Route pattern, e.g. /api/* (optional) -admin_mon_errors_ignored_truncated=Only the most recent ignored groups are shown here; the export includes everything. -admin_mon_nav_security=Security -admin_mon_security_title=Security -admin_mon_security_subtitle=Login attempts and brute-force signals -admin_mon_security_top_failures=Top failed logins (last 24h) -admin_mon_security_no_failures=No failed logins in the last 24h. -admin_mon_security_recent=Recent login attempts -admin_mon_security_no_attempts=No login attempts recorded yet. -admin_mon_security_col_result=Result -admin_mon_security_col_email=Email -admin_mon_security_col_ip=IP -admin_mon_security_col_time=Time -admin_mon_security_ok=OK -admin_mon_security_fail=FAIL -admin_mon_nav_jobs=Jobs -admin_mon_jobs_title=Background Jobs -admin_mon_jobs_subtitle=Status of recurring server tasks -admin_mon_jobs_section=Recurring tasks -admin_mon_jobs_col_name=Task -admin_mon_jobs_col_status=Status -admin_mon_jobs_col_last_run=Last run -admin_mon_jobs_col_next_run=Next run -admin_mon_jobs_alive=Alive -admin_mon_jobs_stopped=Stopped -admin_mon_jobs_failing=Last run failed -admin_mon_jobs_error=Error -admin_mon_jobs_none=No background jobs are registered. -admin_mon_jobs_health_ok=All background jobs are healthy. -admin_mon_jobs_health_warn=A job reported an error on its most recent run. -admin_mon_nav_logs=Logs -admin_mon_logs_title=Logs -admin_mon_logs_subtitle=Recent server log output (live) -admin_mon_logs_level=Level -admin_mon_logs_all=All -admin_mon_logs_search=Search -admin_mon_logs_search_placeholder=Filter by text or logger -admin_mon_logs_autorefresh=Auto-refresh -admin_mon_logs_export=Export -admin_mon_logs_empty=No log lines match. -api_review_create_error_no_scenes=No scenes were selected for review -api_review_create_error_invalid_scene=One of the selected scenes is invalid -api_review_create_error_invalid_email=The editor's email address is invalid -api_review_create_error_too_long=The label or note is too long -api_review_create_error_project_not_found=Project not found -api_review_create_error_draft_failed=Failed to snapshot scenes for review -api_review_create_error_sync_busy=A sync is in progress, try again in a moment -api_review_token_error_invalid=This review link is not valid -api_review_token_error_expired=This review link has expired -api_review_token_error_revoked=This review request was revoked by the author -api_review_error_not_found=Review not found -api_review_revoke_error_submitted=A submitted review can no longer be revoked -review_panel_title=Editorial Reviews -review_panel_description=Invite an editor by email to mark up your scenes with suggested edits. No account needed. -review_panel_empty=No review requests yet. -review_panel_request_button=Request a Review -review_panel_revoke=Revoke -review_panel_revoke_confirm=Revoke this review request? The link will stop working. -review_status_sent=Sent -review_status_opened=Opened -review_status_in_progress=In Progress -review_status_submitted=Submitted -review_status_resolved=Resolved -review_status_canceled=Revoked -review_status_expired=Expired -review_meta_scenes={0} scenes -review_meta_one_scene=1 scene -review_meta_sent=sent {0} -review_meta_opened=opened {0} -review_meta_not_opened=not opened yet -review_meta_expires=expires {0} -review_meta_submitted=submitted {0} -review_meta_suggestions={0} suggestions -review_meta_progress={0} of {1} scenes read -review_dialog_title=Request a Review -review_dialog_email=Editor's email -review_dialog_email_placeholder=them@example.com -review_dialog_label=Label -review_dialog_label_hint=(just for you) -review_dialog_label_placeholder=e.g. My agent -review_dialog_note=Note to your editor -review_dialog_note_hint=(optional) -review_dialog_note_placeholder=Anything they should know before reading? -review_dialog_expiry=Link expires -review_dialog_expiry_30=30 days -review_dialog_expiry_60=60 days -review_dialog_expiry_90=90 days -review_dialog_expiry_never=Never -review_dialog_scenes=Scenes to review -review_dialog_scenes_none=none selected -review_dialog_scenes_count={0} of {1} selected -review_dialog_all_scenes=All scenes -review_dialog_clear_all=Clear all -review_dialog_select_all=Select all -review_dialog_clear=Clear -review_dialog_cancel=Cancel -review_dialog_send=Send Request -review_toast_created=Review request emailed to {0}. -review_toast_created_no_email=Review request created. Share the link with your editor. -review_toast_email_failed=Review request created, but the email failed to send. Share the link manually. -review_toast_revoked=Review request for "{0}" revoked. The link no longer works. -review_link_dialog_title=Share this review link -review_link_dialog_body=Email isn't configured on this server, so send your editor this link yourself. For security it won't be shown again. -review_link_dialog_copy=Copy Link -review_link_dialog_copied=Copied! -review_link_dialog_done=Done -review_page_overline=Editorial Review -review_page_by=by -review_page_expires=Link expires {0} -review_page_note_title=A note from the author -review_page_scene_position=Scene {0} of {1} -review_page_error_title=This link isn't working -review_error_the_author=the author -review_error_invalid_title=This link isn't working -review_error_invalid_body=Check that the whole link made it here from your email — these links are long and easy to cut short. -review_error_expired_title=This review link has expired -review_error_expired_body=For the author''s security, review links only last so long. If you''d still like to mark up "{1}", ask {0} to send a fresh request. -review_error_revoked_title=The author closed this review -review_error_revoked_body={0} has withdrawn the review request for "{1}". If that seems like a mistake, reach out to them directly. -review_invite_subject={0} would like your editorial eye on "{1}" -review_invite_title=You've been asked to edit a manuscript -review_invite_intro={0} has invited you to read and mark up scenes from their story "{1}" on Hammer. -review_invite_explain=No account is needed — the link below opens a private review page where you can suggest edits and leave comments, right in the text. -review_invite_button=Open the Manuscript -review_invite_or_copy=Or copy this link into your browser: -review_invite_expires=This link expires on {0}. -review_invite_no_expiry=This link does not expire. -review_invite_ignore=Not expecting this? You can safely ignore this email. -review_invite_footer=Sent by Hammer, an open-source app for writers. -api_review_token_error_submitted=This review has already been submitted -api_review_suggestion_error_invalid=That suggestion could not be saved -api_review_suggestion_error_overlap=That selection overlaps an existing suggestion -api_review_suggestion_error_too_long=That suggestion is too long to save -api_review_commit_error_not_submitted=This review hasn't been submitted yet -api_review_commit_error_resolved=This review has already been resolved -api_review_commit_error_draft_failed=Failed to save the revised drafts -review_submit=Submit Revisions -review_locked_banner=This review has been submitted and is now locked. The author has been notified of your suggestions. -review_hint=Select a passage to suggest an edit — or click anywhere in the text to insert at that spot. -review_scenes_title=Scenes in this request -review_suggestions_title=Suggestions -review_no_suggestions=No suggestions in this scene yet. Select some text to begin. -review_action_reword=Reword -review_action_delete=Delete -review_action_insert=Insert after -review_action_insert_here=Insert here -review_action_comment=Comment -review_action_save=Save -review_action_remove=Remove suggestion -review_action_replacement_label=Suggested rewording -review_action_insert_label=Text to insert -review_action_comment_label=Comment -review_action_reason_placeholder=Reason (optional) -review_action_comment_placeholder=What should the author know? -review_action_type_placeholder=Type the new text… -review_save_failed=Could not save that suggestion. Please try again. -review_action_remove_confirm=Remove this suggestion? This can't be undone. -review_action_reason_label=Reason -review_action_edit=Edit suggestion -review_submit_confirm_title=Submit your revisions? -review_submit_confirm_body=You're about to send {0} to {1}. After submitting, the manuscript locks and you won't be able to add or change your suggestions. -review_submit_tally_one=1 suggestion -review_submit_tally_many={0} suggestions -review_submit_confirm_action=Submit & Lock -review_done_mark=Mark scene as done -review_done_marked=Done — tap to unmark -review_welcome_title=A manuscript awaits your pen -review_welcome_intro={0} has shared {1} for your editorial pass. -review_welcome_how=Select any passage to suggest a rewording, a cut, or a comment — or click between words to insert something new. Your marks save as you go. -review_welcome_submit_note=When you're done with every scene, click "{0}" to send your edits back. Until then, the author can't see your work and you can change anything. -review_welcome_action=Start Editing -review_author_suggestions_from=Suggestions from {0} -review_author_submitted_on=Submitted {0} -review_author_resolved_banner=This review was resolved on {0}. The suggestions below are read-only. -review_author_hint=Accept or reject each suggestion — the manuscript shows the result as you go. When you're done, apply the revisions. -review_commit=Apply Revisions -review_accept=Accept -review_reject=Reject -review_resolve=Resolve -review_undo=Undo -review_accept_all=Accept all -review_reject_all=Reject all -review_chip_accepted=Accepted -review_chip_rejected=Rejected -review_chip_resolved=Resolved -review_commit_confirm_title=Apply your revisions? -review_commit_confirm_body=Accepted changes are saved as a new draft for each scene. Scenes you haven't touched since sending the request are updated to the revised text. This resolves the review and can't be undone. -review_commit_tally_accepted={0} accepted -review_commit_tally_rejected={0} rejected -review_commit_tally_pending={0} undecided -review_commit_pending_warning=Undecided suggestions won't be applied. -review_commit_confirm_action=Apply & Resolve -review_commit_failed=Could not apply the revisions. Please try again. -review_commit_result_title=Revisions applied -review_commit_result_draft=Saved as draft '{0}' -review_commit_outcome_applied=Working copy updated -review_commit_outcome_diverged=Changed since the review was sent — merge the new draft with Draft Compare in the app -review_commit_outcome_unchanged=No accepted changes -review_commit_outcome_scene_missing=This scene no longer exists in the project -review_commit_done=Done -review_panel_open_review=Review suggestions -review_panel_view_review=View suggestions -review_submitted_subject={0} has sent back their edits on {1} -review_submitted_title=Your manuscript is back -review_submitted_intro={0} has finished editing {1} and sent back {2}. -review_submitted_explain=Open the review to read their suggestions, accept or reject each one, and apply the revisions as a new draft. -review_submitted_button=See the Suggestions -api_ideas_error_ideaidmissing=Missing idea id -api_ideas_error_notfound=Idea not found -api_ideas_error_deleted=Idea has been deleted -api_ideas_error_toolarge=Idea is too large +language=English +page_title=Hammer +home_servermessage=Server notice +home_servermessage_whitelist=This server requires you to get permission before creating an account. Please contact {0} to get on the whitelist. +home_meta_description=Hammer is an open source tool for writing stories, and the worlds in which they exist. +home_meta_tagline=Own your book, build your world +# Masthead +home_masthead_title_first=Own your book, +home_masthead_title_second=build your world +home_masthead_subtitle=Free and open source. Works offline, on every device you own, and every chapter stays a plain text file on your own disk. +home_hero_download=Download free +home_hero_platforms=Windows, macOS, Linux, Android and iOS. Free and open source. +# Writing features +home_features_eyebrow=Writing in Hammer +home_features_title=Built for the way a novel actually gets written +home_features_lede=Scenes you can move, an encyclopedia that knows who is in them, a timeline, notes, and a record of what you actually wrote. +home_feature_editor_title=An editor that stays out of the way +home_feature_editor_body1=Your scene, the outline you wrote for it, and your notes about it, all on one screen. Word count and metadata sit to the side when you want them and disappear when you don't. +home_feature_editor_body2=Break the book into scenes as small as you like, then drag them into a different order when two characters need to meet earlier. +home_feature_editor_alt=The Hammer editor showing a scene, with the scene list on the left and outline, notes and references on the right +home_feature_encyclopedia_title=An encyclopedia that looks like your world +home_feature_encyclopedia_body1=Characters, places, events and anything else you invent, with pictures. Tag them, search them, keep them a keystroke from the page you are on. +home_feature_encyclopedia_body2=Hammer reads your scenes and works out who is in them. Open a character and it lists every scene they appear in; open a scene and it shows the characters and locations it found, which you confirm or dismiss. Nobody has to remember to tag anything. +home_feature_encyclopedia_body3=Which means you can answer the question every draft eventually raises: where did she go? Three chapters without your antagonist show up as an absence you can see. +home_feature_encyclopedia_alt=The Hammer encyclopedia showing illustrated entries for characters, places and events +home_feature_timeline_title=See the plot laid out in order +home_feature_timeline_body1=Put the events of your story, or the history of your world, on a dated and tagged timeline, so you can see that the potion comes before the trial, and that three chapters pass without your antagonist. +home_feature_timeline_alt=The Hammer timeline listing dated, tagged story events +home_feature_notes_title=Somewhere to put the thought you just had +home_feature_notes_body1=The observation about your own book that arrives at the wrong moment. Tag it, find it later, keep it next to the manuscript instead of in a file on the desktop called notes2-final.txt. +home_feature_notes_alt=The Hammer notes view with tagged notes about a story +home_feature_activity_title=It counts what you wrote, not what survived +home_feature_activity_body1=Hammer watches each save and counts the words you added, ignoring deletions and rearrangements. Spend a day cutting a chapter to pieces and it still records the writing you did, because you did do it. +home_feature_activity_body2=Daily and weekly goals, today's count, this week against last, your current and longest streak, your best day, and a twelve-week heat map. It also knows your weekday average against your weekend one, which turns out to be more useful than it sounds. +home_feature_activity_body3=Sync, and sessions merge across devices: the four hundred words you got at a bus stop land in the same total as the ones at your desk. Hammer can even tell you which device you write best on. +home_feature_activity_body4=And the log is a plain text file in your project folder, like everything else. Your writing history is yours to read, or to delete. +home_feature_activity_alt=The Hammer project home showing total words, a weekly goal bar, this week's total against last week, and a writing streak +# After the draft +home_afterdraft_eyebrow=After the draft +home_afterdraft_title=When the book has to leave your desk +home_afterdraft_lede=Writing is only half of it. At some point a draft has to reach an editor, a beta reader, an agent, or a stranger. That is usually where a writing app stops and the copy-paste starts. +home_feature_review_title=Send it to your editor. They don't need an account. +home_feature_review_body1=Choose the scenes, type an email address, and Hammer sends a link. Your editor opens it in a browser and marks your prose up with suggested edits. No download, no signup, no copy of your project handed over. +home_feature_review_body2=You see when they opened it, how many of the scenes they have read, and how many suggestions are waiting for you. Label each request for your own benefit: my agent, beta read 2. Links expire, and you can revoke one at any point. +home_feature_review_alt=A review request open in a browser, with the editor's suggested edits marked on the manuscript and accept or reject controls beside each one +home_feature_private_title=Show it to three people +home_feature_private_body1=Private links are password protected, can carry an expiry date, and can be revoked whenever you like. Make a separate one per reader if you want to know who is still reading. +home_feature_private_alt=The create private access dialog asking for a password and an optional expiry date, over a story's sharing panel +home_feature_publish_title=Or publish it to everyone +home_feature_publish_body1=A public story page under your pen name, an author page collecting your published work, and a place in the community feed. +home_feature_publish_body2=You can see how many people actually read it, all time and day by day. +home_feature_publish_body3=And it tells you what publishing publicly can cost you before you do it, not after. +home_feature_publish_alt=A published story showing its public sharing link and a readers panel with a total count and a daily chart +home_feature_export_title=Five formats, and it tells you which to pick +home_feature_export_markdown_name=Markdown +home_feature_export_markdown_body=For version control, or moving into another tool. +home_feature_export_epub_name=EPUB +home_feature_export_epub_body=For beta readers on e-readers and phones, where the text has to reflow. +home_feature_export_pdf_name=PDF +home_feature_export_pdf_body=For when the layout must not move. +home_feature_export_word_name=Word +home_feature_export_word_body=For anyone who will leave comments or track changes. +home_feature_export_rtf_name=RTF +home_feature_export_rtf_body=For when you don't know what they use. +home_feature_export_body1=Hammer explains each one at the moment you are choosing, instead of leaving you to guess from a row of file extensions. +home_feature_export_alt=The export dialog with a format picker and an option to treat top-level scenes as chapters +# The three rules +home_rules_eyebrow=The three rules +home_rules_title=Now the part that matters in ten years +home_rules_lede=Everything you just saw is built on three rules. Every one of those features writes to plain files in a folder you own, which is the whole reason to pick this over the alternatives. +home_rule_offline_name=Offline first +home_rule_offline_title=It works with no internet, forever +home_rule_offline_body=Hammer has never required a connection and never will. Write in a cabin, on a plane, in a van parked somewhere with no signal. Sync between your devices if you want it, through a server like this one or one you run yourself, but nothing waits on a server and nothing breaks because a service is down. +home_rule_files_name=Transparent data +home_rule_files_title=Open the folder and it makes sense +home_rule_files_body=Not a database, not a proprietary bundle. Open the project folder with Hammer shut and the shape of your book is already there: a file per scene, named the way you named it. Search it, back it up, mail it to yourself. Reading your own work should never require this program, or a computer science degree. +home_rule_safety_name=Never lose data +home_rule_safety_title=It is paranoid about losing your work +home_rule_safety_body=Losing your work is the one bug this program is not allowed to have. Sync starts by backing up the whole project. A draft saved before a big rewrite is always there to go back to. Unsaved edits are written to disk as you type, so a crash costs you a sentence at worst. When two devices disagree, Hammer asks which one is right instead of quietly picking. +# Origin story +home_origin_eyebrow=Why it works this way +home_origin_title=I wrote these rules after losing a manuscript +home_origin_body1=I once lived in a van, traveling and writing in often very remote places. I was not working at the time, and was using free writing software that claimed to support offline work. As I came to learn, that claim didn't hold up. After about a week offline, it stopped working. When I got back to civilization, it started working again, but my manuscript was gone. +home_origin_body2=I browsed the app's community forum and saw many other authors asking the same question I was: Where did my manuscript go? +home_origin_body3=The seeds of the idea for Hammer were born out of this experience. Why was everything a webpage these days? I just wanted to use my own computer that was right in my hands to write and store my stories. +home_origin_caption=Where the three rules came from. September 2020 +home_origin_alt=A camper van parked on red dirt at sunset, its back doors open to show a bed and storage bins, a black dog and climbing gear on the ground beside it +home_origin_link=Read the whole story +# Price +home_price_eyebrow=What it costs +home_price_amount=Nothing +home_price_body=Hammer is free and open source under the MIT license. No trial, no card, no account needed to write, and no paid tier of the app. If this server vanished tomorrow, the copy on your machine would carry on working exactly as it does today. +# FAQ +home_faq_title=Reasonable questions +home_faq_account_q=Do I need an account? +home_faq_account_a=No. Download it and write. An account only exists so you can sync between devices, and it is entirely optional. +home_faq_where_q=Where does my writing live? +home_faq_where_a=In a folder you choose on your own device, as plain text files, one per scene, alongside small readable files for your notes and encyclopedia entries. +home_faq_export_q=Can I get my manuscript out? +home_faq_export_a=It was never in. Your scenes are already ordinary files you can open, copy, or hand to anyone, whether or not Hammer is installed. When you want one file instead of a folder, export the whole book as Markdown, EPUB, PDF, Word or RTF. +home_faq_editor_q=Does my editor need to buy anything? +home_faq_editor_a=No, and they don't need an account either. You send a link, they mark up the scenes you chose in their browser, and you get the suggestions back. The link expires and you can revoke it. +home_faq_goesaway_q=What happens if Hammer goes away? +home_faq_goesaway_a=Nothing happens to your book. It is already in a format every text editor on earth can open. The source is MIT licensed, so anyone who wants to keep the app alive can. +home_faq_privacy_q=Is my writing sent anywhere? +home_faq_privacy_a=Only if you switch sync on, and then only to the sync server you chose, whether that is this one or one you run yourself. +home_faq_platforms_q=Which platforms? +home_faq_platforms_a=Windows, macOS, Linux, Android and iOS. The same app and the same files on each, not a website in a box. +# Under the hood +home_hood_eyebrow=Under the hood +home_hood_title=For the technically minded +home_hood_native_header=Native, not a web page +home_hood_native_body=Built in Kotlin with Compose Multiplatform. One codebase, genuinely native clients on five platforms, not a website wrapped in an app shell. +home_hood_screens_header=Fits the screen you have +home_hood_screens_body=Phone, tablet, laptop or a large desktop display: the layout expands to use the space you have. Light and dark themes throughout. +home_hood_license_header=MIT licensed, self-hostable +home_hood_license_body=Read the source, build it yourself, run your own sync server. No telemetry in the app, and nothing phoning home for a license check. +home_download_header=Get your Hammer +home_download_lede=Free, and free to keep. +home_download_windows=Windows +home_download_linux=Linux +home_download_macos=macOS +home_download_android=Android +home_download_link=Download +home_download_format_windows=Windows download format +home_download_format_macos=macOS download format +home_download_format_linux=Linux download format +home_download_format_android=Android download format +home_download_link_windows=Download Hammer for Windows +home_download_link_macos=Download Hammer for macOS +home_download_link_linux=Download Hammer for Linux +home_download_link_android=Download Hammer for Android +home_download_link_ios=Download Hammer for iOS +home_about_callout_title=About This Server +home_about_callout_text=Learn more about this Hammer server +home_header_title=Hammer +home_header_adminbutton=Admin +home_header_loginbutton=Login +home_header_dashboardbutton=Dashboard +# Header Navigation +header_nav_toggle=Toggle navigation menu +header_nav_about=About +header_nav_community=Community +header_nav_dashboard=Dashboard +header_nav_login=Login +home_footer_github=GitHub +home_footer_discord=Discord +home_footer_reddit=Reddit +home_footer_youtube=YouTube +home_footer_patreon=Patreon +home_footer_mastodon=Mastodon +home_footer_bluesky=Bluesky +footer_about=About +footer_terms=Terms +terms_page_title=Terms of Service +footer_privacy=Privacy +footer_studio=Darkrock Studios +privacy_page_title=Privacy Policy +admin_signin_header=Log in +admin_signin_email=E-mail: +admin_signin_password=Password: +admin_signin_loginbutton=Log In +admin_signin_loginfailure=Could not log in +admin_header=Admin +admin_whitelist_checkbox=Enforce Whitelist +admin_whitelist_title=Whitelist: +admin_whitelist_colheader_email=E-mail address +admin_whitelist_colheader_remove=Remove +admin_addtowhitelist_header=Add to whitelist: +admin_addtowhitelist_hint_email=E-mail address +admin_addtowhitelist_addbutton=Add +unauthorized=Unauthorized Access +api_success=Success +api_error_unknown=Unknown failure +api_whitelist_rejected=You are not on the server's whitelist +api_admin_whitelist_error_emailmissing=E-mail address missing +api_admin_whitelist_error_expiresinvalid=Invalid expiry; use an ISO-8601 instant in the future +api_admin_addtowhitelist_invalidemail=Invalid e-mail address +api_admin_addtowhitelist_invalidexpiry=Expiry must be a valid instant in the future +api_admin_enablewhitelist_enablemissing=Set Enable was missing +api_accounts_create_error_notonwhitelist=E-mail not on whitelist +api_accounts_create_error_accountexists=Account already exists +api_accounts_create_error_invalidemail=Invalid e-mail address +api_accounts_create_error_password_tooshort=Password too short +api_accounts_create_error_password_toolong=Password too long +api_accounts_create_error_password_nouppercase=Password must contain at least one uppercase letter +api_accounts_create_error_password_nolowercase=Password must contain at least one lowercase letter +api_accounts_create_error_password_nonumber=Password must contain at least one number +api_accounts_create_error_password_nospecial=Password must contain at least one special character +api_accounts_create_error_password_generic=Invalid password +api_accounts_login_error_notfound=Account not found +api_accounts_login_error_badpassword=Incorrect Password +api_accounts_login_error_invalid=Invalid email or password +api_accounts_login_error_notoken=No valid token found +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. +api_accounts_tokenrefresh_error=Failed to refresh auth token +api_accounts_testauth_error=Authenticated as ${0,number} +api_project_sync_begin_error_session=User {0} already has a synchronization session +api_project_sync_error_projectnamemissing=Project name missing +api_project_sync_error_projectidmissing=Project ID missing +api_project_sync_error_syncidmissing=Missing sync ID +api_project_sync_end_noid=User {0} does not have a synchronization session +api_project_sync_end_invalidid=Invalid sync ID +api_project_getproject_error_notfound=Project does not exist +api_project_saveentity_error_typeconflict=Entity type mismatch. Server has an entity with this ID but different type. +api_project_error_entityidmissing=Entity ID missing +api_project_error_entitytypemissing=Entity Type missing +api_project_rename_error_invalidname=Invalid project name +api_project_writingactivity_error_deviceidmissing=Device ID missing +# Login Page +login_title=Author Login +login_subtitle=Enter your credentials to access the Hammer web portal +login_email_label=Email +login_email_placeholder=admin@example.com +login_password_label=Password +login_password_placeholder=•••••••• +login_signin_button=Sign In +login_info_create_account=Don't have an account? Create one from within the Hammer Editor app on your device. +login_info_whitelist_notice=This server requires you to be on the whitelist before you can create an account. +login_info_whitelist_contact=To request access, please contact: +# User Dashboard +dashboard_title=My Dashboard +dashboard_subtitle=Welcome back, +dashboard_manage_server=Manage Server +dashboard_projects_title=Your Stories +dashboard_projects_description=Stories synced to this server from your Hammer Editor clients. +dashboard_last_sync=Last synced +dashboard_no_projects=You haven't synced any stories to this server yet. Open Hammer Editor on your device and configure server synchronization to see your projects here. +dashboard_account_title=Account +dashboard_account_email=Email +dashboard_account_created=Created +dashboard_author_page_link=View Author Page +# Admin Dashboard +admin_dashboard_title=Admin Dashboard +admin_dashboard_subtitle=Manage server settings and user access +admin_logout_button=Log Out +admin_servermessage_title=Server Message +admin_servermessage_description=This message is shown to users on the home page. +admin_serversettings_title=Server Settings +admin_serversettings_description=Configure general server settings. +admin_serversettings_contact_label=Contact Email +admin_serversettings_contact_placeholder=admin@example.com +admin_serversettings_default_locale_label=Default Language +admin_serversettings_message_label=Server Message +admin_serversettings_message_help=Post a notification to users on the front page +admin_serversettings_about_label=Server About Page +admin_serversettings_about_help=Explain this server to users (Markdown accepted) +admin_serversettings_save_button=Save Settings +admin_badge_readonly=Read Only +admin_badge_active=Active +admin_whitelist_management_title=Whitelist Users +admin_whitelist_enabled_subtitle=White list enabled, user's must be whitelisted before they can sign up +admin_whitelist_disabled_subtitle=Whitelist disabled, anyone can sign up +admin_whitelist_description=Add email addresses to grant access to this Hammer server instance. +admin_whitelist_email_label=Email Address +admin_whitelist_email_placeholder=user@example.com +admin_whitelist_add_button=Add User +admin_whitelist_remove_button=Remove +admin_whitelist_empty=No users on the whitelist yet. Add one above to get started. +admin_whitelist_error_emailrequired=Email address is required +admin_whitelist_error_emailinvalid=Invalid email address format +admin_whitelist_error_reasontoolong=Reason must be 32 characters or less +admin_whitelist_error_expiryinvalid=Expiry date must be a valid date in the future +admin_pagination_prev=Previous +admin_pagination_next=Next +admin_pagination_page=Page +admin_pagination_of=of +# Admin Navigation +admin_nav_settings=Settings +admin_nav_whitelist=Whitelist +admin_nav_users=Users +admin_nav_patreon=Patreon +# Admin - Patreon Integration +admin_patreon_title=Patreon Integration +admin_patreon_subtitle=Automatically whitelist Patreon supporters +admin_patreon_status_title=Sync Status +admin_patreon_enabled_label=Integration Status +admin_patreon_enabled=Enabled +admin_patreon_disabled=Disabled +admin_patreon_last_sync=Last Sync +admin_patreon_members_count=Patreon Members on Whitelist +admin_patreon_members_with_accounts=Members with Accounts +admin_patreon_sync_now=Sync Now +admin_patreon_settings_title=Configuration +admin_patreon_settings_description=Configure your Patreon integration settings. You'll need a Creator Access Token from your Patreon developer settings. +admin_patreon_enable_integration=Enable Patreon Integration +admin_patreon_campaign_id=Campaign ID +admin_patreon_campaign_id_help=Your Patreon campaign ID - use the Fetch button to auto-detect it +admin_patreon_fetch_campaign=Fetch +admin_patreon_error_fetch_failed=Failed to fetch - check token +admin_patreon_access_token=Creator Access Token +admin_patreon_access_token_help=Generate this from your Patreon developer settings +admin_patreon_access_token_placeholder=Enter token (leave blank to keep existing) +admin_patreon_webhook_secret=Webhook Secret +admin_patreon_webhook_secret_help=Secret for verifying webhook requests from Patreon +admin_patreon_webhook_secret_placeholder=Enter secret (leave blank to keep existing) +admin_patreon_minimum_amount=Minimum Amount ($) +admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for whitelist +admin_patreon_poll_interval=Poll Interval (minutes) +admin_patreon_poll_interval_help=How often to sync with Patreon (in addition to webhooks) +admin_patreon_save_button=Save Settings +admin_patreon_webhook_title=Webhook Configuration +admin_patreon_webhook_description=Configure a webhook in your Patreon developer settings to receive real-time updates. +admin_patreon_webhook_url=Webhook URL +admin_patreon_sync_disabled_hint=Enable Patreon integration first +admin_patreon_error_campaign_required=Campaign ID is required to enable Patreon integration +admin_patreon_error_token_required=Access Token is required to enable Patreon integration +admin_patreon_error_not_enabled=Patreon integration must be enabled before syncing +admin_patreon_url=Patreon Page URL +admin_patreon_url_help=Link to your Patreon page (shown to users on the login page) +admin_patreon_url_placeholder=https://www.patreon.com/YourPage +admin_patreon_last_sync_never=Never +# Login - Patreon Notice +login_patreon_notice_title=Patreon Subscription Required +login_patreon_notice_message=This server requires an active Patreon subscription of ${0}/month or more to create an account or log in. +login_failure_patreon_notice_message=This server requires an active Patreon subscription of ${0}/month or more to create an account or log in. ${1} +login_patreon_notice_link=Subscribe on Patreon +# Admin - Email Settings +admin_nav_email=Email +admin_email_title=Email Settings +admin_email_subtitle=Configure email sending for server notifications +admin_email_status_title=Email Status +admin_email_status_label=Configuration Status +admin_email_provider_label=Active Provider +admin_email_configured=Configured +admin_email_not_configured=Not Configured +admin_email_settings_title=SMTP Configuration +admin_email_settings_description=Configure your SMTP server settings to enable email sending from this server. +admin_email_smtp_settings_title=SMTP Configuration +admin_email_smtp_settings_description=Configure your SMTP server settings to enable email sending from this server. +admin_email_sendgrid_settings_title=SendGrid Configuration +admin_email_sendgrid_settings_description=Configure your SendGrid API settings to enable email sending from this server. +admin_email_sendgrid_api_key=API Key +admin_email_sendgrid_api_key_help=Your SendGrid API key with Mail Send permissions +admin_email_sendgrid_api_key_placeholder=Leave blank to keep existing +admin_email_postmark_settings_title=Postmark Configuration +admin_email_postmark_settings_description=Configure your Postmark API settings to enable email sending from this server. +admin_email_postmark_server_token=Server Token +admin_email_postmark_server_token_help=Your Postmark server API token +admin_email_postmark_server_token_placeholder=Leave blank to keep existing +admin_email_mailgun_settings_title=Mailgun Configuration +admin_email_mailgun_settings_description=Configure your Mailgun API settings to enable email sending from this server. +admin_email_mailgun_api_key=API Key +admin_email_mailgun_api_key_help=Your Mailgun private API key +admin_email_mailgun_api_key_placeholder=Leave blank to keep existing +admin_email_mailgun_domain=Domain +admin_email_mailgun_domain_help=Your Mailgun sending domain (e.g., mg.example.com) +admin_email_mailgun_domain_placeholder=mg.example.com +admin_email_mailgun_use_eu_region=Use EU Region +admin_email_mailgun_use_eu_region_help=Enable this if your Mailgun domain is hosted in the EU region +admin_email_host=SMTP Host +admin_email_host_help=The hostname of your SMTP server (e.g., smtp.gmail.com) +admin_email_host_placeholder=smtp.example.com +admin_email_port=SMTP Port +admin_email_port_help=Common ports: 587 (TLS), 465 (SSL), 25 (unencrypted) +admin_email_port_placeholder=587 +admin_email_username=Username +admin_email_username_help=Your SMTP authentication username (often your email address) +admin_email_username_placeholder=your@email.com +admin_email_password=Password +admin_email_password_help=Your SMTP authentication password or app-specific password +admin_email_password_placeholder=Leave blank to keep existing +admin_email_from_address=From Address +admin_email_from_address_help=The email address that will appear in the From field +admin_email_from_address_placeholder=noreply@example.com +admin_email_from_name=From Name +admin_email_from_name_help=The display name that will appear alongside the email address +admin_email_default_from_name=Hammer Server +admin_email_use_tls=Use TLS/SSL +admin_email_use_tls_help=Enable secure TLS/SSL connection (recommended for port 465) +admin_email_use_starttls=Use STARTTLS +admin_email_use_starttls_help=Upgrade connection to TLS after connecting (recommended for port 587) +admin_email_save_button=Save Settings +admin_email_test_title=Test Email +admin_email_test_description=Send a test email to verify your SMTP configuration is working correctly. +admin_email_test_recipient=Recipient Email +admin_email_test_recipient_help=Enter an email address to receive the test message +admin_email_test_recipient_placeholder=you@example.com +admin_email_test_send=Send Test Email +admin_email_test_success=Test email sent successfully! Check your inbox. +admin_email_test_failed=Failed to send test email +admin_email_error_recipient_required=Please enter a recipient email address +admin_email_error_not_configured=Email is not configured. Please save your SMTP settings first. +admin_email_test_email_subject=Hammer Server Test Email +admin_email_test_email_html=

    Test Email from Hammer Server

    If you received this email, your SMTP configuration is working correctly.

    +admin_email_test_email_text=Test Email from Hammer Server\n\nIf you received this email, your SMTP configuration is working correctly. +# Admin - User Management +admin_users_title=User Management +admin_users_subtitle=View and manage registered users +admin_users_list_title=Registered Users +admin_users_description=All users registered on this server. +admin_users_projects=Projects +admin_users_created=Created +admin_users_last_sync=Last Sync +admin_users_empty=No users registered yet. +admin_users_sort_controls=Sort Controls +admin_users_sort_by=Sort By +admin_users_sort_direction=Direction +admin_users_sort_created=Created Date +admin_users_sort_lastsync=Last Sync +admin_users_sort_projectcount=Project Count +admin_users_sort_desc=Newest First +admin_users_sort_asc=Oldest First +admin_whitelist_add_title=Add to Whitelist +admin_whitelist_date_added_unknown=Unknown +admin_whitelist_edit_button=Edit +admin_whitelist_edit_reason_title=Edit Reason +admin_whitelist_reason_label=Reason +admin_whitelist_reason_placeholder=e.g., Beta tester, Friend of the project +admin_whitelist_reason_help=Brief note about why this user was whitelisted (max 32 characters) +admin_whitelist_save_reason=Save +admin_whitelist_edit_expiry_title=Edit Expiry +admin_whitelist_expiry_label=Expires +admin_whitelist_expiry_help=Access is revoked automatically once the entry expires. +admin_whitelist_expiry_never=Never +admin_whitelist_expiry_7days=In 7 days +admin_whitelist_expiry_30days=In 30 days +admin_whitelist_expiry_90days=In 90 days +admin_whitelist_expiry_custom=On a specific date +admin_whitelist_expiry_date_label=Expiry date +admin_whitelist_expires_prefix=Expires: +admin_whitelist_edit_expiry_button=Edit expiry +admin_whitelist_expiry_current=Current expiry: +admin_whitelist_expiry_change_label=Change to +admin_whitelist_expiry_extend_7days=Extend 7 days +admin_whitelist_expiry_extend_30days=Extend 30 days +admin_whitelist_expiry_extend_90days=Extend 90 days +admin_whitelist_save_expiry=Save +admin_whitelist_cannot_disable_patreon=Cannot disable whitelist while Patreon integration is active +admin_whitelist_sort_newest_first=Sort: Newest First +admin_whitelist_sort_oldest_first=Sort: Oldest First +admin_whitelist_account_created=Account Created +admin_whitelist_no_account=No Account +admin_dialog_cancel=Cancel +# Error Pages - Common +error_button_home=Go Home +error_button_signin=Sign In +error_button_back=Go Back +error_button_retry=Try Again +# Error Pages - 403 Unauthorized +error_unauthorized_title=Unauthorized Access +error_unauthorized_message=You don't have permission to access this page. This area is restricted to authorized users only. +error_unauthorized_note=If you believe you should have access, please contact the server administrator. +# Error Pages - 404 Not Found +error_notfound_title=Page Not Found +error_notfound_message=Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed in the first place. +error_notfound_note=If you think this is a mistake, please let us know! +# Error Pages - 500 Server Error +error_servererror_title=Server Error +error_servererror_message=Oops! Something went wrong on our end. Our server encountered an error and couldn't complete your request. +error_servererror_note=We've been notified and are working to fix the issue. Please try again in a few moments. +# Security +security_access_denied=Access denied +security_please_login=Please log in +# Story Page +story_back_to_dashboard=Back to Dashboard +story_no_scenes=This story doesn't have any scenes yet. Add scenes in the Hammer Editor app to see them here. +story_error_title=Error Loading Story +story_info_title=Story Info +story_readers_title=Readers +story_readers_total_label=total readers, all time +story_readers_graph_caption=Readers per day · last 30 days +story_info_scenes=Scenes +story_info_last_sync=Last Synced +story_settings_title=Sharing +story_publish_title=Public Access +story_publish_description=Make this story publicly accessible to anyone with the link. No login required. +story_private_share_title=Private Access +story_status_published=Published +story_status_private=Private +story_public_url=Sharing URL +story_copy_url=Copy URL +story_toast_published=Story published! Anyone with the link can now read it. +story_toast_unpublished=Story unpublished. It is now private. +# Pen Name Feature +penname_label=Pen Name: +penname_release_button=Release +penname_claim_button=Claim Your Pen Name +penname_explainer=Pen names allow you to share your work publicly. After setting one, you can share stories under that name. +penname_edit_label=Choose your pen name: +penname_edit_placeholder=e.g., Virginia Woolf +penname_set_button=Set Pen Name +penname_cancel_button=Cancel +penname_release_title=Release Your Pen Name +penname_release_warning1=Once released, your pen name will become available for others to claim. You won''t be able to get it back unless you claim it again before someone else does. +penname_release_warning2=Any links you''ve shared using this pen name will no longer work. +penname_release_current_label=Current pen name: +penname_release_confirm_label=Type your pen name to confirm: +penname_release_confirm_placeholder=Type pen name here +penname_release_confirm_button=Release Pen Name +penname_toast_saved=Pen name saved! +penname_toast_released=Pen name released +penname_validation_valid=Pen name is valid +penname_validation_too_short=Pen name must be at least {0} characters +penname_validation_too_long=Pen name must be at most {0} characters +penname_validation_invalid_chars=Pen name can only contain letters, numbers, spaces, hyphens, and underscores +penname_validation_taken=Pen name is already taken +penname_validation_available=Pen name is available +penname_checking_availability=Checking availability... +penname_check_failed=Could not check availability +penname_release_confirm_message=Are you sure you want to release "{0}"? This action cannot be undone. +# Bio +bio_label=Bio: +bio_add_button=Add Bio +bio_edit_button=Edit +bio_clear_button=Clear +bio_edit_label=Your author biography: +bio_edit_placeholder=Write a brief biography in Markdown. You can use **bold**, *italic*, [links](url), lists, and more. +bio_markdown_help=Markdown formatting supported: **bold**, *italic*, [links](url), headings, lists, etc. +bio_save_button=Save Bio +bio_cancel_button=Cancel +bio_toast_saved=Bio saved! +bio_toast_cleared=Bio cleared +bio_validation_too_long=Bio must be at most {0} characters +bio_error_no_penname=You must set a pen name before adding a bio +bio_clear_confirm=Are you sure you want to remove your bio? +# Account Deletion +account_delete_title=Danger Zone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. +account_delete_dialog_project_count_label=Projects that will be deleted from this server: +account_delete_confirm_label=Type your email address to confirm: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Cancel +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Restore +admin_users_restore_failed=Restore failed. That account no longer exists on this server. +# Public Story Page +public_story_by=A story by +public_story_powered_by=Written with +public_story_meta_description={0} by {1}. Read the full story on Hammer. +# OpenGraph share cards +og_attribution=Written with Hammer +og_author_subtitle=An author on {0} +# Story Access Management +story_share_private=Share with Password +story_share_private_title=Create Private Access +story_access_password=Password +story_access_password_placeholder=Enter a password +story_access_expires=Expiration Date +story_access_optional=optional +story_access_no_expiry=No expiration +story_access_expired=Expired +story_access_list_title=Private Access Links +story_access_passworded=Password Protected +story_access_delete_confirm=Are you sure you want to remove this access? +story_access_delete=Delete access +story_access_show_password=Show password +story_create_access=Create Access +story_cancel=Cancel +story_toast_access_created=Private access created successfully +story_toast_access_deleted=Access removed +# Public Story Password +public_story_password_required=Password Required +public_story_password_description=This story requires a password to view. +public_story_password_label=Password +public_story_password_placeholder=Enter password +public_story_password_submit=View Story +public_story_password_incorrect=Incorrect password. Please try again. +# Publish Warning Dialog +story_publish_warning_title=Publishing Publicly +story_publish_warning_content=Publishing the story here publicly, however briefly, could jeopardize your ability to sell it later. Many publishers demand "First Publication Rights", meaning it has never been publicly available anywhere before. +story_publish_warning_recommendation=If you intend to submit this project for publication, it is recommended to use Private Access sharing instead. +story_publish_publicly=Publish Publicly +# Author Page +author_published_works=Published Works +author_read_story=Read story +author_story_count=published stories +author_no_stories_title=No Published Stories Yet +author_no_stories_subtitle=This author hasn't published any stories yet. Check back later! +author_story_published_on=Published on +# Public Story Pagination +public_story_words=words +public_story_min_read=min read +public_story_prev_page=Previous +public_story_next_page=Next +story_info_words=Words +# Scene Selector +story_scene_selector_label=Scene +story_view_all=View All +# About Page +about_page_title=About This Server +# Password Reset +password_reset_forgot_title=Forgot Your Password? +password_reset_forgot_subtitle=Enter your email address and we'll send you a link to reset your password. +password_reset_forgot_link=Forgot your password? +password_reset_enter_email_help=Enter the email address associated with your account +password_reset_send_link=Send Reset Link +password_reset_email_sent=Reset Link Sent! +password_reset_check_email=If an account exists for +password_reset_link_expires=The reset link will expire in 15 minutes. +password_reset_back_to_login=Back to Login +password_reset_title=Reset Your Password +password_reset_subtitle=Enter your new password below +password_reset_new_password=New Password +password_reset_confirm_password=Confirm Password +password_reset_requirements=Password must be 8-64 characters long +password_reset_submit=Reset Password +password_reset_success_title=Password Reset Successful +password_reset_success_subtitle=Your password has been changed +password_reset_success_message=You can now log in with your new password. All other sessions have been logged out for security. +password_reset_go_to_login=Go to Login +password_reset_error_title=Reset Link Invalid +password_reset_error_invalid_token=This password reset link is invalid or has already been used. +password_reset_error_expired=This password reset link has expired. Please request a new one. +password_reset_error_already_used=This password reset link has already been used. Please request a new one if needed. +password_reset_request_new=Request New Reset Link +password_reset_error_email_required=Please enter your email address +password_reset_error_fields_required=Please fill in all fields +password_reset_error_mismatch=Passwords do not match +password_reset_error_generic=Failed to reset password. Please try again. +# Password Reset Email Template +password_reset_email_title=Reset Your Hammer Password +password_reset_email_intro=You requested to reset your password for your Hammer account. +password_reset_email_cta=Click the button below to reset your password: +password_reset_email_button=Reset Password +password_reset_email_or_copy=Or copy and paste this link into your browser: +password_reset_email_expires=This link will expire in 15 minutes. +password_reset_email_ignore=If you didn't request a password reset, you can safely ignore this email. +password_reset_email_footer=This is an automated message from Hammer Server. Please do not reply to this email. +# Community +community_label=Community: +community_promo_title=Join this server's community! +community_promo_text=Share your stories and discover other's work. +community_toggle_label=Join Community +community_status_joined=Community Member +community_status_not_joined=Not a member +community_explanation=Joining the community means your pen name will be listed on the authors page of this server, and any stories that you publish publicly will be discoverable by the rest of the community. This is entirely optional, and if you do join, will still only list stories that you explicitly publish publicly. +community_toast_joined=Welcome to the community! +community_toast_left=You have left the community +community_error_no_penname=You must set a pen name before joining the community +community_error_disabled=Community features are not enabled on this server +# Authors Page +authors_page_title=Community Authors +authors_page_subtitle=Discover writers sharing their stories on this server +authors_count_label=authors +authors_view_profile=View Profile +authors_no_bio=No bio yet +authors_empty_title=No Authors Yet +authors_empty_text=Be the first to join the community! Set a pen name and opt in to appear here. +# Feed Page +feed_page_title=Story Feed +feed_page_subtitle=The latest stories from our community of writers +feed_count_label=stories +feed_latest_stories=Latest Publications +feed_story_by=by +feed_read_story=Read Story +feed_empty_title=No Stories Yet +feed_empty_text=When community members publish their stories, they'll appear here. Check back soon! +# Community Navigation +community_nav_authors=Authors +community_nav_feed=Story Feed +# Community Callout +community_callout_title=Explore Our Community +community_callout_authors=authors +community_callout_sharing=sharing +community_callout_stories=stories +community_callout_authors_only=Authors are ready to share their stories! +community_callout_empty=Be the first to join our writing community! +# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". +community_badge_authors={0,choice,0#authors|1#author|1 Date: Mon, 3 Aug 2026 23:18:16 -0700 Subject: [PATCH 030/213] Keep the drop target live while the list autoscrolls (#845) --- .../scenelist/scenetree/SceneTree.kt | 31 ++------ .../scenelist/scenetree/SceneTreeState.kt | 75 ++++++++++++++++--- .../desktopTest/kotlin/SceneTreeDragTest.kt | 37 ++++++++- 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt index 71db15990..20e96b3db 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTree.kt @@ -127,11 +127,13 @@ private fun Modifier.reorderableModifier(state: SceneTreeState): Modifier { state.apply { return pointerInput(Unit) { detectDragGesturesAfterLongPress( - onDragStart = { + onDragStart = { offset -> val id = pressedRowId if (id != SceneTreeState.NO_SELECTION) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) startDragging(id) + updateDragPosition(offset) + autoScrollForDrag() } }, onDragCancel = { @@ -142,31 +144,8 @@ private fun Modifier.reorderableModifier(state: SceneTreeState): Modifier { } ) { change, _ -> change.consume() - - val layoutInfo: LazyListLayoutInfo = listState.layoutInfo - val insertPosition = findInsertPosition( - dragOffset = change.position, - layouts = rowLayouts, - collapsedGroups = collapsedNodes, - tree = summary.sceneTree, - visibleNodes = visibleNodes, - selectedNode = selectedNode, - ) - - if (insertAt != insertPosition) { - insertAt = insertPosition - } - - // Auto scroll - val height = layoutInfo.viewportSize.height - layoutInfo.viewportStartOffset - val bottomTenPercent: Float = height * .9f - val topTenPercent: Float = height * .1f - - if (change.position.y >= bottomTenPercent) { - autoScroll(false) - } else if (change.position.y <= topTenPercent) { - autoScroll(true) - } + updateDragPosition(change.position) + autoScrollForDrag() } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt index ce228ab24..a43e4d60b 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/scenetree/SceneTreeState.kt @@ -71,13 +71,34 @@ class SceneTreeState( internal var summary by mutableStateOf(sceneSummary) var selectedId by mutableStateOf(NO_SELECTION) var selectedNode by mutableStateOf?>(null) - var insertAt by mutableStateOf(null) /** The rows the tree renders, in display order — also the drag handler's id → node lookup. */ val visibleNodes: List> by derivedStateOf { visibleSceneNodes(summary.sceneTree, collapsedNodes) } + var dragPosition by mutableStateOf(null) + private set + + /** + * Derived rather than assigned from the drag handler, because the pointer is only half of it: + * autoscroll slides the rows out from under a finger that never moves and so never delivers + * another event. Recomputing whenever either side changes keeps the drop target under the + * finger, and [rowLayouts] re-reads on every placement pass to make that happen. + */ + val insertAt: InsertPosition? by derivedStateOf { + val position = dragPosition ?: return@derivedStateOf null + + findInsertPosition( + dragOffset = position, + layouts = rowLayouts, + collapsedGroups = collapsedNodes, + tree = summary.sceneTree, + visibleNodes = visibleNodes, + selectedNode = selectedNode, + ) + } + /** * Row this press started on, claimed by the row itself so it is the row the user aimed at. * Deliberately not snapshot state: it changes on every press and must not recompose the tree. @@ -157,7 +178,6 @@ class SceneTreeState( rows.remove(id) } - private var scrollJob by mutableStateOf(null) private var treeHash by mutableStateOf(sceneSummary.sceneTree.hashCode()) @@ -195,19 +215,42 @@ class SceneTreeState( collapsedNodes.clear() } - fun autoScroll(up: Boolean) { + /** True to scroll up, false to scroll down, null when the drag is clear of both edges. */ + private fun dragScrollDirection(): Boolean? { + val position = dragPosition ?: return null + val layoutInfo = listState.layoutInfo + val height = layoutInfo.viewportSize.height - layoutInfo.viewportStartOffset + if (height <= 0) return null + + return when { + position.y >= height * AUTO_SCROLL_ZONE_END -> false + position.y <= height * AUTO_SCROLL_ZONE_START -> true + else -> null + } + } + + /** + * Scrolls a row at a time while the drag is held near either edge. + * + * The job keeps stepping on its own until the pointer leaves the edge or the list runs out, + * because a drag parked in the hot zone delivers no further pointer events to step it along. + */ + internal fun autoScrollForDrag() { if (scrollJob?.isActive == true) return + if (dragScrollDirection() == null) return scrollJob = coroutineScope.launch { - if (up) { - val targetIndex = (listState.firstVisibleItemIndex - 1).coerceAtLeast(0) - listState.animateScrollToItem(targetIndex) - } else { - val visibleItems = listState.layoutInfo.visibleItemsInfo - if (visibleItems.isNotEmpty()) { - val nextIndex = listState.firstVisibleItemIndex + 1 - listState.animateScrollToItem(nextIndex) + while (true) { + val up = dragScrollDirection() ?: break + if (up && !listState.canScrollBackward) break + if (!up && !listState.canScrollForward) break + + val target = if (up) { + (listState.firstVisibleItemIndex - 1).coerceAtLeast(0) + } else { + listState.firstVisibleItemIndex + 1 } + listState.animateScrollToItem(target) } } } @@ -219,6 +262,10 @@ class SceneTreeState( } } + internal fun updateDragPosition(position: Offset) { + dragPosition = position + } + fun stopDragging() { val insertPosition = insertAt if (selectedId != ROOT_ID && insertPosition != null) { @@ -231,7 +278,8 @@ class SceneTreeState( selectedId = NO_SELECTION selectedNode = null - insertAt = null + dragPosition = null + scrollJob?.cancel() } fun toggleExpanded(nodeId: Int) { @@ -241,5 +289,8 @@ class SceneTreeState( companion object { const val NO_SELECTION = -1 + + private const val AUTO_SCROLL_ZONE_START = .1f + private const val AUTO_SCROLL_ZONE_END = .9f } } diff --git a/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt index 4959d08ce..645d0cd2c 100644 --- a/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt +++ b/composeUi/src/desktopTest/kotlin/SceneTreeDragTest.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.hammer.common.data.MoveRequest import com.darkrockstudios.apps.hammer.common.data.SceneItem @@ -29,6 +31,7 @@ import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue private const val TREE_TAG = "tree" private val RowHeight = 30.dp @@ -77,7 +80,7 @@ class SceneTreeDragTest { private lateinit var treeState: SceneTreeState private var summary by mutableStateOf(buildSummary(camerasAtRoot = false)) - private fun showTree() { + private fun showTree(height: Dp = 800.dp) { compose.setContent { val state = rememberReorderableLazyListState( summary = summary, @@ -86,7 +89,7 @@ class SceneTreeDragTest { treeState = state LaunchedEffect(summary) { state.updateSummary(summary) } - Box(modifier = Modifier.size(400.dp, 800.dp)) { + Box(modifier = Modifier.size(400.dp, height)) { SceneTree( state = state, modifier = Modifier.fillMaxSize().testTag(TREE_TAG), @@ -146,6 +149,36 @@ class SceneTreeDragTest { assertEquals(Ids.VERIFICATION, treeState.selectedId, "Grabbed the wrong row") } + @Test + fun `the drop target follows the list when it scrolls under a still pointer`() { + showTree(height = 200.dp) + + longPressAt(visualCenterOf(Ids.PREFACE)) + val holdAt = visualCenterOf(Ids.CH2) + compose.onNodeWithTag(TREE_TAG).performMouseInput { moveTo(holdAt) } + compose.waitForIdle() + + // Autoscroll moves the list without the pointer moving, so no drag event arrives. + compose.onNodeWithTag(TREE_TAG).performScrollToIndex(12) + compose.waitForIdle() + + compose.onNodeWithTag(TREE_TAG).performMouseInput { release() } + compose.waitForIdle() + + val request = moveRequest + assertNotNull(request, "No move was requested") + val anchorId = treeState.getTree()[request.toPosition.coords.globalIndex].value.id + val anchor = compose.onNodeWithTag(rowTag(anchorId)).getBoundsInRoot() + val top = with(compose.density) { anchor.top.toPx() } + val bottom = with(compose.density) { anchor.bottom.toPx() } + + assertTrue( + holdAt.y in top..bottom, + "Anchored to a row that is not under the pointer: $anchorId spans $top..$bottom, " + + "pointer at ${holdAt.y}", + ) + } + @Test fun `rows are reported in display order`() { // A tree whose display order is not ascending by id: "More Cameras" (9) sits near the From 5ab2afabeea2237e6e744b7bd3d1f242a1d7a1a0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 23:24:26 -0700 Subject: [PATCH 031/213] Guard the English message bundle against dropped keys English is the fallback bundle every locale resolves through, so a key the server looks up but Messages_en.properties does not carry throws MissingResourceException at request time. A Crowdin sync rewrote the English source file and dropped 129 live keys, which 500ed the home page, account refresh, and account deletion. The test scans msg/Msg.r/localizedMsg call sites and mustache msg.* references and asserts each resolves in English. Parity is checked against referenced keys rather than the translation files, which Crowdin owns and which carry strings that have outlived their use in the templates. --- .../frontend/utils/MessageBundleParityTest.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt new file mode 100644 index 000000000..e88f7b411 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt @@ -0,0 +1,102 @@ +package com.darkrockstudios.apps.hammer.frontend.utils + +import com.darkrockstudios.apps.hammer.utilities.ResUtils +import org.junit.jupiter.api.Test +import java.io.File +import java.util.Locale +import java.util.Properties +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * English is the fallback bundle every other locale resolves through, so a key + * that is looked up but absent from Messages_en.properties throws + * MissingResourceException at request time instead of degrading to untranslated + * text. + * + * Parity is asserted against the keys the server actually references rather than + * against the translation files, which Crowdin owns and which carry strings that + * have outlived their use in the templates. + */ +class MessageBundleParityTest { + + private val kotlinSources = File("src/main/kotlin") + private val templates = File("src/main/resources/templates") + + private val callSitePatterns = listOf( + Regex("""Msg\.r\(\s*"([^"]+)""""), + Regex("""\bmsg\(\s*"([^"]+)""""), + Regex("""\bmsg\(\s*[A-Za-z_][A-Za-z0-9_]*\s*,\s*"([^"]+)""""), + Regex("""localizedMsg\(\s*[^,]+,\s*"([^"]+)""""), + ) + private val templatePattern = Regex("""\{\{[{&]?\s*msg\.([A-Za-z0-9_]+)""") + + private fun keysOf(locale: Locale): Set { + val properties = Properties() + ResUtils.getResourceAsStream("i18n/Messages_$locale.properties") + .reader(Charsets.UTF_8) + .use { properties.load(it) } + return properties.stringPropertyNames() + } + + private fun filesUnder(root: File, extension: String): List = + root.walkTopDown().filter { it.isFile && it.extension == extension }.toList() + + private fun referencedKeys(): Map { + val references = mutableMapOf() + + filesUnder(kotlinSources, "kt").forEach { file -> + val text = file.readText() + callSitePatterns.forEach { pattern -> + pattern.findAll(text).forEach { match -> + val key = match.groupValues[1] + // Interpolated keys are assembled at runtime and cannot be resolved here. + if (!key.contains('$')) references.putIfAbsent(key, file.name) + } + } + } + + filesUnder(templates, "mustache").forEach { file -> + val text = file.readText() + templatePattern.findAll(text).forEach { match -> + references.putIfAbsent(match.groupValues[1], file.name) + } + } + + return references + } + + @Test + fun `every referenced message key resolves in English`() { + assertTrue(kotlinSources.isDirectory, "Kotlin sources not found at ${kotlinSources.absolutePath}") + assertTrue(templates.isDirectory, "Templates not found at ${templates.absolutePath}") + + val references = referencedKeys() + // A regex that quietly stops matching would turn this into a vacuous pass. + assertTrue(references.size > 200, "Only found ${references.size} message references; the scan is broken") + + val englishKeys = keysOf(Locale.ENGLISH) + val missing = references.filterKeys { it !in englishKeys } + + if (missing.isNotEmpty()) { + val report = missing.toSortedMap().entries.joinToString("\n") { (key, source) -> " $key (used by $source)" } + fail( + "Messages_en.properties is the fallback bundle and must carry every key the server looks up.\n" + + "$report\n" + + "If a Crowdin sync rewrote the English source file, restore the dropped keys." + ) + } + } + + @Test + fun `every English key resolves to a non-blank value`() { + val properties = Properties() + ResUtils.getResourceAsStream("i18n/Messages_en.properties") + .reader(Charsets.UTF_8) + .use { properties.load(it) } + + val blank = properties.stringPropertyNames().filter { properties.getProperty(it).isNullOrBlank() } + + assertTrue(blank.isEmpty(), "English keys with blank values: ${blank.sorted()}") + } +} From 71a13d9e7b4cfd35bb7383c6600fef5f1ef64b0f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 3 Aug 2026 23:33:26 -0700 Subject: [PATCH 032/213] Normalize tag needles for search and suggestions (#844) * Normalize tag needles for search and suggestions Tags are stored NFC-composed, but the read paths compared raw input against them, so a needle typed decomposed found nothing. Search queries and both suggestion services now normalize the needle first. Suggestion prefixes were also split on the ASCII space alone, so a part-typed tag after a comma or an ideographic separator offered nothing even though those separators split tags on save. Adds normalizeTagNeedle and tagPrefixOf, and folds the two ad-hoc hash-prefix checks into Char.isTagPrefix so the fullwidth form is recognized in queries as well. * Agree tag search with tag storage on where a tag starts and ends Review of the previous commit found that widening tag recognition on the needle side alone left three regressions. parseQuery normalized needles, which let two typed spellings collapse onto one string; the tag list was still an undeduped List feeding a keyed LazyRow in Global Search, so `##epic #epic` crashed composition. It also treated a hash anywhere as a tag opener, swallowing the fullwidth one out of ordinary free text, and still ended a needle at whitespace while storage ends tags at commas too. A hash now opens a tag only at a word boundary, needles split on the storage separators, and the tags come back deduped. The suggestion strip fired on the last run of the draft but its callers threw the whole draft away on select, losing tags already typed. replaceTagPrefix swaps just the run being completed. Also normalizes tag keys as the index is built, so a tag that reached disk unnormalized is still reachable, and folds the triplicated suggestion derivation into rememberTagSuggestions. --- .../hammer/common/data/search/SearchQuery.kt | 28 +++++++++--- .../common/data/tagindex/AccountTagService.kt | 2 +- .../data/tagindex/BuildTagIndexUseCase.kt | 8 +++- .../common/data/tagindex/TagIndexService.kt | 2 +- .../common/data/tagindex/TagNormalizer.kt | 28 +++++++++++- .../kotlin/data/search/SearchQueryTest.kt | 43 +++++++++++++++++++ .../tagindex/AccountTagServiceTest.kt | 10 +++++ .../tagindex/TagIndexServiceTest.kt | 43 +++++++++++++++++++ .../tagindex/TagNormalizerTest.kt | 30 +++++++++++++ .../designsystem/HdHairlineTagField.kt | 25 ++++++++--- .../hammer/common/encyclopedia/ViewEntryUi.kt | 9 ++-- .../sceneeditor/SceneMetadataPanelUi.kt | 9 ++-- 12 files changed, 206 insertions(+), 31 deletions(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt index 816fd3356..b7faed6d7 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/search/SearchQuery.kt @@ -1,5 +1,9 @@ package com.darkrockstudios.apps.hammer.common.data.search +import com.darkrockstudios.apps.hammer.common.data.tagindex.isTagPrefix +import com.darkrockstudios.apps.hammer.common.data.tagindex.isTagSeparator +import com.darkrockstudios.apps.hammer.common.data.tagindex.normalizeTagNeedle + /** Parsed search query: free text plus any `#tags` pulled out of it. */ data class ParsedQuery( val text: String, @@ -17,25 +21,35 @@ data class ParsedQuery( } } -/** A `#` starts a tag that runs to the next whitespace; everything else is free text. */ +/** + * A `#` opening a word starts a tag; everything else is free text. Tags are split and normalized + * the way tag input splits them on save, so a needle typed decomposed, or joined to the next by a + * comma, still matches what is stored. A `#` inside a word (`C#`) is part of the word. + */ fun parseQuery(query: String): ParsedQuery { - val tags = mutableListOf() + val tags = LinkedHashSet() val textBuilder = StringBuilder() var i = 0 while (i < query.length) { val c = query[i] - if (c == '#') { + if (c.isTagPrefix() && (i == 0 || query[i - 1].isTagSeparator())) { i++ - val tagStart = i - while (i < query.length && !query[i].isWhitespace()) i++ - if (i > tagStart) tags.add(query.substring(tagStart, i)) + // A comma chains straight into the next tag; whitespace ends tag context entirely. + while (i < query.length) { + val tagStart = i + while (i < query.length && !query[i].isTagSeparator()) i++ + normalizeTagNeedle(query.substring(tagStart, i)) + .takeIf { it.isNotEmpty() } + ?.let(tags::add) + if (i < query.length && !query[i].isWhitespace()) i++ else break + } } else { textBuilder.append(c) i++ } } val text = textBuilder.toString().replace(Regex("\\s+"), " ").trim() - return ParsedQuery(text = text, tags = tags) + return ParsedQuery(text = text, tags = tags.toList()) } /** diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/AccountTagService.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/AccountTagService.kt index c72968d0b..306b5e9b5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/AccountTagService.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/AccountTagService.kt @@ -64,7 +64,7 @@ class AccountTagService( exclude: Set = emptySet(), limit: Int = MAX_SUGGESTIONS, ): List { - val needle = prefix.trim().removePrefix("#") + val needle = normalizeTagNeedle(prefix) if (needle.isEmpty()) return emptyList() val merged = ideaTagCounts.value.toMutableMap() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/BuildTagIndexUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/BuildTagIndexUseCase.kt index fcde698f2..bac36188e 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/BuildTagIndexUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/BuildTagIndexUseCase.kt @@ -87,6 +87,8 @@ class BuildTagIndexUseCase( ) } + // Normalized on the way in rather than trusting every writer to have called cleanTags: a tag + // that reached disk decomposed would otherwise be a key no typed needle can ever match. private fun accumulate( tagToEntities: MutableMap>, countsByType: MutableMap>, @@ -94,8 +96,10 @@ class BuildTagIndexUseCase( tag: String, ref: TaggedEntityRef, ) { - tagToEntities.getOrPut(tag) { mutableSetOf() }.add(ref) + val key = normalizeTagNeedle(tag) + if (key.isEmpty()) return + tagToEntities.getOrPut(key) { mutableSetOf() }.add(ref) val perType = countsByType.getOrPut(type) { mutableMapOf() } - perType[tag] = (perType[tag] ?: 0) + 1 + perType[key] = (perType[key] ?: 0) + 1 } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagIndexService.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagIndexService.kt index c6f129173..920866877 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagIndexService.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagIndexService.kt @@ -78,7 +78,7 @@ class TagIndexService( _tagIndex.value.tagToEntities[tag].orEmpty() fun suggest(prefix: String, limit: Int = 10): List { - val needle = prefix.trim() + val needle = normalizeTagNeedle(prefix) if (needle.isEmpty()) return getRankedTags(limit) return _tagIndex.value.tagToEntities .filterKeys { it.startsWith(needle, ignoreCase = true) } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt index 621b127ba..5469ff5f9 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tagindex/TagNormalizer.kt @@ -16,6 +16,9 @@ internal expect fun normalizeTagForm(text: String): String /** True when the supplementary-plane [codePoint] is a letter or a digit. */ internal expect fun isSupplementaryLetterOrDigit(codePoint: Int): Boolean +/** Opens a `#tag`, in ASCII or fullwidth form. */ +fun Char.isTagPrefix(): Boolean = this == '#' || this == FULLWIDTH_NUMBER_SIGN + /** Separates one tag from the next in free-form input, in any script's punctuation. */ fun Char.isTagSeparator(): Boolean = isWhitespace() || @@ -61,11 +64,32 @@ private fun codePointOf(high: Char, low: Char): Int = 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00) private fun String.removeTagPrefix(): String = - if (startsWith('#') || startsWith(FULLWIDTH_NUMBER_SIGN)) substring(1) else this + if (isNotEmpty() && this[0].isTagPrefix()) substring(1) else this + +/** + * Put a tag the user typed into the form stored tags are held in, so searching and suggesting can + * compare the two: composed, trimmed, and without a leading `#`. + */ +fun normalizeTagNeedle(text: String): String = normalizeTagForm(text.trim()).removeTagPrefix() + +/** + * The tag the user is part-way through typing at the end of [input]: whatever follows the last + * separator, ready to match against stored tags. Splits on the same separators [parseTagInput] + * does, so the suggestions offered agree with the tags that would be saved. + */ +fun tagPrefixOf(input: String): String = + normalizeTagNeedle(input.takeLastWhile { !it.isTagSeparator() }) + +/** + * [input] with the tag being typed at its end swapped for [tag], leaving any earlier tags in the + * field untouched. Pairs with [tagPrefixOf]: what that offers a suggestion for, this replaces. + */ +fun replaceTagPrefix(input: String, tag: String): String = + input.dropLast(input.takeLastWhile { !it.isTagSeparator() }.length) + tag fun cleanTags(tags: Set): Set = tags.asSequence() - .map { normalizeTagForm(it.trim()).removeTagPrefix() } + .map(::normalizeTagNeedle) .filter(::isValidTag) .toSet() diff --git a/common/src/desktopTest/kotlin/data/search/SearchQueryTest.kt b/common/src/desktopTest/kotlin/data/search/SearchQueryTest.kt index 5562973ff..4430bbd58 100644 --- a/common/src/desktopTest/kotlin/data/search/SearchQueryTest.kt +++ b/common/src/desktopTest/kotlin/data/search/SearchQueryTest.kt @@ -34,6 +34,49 @@ class SearchQueryTest { assertEquals("alice in wonderland", parsed.text) } + /** Text entered on iOS/macOS can arrive decomposed: base letter plus a combining mark. */ + @Test + fun `parseQuery composes decomposed accents in tags`() { + val parsed = parseQuery("#the\u0300me") + + assertEquals(listOf("th\u00e8me"), parsed.tags) + assertTrue(setOf("th\u00e8me").matchesAllTags(parsed.tags)) + } + + @Test + fun `parseQuery starts a tag on a fullwidth hash`() { + val parsed = parseQuery("\uff03\u4eba\u7269") + + assertEquals(listOf("\u4eba\u7269"), parsed.tags) + assertEquals("", parsed.text) + } + + @Test + fun `parseQuery drops duplicate tags`() { + assertEquals(listOf("epic"), parseQuery("##epic #epic").tags) + assertEquals(listOf("th\u00e8me"), parseQuery("#th\u00e8me #the\u0300me").tags) + } + + /** A hash mid-word is part of the word: `C#` is a search term, not an empty tag. */ + @Test + fun `parseQuery keeps a hash inside a word as free text`() { + val ascii = parseQuery("C#") + assertTrue(ascii.tags.isEmpty()) + assertEquals("C#", ascii.text) + + val fullwidth = parseQuery("\uff23\uff03") + assertTrue(fullwidth.tags.isEmpty()) + assertEquals("\uff23\uff03", fullwidth.text) + } + + @Test + fun `parseQuery splits a comma-joined tag run the way tag input does`() { + val parsed = parseQuery("#\u4eba\u7269\u3001\u9b54\u6cd5") + + assertEquals(listOf("\u4eba\u7269", "\u9b54\u6cd5"), parsed.tags) + assertEquals("", parsed.text) + } + @Test fun `isUsable requires a tag or two text chars`() { assertFalse(parseQuery("a").isUsable()) diff --git a/common/src/desktopTest/kotlin/repositories/tagindex/AccountTagServiceTest.kt b/common/src/desktopTest/kotlin/repositories/tagindex/AccountTagServiceTest.kt index 04caeffa5..391d8fe98 100644 --- a/common/src/desktopTest/kotlin/repositories/tagindex/AccountTagServiceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/tagindex/AccountTagServiceTest.kt @@ -108,6 +108,16 @@ class AccountTagServiceTest : BaseTest() { assertEquals(listOf(TagCount("gothic", 1)), service.suggest("#go")) } + /** Text entered on iOS/macOS can arrive decomposed: base letter plus a combining mark. */ + @Test + fun `Prefix matching composes decomposed accents`() = runTest { + advanceUntilIdle() + ideasRepository.createIdea(content = "a", tags = setOf("th\u00e8me")) + advanceUntilIdle() + + assertEquals(listOf(TagCount("th\u00e8me", 1)), service.suggest("the\u0300")) + } + @Test fun `Blank prefix suggests nothing`() = runTest { advanceUntilIdle() diff --git a/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt b/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt index c4c76ece9..97e9fc69e 100644 --- a/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt @@ -262,6 +262,49 @@ class TagIndexServiceTest : BaseTest() { assertTrue(empty.isEmpty()) } + /** Text entered on iOS/macOS can arrive decomposed: base letter plus a combining mark. */ + @Test + fun `suggest matches a decomposed accented prefix`() = runTest(mainTestDispatcher) { + stubNotes(note(1, "th\u00e8me")) + stubTimeline() + stubEntries() + + val service = makeService() + advanceUntilIdle() + + assertEquals(listOf("th\u00e8me" to 1), service.suggest("the\u0300").map { it.tag to it.count }) + } + + /** A tag can reach disk unnormalized (hand-edited project file, import); the index composes it. */ + @Test + fun `index keys are normalized so an unnormalized stored tag is still found`() = + runTest(mainTestDispatcher) { + stubNotes(note(1, "the\u0300me")) + stubTimeline() + stubEntries() + + val service = makeService() + advanceUntilIdle() + + assertEquals( + setOf(TaggedEntityRef(TaggedEntityType.Note, 1)), + service.getEntitiesWithTag("th\u00e8me"), + ) + assertEquals(listOf("th\u00e8me" to 1), service.suggest("th\u00e8me").map { it.tag to it.count }) + } + + @Test + fun `suggest ignores a leading hash`() = runTest(mainTestDispatcher) { + stubNotes(note(1, "arwen")) + stubTimeline() + stubEntries() + + val service = makeService() + advanceUntilIdle() + + assertEquals(listOf("arwen" to 1), service.suggest("#ar").map { it.tag to it.count }) + } + @Test fun `rebuild fires when a content-changed signal is emitted`() = runTest(mainTestDispatcher) { stubNotes(note(1, "alpha")) diff --git a/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt b/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt index 27e9e3e6e..59034ed00 100644 --- a/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt +++ b/common/src/desktopTest/kotlin/repositories/tagindex/TagNormalizerTest.kt @@ -2,6 +2,8 @@ package repositories.tagindex import com.darkrockstudios.apps.hammer.common.data.tagindex.cleanTags import com.darkrockstudios.apps.hammer.common.data.tagindex.parseTagInput +import com.darkrockstudios.apps.hammer.common.data.tagindex.replaceTagPrefix +import com.darkrockstudios.apps.hammer.common.data.tagindex.tagPrefixOf import org.junit.jupiter.api.Test import kotlin.test.assertEquals @@ -97,6 +99,34 @@ class TagNormalizerTest { assertEquals(setOf("\u4eba\u7269", "\u5834\u6240", "\u9b54\u6cd5"), parseTagInput(input)) } + @Test + fun `Tag prefix is the run after the last separator`() { + assertEquals("\u00e9t\u00e9", tagPrefixOf("guerre,\u00e9t\u00e9")) + assertEquals("\u9b54\u6cd5", tagPrefixOf("\u4eba\u7269\u3001\u9b54\u6cd5")) + assertEquals("\u5834\u6240", tagPrefixOf("\u4eba\u7269\u3000\u5834\u6240")) + } + + @Test + fun `Tag prefix composes decomposed accents`() { + assertEquals("th\u00e8", tagPrefixOf("guerre #the\u0300")) + } + + @Test + fun `Tag prefix is empty when the input ends on a separator`() { + assertEquals("", tagPrefixOf("guerre ")) + assertEquals("", tagPrefixOf("")) + assertEquals("", tagPrefixOf("#")) + } + + @Test + fun `Replace tag prefix swaps only the tag being typed`() { + assertEquals("alice bob", replaceTagPrefix("alice bo", "bob")) + assertEquals("alice,bob", replaceTagPrefix("alice,bo", "bob")) + assertEquals("alice bob", replaceTagPrefix("alice ", "bob")) + assertEquals("bob", replaceTagPrefix("", "bob")) + assertEquals("bob", replaceTagPrefix("#bo", "bob")) + } + @Test fun `Parse tag input splits on non-breaking spaces`() { val input = "one\u00a0two" diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt index 2256936fd..229592baf 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.hammer.common.compose.CollapseWhileTyping import com.darkrockstudios.apps.hammer.common.data.tagindex.isTagSeparator import com.darkrockstudios.apps.hammer.common.data.tagindex.parseTagInput +import com.darkrockstudios.apps.hammer.common.data.tagindex.replaceTagPrefix +import com.darkrockstudios.apps.hammer.common.data.tagindex.tagPrefixOf /** * Tag chip field — labeled hairline-underline input that keeps its @@ -72,11 +74,7 @@ fun HdHairlineTagField( true } - val suggestions = remember(draft, tags, suggestTags) { - val prefix = draft.trim().removePrefix("#") - if (prefix.isEmpty()) emptyList() - else suggestTags(prefix).filter { it !in tags } - } + val suggestions = rememberTagSuggestions(draft, tags, suggestTags) val onSurface = MaterialTheme.colorScheme.onSurface val muted = MaterialTheme.colorScheme.onSurfaceVariant @@ -178,13 +176,28 @@ fun HdHairlineTagField( HdTagSuggestionStrip( suggestions = suggestions, - onSelect = { addTag(it) }, + onSelect = { addTag(replaceTagPrefix(draft, it)) }, modifier = Modifier.padding(top = 6.dp), ) } } } +/** + * Suggestions for the tag being typed at the end of [draft], minus the ones in [applied]. Pair the + * strip's `onSelect` with [replaceTagPrefix] so picking one keeps the tags typed before it. + */ +@Composable +fun rememberTagSuggestions( + draft: String, + applied: Collection, + suggestTags: (prefix: String) -> List, +): List = remember(draft, applied, suggestTags) { + val prefix = tagPrefixOf(draft) + if (prefix.isEmpty()) emptyList() + else suggestTags(prefix).filter { it !in applied } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun HdTagSuggestionStrip( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt index 8220b1f56..9b0a40516 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt @@ -52,6 +52,7 @@ import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.Encycl import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EntryError import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EntryResult import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef +import com.darkrockstudios.apps.hammer.common.data.tagindex.replaceTagPrefix import com.darkrockstudios.apps.hammer.common.util.StrRes import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.FileKitType @@ -1130,11 +1131,7 @@ private fun TagAddDialog( ) { var newTagsText by rememberSaveable { mutableStateOf("") } val existingTags = state.content?.tags.orEmpty() - val suggestions = remember(newTagsText, existingTags) { - val prefix = newTagsText.substringAfterLast(' ').trim().removePrefix("#") - if (prefix.isEmpty()) emptyList() - else component.suggestTags(prefix).filter { it !in existingTags } - } + val suggestions = rememberTagSuggestions(newTagsText, existingTags, component::suggestTags) Column(verticalArrangement = Arrangement.spacedBy(Ui.Padding.L)) { HdHairlineField( label = Res.string.encyclopedia_create_entry_tags_label.get(), @@ -1145,7 +1142,7 @@ private fun TagAddDialog( suggestions = suggestions, onSelect = { tag -> scope.launch { - component.addTags(tag) + component.addTags(replaceTagPrefix(newTagsText, tag)) withContext(mainDispatcher) { newTagsText = "" } } }, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt index 8c553a288..16ab0f77f 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt @@ -25,6 +25,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType +import com.darkrockstudios.apps.hammer.common.data.tagindex.replaceTagPrefix import com.darkrockstudios.apps.hammer.common.encyclopedia.EntryRefChipLabel import com.darkrockstudios.apps.hammer.common.util.format import kotlinx.datetime.TimeZone @@ -625,11 +626,7 @@ private fun AddTagDialog( var draft by rememberSaveable { mutableStateOf("") } LaunchedEffect(visible) { if (!visible) draft = "" } - val suggestions = remember(draft, existingTags) { - val prefix = draft.substringAfterLast(' ').trim().removePrefix("#") - if (prefix.isEmpty()) emptyList() - else component.suggestTags(prefix).filter { it !in existingTags } - } + val suggestions = rememberTagSuggestions(draft, existingTags, component::suggestTags) AnimatedDialog( visible = visible, @@ -658,7 +655,7 @@ private fun AddTagDialog( HdTagSuggestionStrip( suggestions = suggestions, onSelect = { tag -> - component.addTags(tag) + component.addTags(replaceTagPrefix(draft, tag)) draft = "" }, ) From cfac12ed5f8ed04b3e7ac6de0442b18beabae9d5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 00:27:59 -0700 Subject: [PATCH 033/213] Lib bumps Remove kotlinx.datetime --- .../common/data/projectstatistics/ProjectStatistics.kt | 2 +- .../statistics/StatisticsDatasourceTest.kt | 2 +- .../statistics/StatisticsRepositoryTest.kt | 2 +- gradle/libs.versions.toml | 6 +++--- .../darkrockstudios/apps/hammer/database/AccountDao.kt | 2 +- .../apps/hammer/database/InstantColumnAdapter.kt | 4 ++-- .../com/darkrockstudios/apps/hammer/Account.sq | 2 +- .../com/darkrockstudios/apps/hammer/ApiMetric.sq | 2 +- .../com/darkrockstudios/apps/hammer/AuthToken.sq | 2 +- .../com/darkrockstudios/apps/hammer/DeletedEntity.sq | 2 +- .../com/darkrockstudios/apps/hammer/DeletedIdea.sq | 2 +- .../com/darkrockstudios/apps/hammer/DeletedProject.sq | 2 +- .../com/darkrockstudios/apps/hammer/ErrorLog.sq | 2 +- .../com/darkrockstudios/apps/hammer/LoginAttempt.sq | 2 +- .../darkrockstudios/apps/hammer/PasswordResetToken.sq | 2 +- .../com/darkrockstudios/apps/hammer/Project.sq | 2 +- .../com/darkrockstudios/apps/hammer/ProjectAccess.sq | 2 +- .../com/darkrockstudios/apps/hammer/ProjectData.sq | 2 +- .../apps/hammer/PublishedStoryReader.sq | 2 +- .../com/darkrockstudios/apps/hammer/ReviewRequest.sq | 2 +- .../darkrockstudios/apps/hammer/ReviewSuggestion.sq | 2 +- .../com/darkrockstudios/apps/hammer/ServerConfig.sq | 2 +- .../com/darkrockstudios/apps/hammer/UserActivity.sq | 2 +- .../com/darkrockstudios/apps/hammer/WhiteList.sq | 2 +- .../apps/hammer/frontend/SitemapRoutesTest.kt | 10 +++++----- 25 files changed, 32 insertions(+), 32 deletions(-) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatistics.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatistics.kt index 39f8a6172..2864296e1 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatistics.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectstatistics/ProjectStatistics.kt @@ -2,8 +2,8 @@ package com.darkrockstudios.apps.hammer.common.data.projectstatistics import com.darkrockstudios.apps.hammer.base.http.projectdata.WordCountGoal import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType -import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable data class EntryAppearance( diff --git a/common/src/desktopTest/kotlin/repositories/statistics/StatisticsDatasourceTest.kt b/common/src/desktopTest/kotlin/repositories/statistics/StatisticsDatasourceTest.kt index 1b156bed5..7aeaca6c8 100644 --- a/common/src/desktopTest/kotlin/repositories/statistics/StatisticsDatasourceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/statistics/StatisticsDatasourceTest.kt @@ -10,7 +10,6 @@ import com.darkrockstudios.apps.hammer.common.getCacheDirectory import getProject1Def import getProjectDef import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Instant import net.peanuuutz.tomlkt.Toml import okio.Path.Companion.toPath import okio.fakefilesystem.FakeFileSystem @@ -22,6 +21,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Instant class StatisticsDatasourceTest : BaseTest() { diff --git a/common/src/desktopTest/kotlin/repositories/statistics/StatisticsRepositoryTest.kt b/common/src/desktopTest/kotlin/repositories/statistics/StatisticsRepositoryTest.kt index 354549709..781ed9388 100644 --- a/common/src/desktopTest/kotlin/repositories/statistics/StatisticsRepositoryTest.kt +++ b/common/src/desktopTest/kotlin/repositories/statistics/StatisticsRepositoryTest.kt @@ -8,13 +8,13 @@ import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Instant import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import utils.BaseTest import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Instant /** * Pins the dirty-flag mechanics of [StatisticsRepository.markDirty]: synchronous in-memory diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c5a79b9c..6f747c234 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,7 +71,7 @@ ktor = "3.5.1" ktor_plugin = "3.5.1" ktor_i18n = "2.0.0" -okio = "3.17.0" +okio = "3.18.1" slf4jSimple = "2.0.18" splashify = "1.0.0-alpha1" turbine = "1.2.1" @@ -95,7 +95,7 @@ testcontainers = "2.0.5" buildconfig = "6.0.10" resources = "1.1.0" aboutlibraries = "15.0.4" -logback_classic = "1.5.38" +logback_classic = "1.6.1" multiplatform_settings = "1.3.0" korge-core = "6.0.0" bouncycastle = "1.85" @@ -103,7 +103,7 @@ kotlinx-io-okio = "0.9.1" compose-texteditor = "2.3.1" kotlin-multiplatform-diff = "1.3.0" -platform-spellcheckerkt = "1.3.1" +platform-spellcheckerkt = "1.3.2" nucleus = "1.15.7" colorpicker-compose = "1.2.0" material-kolor = "5.0.0" diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt index c7d2978c7..e16384df6 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/AccountDao.kt @@ -6,8 +6,8 @@ import com.darkrockstudios.apps.hammer.account.SortDirection import com.darkrockstudios.apps.hammer.account.UserSortField import com.darkrockstudios.apps.hammer.utilities.injectIoDispatcher import kotlinx.coroutines.withContext -import kotlinx.datetime.Instant import org.koin.core.component.KoinComponent +import kotlin.time.Instant class AccountDao( database: Database, diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/InstantColumnAdapter.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/InstantColumnAdapter.kt index 2849a19f1..af58fad14 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/InstantColumnAdapter.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/database/InstantColumnAdapter.kt @@ -2,17 +2,17 @@ package com.darkrockstudios.apps.hammer.database import app.cash.sqldelight.ColumnAdapter import com.darkrockstudios.apps.hammer.utilities.coerceToStorableRange +import org.slf4j.LoggerFactory import java.time.OffsetDateTime import java.time.ZoneOffset import kotlin.time.Instant import kotlin.time.toJavaInstant import kotlin.time.toKotlinInstant -import org.slf4j.LoggerFactory /** * Adapts the JDBC Postgres `TIMESTAMPTZ` column type (`OffsetDateTime`) to * `kotlin.time.Instant` so DAOs and downstream code can stay in the - * kotlinx-datetime / kotlin.time world. + * kotlin.time world. */ internal object InstantColumnAdapter : ColumnAdapter { private val logger = LoggerFactory.getLogger(InstantColumnAdapter::class.java) diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq index df6884213..55deb0119 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Account.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- email and pen_name columns are declared TEXT here for SqlDelight's parser, -- and promoted to CITEXT in PostgresSchemaPostInit so comparisons and the diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ApiMetric.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ApiMetric.sq index e70e15989..d0f1f29a8 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ApiMetric.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ApiMetric.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Rolled-up API request metrics. The in-memory collector accumulates requests -- and flushes them into HOUR buckets; the maintenance job later rolls HOUR diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq index b6dadfd38..abece45a4 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/AuthToken.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- One auth_token row per (user_id, install_id) pair. The token itself is a -- separately UNIQUE credential. This restructure (vs SQLite's PK-on-token) lets diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq index 3985a2665..759aed105 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedEntity.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Audit trail of entities that have been deleted; sync clients consult it to -- know what to drop locally. diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq index 50ecae9f5..039609b44 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedIdea.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Permanent deletion tombstones for story ideas: written when a delete lands, never -- pruned, so a stale device holding a deleted idea cannot resurrect it on sync. diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq index 33c9a1cd8..d8117a09b 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/DeletedProject.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- uuid is declared TEXT for SqlDelight's parser; promoted to UUID at runtime -- by PostgresSchemaInitializer (matches project.uuid). Callers cast bound diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq index aac782682..9263501b6 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ErrorLog.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Deduplicated server-side errors. Rather than a row per occurrence, errors are -- grouped by `fingerprint` (a hash of exception type + route, optionally + user) diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq index 38d356f9a..a754fa4fb 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/LoginAttempt.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Raw login attempts, used for brute-force visibility on the Security page. -- `email` is stored lower-cased by the repository for stable counting; it may diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq index 84d7d979f..3fffc9d43 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PasswordResetToken.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; CREATE TABLE password_reset_token( id BIGSERIAL PRIMARY KEY, diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq index 2522146b6..26be961d5 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/Project.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- uuid is declared TEXT for SqlDelight's parser; PostgresSchemaInitializer -- ALTERs it to UUID at runtime for 16-byte storage and validation. The diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectAccess.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectAccess.sq index a11435bc8..851535663 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectAccess.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectAccess.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; CREATE TABLE project_access ( id BIGSERIAL PRIMARY KEY, diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq index 15334e004..c31bb7597 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ProjectData.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- updated_at was a Unix-seconds INTEGER in SQLite; promoted to TIMESTAMPTZ here. -- The migrator converts INTEGER seconds → Instant during the data copy. diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq index d078eba62..da0e312a2 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/PublishedStoryReader.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Deduplicated record of anonymous reads of published / privately-shared stories, -- backing the best-effort "unique readers" counter. There is no reader account, so diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq index 3337c8d7b..d41f40b2d 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewRequest.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; CREATE TABLE review_request( id BIGSERIAL PRIMARY KEY, diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq index 603ce747c..d2717420c 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ReviewSuggestion.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; CREATE TABLE review_suggestion( id BIGSERIAL PRIMARY KEY, diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ServerConfig.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ServerConfig.sq index 8613c1c3e..5c302244b 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ServerConfig.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/ServerConfig.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- updated_at was a Unix-seconds INTEGER in SQLite; promoted to TIMESTAMPTZ here. CREATE TABLE server_config ( diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq index 41164d96f..48cb35dd0 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/UserActivity.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- Deduplicated record of which logged-in users were active, used for the -- "Active Users" dashboard metric. One row per (user, activity type, hour) so diff --git a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WhiteList.sq b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WhiteList.sq index 046ebe866..8b281ef2e 100644 --- a/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WhiteList.sq +++ b/server/src/main/sqldelight/com/darkrockstudios/apps/hammer/WhiteList.sq @@ -1,4 +1,4 @@ -import kotlinx.datetime.Instant; +import kotlin.time.Instant; -- date_added was Unix-seconds INTEGER in SQLite; promoted to TIMESTAMPTZ here. -- email is declared TEXT for SqlDelight's parser; PostgresSchemaPostInit diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SitemapRoutesTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SitemapRoutesTest.kt index 9eb16b3a5..d84841ed9 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SitemapRoutesTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SitemapRoutesTest.kt @@ -4,15 +4,15 @@ import com.darkrockstudios.apps.hammer.ServerConfig import com.darkrockstudios.apps.hammer.database.CommunityAuthor import com.darkrockstudios.apps.hammer.database.CommunityFeedStory import com.darkrockstudios.apps.hammer.frontend.utils.ProjectName -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import io.ktor.server.routing.routing -import io.ktor.server.testing.testApplication +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* import io.mockk.mockk -import kotlinx.datetime.Instant import org.junit.jupiter.api.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.time.Instant class SitemapRoutesTest { From e2906293bcd20cf18cdc9b70f7710c1dbd344e32 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 00:30:42 -0700 Subject: [PATCH 034/213] Upgrade Rich Text Editor --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f747c234..c35e07df9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ korge-core = "6.0.0" bouncycastle = "1.85" kotlinx-io-okio = "0.9.1" -compose-texteditor = "2.3.1" +compose-texteditor = "2.4.0" kotlin-multiplatform-diff = "1.3.0" platform-spellcheckerkt = "1.3.2" nucleus = "1.15.7" From ce54ad7054ade96f660c56c8cfed6a9a6fa8086c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 00:32:23 -0700 Subject: [PATCH 035/213] Update AGP --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c35e07df9..ea66868ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ kotlin = "2.4.10" serialization = "1.11.0" # Android -android_gradle_plugin = "9.1.1" +android_gradle_plugin = "9.2.1" android_sdk_compile = "37" android_sdk_target = "37" android_sdk_min = "28" From 7f998f26185933c6fdf7dce69c5748eaf8493478 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 08:40:48 -0700 Subject: [PATCH 036/213] ktor bump --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea66868ea..da7bb2054 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,8 +67,8 @@ kmpalette = "3.1.0" koin_bom = "4.2.2" koin_plugin = "1.0.2" -ktor = "3.5.1" -ktor_plugin = "3.5.1" +ktor = "3.5.2" +ktor_plugin = "3.5.2" ktor_i18n = "2.0.0" okio = "3.18.1" From 76fc56f8698aecec5dc5e242a9558ad7ea4a310c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 19:04:41 -0700 Subject: [PATCH 037/213] Add create-admin-account form to the server setup page (#849) The first-run /setup page now hosts an email/password form that creates the initial admin account, signs the operator into a web session, and redirects to /admin. The app-based setup steps remain as a secondary section. POST is rate limited alongside login and re-checks hasUsers() to guard races and double submits. --- .../apps/hammer/frontend/SetupPage.kt | 77 +++++- .../apps/hammer/plugins/SetupModePlugin.kt | 3 +- .../src/main/resources/assets/css/error.css | 84 +++++++ .../resources/i18n/Messages_en.properties | 7 + .../main/resources/templates/setup.mustache | 77 ++++-- .../apps/hammer/e2e/SetupFlowTest.kt | 108 ++++++++ .../apps/hammer/frontend/SetupPageTest.kt | 234 ++++++++++++++++++ 7 files changed, 562 insertions(+), 28 deletions(-) create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/SetupFlowTest.kt create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPageTest.kt diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPage.kt index 7957f1b76..567eeff84 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPage.kt @@ -2,13 +2,22 @@ package com.darkrockstudios.apps.hammer.frontend import com.darkrockstudios.apps.hammer.ServerConfig import com.darkrockstudios.apps.hammer.account.AccountsRepository +import com.darkrockstudios.apps.hammer.frontend.data.UserSession +import com.darkrockstudios.apps.hammer.frontend.utils.msg +import com.darkrockstudios.apps.hammer.plugins.LOGIN_RATE_LIMIT +import com.darkrockstudios.apps.hammer.projects.ProjectsRepository +import com.darkrockstudios.apps.hammer.utilities.isSuccess import io.ktor.server.mustache.* +import io.ktor.server.plugins.ratelimit.* +import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.server.sessions.* import org.koin.ktor.ext.inject fun Route.setupPage(serverConfig: ServerConfig) { val accountsRepository: AccountsRepository by inject() + val projectsRepository: ProjectsRepository by inject() route("/setup") { get { @@ -18,18 +27,74 @@ fun Route.setupPage(serverConfig: ServerConfig) { return@get } - val model = call.withDefaults() - model["page_stylesheet"] = "/assets/css/error.css" + call.respond(MustacheContent("setup.mustache", buildSetupModel(call, serverConfig))) + } - // Build the server address for display - val serverAddress = buildServerAddress(serverConfig) - model["serverAddress"] = serverAddress + rateLimit(RateLimitName(LOGIN_RATE_LIMIT)) { + post { + // Guards against races with app-side account creation and double-submits + if (accountsRepository.hasUsers()) { + call.respondRedirect("/login") + return@post + } - call.respond(MustacheContent("setup.mustache", model)) + val params = call.receiveParameters() + val email = params["email"]?.trim() ?: "" + val password = params["password"] ?: "" + val confirmPassword = params["confirmPassword"] ?: "" + + suspend fun respondWithError(message: String) { + val model = buildSetupModel(call, serverConfig) + model["error"] = message + model["email"] = email + call.respond(MustacheContent("setup.mustache", model)) + } + + if (email.isEmpty() || password.isEmpty() || confirmPassword.isEmpty()) { + respondWithError(call.msg("setup_error_fields_required")) + return@post + } + if (password != confirmPassword) { + respondWithError(call.msg("setup_error_password_mismatch")) + return@post + } + + // ToS challenge is intentionally skipped for the first admin account, + // so this goes to the repository directly rather than AccountsComponent. + val result = accountsRepository.createAccount( + email = email, + installId = "web", + password = password, + ) + if (isSuccess(result)) { + val token = result.data + projectsRepository.createUserData(token.userId) + + // Read from the DB rather than assuming admin: a lost creation race + // means this account wasn't first and must not get an admin cookie. + val isAdmin = accountsRepository.isAdmin(token.userId) + val session = UserSession( + userId = token.userId, + username = email, + isAdmin = isAdmin, + ) + call.sessions.set(session) + call.respondRedirect(if (isAdmin) "/admin" else "/dashboard") + } else { + respondWithError(result.displayMessageText(call) ?: call.msg("setup_error_generic")) + } + } } } } +private suspend fun buildSetupModel(call: RoutingCall, serverConfig: ServerConfig): MutableMap { + val model = call.withDefaults() + model["page_stylesheet"] = "/assets/css/error.css" + model["serverAddress"] = buildServerAddress(serverConfig) + return model +} + private fun buildServerAddress(config: ServerConfig): String { val host = config.host val port = config.port diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/SetupModePlugin.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/SetupModePlugin.kt index 8e2e8b0ce..18286ccc7 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/SetupModePlugin.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/SetupModePlugin.kt @@ -12,7 +12,8 @@ import java.util.concurrent.atomic.AtomicBoolean /** * Plugin that redirects frontend requests to the setup page when no users exist in the database. - * This allows admins to see instructions for creating their first account via the Hammer app. + * The setup page lets the admin create the first account directly, or shows instructions for + * creating it via the Hammer app. * * API routes are NOT blocked - they must remain accessible so the Hammer app can create the first account. */ diff --git a/server/src/main/resources/assets/css/error.css b/server/src/main/resources/assets/css/error.css index 2aaff6f3c..e7424991b 100644 --- a/server/src/main/resources/assets/css/error.css +++ b/server/src/main/resources/assets/css/error.css @@ -499,9 +499,93 @@ } } +/* Setup Admin Account Form */ +.setup-form { + max-width: 400px; + margin: 0 auto 1.5rem; + text-align: left; +} + +.setup-form__error { + background: rgba(239, 68, 68, 0.12); + border: 2px solid #ef4444; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; +} + +.setup-form__error p { + color: #991b1b; + font-weight: 500; + margin: 0; + font-size: 0.9375rem; +} + +.setup-form__actions { + margin-top: 1.25rem; +} + +.setup-form__submit { + width: 100%; + font-family: 'Kingthings Typewriter', monospace; + font-size: 1.125rem; + padding: 1rem 2rem; + background: #14532d; + color: white; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-md); +} + +.setup-form__submit:hover { + background: #166534; + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.setup-form__submit:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.setup-form__submit:focus-visible { + outline: 2px solid #22c55e; + outline-offset: 4px; +} + +/* Secondary "set up from the app" section */ +.setup-alt { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px dashed rgba(20, 83, 45, 0.3); +} + +.setup-alt__title { + font-family: 'Kingthings Typewriter', monospace; + font-size: 1rem; + color: #166534; + margin-bottom: 1rem; +} + +@media only screen and (max-width: 600px) { + .setup-form__submit { + font-size: 1rem; + padding: 0.875rem 1.75rem; + } +} + /* Reduced motion for setup */ @media (prefers-reduced-motion: reduce) { .error-card.setup .error-icon { animation: none; } + + .setup-form__submit, + .setup-form__submit:hover, + .setup-form__submit:active { + transition: none; + transform: none; + } } diff --git a/server/src/main/resources/i18n/Messages_en.properties b/server/src/main/resources/i18n/Messages_en.properties index 5bcf73c86..4b22f810c 100644 --- a/server/src/main/resources/i18n/Messages_en.properties +++ b/server/src/main/resources/i18n/Messages_en.properties @@ -679,6 +679,13 @@ setup_step2=Go to Settings and enter this server address: setup_step3=Create your account to complete setup setup_admin_note=The first account created will become the administrator setup_return_note=After creating your account, refresh this page to continue. +setup_form_password_label=Password +setup_form_confirm_password_label=Confirm Password +setup_form_submit=Create Admin Account +setup_error_fields_required=Please fill in all fields +setup_error_password_mismatch=Passwords do not match +setup_error_generic=Account creation could not be completed. Please try again. +setup_alt_title=Or set up from the Hammer app # Monitoring dashboard admin_nav_monitoring=Monitoring admin_mon_nav_overview=Overview diff --git a/server/src/main/resources/templates/setup.mustache b/server/src/main/resources/templates/setup.mustache index 12bdce081..49738bea5 100644 --- a/server/src/main/resources/templates/setup.mustache +++ b/server/src/main/resources/templates/setup.mustache @@ -13,29 +13,64 @@ {{msg.setup_admin_note}} -
    -
    - 1 - {{msg.setup_step1}} +
    +
    + +
    -
    - 2 - {{msg.setup_step2}} -
    -
    - - - - {{serverAddress}} -
    -
    - 3 - {{msg.setup_step3}} -
    -
    -
    -

    {{msg.setup_return_note}}

    +
    + + +

    {{msg.password_reset_requirements}}

    +
    + +
    + + +
    + + {{#error}} +
    +

    {{error}}

    +
    + {{/error}} + +
    + +
    + + +
    +

    {{msg.setup_alt_title}}

    + +
    +
    + 1 + {{msg.setup_step1}} +
    +
    + 2 + {{msg.setup_step2}} +
    +
    + + + + {{serverAddress}} +
    +
    + 3 + {{msg.setup_step3}} +
    +
    + +
    +

    {{msg.setup_return_note}}

    +
    diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/SetupFlowTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/SetupFlowTest.kt new file mode 100644 index 000000000..d7eaf3418 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/SetupFlowTest.kt @@ -0,0 +1,108 @@ +package com.darkrockstudios.apps.hammer.e2e + +import com.darkrockstudios.apps.hammer.e2e.util.EndToEndTest +import io.ktor.client.HttpClient +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.parameters +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SetupFlowTest : EndToEndTest() { + + private fun webClient() = HttpClient { + install(HttpCookies) + followRedirects = false + } + + private suspend fun HttpClient.postSetupForm( + email: String, + password: String, + confirmPassword: String, + ) = submitForm( + url = route("setup"), + formParameters = parameters { + append("email", email) + append("password", password) + append("confirmPassword", confirmPassword) + } + ) + + @Test + fun `creating the first admin account from the setup page logs in and lands on the admin dashboard`() { + doStartServer() + + runBlocking { + webClient().use { web -> + // With zero accounts the server is in setup mode: everything redirects to /setup + val dashboard = web.get(route("dashboard")) + assertEquals(HttpStatusCode.Found, dashboard.status) + assertEquals("/setup", dashboard.headers["Location"]) + + val response = web.postSetupForm("admin@test.com", "password123", "password123") + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/admin", response.headers["Location"]) + + val account = database().serverDatabase.accountQueries + .findAccount("admin@test.com").executeAsOne() + assertTrue(account.is_admin) + + // The session cookie set by the POST authorizes the admin dashboard + val admin = web.get(route("admin")) + assertEquals(HttpStatusCode.OK, admin.status) + } + } + } + + @Test + fun `setup mode ends after the first account is created`() { + doStartServer() + + runBlocking { + webClient().use { web -> + val created = web.postSetupForm("admin@test.com", "password123", "password123") + assertEquals(HttpStatusCode.Found, created.status) + + val setupAgain = web.get(route("setup")) + assertEquals(HttpStatusCode.Found, setupAgain.status) + assertEquals("/", setupAgain.headers["Location"]) + + // A stale or double-submitted form is bounced to login, not processed + webClient().use { fresh -> + val resubmit = fresh.postSetupForm("other@test.com", "password123", "password123") + assertEquals(HttpStatusCode.Found, resubmit.status) + assertEquals("/login", resubmit.headers["Location"]) + } + assertNull( + database().serverDatabase.accountQueries + .findAccount("other@test.com").executeAsOneOrNull() + ) + } + } + } + + @Test + fun `mismatched passwords re-render the form with an error and create no account`() { + doStartServer() + + runBlocking { + webClient().use { web -> + val response = web.postSetupForm("admin@test.com", "password123", "different456") + + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "Passwords do not match") + assertNull( + database().serverDatabase.accountQueries + .findAccount("admin@test.com").executeAsOneOrNull() + ) + } + } + } +} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPageTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPageTest.kt new file mode 100644 index 000000000..810c4a7a2 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/SetupPageTest.kt @@ -0,0 +1,234 @@ +package com.darkrockstudios.apps.hammer.frontend + +import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.account.AccountsRepository +import com.darkrockstudios.apps.hammer.admin.AdminServerConfig +import com.darkrockstudios.apps.hammer.admin.ConfigRepository +import com.darkrockstudios.apps.hammer.base.http.Token +import com.darkrockstudios.apps.hammer.frontend.data.UserSession +import com.darkrockstudios.apps.hammer.plugins.LOGIN_RATE_LIMIT +import com.darkrockstudios.apps.hammer.plugins.configureLocalization +import com.darkrockstudios.apps.hammer.plugins.configureTemplating +import com.darkrockstudios.apps.hammer.projects.ProjectsRepository +import com.darkrockstudios.apps.hammer.utilities.Msg +import com.darkrockstudios.apps.hammer.utilities.SResult +import com.darkrockstudios.apps.hammer.utils.BaseTest +import com.darkrockstudios.apps.hammer.utils.setupKtorTestKoin +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.parameters +import io.ktor.server.application.install +import io.ktor.server.plugins.ratelimit.RateLimit +import io.ktor.server.plugins.ratelimit.RateLimitName +import io.ktor.server.routing.routing +import io.ktor.server.sessions.Sessions +import io.ktor.server.sessions.cookie +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +class SetupPageTest : BaseTest() { + + private val accountsRepository: AccountsRepository = mockk() + private val projectsRepository: ProjectsRepository = mockk() + private val configRepository: ConfigRepository = mockk() + + private fun ApplicationTestBuilder.configureApp() { + application { + setupKtorTestKoin( + this@SetupPageTest, + module { + single { accountsRepository } + single { projectsRepository } + single { configRepository } + } + ) + configureTemplating() + configureLocalization() + install(RateLimit) { + register(RateLimitName(LOGIN_RATE_LIMIT)) { + rateLimiter(limit = 1_000_000, refillPeriod = 1.seconds) + } + } + install(Sessions) { + cookie(COOKIE_USER_SESSION) + } + routing { + setupPage(ServerConfig()) + } + } + } + + private fun mockPageModelDependencies() { + coEvery { configRepository.get(AdminServerConfig.ABOUT_SERVER) } returns "" + coEvery { configRepository.get(AdminServerConfig.DEFAULT_LOCALE) } returns "en" + } + + private suspend fun ApplicationTestBuilder.postSetupForm( + email: String, + password: String, + confirmPassword: String, + ) = createClient { followRedirects = false }.submitForm( + url = "/setup", + formParameters = parameters { + append("email", email) + append("password", password) + append("confirmPassword", confirmPassword) + } + ) + + @Test + fun `GET renders the admin account form while no users exist`() = testApplication { + coEvery { accountsRepository.hasUsers() } returns false + mockPageModelDependencies() + configureApp() + + val response = createClient { followRedirects = false }.get("/setup") + + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertContains(body, "action=\"/setup\"") + assertContains(body, "name=\"email\"") + assertContains(body, "name=\"confirmPassword\"") + } + + @Test + fun `GET redirects home once users exist`() = testApplication { + coEvery { accountsRepository.hasUsers() } returns true + configureApp() + + val response = createClient { followRedirects = false }.get("/setup") + + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/", response.headers["Location"]) + } + + @Test + fun `POST creates the admin account, sets a session, and redirects to the admin dashboard`() = + testApplication { + val token = Token(userId = 1, auth = "auth", refresh = "refresh") + coEvery { accountsRepository.hasUsers() } returns false + coEvery { + accountsRepository.createAccount("admin@test.com", "web", "password123") + } returns SResult.success(token) + coEvery { projectsRepository.createUserData(1) } returns Unit + coEvery { accountsRepository.isAdmin(1) } returns true + configureApp() + + val response = postSetupForm("admin@test.com", "password123", "password123") + + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/admin", response.headers["Location"]) + val setCookie = response.headers["Set-Cookie"] + assertNotNull(setCookie) + assertContains(setCookie, COOKIE_USER_SESSION) + coVerify(exactly = 1) { accountsRepository.createAccount("admin@test.com", "web", "password123") } + coVerify(exactly = 1) { projectsRepository.createUserData(1) } + } + + @Test + fun `POST with mismatched passwords re-renders with an error and never creates an account`() = + testApplication { + coEvery { accountsRepository.hasUsers() } returns false + mockPageModelDependencies() + configureApp() + + val response = postSetupForm("admin@test.com", "password123", "different456") + + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertContains(body, "Passwords do not match") + assertContains(body, "value=\"admin@test.com\"") + coVerify(exactly = 0) { accountsRepository.createAccount(any(), any(), any()) } + } + + @Test + fun `POST with empty fields re-renders with an error`() = testApplication { + coEvery { accountsRepository.hasUsers() } returns false + mockPageModelDependencies() + configureApp() + + val response = postSetupForm("", "", "") + + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "Please fill in all fields") + coVerify(exactly = 0) { accountsRepository.createAccount(any(), any(), any()) } + } + + @Test + fun `POST surfaces the repository's invalid email message and skips user data creation`() = + testApplication { + coEvery { accountsRepository.hasUsers() } returns false + coEvery { accountsRepository.createAccount(any(), any(), any()) } returns SResult.failure( + "invalid email", + Msg.r("api_accounts_create_error_invalidemail"), + ) + mockPageModelDependencies() + configureApp() + + val response = postSetupForm("not-an-email", "password123", "password123") + + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "Invalid e-mail address") + coVerify(exactly = 0) { projectsRepository.createUserData(any()) } + } + + @Test + fun `POST surfaces the repository's short password message`() = testApplication { + coEvery { accountsRepository.hasUsers() } returns false + coEvery { accountsRepository.createAccount(any(), any(), any()) } returns SResult.failure( + "password failure", + Msg.r("api_accounts_create_error_password_tooshort"), + ) + mockPageModelDependencies() + configureApp() + + val response = postSetupForm("admin@test.com", "short", "short") + + assertEquals(HttpStatusCode.OK, response.status) + assertContains(response.bodyAsText(), "Password too short") + } + + @Test + fun `POST after a user already exists redirects to login without creating anything`() = + testApplication { + coEvery { accountsRepository.hasUsers() } returns true + configureApp() + + val response = postSetupForm("admin@test.com", "password123", "password123") + + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/login", response.headers["Location"]) + assertNull(response.headers["Set-Cookie"]) + coVerify(exactly = 0) { accountsRepository.createAccount(any(), any(), any()) } + } + + @Test + fun `POST that loses the creation race to another account does not grant an admin session`() = + testApplication { + val token = Token(userId = 2, auth = "auth", refresh = "refresh") + coEvery { accountsRepository.hasUsers() } returns false + coEvery { + accountsRepository.createAccount("second@test.com", "web", "password123") + } returns SResult.success(token) + coEvery { projectsRepository.createUserData(2) } returns Unit + coEvery { accountsRepository.isAdmin(2) } returns false + configureApp() + + val response = postSetupForm("second@test.com", "password123", "password123") + + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/dashboard", response.headers["Location"]) + } +} From d1ea053c28d9979d5f7f65ec0191027f794fec4f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 19:04:59 -0700 Subject: [PATCH 038/213] Bake the changelog into the app instead of checking GitHub on load (#850) The app called the GitHub releases API from ProjectSelectionComponent.init to decide whether to show an "update available" dialog. That is an unprompted network request before the user has done anything, which has been raised as a privacy concern. The release notes are already in the repo at release time, so bake them in instead. prepareForRelease writes the new CHANGELOG.md entry to common/src/commonMain/composeResources/files/changelog.md and commits it alongside the version bump, so the resource cannot drift from the version in libs.versions.toml. On load, ChangelogRepository compares the baked entry's version against lastSeenChangelogVersion in global settings and shows a "What's New" dialog when they differ. Dismissing records the version. Fresh installs are seeded as already-seen, so only upgrading users get the popup; the old lastDismissedUpdateVersion key is dropped, which is safe because the TOML serializer ignores unknown keys. About drops its update check entirely and gains two buttons: Changes, which reopens the dialog with no network, and GitHub Release, which opens the browser. VersionCheckRepository stays for the protocol mismatch dialog, which only appears after the user has connected to a sync server and exists to tell them which version to get. It keeps its automatic check and is now that repository's only consumer. Links in the notes are clickable via linkifyChangelog, which handles [label](url) and bare urls, including urls containing parentheses. It is deliberately not a markdown renderer: entries lead with [New] / [Fix] tags that a real parser mangles. --- .../preview/ProjectSelectActivity.Preview.kt | 10 +- .../apps/hammer/base/community.kt | 1 + build.gradle.kts | 11 +- buildSrc/src/main/java/changelog.kt | 16 ++- .../composeResources/files/changelog.md | 10 ++ .../values-de/strings_about_app.xml | 10 +- .../values-es/strings_about_app.xml | 10 +- .../values-fr/strings_about_app.xml | 10 +- .../values-it/strings_about_app.xml | 10 +- .../values-pt-rBR/strings_about_app.xml | 10 +- .../values-uk/strings_about_app.xml | 10 +- .../values/strings_about_app.xml | 10 +- .../projectselection/ProjectSelection.kt | 19 ++- .../ProjectSelectionComponent.kt | 75 ++++------ .../projectselection/aboutapp/AboutApp.kt | 5 +- .../aboutapp/AboutAppComponent.kt | 36 ++--- .../hammer/common/data/changelog/Changelog.kt | 35 +++++ .../data/changelog/ChangelogDatasource.kt | 5 + .../data/changelog/ChangelogRepository.kt | 35 +++++ .../changelog/ResourceChangelogDatasource.kt | 24 ++++ .../data/globalsettings/GlobalSettings.kt | 2 +- .../globalsettings/GlobalSettingsStore.kt | 6 +- .../ShouldNotifyOfUpdateUseCase.kt | 19 --- .../versioncheck/VersionCheckRepository.kt | 20 +-- .../common/dependencyinjection/mainModule.kt | 10 +- .../ProjectSelectionComponentTest.kt | 129 ++++++------------ .../data/changelog/ChangelogParseTest.kt | 50 +++++++ .../data/changelog/ChangelogRepositoryTest.kt | 112 +++++++++++++++ .../ShouldNotifyOfUpdateUseCaseTest.kt | 71 ---------- .../VersionCheckRepositoryTest.kt | 12 -- ...eAvailableDialog.kt => ChangelogDialog.kt} | 84 ++++-------- .../projectselection/LinkifyChangelog.kt | 68 +++++++++ .../projectselection/ProjectSelectionUi.kt | 2 +- .../projectselection/about/AboutAppUi.kt | 60 ++++---- .../about/AboutAppUi.Preview.kt | 9 +- .../desktopTest/kotlin/ChangelogDialogTest.kt | 98 +++++++++++++ .../kotlin/LinkifyChangelogTest.kt | 101 ++++++++++++++ docs/HOW-TO-RELEASE.md | 3 + 38 files changed, 752 insertions(+), 456 deletions(-) create mode 100644 common/src/commonMain/composeResources/files/changelog.md create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/Changelog.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogDatasource.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogRepository.kt create mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ResourceChangelogDatasource.kt delete mode 100644 common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/ShouldNotifyOfUpdateUseCase.kt create mode 100644 common/src/desktopTest/kotlin/data/changelog/ChangelogParseTest.kt create mode 100644 common/src/desktopTest/kotlin/data/changelog/ChangelogRepositoryTest.kt delete mode 100644 common/src/desktopTest/kotlin/data/versioncheck/ShouldNotifyOfUpdateUseCaseTest.kt rename composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/{UpdateAvailableDialog.kt => ChangelogDialog.kt} (68%) create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/LinkifyChangelog.kt create mode 100644 composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/LinkifyChangelogTest.kt diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt index 9f1434baf..c094e6f5a 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt @@ -92,16 +92,16 @@ val component = object : ProjectSelection { override val navRailState: Value = MutableValue(ProjectSelection.NavRailState(expanded = false)) - override val updateNotification: Value = - MutableValue(ProjectSelection.UpdateNotificationState()) + override val changelog: Value = + MutableValue(ProjectSelection.ChangelogState()) override fun isAtRoot() = false override fun onBack() {} override fun showLocation(location: ProjectSelection.Locations) {} override fun toggleNavRailExpanded() {} - override fun openReleaseUrl() {} - override fun dismissUpdateNotification(remember: Boolean) {} - override fun showCurrentReleaseDetails() {} + override fun openLatestRelease() {} + override fun dismissChangelog() {} + override fun showChangelog() {} override val backHandler = dummyBackHandler } diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/community.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/community.kt index dec205d95..f462a7190 100644 --- a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/community.kt +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/community.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.apps.hammer.base const val GITHUB_URL = "https://github.com/Darkrock-Studios/hammer-editor/" +const val RELEASES_LATEST_URL = "${GITHUB_URL}releases/latest" const val DISCORD_URL = "https://discord.gg/GTmgjZcupk" const val REDDIT_URL = "https://www.reddit.com/r/hammereditor/" const val VERSION_CHECK_URL = "https://api.github.com/repos/Darkrock-Studios/hammer-editor/releases/latest" diff --git a/build.gradle.kts b/build.gradle.kts index 8b070e242..832d8f9c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ import com.darkrockstudios.build.storeNotesLength import com.darkrockstudios.build.updateFlatpakFiles import com.darkrockstudios.build.updateIosShortVersion import com.darkrockstudios.build.updateSnapcraftYaml +import com.darkrockstudios.build.writeBakedChangelog import com.darkrockstudios.build.writeChangelogMarkdown import com.darkrockstudios.build.writeSemvar import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @@ -224,7 +225,14 @@ tasks.register("prepareForRelease") { // Write the Global changelog file val globalChangelogFile = File("${project.rootDir}/CHANGELOG.md") - writeChangelogMarkdown(releaseInfo, globalChangelogFile) + val changelogEntry = writeChangelogMarkdown(releaseInfo, globalChangelogFile) + + // Bake the same entry into the app. Written for every release, including server-only + // ones, so the resource never drifts from the version in libs.versions.toml. + val bakedChangelogPath = + "common/src/commonMain/composeResources/files/changelog.md".replace("/", File.separator) + val bakedChangelogFile = project.rootDir.resolve(bakedChangelogPath) + writeBakedChangelog(changelogEntry, bakedChangelogFile) // Update snapcraft.yaml with new version and JVM version val snapcraftPath = "snap/snapcraft.yaml".replace("/", File.separator) @@ -273,6 +281,7 @@ tasks.register("prepareForRelease") { } git("add", versionsFile.absolutePath) git("add", globalChangelogFile.absolutePath) + git("add", bakedChangelogFile.absolutePath) git("add", snapcraftFile.absolutePath) git("add", iosInfoPlistFile.absolutePath) git("commit", "-m", "Prepared for release: v${releaseInfo.semVar}") diff --git a/buildSrc/src/main/java/changelog.kt b/buildSrc/src/main/java/changelog.kt index 23ecfddba..128ba6a7a 100644 --- a/buildSrc/src/main/java/changelog.kt +++ b/buildSrc/src/main/java/changelog.kt @@ -57,17 +57,27 @@ fun extractLatestChangelog(changelogFile: File): String? { return body.trim().ifEmpty { null } } -fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File) { +/** Prepends this release's entry to CHANGELOG.md and returns the entry text. */ +fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File): String { val currentChangelog = changelogFile.readText() val withoutHeader = currentChangelog.substring(currentChangelog.indexOf('\n') + 1) val headerDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-M-d")) - val newEntry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n\n" + val entry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n" - val newChangeLog = "# Changelog\n\n" + newEntry + withoutHeader + val newChangeLog = "# Changelog\n\n" + entry + "\n" + withoutHeader changelogFile.writeText(newChangeLog) println("CHANGELOG.md written") + + return entry +} + +/** The same entry, baked into the app so the client can show it without calling GitHub. */ +fun writeBakedChangelog(entry: String, bakedFile: File) { + bakedFile.parentFile.mkdirs() + bakedFile.writeText(entry) + println("Baked changelog written to ${bakedFile.path}") } /** diff --git a/common/src/commonMain/composeResources/files/changelog.md b/common/src/commonMain/composeResources/files/changelog.md new file mode 100644 index 000000000..e132d6ac5 --- /dev/null +++ b/common/src/commonMain/composeResources/files/changelog.md @@ -0,0 +1,10 @@ +## [3.7.2] - 2026-7-27 + +[Improve] +- Translations updated for German, Spanish, French, Italian, +Portuguese (Brazil), and Ukrainian +- Web: faster page loads and accessibility fixes across hammer.ink +- Web: Redesign home page + +[Server operators] +- Official Docker image for self-hosting the sync server diff --git a/common/src/commonMain/composeResources/values-de/strings_about_app.xml b/common/src/commonMain/composeResources/values-de/strings_about_app.xml index 03970ec10..c02873e4c 100644 --- a/common/src/commonMain/composeResources/values-de/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-de/strings_about_app.xml @@ -8,16 +8,12 @@ Github Quellen Bibliotheken - Version %s ist jetzt verfügbar - Aktualisiert! - Update anzeigen - Versionshinweise - Eine neue Version von Hammer ist verfügbar - Versionshinweise für %s + Änderungen + GitHub-Release + Was ist neu Versionshinweise Keine Versionshinweise vorhanden. Release öffnen - Schließen Anwendungsprotokolle Protokollverzeichnis: Protokolle exportieren diff --git a/common/src/commonMain/composeResources/values-es/strings_about_app.xml b/common/src/commonMain/composeResources/values-es/strings_about_app.xml index fd2c6ed65..fef0d4694 100644 --- a/common/src/commonMain/composeResources/values-es/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-es/strings_about_app.xml @@ -8,16 +8,12 @@ Github Atribución Bibliotecas - La versión %s ya está disponible - ¡Actualizado! - Ver actualización - Notas de la versión - Hay una nueva versión de Hammer disponible - Notas de la versión %s + Cambios + Versión en GitHub + Novedades Notas de la versión No se proporcionaron notas de la versión. Abrir versión - Descartar Registros de la aplicación Carpeta de registros: Exportar registros diff --git a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml index a0b65e573..54422ddf5 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml @@ -8,16 +8,12 @@ GitHub Attribution Bibliothèques - La version %s est désormais disponible - À jour ! - Voir la mise-à jour - Notes de version - Une nouvelle version de Hammer est disponible - Notes de version pour %s + Modifications + Version GitHub + Nouveautés Notes de version Aucune note de version publiée. Ouvrir la version - Ignorer Journaux de l'application Dossier des journaux : Exporter les journaux diff --git a/common/src/commonMain/composeResources/values-it/strings_about_app.xml b/common/src/commonMain/composeResources/values-it/strings_about_app.xml index 0d8dbc7cd..9fd146703 100644 --- a/common/src/commonMain/composeResources/values-it/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-it/strings_about_app.xml @@ -8,16 +8,12 @@ Github Attribuzione Librerie - La versione %s è ora disponibile - Aggiornato! - Visualizza aggiornamento - Note di rilascio - È disponibile una nuova versione di Hammer - Note di rilascio per %s + Modifiche + Versione su GitHub + Novità Note di rilascio Nessuna nota di rilascio fornita. Apri la versione - Ignora Log dell’applicazione Cartella dei log: Esporta i log diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml index 1ab028206..07e3ccb14 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml @@ -8,16 +8,12 @@ Github Atribuição Bibliotecas - A versão %s está disponível agora - Atualizado! - Ver Atualização - Notas de Versão - Uma nova versão do Hammer está disponível - Notas de versão para %s + Alterações + Versão no GitHub + Novidades Notas de versão Nenhuma nota de versão fornecida. Abrir Versão - Dispensar Logs do Aplicativo Diretório de Logs: Exportar Logs diff --git a/common/src/commonMain/composeResources/values-uk/strings_about_app.xml b/common/src/commonMain/composeResources/values-uk/strings_about_app.xml index b286946d0..7380feb22 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_about_app.xml @@ -8,16 +8,12 @@ Github Атрибуція Бібліотеки - Тепер доступна версія %s - Актуальна версія! - Переглянути оновлення - Примітки до випуску - Доступна нова версія Hammer - Примітки до випуску %s + Зміни + Випуск на GitHub + Що нового Примітки до випуску Приміток до випуску не надано. Відкрити випуск - Закрити Журнали застосунку Тека журналів: Експортувати журнали diff --git a/common/src/commonMain/composeResources/values/strings_about_app.xml b/common/src/commonMain/composeResources/values/strings_about_app.xml index 8985c3be2..9afe0e7cb 100644 --- a/common/src/commonMain/composeResources/values/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values/strings_about_app.xml @@ -11,17 +11,13 @@ Attribution Libraries - Version %s is now available - Up to date! - View Update - Release Notes + Changes + GitHub Release - A new version of Hammer is available - Release notes for %s + What's New Release notes No release notes provided. Open Release - Dismiss Application Logs Log Directory: diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt index 98f627d19..bdeef98e9 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt @@ -15,7 +15,7 @@ import org.jetbrains.compose.resources.StringResource interface ProjectSelection : HammerComponent, BackHandlerOwner { val stack: Value> val navRailState: Value - val updateNotification: Value + val changelog: Value fun isAtRoot(): Boolean fun onBack() @@ -23,20 +23,17 @@ interface ProjectSelection : HammerComponent, BackHandlerOwner { fun showLocation(location: Locations) fun toggleNavRailExpanded() - fun openReleaseUrl() - fun dismissUpdateNotification(remember: Boolean) - fun showCurrentReleaseDetails() + fun openLatestRelease() + fun dismissChangelog() + fun showChangelog() data class NavRailState(val expanded: Boolean) - data class UpdateNotificationState( + data class ChangelogState( val visible: Boolean = false, - val latestVersionTag: String? = null, - val releaseName: String? = null, - val releaseBody: String? = null, - val releaseUrl: String? = null, - val isNewVersionAvailable: Boolean = false, - val manuallyTriggered: Boolean = false, + val version: String? = null, + val date: String? = null, + val notes: String? = null, ) enum class Locations(val text: StringResource, val shortText: StringResource) { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt index 0435ef4aa..72bbfe18b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt @@ -16,9 +16,9 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.storyi import com.darkrockstudios.apps.hammer.common.data.ExampleProjectRepository import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore -import com.darkrockstudios.apps.hammer.common.data.versioncheck.GithubReleaseInfo -import com.darkrockstudios.apps.hammer.common.data.versioncheck.ShouldNotifyOfUpdateUseCase -import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository +import com.darkrockstudios.apps.hammer.base.RELEASES_LATEST_URL +import com.darkrockstudios.apps.hammer.common.data.changelog.Changelog +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository import com.darkrockstudios.apps.hammer.common.util.UrlLauncher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -32,8 +32,7 @@ class ProjectSelectionComponent( private val exampleProjectRepository: ExampleProjectRepository by inject() private val urlLauncher: UrlLauncher by inject() private val settingsRepository: GlobalSettingsStore by inject() - private val versionCheckRepository: VersionCheckRepository by inject() - private val shouldNotifyOfUpdate: ShouldNotifyOfUpdateUseCase by inject() + private val changelogRepository: ChangelogRepository by inject() private val navigation = StackNavigation() override val stack = childStack( @@ -49,8 +48,8 @@ class ProjectSelectionComponent( ) override val navRailState: Value = _navRailState - private val _updateNotification = MutableValue(ProjectSelection.UpdateNotificationState()) - override val updateNotification: Value = _updateNotification + private val _changelog = MutableValue(ProjectSelection.ChangelogState()) + override val changelog: Value = _changelog init { if (exampleProjectRepository.shouldInstallFirstTime()) { @@ -67,45 +66,31 @@ class ProjectSelectionComponent( } } + // Read from the changelog baked in at release time — nothing here touches the network. scope.launch { - val result = versionCheckRepository.checkForUpdate() - val dismissed = settingsRepository.globalSettings.lastDismissedUpdateVersion - if (shouldNotifyOfUpdate(result, dismissed)) { - val release = result.latestRelease ?: return@launch + if (changelogRepository.hasUnseenChangelog()) { + val changelog = changelogRepository.getChangelog() ?: return@launch withContext(dispatcherMain) { - _updateNotification.update { - release.toNotificationState( - isNewVersionAvailable = result.isNewVersionAvailable, - manuallyTriggered = false, - ) - } + _changelog.update { changelog.toState() } } } } } - override fun showCurrentReleaseDetails() { - val result = versionCheckRepository.currentResult() ?: return - val release = result.latestRelease ?: return - val next = release.toNotificationState( - isNewVersionAvailable = result.isNewVersionAvailable, - manuallyTriggered = true, - ) - if (next == _updateNotification.value) return - _updateNotification.update { next } + override fun showChangelog() { + scope.launch { + val changelog = changelogRepository.getChangelog() ?: return@launch + withContext(dispatcherMain) { + _changelog.update { changelog.toState() } + } + } } - private fun GithubReleaseInfo.toNotificationState( - isNewVersionAvailable: Boolean, - manuallyTriggered: Boolean, - ) = ProjectSelection.UpdateNotificationState( + private fun Changelog.toState() = ProjectSelection.ChangelogState( visible = true, - latestVersionTag = bareVersion, - releaseName = name, - releaseBody = body, - releaseUrl = htmlUrl, - isNewVersionAvailable = isNewVersionAvailable, - manuallyTriggered = manuallyTriggered, + version = version, + date = date, + notes = notes, ) override fun toggleNavRailExpanded() { @@ -114,18 +99,13 @@ class ProjectSelectionComponent( } } - override fun openReleaseUrl() { - _updateNotification.value.releaseUrl?.let { urlLauncher.openInBrowser(it) } + override fun openLatestRelease() { + urlLauncher.openInBrowser(RELEASES_LATEST_URL) } - override fun dismissUpdateNotification(remember: Boolean) { - val tag = _updateNotification.value.latestVersionTag - _updateNotification.update { it.copy(visible = false) } - if (remember && tag != null) { - scope.launch { - settingsRepository.updateSettings { it.copy(lastDismissedUpdateVersion = tag) } - } - } + override fun dismissChangelog() { + _changelog.update { it.copy(visible = false) } + scope.launch { changelogRepository.markSeen() } } private fun createChild( @@ -164,8 +144,7 @@ class ProjectSelectionComponent( componentContext = componentContext, urlLauncher = urlLauncher, updateShouldClose = { navigation.pop() }, - versionCheckRepository = versionCheckRepository, - onShowReleaseDetails = ::showCurrentReleaseDetails, + onShowChangelog = ::showChangelog, ) ) } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt index 9b15e44cb..cb07de6d3 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt @@ -11,13 +11,12 @@ interface AboutApp { fun openDiscord() fun openReddit() fun openGithub() - fun viewReleaseDetails() + fun viewChangelog() + fun openLatestRelease() @Serializable data class State( - val latestVersion: String? = null, val currentVersion: String = getAppVersionString(), - val newVersionAvailable: Boolean = false, val logDirectoryPath: String = "", ) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt index 0844bf6d4..f3573e438 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt @@ -3,49 +3,25 @@ package com.darkrockstudios.apps.hammer.common.components.projectselection.about import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value -import com.arkivanov.decompose.value.update import com.darkrockstudios.apps.hammer.base.DISCORD_URL import com.darkrockstudios.apps.hammer.base.GITHUB_URL import com.darkrockstudios.apps.hammer.base.REDDIT_URL +import com.darkrockstudios.apps.hammer.base.RELEASES_LATEST_URL import com.darkrockstudios.apps.hammer.common.components.ComponentBase -import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository import com.darkrockstudios.apps.hammer.common.getConfigDirectory import com.darkrockstudios.apps.hammer.common.getLogDirectory import com.darkrockstudios.apps.hammer.common.util.UrlLauncher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class AboutAppComponent( componentContext: ComponentContext, private val urlLauncher: UrlLauncher, private val updateShouldClose: () -> Unit, - private val versionCheckRepository: VersionCheckRepository, - private val onShowReleaseDetails: () -> Unit, + private val onShowChangelog: () -> Unit, ) : AboutApp, ComponentBase(componentContext) { private val _state = MutableValue(AboutApp.State(logDirectoryPath = getLogDirectoryPath())) override val state: Value = _state - init { - scope.launch { - versionCheckRepository.updates.collect { result -> - val tag = result.latestRelease?.bareVersion - withContext(dispatcherMain) { - _state.update { - it.copy( - latestVersion = tag, - newVersionAvailable = result.isNewVersionAvailable, - ) - } - } - } - } - - if (versionCheckRepository.currentResult() == null) { - scope.launch { versionCheckRepository.checkForUpdate() } - } - } - override fun openDiscord() { urlLauncher.openInBrowser(DISCORD_URL) } @@ -58,8 +34,12 @@ class AboutAppComponent( urlLauncher.openInBrowser(GITHUB_URL) } - override fun viewReleaseDetails() { - onShowReleaseDetails() + override fun viewChangelog() { + onShowChangelog() + } + + override fun openLatestRelease() { + urlLauncher.openInBrowser(RELEASES_LATEST_URL) } private fun getLogDirectoryPath(): String { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/Changelog.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/Changelog.kt new file mode 100644 index 000000000..15ee2c7a7 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/Changelog.kt @@ -0,0 +1,35 @@ +package com.darkrockstudios.apps.hammer.common.data.changelog + +import com.darkrockstudios.apps.hammer.base.BuildMetadata + +data class Changelog( + val version: String, + val date: String?, + val notes: String, +) + +private val HEADER = Regex("""^##\s*\[([^]]+)]\s*(?:-\s*(.+))?$""") + +/** + * Parses one `## [version] - date` CHANGELOG.md entry. A file without that header is still + * usable — it becomes notes for the running version — so a hand-edited resource never + * silently shows nothing. + */ +fun parseChangelog(text: String): Changelog? { + if (text.isBlank()) return null + + val lines = text.trimStart().lines() + val match = HEADER.matchEntire(lines.first().trim()) + ?: return Changelog( + version = BuildMetadata.APP_VERSION, + date = null, + notes = text.trim(), + ) + + val notes = lines.drop(1).joinToString("\n").trim() + return Changelog( + version = match.groupValues[1].trim(), + date = match.groupValues[2].trim().ifEmpty { null }, + notes = notes, + ) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogDatasource.kt new file mode 100644 index 000000000..f7e21f2b8 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogDatasource.kt @@ -0,0 +1,5 @@ +package com.darkrockstudios.apps.hammer.common.data.changelog + +interface ChangelogDatasource { + suspend fun loadChangelog(): Changelog? +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogRepository.kt new file mode 100644 index 000000000..c2b2ca946 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ChangelogRepository.kt @@ -0,0 +1,35 @@ +package com.darkrockstudios.apps.hammer.common.data.changelog + +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock + +/** + * The baked changelog plus whether the user has seen it. Loaded once per session; the + * resource can't change while the app runs. + */ +class ChangelogRepository( + private val datasource: ChangelogDatasource, + private val settingsStore: GlobalSettingsStore, +) { + private val lock = reentrantLock() + private var cached: Changelog? = null + + suspend fun getChangelog(): Changelog? { + lock.withLock { cached }?.let { return it } + + val changelog = datasource.loadChangelog() ?: return null + lock.withLock { cached = changelog } + return changelog + } + + suspend fun hasUnseenChangelog(): Boolean { + val changelog = getChangelog() ?: return false + return changelog.version != settingsStore.globalSettings.lastSeenChangelogVersion + } + + suspend fun markSeen() { + val version = getChangelog()?.version ?: return + settingsStore.updateSettings { it.copy(lastSeenChangelogVersion = version) } + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ResourceChangelogDatasource.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ResourceChangelogDatasource.kt new file mode 100644 index 000000000..b360558db --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/ResourceChangelogDatasource.kt @@ -0,0 +1,24 @@ +package com.darkrockstudios.apps.hammer.common.data.changelog + +import com.darkrockstudios.apps.hammer.Res +import io.github.aakira.napier.Napier +import org.jetbrains.compose.resources.ExperimentalResourceApi + +/** Reads the release notes baked in by `prepareForRelease`. No network, ever. */ +class ResourceChangelogDatasource : ChangelogDatasource { + + @OptIn(ExperimentalResourceApi::class) + @Suppress("TooGenericExceptionCaught") // A missing resource must not block app start + override suspend fun loadChangelog(): Changelog? { + return try { + parseChangelog(Res.readBytes(RESOURCE_PATH).decodeToString()) + } catch (e: Exception) { + Napier.w("Failed to read baked changelog", e) + null + } + } + + companion object { + private const val RESOURCE_PATH = "files/changelog.md" + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt index 8c2765362..d7f613edc 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt @@ -47,7 +47,7 @@ data class GlobalSettings( val installId: String? = null, val deviceLabel: String? = null, val initialProjectScreen: InitialProjectScreen = InitialProjectScreen.Home, - val lastDismissedUpdateVersion: String? = null, + val lastSeenChangelogVersion: String? = null, ) { companion object { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettingsStore.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettingsStore.kt index 0b0300822..c00733632 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettingsStore.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettingsStore.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.apps.hammer.common.data.globalsettings +import com.darkrockstudios.apps.hammer.base.BuildMetadata import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.GlobalSettingsDatasource import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.ServerSettingsDatasource import com.darkrockstudios.apps.hammer.common.fileio.HPath @@ -147,7 +148,10 @@ class GlobalSettingsStore( spellCheckSettings = SpellCheckerSettings( enabled = (hasSpForLang != null), locale = language.toLocale() - ) + ), + // A brand new install has nothing to catch up on, so seed the changelog as seen. + // Upgrading users have no such key in their TOML and get the popup. + lastSeenChangelogVersion = BuildMetadata.APP_VERSION, ) } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/ShouldNotifyOfUpdateUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/ShouldNotifyOfUpdateUseCase.kt deleted file mode 100644 index 4caa8cb60..000000000 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/ShouldNotifyOfUpdateUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.darkrockstudios.apps.hammer.common.data.versioncheck - -import com.darkrockstudios.apps.hammer.common.util.stripReleaseSuffix - -/** - * True when there's a new version the user hasn't already dismissed. - * Comparison is on bare semvers, so partial-release suffixes (`+google-play` - * etc.) don't fragment dismissals across stores. - */ -class ShouldNotifyOfUpdateUseCase { - operator fun invoke( - result: VersionCheckRepository.VersionCheckResult, - dismissed: String?, - ): Boolean { - if (!result.isNewVersionAvailable) return false - val release = result.latestRelease ?: return false - return stripReleaseSuffix(release.tagName) != dismissed?.let(::stripReleaseSuffix) - } -} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/VersionCheckRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/VersionCheckRepository.kt index bf153d177..1b0e54c8b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/VersionCheckRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/versioncheck/VersionCheckRepository.kt @@ -4,14 +4,14 @@ import com.darkrockstudios.apps.hammer.common.util.getAppVersionString import com.darkrockstudios.apps.hammer.common.util.isNewVersionAvailable import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.locks.withLock -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow /** * App-scoped cache of the latest GitHub release. One HTTP request per session - * unless [checkForUpdate] is called with `force = true`. Subscribers receive - * the cached result immediately via the `replay = 1` flow. + * unless [checkForUpdate] is called with `force = true`. + * + * The protocol mismatch dialog is the only consumer, and it only appears once the user has + * already connected to a sync server. Nothing on the app-load path may use this — the in-app + * changelog is baked in at release time precisely so startup never calls GitHub. */ class VersionCheckRepository( private val dataSource: VersionCheckDataSource, @@ -19,13 +19,6 @@ class VersionCheckRepository( private val lock = reentrantLock() private var cached: VersionCheckResult? = null - private val _updates = MutableSharedFlow( - extraBufferCapacity = 1, - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val updates: SharedFlow = _updates - suspend fun checkForUpdate(force: Boolean = false): VersionCheckResult { val existing = lock.withLock { cached } if (existing != null && !force) return existing @@ -37,12 +30,9 @@ class VersionCheckRepository( currentVersion = getAppVersionString(), ) lock.withLock { cached = result } - _updates.tryEmit(result) return result } - fun currentResult(): VersionCheckResult? = lock.withLock { cached } - data class VersionCheckResult( val latestRelease: GithubReleaseInfo?, val isNewVersionAvailable: Boolean, diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt index 9762c902c..002e478eb 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt @@ -92,8 +92,10 @@ import com.darkrockstudios.apps.hammer.common.data.tagindex.BuildTagIndexUseCase import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineDatasource import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogDatasource +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository +import com.darkrockstudios.apps.hammer.common.data.changelog.ResourceChangelogDatasource import com.darkrockstudios.apps.hammer.common.data.versioncheck.GithubVersionCheckDataSource -import com.darkrockstudios.apps.hammer.common.data.versioncheck.ShouldNotifyOfUpdateUseCase import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckDataSource import com.darkrockstudios.apps.hammer.common.data.protocolmismatch.ProtocolMismatchRepository import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository @@ -183,9 +185,13 @@ val mainModule = module { single() single() + // Only the protocol mismatch dialog checks GitHub, and only once the user has already + // connected to a sync server. Nothing on the app-load path may use this. single() bind VersionCheckDataSource::class single() - factory() + + single() bind ChangelogDatasource::class + single() factory() factory() diff --git a/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt index 9ce59a1b5..71e1884f2 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt @@ -12,16 +12,13 @@ import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.ClientAccountSynchronizer -import com.darkrockstudios.apps.hammer.common.data.versioncheck.GithubReleaseInfo -import com.darkrockstudios.apps.hammer.common.data.versioncheck.ShouldNotifyOfUpdateUseCase -import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository +import com.darkrockstudios.apps.hammer.common.data.changelog.Changelog +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath import com.darkrockstudios.apps.hammer.common.util.UrlLauncher import getProjectsDirectory import io.mockk.* -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle @@ -51,8 +48,7 @@ class ProjectSelectionComponentTest : BaseTest() { lateinit var projectsRepository: ProjectsRepository lateinit var exampleProjectRepository: ExampleProjectRepository lateinit var projectsSynchronizer: ClientAccountSynchronizer - lateinit var versionCheckRepository: VersionCheckRepository - lateinit var versionCheckUpdates: MutableSharedFlow + lateinit var changelogRepository: ChangelogRepository lateinit var urlLauncher: UrlLauncher private var globalSettings: GlobalSettings = GlobalSettings( @@ -75,24 +71,16 @@ class ProjectSelectionComponentTest : BaseTest() { projectsRepository = mockk() exampleProjectRepository = mockk() projectsSynchronizer = mockk() - versionCheckRepository = mockk() + changelogRepository = mockk(relaxed = true) urlLauncher = mockk(relaxed = true) - versionCheckUpdates = MutableSharedFlow( - extraBufferCapacity = 1, - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - every { versionCheckRepository.updates } returns versionCheckUpdates - val testModule = module { single { globalSettingsStore } bind GlobalSettingsStore::class single { projectsRepository } bind ProjectsRepository::class single { exampleProjectRepository } bind ExampleProjectRepository::class single { projectsSynchronizer } - single { versionCheckRepository } + single { changelogRepository } single { urlLauncher } bind UrlLauncher::class - factory { ShouldNotifyOfUpdateUseCase() } } setupKoin(testModule) lifecycle.resume() @@ -128,119 +116,90 @@ class ProjectSelectionComponentTest : BaseTest() { onProjectSelected = {}, ) - private fun release(tag: String) = GithubReleaseInfo( - tagName = tag, - name = "Release $tag", - body = "Notes for $tag", - htmlUrl = "https://github.com/Darkrock-Studios/hammer-editor/releases/tag/$tag", + private val changelog = Changelog( + version = "99.0.0", + date = "2026-8-4", + notes = "[New]\n- A thing", ) @Test - fun `Dialog visible on launch when new version available and not previously dismissed`() = runTest { - coEvery { versionCheckRepository.checkForUpdate(any()) } answers { - val result = VersionCheckRepository.VersionCheckResult( - latestRelease = release("v99.0.0"), - isNewVersionAvailable = true, - currentVersion = "v1.0.0", - ) - versionCheckUpdates.tryEmit(result) - result - } + fun `Dialog visible on launch when the baked changelog has not been seen`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns true + coEvery { changelogRepository.getChangelog() } returns changelog val component = newComponent() advanceUntilIdle() - val state = component.updateNotification.value + val state = component.changelog.value assertTrue(state.visible) - assertEquals("v99.0.0", state.latestVersionTag) - assertEquals("Notes for v99.0.0", state.releaseBody) + assertEquals("99.0.0", state.version) + assertEquals("2026-8-4", state.date) + assertEquals("[New]\n- A thing", state.notes) } @Test - fun `Dialog hidden when latest tag matches lastDismissedUpdateVersion`() = runTest { - globalSettings = globalSettings.copy(lastDismissedUpdateVersion = "v99.0.0") - coEvery { versionCheckRepository.checkForUpdate(any()) } returns - VersionCheckRepository.VersionCheckResult( - latestRelease = release("v99.0.0"), - isNewVersionAvailable = true, - currentVersion = "v1.0.0", - ) + fun `Dialog hidden on launch when the changelog has already been seen`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns false val component = newComponent() advanceUntilIdle() - assertFalse(component.updateNotification.value.visible) + assertFalse(component.changelog.value.visible) + coVerify(exactly = 0) { changelogRepository.getChangelog() } } @Test - fun `Dialog hidden when no new version is available`() = runTest { - coEvery { versionCheckRepository.checkForUpdate(any()) } returns - VersionCheckRepository.VersionCheckResult( - latestRelease = release("v1.0.0"), - isNewVersionAvailable = false, - currentVersion = "v1.0.0", - ) + fun `Launch never checks GitHub for a version`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns true + coEvery { changelogRepository.getChangelog() } returns changelog - val component = newComponent() + newComponent() advanceUntilIdle() - assertFalse(component.updateNotification.value.visible) + // The whole point of baking the changelog in: startup makes no network request. + coVerify(exactly = 0) { urlLauncher.openInBrowser(any()) } } @Test - fun `dismissUpdateNotification with remember writes lastDismissedUpdateVersion`() = runTest { - coEvery { versionCheckRepository.checkForUpdate(any()) } returns - VersionCheckRepository.VersionCheckResult( - latestRelease = release("v99.0.0"), - isNewVersionAvailable = true, - currentVersion = "v1.0.0", - ) - val captured = slot<(GlobalSettings) -> GlobalSettings>() - coEvery { globalSettingsStore.updateSettings(capture(captured)) } just Runs + fun `dismissChangelog hides the dialog and marks it seen`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns true + coEvery { changelogRepository.getChangelog() } returns changelog val component = newComponent() advanceUntilIdle() - component.dismissUpdateNotification(remember = true) + component.dismissChangelog() advanceUntilIdle() - assertFalse(component.updateNotification.value.visible) - val updated = captured.captured(globalSettings) - assertEquals("v99.0.0", updated.lastDismissedUpdateVersion) + assertFalse(component.changelog.value.visible) + coVerify(exactly = 1) { changelogRepository.markSeen() } } @Test - fun `dismissUpdateNotification without remember does not write settings`() = runTest { - coEvery { versionCheckRepository.checkForUpdate(any()) } returns - VersionCheckRepository.VersionCheckResult( - latestRelease = release("v99.0.0"), - isNewVersionAvailable = true, - currentVersion = "v1.0.0", - ) + fun `showChangelog reopens the dialog after it was dismissed`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns false + coEvery { changelogRepository.getChangelog() } returns changelog val component = newComponent() advanceUntilIdle() - component.dismissUpdateNotification(remember = false) + assertFalse(component.changelog.value.visible) + + component.showChangelog() advanceUntilIdle() - assertFalse(component.updateNotification.value.visible) - coVerify(exactly = 0) { globalSettingsStore.updateSettings(any()) } + assertTrue(component.changelog.value.visible) + assertEquals("99.0.0", component.changelog.value.version) } @Test - fun `openReleaseUrl launches the release URL`() = runTest { - coEvery { versionCheckRepository.checkForUpdate(any()) } returns - VersionCheckRepository.VersionCheckResult( - latestRelease = release("v99.0.0"), - isNewVersionAvailable = true, - currentVersion = "v1.0.0", - ) + fun `openLatestRelease launches the releases page`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns false val component = newComponent() advanceUntilIdle() - component.openReleaseUrl() + component.openLatestRelease() coVerify(exactly = 1) { - urlLauncher.openInBrowser("https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v99.0.0") + urlLauncher.openInBrowser("https://github.com/Darkrock-Studios/hammer-editor/releases/latest") } } } diff --git a/common/src/desktopTest/kotlin/data/changelog/ChangelogParseTest.kt b/common/src/desktopTest/kotlin/data/changelog/ChangelogParseTest.kt new file mode 100644 index 000000000..0b4717e53 --- /dev/null +++ b/common/src/desktopTest/kotlin/data/changelog/ChangelogParseTest.kt @@ -0,0 +1,50 @@ +package data.changelog + +import com.darkrockstudios.apps.hammer.base.BuildMetadata +import com.darkrockstudios.apps.hammer.common.data.changelog.parseChangelog +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ChangelogParseTest { + + @Test + fun `parses version date and notes from the header`() { + val changelog = parseChangelog( + """ + ## [3.7.2] - 2026-7-27 + + [Improve] + - Web: Redesign home page + """.trimIndent() + ) + + assertEquals("3.7.2", changelog?.version) + assertEquals("2026-7-27", changelog?.date) + assertEquals("[Improve]\n- Web: Redesign home page", changelog?.notes) + } + + @Test + fun `header without a date still parses`() { + val changelog = parseChangelog("## [3.7.2]\n\n- A thing") + + assertEquals("3.7.2", changelog?.version) + assertNull(changelog?.date) + assertEquals("- A thing", changelog?.notes) + } + + @Test + fun `text with no header falls back to the running version`() { + val changelog = parseChangelog("- Just some notes") + + assertEquals(BuildMetadata.APP_VERSION, changelog?.version) + assertNull(changelog?.date) + assertEquals("- Just some notes", changelog?.notes) + } + + @Test + fun `blank text is not a changelog`() { + assertNull(parseChangelog("")) + assertNull(parseChangelog(" \n \n")) + } +} diff --git a/common/src/desktopTest/kotlin/data/changelog/ChangelogRepositoryTest.kt b/common/src/desktopTest/kotlin/data/changelog/ChangelogRepositoryTest.kt new file mode 100644 index 000000000..2b87ebac6 --- /dev/null +++ b/common/src/desktopTest/kotlin/data/changelog/ChangelogRepositoryTest.kt @@ -0,0 +1,112 @@ +package data.changelog + +import com.darkrockstudios.apps.hammer.common.data.changelog.Changelog +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogDatasource +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import utils.BaseTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChangelogRepositoryTest : BaseTest() { + + private lateinit var datasource: ChangelogDatasource + private lateinit var settingsStore: GlobalSettingsStore + + private var settings = GlobalSettings( + projectsDirectory = "", + spellCheckSettings = SpellCheckerSettings(locale = mockk()), + ) + + private val changelog = Changelog(version = "3.7.2", date = "2026-7-27", notes = "- A thing") + + @BeforeEach + override fun setup() { + super.setup() + + datasource = mockk() + settingsStore = mockk(relaxed = true) + every { settingsStore.globalSettings } answers { settings } + } + + private fun repository() = ChangelogRepository(datasource, settingsStore) + + @Test + fun `Changelog is unseen when the stored version differs`() = runTest { + settings = settings.copy(lastSeenChangelogVersion = "3.6.0") + coEvery { datasource.loadChangelog() } returns changelog + + assertTrue(repository().hasUnseenChangelog()) + } + + @Test + fun `Changelog is unseen when nothing has been stored`() = runTest { + coEvery { datasource.loadChangelog() } returns changelog + + assertNull(settings.lastSeenChangelogVersion) + assertTrue(repository().hasUnseenChangelog()) + } + + @Test + fun `Changelog is seen when the stored version matches`() = runTest { + settings = settings.copy(lastSeenChangelogVersion = "3.7.2") + coEvery { datasource.loadChangelog() } returns changelog + + assertFalse(repository().hasUnseenChangelog()) + } + + @Test + fun `Missing resource never shows the dialog`() = runTest { + coEvery { datasource.loadChangelog() } returns null + + val repository = repository() + assertFalse(repository.hasUnseenChangelog()) + assertNull(repository.getChangelog()) + } + + @Test + fun `markSeen stores the changelog version`() = runTest { + coEvery { datasource.loadChangelog() } returns changelog + val captured = slot<(GlobalSettings) -> GlobalSettings>() + coEvery { settingsStore.updateSettings(capture(captured)) } just Runs + + repository().markSeen() + + assertEquals("3.7.2", captured.captured(settings).lastSeenChangelogVersion) + } + + @Test + fun `markSeen with no changelog writes nothing`() = runTest { + coEvery { datasource.loadChangelog() } returns null + + repository().markSeen() + + coVerify(exactly = 0) { settingsStore.updateSettings(any()) } + } + + @Test + fun `Resource is read once per session`() = runTest { + coEvery { datasource.loadChangelog() } returns changelog + + val repository = repository() + repository.getChangelog() + repository.getChangelog() + repository.hasUnseenChangelog() + + coVerify(exactly = 1) { datasource.loadChangelog() } + } +} diff --git a/common/src/desktopTest/kotlin/data/versioncheck/ShouldNotifyOfUpdateUseCaseTest.kt b/common/src/desktopTest/kotlin/data/versioncheck/ShouldNotifyOfUpdateUseCaseTest.kt deleted file mode 100644 index e2902f5cb..000000000 --- a/common/src/desktopTest/kotlin/data/versioncheck/ShouldNotifyOfUpdateUseCaseTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package data.versioncheck - -import com.darkrockstudios.apps.hammer.common.data.versioncheck.GithubReleaseInfo -import com.darkrockstudios.apps.hammer.common.data.versioncheck.ShouldNotifyOfUpdateUseCase -import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository -import org.junit.jupiter.api.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ShouldNotifyOfUpdateUseCaseTest { - - private val useCase = ShouldNotifyOfUpdateUseCase() - - private fun result( - tag: String?, - isNewVersionAvailable: Boolean = true, - ) = VersionCheckRepository.VersionCheckResult( - latestRelease = tag?.let { - GithubReleaseInfo( - tagName = it, - name = "Release $it", - body = "body", - htmlUrl = "https://example/$it", - ) - }, - isNewVersionAvailable = isNewVersionAvailable, - currentVersion = "v1.0.0", - ) - - @Test - fun `notifies when new version and no dismissal`() { - assertTrue(useCase(result("v1.2.4"), dismissed = null)) - } - - @Test - fun `does not notify when no new version available`() { - assertFalse(useCase(result("v1.2.4", isNewVersionAvailable = false), dismissed = null)) - } - - @Test - fun `does not notify when latest release is null`() { - assertFalse(useCase(result(tag = null), dismissed = null)) - } - - @Test - fun `does not notify when latest version equals dismissed`() { - assertFalse(useCase(result("v1.2.4"), dismissed = "v1.2.4")) - } - - @Test - fun `partial release notifies users who haven't dismissed`() { - assertTrue(useCase(result("v1.2.4+google-play"), dismissed = null)) - } - - @Test - fun `dismissing a partial release suppresses the eventual full release at the same version`() { - // User dismissed v1.2.4+google-play; later v1.2.4 (full) arrives — - // they shouldn't be re-prompted for the same nominal version. - assertFalse(useCase(result("v1.2.4"), dismissed = "v1.2.4+google-play")) - } - - @Test - fun `dismissing a full release suppresses a later partial release at the same version`() { - assertFalse(useCase(result("v1.2.4+fdroid"), dismissed = "v1.2.4")) - } - - @Test - fun `partial release of newer version still notifies even if older was dismissed`() { - assertTrue(useCase(result("v1.2.5+google-play"), dismissed = "v1.2.4")) - } -} diff --git a/common/src/desktopTest/kotlin/data/versioncheck/VersionCheckRepositoryTest.kt b/common/src/desktopTest/kotlin/data/versioncheck/VersionCheckRepositoryTest.kt index 95d787703..02a7ec0b2 100644 --- a/common/src/desktopTest/kotlin/data/versioncheck/VersionCheckRepositoryTest.kt +++ b/common/src/desktopTest/kotlin/data/versioncheck/VersionCheckRepositoryTest.kt @@ -80,16 +80,4 @@ class VersionCheckRepositoryTest { coVerify(exactly = 2) { dataSource.fetchLatestRelease() } } - @Test - fun `updates flow replays cached value for late subscribers`() = runTest { - val dataSource = mockk() - coEvery { dataSource.fetchLatestRelease() } returns release("v99.0.0") - val repo = VersionCheckRepository(dataSource) - - repo.checkForUpdate() - val replayed = repo.updates.replayCache.lastOrNull() - - assertNotNull(replayed) - assertEquals("v99.0.0", replayed?.latestRelease?.tagName) - } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/UpdateAvailableDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ChangelogDialog.kt similarity index 68% rename from composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/UpdateAvailableDialog.kt rename to composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ChangelogDialog.kt index 298f74d2c..35593cda4 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/UpdateAvailableDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ChangelogDialog.kt @@ -12,7 +12,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -26,20 +28,18 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMastheadAct import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.theme.hammerMonoFontFamily -import korlibs.io.lang.format /** - * GitHub release `body` is rendered verbatim as plain text — if a future - * contributor wants markdown rendering, add a real renderer rather than - * swapping in an unstyled drop-in. + * The release notes baked into the app by `prepareForRelease`. Never hits the network. + * Notes render as plain monospace text apart from links, which [linkifyChangelog] makes + * clickable — see that function for why this isn't a markdown renderer. */ @Composable -fun UpdateAvailableDialog(component: ProjectSelection) { - val state by component.updateNotification.subscribeAsState() - val tag = state.latestVersionTag +fun ChangelogDialog(component: ProjectSelection) { + val state by component.changelog.subscribeAsState() AnimatedDialog( - visible = state.visible && tag != null, - onCloseRequest = { component.dismissUpdateNotification(remember = false) }, + visible = state.visible && state.notes != null, + onCloseRequest = component::dismissChangelog, modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { @@ -55,54 +55,35 @@ fun UpdateAvailableDialog(component: ProjectSelection) { ) { Column { HdMasthead( - section = "UPDATE", - leadingMeta = listOfNotNull(tag), + section = "CHANGES", + leadingMeta = listOfNotNull(state.version, state.date), trailing = { HdMastheadAction( label = "× CLOSE", - onClick = { component.dismissUpdateNotification(remember = false) }, + onClick = component::dismissChangelog, ) }, ) HdFolioDivider() - Body( - isNewVersionAvailable = state.isNewVersionAvailable, - tag = tag.orEmpty(), - releaseName = state.releaseName, - releaseBody = state.releaseBody, - ) + Body(notes = state.notes) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp, ) - Footer( - onOpenRelease = component::openReleaseUrl, - onDismiss = { component.dismissUpdateNotification(remember = true) }, - manuallyTriggered = state.manuallyTriggered, - ) + Footer(onOpenRelease = component::openLatestRelease) } } } } @Composable -private fun Body( - isNewVersionAvailable: Boolean, - tag: String, - releaseName: String?, - releaseBody: String?, -) { - val title = if (isNewVersionAvailable) { - Res.string.update_dialog_title.get() - } else { - Res.string.update_dialog_title_current.get().format(tag) - } +private fun Body(notes: String?) { Column( modifier = Modifier.padding(start = 26.dp, end = 26.dp, top = 22.dp, bottom = 22.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = title, + text = Res.string.changelog_dialog_title.get(), style = MaterialTheme.typography.headlineSmall.copy( fontWeight = FontWeight.Normal, letterSpacing = (-0.26).sp, @@ -110,21 +91,18 @@ private fun Body( ), color = MaterialTheme.colorScheme.onSurface, ) - if (!releaseName.isNullOrBlank()) { - Text( - text = releaseName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - ReleaseNotesSection(releaseBody = releaseBody) + ReleaseNotesSection(notes = notes) } } @Composable -private fun ReleaseNotesSection(releaseBody: String?) { +private fun ReleaseNotesSection(notes: String?) { var expanded by remember { mutableStateOf(true) } val scroll = rememberScrollState() + val linkStyle = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( modifier = Modifier @@ -138,10 +116,10 @@ private fun ReleaseNotesSection(releaseBody: String?) { HdMonoLabel(text = Res.string.update_dialog_release_notes_section.get()) } AnimatedVisibility(visible = expanded) { - val text = releaseBody?.takeIf { it.isNotBlank() } + val text = notes?.takeIf { it.isNotBlank() } ?: Res.string.update_dialog_release_notes_empty.get() Text( - text = text, + text = linkifyChangelog(text, linkStyle), fontFamily = hammerMonoFontFamily(), fontSize = 12.sp, lineHeight = 18.sp, @@ -158,11 +136,7 @@ private fun ReleaseNotesSection(releaseBody: String?) { } @Composable -private fun Footer( - onOpenRelease: () -> Unit, - onDismiss: () -> Unit, - manuallyTriggered: Boolean, -) { +private fun Footer(onOpenRelease: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -172,14 +146,6 @@ private fun Footer( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Spacer(modifier = Modifier.weight(1f)) - if (!manuallyTriggered) { - TextButton( - onClick = onDismiss, - shape = RoundedCornerShape(4.dp), - ) { - Text(Res.string.update_dialog_dismiss_button.get()) - } - } OutlinedButton( onClick = onOpenRelease, shape = RoundedCornerShape(4.dp), diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/LinkifyChangelog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/LinkifyChangelog.kt new file mode 100644 index 000000000..aa12a46bb --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/LinkifyChangelog.kt @@ -0,0 +1,68 @@ +package com.darkrockstudios.apps.hammer.common.projectselection + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink + +/** + * Makes links in the changelog clickable while leaving everything else verbatim. Deliberately + * not a markdown renderer: changelog entries lead with `[New]` / `[Fix]` tags, which a real + * parser mangles. Only `[label](url)` and bare http(s) urls are touched. + */ +fun linkifyChangelog(text: String, linkStyle: SpanStyle): AnnotatedString { + val styles = TextLinkStyles(style = linkStyle) + return buildAnnotatedString { + var index = 0 + while (index < text.length) { + val match = LINK.find(text, index) + if (match == null) { + append(text.substring(index)) + break + } + + append(text.substring(index, match.range.first)) + + val label = match.groupValues[1] + val markdownUrl = match.groupValues[2] + if (markdownUrl.isNotEmpty()) { + withLink(LinkAnnotation.Url(markdownUrl, styles)) { append(label) } + index = match.range.last + 1 + } else { + val url = trimBareUrl(match.value) + withLink(LinkAnnotation.Url(url, styles)) { append(url) } + index = match.range.first + url.length + } + } + } +} + +/** + * Drops sentence punctuation that butted up against a bare url. A closing paren only counts + * as punctuation when it has no opener inside the url, so `…/Foo_(Bar)` survives while + * `(see https://example.com)` doesn't swallow the wrapping paren. + */ +private fun trimBareUrl(matched: String): String { + var url = matched + while (url.isNotEmpty()) { + val last = url.last() + val isTrailing = when (last) { + ')' -> url.count { it == ')' } > url.count { it == '(' } + else -> last in SENTENCE_PUNCTUATION + } + if (!isTrailing) break + url = url.dropLast(1) + } + return url +} + +/** + * The url of a markdown link may itself contain parentheses, so one nested balanced pair is + * allowed before the `)` that closes the link — otherwise `…/Rich_Text_Format_(RTF)` truncates. + */ +private val LINK = + Regex("""\[([^]\n]*)]\((https?://(?:[^()\s]|\([^()\s]*\))*)\)|https?://\S+""") + +private const val SENTENCE_PUNCTUATION = ".,;:!?" diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt index f40d54660..c70fad8d4 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt @@ -113,7 +113,7 @@ fun ProjectSelectionUi( modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) ) - UpdateAvailableDialog(component) + ChangelogDialog(component) } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt index 817f042b2..7cda0fbb9 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt @@ -29,7 +29,6 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.Github import com.darkrockstudios.apps.hammer.common.compose.icons.Reddit import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay -import korlibs.io.lang.format import org.jetbrains.compose.resources.painterResource private val MaxColumnWidth = 880.dp @@ -110,7 +109,11 @@ fun AboutAppUi(component: AboutApp, modifier: Modifier = Modifier) { ) } - VersionCard(state, onViewReleaseDetails = component::viewReleaseDetails) + VersionCard( + state = state, + onViewChangelog = component::viewChangelog, + onOpenLatestRelease = component::openLatestRelease, + ) HdHairlineSection( section = 4, @@ -243,46 +246,31 @@ private fun CommunityRowDivider() { ) } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun VersionCard(state: AboutApp.State, onViewReleaseDetails: () -> Unit) { - val statusGreeble = if (state.newVersionAvailable) "UPDATE AVAILABLE" else "UP TO DATE" - val latestGreeble = state.latestVersion?.let { "LATEST $it" } - val latest = state.latestVersion - val message = if (state.newVersionAvailable && latest != null) { - Res.string.about_version_new_available_message.get().format(latest) - } else { - Res.string.about_version_up_to_date.get() - } - val messageColor = if (state.newVersionAvailable) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - } - val buttonLabel = if (state.newVersionAvailable) { - Res.string.about_version_view_update_button.get() - } else { - Res.string.about_version_view_notes_button.get() - } - +private fun VersionCard( + state: AboutApp.State, + onViewChangelog: () -> Unit, + onOpenLatestRelease: () -> Unit, +) { HdCatalogueCard( topStart = "§ III · VERSION", - topEnd = statusGreeble, + topEnd = "INSTALLED", bottomStart = state.currentVersion, - bottomEnd = latestGreeble, ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = messageColor, + // FlowRow, not Row: the two labels wrap instead of clipping on a narrow screen. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + HdHairlineButton( + label = Res.string.about_version_changes_button.get(), + onClick = onViewChangelog, + ) + HdHairlineButton( + label = Res.string.about_version_github_button.get(), + onClick = onOpenLatestRelease, ) - if (latest != null) { - HdHairlineButton( - label = buttonLabel, - onClick = onViewReleaseDetails, - emphasised = state.newVersionAvailable, - ) - } } } } diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt index 3ad87e7c9..91bf450e0 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt @@ -31,15 +31,12 @@ fun ScreenAboutAppUiTabletPreview() { private val previewComponent = object : AboutApp { override val state = MutableValue( - AboutApp.State( - latestVersion = "v9.9.9", - currentVersion = "v1.0.0", - newVersionAvailable = true - ) + AboutApp.State(currentVersion = "v1.0.0") ) override fun openDiscord() {} override fun openReddit() {} override fun openGithub() {} - override fun viewReleaseDetails() {} + override fun viewChangelog() {} + override fun openLatestRelease() {} } \ No newline at end of file diff --git a/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt b/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt new file mode 100644 index 000000000..8d5ce3bcf --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt @@ -0,0 +1,98 @@ +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.backhandler.BackHandler +import com.darkrockstudios.apps.hammer.common.components.projectselection.ProjectSelection +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview +import com.darkrockstudios.apps.hammer.common.projectselection.ChangelogDialog +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class ChangelogDialogTest { + @get:Rule + val compose = createComposeRule() + + private class FakeProjectSelection( + state: ProjectSelection.ChangelogState, + ) : ProjectSelection { + var dismissCount = 0 + var openReleaseCount = 0 + + override val changelog: Value = MutableValue(state) + + override fun dismissChangelog() { + dismissCount++ + } + + override fun openLatestRelease() { + openReleaseCount++ + } + + override fun showChangelog() = Unit + + override val stack: Value> = + mockk() + override val navRailState: Value = + MutableValue(ProjectSelection.NavRailState(expanded = false)) + override val backHandler: BackHandler = mockk(relaxed = true) + override fun isAtRoot() = false + override fun onBack() = Unit + override fun showLocation(location: ProjectSelection.Locations) = Unit + override fun toggleNavRailExpanded() = Unit + } + + private fun content(component: ProjectSelection): @Composable () -> Unit = { + AppTheme(globalSettingsPreview) { + ChangelogDialog(component) + } + } + + @Test + fun `Dialog shows the baked version date and notes`() { + val component = FakeProjectSelection( + ProjectSelection.ChangelogState( + visible = true, + version = "3.7.2", + date = "2026-7-27", + notes = "[Improve]\n- Web: Redesign home page", + ) + ) + compose.setContent(content(component)) + + compose.onNodeWithText("3.7.2").assertIsDisplayed() + compose.onNodeWithText("2026-7-27").assertIsDisplayed() + compose.onNodeWithText("[Improve]\n- Web: Redesign home page").assertIsDisplayed() + } + + @Test + fun `Closing the dialog dismisses it`() { + val component = FakeProjectSelection( + ProjectSelection.ChangelogState(visible = true, version = "3.7.2", notes = "- A thing") + ) + compose.setContent(content(component)) + + compose.onNodeWithText("× CLOSE").performClick() + + assertEquals(1, component.dismissCount) + } + + @Test + fun `Release button opens the releases page`() { + val component = FakeProjectSelection( + ProjectSelection.ChangelogState(visible = true, version = "3.7.2", notes = "- A thing") + ) + compose.setContent(content(component)) + + compose.onNodeWithText("Open Release").performClick() + + assertEquals(1, component.openReleaseCount) + } +} diff --git a/composeUi/src/desktopTest/kotlin/LinkifyChangelogTest.kt b/composeUi/src/desktopTest/kotlin/LinkifyChangelogTest.kt new file mode 100644 index 000000000..d8c5698a7 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/LinkifyChangelogTest.kt @@ -0,0 +1,101 @@ +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import com.darkrockstudios.apps.hammer.common.projectselection.linkifyChangelog +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LinkifyChangelogTest { + + private fun linkify(text: String) = linkifyChangelog(text, SpanStyle()) + + private fun AnnotatedString.urls(): List = + getLinkAnnotations(0, length) + .mapNotNull { (it.item as? LinkAnnotation.Url)?.url } + + @Test + fun `markdown link renders its label and carries the url`() { + val result = linkify("- See [the docs](https://hammer.ink/docs) for more") + + assertEquals("- See the docs for more", result.text) + assertEquals(listOf("https://hammer.ink/docs"), result.urls()) + } + + @Test + fun `bare url links to itself`() { + val result = linkify("Visit https://hammer.ink today") + + assertEquals("Visit https://hammer.ink today", result.text) + assertEquals(listOf("https://hammer.ink"), result.urls()) + } + + @Test + fun `trailing sentence punctuation stays outside the href`() { + val result = linkify("Read https://hammer.ink/docs.") + + assertEquals("Read https://hammer.ink/docs.", result.text) + assertEquals(listOf("https://hammer.ink/docs"), result.urls()) + } + + @Test + fun `section tags are left untouched`() { + val notes = "[New]\n- Ideas: capture ideas for new stories\n\n[Fix]\n- Text editor crash" + val result = linkify(notes) + + assertEquals(notes, result.text) + assertTrue(result.urls().isEmpty()) + } + + @Test + fun `text with no links round-trips unchanged`() { + val notes = "- Translations updated for German, Spanish, French" + val result = linkify(notes) + + assertEquals(notes, result.text) + assertTrue(result.urls().isEmpty()) + } + + @Test + fun `markdown url containing parentheses is not truncated`() { + val url = "https://en.wikipedia.org/wiki/Rich_Text_Format_(RTF)" + val result = linkify("- See [the format]($url) for details") + + assertEquals("- See the format for details", result.text) + assertEquals(listOf(url), result.urls()) + } + + @Test + fun `bare url keeps its own balanced parentheses`() { + val url = "https://en.wikipedia.org/wiki/Rich_Text_Format_(RTF)" + val result = linkify("See $url today") + + assertEquals("See $url today", result.text) + assertEquals(listOf(url), result.urls()) + } + + @Test + fun `wrapping paren is not swallowed by a bare url`() { + val result = linkify("(see https://hammer.ink)") + + assertEquals("(see https://hammer.ink)", result.text) + assertEquals(listOf("https://hammer.ink"), result.urls()) + } + + @Test + fun `balanced parens survive trailing sentence punctuation`() { + val url = "https://example.com/a_(b)" + val result = linkify("Read $url.") + + assertEquals("Read $url.", result.text) + assertEquals(listOf(url), result.urls()) + } + + @Test + fun `multiple links in one line are all captured`() { + val result = linkify("[a](https://a.example) and https://b.example done") + + assertEquals("a and https://b.example done", result.text) + assertEquals(listOf("https://a.example", "https://b.example"), result.urls()) + } +} diff --git a/docs/HOW-TO-RELEASE.md b/docs/HOW-TO-RELEASE.md index 912651837..a8d3ef4df 100644 --- a/docs/HOW-TO-RELEASE.md +++ b/docs/HOW-TO-RELEASE.md @@ -7,6 +7,9 @@ - Increment app version `app` in `libs.versions.toml` - Add new changelog in `fastlane\metadata\android\en-US\changelogs` called `n.txt` where `n` is the android version code + - Bake the release notes into the app at + `common/src/commonMain/composeResources/files/changelog.md` — this is what the in-app + "What's New" dialog shows, so never edit it by hand - Merge `develop` into `release` - Tag the latest commit to make the release from in the [semvar](https://semver.org) format of `v1.1.1` From e836ee71829373c81f46cfbb3f56274fe05209fc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 20:51:46 -0700 Subject: [PATCH 039/213] Switching way from kotlinx datetime where possible --- base/build.gradle.kts | 1 - buildSrc/build.gradle.kts | 2 -- .../apps/hammer/common/util/DateTimeUtils.kt | 6 +++++- .../apps/hammer/common/util/DateTimeUtils.kt | 6 +++++- integrationTests/build.gradle.kts | 1 - server/build.gradle.kts | 2 -- .../apps/hammer/utilities/DateTimeUtils.kt | 13 ------------- 7 files changed, 10 insertions(+), 21 deletions(-) diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 2f2dc6fc0..78f323a15 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -47,7 +47,6 @@ kotlin { api(libs.koin.core) implementation(libs.serialization.core) implementation(libs.coroutines.core) - implementation(libs.kotlinx.datetime) implementation(libs.serialization.json) //implementation("org.kotlincrypto.endians:endians:0.1.0") //api("io.getstream:stream-result:1.1.0") diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index d2b1b89ce..9f8dca2a3 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,8 +8,6 @@ repositories { dependencies { implementation(kotlin("stdlib")) - //implementation(libs.markdown) - implementation(libs.kotlinx.datetime) // for the prepareForRelease dialog. implementation(libs.flatlaf) testImplementation(kotlin("test")) diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt index d73964c15..23b43987d 100644 --- a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt +++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt @@ -1,7 +1,11 @@ package com.darkrockstudios.apps.hammer.common.util -import kotlinx.datetime.* +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import java.time.format.DateTimeFormatter +import kotlin.time.Instant actual fun Instant.formatLocal(format: String): String = toLocalDateTime(TimeZone.currentSystemDefault()).format(format) diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt index d73964c15..23b43987d 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/util/DateTimeUtils.kt @@ -1,7 +1,11 @@ package com.darkrockstudios.apps.hammer.common.util -import kotlinx.datetime.* +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import java.time.format.DateTimeFormatter +import kotlin.time.Instant actual fun Instant.formatLocal(format: String): String = toLocalDateTime(TimeZone.currentSystemDefault()).format(format) diff --git a/integrationTests/build.gradle.kts b/integrationTests/build.gradle.kts index 70b5ee418..fded92d52 100644 --- a/integrationTests/build.gradle.kts +++ b/integrationTests/build.gradle.kts @@ -39,7 +39,6 @@ kotlin { implementation("org.jetbrains.compose.components:components-resources:${libs.versions.jetbrains.compose.get()}") implementation(libs.okio) implementation(libs.ktor.client.java) - implementation(libs.kotlinx.datetime) implementation(libs.serialization.json) implementation(project.dependencies.platform(libs.koin.bom)) diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 3f348926c..cd6c44f9a 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -80,7 +80,6 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.coroutines.jdk8) implementation(libs.serialization.jvm) - implementation(libs.kotlinx.datetime) implementation(libs.clikt) implementation(libs.bundles.ktor.server) @@ -169,7 +168,6 @@ dependencies { testFixturesApi(libs.bundles.junit.jupiter) testFixturesApi(libs.coroutines.core) testFixturesApi(libs.serialization.json) - testFixturesApi(libs.kotlinx.datetime) testFixturesApi(project.dependencies.platform(libs.koin.bom)) testFixturesApi(libs.koin.core) testFixturesApi(libs.embedded.postgres) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/DateTimeUtils.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/DateTimeUtils.kt index f94c143ca..63e865be2 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/DateTimeUtils.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/DateTimeUtils.kt @@ -1,30 +1,17 @@ package com.darkrockstudios.apps.hammer.utilities -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toJavaLocalDateTime -import kotlinx.datetime.toLocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlin.time.Instant import kotlin.time.toJavaInstant import kotlin.time.toKotlinInstant -fun Instant.formatLocal(format: String): String = - toLocalDateTime(TimeZone.currentSystemDefault()).format(format) - private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 /** Floor an instant to the start (00:00) of its UTC day. */ fun Instant.truncateToUtcDay(): Instant = Instant.fromEpochMilliseconds(toEpochMilliseconds() / MILLIS_PER_DAY * MILLIS_PER_DAY) -fun LocalDateTime.format(format: String): String = - DateTimeFormatter.ofPattern(format).format(this.toJavaLocalDateTime()) - -//ISO 8601 -fun Instant.toISO8601(): String = toString() - /** * Coerce an instant into the range a Postgres `TIMESTAMPTZ` column can hold. Instants near * [Instant.DISTANT_PAST]..[Instant.DISTANT_FUTURE] round-trip; values beyond reach further than an From 32d8b6be4af5086cd96d052c3f20d38e044ff013 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 20:53:39 -0700 Subject: [PATCH 040/213] Bump gradle --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ff31567bb..e95e47fb7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=30000 retries=3 retryBackOffMs=1000 From 0753c5f122793def593d0ad4333fa2c4553dfc7e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 21:25:19 -0700 Subject: [PATCH 041/213] Restore project-language string keys dropped by Crowdin sync (#851) The previous Crowdin sync PR (#847) overwrote strings_sync.xml from a stale copy that predated the project-language feature (#838), silently deleting 7 keys still referenced by ProjectInfoSettingsUi.kt, ProjectSettingsUi.kt, and ProjectDataConflict.kt. That broke compileAndroidMain and every other target across all client builds. --- .../commonMain/composeResources/values/strings_sync.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/common/src/commonMain/composeResources/values/strings_sync.xml b/common/src/commonMain/composeResources/values/strings_sync.xml index 4fa3410fb..4e287e863 100644 --- a/common/src/commonMain/composeResources/values/strings_sync.xml +++ b/common/src/commonMain/composeResources/values/strings_sync.xml @@ -45,6 +45,13 @@ Tags ↵ to add fantasy, draft, nanowrimo… + Language + Not set + Choose a language + Close + Search languages + Clear (not set) + No languages match Edit Cancel OK @@ -55,6 +62,7 @@ by %1$s by (no author set) Spell checking + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Project Settings · %1$s §§ %1$d @@ -64,6 +72,7 @@ Project Theme Word Count Goal Tags + Language (not set) day week From 7620fb81bc1f43778bbef6e6b6570996612933f6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 4 Aug 2026 21:25:39 -0700 Subject: [PATCH 042/213] New Crowdin updates (#848) * New translations messages_en.properties (French) [ci skip] * New translations messages_en.properties (Spanish) [ci skip] * New translations messages_en.properties (German) [ci skip] * New translations messages_en.properties (Italian) [ci skip] * New translations messages_en.properties (Ukrainian) [ci skip] * New translations messages_en.properties (Portuguese, Brazilian) [ci skip] * New translations strings_account_settings.xml (French) [ci skip] * New translations strings_account_settings.xml (Spanish) [ci skip] * New translations strings_account_settings.xml (German) [ci skip] * New translations strings_account_settings.xml (Italian) [ci skip] * New translations strings_account_settings.xml (Ukrainian) [ci skip] * New translations strings_account_settings.xml (Portuguese, Brazilian) [ci skip] * New translations strings_project_home.xml (French) [ci skip] * New translations strings_project_home.xml (Spanish) [ci skip] * New translations strings_project_home.xml (German) [ci skip] * New translations strings_project_home.xml (Italian) [ci skip] * New translations strings_project_home.xml (Ukrainian) [ci skip] * New translations strings_project_home.xml (Portuguese, Brazilian) [ci skip] * New translations strings_sync.xml (French) [ci skip] * New translations strings_sync.xml (Spanish) [ci skip] * New translations strings_sync.xml (German) [ci skip] * New translations strings_sync.xml (Italian) [ci skip] * New translations strings_sync.xml (Ukrainian) [ci skip] * New translations strings_sync.xml (Portuguese, Brazilian) [ci skip] * New translations full_description.txt (French) [ci skip] * New translations full_description.txt (Spanish) [ci skip] * New translations full_description.txt (German) [ci skip] * New translations full_description.txt (Italian) [ci skip] * New translations full_description.txt (Ukrainian) [ci skip] * New translations full_description.txt (Portuguese, Brazilian) [ci skip] * New translations strings_account_settings.xml (French) [ci skip] * New translations strings_project_home.xml (French) [ci skip] * New translations messages_en.properties (French) [ci skip] * Update source file Messages_en.properties [ci skip] * New translations messages_en.properties (French) [ci skip] * New translations messages_en.properties (Spanish) [ci skip] * New translations messages_en.properties (German) [ci skip] * New translations messages_en.properties (Italian) [ci skip] * New translations messages_en.properties (Ukrainian) [ci skip] * New translations messages_en.properties (Portuguese, Brazilian) [ci skip] * New translations strings_about_app.xml (French) [ci skip] * New translations strings_about_app.xml (Spanish) [ci skip] * New translations strings_about_app.xml (German) [ci skip] * New translations strings_about_app.xml (Italian) [ci skip] * New translations strings_about_app.xml (Ukrainian) [ci skip] * New translations strings_about_app.xml (Portuguese, Brazilian) [ci skip] * Update source file strings_about_app.xml [ci skip] * Restore project-language string keys dropped by Crowdin sync The prior Crowdin sync (#847) overwrote strings_sync.xml from a stale copy that predated the project-language feature (#838), silently deleting 7 keys still referenced by composeUi. Restoring them here so this sync's build compiles; see #851 for the same fix on develop. * Update source file strings_sync.xml [ci skip] --- .../values-de/strings_account_settings.xml | 1 + .../values-de/strings_project_home.xml | 5 + .../values-de/strings_sync.xml | 9 - .../values-es/strings_account_settings.xml | 1 + .../values-es/strings_project_home.xml | 5 + .../values-es/strings_sync.xml | 9 - .../values-fr/strings_account_settings.xml | 5 +- .../values-fr/strings_project_home.xml | 7 +- .../values-fr/strings_sync.xml | 9 - .../values-it/strings_account_settings.xml | 1 + .../values-it/strings_project_home.xml | 5 + .../values-it/strings_sync.xml | 9 - .../strings_account_settings.xml | 1 + .../values-pt-rBR/strings_project_home.xml | 5 + .../values-pt-rBR/strings_sync.xml | 9 - .../values-uk/strings_account_settings.xml | 1 + .../values-uk/strings_project_home.xml | 5 + .../values-uk/strings_sync.xml | 9 - .../android/de-DE/full_description.txt | 40 ++-- .../android/es-ES/full_description.txt | 40 ++-- .../android/fr-FR/full_description.txt | 40 ++-- .../android/it-IT/full_description.txt | 40 ++-- .../android/pt-BR/full_description.txt | 40 ++-- .../metadata/android/uk/full_description.txt | 40 ++-- .../resources/i18n/Messages_de.properties | 179 +++++++++++++++--- .../resources/i18n/Messages_es.properties | 179 +++++++++++++++--- .../resources/i18n/Messages_fr.properties | 179 +++++++++++++++--- .../resources/i18n/Messages_it.properties | 179 +++++++++++++++--- .../resources/i18n/Messages_pt_BR.properties | 179 +++++++++++++++--- .../resources/i18n/Messages_uk.properties | 179 +++++++++++++++--- 30 files changed, 1053 insertions(+), 357 deletions(-) diff --git a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml index c98b12fb1..41ac50011 100644 --- a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml @@ -110,4 +110,5 @@ Der Server reagiert nicht. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut. Server nicht gefunden. Überprüfen Sie die Serveradresse und Ihre Internetverbindung. Netzwerkfehler beim Verbinden mit %1$s. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-de/strings_project_home.xml b/common/src/commonMain/composeResources/values-de/strings_project_home.xml index f02f65c29..754ecfcb3 100644 --- a/common/src/commonMain/composeResources/values-de/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-de/strings_project_home.xml @@ -104,7 +104,12 @@ Kapitelgruppen erstellen Vorschau Nichts zu importieren + Reading your manuscript Erstellt %1$d Szene(n) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Abbrechen Importieren Einstellungen diff --git a/common/src/commonMain/composeResources/values-de/strings_sync.xml b/common/src/commonMain/composeResources/values-de/strings_sync.xml index 4d51fda9c..40b6920ad 100644 --- a/common/src/commonMain/composeResources/values-de/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-de/strings_sync.xml @@ -34,13 +34,6 @@ Tags ↵ zum Hinzufügen Fantasy, Entwurf, NaNoWriMo… - Language - Not set - Choose a language - Schließen - Search languages - Clear (not set) - No languages match Bearbeiten Abbrechen OK @@ -51,7 +44,6 @@ von %1$s von (kein Autor festgelegt) Rechtschreibprüfung - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Projekteinstellungen · %1$s §§ %1$d Projekteinstellungen-Konflikt @@ -60,7 +52,6 @@ Projekt-Farbschema Wortzahlziel Tags - Language (nicht festgelegt) Tag Woche diff --git a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml index 39d9b7fe0..4a0bae9a1 100644 --- a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml @@ -110,4 +110,5 @@ El servidor no responde. Comprueba tu conexión a internet e inténtalo de nuevo. Servidor no encontrado. Comprueba la dirección del servidor y tu conexión a internet. Error de red al conectar con %1$s. Comprueba tu conexión e inténtalo de nuevo. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-es/strings_project_home.xml b/common/src/commonMain/composeResources/values-es/strings_project_home.xml index 9bfeca1e8..223deca28 100644 --- a/common/src/commonMain/composeResources/values-es/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-es/strings_project_home.xml @@ -104,7 +104,12 @@ Crear grupos de capítulos Vista previa Nada que importar + Reading your manuscript Creará %1$d escena(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Cancelar Importar Ajustes diff --git a/common/src/commonMain/composeResources/values-es/strings_sync.xml b/common/src/commonMain/composeResources/values-es/strings_sync.xml index 18ac8dfa5..a57676194 100644 --- a/common/src/commonMain/composeResources/values-es/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-es/strings_sync.xml @@ -34,13 +34,6 @@ Etiquetas ↵ para añadir fantasía, borrador, nanowrimo… - Language - Not set - Choose a language - Cerrar - Search languages - Clear (not set) - No languages match Editar Cancelar OK @@ -51,7 +44,6 @@ por %1$s por (sin autor definido) Corrección ortográfica - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Ajustes del proyecto · %1$s §§ %1$d Conflicto de ajustes del proyecto @@ -60,7 +52,6 @@ Tema del proyecto Meta de recuento de palabras Etiquetas - Language (sin definir) día semana diff --git a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml index 421e6664d..9a0b7a870 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml @@ -4,7 +4,7 @@ INSTALLATION LOCALE PARAMÈTRES DU COMPTE Sauvegardes - Ce système de sauvegarde est destiné à protéger contre la perte et la corruption des données. Il n'est PAS conçu pour conserver d'anciennes versions de vos histoires. Utilisez le système de brouillons pour conserver vos données. + Ce système de sauvegarde est destiné à protéger contre la perte et la corruption des données. Il n'est PAS conçu pour conserver d'anciennes versions de vos histoires. Utilisez le système de brouillons pour conserver vos données Gérer les sauvegardes Thème de l'interface CLAIR @@ -17,7 +17,7 @@ Définissez le dossier que Hammer utilisera pour stocker les données de vos projets. Sélectionnez un dossier Projet de démonstration - Installez un projet de démonstration pour voir comment les différentes fonctionnalités sont utilisées. + Installez un projet de démonstration pour voir comment les différentes fonctionnalités sont utilisées Réinstaller Projet de démonstration créé. CONNECTÉ @@ -111,4 +111,5 @@ Des conflits peuvent se déclarer et causer des erreurs si les contenus ne corre Le serveur ne répond pas. Vérifiez votre connexion Internet et réessayez. Serveur introuvable. Vérifiez l'adresse du serveur et votre connexion Internet. Erreur réseau lors de la connexion à %1$s. Vérifiez votre connexion et réessayez. + Impossible d'établir une connection sécurisée à %1$s. Hammer se connecte uniquement via HTTPS, le serveur a besoin de son propre certificat TLS ou d'un proxy inverse qui lui en fournit un diff --git a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml index f42aa4cf7..8b385c13e 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml @@ -26,7 +26,7 @@ aucun changement vs semaine dernière aucune donnée la semaine dernière Aujourd'hui - Moy. quotidienne + Moy. quot. Série %1$d jours Plus longue : %1$d @@ -104,7 +104,12 @@ Créer des groupes de chapitres Aperçu Rien à importer + Lecture de votre manuscrit Créera %1$d scène(s) + Scène large + Une scène contient %1$s mots, donc la détection de chapitre a probablement manqué les titres de votre chapitre. Essayez un autre paramètre de détection + Les scènes %1$d contiennent des mots %2$s ou plus, donc la détection de chapitre a probablement manqué certains titres de chapitre. Essayez un autre paramètre de détection + %1$s S Annuler Importer Paramètres diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index e2eaa0a84..3c6d9af7b 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -34,13 +34,6 @@ Étiquettes ↵ ajouter fantaisie, brouillon, nanowrimo… - Language - Not set - Choose a language - Fermer - Search languages - Clear (not set) - No languages match Modifier Annuler OK @@ -51,7 +44,6 @@ par %1$s par (aucun auteur défini) Correction orthographique - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Paramètres du projet · %1$s §§ %1$d Conflit de paramètres du projet @@ -60,7 +52,6 @@ Thème du projet Objectif de nombre de mots Étiquettes - Language (non défini) jour semaine diff --git a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml index eca45ef27..df9c5851c 100644 --- a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml @@ -110,4 +110,5 @@ Il server non risponde. Controlla la tua connessione a internet e riprova. Server non trovato. Controlla l’indirizzo del server e la tua connessione a internet. Errore di rete durante la connessione a %1$s. Controlla la tua connessione e riprova. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-it/strings_project_home.xml b/common/src/commonMain/composeResources/values-it/strings_project_home.xml index 35548e43e..4d3f70efa 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_home.xml @@ -104,7 +104,12 @@ Crea gruppi di capitoli Anteprima Niente da importare + Reading your manuscript Creerà %1$d scene + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Annulla Importa Impostazioni diff --git a/common/src/commonMain/composeResources/values-it/strings_sync.xml b/common/src/commonMain/composeResources/values-it/strings_sync.xml index 626aecacd..8a56721ae 100644 --- a/common/src/commonMain/composeResources/values-it/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-it/strings_sync.xml @@ -34,13 +34,6 @@ Tag ↵ per aggiungere fantasy, bozza, nanowrimo… - Language - Not set - Choose a language - Chiudi - Search languages - Clear (not set) - No languages match Modifica Annulla OK @@ -51,7 +44,6 @@ di %1$s di (nessun autore impostato) Controllo ortografico - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Impostazioni del progetto · %1$s §§ %1$d Conflitto di impostazioni del progetto @@ -60,7 +52,6 @@ Tema del progetto Obiettivo di conteggio parole Tag - Language (non impostato) giorno settimana diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml index f4104e4da..63e7efb06 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml @@ -110,4 +110,5 @@ O servidor não está respondendo. Verifique sua conexão com a 'internet' e tente novamente. Servidor não encontrado. Verifique o endereço do servidor e sua conexão de 'internet'. Erro de rede ao conectar-se a %1$s. Verifique sua conexão e tente novamente. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml index 53ee366f6..50a49180e 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml @@ -104,7 +104,12 @@ Criar grupos de capítulos Prévia Nada para importar + Reading your manuscript Criará %1$d cena(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Cancelar Importar Configurações diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml index fb77f771a..11d5d580b 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml @@ -34,13 +34,6 @@ Etiquetas ↵ para adicionar fantasia, rascunho, nanowrimo… - Language - Not set - Choose a language - Fechar - Search languages - Clear (not set) - No languages match Editar Cancelar OK @@ -51,7 +44,6 @@ por %1$s por (nenhum autor definido) Corretor ortográfico - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Configurações do Projeto · %1$s §§ %1$d Conflito de Configurações do Projeto @@ -60,7 +52,6 @@ Tema do Projeto Meta de Contagem de Palavras Etiquetas - Language (não definido) dia semana diff --git a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml index 999f1d972..e6a0a0902 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml @@ -110,4 +110,5 @@ Сервер не відповідає. Перевірте підключення до Інтернету та спробуйте ще раз. Сервер не знайдено. Перевірте адресу сервера та підключення до Інтернету. Помилка мережі під час під'єднання до %1$s. Перевірте з'єднання та спробуйте ще раз. + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. diff --git a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml index 8fa48da49..51b275181 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml @@ -104,7 +104,12 @@ Створювати групи розділів Попередній перегляд Немає що імпортувати + Reading your manuscript Буде створено %1$d сцен + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W Скасувати Імпортувати Налаштування diff --git a/common/src/commonMain/composeResources/values-uk/strings_sync.xml b/common/src/commonMain/composeResources/values-uk/strings_sync.xml index db38fd497..bc8b92fe2 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_sync.xml @@ -34,13 +34,6 @@ Теги ↵ щоб додати фентезі, чернетка, nanowrimo… - Language - Not set - Choose a language - Закрити - Search languages - Clear (not set) - No languages match Редагувати Скасувати OK @@ -51,7 +44,6 @@ автор: %1$s автор: (не вказано) Перевірка правопису - Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Налаштування проєкту · %1$s §§ %1$d Конфлікт налаштувань проєкту @@ -60,7 +52,6 @@ Тема проєкту Ціль за кількістю слів Теги - Language (не встановлено) день тиждень diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index e21b7a24d..86866e0b7 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,21 +1,21 @@ -Bitte trete unserem Discord bei, um Probleme zu melden! https://discord.gg/GTmgjZcupk - -Plattformübergreifend - -Diese App ist wo immer du bist. Überall verfügbar – auf deinem Smartphone, Tablet, PC oder Laptop. Es ist nicht einfach nur eine "website-in-a-box". Es werden native Technologien des Endgerätes verwendet, um die bestmögliche Erfahrung zu bieten. - -Offline first - -Ich war frustriert über die meisten Tools zum Schreiben von Geschichten, die ich gefunden habe, da sie Web-Technologien verwenden (aka: "webpage in a box"), die immer in Schwierigkeiten zu geraten scheinen, während sie für längere Zeit offline verwendet werden. Hammer ist von Grund für die lokale Nutzung konzipiert, ohne, dass jemals eine Internetverbindung erforderlich ist. - -Datenhoheit - -Deine Daten gehören dir. Es wird nicht in der Cloud oder einer undurchsichtigen Datenbank gespeichert. Deine Geschichte wird in einfachen und lesbaren Dateien gespeichert (nur Dateien und Ordnern), um die Projektstruktur zu definieren. Du kannst deinem Betriebssystem-Dateimanager öffnen und dir die Daten selbst anschauen. Falls Hammer nicht mehr verfügbar ist, kannst du trotzdem einfach mit deinen Daten ingeragieren. - -Intelligente Synchronisierung zwischen Geräten - -Deine Daten können zwischen Geräten synchronisiert werden, sodass du von überall aus an deiner Geschichte weiterarbeiten kannst und keine Angst vor einer Änderung auf einem anderen Gerät haben musst. - -Synchronisierung: optional oder selbstbetrieben - +Bitte trete unserem Discord bei, um Probleme zu melden! https://discord.gg/GTmgjZcupk + +Plattformübergreifend + +Diese App ist wo immer du bist. Überall verfügbar – auf deinem Smartphone, Tablet, PC oder Laptop. Es ist nicht einfach nur eine "website-in-a-box". Es werden native Technologien des Endgerätes verwendet, um die bestmögliche Erfahrung zu bieten. + +Offline first + +Ich war frustriert über die meisten Tools zum Schreiben von Geschichten, die ich gefunden habe, da sie Web-Technologien verwenden (aka: "webpage in a box"), die immer in Schwierigkeiten zu geraten scheinen, während sie für längere Zeit offline verwendet werden. Hammer ist von Grund für die lokale Nutzung konzipiert, ohne, dass jemals eine Internetverbindung erforderlich ist. + +Datenhoheit + +Deine Daten gehören dir. Es wird nicht in der Cloud oder einer undurchsichtigen Datenbank gespeichert. Deine Geschichte wird in einfachen und lesbaren Dateien gespeichert (nur Dateien und Ordnern), um die Projektstruktur zu definieren. Du kannst deinem Betriebssystem-Dateimanager öffnen und dir die Daten selbst anschauen. Falls Hammer nicht mehr verfügbar ist, kannst du trotzdem einfach mit deinen Daten ingeragieren. + +Intelligente Synchronisierung zwischen Geräten + +Deine Daten können zwischen Geräten synchronisiert werden, sodass du von überall aus an deiner Geschichte weiterarbeiten kannst und keine Angst vor einer Änderung auf einem anderen Gerät haben musst. + +Synchronisierung: optional oder selbstbetrieben + Synchronisierung ist vollständig optional, du kannst den Client installieren und auf einem Gerät verwenden, oder du kannst den Server installieren und deine Daten zwischen den Geräten synchronisieren. \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index ac119a6da..38256b092 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multiplataforma - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Datos transparentes - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multiplataforma + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Datos transparentes + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 0f0c21734..fff9b8f79 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1,21 +1,21 @@ -Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk - -Multiplateforme - -Cette application est utilisable peu importe l'endroit où vous vous trouvez. Votre smartphone, tablette, ordinateur de bureau et ordinateur portable sera en mesure de pouvoir installer et utiliser ce programme, ce n'est pas une application web de poche, mais une véritable application client afin de fournir la meilleure expérience possible. - -Hors ligne avant tout - -J'ai été frustré par la plupart des logiciels de rédaction d'histoire que je trouvais puisqu'ils utilisaient tous des technologies web (ex : page web déguisé en application) qui semblent toujours rencontrer des problèmes lorsqu'ils sont utilisés hors ligne trop longtemps. Hammer a été conçu dans le but d'être entièrement fonctionnel en local, sans aucune connexion internet nécessaire, pour toujours. - -Transparence des données - -Vos données vous appartiennent. Ils ne sont pas stockés dans le cloud, ou dans une obscure base de données. Tout est stocké dans des fichiers humainement lisibles, utilisant uniquement des fichiers et des dossiers pour définir la structure du projet. Vous pouvez donc y jeter un œil par vous-même en naviguant dans vos fichiers. Si ce programme disparaissait aujourd'hui, vous serez en mesure d'interagir facilement avec vos données. - -Synchronisation intelligente entre vos appareils - -Vos données peuvent être synchronisées entre plusieurs appareils vous permettant de poursuivre l'écriture de votre histoire là où vous le souhaitez, sans avoir la crainte qu'une édition faite sur l'un de vos appareils n'en écrase une faite sur un autre. - -Synchronisation optionnelle et auto-hébergée - +Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk + +Multiplateforme + +Cette application est utilisable peu importe l'endroit où vous vous trouvez. Votre smartphone, tablette, ordinateur de bureau et ordinateur portable sera en mesure de pouvoir installer et utiliser ce programme, ce n'est pas une application web de poche, mais une véritable application client afin de fournir la meilleure expérience possible. + +Hors ligne avant tout + +J'ai été frustré par la plupart des logiciels de rédaction d'histoire que je trouvais puisqu'ils utilisaient tous des technologies web (ex : page web déguisé en application) qui semblent toujours rencontrer des problèmes lorsqu'ils sont utilisés hors ligne trop longtemps. Hammer a été conçu dans le but d'être entièrement fonctionnel en local, sans aucune connexion internet nécessaire, pour toujours. + +Transparence des données + +Vos données vous appartiennent. Ils ne sont pas stockés dans le cloud, ou dans une obscure base de données. Tout est stocké dans des fichiers humainement lisibles, utilisant uniquement des fichiers et des dossiers pour définir la structure du projet. Vous pouvez donc y jeter un œil par vous-même en naviguant dans vos fichiers. Si ce programme disparaissait aujourd'hui, vous serez en mesure d'interagir facilement avec vos données. + +Synchronisation intelligente entre vos appareils + +Vos données peuvent être synchronisées entre plusieurs appareils vous permettant de poursuivre l'écriture de votre histoire là où vous le souhaitez, sans avoir la crainte qu'une édition faite sur l'un de vos appareils n'en écrase une faite sur un autre. + +Synchronisation optionnelle et auto-hébergée + La synchronisation est entièrement optionnelle, vous pouvez installer le client et l'utiliser sur votre appareil sans aucune action supplémentaire, ou vous pouvez installer un serveur pour la synchronisation entre vos appareils. \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index d4c12dafa..70819e6e8 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Multipiattaforma - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Dati trasparenti - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Multipiattaforma + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Dati trasparenti + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt index dcf150a55..f3cef1b81 100644 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -1,21 +1,21 @@ -Por favor, junte-se ao nosso servidor de Discord para relatar problemas! https://discord.gg/GTmgjZcupk - -Multi-plataforma - -Este aplicativo está sempre ao seu lado. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. O Hammer é projetado do zero para ser completamente local, sem necessidade de conexão com a internet. - -Dados Transparentes - -Os seus dados são seus. Eles não são armazenados na nuvem ou algum banco de dados obscuro. Eles são armazenados em arquivos simples e legíveis por humanos, usando apenas arquivos e pastas para definir a estrutura do projeto. Você pode abrir o explorador de arquivos do seu sistema operacional e verificar por si mesmo. Se esse programa sumisse hoje, você ainda poderia facilmente interagir com seus dados. - -Sincronização inteligente entre aparelhos - -Seus dados podem ser sincronizados entre aparelhos, permitindo que você trabalhe na sua história em qualquer lugar, e sem temer que uma alteração em um aparelho sobrescreva a feita em outro. - -Sincronização Opcional e Auto-hospedada - +Por favor, junte-se ao nosso servidor de Discord para relatar problemas! https://discord.gg/GTmgjZcupk + +Multi-plataforma + +Este aplicativo está sempre ao seu lado. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. O Hammer é projetado do zero para ser completamente local, sem necessidade de conexão com a internet. + +Dados Transparentes + +Os seus dados são seus. Eles não são armazenados na nuvem ou algum banco de dados obscuro. Eles são armazenados em arquivos simples e legíveis por humanos, usando apenas arquivos e pastas para definir a estrutura do projeto. Você pode abrir o explorador de arquivos do seu sistema operacional e verificar por si mesmo. Se esse programa sumisse hoje, você ainda poderia facilmente interagir com seus dados. + +Sincronização inteligente entre aparelhos + +Seus dados podem ser sincronizados entre aparelhos, permitindo que você trabalhe na sua história em qualquer lugar, e sem temer que uma alteração em um aparelho sobrescreva a feita em outro. + +Sincronização Opcional e Auto-hospedada + Você pode instalar o cliente e usá-lo em um dispositivo, ou instalar o servidor e sincronizar seus dados com outros dispositivos. \ No newline at end of file diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 29b6fd2c0..ba77097fb 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -1,21 +1,21 @@ -Please join our Discord to report issues! https://discord.gg/GTmgjZcupk - -Багатоплатформність - -This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. - -Offline first - -I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. - -Прозорі дані - -Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. - -Intelligent Syncing between devices - -Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. - -Optional and Self hosted Syncing - +Please join our Discord to report issues! https://discord.gg/GTmgjZcupk + +Багатоплатформність + +This app is where ever you are. Your phone, tablet, desktop, laptop, this program can be installed and run, not simply a website-in-a-box, but instead using native client side technologies to provide the best experience possible. + +Offline first + +I was frustrated with most of the story writing software I was finding as they were using web technologies (aka: Web Page in a box) which always seems to run into problems while being used offline for long periods of time. Hammer is designed from the ground up to be entirely local, no internet connection required, ever. + +Прозорі дані + +Your data is yours. It's not stored in the cloud, or some opaque database. It is stored in simple, human readable files, just using files and folders to define the project structure. You can open your OSes file browser and take a look for your self. If this program went away today you would be able to easily interact with your data. + +Intelligent Syncing between devices + +Your data can be synchronized between devices allowing you to work on your story from anywhere, and have no fear of a change on one device, overwriting a change on another device. + +Optional and Self hosted Syncing + Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 11a017188..9062ae4e3 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -1,18 +1,122 @@ language=Deutsch page_title=Hammer -home_servermessage=Server-Nachricht +home_servermessage=Server notice home_servermessage_whitelist=Für diesen Server benötigst du eine Freigabe, bevor du ein Konto erstellen kannst. Bitte kontaktiere {0} um auf die Whitelist zu gelangen. -home_whatishammer_title=What is Hammer? -home_whatishammer_subtitle=Hammer is an open source tool for writing stories, and the worlds in which they exist. -home_feature_multiplatform_header=Plattformübergreifend -home_feature_multiplatform_body=Bring this app anywhere — on your phone, tablet, desktop, or laptop. It is not simply a website-in-a-box. It uses native client side technologies to provide the best experience possible. -home_feature_multiscreen_header=Multi-Screen -home_feature_multiscreen_body=Whether you are on a phone, tablet, or desktop, Hammer will make the best use of your screen space, expanding to give you space to work. Also supporting both light and dark modes. -home_feature_offlinefirst_header=Offline First -home_feature_offlinefirst_body=Hammer is designed from the ground up to be entirely local, no internet connection required, ever. You can sync to a server, either self-hosted or one hosted by a third party such as hammer.ink. But it's entirely optional. -home_feature_transparentdata_header=Datenhoheit -home_feature_transparentdata_body=Your data is yours. It's not stored in the cloud or some opaque database. It is stored in simple, human-readable files, just using files and folders to define the project structure. You can open your system's file browser and take a look for your self. If this program went away today, you would be able to easily interact with your data. -home_download_header=Get your Hammer\: +home_meta_description=Hammer is an open source tool for writing stories, and the worlds in which they exist. +home_meta_tagline=Own your book, build your world +# Masthead +home_masthead_title_first=Own your book, +home_masthead_title_second=build your world +home_masthead_subtitle=Free and open source. Works offline, on every device you own, and every chapter stays a plain text file on your own disk. +home_hero_download=Download free +home_hero_platforms=Windows, macOS, Linux, Android and iOS. Free and open source. +# Writing features +home_features_eyebrow=Writing in Hammer +home_features_title=Built for the way a novel actually gets written +home_features_lede=Scenes you can move, an encyclopedia that knows who is in them, a timeline, notes, and a record of what you actually wrote. +home_feature_editor_title=An editor that stays out of the way +home_feature_editor_body1=Your scene, the outline you wrote for it, and your notes about it, all on one screen. Word count and metadata sit to the side when you want them and disappear when you don't. +home_feature_editor_body2=Break the book into scenes as small as you like, then drag them into a different order when two characters need to meet earlier. +home_feature_editor_alt=The Hammer editor showing a scene, with the scene list on the left and outline, notes and references on the right +home_feature_encyclopedia_title=An encyclopedia that looks like your world +home_feature_encyclopedia_body1=Characters, places, events and anything else you invent, with pictures. Tag them, search them, keep them a keystroke from the page you are on. +home_feature_encyclopedia_body2=Hammer reads your scenes and works out who is in them. Open a character and it lists every scene they appear in; open a scene and it shows the characters and locations it found, which you confirm or dismiss. Nobody has to remember to tag anything. +home_feature_encyclopedia_body3=Which means you can answer the question every draft eventually raises\: where did she go? Three chapters without your antagonist show up as an absence you can see. +home_feature_encyclopedia_alt=The Hammer encyclopedia showing illustrated entries for characters, places and events +home_feature_timeline_title=See the plot laid out in order +home_feature_timeline_body1=Put the events of your story, or the history of your world, on a dated and tagged timeline, so you can see that the potion comes before the trial, and that three chapters pass without your antagonist. +home_feature_timeline_alt=The Hammer timeline listing dated, tagged story events +home_feature_notes_title=Somewhere to put the thought you just had +home_feature_notes_body1=The observation about your own book that arrives at the wrong moment. Tag it, find it later, keep it next to the manuscript instead of in a file on the desktop called notes2-final.txt. +home_feature_notes_alt=The Hammer notes view with tagged notes about a story +home_feature_activity_title=It counts what you wrote, not what survived +home_feature_activity_body1=Hammer watches each save and counts the words you added, ignoring deletions and rearrangements. Spend a day cutting a chapter to pieces and it still records the writing you did, because you did do it. +home_feature_activity_body2=Daily and weekly goals, today's count, this week against last, your current and longest streak, your best day, and a twelve-week heat map. It also knows your weekday average against your weekend one, which turns out to be more useful than it sounds. +home_feature_activity_body3=Sync, and sessions merge across devices\: the four hundred words you got at a bus stop land in the same total as the ones at your desk. Hammer can even tell you which device you write best on. +home_feature_activity_body4=And the log is a plain text file in your project folder, like everything else. Your writing history is yours to read, or to delete. +home_feature_activity_alt=The Hammer project home showing total words, a weekly goal bar, this week's total against last week, and a writing streak +# After the draft +home_afterdraft_eyebrow=After the draft +home_afterdraft_title=When the book has to leave your desk +home_afterdraft_lede=Writing is only half of it. At some point a draft has to reach an editor, a beta reader, an agent, or a stranger. That is usually where a writing app stops and the copy-paste starts. +home_feature_review_title=Send it to your editor. They don't need an account. +home_feature_review_body1=Choose the scenes, type an email address, and Hammer sends a link. Your editor opens it in a browser and marks your prose up with suggested edits. No download, no signup, no copy of your project handed over. +home_feature_review_body2=You see when they opened it, how many of the scenes they have read, and how many suggestions are waiting for you. Label each request for your own benefit\: my agent, beta read 2. Links expire, and you can revoke one at any point. +home_feature_review_alt=A review request open in a browser, with the editor's suggested edits marked on the manuscript and accept or reject controls beside each one +home_feature_private_title=Show it to three people +home_feature_private_body1=Private links are password protected, can carry an expiry date, and can be revoked whenever you like. Make a separate one per reader if you want to know who is still reading. +home_feature_private_alt=The create private access dialog asking for a password and an optional expiry date, over a story's sharing panel +home_feature_publish_title=Or publish it to everyone +home_feature_publish_body1=A public story page under your pen name, an author page collecting your published work, and a place in the community feed. +home_feature_publish_body2=You can see how many people actually read it, all time and day by day. +home_feature_publish_body3=And it tells you what publishing publicly can cost you before you do it, not after. +home_feature_publish_alt=A published story showing its public sharing link and a readers panel with a total count and a daily chart +home_feature_export_title=Five formats, and it tells you which to pick +home_feature_export_markdown_name=Markdown +home_feature_export_markdown_body=For version control, or moving into another tool. +home_feature_export_epub_name=EPUB +home_feature_export_epub_body=For beta readers on e-readers and phones, where the text has to reflow. +home_feature_export_pdf_name=PDF +home_feature_export_pdf_body=For when the layout must not move. +home_feature_export_word_name=Word +home_feature_export_word_body=For anyone who will leave comments or track changes. +home_feature_export_rtf_name=RTF +home_feature_export_rtf_body=For when you don't know what they use. +home_feature_export_body1=Hammer explains each one at the moment you are choosing, instead of leaving you to guess from a row of file extensions. +home_feature_export_alt=The export dialog with a format picker and an option to treat top-level scenes as chapters +# The three rules +home_rules_eyebrow=The three rules +home_rules_title=Now the part that matters in ten years +home_rules_lede=Everything you just saw is built on three rules. Every one of those features writes to plain files in a folder you own, which is the whole reason to pick this over the alternatives. +home_rule_offline_name=Offline first +home_rule_offline_title=It works with no internet, forever +home_rule_offline_body=Hammer has never required a connection and never will. Write in a cabin, on a plane, in a van parked somewhere with no signal. Sync between your devices if you want it, through a server like this one or one you run yourself, but nothing waits on a server and nothing breaks because a service is down. +home_rule_files_name=Transparent data +home_rule_files_title=Open the folder and it makes sense +home_rule_files_body=Not a database, not a proprietary bundle. Open the project folder with Hammer shut and the shape of your book is already there\: a file per scene, named the way you named it. Search it, back it up, mail it to yourself. Reading your own work should never require this program, or a computer science degree. +home_rule_safety_name=Never lose data +home_rule_safety_title=It is paranoid about losing your work +home_rule_safety_body=Losing your work is the one bug this program is not allowed to have. Sync starts by backing up the whole project. A draft saved before a big rewrite is always there to go back to. Unsaved edits are written to disk as you type, so a crash costs you a sentence at worst. When two devices disagree, Hammer asks which one is right instead of quietly picking. +# Origin story +home_origin_eyebrow=Why it works this way +home_origin_title=I wrote these rules after losing a manuscript +home_origin_body1=I once lived in a van, traveling and writing in often very remote places. I was not working at the time, and was using free writing software that claimed to support offline work. As I came to learn, that claim didn't hold up. After about a week offline, it stopped working. When I got back to civilization, it started working again, but my manuscript was gone. +home_origin_body2=I browsed the app's community forum and saw many other authors asking the same question I was\: Where did my manuscript go? +home_origin_body3=The seeds of the idea for Hammer were born out of this experience. Why was everything a webpage these days? I just wanted to use my own computer that was right in my hands to write and store my stories. +home_origin_caption=Where the three rules came from. September 2020 +home_origin_alt=A camper van parked on red dirt at sunset, its back doors open to show a bed and storage bins, a black dog and climbing gear on the ground beside it +home_origin_link=Read the whole story +# Price +home_price_eyebrow=What it costs +home_price_amount=Nothing +home_price_body=Hammer is free and open source under the MIT license. No trial, no card, no account needed to write, and no paid tier of the app. If this server vanished tomorrow, the copy on your machine would carry on working exactly as it does today. +# FAQ +home_faq_title=Reasonable questions +home_faq_account_q=Do I need an account? +home_faq_account_a=No. Download it and write. An account only exists so you can sync between devices, and it is entirely optional. +home_faq_where_q=Where does my writing live? +home_faq_where_a=In a folder you choose on your own device, as plain text files, one per scene, alongside small readable files for your notes and encyclopedia entries. +home_faq_export_q=Can I get my manuscript out? +home_faq_export_a=It was never in. Your scenes are already ordinary files you can open, copy, or hand to anyone, whether or not Hammer is installed. When you want one file instead of a folder, export the whole book as Markdown, EPUB, PDF, Word or RTF. +home_faq_editor_q=Does my editor need to buy anything? +home_faq_editor_a=No, and they don't need an account either. You send a link, they mark up the scenes you chose in their browser, and you get the suggestions back. The link expires and you can revoke it. +home_faq_goesaway_q=What happens if Hammer goes away? +home_faq_goesaway_a=Nothing happens to your book. It is already in a format every text editor on earth can open. The source is MIT licensed, so anyone who wants to keep the app alive can. +home_faq_privacy_q=Is my writing sent anywhere? +home_faq_privacy_a=Only if you switch sync on, and then only to the sync server you chose, whether that is this one or one you run yourself. +home_faq_platforms_q=Which platforms? +home_faq_platforms_a=Windows, macOS, Linux, Android and iOS. The same app and the same files on each, not a website in a box. +# Under the hood +home_hood_eyebrow=Under the hood +home_hood_title=For the technically minded +home_hood_native_header=Native, not a web page +home_hood_native_body=Built in Kotlin with Compose Multiplatform. One codebase, genuinely native clients on five platforms, not a website wrapped in an app shell. +home_hood_screens_header=Fits the screen you have +home_hood_screens_body=Phone, tablet, laptop or a large desktop display\: the layout expands to use the space you have. Light and dark themes throughout. +home_hood_license_header=MIT licensed, self-hostable +home_hood_license_body=Read the source, build it yourself, run your own sync server. No telemetry in the app, and nothing phoning home for a license check. +home_download_header=Get your Hammer +home_download_lede=Free, and free to keep. home_download_windows=Windows home_download_linux=Linux home_download_macos=macOS @@ -27,24 +131,6 @@ home_download_link_macos=Download Hammer for macOS home_download_link_linux=Download Hammer for Linux home_download_link_android=Download Hammer for Android home_download_link_ios=Download Hammer for iOS -# Section Labels -home_section_technical=Technical Features -home_section_writing=Writing Features -# Detailed Features -home_detail_scenes_title=Write in Scenes -home_detail_scenes_body=Break your story down into smaller scenes. Take notes alongside, write a short summary to help view your story in outline mode. -home_detail_organize_title=Organize your scenes -home_detail_organize_body=Need two characters to meet earlier? Simply drag and drop your scenes to change their order. -home_detail_drafts_title=Use Drafts of your scenes -home_detail_drafts_body=Save a Draft before a major rewrite of your scene. This makes major changes to your story less scary, because you can always go back. -home_detail_timeline_title=Write out your timeline -home_detail_timeline_body=Use the timeline feature to visualize the timeline of your plot or even your world. -home_detail_encyclopedia_title=Write your Encyclopedia -home_detail_encyclopedia_body=Make entries for your characters, places, events, anything you can think of. Add pictures to capture a thousand words. -home_detail_notes_title=Notizen -home_detail_notes_body=Need to capture a quick thought about your story or world? Throw it into the notes section. -home_masthead_title=Hammer\: -home_masthead_subtitle=A simple tool for building stories home_about_callout_title=Über diesen Server home_about_callout_text=Erfahren Sie mehr über diesen Hammer-Server home_header_title=Hammer @@ -68,6 +154,7 @@ footer_about=Info footer_terms=Nutzungsbedingungen terms_page_title=Nutzungsbedingungen footer_privacy=Datenschutz +footer_studio=Darkrock Studios privacy_page_title=Datenschutzerklärung admin_signin_header=Login admin_signin_email=E-Mail\: @@ -105,6 +192,7 @@ api_accounts_login_error_notfound=Kein Konto gefunden api_accounts_login_error_badpassword=Falsches Passwort api_accounts_login_error_invalid=Ungültige E-Mail oder Passwort api_accounts_login_error_notoken=Kein gültiges Token gefunden +api_accounts_login_error_pending_deletion=This account is pending deletion. Contact the server operator if you wish to restore it. api_accounts_tokenrefresh_error=Fehler beim Aktualisieren des Authentifizierungs-Tokens api_accounts_testauth_error=Authentifiziert als ${0,number} api_project_sync_begin_error_session=Benutzer {0} hat bereits eine Synchronisationssitzung @@ -426,6 +514,25 @@ bio_toast_cleared=Biografie gelöscht bio_validation_too_long=Die Biografie darf höchstens {0} Zeichen lang sein bio_error_no_penname=Sie müssen einen Autorennamen festlegen, bevor Sie eine Biografie hinzufügen bio_clear_confirm=Möchten Sie Ihre Biografie wirklich entfernen? +# Account Deletion +account_delete_title=Gefahrenzone +account_delete_warning=Deleting your account removes it and all of its data from this server. The files on your devices are not affected. +account_delete_button=Delete Account +account_delete_dialog_title=Delete Your Account +account_delete_dialog_devices_note=Your projects stay on your devices. Only the copies stored on this server are deleted. +account_delete_dialog_retention_note=Your data will be kept for {0} days. During that time you can contact the server operator to restore your account. +account_delete_dialog_permanent_note=After that, your account and all of its server data are deleted forever. +account_delete_dialog_email_note=While your data is retained, this email address stays reserved and cannot be used to create a new account. +account_delete_dialog_project_count_label=Projects that will be deleted from this server\: +account_delete_confirm_label=Type your email address to confirm\: +account_delete_confirm_button=Delete My Account +account_delete_cancel=Abbrechen +account_delete_error_email_mismatch=The email address you entered does not match your account +account_delete_error_admin=Administrator accounts cannot be deleted. Remove admin status from this account first. +account_delete_error_generic=Account deletion failed +admin_users_pending_deletion=Pending Deletion +admin_users_restore=Wiederherstellen +admin_users_restore_failed=Restore failed. That account no longer exists on this server. # Public Story Page public_story_by=Geschrieben von public_story_powered_by=Erstellt mit @@ -558,6 +665,11 @@ community_callout_sharing=teilen community_callout_stories=Geschichten community_callout_authors_only=Authors are ready to share their stories\! community_callout_empty=Be the first to join our writing community\! +# Header nav badge. The choice formats pick the singular so a one-author server does not read "1 authors". +community_badge_authors={0,choice,0\#authors|1\#author|1 Date: Tue, 4 Aug 2026 21:47:41 -0700 Subject: [PATCH 043/213] Prepared for release: v3.8.0 --- CHANGELOG.md | 39 +++++++++++++++++ .../composeResources/files/changelog.md | 43 +++++++++++++++---- .../android/en-US/changelogs/308010000.txt | 9 ++++ fastlane/metadata/ios/en-US/release_notes.txt | 22 ++++++++-- fastlane/metadata/osx/en-US/release_notes.txt | 22 ++++++++-- flatpak/studio.darkrock.hammer.metainfo.xml | 19 ++++++++ gradle/libs.versions.toml | 2 +- ios/ios/Info.plist | 2 +- snap/snapcraft.yaml | 2 +- 9 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/308010000.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 79caaa714..087380b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [3.8.0] - 2026-8-4 + +https://hammer.ink/blog/news/hammer-3-8-0 + +### New features + +- Set the language a project is written in, and have spell check follow it +- Self-service account deletion from the web dashboard, with an admin restore window +- Import and export documents as HTML, alongside Markdown +- "What's New" release notes shown in-app with no network request, and reopenable from About +- Import preview warns when a scene comes in unusually large, so a failed chapter split doesn't quietly collapse a manuscript into one scene + +### Improvements + +- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps +- Text editor: platform-correct key bindings on macOS, and AltGr chords type their character instead of firing shortcuts +- Large Markdown imports are dramatically faster: 1600 scenes went from 37 seconds to about 3 +- Search matches the rendered prose, resolving Markdown escapes and formatting +- Tag search matches accented and non-latin characters, and searches entry names as well as bodies +- Scene drag and drop stays accurate while the list autoscrolls +- Chapter detection on Markdown import understands Setext-style and bold titles +- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists +- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian +- Server: the setup page can create the initial admin account +- Server: a self-hosted server not serving HTTPS now reports a clear error instead of "Network error" +- Server: smaller Docker image +- Web: error responses show a toast instead of failing silently + +### Fixes + +- Client: importing a very large story no longer freezes the app +- Client: scene drag could grab the wrong row +- Client: literal `**` and `_` left around emphasis imported from RTF +- Client: sync server URLs are normalized consistently when saved +- Client: a number of text editor crashes around undo, paste, and joined paragraphs +- Web: htmx requests denied access landed on a blank page instead of being redirected +- Web: broken download link on the home page + + ## [3.7.2] - 2026-7-27 [Improve] diff --git a/common/src/commonMain/composeResources/files/changelog.md b/common/src/commonMain/composeResources/files/changelog.md index e132d6ac5..dc248b0e4 100644 --- a/common/src/commonMain/composeResources/files/changelog.md +++ b/common/src/commonMain/composeResources/files/changelog.md @@ -1,10 +1,37 @@ -## [3.7.2] - 2026-7-27 +## [3.8.0] - 2026-8-4 -[Improve] -- Translations updated for German, Spanish, French, Italian, -Portuguese (Brazil), and Ukrainian -- Web: faster page loads and accessibility fixes across hammer.ink -- Web: Redesign home page +https://hammer.ink/blog/news/hammer-3-8-0 -[Server operators] -- Official Docker image for self-hosting the sync server +### New features + +- Set the language a project is written in, and have spell check follow it +- Self-service account deletion from the web dashboard, with an admin restore window +- Import and export documents as HTML, alongside Markdown +- "What's New" release notes shown in-app with no network request, and reopenable from About +- Import preview warns when a scene comes in unusually large, so a failed chapter split doesn't quietly collapse a manuscript into one scene + +### Improvements + +- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps +- Text editor: platform-correct key bindings on macOS, and AltGr chords type their character instead of firing shortcuts +- Large Markdown imports are dramatically faster: 1600 scenes went from 37 seconds to about 3 +- Search matches the rendered prose, resolving Markdown escapes and formatting +- Tag search matches accented and non-latin characters, and searches entry names as well as bodies +- Scene drag and drop stays accurate while the list autoscrolls +- Chapter detection on Markdown import understands Setext-style and bold titles +- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists +- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian +- Server: the setup page can create the initial admin account +- Server: a self-hosted server not serving HTTPS now reports a clear error instead of "Network error" +- Server: smaller Docker image +- Web: error responses show a toast instead of failing silently + +### Fixes + +- Client: importing a very large story no longer freezes the app +- Client: scene drag could grab the wrong row +- Client: literal `**` and `_` left around emphasis imported from RTF +- Client: sync server URLs are normalized consistently when saved +- Client: a number of text editor crashes around undo, paste, and joined paragraphs +- Web: htmx requests denied access landed on a blank page instead of being redirected +- Web: broken download link on the home page diff --git a/fastlane/metadata/android/en-US/changelogs/308010000.txt b/fastlane/metadata/android/en-US/changelogs/308010000.txt new file mode 100644 index 000000000..82765fc1e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/308010000.txt @@ -0,0 +1,9 @@ +https://hammer.ink/blog/news/hammer-3-8-0 +New: +- Set the language a project is written in, and have spell check follow it +- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps +- Large Markdown imports are dramatically faster +- Search matches the rendered prose, resolving Markdown escapes and formatting… + +Full changelog: +https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.8.0 \ No newline at end of file diff --git a/fastlane/metadata/ios/en-US/release_notes.txt b/fastlane/metadata/ios/en-US/release_notes.txt index 5dcbeb3d0..7277ebd2e 100644 --- a/fastlane/metadata/ios/en-US/release_notes.txt +++ b/fastlane/metadata/ios/en-US/release_notes.txt @@ -1,6 +1,20 @@ -[Improve] -- Translations updated for German, Spanish, French, Italian, -Portuguese (Brazil), and Ukrainian +https://hammer.ink/blog/news/hammer-3-8-0 + +New: +- Set the language a project is written in, and have spell check follow it +- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps +- Large Markdown imports are dramatically faster +- Search matches the rendered prose, resolving Markdown escapes and formatting +- Tag search matches accented and non-latin characters, and searches entry names as well as bodies +- Chapter detection on Markdown import understands Setext-style and bold titles +- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists + +Fixes: +- importing a very large story no longer freezes the app +- scene drag could grab the wrong row +- literal `**` and `_` left around emphasis imported from RTF +- a number of text editor crashes around undo, paste, and joined paragraphs +- Scene drag and drop stays accurate while the list autoscrolls Full changelog: -https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.7.2 \ No newline at end of file +https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.8.0 \ No newline at end of file diff --git a/fastlane/metadata/osx/en-US/release_notes.txt b/fastlane/metadata/osx/en-US/release_notes.txt index 5dcbeb3d0..7277ebd2e 100644 --- a/fastlane/metadata/osx/en-US/release_notes.txt +++ b/fastlane/metadata/osx/en-US/release_notes.txt @@ -1,6 +1,20 @@ -[Improve] -- Translations updated for German, Spanish, French, Italian, -Portuguese (Brazil), and Ukrainian +https://hammer.ink/blog/news/hammer-3-8-0 + +New: +- Set the language a project is written in, and have spell check follow it +- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps +- Large Markdown imports are dramatically faster +- Search matches the rendered prose, resolving Markdown escapes and formatting +- Tag search matches accented and non-latin characters, and searches entry names as well as bodies +- Chapter detection on Markdown import understands Setext-style and bold titles +- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists + +Fixes: +- importing a very large story no longer freezes the app +- scene drag could grab the wrong row +- literal `**` and `_` left around emphasis imported from RTF +- a number of text editor crashes around undo, paste, and joined paragraphs +- Scene drag and drop stays accurate while the list autoscrolls Full changelog: -https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.7.2 \ No newline at end of file +https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.8.0 \ No newline at end of file diff --git a/flatpak/studio.darkrock.hammer.metainfo.xml b/flatpak/studio.darkrock.hammer.metainfo.xml index 3b318b5f0..e541858a8 100644 --- a/flatpak/studio.darkrock.hammer.metainfo.xml +++ b/flatpak/studio.darkrock.hammer.metainfo.xml @@ -32,6 +32,25 @@ + + +

    https://hammer.ink/blog/news/hammer-3-8-0

    +

    New:

    +

    - Set the language a project is written in, and have spell check follow it

    +

    - Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps

    +

    - Large Markdown imports are dramatically faster

    +

    - Search matches the rendered prose, resolving Markdown escapes and formatting

    +

    - Tag search matches accented and non-latin characters, and searches entry names as well as bodies

    +

    - Chapter detection on Markdown import understands Setext-style and bold titles

    +

    - Visible scrollbars on scrolling screens, and scroll-away footers float over their lists

    +

    Fixes:

    +

    - importing a very large story no longer freezes the app

    +

    - scene drag could grab the wrong row

    +

    - literal `**` and `_` left around emphasis imported from RTF

    +

    - a number of text editor crashes around undo, paste, and joined paragraphs

    +

    - Scene drag and drop stays accurate while the list autoscrolls

    +
    +

    [Improve]

    diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da7bb2054..04bd75043 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # App Versions -app = "3.7.2" +app = "3.8.0" data_version = "1.0" # Kotlin diff --git a/ios/ios/Info.plist b/ios/ios/Info.plist index d936c1f31..b4b18f29d 100644 --- a/ios/ios/Info.plist +++ b/ios/ios/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 3.7.2 + 3.8.0 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 220026715..2aada511f 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: hammer-editor base: core22 -version: '3.7.2' +version: '3.8.0' summary: A simple tool for building stories description: | Hammer is a multi-platform story editor application. It's designed for From 8897620f1f0ba21bf93e99b7fe734cfa97452a41 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 00:06:33 -0700 Subject: [PATCH 044/213] Add keyboard shortcuts doc --- docs/KEYBOARD-SHORTCUTS.md | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/KEYBOARD-SHORTCUTS.md diff --git a/docs/KEYBOARD-SHORTCUTS.md b/docs/KEYBOARD-SHORTCUTS.md new file mode 100644 index 000000000..9bdefc829 --- /dev/null +++ b/docs/KEYBOARD-SHORTCUTS.md @@ -0,0 +1,40 @@ +# Keyboard Shortcuts + +Desktop-only. `Ctrl` and `Cmd` are interchangeable everywhere below: `onKeyShortcut` +( +`composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt`) +treats `isCtrlPressed || isMetaPressed` as a single "ctrl" modifier, so these are not +macOS-specific `Cmd` bindings, they work with either key on every platform. + +## Application + +| Shortcut | Action | Source | +|----------------|-----------------------|----------------------------------------------------------------------| +| `Esc` | Navigate back | `ProjectEditorWindow.kt`, `ProjectSelectionWindow.kt` (`onKeyEvent`) | +| `Ctrl+Q` | Quit application | `ProjectEditorWindow.kt`, `ProjectSelectionWindow.kt` (`onKeyEvent`) | +| `Ctrl+W` | Close current project | `ProjectEditorWindow.kt` (`onKeyEvent`) | +| `Ctrl+Shift+F` | Open global search | `ProjectEditorWindow.kt` (`onKeyEvent`) | + +## Project (while a project is open) + +| Shortcut | Action | Source | +|--------------|----------------------------------------------|--------------------------------------------------| +| `Ctrl+Alt+S` | Save all dirty buffers (scenes, notes, etc.) | `saveAllShortcutModifier` via `ProjectRootUi.kt` | +| `F3` | Start project sync | `syncShortcutModifier` via `ProjectRootUi.kt` | + +## Editors (scene, note, timeline event, story idea) + +| Shortcut | Action | Source | +|----------|-----------------------|-------------------------------------------------------------------------------------------------------------| +| `Ctrl+S` | Save the current item | `saveShortcutModifier` via `SceneEditorUi.kt`, `ViewNoteUi.kt`, `ViewTimeLineEventUi.kt`, `StoryIdeasUi.kt` | +| `Ctrl+F` | Open find bar | `findShortcutModifier` via `SceneEditorUi.kt`, `FocusModeUi.kt` | + +## Rich Text Formatting + +Available anywhere `MarkdownFormatBar.kt` is used (scene, note, and encyclopedia editors). + +| Shortcut | Action | +|----------------|----------------------| +| `Ctrl+B` | Toggle bold | +| `Ctrl+I` | Toggle italic | +| `Ctrl+Shift+X` | Toggle strikethrough | From eda6a40a1aae8fc73865310c9398346180e31998 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 00:56:49 -0700 Subject: [PATCH 045/213] Fix tag suggestion click adding the typed prefix Suggestion chips were focusable, so pressing one pulled focus off the tag input and fired commit-on-focus-loss, which added the raw draft and cleared it. That emptied the strip mid-gesture, so the release never reached onSelect. --- .../designsystem/HdHairlineTagField.kt | 4 + .../kotlin/HdHairlineTagFieldTest.kt | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 composeUi/src/desktopTest/kotlin/HdHairlineTagFieldTest.kt diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt index 229592baf..feafe22b1 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineTagField.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag @@ -212,10 +213,13 @@ fun HdTagSuggestionStrip( verticalArrangement = Arrangement.spacedBy(6.dp), ) { suggestions.forEach { tag -> + // A focusable chip takes focus on press, which commits the raw draft as a tag and + // removes the strip before the release ever reaches onSelect. HdTagChip( label = tag, active = false, onClick = { onSelect(tag) }, + modifier = Modifier.focusProperties { canFocus = false }, ) } } diff --git a/composeUi/src/desktopTest/kotlin/HdHairlineTagFieldTest.kt b/composeUi/src/desktopTest/kotlin/HdHairlineTagFieldTest.kt new file mode 100644 index 000000000..e8b6708b3 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/HdHairlineTagFieldTest.kt @@ -0,0 +1,77 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineTagField +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +private const val FIELD_TAG = "tag-field" + +class HdHairlineTagFieldTest { + + @get:Rule + val compose = createComposeRule() + + private lateinit var tags: List + + private fun showField(suggestions: List) { + compose.setContent { + var current by remember { mutableStateOf(emptyList()) } + tags = current + Box(modifier = Modifier.size(600.dp, 300.dp)) { + HdHairlineTagField( + label = "TAGS", + tags = current, + onTagsChange = { current = it }, + suggestTags = { prefix -> suggestions.filter { it.startsWith(prefix) } }, + testTag = FIELD_TAG, + ) + } + } + } + + @Test + fun `picking a suggestion adds the suggested tag, not the typed prefix`() { + showField(listOf("shorts", "shortstory")) + + compose.onNodeWithTag(FIELD_TAG).performTextInput("short") + compose.onNodeWithText("shortstory").performClick() + compose.waitForIdle() + + assertEquals(listOf("shortstory"), tags) + } + + @Test + fun `picking a suggestion leaves focus in the field`() { + showField(listOf("shortstory")) + + compose.onNodeWithTag(FIELD_TAG).performTextInput("short") + compose.onNodeWithText("shortstory").performClick() + compose.waitForIdle() + + compose.onNodeWithTag(FIELD_TAG).assertIsFocused() + } + + @Test + fun `picking a suggestion keeps tags typed before it`() { + showField(listOf("shortstory")) + + compose.onNodeWithTag(FIELD_TAG).performTextInput("novel short") + compose.onNodeWithText("shortstory").performClick() + compose.waitForIdle() + + assertEquals(listOf("novel", "shortstory"), tags) + } +} From 43c25d9c1d456887a89af2b4871ddf77bf89d99c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 18:36:40 -0700 Subject: [PATCH 046/213] New Crowdin updates (#853) * New translations strings_sync.xml (French) [ci skip] * New translations strings_sync.xml (Spanish) [ci skip] * New translations strings_sync.xml (German) [ci skip] * New translations strings_sync.xml (Italian) [ci skip] * New translations strings_sync.xml (Ukrainian) [ci skip] * New translations strings_sync.xml (Portuguese, Brazilian) [ci skip] --- .../composeResources/values-de/strings_sync.xml | 9 +++++++++ .../composeResources/values-es/strings_sync.xml | 9 +++++++++ .../composeResources/values-fr/strings_sync.xml | 9 +++++++++ .../composeResources/values-it/strings_sync.xml | 9 +++++++++ .../composeResources/values-pt-rBR/strings_sync.xml | 9 +++++++++ .../composeResources/values-uk/strings_sync.xml | 9 +++++++++ 6 files changed, 54 insertions(+) diff --git a/common/src/commonMain/composeResources/values-de/strings_sync.xml b/common/src/commonMain/composeResources/values-de/strings_sync.xml index 40b6920ad..4d51fda9c 100644 --- a/common/src/commonMain/composeResources/values-de/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-de/strings_sync.xml @@ -34,6 +34,13 @@ Tags ↵ zum Hinzufügen Fantasy, Entwurf, NaNoWriMo… + Language + Not set + Choose a language + Schließen + Search languages + Clear (not set) + No languages match Bearbeiten Abbrechen OK @@ -44,6 +51,7 @@ von %1$s von (kein Autor festgelegt) Rechtschreibprüfung + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Projekteinstellungen · %1$s §§ %1$d Projekteinstellungen-Konflikt @@ -52,6 +60,7 @@ Projekt-Farbschema Wortzahlziel Tags + Language (nicht festgelegt) Tag Woche diff --git a/common/src/commonMain/composeResources/values-es/strings_sync.xml b/common/src/commonMain/composeResources/values-es/strings_sync.xml index a57676194..18ac8dfa5 100644 --- a/common/src/commonMain/composeResources/values-es/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-es/strings_sync.xml @@ -34,6 +34,13 @@ Etiquetas ↵ para añadir fantasía, borrador, nanowrimo… + Language + Not set + Choose a language + Cerrar + Search languages + Clear (not set) + No languages match Editar Cancelar OK @@ -44,6 +51,7 @@ por %1$s por (sin autor definido) Corrección ortográfica + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Ajustes del proyecto · %1$s §§ %1$d Conflicto de ajustes del proyecto @@ -52,6 +60,7 @@ Tema del proyecto Meta de recuento de palabras Etiquetas + Language (sin definir) día semana diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index 3c6d9af7b..e2eaa0a84 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -34,6 +34,13 @@ Étiquettes ↵ ajouter fantaisie, brouillon, nanowrimo… + Language + Not set + Choose a language + Fermer + Search languages + Clear (not set) + No languages match Modifier Annuler OK @@ -44,6 +51,7 @@ par %1$s par (aucun auteur défini) Correction orthographique + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Paramètres du projet · %1$s §§ %1$d Conflit de paramètres du projet @@ -52,6 +60,7 @@ Thème du projet Objectif de nombre de mots Étiquettes + Language (non défini) jour semaine diff --git a/common/src/commonMain/composeResources/values-it/strings_sync.xml b/common/src/commonMain/composeResources/values-it/strings_sync.xml index 8a56721ae..626aecacd 100644 --- a/common/src/commonMain/composeResources/values-it/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-it/strings_sync.xml @@ -34,6 +34,13 @@ Tag ↵ per aggiungere fantasy, bozza, nanowrimo… + Language + Not set + Choose a language + Chiudi + Search languages + Clear (not set) + No languages match Modifica Annulla OK @@ -44,6 +51,7 @@ di %1$s di (nessun autore impostato) Controllo ortografico + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Impostazioni del progetto · %1$s §§ %1$d Conflitto di impostazioni del progetto @@ -52,6 +60,7 @@ Tema del progetto Obiettivo di conteggio parole Tag + Language (non impostato) giorno settimana diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml index 11d5d580b..fb77f771a 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml @@ -34,6 +34,13 @@ Etiquetas ↵ para adicionar fantasia, rascunho, nanowrimo… + Language + Not set + Choose a language + Fechar + Search languages + Clear (not set) + No languages match Editar Cancelar OK @@ -44,6 +51,7 @@ por %1$s por (nenhum autor definido) Corretor ortográfico + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Configurações do Projeto · %1$s §§ %1$d Conflito de Configurações do Projeto @@ -52,6 +60,7 @@ Tema do Projeto Meta de Contagem de Palavras Etiquetas + Language (não definido) dia semana diff --git a/common/src/commonMain/composeResources/values-uk/strings_sync.xml b/common/src/commonMain/composeResources/values-uk/strings_sync.xml index bc8b92fe2..db38fd497 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_sync.xml @@ -34,6 +34,13 @@ Теги ↵ щоб додати фентезі, чернетка, nanowrimo… + Language + Not set + Choose a language + Закрити + Search languages + Clear (not set) + No languages match Редагувати Скасувати OK @@ -44,6 +51,7 @@ автор: %1$s автор: (не вказано) Перевірка правопису + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. Налаштування проєкту · %1$s §§ %1$d Конфлікт налаштувань проєкту @@ -52,6 +60,7 @@ Тема проєкту Ціль за кількістю слів Теги + Language (не встановлено) день тиждень From fcd94201ef4d6ae099b65065aa0fb8b80606e2a2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 21:20:40 -0700 Subject: [PATCH 047/213] Trust proxy forwarding so a proxied server sees real client addresses (#855) Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy. Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request. --- docs/HOW-TO-RUN-A-SERVER-DOCKER.md | 16 +++ docs/HOW-TO-RUN-A-SERVER.md | 37 +++++++ gradle/libs.versions.toml | 1 + server/build.gradle.kts | 1 + .../apps/hammer/ServerConfig.kt | 6 ++ .../apps/hammer/plugins/HTTP.kt | 9 ++ .../apps/hammer/e2e/BehindProxyTest.kt | 99 +++++++++++++++++++ 7 files changed, 169 insertions(+) create mode 100644 server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/BehindProxyTest.kt diff --git a/docs/HOW-TO-RUN-A-SERVER-DOCKER.md b/docs/HOW-TO-RUN-A-SERVER-DOCKER.md index e36a88c8f..30caaade8 100644 --- a/docs/HOW-TO-RUN-A-SERVER-DOCKER.md +++ b/docs/HOW-TO-RUN-A-SERVER-DOCKER.md @@ -61,6 +61,22 @@ To change the host-side binding or port: HAMMER_HTTP_BIND=0.0.0.0 HAMMER_HTTP_PORT=8090 docker compose up -d ``` +### Reverse proxy: set `trustProxyForwarding` + +With a proxy in front, every request reaches the container from the proxy's address, so the +login rate limiter treats the whole server as one client and the login audit trail records one +address for everyone. Setting `trustProxyForwarding = true` in `config.toml` makes Hammer read +the real client from `X-Forwarded-For`. See +[Behind a proxy](HOW-TO-RUN-A-SERVER.md#behind-a-proxy-every-request-looks-like-it-came-from-the-proxy) +for the full picture, the warning that goes with it, and what to do when a CDN sits in front of +your proxy. + +That warning matters more in a container than anywhere else: the flag is only safe while the +proxy is the *only* route in. Publishing the container port to `0.0.0.0` (above) reopens a +direct path, and with `trustProxyForwarding` on, anything reaching it can forge a client address per +request and walk through the rate limiter. Keep the publish on `127.0.0.1`, or on a Docker +network only the proxy shares. + ### A client that will not connect If a client reports that it could not make a secure connection, it reached the diff --git a/docs/HOW-TO-RUN-A-SERVER.md b/docs/HOW-TO-RUN-A-SERVER.md index 9e935b7eb..2345c74c8 100644 --- a/docs/HOW-TO-RUN-A-SERVER.md +++ b/docs/HOW-TO-RUN-A-SERVER.md @@ -456,6 +456,43 @@ bindHosts = ["127.0.0.1", "::1"] Make sure to update your DNS with your desired URL to be able to use LetsEncrypt and the like. +### Behind a proxy, every request looks like it came from the proxy + +If nothing proxies your server, skip this section: Hammer already sees each visitor's real +address, and nothing needs configuring. + +Behind a proxy it sees the proxy instead, identically for every visitor. That breaks three +things: the login rate limiter (10 attempts per minute) becomes one bucket shared by the whole +server rather than one per client, so a burst from anywhere locks everyone out; recorded login +IPs all read as the proxy; and public story reader counts collapse to a single visitor. + +Your proxy already sends the real values in `X-Forwarded-For` and `X-Forwarded-Proto` (both are +in the Nginx config below). Tell Hammer to trust them: + +```toml +# Default false. Only enable when clients cannot reach the server directly. +trustProxyForwarding = true +``` + +> [!WARNING] +> Only set this when the proxy is the *only* route to the server, which is what +> `bindHosts = ["127.0.0.1", "::1"]` above ensures. `X-Forwarded-For` is just a request header: +> anything that can reach Hammer directly can forge one, and with this on that means a fresh +> identity per request and a free pass through the login rate limiter. If your server is +> reachable both through the proxy and directly, leave it `false`. + +**One proxy only.** Hammer trusts the last `X-Forwarded-For` entry, which is the one your proxy +added. Put a CDN in front of Nginx (Cloudflare, Fastly) and that entry becomes the CDN's edge +node rather than the visitor, so have Nginx replace the header with the address the CDN reports: + +```nginx +# Cloudflare example. Keep set_real_ip_from restricted to the CDN's ranges, +# or anything can supply its own CF-Connecting-IP. +set_real_ip_from 173.245.48.0/20; # ...and the rest of Cloudflare's published ranges +real_ip_header CF-Connecting-IP; +proxy_set_header X-Forwarded-For $remote_addr; +``` + ### Base Nginx Config Create your base file. Make sure to change `hammer.example.com` to your domain! diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04bd75043..d4a50e337 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -262,6 +262,7 @@ ktor_server_mustache = { group = "io.ktor", name = "ktor-server-mustache", versi ktor_server_html_builder = { group = "io.ktor", name = "ktor-server-html-builder", version.ref = "ktor" } ktor_server_status_pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" } ktor_server_hsts = { group = "io.ktor", name = "ktor-server-hsts", version.ref = "ktor" } +ktor_server_forwarded_header = { group = "io.ktor", name = "ktor-server-forwarded-header", version.ref = "ktor" } # HTMX for Ktor ktor_htmx = { group = "io.ktor", name = "ktor-htmx", version.ref = "ktor" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index cd6c44f9a..23b6e5910 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(libs.bundles.ktor.server) implementation(libs.ktor.server.hsts) + implementation(libs.ktor.server.forwarded.header) implementation(libs.ktor.network.tlscertificates) implementation(libs.bouncycastle.bcpkix) implementation(libs.jakarta.mail) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt index 75ef69fae..f38f711c2 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt @@ -29,6 +29,12 @@ data class ServerConfig( * links are derived from each request's Host header. */ val publicUrl: String? = null, + /** + * Trust the `X-Forwarded-*` headers for each request's client address and scheme. Only safe + * when the proxy is the only route in, since anything reaching the server directly can forge + * them. Assumes a single proxy: the address comes from the last `X-Forwarded-For` entry. + */ + val trustProxyForwarding: Boolean = false, val additionalSitemaps: List = emptyList(), /** * Generate per-page social share images (OpenGraph) on the fly for author and story pages. diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/HTTP.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/HTTP.kt index cd43306f0..da89256d5 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/HTTP.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/HTTP.kt @@ -17,12 +17,21 @@ import io.ktor.server.plugins.compression.gzip import io.ktor.server.plugins.compression.minimumSize import io.ktor.server.plugins.conditionalheaders.ConditionalHeaders import io.ktor.server.plugins.defaultheaders.DefaultHeaders +import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders import io.ktor.server.plugins.hsts.HSTS import io.ktor.server.plugins.httpsredirect.HttpsRedirect import io.ktor.server.request.path import io.ktor.server.routing.IgnoreTrailingSlash fun Application.configureHTTP(config: ServerConfig) { + if (config.trustProxyForwarding) { + install(XForwardedHeaders) { + // Ktor defaults to the first X-Forwarded-For entry, which the client supplies. + // Only the last is the adjacent proxy's own observation of who connected. + useLastProxy() + } + } + val analyticsProvider = config.analytics.provider val analyticsScriptHosts = analyticsProvider?.scriptSrcHosts().orEmpty() val analyticsConnectHosts = analyticsProvider?.connectSrcHosts().orEmpty() diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/BehindProxyTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/BehindProxyTest.kt new file mode 100644 index 000000000..fc49e3a93 --- /dev/null +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/BehindProxyTest.kt @@ -0,0 +1,99 @@ +package com.darkrockstudios.apps.hammer.e2e + +import com.darkrockstudios.apps.hammer.EncryptionConfig +import com.darkrockstudios.apps.hammer.EncryptionMode +import com.darkrockstudios.apps.hammer.ServerConfig +import com.darkrockstudios.apps.hammer.base.http.HAMMER_PROTOCOL_HEADER +import com.darkrockstudios.apps.hammer.base.http.HAMMER_PROTOCOL_VERSION +import com.darkrockstudios.apps.hammer.e2e.util.EndToEndTest +import com.darkrockstudios.apps.hammer.utils.SERVER_CONFIG_ONE +import com.darkrockstudios.apps.hammer.utils.createTestServer +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * The login rate limiter keys on the connecting address. Behind a proxy every request carries + * the proxy's, so `trustProxyForwarding` decides whether one client can exhaust the bucket for + * everyone. + */ +class BehindProxyTest : EndToEndTest() { + + override val serverConfig = ServerConfig( + encryption = EncryptionConfig(EncryptionMode.AES), + trustProxyForwarding = true, + ) + + @Test + fun `a forwarded client address gets its own rate limit bucket`(): Unit = runBlocking { + createTestServer(SERVER_CONFIG_ONE, fileSystem, database()) + doStartServer() + + // Exhaust the window from one forwarded address. + repeat(RATE_LIMIT) { attempt -> + val response = failedLogin(forwardedFor = "203.0.113.10") + assertEquals( + HttpStatusCode.Unauthorized, + response.status, + "Attempt ${attempt + 1} should still be inside the window", + ) + } + assertEquals( + HttpStatusCode.TooManyRequests, + failedLogin(forwardedFor = "203.0.113.10").status, + "The exhausted address must be limited", + ) + + assertEquals( + HttpStatusCode.Unauthorized, + failedLogin(forwardedFor = "203.0.113.99").status, + "A different forwarded address must not inherit the first one's exhausted bucket", + ) + } + + @Test + fun `a client-supplied entry ahead of the proxy's cannot claim a fresh bucket`(): Unit = runBlocking { + createTestServer(SERVER_CONFIG_ONE, fileSystem, database()) + doStartServer() + + // The proxy appends what it saw to whatever arrived, so only the trailing entry is + // its own observation. Everything before it is the client's to invent. + repeat(RATE_LIMIT) { attempt -> + val response = failedLogin(forwardedFor = "9.9.9.$attempt, 203.0.113.10") + assertEquals( + HttpStatusCode.Unauthorized, + response.status, + "Attempt ${attempt + 1} should still be inside the window", + ) + } + + assertEquals( + HttpStatusCode.TooManyRequests, + failedLogin(forwardedFor = "1.1.1.1, 203.0.113.10").status, + "A forged leading entry must not escape the bucket the proxy's entry earned", + ) + } + + private suspend fun failedLogin(forwardedFor: String) = client().post(api("account/login")) { + headers { + append(HAMMER_PROTOCOL_HEADER, HAMMER_PROTOCOL_VERSION.toString()) + append(HttpHeaders.XForwardedFor, forwardedFor) + } + setBody( + FormDataContent( + Parameters.build { + append("email", "test@example.com") + append("password", "definitely-not-the-password") + append("installId", "fake-install-id") + } + ) + ) + } + + private companion object { + const val RATE_LIMIT = 10 + } +} From 39e7e913bb70ac89b964180e039cca2475f3f7a7 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 21:54:54 -0700 Subject: [PATCH 048/213] Load scene content as markdown so block structure survives (#856) Horizontal rules, images and code fences live in the editor's rich span manager rather than in the AnnotatedString, so seeding an editor from getInitialEditorContent dropped every block. Import the markdown instead. Loading this way exposed several things that depended on the old seed: - The spell checker's one-shot scan is keyed on the checker, not the content, and importMarkdown emits no edit operations, so a scene opened once the dictionary was cached was never checked. Run a full check after loading. - updateMarkdownStyles remapped heading styles before the extension's config setter rebaked them, leaving the rebake unable to strip the old style and appending a duplicate span per heading per config change. - Block lines render as a lone space, which the prose diff could not tell from a blank line, so a scene break added or removed between draft and current went unmarked. sceneDiffText swaps in length-preserving sentinels to keep the diff in the editor's coordinate space. Also hoist the Draft Compare editors above the width breakpoint so a resize no longer resets the merge pane while mergedContent points at the torn-down editor, give both panes the markdown config effect so they restyle together, and move the markdown export off the main thread. --- .../hammer/common/compose/ComposeRichText.kt | 8 -- .../compose/markdown/updateMarkdownStyles.kt | 11 +- .../storyeditor/drafts/DraftCompareUi.kt | 124 +++++++++++------- .../storyeditor/focusmode/FocusModeUi.kt | 20 ++- .../sceneeditor/SceneContentUtils.kt | 87 +++++++++--- .../storyeditor/sceneeditor/SceneEditorUi.kt | 17 ++- .../kotlin/MarkdownConfigRestyleTest.kt | 45 +++++++ .../kotlin/SceneContentLoadTest.kt | 111 ++++++++++++++++ 8 files changed, 330 insertions(+), 93 deletions(-) create mode 100644 composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt index ed8b1a175..5916743ed 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt @@ -22,14 +22,6 @@ data class ComposeRichText( } } - fun getAnnotatedString(): AnnotatedString { - return when { - snapshot != null -> snapshot - state != null -> state.editorState.getAllText() - else -> error("ComposeRichText must contain non-null data ") - } - } - override fun stateCompare(text: PlatformRichText?): Boolean { text as ComposeRichText return (snapshot != null && text.snapshot === snapshot) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt index c7ad168cf..ce60f6d7a 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt @@ -32,6 +32,10 @@ internal fun updateMarkdownStyles( ) { if (state.textLines.isEmpty()) return + // Heading styles are deliberately absent: MarkdownExtension.markdownConfiguration's + // setter rebakes them from their HeaderSpanStyle spans. Remapping them here too + // leaves the rebake unable to find the old style to strip, so it appends a second + // copy to every heading on each config change. val styleMapping = mapOf( oldConfig.defaultTextStyle to newConfig.defaultTextStyle, oldConfig.boldStyle to newConfig.boldStyle, @@ -39,12 +43,6 @@ internal fun updateMarkdownStyles( oldConfig.codeStyle to newConfig.codeStyle, oldConfig.linkStyle to newConfig.linkStyle, oldConfig.blockquoteStyle to newConfig.blockquoteStyle, - oldConfig.header1Style to newConfig.header1Style, - oldConfig.header2Style to newConfig.header2Style, - oldConfig.header3Style to newConfig.header3Style, - oldConfig.header4Style to newConfig.header4Style, - oldConfig.header5Style to newConfig.header5Style, - oldConfig.header6Style to newConfig.header6Style ) state.processLines { _: Int, line: AnnotatedString -> @@ -59,7 +57,6 @@ internal fun updateMarkdownStyles( addStyle(newStyle, span.start, span.end) } else { // Keep the original style if it's not a markdown style - println("Skipping style update") addStyle(span.item, span.start, span.end) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt index ac80350e2..21718f43c 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt @@ -24,10 +24,14 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors -import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.getInitialEditorContent +import com.darkrockstudios.apps.hammer.common.data.SceneContent +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditor import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration +import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle import com.darkrockstudios.texteditor.richstyle.RichSpan @@ -50,6 +54,17 @@ fun DraftCompareUi(component: DraftCompare) { val screen = LocalScreenCharacteristic.current val state by component.state.subscribeAsState() val draftName = component.draftDef.draftName + val markdownConfig = LocalMarkdownConfig.current + + // Hoisted above the width branch: the two layouts are separate composables, so building + // the editors inside them would discard the user's merge edits on a resize or rotate + // while component.mergedContent still pointed at the torn-down editor. + val draftMarkdown = key(state.draftContent) { + rememberSceneContentEditor(state.draftContent, markdownConfig) + } + val currentMarkdown = key(state.sceneContent) { + rememberSceneContentEditor(state.sceneContent, markdownConfig) + } Column(modifier = Modifier.fillMaxSize()) { HdMasthead( @@ -69,35 +84,44 @@ fun DraftCompareUi(component: DraftCompare) { when (screen.windowWidthClass) { WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> { - CompactDraftCompareUi(Modifier.fillMaxSize(), component) + CompactDraftCompareUi( + modifier = Modifier.fillMaxSize(), + component = component, + draftMarkdown = draftMarkdown, + currentMarkdown = currentMarkdown, + ) } else -> { - ExpandedDraftCompareUi(Modifier.fillMaxSize(), component) + ExpandedDraftCompareUi( + modifier = Modifier.fillMaxSize(), + component = component, + draftMarkdown = draftMarkdown, + currentMarkdown = currentMarkdown, + ) } } } } @Composable -private fun CompactDraftCompareUi(modifier: Modifier, component: DraftCompare) { +private fun CompactDraftCompareUi( + modifier: Modifier, + component: DraftCompare, + draftMarkdown: MarkdownExtension, + currentMarkdown: MarkdownExtension, +) { var pane by rememberSaveable { mutableIntStateOf(PANE_DRAFT) } - val state by component.state.subscribeAsState() - val markdownConfig = LocalMarkdownConfig.current val draftLabel = Res.string.draft_compare_tab_title_draft.get() val currentLabel = Res.string.draft_compare_tab_title_current.get() - val draftState = key(state.draftContent) { - rememberTextEditorState(getInitialEditorContent(state.draftContent, markdownConfig)) + // Only one pane is composed at a time here, so the panes' own seeding effects can't + // be relied on to feed both sides of the diff. + LaunchedEffect(draftMarkdown) { + component.submitDraftText(sceneDiffText(draftMarkdown)) } - val currentState = key(state.sceneContent) { - rememberTextEditorState(getInitialEditorContent(state.sceneContent, markdownConfig)) - } - LaunchedEffect(draftState) { - component.submitDraftText(draftState.getAllText().text) - } - LaunchedEffect(currentState) { - component.onCurrentTextChanged(currentState.getAllText().text) + LaunchedEffect(currentMarkdown) { + component.onCurrentTextChanged(sceneDiffText(currentMarkdown)) } Column(modifier = modifier) { @@ -116,32 +140,29 @@ private fun CompactDraftCompareUi(modifier: Modifier, component: DraftCompare) { modifier = Modifier.fillMaxSize(), sectionNumber = 1, component = component, - textEditorState = draftState, + markdownExtension = draftMarkdown, ) } else { CurrentPane( modifier = Modifier.fillMaxSize(), sectionNumber = 1, component = component, - textEditorState = currentState, + markdownExtension = currentMarkdown, ) } } } @Composable -private fun ExpandedDraftCompareUi(modifier: Modifier, component: DraftCompare) { +private fun ExpandedDraftCompareUi( + modifier: Modifier, + component: DraftCompare, + draftMarkdown: MarkdownExtension, + currentMarkdown: MarkdownExtension, +) { val state by component.state.subscribeAsState() - val markdownConfig = LocalMarkdownConfig.current - - // Hoist both editor states here so we can wire synchronized scrolling between the panes. - // Each is keyed on its content so it rebuilds when the draft / scene finishes loading. - val draftState = key(state.draftContent) { - rememberTextEditorState(getInitialEditorContent(state.draftContent, markdownConfig)) - } - val currentState = key(state.sceneContent) { - rememberTextEditorState(getInitialEditorContent(state.sceneContent, markdownConfig)) - } + val draftState = draftMarkdown.editorState + val currentState = currentMarkdown.editorState SyncScrolling( leftState = draftState, @@ -155,7 +176,7 @@ private fun ExpandedDraftCompareUi(modifier: Modifier, component: DraftCompare) modifier = Modifier.weight(1f).fillMaxHeight(), sectionNumber = 1, component = component, - textEditorState = draftState, + markdownExtension = draftMarkdown, ) VerticalDivider( color = MaterialTheme.colorScheme.outlineVariant, @@ -165,7 +186,7 @@ private fun ExpandedDraftCompareUi(modifier: Modifier, component: DraftCompare) modifier = Modifier.weight(1f).fillMaxHeight(), sectionNumber = 2, component = component, - textEditorState = currentState, + markdownExtension = currentMarkdown, ) } } @@ -175,15 +196,16 @@ private fun DraftPane( modifier: Modifier, sectionNumber: Int, component: DraftCompare, - textEditorState: TextEditorState, + markdownExtension: MarkdownExtension, ) { val strRes = rememberStrRes() val state by component.state.subscribeAsState() val deletedHighlight = rememberDeletedHighlight() + val textEditorState = markdownExtension.editorState // The draft is read-only, so its rendered text never changes — submit it once for the diff. - LaunchedEffect(textEditorState) { - component.submitDraftText(textEditorState.getAllText().text) + LaunchedEffect(markdownExtension) { + component.submitDraftText(sceneDiffText(markdownExtension)) } val spans = if (state.showDiff) state.diffResult?.leftSpans.orEmpty() else emptyList() DiffHighlightEffect(textEditorState, spans, deletedHighlight) @@ -241,30 +263,24 @@ private fun CurrentPane( modifier: Modifier, sectionNumber: Int, component: DraftCompare, - textEditorState: TextEditorState, + markdownExtension: MarkdownExtension, ) { val state by component.state.subscribeAsState() - val markdownConfig = LocalMarkdownConfig.current val insertedHighlight = rememberInsertedHighlight() + val textEditorState = markdownExtension.editorState - val markdownExtension = remember(textEditorState) { textEditorState.withMarkdown(markdownConfig) } - - LaunchedEffect(markdownExtension, markdownConfig) { - markdownExtension.updateMarkdownConfiguration(markdownConfig) - } - - LaunchedEffect(textEditorState) { + LaunchedEffect(markdownExtension) { // Seed the diff with the initial text, then watch for edits. `collectLatest` cancels the // previous lambda when a new edit arrives, so the `delay` acts as a per-keystroke debounce: // the diff recompute only fires after the user stops typing for [DIFF_RECOMPUTE_DELAY_MS]. // The merged-content update runs synchronously before the delay so picking the current // draft always uses the latest text. Submitting the editor's rendered text (not the // markdown) keeps the diff in the same coordinate space the highlights are drawn in. - component.onCurrentTextChanged(textEditorState.getAllText().text) + component.onCurrentTextChanged(sceneDiffText(markdownExtension)) textEditorState.editOperations.collectLatest { _ -> component.onMergedContentChanged(ComposeRichText(markdownExtension)) delay(DIFF_RECOMPUTE_DELAY_MS) - component.onCurrentTextChanged(textEditorState.getAllText().text) + component.onCurrentTextChanged(sceneDiffText(markdownExtension)) } } @@ -313,6 +329,24 @@ private fun CurrentPane( } } +/** An editor seeded with [content], keeping the block structure markdown carries. */ +@Composable +private fun rememberSceneContentEditor( + content: SceneContent?, + markdownConfig: MarkdownConfiguration, +): MarkdownExtension { + val editorState = rememberTextEditorState() + val markdownExtension = remember(editorState) { + editorState.withMarkdown(markdownConfig).also { loadSceneContent(it, content) } + } + // Both panes restyle together; the config is otherwise baked in at import time and + // the two would drift apart on a font size or theme change. + LaunchedEffect(markdownExtension, markdownConfig) { + markdownExtension.updateMarkdownConfiguration(markdownConfig) + } + return markdownExtension +} + @Composable private fun rememberDeletedHighlight(): HighlightSpanStyle { val danger = LocalHammerColors.current.danger diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt index e63472103..23b056ac7 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt @@ -41,11 +41,13 @@ import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.data.UpdateSource -import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.getInitialEditorContent +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneContentMarkdown import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker import com.darkrockstudios.apps.hammer.scene_editor_menu_item_close import com.darkrockstudios.texteditor.find.FindBar @@ -54,22 +56,21 @@ import com.darkrockstudios.texteditor.rememberTextEditorStyle import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState +import kotlinx.coroutines.withContext @Composable fun FocusModeUi(component: FocusMode) { val state by component.state.subscribeAsState() val lastForceUpdate by component.lastForceUpdate.subscribeAsState() val markdownConfig = LocalMarkdownConfig.current + val defaultDispatcher = rememberDefaultDispatcher() val editorSpellChecker = remember(state.spellChecker) { state.spellChecker.toEditorSpellChecker() } - // Only read once, but unremembered it rebuilds the whole document's AnnotatedString on the UI - // thread every recomposition, and the buffer republishes every 500ms while typing. - val initialEditorContent = remember { getInitialEditorContent(state.sceneBuffer?.content, markdownConfig) } val textEditorState = rememberSpellCheckState( spellChecker = editorSpellChecker, - initialText = initialEditorContent, + initialText = null, enableSpellChecking = state.spellCheckingEnabled, ) val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) } @@ -87,8 +88,13 @@ fun FocusModeUi(component: FocusMode) { state.sceneBuffer?.let { buffer -> // Always update on first buffer (initial load), or when external update occurs if (!hasReceivedInitialBuffer || buffer.source != UpdateSource.Editor) { - val newContent = getInitialEditorContent(buffer.content, markdownConfig) - textEditorState.textState.setText(newContent) + val sceneMarkdown = withContext(defaultDispatcher) { + sceneContentMarkdown(buffer.content) + } + loadSceneContent(markdownExtension, sceneMarkdown) + // Importing emits no edit operations, so the incremental checker never + // sees this text. The one-shot check already ran against an empty document. + textEditorState.runFullSpellCheck() hasReceivedInitialBuffer = true } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneContentUtils.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneContentUtils.kt index d206bc9de..63933e780 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneContentUtils.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneContentUtils.kt @@ -1,26 +1,73 @@ package com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor -import androidx.compose.ui.text.AnnotatedString -import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText import com.darkrockstudios.apps.hammer.common.data.SceneContent -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration -import com.darkrockstudios.texteditor.markdown.toAnnotatedStringFromMarkdown +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle +import com.darkrockstudios.texteditor.richstyle.ImageBlockSpanStyle -fun getInitialEditorContent( - sceneContent: SceneContent?, - markdownConfig: MarkdownConfiguration -): AnnotatedString { - return if (sceneContent != null) { - val composeText = sceneContent.platformRepresentation as? ComposeRichText - val markdown = sceneContent.markdown - if (composeText != null) { - composeText.getAnnotatedString() - } else if (markdown != null) { - markdown.toAnnotatedStringFromMarkdown(markdownConfig) - } else { - error("Should be impossible to not have either platform rep or markdown") +/** Private Use Area, so they can never collide with anything a writer types. */ +private val HR_MARKER = Char(0xE000) +private val IMAGE_MARKER = Char(0xE001) + +/** + * Horizontal rules and code fences live in the editor's rich span manager, not in the + * AnnotatedString, so scene content has to travel as markdown and be imported. + * Round-tripping it as an AnnotatedString silently drops every block. Images need an + * ImageProvider the editors don't supply, so they import as literal text. + */ +fun loadSceneContent(markdown: MarkdownExtension, sceneContent: SceneContent?) { + loadSceneContent(markdown, sceneContentMarkdown(sceneContent)) +} + +fun loadSceneContent(markdown: MarkdownExtension, sceneMarkdown: String) { + markdown.importMarkdown(sceneMarkdown) +} + +/** + * Serializing a buffer that holds a live editor walks its whole document, so callers on + * the main thread should do this off it first. [MarkdownExtension.exportAsMarkdown] reads + * a single immutable snapshot and is safe from any thread. + */ +fun sceneContentMarkdown(sceneContent: SceneContent?): String = + sceneContent?.coerceMarkdown() ?: "" + +/** + * The editor's rendered text with block placeholders swapped for distinct sentinels. + * Rules and images both render as a lone space, so a plain diff cannot tell a scene + * break from a blank line and silently drops one during a draft merge. The swap is + * length-preserving to keep the result in the editor's coordinate space, which is + * what the diff highlight spans are drawn in. + */ +fun sceneDiffText(markdown: MarkdownExtension): String { + val text = markdown.editorState.getAllText().text + val markers = blockMarkersByLine(markdown) + if (markers.isEmpty()) return text + + val chars = StringBuilder(text) + var line = 0 + var lineStart = 0 + while (true) { + val newline = chars.indexOf("\n", lineStart) + val end = if (newline == -1) chars.length else newline + val marker = markers[line] + // Only a bare placeholder line is a block; anything longer is real prose. + if (marker != null && end - lineStart == 1) { + chars[lineStart] = marker } - } else { - AnnotatedString("") + if (newline == -1) break + lineStart = newline + 1 + line++ } -} \ No newline at end of file + return chars.toString() +} + +private fun blockMarkersByLine(markdown: MarkdownExtension): Map = + markdown.editorState.richSpanManager.getAllRichSpans() + .mapNotNull { span -> + when (span.style) { + is HorizontalRuleSpanStyle -> span.range.start.line to HR_MARKER + is ImageBlockSpanStyle -> span.range.start.line to IMAGE_MARKER + else -> null + } + } + .toMap() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt index b51cb09b2..33375c644 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt @@ -52,6 +52,7 @@ import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialog import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher import com.darkrockstudios.apps.hammer.common.compose.Toaster import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier @@ -70,6 +71,7 @@ import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext const val SCENE_EDITOR_TEXT_TAG = "scene-editor-text" const val SCENE_EDITOR_SAVE_TAG = "scene-editor-save" @@ -86,16 +88,14 @@ fun SceneEditorUi( val lastForceUpdate by component.lastForceUpdate.subscribeAsState() val markdownConfig = LocalMarkdownConfig.current val scope = rememberCoroutineScope() + val defaultDispatcher = rememberDefaultDispatcher() val editorSpellChecker = remember(state.spellChecker) { state.spellChecker.toEditorSpellChecker() } - // Only read once, but unremembered it rebuilds the whole document's AnnotatedString on the UI - // thread every recomposition, and the buffer republishes every 500ms while typing. - val initialEditorContent = remember { getInitialEditorContent(state.sceneBuffer?.content, markdownConfig) } val textEditorState = rememberSpellCheckState( spellChecker = editorSpellChecker, - initialText = initialEditorContent, + initialText = null, enableSpellChecking = state.spellCheckingEnabled, spellCheckMode = SpellCheckMode.Word, ) @@ -115,8 +115,13 @@ fun SceneEditorUi( state.sceneBuffer?.let { buffer -> // Always update on first buffer (initial load), or when external update occurs if (!hasReceivedInitialBuffer || buffer.source != UpdateSource.Editor) { - val newContent = getInitialEditorContent(buffer.content, markdownConfig) - textEditorState.textState.setText(newContent) + val sceneMarkdown = withContext(defaultDispatcher) { + sceneContentMarkdown(buffer.content) + } + loadSceneContent(markdownExtension, sceneMarkdown) + // Importing emits no edit operations, so the incremental checker never + // sees this text. The one-shot check already ran against an empty document. + textEditorState.runFullSpellCheck() hasReceivedInitialBuffer = true } } diff --git a/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt new file mode 100644 index 000000000..4e5e3bd46 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt @@ -0,0 +1,45 @@ +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration +import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class MarkdownConfigRestyleTest { + + private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + return MarkdownExtension(state, config) + } + + private fun configWithBodySize(size: Float) = MarkdownConfiguration.DEFAULT.copy( + defaultTextStyle = MarkdownConfiguration.DEFAULT.defaultTextStyle.copy(fontSize = size.sp), + header1Style = MarkdownConfiguration.DEFAULT.header1Style.copy(fontSize = (size * 2).sp), + ) + + @Test + fun `stepping the font size leaves one baked style per heading`() = runTest { + val editor = newEditor(configWithBodySize(16f)) + editor.importMarkdown("# Chapter One\n\nSome body text.") + val before = editor.editorState.textLines[0].spanStyles.size + + repeat(5) { step -> editor.updateMarkdownConfiguration(configWithBodySize(18f + step * 2)) } + + assertEquals(before, editor.editorState.textLines[0].spanStyles.size) + } + + @Test + fun `heading tracks the new font size`() = runTest { + val editor = newEditor(configWithBodySize(16f)) + editor.importMarkdown("# Chapter One") + + editor.updateMarkdownConfiguration(configWithBodySize(20f)) + + val sizes = editor.editorState.textLines[0].spanStyles.map { it.item.fontSize } + assertEquals(listOf(40f.sp), sizes.filter { it != 20f.sp }) + } +} diff --git a/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt b/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt new file mode 100644 index 000000000..ab44acea0 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt @@ -0,0 +1,111 @@ +import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.SceneContent +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText +import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SceneContentLoadTest { + + private val projectDef = ProjectDef( + name = "Test Project", + path = HPath(path = "/test", name = "Test Project", isAbsolute = true), + ) + + private val sceneItem = SceneItem( + projectDef = projectDef, + type = SceneItem.Type.Scene, + id = 1, + name = "Test Scene", + order = 0, + ) + + private fun TestScope.newEditor(): MarkdownExtension { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + return MarkdownExtension(state, MarkdownConfiguration.DEFAULT) + } + + private fun MarkdownExtension.horizontalRuleLines(): List = + editorState.richSpanManager.getAllRichSpans() + .filter { it.style === HorizontalRuleSpanStyle } + .map { it.range.start.line } + .sorted() + + @Test + fun `horizontal rule survives reloading a live editor buffer`() = runTest { + val original = "Before the break.\n\n---\n\nAfter the break." + + val editing = newEditor() + editing.importMarkdown(original) + val buffered = SceneContent( + scene = sceneItem, + platformRepresentation = ComposeRichText(editing), + ) + + val reopened = newEditor() + loadSceneContent(reopened, buffered) + + assertEquals(listOf(2), reopened.horizontalRuleLines()) + assertEquals(original, reopened.exportAsMarkdown()) + } + + @Test + fun `horizontal rule survives loading markdown from disk`() = runTest { + val original = "Before the break.\n\n---\n\nAfter the break." + val onDisk = SceneContent(scene = sceneItem, markdown = original) + + val reopened = newEditor() + loadSceneContent(reopened, onDisk) + + assertEquals(listOf(2), reopened.horizontalRuleLines()) + assertEquals(original, reopened.exportAsMarkdown()) + } + + @Test + fun `null content loads an empty document`() = runTest { + val editor = newEditor() + editor.importMarkdown("Some prior text") + + loadSceneContent(editor, null) + + assertEquals("", editor.exportAsMarkdown()) + } + + @Test + fun `diff text distinguishes a horizontal rule from a blank line`() = runTest { + val withRule = newEditor() + loadSceneContent(withRule, SceneContent(sceneItem, "Before.\n\n---\n\nAfter.")) + val withoutRule = newEditor() + loadSceneContent(withoutRule, SceneContent(sceneItem, "Before.\n\n\n\nAfter.")) + + assertNotEquals(sceneDiffText(withRule), sceneDiffText(withoutRule)) + } + + @Test + fun `diff text stays in the editor coordinate space`() = runTest { + val editor = newEditor() + loadSceneContent(editor, SceneContent(sceneItem, "Before.\n\n---\n\nAfter.")) + + // The diff's offsets index back into the editor, so substitution must not resize. + assertEquals(editor.editorState.getAllText().text.length, sceneDiffText(editor).length) + } + + @Test + fun `diff text leaves prose untouched`() = runTest { + val editor = newEditor() + loadSceneContent(editor, SceneContent(sceneItem, "Just prose.\n\nNo blocks here.")) + + assertEquals(editor.editorState.getAllText().text, sceneDiffText(editor)) + } +} From b92b0c3eb58716487fe844905dc126268e78de28 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 5 Aug 2026 23:22:38 -0700 Subject: [PATCH 049/213] Migrate desktop window decoration to Nucleus 2.1.9 TAO backend (#854) Holy crap this is so much better! --- .../common/compose/ScreenCharacteristics.kt | 23 +- .../projectsync/ProjectSynchronizationUi.kt | 19 +- .../kotlin/ScreenCharacteristicsTest.kt | 67 ++ desktop/build.gradle.kts | 17 +- desktop/proguard-rules.pro | 11 +- .../apps/hammer/desktop/Main.kt | 58 +- .../hammer/desktop/ProjectEditorWindow.kt | 116 +-- .../hammer/desktop/ProjectSelectionWindow.kt | 27 +- .../apps/hammer/desktop/SplashScreen.kt | 66 +- .../hammer/desktop/WindowGeometryStore.kt | 2 + .../desktop/shortcuts/LinuxQuicklist.kt | 10 +- .../desktop/shortcuts/MacOsDockShortcuts.kt | 6 +- .../desktop/shortcuts/WindowsJumpList.kt | 2 +- .../src/jvmMain/resources/aboutlibraries.json | 802 +++++++++++------- gradle/libs.versions.toml | 17 +- 15 files changed, 791 insertions(+), 452 deletions(-) create mode 100644 composeUi/src/desktopTest/kotlin/ScreenCharacteristicsTest.kt diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScreenCharacteristics.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScreenCharacteristics.kt index a2790e535..b63374193 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScreenCharacteristics.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ScreenCharacteristics.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import com.darkrockstudios.apps.hammer.common.uiNeedsExplicitCloseButtons @@ -26,12 +29,28 @@ val LocalScreenCharacteristic = staticCompositionLocalOf { ) } +/** + * Size classes come from the constraints of the space the UI occupies rather than from + * `calculateWindowSizeClass()`: the desktop implementation of that reads an AWT window, + * which does not exist under the Tao window backend. + */ @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberWindowSizeClass(constraints: Constraints): WindowSizeClass { + val density = LocalDensity.current + return remember(constraints, density) { + WindowSizeClass.calculateFromSize( + size = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat()), + density = density, + ) + } +} + @Composable fun SetScreenCharacteristics(wideThreshold: Dp, content: @Composable BoxWithConstraintsScope.() -> Unit) { BoxWithConstraints { val isWide by remember(maxWidth) { derivedStateOf { maxWidth >= wideThreshold } } - val windowSizeClass = calculateWindowSizeClass() + val windowSizeClass = rememberWindowSizeClass(constraints) CompositionLocalProvider( LocalScreenCharacteristic provides ScreenCharacteristics( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectSynchronizationUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectSynchronizationUi.kt index 1ee78f0e1..80e3f536f 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectSynchronizationUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectsync/ProjectSynchronizationUi.kt @@ -12,7 +12,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import com.darkrockstudios.apps.hammer.common.compose.rememberWindowSizeClass import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -59,14 +59,15 @@ internal fun ProjectSynchronization( onClosed = { component.endSync() }, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - val screenCharacteristics = calculateWindowSizeClass() - ProjectSynchronizationContent( - component = component, - showSnackbar = showSnackbar, - screenCharacteristics = screenCharacteristics, - modifier = Modifier.predictiveBackTransform(), - onClose = requestClose, - ) + BoxWithConstraints { + ProjectSynchronizationContent( + component = component, + showSnackbar = showSnackbar, + screenCharacteristics = rememberWindowSizeClass(constraints), + modifier = Modifier.predictiveBackTransform(), + onClose = requestClose, + ) + } } if (confirmCancel) { diff --git a/composeUi/src/desktopTest/kotlin/ScreenCharacteristicsTest.kt b/composeUi/src/desktopTest/kotlin/ScreenCharacteristicsTest.kt new file mode 100644 index 000000000..019ba25a3 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/ScreenCharacteristicsTest.kt @@ -0,0 +1,67 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.ScreenCharacteristics +import com.darkrockstudios.apps.hammer.common.compose.SetScreenCharacteristics +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +private val WideThreshold = 720.dp + +/** + * The size classes must come from the space the UI actually occupies. The desktop + * `calculateWindowSizeClass()` reads an AWT window, which no longer exists under the + * Tao backend, so every window reported Compact. + */ +class ScreenCharacteristicsTest { + + @get:Rule + val compose = createComposeRule() + + private fun characteristicsAt(width: Dp, height: Dp): ScreenCharacteristics { + lateinit var observed: ScreenCharacteristics + compose.setContent { + Box(modifier = Modifier.requiredSize(width, height)) { + SetScreenCharacteristics(WideThreshold) { + observed = LocalScreenCharacteristic.current + } + } + } + compose.waitForIdle() + return observed + } + + @Test + fun `desktop sized window is expanded`() { + val screen = characteristicsAt(1280.dp, 900.dp) + + assertEquals(WindowWidthSizeClass.Expanded, screen.windowWidthClass) + assertEquals(WindowHeightSizeClass.Expanded, screen.windowHeightClass) + assertEquals(true, screen.isWide) + } + + @Test + fun `tablet sized window is medium`() { + val screen = characteristicsAt(700.dp, 600.dp) + + assertEquals(WindowWidthSizeClass.Medium, screen.windowWidthClass) + assertEquals(WindowHeightSizeClass.Medium, screen.windowHeightClass) + assertEquals(false, screen.isWide) + } + + @Test + fun `phone sized window is compact`() { + val screen = characteristicsAt(360.dp, 640.dp) + + assertEquals(WindowWidthSizeClass.Compact, screen.windowWidthClass) + assertEquals(WindowHeightSizeClass.Medium, screen.windowHeightClass) + assertEquals(false, screen.isWide) + } +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index e33c72b5d..c52043db2 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -60,12 +60,12 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.clikt) implementation(libs.nucleus.darkmode.detector) - implementation(libs.nucleus.decorated.window.jbr) + implementation(libs.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.material3) implementation(libs.nucleus.launcher.windows) implementation(libs.nucleus.launcher.linux) implementation(libs.nucleus.launcher.macos) - implementation(libs.splashify) } } val jvmTest by getting { @@ -193,10 +193,15 @@ val extractMacosNativeLibs = tasks.register("extractMacosNativeLibs") { val nativeLibs = listOf( Triple("net.java.dev.jna", "jna", "com/sun/jna/darwin-aarch64/libjnidispatch.jnilib"), - Triple("io.github.kdroidfilter", "nucleus.launcher-macos", "nucleus/native/darwin-aarch64/libnucleus_launcher_macos.dylib"), - Triple("io.github.kdroidfilter", "nucleus.darkmode-detector", "nucleus/native/darwin-aarch64/libnucleus_darkmode.dylib"), - Triple("io.github.kdroidfilter", "nucleus.decorated-window-core", "nucleus/native/darwin-aarch64/libnucleus_layout_direction.dylib"), - Triple("io.github.kdroidfilter", "nucleus.decorated-window-jbr", "nucleus/native/darwin-aarch64/libnucleus_macos.dylib"), + Triple("dev.nucleusframework", "nucleus.launcher-macos", "nucleus/native/darwin-aarch64/libnucleus_launcher_macos.dylib"), + Triple("dev.nucleusframework", "nucleus.darkmode-detector", "nucleus/native/darwin-aarch64/libnucleus_darkmode.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-core", "nucleus/native/darwin-aarch64/libnucleus_layout_direction.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao_dnd.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao_macos_deco.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao_macos_native_view.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao_macos_popup.dylib"), + Triple("dev.nucleusframework", "nucleus.decorated-window-tao", "nucleus/native/darwin-aarch64/libnucleus_tao_metal.dylib"), ) val runtime = configurations.named("jvmRuntimeClasspath") diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 8ff2e171a..c2fe12599 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -58,15 +58,22 @@ # NUCLEUS LAUNCHER (JNI bridge classes — JNI invokes static on*(...) callbacks) # Windows launcher ships its own keep rules via the Nucleus Gradle plugin. ################################################################################ --keep class io.github.kdroidfilter.nucleus.launcher.linux.NativeLinuxLauncherBridge { +-keep class dev.nucleusframework.launcher.linux.NativeLinuxLauncherBridge { native ; static ** on*(...); } --keep class io.github.kdroidfilter.nucleus.launcher.macos.NativeMacOsDockMenuBridge { +-keep class dev.nucleusframework.launcher.macos.NativeMacOsDockMenuBridge { native ; static ** on*(...); } +################################################################################ +# NUCLEUS TAO BACKEND (native window layer: JNI bridges call back into these, +# and the Tao main dispatcher is discovered reflectively via ServiceLoader) +################################################################################ +-keep class dev.nucleusframework.window.tao.** { *; } +-keep class kotlinx.coroutines.internal.MainDispatcherFactory + ################################################################################ # LIBRARIES (Broad Keeps for Stability) ################################################################################ diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt index 6ee2e2852..f97631b2a 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt @@ -4,11 +4,12 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.window.ApplicationScope -import androidx.compose.ui.window.application +import androidx.compose.runtime.setValue import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory +import com.arkivanov.decompose.DecomposeSettings import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.value.MutableValue @@ -36,15 +37,18 @@ import com.darkrockstudios.apps.hammer.common.setInDevelopmentMode import com.darkrockstudios.apps.hammer.desktop.aboutlibraries.aboutLibrariesModule import com.darkrockstudios.apps.hammer.desktop.sandbox.SandboxStartup import com.darkrockstudios.apps.hammer.desktop.shortcuts.QuickShortcuts +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.NucleusDecoratedWindowTheme import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier -import io.github.kdroidfilter.nucleus.darkmodedetector.isSystemInDarkMode -import io.github.kdroidfilter.nucleus.window.NucleusDecoratedWindowTheme -import io.github.sudarshanmhasrup.splashify.SplashifyApp import io.github.vinceglb.filekit.FileKit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -175,8 +179,16 @@ fun main(args: Array) { } } + // Decompose's ServiceLoader-provided checker only knows the AWT EDT, but the + // Tao backend drives the UI (and Dispatchers.Main) from Tao's own main thread. + DecomposeSettings.update { it.copy(mainThreadCheckEnabled = false) } + Napier.i("Startup: entering Compose application") - application { + nucleusApplication( + args = args, + backend = NucleusBackend.Tao, + enableSingleInstance = false, + ) { LaunchedEffect(Unit) { Napier.i("Startup: first composition") } val applicationState = remember { ApplicationState( @@ -205,17 +217,33 @@ fun main(args: Array) { AppTheme(useDarkTheme = darkMode, settings = settingsState) { when (val windowState = applicationState.windows.value) { is WindowState.ProjectSectionWindow -> { - SplashifyApp( - splashScreen = { SplashScreen() } - ) { - ProjectSelectionWindow { project -> - applicationState.openProject(project) - } + var showSplash by remember { mutableStateOf(true) } + // The real window always exists, merely minimized behind the splash, and + // the hand-off is timed here rather than inside either window: a window + // that fails to render can neither stall it nor swallow the app. + LaunchedEffect(Unit) { + withContext(Dispatchers.Default) { delay(SplashDurationMs.toLong()) } + showSplash = false + } + ProjectSelectionWindow( + settings = settingsState, + darkMode = darkMode, + minimized = showSplash, + ) { project -> + applicationState.openProject(project) + } + if (showSplash) { + SplashWindow() } } is WindowState.ProjectWindow -> { - ProjectEditorWindow(applicationState, windowState.projectDef) + ProjectEditorWindow( + app = applicationState, + projectDef = windowState.projectDef, + settings = settingsState, + darkMode = darkMode, + ) } } } @@ -234,7 +262,7 @@ internal enum class ConfirmCloseResult { Cancel } -internal fun ApplicationScope.performClose( +internal fun NucleusApplicationScope.performClose( app: ApplicationState, closeType: ApplicationState.CloseType ) { @@ -250,7 +278,7 @@ internal fun ApplicationScope.performClose( } } -internal fun ApplicationScope.onRequestClose( +internal fun NucleusApplicationScope.onRequestClose( component: AppCloseManager, app: ApplicationState, closeType: ApplicationState.CloseType diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt index c66020348..d9d88af7a 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.MenuBar import com.arkivanov.decompose.DefaultComponentContext @@ -48,8 +47,10 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNavRail import com.darkrockstudios.apps.hammer.common.compose.rememberMainDispatcher import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.compose.theme.ProjectThemeOverride import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.projectroot.ProjectRootFab import com.darkrockstudios.apps.hammer.common.projectroot.ProjectRootUi import com.darkrockstudios.apps.hammer.common.projectroot.toHdNavRailDestination @@ -57,8 +58,9 @@ import com.darkrockstudios.apps.hammer.project_window_menu_file import com.darkrockstudios.apps.hammer.project_window_menu_item_close import com.darkrockstudios.apps.hammer.project_window_menu_item_exit import com.darkrockstudios.apps.hammer.project_window_title -import io.github.kdroidfilter.nucleus.window.material.MaterialDecoratedWindow -import io.github.kdroidfilter.nucleus.window.material.MaterialTitleBar +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.window.material.MaterialDecoratedWindow +import dev.nucleusframework.window.material.MaterialTitleBar import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -67,9 +69,11 @@ import kotlinx.coroutines.withContext @ExperimentalDecomposeApi @ExperimentalMaterialApi @Composable -internal fun ApplicationScope.ProjectEditorWindow( +internal fun NucleusApplicationScope.ProjectEditorWindow( app: ApplicationState, projectDef: ProjectDef, + settings: GlobalSettings, + darkMode: Boolean, ) { val backDispatcher = BackDispatcher() val lifecycle = remember { LifecycleRegistry() } @@ -146,78 +150,76 @@ internal fun ApplicationScope.ProjectEditorWindow( ) } - // The old menu bar approach -// Column { -// EditorMenuBar(component, app, ::onRequestClose) -// -// AppContent(component) -// } - AppContent(component) + // Tao windows are their own ComposeScene: locals provided outside the + // window (AppTheme in Main.kt) don't reach this content, so re-apply. + AppTheme(useDarkTheme = darkMode, settings = settings) { + AppContent(component) - LaunchedEffect(closeRequest) { - if (closeRequest != ApplicationState.CloseType.None) { - component.requestClose() + LaunchedEffect(closeRequest) { + if (closeRequest != ApplicationState.CloseType.None) { + component.requestClose() + } } - } - if (shouldConfirmClose.isNotEmpty()) { - val item = shouldConfirmClose.first() - when (item) { - CloseConfirm.Scenes -> { - confirmCloseUnsavedScenesDialog(closeRequest) { result, closeType -> - scope.launch { - if (result == ConfirmCloseResult.SaveAll) { - component.storeDirtyBuffers() - } + if (shouldConfirmClose.isNotEmpty()) { + val item = shouldConfirmClose.first() + when (item) { + CloseConfirm.Scenes -> { + confirmCloseUnsavedScenesDialog(closeRequest) { result, closeType -> + scope.launch { + if (result == ConfirmCloseResult.SaveAll) { + component.storeDirtyBuffers() + } - withContext(mainDispatcher) { - if (result != ConfirmCloseResult.Cancel) { - component.closeRequestDealtWith(CloseConfirm.Scenes) - } else { - cancelClose() + withContext(mainDispatcher) { + if (result != ConfirmCloseResult.Cancel) { + component.closeRequestDealtWith(CloseConfirm.Scenes) + } else { + cancelClose() + } } } } } - } - CloseConfirm.Notes -> { - confirmCloseUnsavedNotesDialog(closeRequest) { result, closeType -> - when (result) { - ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Notes) - ConfirmCloseResult.Cancel -> cancelClose() + CloseConfirm.Notes -> { + confirmCloseUnsavedNotesDialog(closeRequest) { result, closeType -> + when (result) { + ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") + ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Notes) + ConfirmCloseResult.Cancel -> cancelClose() + } } } - } - CloseConfirm.Encyclopedia -> { - confirmCloseUnsavedEncyclopediaDialog(closeRequest) { result, closeType -> - when (result) { - ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Encyclopedia) - ConfirmCloseResult.Cancel -> cancelClose() + CloseConfirm.Encyclopedia -> { + confirmCloseUnsavedEncyclopediaDialog(closeRequest) { result, closeType -> + when (result) { + ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") + ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Encyclopedia) + ConfirmCloseResult.Cancel -> cancelClose() + } } } - } - CloseConfirm.Timeline -> { - confirmCloseUnsavedTimelineDialog(closeRequest) { result, closeType -> - when (result) { - ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith( - CloseConfirm.Timeline - ) - ConfirmCloseResult.Cancel -> cancelClose() + CloseConfirm.Timeline -> { + confirmCloseUnsavedTimelineDialog(closeRequest) { result, closeType -> + when (result) { + ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") + ConfirmCloseResult.Discard -> component.closeRequestDealtWith( + CloseConfirm.Timeline + ) + ConfirmCloseResult.Cancel -> cancelClose() + } } } - } - CloseConfirm.Sync -> { - component.showProjectSync() - } + CloseConfirm.Sync -> { + component.showProjectSync() + } - CloseConfirm.Complete -> performClose(app, closeRequest) + CloseConfirm.Complete -> performClose(app, closeRequest) + } } } } diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectSelectionWindow.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectSelectionWindow.kt index 229eda397..776187442 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectSelectionWindow.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectSelectionWindow.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -22,7 +23,6 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.ApplicationScope import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.lifecycle.LifecycleController @@ -37,18 +37,24 @@ import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNavRail import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.projectselection.ProjectSelectionUi import com.darkrockstudios.apps.hammer.common.projectselection.toHdNavRailDestination import com.darkrockstudios.apps.hammer.common.util.getAppVersionString -import io.github.kdroidfilter.nucleus.window.material.MaterialDecoratedWindow -import io.github.kdroidfilter.nucleus.window.material.MaterialTitleBar +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.window.material.MaterialDecoratedWindow +import dev.nucleusframework.window.material.MaterialTitleBar @ExperimentalMaterialApi @ExperimentalComposeApi @ExperimentalDecomposeApi @Composable -internal fun ApplicationScope.ProjectSelectionWindow( +internal fun NucleusApplicationScope.ProjectSelectionWindow( + settings: GlobalSettings, + darkMode: Boolean, + minimized: Boolean = false, onProjectSelected: (projectDef: ProjectDef) -> Unit ) { val backDispatcher = BackDispatcher() @@ -57,7 +63,14 @@ internal fun ApplicationScope.ProjectSelectionWindow( val windowState = rememberPersistedWindowState( WindowGeometryStore.Window.ProjectSelect, defaultSize = coerceWindowSize(900.dp, 800.dp), + startMinimized = minimized, ) + + // Restore once the splash is done. One-way on purpose: a later user-initiated + // minimize must stick. + LaunchedEffect(minimized) { + if (!minimized) windowState.isMinimized = false + } val component = remember { ProjectSelectionComponent( componentContext = compContext, @@ -97,7 +110,11 @@ internal fun ApplicationScope.ProjectSelectionWindow( modifier = Modifier.align(Alignment.CenterHorizontally), ) } - Content(component) + // Tao windows are their own ComposeScene: locals provided outside the + // window (AppTheme in Main.kt) don't reach this content, so re-apply. + AppTheme(useDarkTheme = darkMode, settings = settings) { + Content(component) + } } } diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/SplashScreen.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/SplashScreen.kt index c59389753..e5eb8203a 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/SplashScreen.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/SplashScreen.kt @@ -1,13 +1,19 @@ package com.darkrockstudios.apps.hammer.desktop +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -15,12 +21,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState import com.darkrockstudios.apps.hammer.* -import io.github.sudarshanmhasrup.splashify.ui.config.SplashScreenSize -import io.github.sudarshanmhasrup.splashify.ui.config.SplashScreenStyle -import io.github.sudarshanmhasrup.splashify.ui.splashscreen.SimpleSplashScreen +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusApplicationScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -33,27 +44,50 @@ private val TitleColor = Color(0xFFEDE0DB) private val SubtitleColor = Color(0xFFB9ACA6) private val TrackColor = Color(0x33FFFFFF) +internal const val SplashDurationMs = 600 + +/** + * Borderless splash window shown over the main window while it spins up. Purely + * cosmetic: it owns no timer and gates nothing, so no way it can fail is a way the + * app fails to appear. The caller decides when it goes away. + */ @Composable -fun SplashScreen() { - val size = SplashScreenSize( - width = 600.dp, - height = 380.dp +internal fun NucleusApplicationScope.SplashWindow() { + val windowState = rememberWindowState( + size = DpSize(600.dp, 380.dp), + position = WindowPosition(Alignment.Center), ) - val style = SplashScreenStyle( - backgroundColor = SplashBackground, - cornerRadius = 16.dp - ) + DecoratedWindow( + onCloseRequest = {}, + state = windowState, + title = "", + undecorated = true, + resizable = false, + ) { + var filling by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { filling = true } + SplashScreen(if (filling) 1f else 0f) + } +} + +@Composable +private fun SplashScreen(progress: Float) { val typewriter = FontFamily(Font(Res.font.Kingthings_Trypewriter_2)) val mono = FontFamily(Font(Res.font.IBMPlexMono_SemiBold)) - SimpleSplashScreen( - size = size, - style = style - ) { progress -> - val animatedProgress by animateFloatAsState(targetValue = progress) + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = SplashDurationMs, easing = LinearEasing), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(SplashBackground), + contentAlignment = Alignment.Center, + ) { Column( modifier = Modifier .fillMaxWidth() diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/WindowGeometryStore.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/WindowGeometryStore.kt index 2a5dc5751..108eeb916 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/WindowGeometryStore.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/WindowGeometryStore.kt @@ -61,12 +61,14 @@ class WindowGeometryStore(private val settings: Settings) { fun rememberPersistedWindowState( window: WindowGeometryStore.Window, defaultSize: DpSize, + startMinimized: Boolean = false, ): WindowState { val store = remember { getKoin().get() } val initial = remember { store.load(window, defaultSize) } val windowState = rememberWindowState( size = initial.size, placement = if (initial.maximized) WindowPlacement.Maximized else WindowPlacement.Floating, + isMinimized = startMinimized, ) LaunchedEffect(windowState) { diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/LinuxQuicklist.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/LinuxQuicklist.kt index fdb469db5..31ec05975 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/LinuxQuicklist.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/LinuxQuicklist.kt @@ -3,17 +3,17 @@ package com.darkrockstudios.apps.hammer.desktop.shortcuts import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import io.github.aakira.napier.Napier -import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon -import io.github.kdroidfilter.nucleus.launcher.linux.DbusmenuItem -import io.github.kdroidfilter.nucleus.launcher.linux.LauncherProperties -import io.github.kdroidfilter.nucleus.launcher.linux.LinuxLauncherEntry +import dev.nucleusframework.freedesktop.icons.FreedesktopIcon +import dev.nucleusframework.launcher.linux.DbusmenuItem +import dev.nucleusframework.launcher.linux.LauncherProperties +import dev.nucleusframework.launcher.linux.LinuxLauncherEntry import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.CoroutineContext -import io.github.kdroidfilter.nucleus.launcher.linux.LinuxQuicklist as NucleusQuicklist +import dev.nucleusframework.launcher.linux.LinuxQuicklist as NucleusQuicklist class LinuxQuicklist( private val projectsRepository: ProjectsRepository, diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/MacOsDockShortcuts.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/MacOsDockShortcuts.kt index ba3c8036c..628ea6229 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/MacOsDockShortcuts.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/MacOsDockShortcuts.kt @@ -3,9 +3,9 @@ package com.darkrockstudios.apps.hammer.desktop.shortcuts import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import io.github.aakira.napier.Napier -import io.github.kdroidfilter.nucleus.launcher.macos.DockMenuItem -import io.github.kdroidfilter.nucleus.launcher.macos.DockMenuListener -import io.github.kdroidfilter.nucleus.launcher.macos.MacOsDockMenu +import dev.nucleusframework.launcher.macos.DockMenuItem +import dev.nucleusframework.launcher.macos.DockMenuListener +import dev.nucleusframework.launcher.macos.MacOsDockMenu import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/WindowsJumpList.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/WindowsJumpList.kt index 84e09cd50..c6ce91756 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/WindowsJumpList.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/shortcuts/WindowsJumpList.kt @@ -6,7 +6,7 @@ import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRe import com.darkrockstudios.apps.hammer.desktop.PROJECT_FLAG import com.darkrockstudios.apps.hammer.jump_list_recent_projects import io.github.aakira.napier.Napier -import io.github.kdroidfilter.nucleus.launcher.windows.* +import dev.nucleusframework.launcher.windows.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/desktop/src/jvmMain/resources/aboutlibraries.json b/desktop/src/jvmMain/resources/aboutlibraries.json index ed5fd6c06..927ef701d 100644 --- a/desktop/src/jvmMain/resources/aboutlibraries.json +++ b/desktop/src/jvmMain/resources/aboutlibraries.json @@ -199,10 +199,10 @@ }, { "uniqueId": "androidx.lifecycle:lifecycle-common-jvm", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle-Common", "description": "Android Lifecycle-Common", - "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.10.0", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.11.0", "developers": [ { "name": "The Android Open Source Project" @@ -224,10 +224,10 @@ }, { "uniqueId": "androidx.lifecycle:lifecycle-runtime-compose-desktop", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle Runtime Compose", "description": "Compose integration with Lifecycle", - "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.10.0", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.11.0", "developers": [ { "name": "The Android Open Source Project" @@ -249,10 +249,10 @@ }, { "uniqueId": "androidx.lifecycle:lifecycle-runtime-desktop", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle Runtime", "description": "Android Lifecycle Runtime", - "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.10.0", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.11.0", "developers": [ { "name": "The Android Open Source Project" @@ -274,10 +274,10 @@ }, { "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-desktop", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle ViewModel", "description": "Android Lifecycle ViewModel", - "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.10.0", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.11.0", "developers": [ { "name": "The Android Open Source Project" @@ -299,10 +299,10 @@ }, { "uniqueId": "androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle ViewModel with SavedState", "description": "Android Lifecycle ViewModel", - "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.10.0", + "website": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.11.0", "developers": [ { "name": "The Android Open Source Project" @@ -537,7 +537,7 @@ }, { "uniqueId": "com.darkrockstudios:composetexteditor-desktop", - "artifactVersion": "2.3.1", + "artifactVersion": "2.4.0", "name": "Compose Text Editor", "description": "A Kotlin Multiplatform Text Editor.", "website": "https://github.com/Darkrock-Studios/ComposeTextEditor", @@ -560,7 +560,7 @@ }, { "uniqueId": "com.darkrockstudios:composetexteditor-find-desktop", - "artifactVersion": "2.3.1", + "artifactVersion": "2.4.0", "name": "Compose Text Editor Find", "description": "Find functionality addon for Compose Text Editor.", "website": "https://github.com/Darkrock-Studios/ComposeTextEditor", @@ -583,7 +583,7 @@ }, { "uniqueId": "com.darkrockstudios:composetexteditor-spellcheck-desktop", - "artifactVersion": "2.3.1", + "artifactVersion": "2.4.0", "name": "Compose Text Editor Spell Check", "description": "Spell checking addon for Compose Text Editor.", "website": "https://github.com/Darkrock-Studios/ComposeTextEditor", @@ -629,19 +629,19 @@ }, { "uniqueId": "com.darkrockstudios:platform-spellcheckerkt-desktop", - "artifactVersion": "1.3.1", + "artifactVersion": "1.3.2", "name": "PlatformSpellChecker", "description": "A Kotlin Multiplatform library providing spell checking functionality for Android, iOS, and Desktop platforms", - "website": "https://github.com/Wavesonics/PlatformSpellCheckerKt", + "website": "https://github.com/Darkrock-Studios/PlatformSpellCheckerKt", "developers": [ { "name": "Adam Brown" } ], "scm": { - "connection": "scm:git:git://github.com/Wavesonics/PlatformSpellCheckerKt.git", - "developerConnection": "scm:git:ssh://github.com/Wavesonics/PlatformSpellCheckerKt.git", - "url": "https://github.com/Wavesonics/PlatformSpellCheckerKt" + "connection": "scm:git:git://github.com/Darkrock-Studios/PlatformSpellCheckerKt.git", + "developerConnection": "scm:git:ssh://github.com/Darkrock-Studios/PlatformSpellCheckerKt.git", + "url": "https://github.com/Darkrock-Studios/PlatformSpellCheckerKt" }, "licenses": [ "MIT" @@ -719,6 +719,72 @@ ] }, + { + "uniqueId": "com.fleeksoft.charset:charset-jvm", + "artifactVersion": "0.0.8", + "name": "charset", + "description": "Kotlin Multiplatform Charsets", + "website": "https://github.com/fleeksoft/charset", + "developers": [ + { + "name": "Sabeeh Ul Hussnain Anjum" + } + ], + "scm": { + "connection": "https://github.com/fleeksoft/charset.git", + "url": "https://github.com/fleeksoft/charset" + }, + "licenses": [ + "Apache-2.0" + ], + "funding": [ + + ] + }, + { + "uniqueId": "com.fleeksoft.io:io-core-jvm", + "artifactVersion": "0.0.8", + "name": "io-core", + "description": "Kotlin Multiplatform IO", + "website": "https://github.com/fleeksoft/fleeksoft-io", + "developers": [ + { + "name": "Sabeeh Ul Hussnain Anjum" + } + ], + "scm": { + "connection": "https://github.com/fleeksoft/fleeksoft-io.git", + "url": "https://github.com/fleeksoft/fleeksoft-io" + }, + "licenses": [ + "Apache-2.0" + ], + "funding": [ + + ] + }, + { + "uniqueId": "com.fleeksoft.ksoup:ksoup-jvm", + "artifactVersion": "0.2.6", + "name": "ksoup", + "description": "Ksoup is a Kotlin Multiplatform library for working with HTML and XML, and offers an easy-to-use API for URL fetching, data parsing, extraction, and manipulation using DOM and CSS selectors.", + "website": "https://github.com/fleeksoft/ksoup", + "developers": [ + { + "name": "Sabeeh Ul Hussnain Anjum" + } + ], + "scm": { + "connection": "https://github.com/fleeksoft/ksoup.git", + "url": "https://github.com/fleeksoft/ksoup" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, { "uniqueId": "com.github.ajalt.clikt:clikt-core-jvm", "artifactVersion": "5.1.0", @@ -1299,19 +1365,19 @@ }, { "uniqueId": "com.squareup.okio:okio-jvm", - "artifactVersion": "3.17.0", + "artifactVersion": "3.18.1", "name": "okio", "description": "A modern I/O library for Android, Java, and Kotlin Multiplatform.", - "website": "https://github.com/square/okio/", + "website": "https://github.com/lysine-dev/okio/", "developers": [ { "name": "Square, Inc." } ], "scm": { - "connection": "scm:git:git://github.com/square/okio.git", - "developerConnection": "scm:git:ssh://git@github.com/square/okio.git", - "url": "https://github.com/square/okio/" + "connection": "scm:git:git://github.com/lysine-dev/okio.git", + "developerConnection": "scm:git:ssh://git@github.com/lysine-dev/okio.git", + "url": "https://github.com/lysine-dev/okio/" }, "licenses": [ "Apache-2.0" @@ -1433,6 +1499,328 @@ ] }, + { + "uniqueId": "dev.nucleusframework:nucleus.aot-runtime", + "artifactVersion": "2.1.9", + "name": "Nucleus AOT Runtime", + "description": "AOT runtime library for the Nucleus Gradle plugin", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.core-runtime", + "artifactVersion": "2.1.9", + "name": "Nucleus Core Runtime", + "description": "Core runtime library for the Nucleus Gradle plugin", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.darkmode-detector", + "artifactVersion": "2.1.9", + "name": "Nucleus Dark Mode Detector", + "description": "Reactive dark mode detection for Compose Desktop across Windows, macOS and Linux", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.decorated-window-awt", + "artifactVersion": "2.1.9", + "name": "Nucleus Decorated Window AWT", + "description": "AWT/Compose Desktop integration of Nucleus Decorated Window (consumed by JBR and JNI backends)", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.decorated-window-core", + "artifactVersion": "2.1.9", + "name": "Nucleus Decorated Window Core", + "description": "Shared types, layout, styling, and resources for Nucleus Decorated Window", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.decorated-window-material3", + "artifactVersion": "2.1.9", + "name": "Nucleus Material Decorated Window", + "description": "Material 3 integration for Nucleus Decorated Window", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.decorated-window-tao", + "artifactVersion": "2.1.9", + "name": "Nucleus Decorated Window Tao", + "description": "Experimental no-AWT decorated window backend for Compose Desktop, powered by Tao via direct JNI for macOS, Windows, and Linux.", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.freedesktop-icons", + "artifactVersion": "2.1.9", + "name": "Nucleus Freedesktop Icons", + "description": "Type-safe freedesktop icon naming specification constants for JVM desktop applications", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.graalvm-runtime", + "artifactVersion": "2.1.9", + "name": "Nucleus GraalVM Runtime", + "description": "GraalVM native-image runtime initialization and font manager substitutions", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.launcher-linux", + "artifactVersion": "2.1.9", + "name": "Nucleus Launcher Linux", + "description": "Unity Launcher API (com.canonical.Unity.LauncherEntry) for JVM desktop applications via JNI", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.launcher-macos", + "artifactVersion": "2.1.9", + "name": "Nucleus Launcher macOS", + "description": "macOS dock context menu for JVM desktop applications via JNI", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.launcher-windows", + "artifactVersion": "2.1.9", + "name": "Nucleus Launcher Windows", + "description": "Windows Launcher API (Badge Notifications) for JVM desktop applications via JNI", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.linux-hidpi", + "artifactVersion": "2.1.9", + "name": "Nucleus Linux HiDPI", + "description": "Native HiDPI scale factor detection for Compose Desktop on Linux (GSettings, GDK_SCALE, Xft.dpi)", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, + { + "uniqueId": "dev.nucleusframework:nucleus.nucleus-application", + "artifactVersion": "2.1.9", + "name": "Nucleus Application", + "description": "Unified entry point picking the decorated-window backend (JBR/JNI AWT or no-AWT Tao) and exposing a backend-agnostic window handle.", + "website": "https://github.com/NucleusFramework/Nucleus", + "developers": [ + { + "name": "NucleusFramework" + } + ], + "scm": { + "connection": "scm:git:git://github.com/NucleusFramework/Nucleus.git", + "developerConnection": "scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git", + "url": "https://github.com/NucleusFramework/Nucleus" + }, + "licenses": [ + "MIT" + ], + "funding": [ + + ] + }, { "uniqueId": "io.coil-kt.coil3:coil-compose-core-jvm", "artifactVersion": "3.5.0", @@ -1593,213 +1981,6 @@ ] }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.core-runtime", - "artifactVersion": "1.15.7", - "name": "Nucleus Core Runtime", - "description": "Core runtime library for the Nucleus Gradle plugin", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.darkmode-detector", - "artifactVersion": "1.15.7", - "name": "Nucleus Dark Mode Detector", - "description": "Reactive dark mode detection for Compose Desktop across Windows, macOS and Linux", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.decorated-window-core", - "artifactVersion": "1.15.7", - "name": "Nucleus Decorated Window Core", - "description": "Shared types, layout, styling, and resources for Nucleus Decorated Window", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.decorated-window-jbr", - "artifactVersion": "1.15.7", - "name": "Nucleus Decorated Window JBR", - "description": "JBR-based custom decorated window with native title bar for Compose Desktop", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.decorated-window-material3", - "artifactVersion": "1.15.7", - "name": "Nucleus Material Decorated Window", - "description": "Material 3 integration for Nucleus Decorated Window", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.freedesktop-icons", - "artifactVersion": "1.15.7", - "name": "Nucleus Freedesktop Icons", - "description": "Type-safe freedesktop icon naming specification constants for JVM desktop applications", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.launcher-linux", - "artifactVersion": "1.15.7", - "name": "Nucleus Launcher Linux", - "description": "Unity Launcher API (com.canonical.Unity.LauncherEntry) for JVM desktop applications via JNI", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.launcher-macos", - "artifactVersion": "1.15.7", - "name": "Nucleus Launcher macOS", - "description": "macOS dock context menu for JVM desktop applications via JNI", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, - { - "uniqueId": "io.github.kdroidfilter:nucleus.launcher-windows", - "artifactVersion": "1.15.7", - "name": "Nucleus Launcher Windows", - "description": "Windows Launcher API (Badge Notifications) for JVM desktop applications via JNI", - "website": "https://github.com/kdroidFilter/Nucleus", - "developers": [ - { - "name": "kdroidFilter" - } - ], - "scm": { - "connection": "scm:git:git://github.com/kdroidFilter/Nucleus.git", - "developerConnection": "scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git", - "url": "https://github.com/kdroidFilter/Nucleus" - }, - "licenses": [ - "MIT" - ], - "funding": [ - - ] - }, { "uniqueId": "io.github.koalaplot:koalaplot-core-desktop", "artifactVersion": "0.11.2", @@ -1891,29 +2072,6 @@ ] }, - { - "uniqueId": "io.github.sudarshanmhasrup.splashify:splashify-desktop", - "artifactVersion": "1.0.0-alpha1", - "name": "Splashify", - "description": "A Kotlin Multiplatform library for effortless, customizable splash screens in Compose Multiplatform desktop apps.", - "website": "https://github.com/sudarshanmhasrup/splashify", - "developers": [ - { - "name": "Sudarshan Mhasrup" - } - ], - "scm": { - "connection": "scm:git:https://github.com/sudarshanmhasrup/splashify.git", - "developerConnection": "scm:git:ssh://git@github.com/sudarshanmhasrup/splashify.git", - "url": "https://github.com/sudarshanmhasrup/splashify" - }, - "licenses": [ - "Apache-2.0" - ], - "funding": [ - - ] - }, { "uniqueId": "io.github.vinceglb:filekit-core-jvm", "artifactVersion": "0.14.2", @@ -1961,7 +2119,7 @@ }, { "uniqueId": "io.ktor:ktor-client-auth-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-auth", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -1983,7 +2141,7 @@ }, { "uniqueId": "io.ktor:ktor-client-content-negotiation-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-content-negotiation", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2005,7 +2163,7 @@ }, { "uniqueId": "io.ktor:ktor-client-core-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-core", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2027,7 +2185,7 @@ }, { "uniqueId": "io.ktor:ktor-client-encoding-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-encoding", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2049,7 +2207,7 @@ }, { "uniqueId": "io.ktor:ktor-client-java-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-java", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2071,7 +2229,7 @@ }, { "uniqueId": "io.ktor:ktor-client-logging-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-client-logging", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2093,7 +2251,7 @@ }, { "uniqueId": "io.ktor:ktor-events-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-events", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2115,7 +2273,7 @@ }, { "uniqueId": "io.ktor:ktor-http-cio-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-http-cio", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2137,7 +2295,7 @@ }, { "uniqueId": "io.ktor:ktor-http-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-http", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2159,7 +2317,7 @@ }, { "uniqueId": "io.ktor:ktor-io-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-io", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2181,7 +2339,7 @@ }, { "uniqueId": "io.ktor:ktor-network-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-network", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2203,7 +2361,7 @@ }, { "uniqueId": "io.ktor:ktor-serialization-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-serialization", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2225,7 +2383,7 @@ }, { "uniqueId": "io.ktor:ktor-serialization-kotlinx-json-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-serialization-kotlinx-json", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2247,7 +2405,7 @@ }, { "uniqueId": "io.ktor:ktor-serialization-kotlinx-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-serialization-kotlinx", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2269,7 +2427,7 @@ }, { "uniqueId": "io.ktor:ktor-sse-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-sse", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2291,7 +2449,7 @@ }, { "uniqueId": "io.ktor:ktor-utils-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-utils", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2313,7 +2471,7 @@ }, { "uniqueId": "io.ktor:ktor-websocket-serialization-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-websocket-serialization", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2335,7 +2493,7 @@ }, { "uniqueId": "io.ktor:ktor-websockets-jvm", - "artifactVersion": "3.5.1", + "artifactVersion": "3.5.2", "name": "ktor-websockets", "description": "Ktor is a framework for quickly creating web applications in Kotlin with minimal effort.", "website": "https://github.com/ktorio/ktor", @@ -2780,11 +2938,11 @@ ] }, { - "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-common", - "artifactVersion": "2.10.0", + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-common-jvm", + "artifactVersion": "2.11.0", "name": "Lifecycle-Common", "description": "Android Lifecycle-Common", - "website": "https://github.com/JetBrains/compose-jb", + "website": "https://github.com/JetBrains/compose-multiplatform", "developers": [ { "name": "Compose Multiplatform Team", @@ -2792,33 +2950,9 @@ } ], "scm": { - "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "url": "https://github.com/JetBrains/compose-jb" - }, - "licenses": [ - "Apache-2.0" - ], - "funding": [ - - ] - }, - { - "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-runtime", - "artifactVersion": "2.10.0", - "name": "Lifecycle Runtime", - "description": "Android Lifecycle Runtime", - "website": "https://github.com/JetBrains/compose-jb", - "developers": [ - { - "name": "Compose Multiplatform Team", - "organisationUrl": "https://www.jetbrains.com" - } - ], - "scm": { - "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "url": "https://github.com/JetBrains/compose-jb" + "connection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "url": "https://github.com/JetBrains/compose-multiplatform" }, "licenses": [ "Apache-2.0" @@ -2829,10 +2963,10 @@ }, { "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop", - "artifactVersion": "2.10.0", + "artifactVersion": "2.11.0", "name": "Lifecycle Runtime Compose", "description": "Compose integration with Lifecycle", - "website": "https://github.com/JetBrains/compose-jb", + "website": "https://github.com/JetBrains/compose-multiplatform", "developers": [ { "name": "Compose Multiplatform Team", @@ -2840,9 +2974,9 @@ } ], "scm": { - "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "url": "https://github.com/JetBrains/compose-jb" + "connection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "url": "https://github.com/JetBrains/compose-multiplatform" }, "licenses": [ "Apache-2.0" @@ -2852,11 +2986,35 @@ ] }, { - "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", - "artifactVersion": "2.10.0", + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-runtime-desktop", + "artifactVersion": "2.11.0", + "name": "Lifecycle Runtime", + "description": "Android Lifecycle Runtime", + "website": "https://github.com/JetBrains/compose-multiplatform", + "developers": [ + { + "name": "Compose Multiplatform Team", + "organisationUrl": "https://www.jetbrains.com" + } + ], + "scm": { + "connection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "url": "https://github.com/JetBrains/compose-multiplatform" + }, + "licenses": [ + "Apache-2.0" + ], + "funding": [ + + ] + }, + { + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-desktop", + "artifactVersion": "2.11.0", "name": "Lifecycle ViewModel", "description": "Android Lifecycle ViewModel", - "website": "https://github.com/JetBrains/compose-jb", + "website": "https://github.com/JetBrains/compose-multiplatform", "developers": [ { "name": "Compose Multiplatform Team", @@ -2864,9 +3022,9 @@ } ], "scm": { - "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "url": "https://github.com/JetBrains/compose-jb" + "connection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "url": "https://github.com/JetBrains/compose-multiplatform" }, "licenses": [ "Apache-2.0" @@ -2876,11 +3034,11 @@ ] }, { - "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", - "artifactVersion": "2.9.6", + "uniqueId": "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop", + "artifactVersion": "2.11.0", "name": "Lifecycle ViewModel with SavedState", "description": "Android Lifecycle ViewModel", - "website": "https://github.com/JetBrains/compose-jb", + "website": "https://github.com/JetBrains/compose-multiplatform", "developers": [ { "name": "Compose Multiplatform Team", @@ -2888,9 +3046,9 @@ } ], "scm": { - "connection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "developerConnection": "scm:git:https://github.com/JetBrains/compose-jb.git", - "url": "https://github.com/JetBrains/compose-jb" + "connection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "developerConnection": "scm:git:https://github.com/JetBrains/compose-multiplatform.git", + "url": "https://github.com/JetBrains/compose-multiplatform" }, "licenses": [ "Apache-2.0" @@ -4000,7 +4158,7 @@ }, { "uniqueId": "org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm", - "artifactVersion": "0.9.0", + "artifactVersion": "0.9.1", "name": "kotlinx-io-bytestring", "description": "IO support for Kotlin", "website": "https://github.com/Kotlin/kotlinx-io", @@ -4022,7 +4180,7 @@ }, { "uniqueId": "org.jetbrains.kotlinx:kotlinx-io-core-jvm", - "artifactVersion": "0.9.0", + "artifactVersion": "0.9.1", "name": "kotlinx-io-core", "description": "IO support for Kotlin", "website": "https://github.com/Kotlin/kotlinx-io", @@ -4132,7 +4290,7 @@ }, { "uniqueId": "org.jetbrains.runtime:jbr-api", - "artifactVersion": "1.10.1", + "artifactVersion": "1.9.0", "name": "jbr-api", "description": "Interface for the functionality specific to https://github.com/JetBrains/JetBrainsRuntime", "website": "https://github.com/JetBrains/JetBrainsRuntimeApi", @@ -4275,7 +4433,7 @@ }, { "uniqueId": "org.jetbrains:markdown-jvm", - "artifactVersion": "0.7.7", + "artifactVersion": "0.7.8", "name": "markdown", "description": "Markdown parser in Kotlin", "website": "https://github.com/JetBrains/markdown", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4a50e337..24349b767 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,6 @@ ktor_i18n = "2.0.0" okio = "3.18.1" slf4jSimple = "2.0.18" -splashify = "1.0.0-alpha1" turbine = "1.2.1" work-runtime-ktx = "2.11.2" @@ -104,7 +103,7 @@ kotlinx-io-okio = "0.9.1" compose-texteditor = "2.4.0" kotlin-multiplatform-diff = "1.3.0" platform-spellcheckerkt = "1.3.2" -nucleus = "1.15.7" +nucleus = "2.1.9" colorpicker-compose = "1.2.0" material-kolor = "5.0.0" @@ -228,7 +227,6 @@ koin_sl4j = { group = "io.insert-koin", name = "koin-logger-slf4j" } koin_ktor = { group = "io.insert-koin", name = "koin-ktor" } koin_compose = { group = "io.insert-koin", name = "koin-compose" } -splashify = { module = "io.github.sudarshanmhasrup.splashify:splashify", version.ref = "splashify" } tomlkt = { group = "net.peanuuutz.tomlkt", name = "tomlkt", version.ref = "tomlkt" } ktor_client_core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } @@ -330,12 +328,13 @@ platform-spellcheckerkt = { module = "com.darkrockstudios:platform-spellcheckerk bouncycastle-bcpkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bouncycastle" } -nucleus_darkmode_detector = { module = "io.github.kdroidfilter:nucleus.darkmode-detector", version.ref = "nucleus" } -nucleus_decorated_window_jbr = { module = "io.github.kdroidfilter:nucleus.decorated-window-jbr", version.ref = "nucleus" } -nucleus_decorated_window_material3 = { module = "io.github.kdroidfilter:nucleus.decorated-window-material3", version.ref = "nucleus" } -nucleus_launcher_windows = { module = "io.github.kdroidfilter:nucleus.launcher-windows", version.ref = "nucleus" } -nucleus_launcher_linux = { module = "io.github.kdroidfilter:nucleus.launcher-linux", version.ref = "nucleus" } -nucleus_launcher_macos = { module = "io.github.kdroidfilter:nucleus.launcher-macos", version.ref = "nucleus" } +nucleus_darkmode_detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleus" } +nucleus_application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleus" } +nucleus_decorated_window_tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleus" } +nucleus_decorated_window_material3 = { module = "dev.nucleusframework:nucleus.decorated-window-material3", version.ref = "nucleus" } +nucleus_launcher_windows = { module = "dev.nucleusframework:nucleus.launcher-windows", version.ref = "nucleus" } +nucleus_launcher_linux = { module = "dev.nucleusframework:nucleus.launcher-linux", version.ref = "nucleus" } +nucleus_launcher_macos = { module = "dev.nucleusframework:nucleus.launcher-macos", version.ref = "nucleus" } [plugins] kotlin_multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } From 46c22fd1b82e73828bc6d20af68648800cff5d24 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 6 Aug 2026 01:26:34 -0700 Subject: [PATCH 050/213] Recreate projects whose cached server id is dead (#857) * Recreate projects whose cached server id is dead A local project keeps the server project id it was assigned on a previous sync. Account sync only created a project on the server when that id was missing, so a project holding an id the server has no record of was skipped and per-project sync went straight to begin_sync with it, failing 410 Gone with no path back. Treat a cached id the server neither lists nor has tombstoned as dead and recreate the project. A tombstoned id still means a real server-side delete and keeps its existing handling. * Judge project id liveness on raw sync data, and heal on 410 Two gaps in the stale-id recovery: Liveness was judged against the server list processProjectSyncData had already filtered, which drops projects queued for deletion. A project whose folder came back after a local delete looked unknown to the server and was recreated, undoing the deletion. Judge against the raw begin_sync response instead, where a queued-for-deletion project is still listed. The per-project pipeline only created a project when its id was missing, so syncing from inside a project still failed 410 forever. Discard the id when begin_sync answers 410, which returns the project to the never-synced state so the next sync recreates it. Document both in the syncing protocol. * Withdraw sync queue entries that can never succeed A queued rename keyed on an id the server never issued failed every session forever, writing a red line into the sync log each time with no way to clear it. Drop it without sending: recreation already covers the rename, since it creates the project under its current local name. A project deleted before its first sync left its name queued for creation, so the next sync created an empty project on the server and pushed it to every device, then failed resolving a definition for the deleted directory and repeated forever. Withdraw the queued creation on delete, and drop queued names with no local project so existing bad state heals. --- .../composeResources/values/strings_sync.xml | 1 + .../projectslist/ProjectsListComponent.kt | 2 + .../accountsync/ClientAccountSynchronizer.kt | 90 +++++++++++++--- .../operations/FetchServerDataOperation.kt | 18 +++- .../ClientAccountSynchronizerTest.kt | 101 ++++++++++++++++++ .../FetchServerDataOperationTest.kt | 72 +++++++++++++ docs/SYNCING-PROTOCOL.md | 34 ++++++ 7 files changed, 303 insertions(+), 15 deletions(-) diff --git a/common/src/commonMain/composeResources/values/strings_sync.xml b/common/src/commonMain/composeResources/values/strings_sync.xml index 4e287e863..d7bc212ee 100644 --- a/common/src/commonMain/composeResources/values/strings_sync.xml +++ b/common/src/commonMain/composeResources/values/strings_sync.xml @@ -162,6 +162,7 @@ Client Entity data calculated + The server no longer has this project. It will be recreated the next time you sync. Server data received Client data loaded Local Backup made: %1$s diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt index decaf54c9..fa967b473 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt @@ -313,6 +313,8 @@ class ProjectsListComponent( Napier.i("Project deleted: ${projectDef.name}") if (syncedProject != null) { projectsSynchronizer.deleteProject(syncedProject) + } else if (projectsSynchronizer.isServerSynchronized()) { + projectsSynchronizer.deleteUnsyncedProject(projectDef.name) } loadProjectList() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt index 6cb44e1fc..02327efc0 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/accountsync/ClientAccountSynchronizer.kt @@ -81,10 +81,11 @@ class ClientAccountSynchronizer( syncId = serverSyncData.syncId val clientSyncData = loadSyncData() + val serverKnownIds = serverSyncData.knownProjectIds() yield() - syncRenamedProjects(clientSyncData, serverSyncData, onLog, onUnauthorized) + syncRenamedProjects(clientSyncData, serverSyncData, serverKnownIds, onLog, onUnauthorized) yield() @@ -95,7 +96,14 @@ class ClientAccountSynchronizer( val updatedServerSyncData = processProjectSyncData(serverSyncData, clientSyncData) val localProjects = projectsRepository.getProjects() - syncCreatedProjects(clientSyncData, updatedServerSyncData, localProjects, onLog, onUnauthorized) + syncCreatedProjects( + clientSyncData, + updatedServerSyncData, + serverKnownIds, + localProjects, + onLog, + onUnauthorized, + ) yield() @@ -273,11 +281,20 @@ class ClientAccountSynchronizer( private suspend fun syncRenamedProjects( clientSyncData: ProjectsSynchronizationData, serverSyncData: BeginProjectsSyncResponse, + serverKnownIds: Set, onLog: OnSyncLog, onUnauthorized: suspend () -> Unit = {}, ) { // Rename projects on the server clientSyncData.projectsToRename.forEach { (projectId, newName) -> + // The server can't rename an id it never issued, and would fail this every session + // forever. Recreation covers the rename anyway: it creates under the local name, + // which a local rename has already updated. + if (projectId !in serverKnownIds) { + dropQueuedRename(projectId) + return@forEach + } + val result = serverProjectsApi.renameProject(projectId, serverSyncData.syncId, newName) if (result.isSuccess) { onLog( @@ -288,12 +305,7 @@ class ClientAccountSynchronizer( ) ) ) - updateSyncData { syncData -> - syncData.copy( - projectsToRename = syncData.projectsToRename - .filterNot { it.projectId == projectId }.toSet(), - ) - } + dropQueuedRename(projectId) } else { val exception = result.exceptionOrNull() Napier.e("Failed to rename project: $projectId", exception) @@ -314,6 +326,15 @@ class ClientAccountSynchronizer( } } + private fun dropQueuedRename(projectId: ProjectId) { + updateSyncData { syncData -> + syncData.copy( + projectsToRename = syncData.projectsToRename + .filterNot { it.projectId == projectId }.toSet(), + ) + } + } + private suspend fun syncDeletedProjects( clientSyncData: ProjectsSynchronizationData, serverSyncData: BeginProjectsSyncResponse, @@ -402,6 +423,7 @@ class ClientAccountSynchronizer( private suspend fun syncCreatedProjects( clientSyncData: ProjectsSynchronizationData, serverSyncData: BeginProjectsSyncResponse, + serverKnownIds: Set, localProjects: List, onLog: OnSyncLog, onUnauthorized: suspend () -> Unit = {}, @@ -425,7 +447,14 @@ class ClientAccountSynchronizer( onLog ) - createProjectsOnServer(localProjectsWithIds, clientSyncData, serverSyncData, onLog, onUnauthorized) + createProjectsOnServer( + localProjectsWithIds, + clientSyncData, + serverSyncData, + serverKnownIds, + onLog, + onUnauthorized, + ) createLocalProjectsFromServer(newServerProjects, onLog) } @@ -478,20 +507,34 @@ class ClientAccountSynchronizer( } /** - * Create projects on the server which this client has created locally + * Create projects on the server which this client has created locally. A cached [ProjectId] + * absent from [serverKnownIds] is dead (the client last synced against a different or + * since-reset server): recreate it, or per-project sync gets an id it can only 410 on. */ private suspend fun createProjectsOnServer( localProjectsWithIds: List>, clientSyncData: ProjectsSynchronizationData, serverSyncData: BeginProjectsSyncResponse, + serverKnownIds: Set, onLog: OnSyncLog, onUnauthorized: suspend () -> Unit = {}, ) { val localOnly = localProjectsWithIds.filter { (_, uuid) -> - uuid == null + uuid == null || uuid !in serverKnownIds }.map { it.first.name } - val newLocalProjects = clientSyncData.projectsToCreate + localOnly + // A queued name with no local project was deleted before it ever reached the server. + // Creating it would push an empty project to every device, and resolving its definition + // would point at a directory that is gone, so drop it instead. + val localNames = localProjectsWithIds.mapTo(mutableSetOf()) { it.first.name } + val staleCreates = clientSyncData.projectsToCreate - localNames + if (staleCreates.isNotEmpty()) { + updateSyncData { syncData -> + syncData.copy(projectsToCreate = syncData.projectsToCreate - staleCreates) + } + } + + val newLocalProjects = (clientSyncData.projectsToCreate - staleCreates) + localOnly // Create projects on the server newLocalProjects.forEach { projectName -> val result = serverProjectsApi.createProject(projectName, serverSyncData.syncId) @@ -625,6 +668,19 @@ class ClientAccountSynchronizer( } } + /** + * A project deleted before it ever synced has no id to tombstone, only a queued creation to + * withdraw. Left queued, the next sync would create it on the server and push it back to + * every device. + */ + fun deleteUnsyncedProject(projectName: String) { + updateSyncData { syncData -> + syncData.copy( + projectsToCreate = syncData.projectsToCreate - projectName, + ) + } + } + private fun getSyncDataPath(): Path = projectsRepository.getProjectsDirectory().toOkioPath() / SYNC_FILE_NAME @@ -708,4 +764,12 @@ private data class ProjectPair( ) private fun Throwable?.isAuthenticationFailure(): Boolean = - (this is HttpFailureException && statusCode == HttpStatusCode.Unauthorized) \ No newline at end of file + (this is HttpFailureException && statusCode == HttpStatusCode.Unauthorized) + +/** + * Every project id the server accounted for, live or tombstoned. Must be read from the raw + * begin_sync response: `processProjectSyncData` drops projects this client has queued for + * deletion, and a project missing for that reason is still known to the server, not dead. + */ +private fun BeginProjectsSyncResponse.knownProjectIds(): Set = + projects.mapTo(mutableSetOf()) { it.uuid } + deletedProjects \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/operations/FetchServerDataOperation.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/operations/FetchServerDataOperation.kt index 23d0ae0ae..70da03ce5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/operations/FetchServerDataOperation.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/operations/FetchServerDataOperation.kt @@ -6,10 +6,14 @@ import com.darkrockstudios.apps.hammer.common.data.CResult import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource +import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.* +import com.darkrockstudios.apps.hammer.common.server.HttpFailureException import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import com.darkrockstudios.apps.hammer.common.util.StrRes +import com.darkrockstudios.apps.hammer.sync_log_project_id_stale import com.darkrockstudios.apps.hammer.sync_log_server_data_loaded +import io.ktor.http.HttpStatusCode import kotlinx.coroutines.flow.first class FetchServerDataOperation( @@ -17,6 +21,7 @@ class FetchServerDataOperation( private val globalSettingsStore: GlobalSettingsStore, private val serverProjectApi: ServerProjectApi, private val projectMetadataDatasource: ProjectMetadataDatasource, + private val projectsRepository: ProjectsRepository, private val strRes: StrRes, ) : SyncOperation(projectDef) { @@ -55,9 +60,18 @@ class FetchServerDataOperation( CResult.success(fetchServerDataStateState) } else { - CResult.failure( + val exception = serverSyncDataResult.exceptionOrNull() ?: IllegalStateException("No exception") - ) + + // 410 means the server has no record of this id, so it can never succeed. Drop it and + // EnsureProjectIdOperation creates the project fresh next sync, instead of this + // failing forever. + if (exception is HttpFailureException && exception.statusCode == HttpStatusCode.Gone) { + projectsRepository.removeProjectId(projectDef) + onLog(syncLogW(strRes.get(Res.string.sync_log_project_id_stale), projectDef)) + } + + CResult.failure(exception) } } diff --git a/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt b/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt index baa1376a4..92a014fb3 100644 --- a/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt +++ b/common/src/desktopTest/kotlin/synchronizer/ClientAccountSynchronizerTest.kt @@ -438,6 +438,69 @@ class ClientAccountSynchronizerTest { assertTrue(readSyncData().projectsToCreate.isEmpty()) } + @Test + fun `syncProjects recreates a project whose cached id the server neither holds nor tombstoned`() = runTest { + writeSyncData(emptySyncData()) + val staleId = ProjectId.randomUUID() + val freshId = ProjectId.randomUUID() + val def = projectDef("ResetServerNovel") + + // The server has no record of this id at all: not a live project, not a tombstone. + // This is what a client sees after syncing against a different or since-reset server. + every { projectsRepository.getProjects(any()) } returns listOf(def) + every { projectsRepository.getProjectId(def) } returns staleId + every { projectsRepository.getProjectDefinition("ResetServerNovel") } returns def + coEvery { serverProjectsApi.createProject("ResetServerNovel", "sync-1") } returns + Result.success(CreateProjectResponse(freshId, alreadyExisted = false)) + + val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) + + assertTrue(result) + coVerify { serverProjectsApi.createProject("ResetServerNovel", "sync-1") } + verify { projectsRepository.setProjectId(def, freshId) } + } + + @Test + fun `syncProjects does not recreate a project whose cached id the server still lists`() = runTest { + writeSyncData(emptySyncData()) + val id = ProjectId.randomUUID() + val def = projectDef("KnownNovel") + + coEvery { serverProjectsApi.beginProjectsSync() } returns Result.success( + emptyServerResponse().copy(projects = setOf(ApiProjectDefinition("KnownNovel", id))) + ) + every { projectsRepository.getProjects(any()) } returns listOf(def) + every { projectsRepository.getProjectId(def) } returns id + + val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) + + assertTrue(result) + coVerify(exactly = 0) { serverProjectsApi.createProject(any(), any()) } + } + + @Test + fun `syncProjects does not resurrect a project queued for deletion that is still on disk`() = runTest { + val id = ProjectId.randomUUID() + val def = projectDef("DoomedNovel") + writeSyncData(emptySyncData().copy(projectsToDelete = setOf(id))) + + // processProjectSyncData strips a queued-for-deletion project out of the server list, so + // liveness has to be judged against the raw begin_sync response. The local folder is still + // present here (restored by a file-sync tool), which is what puts it back in getProjects. + coEvery { serverProjectsApi.beginProjectsSync() } returns Result.success( + emptyServerResponse().copy(projects = setOf(ApiProjectDefinition("DoomedNovel", id))) + ) + coEvery { serverProjectsApi.deleteProject(id, "sync-1") } returns Result.success("ok") + every { projectsRepository.getProjects(any()) } returns listOf(def) + every { projectsRepository.getProjectId(def) } returns id + + val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) + + assertTrue(result) + coVerify { serverProjectsApi.deleteProject(id, "sync-1") } + coVerify(exactly = 0) { serverProjectsApi.createProject(any(), any()) } + } + @Test fun `syncProjects deletes a server project this client marked for deletion`() = runTest { val id = ProjectId.randomUUID() @@ -470,10 +533,48 @@ class ClientAccountSynchronizerTest { verify { projectsRepository.deleteProject(def) } } + @Test + fun `syncProjects drops a rename queued against an id the server never knew`() = runTest { + val deadId = ProjectId.randomUUID() + writeSyncData(emptySyncData().copy(projectsToRename = setOf(RenamedProject(deadId, "NewName")))) + every { projectsRepository.getProjects(any()) } returns emptyList() + + val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) + + assertTrue(result) + // Attempting it would fail every session forever and log an error each time. + coVerify(exactly = 0) { serverProjectsApi.renameProject(any(), any(), any()) } + assertTrue(readSyncData().projectsToRename.isEmpty()) + } + + @Test + fun `syncProjects withdraws a queued creation whose local project is gone`() = runTest { + writeSyncData(emptySyncData().copy(projectsToCreate = setOf("DeletedBeforeSync"))) + every { projectsRepository.getProjects(any()) } returns emptyList() + + val result = createSynchronizer().syncProjects(onLog = {}, onUnauthorized = {}) + + assertTrue(result) + coVerify(exactly = 0) { serverProjectsApi.createProject(any(), any()) } + assertTrue(readSyncData().projectsToCreate.isEmpty()) + } + + @Test + fun `deleteUnsyncedProject withdraws the queued creation`() { + writeSyncData(emptySyncData().copy(projectsToCreate = setOf("Draft", "Keeper"))) + + createSynchronizer().deleteUnsyncedProject("Draft") + + assertEquals(setOf("Keeper"), readSyncData().projectsToCreate) + } + @Test fun `syncProjects renames a project on the server`() = runTest { val id = ProjectId.randomUUID() writeSyncData(emptySyncData().copy(projectsToRename = setOf(RenamedProject(id, "NewName")))) + coEvery { serverProjectsApi.beginProjectsSync() } returns Result.success( + emptyServerResponse().copy(projects = setOf(ApiProjectDefinition("OldName", id))) + ) every { projectsRepository.getProjects(any()) } returns emptyList() coEvery { serverProjectsApi.renameProject(id, "sync-1", "NewName") } returns Result.success("ok") diff --git a/common/src/desktopTest/kotlin/synchronizer/operations/FetchServerDataOperationTest.kt b/common/src/desktopTest/kotlin/synchronizer/operations/FetchServerDataOperationTest.kt index e9f35469c..0d97223f5 100644 --- a/common/src/desktopTest/kotlin/synchronizer/operations/FetchServerDataOperationTest.kt +++ b/common/src/desktopTest/kotlin/synchronizer/operations/FetchServerDataOperationTest.kt @@ -2,22 +2,27 @@ package synchronizer.operations import PROJECT_2_NAME import com.darkrockstudios.apps.hammer.base.http.ApiProjectEntity +import com.darkrockstudios.apps.hammer.base.http.HttpResponseError import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings import com.darkrockstudios.apps.hammer.common.data.isSuccess import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource +import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.* import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.operations.FetchServerDataOperation import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import com.darkrockstudios.apps.hammer.common.server.HttpFailureException import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import getProjectDef +import io.ktor.http.HttpStatusCode import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -45,6 +50,9 @@ class FetchServerDataOperationTest : BaseTest() { @MockK(relaxed = true) private lateinit var serverProjectApi: ServerProjectApi + @MockK(relaxed = true) + private lateinit var projectsRepository: ProjectsRepository + private lateinit var strRes: TestStrRes @BeforeEach @@ -73,6 +81,7 @@ class FetchServerDataOperationTest : BaseTest() { strRes = strRes, projectMetadataDatasource = projectMetadataDatasource, globalSettingsStore = globalSettingsStore, + projectsRepository = projectsRepository, serverProjectApi = serverProjectApi, ) } @@ -177,4 +186,67 @@ class FetchServerDataOperationTest : BaseTest() { assertFalse(isSuccess(result)) } + + @Test + fun `A generic failure leaves the cached project id alone`() = runTest { + val projectDef = getProjectDef(PROJECT_2_NAME) + val op = createOperation(projectDef) + + stubSettingsAndMetadata() + coEvery { serverProjectApi.beginProjectSync(any(), any(), any(), any()) } returns + Result.failure(Exception("Test Failure")) + + val result = execute(op) + + assertFalse(isSuccess(result)) + // Only a 410 proves the id is dead; a transient failure must not discard it. + verify(exactly = 0) { projectsRepository.removeProjectId(any()) } + } + + @Test + fun `A 410 Gone drops the dead project id so the next sync can recreate it`() = runTest { + val projectDef = getProjectDef(PROJECT_2_NAME) + val op = createOperation(projectDef) + + stubSettingsAndMetadata() + coEvery { serverProjectApi.beginProjectSync(any(), any(), any(), any()) } returns + Result.failure( + HttpFailureException( + statusCode = HttpStatusCode.Gone, + error = HttpResponseError( + error = "Project Not Found", + displayMessage = "Project does not exist", + ), + ) + ) + + val result = execute(op) + + assertFalse(isSuccess(result)) + verify { projectsRepository.removeProjectId(projectDef) } + } + + private suspend fun stubSettingsAndMetadata() { + coEvery { globalSettingsStore.serverSettingsUpdates } returns sharedFlow { + emit( + mockk().apply { + every { userId } returns 1L + } + ) + } + coEvery { projectMetadataDatasource.loadMetadata(any()) } returns metadata + } + + private suspend fun execute(op: FetchServerDataOperation) = op.execute( + state = FetchLocalDataState( + onlyNew = false, + clientSyncData = projectData, + entityState = entityState, + serverProjectId = projId, + ), + onProgress = mockk(relaxed = true), + onLog = mockk(relaxed = true), + onConflict = mockk(relaxed = true), + onComplete = mockk(relaxed = true), + ) } \ No newline at end of file diff --git a/docs/SYNCING-PROTOCOL.md b/docs/SYNCING-PROTOCOL.md index 75fcad3bc..200c68ade 100644 --- a/docs/SYNCING-PROTOCOL.md +++ b/docs/SYNCING-PROTOCOL.md @@ -23,6 +23,27 @@ parity with each other. Additionally, it will find or create a `projectId` for the client's local projects. These are the key to being able to sync a local project with the server. +### Stale project IDs + +A local project caches the `projectId` it was assigned, so a client that last synced against a +different server (or one whose database has since been reset) holds IDs that server never issued. +Account sync therefore treats a cached ID the server neither lists in `begin_sync` nor reports in +`deletedProjects` as **dead**, and recreates the project as though it had no ID at all. + +Liveness is judged against the raw `begin_sync` response. A project the client has queued for +deletion is filtered out of the working copy of that list, but it is still known to the server, and +judging it against the filtered list would recreate what the user just deleted. A tombstoned ID is +likewise not dead: it means a real server-side delete, which propagates as a local deletion instead. + +Replacing an ID strands anything else queued against the old one, so the queues are withdrawn +rather than retried: + +- A **rename** queued against an ID the server does not know is dropped without being sent. The + server cannot rename an ID it never issued, so retrying only logs an error every session; + recreation already covers it, because the project is created under its current local name. +- A **creation** queued for a name with no local project is dropped. The project was deleted before + it ever reached the server, so creating it would push an empty project out to every device. + Any given user account may only have one sync in progress at a time. Attempting to start a sync when one is already in progress will result in a failure to begin the sync (`400 Bad Request`). @@ -307,6 +328,19 @@ The install is identified server-side from the authenticated bearer token (never The client also fires `end_sync` even when its sync is cancelled, so sessions are normally released cleanly; reclaim is the safety net for the cases where that request can't be delivered. +#### Unknown project (`410 Gone`) + +A project endpoint called with a `projectId` the user doesn't own answers **`410 Gone`**, not `404`. +The distinction matters: `download_entity` maps a `404` to "entity deleted on the server" and, for +an entity it doesn't already hold, silently marks it deleted. A project-level `404` would therefore +make a client abandon undownloaded entities when a project vanishes mid-sync. + +`410` is terminal for that ID: retrying can never succeed. On receiving it from `begin_sync` the +client discards the cached `projectId`, which drops the project back to the never-synced state, and +the next sync recreates it on the server. Without that the project would fail every sync forever, +because the only other repair path (account sync, above) is not run when syncing a single project +from inside the project itself. + You may however have `syncID`s for multiple different projects simultaneously. These are Project level `syncID`s. Account syncing use separate Account level `syncID`s. There may only be one valid Account level `syncID` at a time, and if there is a valid Account `syncID`, then no Project level `syncID`s are allowed to be created. The Account level sync must finish before any Project level syncs may begin. From 79bd7ca2ef53266324c736bcb1350aadd539c954 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 6 Aug 2026 23:38:14 -0700 Subject: [PATCH 051/213] New Crowdin updates (#860) * New translations strings_sync.xml (French) [ci skip] * New translations strings_sync.xml (Spanish) [ci skip] * New translations strings_sync.xml (German) [ci skip] * New translations strings_sync.xml (Italian) [ci skip] * New translations strings_sync.xml (Ukrainian) [ci skip] * New translations strings_sync.xml (Portuguese, Brazilian) [ci skip] * Update source file strings_sync.xml [ci skip] --- .../src/commonMain/composeResources/values-de/strings_sync.xml | 1 + .../src/commonMain/composeResources/values-es/strings_sync.xml | 1 + .../src/commonMain/composeResources/values-fr/strings_sync.xml | 1 + .../src/commonMain/composeResources/values-it/strings_sync.xml | 1 + .../commonMain/composeResources/values-pt-rBR/strings_sync.xml | 1 + .../src/commonMain/composeResources/values-uk/strings_sync.xml | 1 + 6 files changed, 6 insertions(+) diff --git a/common/src/commonMain/composeResources/values-de/strings_sync.xml b/common/src/commonMain/composeResources/values-de/strings_sync.xml index 4d51fda9c..523f1bb60 100644 --- a/common/src/commonMain/composeResources/values-de/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-de/strings_sync.xml @@ -116,6 +116,7 @@ Projekt wird synchronisiert: %1$s Im Projekt %1$s besteht ein Konflikt. Öffnen Sie dieses Projekt und synchronisieren Sie, um ihn aufzulösen. Client-Entitätsdaten berechnet + The server no longer has this project. It will be recreated the next time you sync. Serverdaten empfangen Client-Daten geladen Lokales Backup erstellt: %1$s diff --git a/common/src/commonMain/composeResources/values-es/strings_sync.xml b/common/src/commonMain/composeResources/values-es/strings_sync.xml index 18ac8dfa5..c3a6b85e3 100644 --- a/common/src/commonMain/composeResources/values-es/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-es/strings_sync.xml @@ -116,6 +116,7 @@ Sincronizando el proyecto: %1$s Hay un conflicto en el proyecto: %1$s. Abre ese proyecto y sincroniza para resolverlo. Datos de entidades del cliente calculados + The server no longer has this project. It will be recreated the next time you sync. Datos del servidor recibidos Datos del cliente cargados Copia de seguridad local creada: %1$s diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index e2eaa0a84..19a8cb851 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -116,6 +116,7 @@ Synchronisation du projet : %1$s Il y a un conflit dans le projet : %1$s. Ouvrez ce projet et synchronisez pour le résoudre. Données d'entité client calculées + The server no longer has this project. It will be recreated the next time you sync. Données du serveur reçues Données client chargées Sauvegarde locale effectuée : %1$s diff --git a/common/src/commonMain/composeResources/values-it/strings_sync.xml b/common/src/commonMain/composeResources/values-it/strings_sync.xml index 626aecacd..9f4bc6edf 100644 --- a/common/src/commonMain/composeResources/values-it/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-it/strings_sync.xml @@ -116,6 +116,7 @@ Sincronizzazione del progetto: %1$s C’è un conflitto nel progetto: %1$s. Apri quel progetto e sincronizza per risolverlo. Dati delle entità client calcolati + The server no longer has this project. It will be recreated the next time you sync. Dati del server ricevuti Dati del client caricati Backup locale eseguito: %1$s diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml index fb77f771a..ad3d7cc4d 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_sync.xml @@ -116,6 +116,7 @@ Sincronizando Projeto: %1$s Há um conflito no projeto: %1$s, abra o projeto e sincronize para resolvê-lo Dados da Entidade de Cliente calculados + The server no longer has this project. It will be recreated the next time you sync. Dados do servidor recebidos Dados do cliente carregados Backup Local realizado: %1$s diff --git a/common/src/commonMain/composeResources/values-uk/strings_sync.xml b/common/src/commonMain/composeResources/values-uk/strings_sync.xml index db38fd497..f3856ab2f 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_sync.xml @@ -116,6 +116,7 @@ Синхронізація проєкту: %1$s У проєкті є конфлікт: %1$s. Відкрийте цей проєкт і синхронізуйте, щоб вирішити його. Дані сутностей клієнта обчислено + The server no longer has this project. It will be recreated the next time you sync. Дані сервера отримано Дані клієнта завантажено Локальну резервну копію створено: %1$s From bd2e3a96667ebd4553a70f4b1ee861fa07823ff4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 6 Aug 2026 23:43:34 -0700 Subject: [PATCH 052/213] Give rendered stories real book typography (#858) * Give rendered stories real book typography Lists sat at the prose margin with their items packed together, thematic breaks ran the full column width, and any run of blank lines an author typed collapsed to a single paragraph gap. - MarkdownService emits a break for each blank line past the first, so deliberate white space survives CommonMark's collapsing. Runs are capped and fenced code is left alone. - StoryRendererService separates sibling scenes with a blank line; without it the last paragraph of one scene and the first of the next parsed as a single paragraph. - story.css styles every element markdown can emit: indented lists with accent markers and breathing room, a centered scene-break rule with an ornament, a tinted blockquote panel, code and links, h3-h6, and ragged right prose below 600px where justification opens rivers. * Indent every prose paragraph, including a page's first Each page opens with the scene's heading, so the flush-left rule for a paragraph following a heading swallowed the indent on the first line of every page. Drop the rule entirely: paragraphs after a scene break, list, or quote now indent too, so the prose is uniform. * Keep blank-line spacing to the prose that asked for it Review of the branch turned up four ways the blank-line work reached further than intended. - markdownToSafeHtml takes preserveBlankLines, defaulting off. Bios, the About page, the privacy policy and the review frontend render with CommonMark's collapsing again; only story rendering opts in. - A code fence is now tracked by its delimiter and length, so a ~~~ line inside a ``` block no longer ends it and leaks a literal
    into the code. A blank run between two indented lines is left alone, which covers indented code blocks and fences nested in list items. - The chapter heading no longer carries a leading newline; with appendScene's trailing blank line it made a run long enough to render a break above every heading. - RENDER_VERSION goes to v2 so cached story pages re-render. * Fold the group word count into the scene walker buildGroupMarkdown had its own copy of writeGroupChildren, differing only by a word-count accumulator, so every change to how scenes are separated had to be made twice. writeGroupChildren now returns the count and the single-group export uses it. Also brings the Story Prose table in the design system doc back in line with the rules story.css actually carries. --- docs/WEB-DESIGN-SYSTEM.md | 26 ++ .../apps/hammer/story/StoryRenderCache.kt | 2 +- .../apps/hammer/story/StoryRendererService.kt | 63 ++--- .../apps/hammer/utilities/MarkdownService.kt | 112 ++++++++- .../src/main/resources/assets/css/story.css | 230 +++++++++++++++++- .../hammer/story/StoryRendererServiceTest.kt | 60 +++++ .../hammer/utilities/MarkdownServiceTest.kt | 118 +++++++++ 7 files changed, 560 insertions(+), 51 deletions(-) diff --git a/docs/WEB-DESIGN-SYSTEM.md b/docs/WEB-DESIGN-SYSTEM.md index de08b640e..958252ed9 100644 --- a/docs/WEB-DESIGN-SYSTEM.md +++ b/docs/WEB-DESIGN-SYSTEM.md @@ -513,6 +513,32 @@ h3 { } ``` +### Story Prose + +`.story-content` (in `story.css`) styles the sanitized HTML that `MarkdownService` produces from an +author's scenes. It is the one place where the page renders text the site did not write, so every +element markdown can emit has a rule: + +| Element | Treatment | +|--------------|---------------------------------------------------------------------------------| +| `p` | 2em first-line indent on every prose paragraph, justified | +| `h1` | The story title, in Kingthings at 2rem | +| `h2` | Chapter heading: centered, with a dashed rule above it and 4rem of air, dropped on the first one | +| `h3` / `h4` | Kingthings, stepping down in size, 2.5rem above | +| `h5` / `h6` | Uppercase, letter-spaced and secondary-colored rather than larger | +| `ul` / `ol` | 3.5em indent so markers sit inside the prose indent, 0.75em between items, accent markers | +| `hr` | Centered short rule with an amber diamond, 3rem of air above and below | +| `blockquote` | Tinted panel, amber left rule, italic, with `em` flipped upright inside | +| `pre` | Tinted box that wraps rather than overflows the column | +| `code` | Courier; a tinted pill when it sits inline in a paragraph or list item | +| `a` | Accent-colored, underlined with a softened amber rule | + +`br` needs no rule: it is the break `MarkdownService` emits for each blank line past the first, and +it inherits the prose line height. + +Below 600px the column is too narrow to justify without opening rivers, so paragraphs go ragged +right and hyphenation is off. + --- ## Shared Animations diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRenderCache.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRenderCache.kt index 50c36bedc..4abe65365 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRenderCache.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRenderCache.kt @@ -75,7 +75,7 @@ class StoryRenderCache( .getOrNull() companion object { - private const val RENDER_VERSION = "v1" + private const val RENDER_VERSION = "v2" /** * Everything a render of this story depends on, collapsed to a string: the title heading diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererService.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererService.kt index 8b43ad3d4..84c40f544 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererService.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererService.kt @@ -52,7 +52,7 @@ class StoryRendererService( } val markdown = buildStoryMarkdown(projectDef.name, scenes) - val html = markdownService.markdownToSafeHtml(markdown) + val html = markdownService.markdownToSafeHtml(markdown, preserveBlankLines = true) // Calculate total word count from scene content only (not group names) val totalWordCount = scenes @@ -89,13 +89,12 @@ class StoryRendererService( var chapterNumber = 1 for (scene in rootScenes) { - builder.append("\n## $chapterNumber. ${scene.name}\n\n") + builder.append("## $chapterNumber. ${scene.name}\n\n") if (scene.sceneType == ApiSceneType.Scene) { // Write scene content directly if (scene.content.isNotBlank()) { - builder.append(scene.content) - builder.append("\n") + builder.appendScene(scene.content) } } else { // It's a Group - write all child scenes' content @@ -108,24 +107,36 @@ class StoryRendererService( return builder.toString() } + /** Appends every scene under [parentId], depth first, and returns their total word count. */ private fun writeGroupChildren( builder: StringBuilder, parentId: Int, scenesByParent: Map> - ) { - val children = scenesByParent[parentId]?.sortedBy { it.order } ?: return + ): Int { + val children = scenesByParent[parentId]?.sortedBy { it.order } ?: return 0 + var wordCount = 0 for (child in children) { if (child.sceneType == ApiSceneType.Scene) { if (child.content.isNotBlank()) { - builder.append(child.content) - builder.append("\n") + builder.appendScene(child.content) + wordCount += WordCountUtils.countWords(child.content) } } else { - // Recursively process nested groups - writeGroupChildren(builder, child.id, scenesByParent) + wordCount += writeGroupChildren(builder, child.id, scenesByParent) } } + return wordCount + } + + /** + * Scenes are separate passages. Without the blank line the last paragraph of one scene and the + * first of the next parse as a single paragraph; the trim keeps a scene that already ends in + * newlines from gaining a spurious break. + */ + private fun StringBuilder.appendScene(content: String) { + append(content.trimEnd()) + append("\n\n") } /** @@ -284,7 +295,7 @@ class StoryRendererService( // Build markdown and HTML for current page only val pageMarkdown = buildPaginatedMarkdown(projectDef.name, currentPageScenes, currentPage == 1) - val pageHtml = markdownService.markdownToSafeHtml(pageMarkdown) + val pageHtml = markdownService.markdownToSafeHtml(pageMarkdown, preserveBlankLines = true) return StoryRender( result = PaginatedStoryExportResult( @@ -354,8 +365,7 @@ class StoryRendererService( if (scene.markdown.isNotBlank()) { // Add scene name as chapter header builder.append("## ${scene.scene.name}\n\n") - builder.append(scene.markdown) - builder.append("\n\n") + builder.appendScene(scene.markdown) } } @@ -491,7 +501,7 @@ class StoryRendererService( val (markdown, wordCount) = if (targetScene.sceneType == ApiSceneType.Scene) { // Single scene - just its content val content = if (targetScene.content.isNotBlank()) { - "## ${targetScene.name}\n\n${targetScene.content}\n" + "## ${targetScene.name}\n\n${targetScene.content.trimEnd()}\n" } else { "## ${targetScene.name}\n" } @@ -501,7 +511,7 @@ class StoryRendererService( buildGroupMarkdown(targetScene, scenesByParent) } - val html = markdownService.markdownToSafeHtml(markdown) + val html = markdownService.markdownToSafeHtml(markdown, preserveBlankLines = true) SingleSceneExportResult.Success( projectName = projectDef.name, @@ -524,29 +534,10 @@ class StoryRendererService( scenesByParent: Map> ): Pair { val builder = StringBuilder() - var totalWordCount = 0 - builder.append("## ${group.name}\n\n") + val wordCount = writeGroupChildren(builder, group.id, scenesByParent) - fun collectGroupContent(parentId: Int) { - val children = scenesByParent[parentId]?.sortedBy { it.order } ?: return - for (child in children) { - if (child.sceneType == ApiSceneType.Scene) { - if (child.content.isNotBlank()) { - builder.append(child.content) - builder.append("\n") - totalWordCount += WordCountUtils.countWords(child.content) - } - } else { - // Recursively collect nested group content - collectGroupContent(child.id) - } - } - } - - collectGroupContent(group.id) - - return builder.toString() to totalWordCount + return builder.toString() to wordCount } companion object { diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/MarkdownService.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/MarkdownService.kt index e40d183d8..030c31891 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/MarkdownService.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/MarkdownService.kt @@ -43,13 +43,119 @@ class MarkdownService { * All script tags, event handlers, and javascript: URLs are stripped. * * @param markdown The markdown text to convert + * @param preserveBlankLines Keep runs of blank lines as visible space rather than letting + * CommonMark collapse them. Prose opts in; anywhere the markdown is a short piece of writing + * rather than a story, such as a bio or a policy page, wants the default collapsing. * @return Sanitized HTML string safe for rendering */ - fun markdownToSafeHtml(markdown: String): String { + fun markdownToSafeHtml(markdown: String, preserveBlankLines: Boolean = false): String { if (markdown.isBlank()) return "" - val parsedTree = markdownParser.buildMarkdownTreeFromString(markdown) - val unsafeHtml = HtmlGenerator(markdown, parsedTree, markdownFlavour).generateHtml() + val source = if (preserveBlankLines) expandBlankLines(markdown) else markdown + val parsedTree = markdownParser.buildMarkdownTreeFromString(source) + val unsafeHtml = HtmlGenerator(source, parsedTree, markdownFlavour).generateHtml() return sanitizer.sanitize(unsafeHtml) } + + /** + * CommonMark collapses any run of blank lines into a single paragraph break, which loses the + * deliberate white space a writer put between passages. Each blank line past the first becomes + * a `
    ` block so the rendered story keeps the author's spacing. + * + * Code is left exactly as written: a `
    ` landing inside a code block would both split the + * block and show up as literal text. Fenced blocks are tracked by their delimiter, and a blank + * run between two indented lines is assumed to sit inside an indented block. + */ + private fun expandBlankLines(markdown: String): String { + val out = StringBuilder(markdown.length) + var fence: Fence? = null + var blankRun = 0 + var seenContent = false + var lastIndent = 0 + + for (line in markdown.lineSequence()) { + val openFence = fence + if (openFence != null) { + out.append(line).append('\n') + if (closesFence(line, openFence)) fence = null + continue + } + + if (line.isBlank()) { + blankRun++ + continue + } + + val indent = indentWidth(line) + if (blankRun > 0) { + val insideIndentedBlock = lastIndent > MAX_FENCE_INDENT && indent > MAX_FENCE_INDENT + if (seenContent && !insideIndentedBlock) { + out.append('\n') + repeat((blankRun - 1).coerceAtMost(MAX_CONSECUTIVE_BREAKS)) { + out.append(BREAK_BLOCK).append("\n\n") + } + } else { + repeat(blankRun) { out.append('\n') } + } + blankRun = 0 + } + + out.append(line).append('\n') + seenContent = true + lastIndent = indent + fence = openingFence(line) + } + + return out.toString() + } + + private fun openingFence(line: String): Fence? { + if (indentWidth(line) > MAX_FENCE_INDENT) return null + + val trimmed = line.trimStart() + val delimiter = trimmed.firstOrNull() ?: return null + if (delimiter != '`' && delimiter != '~') return null + + val length = trimmed.takeWhile { it == delimiter }.length + if (length < MIN_FENCE_LENGTH) return null + // A backtick fence's info string may not itself contain a backtick. + if (delimiter == '`' && trimmed.drop(length).contains('`')) return null + + return Fence(delimiter, length) + } + + private fun closesFence(line: String, fence: Fence): Boolean { + if (indentWidth(line) > MAX_FENCE_INDENT) return false + + val trimmed = line.trimStart() + val length = trimmed.takeWhile { it == fence.delimiter }.length + return length >= fence.length && trimmed.drop(length).isBlank() + } + + private fun indentWidth(line: String): Int { + var width = 0 + for (character in line) { + when (character) { + ' ' -> width++ + '\t' -> width += TAB_WIDTH + else -> return width + } + } + return width + } + + /** The delimiter that opened a code fence; only the same character, as long or longer, ends it. */ + private data class Fence(val delimiter: Char, val length: Int) + + companion object { + /** Upper bound on the breaks one run of blank lines can produce. */ + const val MAX_CONSECUTIVE_BREAKS = 6 + + /** A blank line preceding this makes CommonMark pass it through as a raw HTML block. */ + private const val BREAK_BLOCK = "
    " + + private const val MAX_FENCE_INDENT = 3 + private const val MIN_FENCE_LENGTH = 3 + private const val TAB_WIDTH = 4 + } } diff --git a/server/src/main/resources/assets/css/story.css b/server/src/main/resources/assets/css/story.css index f71e78d74..ccd15bff8 100644 --- a/server/src/main/resources/assets/css/story.css +++ b/server/src/main/resources/assets/css/story.css @@ -69,6 +69,11 @@ border-bottom: 3px solid var(--accent); } +/* ======================================== + Story Prose + Typography for author-written markdown + ======================================== */ + .story-content { background: white; padding: 3rem; @@ -76,6 +81,7 @@ box-shadow: var(--shadow-lg); line-height: 1.8; font-size: 1.125rem; + color: var(--text-primary); overflow-wrap: break-word; word-wrap: break-word; word-break: normal; @@ -108,36 +114,212 @@ padding-top: 0; } +.story-content h3, +.story-content h4, +.story-content h5, +.story-content h6 { + font-family: 'Kingthings Typewriter', monospace; + color: var(--text-primary); + margin-top: 2.5rem; + margin-bottom: 0.75rem; +} + +.story-content h3 { + font-size: 1.375rem; +} + +.story-content h4 { + font-size: 1.1875rem; +} + +.story-content h5, +.story-content h6 { + font-size: 1rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); +} + .story-content p { margin-bottom: 0; + font-size: inherit; + color: inherit; text-indent: 2em; text-align: justify; } -/* Standard typographic rule: don't indent the first paragraph after a heading */ -.story-content h1 + p, -.story-content h2 + p, -.story-content h3 + p { +/* ---------------------------------------- + Lists + ---------------------------------------- */ + +.story-content ul, +.story-content ol { + margin: 1.5em 0; + padding-left: 3.5em; + text-align: left; text-indent: 0; } +.story-content li { + margin-bottom: 0.75em; + padding-left: 0.4em; +} + +.story-content li:last-child { + margin-bottom: 0; +} + +.story-content li::marker { + color: var(--accent); +} + +.story-content ol > li::marker { + font-family: 'Kingthings Typewriter', monospace; + font-size: 0.95em; +} + +.story-content li > p { + margin: 0; + text-indent: 0; + text-align: left; +} + +.story-content li > p + p { + margin-top: 0.6em; +} + +/* Nested lists tuck under their parent item instead of repeating the outer gap */ +.story-content li > ul, +.story-content li > ol { + margin: 0.6em 0 0; + padding-left: 2em; +} + +.story-content ul ul { + list-style-type: circle; +} + +.story-content ul ul ul { + list-style-type: square; +} + +.story-content ol ol { + list-style-type: lower-alpha; +} + +.story-content ol ol ol { + list-style-type: lower-roman; +} + +/* ---------------------------------------- + Scene break + ---------------------------------------- */ + +.story-content hr { + position: relative; + width: 45%; + max-width: 14rem; + height: 1px; + margin: 3rem auto; + border: none; + background: linear-gradient(90deg, + transparent 0%, + var(--neutral-300) 20%, + var(--neutral-300) 80%, + transparent 100% + ); + overflow: visible; +} + +.story-content hr::after { + content: '\25C6'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 0 0.75rem; + background: white; + color: var(--accent); + font-size: 0.75rem; + line-height: 1; +} + +/* ---------------------------------------- + Quotes and code + ---------------------------------------- */ + +.story-content blockquote { + margin: 2em 0 2em 1.5em; + padding: 1em 1.5em; + background: rgba(245, 245, 244, 0.7); + border-left: 3px solid rgba(217, 119, 6, 0.4); + border-radius: 0 0.375rem 0.375rem 0; + color: var(--text-secondary); + font-style: italic; +} + +.story-content blockquote p { + margin: 0; + text-indent: 0; + text-align: left; + color: inherit; +} + +.story-content blockquote p + p { + margin-top: 1em; +} + +/* Emphasis inside already-italic type reads as upright */ +.story-content blockquote em { + font-style: normal; +} + +.story-content blockquote blockquote { + margin-right: 0; + margin-bottom: 0; +} + +.story-content blockquote > :last-child { + margin-bottom: 0; +} + .story-content pre { + margin: 1.5em 0; + padding: 1rem 1.25rem; + background: var(--neutral-100); + border: 1px solid var(--neutral-200); + border-radius: 0.375rem; white-space: pre-wrap; word-wrap: break-word; overflow-x: auto; max-width: 100%; + text-indent: 0; } .story-content code { - word-break: break-all; + font-family: 'Courier New', monospace; + font-size: 0.9375em; + word-break: normal; + overflow-wrap: anywhere; } -.story-content blockquote { - margin: 1rem 0; - padding-left: 1rem; - border-left: 3px solid var(--neutral-300); - color: var(--text-secondary); - font-style: italic; +.story-content p > code, +.story-content li > code { + padding: 0.125em 0.375em; + background: var(--neutral-100); + border-radius: 0.25rem; +} + +.story-content a { + color: var(--accent-dark); + text-decoration: underline; + text-decoration-color: rgba(217, 119, 6, 0.4); + text-underline-offset: 0.15em; +} + +.story-content a:hover { + color: var(--accent); + text-decoration-color: var(--accent); } .story-empty { @@ -317,6 +499,32 @@ .story-title { font-size: 2rem; } + + .story-content ul, + .story-content ol { + padding-left: 2.25em; + } + + .story-content blockquote { + margin-left: 0.5em; + padding: 0.875em 1.125em; + } + + .story-content hr { + width: 60%; + margin: 2.25rem auto; + } +} + +/* A column this narrow can't justify without opening rivers between words */ +@media only screen and (max-width: 600px) { + .story-content { + hyphens: none; + } + + .story-content p { + text-align: left; + } } /* ======================================== diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererServiceTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererServiceTest.kt index 83e18bd9a..d252b0de4 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererServiceTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/story/StoryRendererServiceTest.kt @@ -136,6 +136,66 @@ class StoryRendererServiceTest { assertTrue(firstContentPos < secondContentPos, "Child scenes should be in order") } + @Test + fun `chapter headings gain no break from the scene separator`() = runTest { + val scenes = listOf( + createScene(id = 1, name = "First", content = "Content 1", order = 0), + createScene(id = 2, name = "Second", content = "Content 2", order = 1), + ) + setupMocksForScenes(scenes) + + val result = service.renderStoryAsHtml(userId, projectId) + + assertIs(result) + assertFalse(result.html.contains("(result) + assertTrue(result.html.contains("

    First child content.

    ")) + assertTrue(result.html.contains("

    Second child content.

    ")) + } + + @Test + fun `sibling scenes in an exported group render as separate paragraphs`() = runTest { + val scenes = listOf( + createScene(id = 1, name = "Chapter 1", content = "", order = 0, sceneType = ApiSceneType.Group), + createScene(id = 2, name = "Scene 1.1", content = "First child content.", order = 0, path = listOf(0, 1)), + createScene(id = 3, name = "Scene 1.2", content = "Second child content.", order = 1, path = listOf(0, 1)), + ) + setupMocksForScenes(scenes) + + val result = service.renderSceneAsHtml(userId, projectId, sceneId = 1) + + assertIs(result) + assertTrue(result.html.contains("

    First child content.

    ")) + assertTrue(result.html.contains("

    Second child content.

    ")) + } + + @Test + fun `a scene keeps its own block structure`() = runTest { + val content = "A list follows:\n\n- one\n- two\n\nAnd prose after it." + val scene = createScene(id = 1, name = "Chapter One", content = content, order = 0) + setupMocksForScenes(listOf(scene)) + + val result = service.renderStoryAsHtml(userId, projectId) + + assertIs(result) + assertTrue(result.html.contains("

    1CTT6BHE;S2Thip#=XOZB$<2FX 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 l<2RLQYn##7!8lEhzz!*GLB#zrG0@Z2$( z(t{RT9(kNreX5`!Dhb|o7wYxK|DVEZZyWQDZ|F!v1 zkH?MUw7*WG{uIcw;$6s6R|Pi~8hCNGQ6bIR4s@BysOU1JV9}uO@>!KhhKWmA?v>RQ zwl--cfh&=1UC;OcguyFF^%e{v4QotCs>6CAeB0(P5zD?8uForv3GTp=x`n@rS!$Y9 z|7NFu+9x!9!2mvgjyeZ$a})R4$4^p=o#bQ5w^4*6{5c>>-lP1<)Qjp0LY~$AM4At{ z-Cn(Lsq9Zlxv*`GVDlp6{j)glxi?)9A?1aLg{wb_t04eYK&rpjz%pU5Ph-k0PM-FA zj*emzJt@DX?4I(PQeR_+lLcEUhmq7ocqgb@C>1QVe?|ci0)n$i0qxIpc1yigT(^)7 zKEWKcF0IJd|9C2Z8SlrV^KcJ;~Y>aL1>NgzGJpdcK>RRs-y{<6THmK z_ZV#Oh3(h9Tm7%mK%_qY4Ay1KS4C(Ic^j*1sje~}>tM$ke?^Vh!%XazW~NqN)C|$n z_xP3de<~Y>M9ThYX1b7xYSEe~drj_gQour|Y$4(`b)mj!@zop+0jksQA^oBHd$-f^+fD!{yXlgl_;B=Np4>e&2Qcyw(-*-$CzKMGCU^qvUnr= zaS*Gktx#3?qlTy{%>2nmA1#dqRTuj`*}Syke{TNk(?G4k_?G8qW#~8lEVDoE59_+J zW%@uDwbnD@$(Kh1RIcvo%M5Gw^-C+r4IV{v^nj1ct09Z%i>WXxB~G+MuafnzrdGPT z;?4~3MU6%Ktx)tFQl~yT&}svWuLb2!1OlU_|Dog3>q47-Pj8dCCSJ58b#(IyrBeh( zf0f@ixr&@RB|V?5so81{)iUI^W_BZ#of0h2VZlipLkDfNMhLhap5`smqf^ zcph74Ucg?hYBxsRySy<#E?@x6H3nJyieV=u9!_O+VuT2okM<)cqN*e76Lxt*7>9XP z)J9M`fP7H!K)Ov>pTGL`b>^}HTD^bwe>PpKO=$b&bw^cZ(&|RsJ-wMi!mPuh#<^0) zCI9q@hDeG^Wb9XPLt3z=LxN zX-hK11B1Ytt%UH%;V~7Ma+EHN|6_gVu-^E6$HdDtoKBF~H}5<`YnCy&Vx9Xwa1W1F zZT{~LuPFBNsrCO}^-XEou>cJi{f7!B2DNP`6 zv7<5191}_2*MuCPgwECQ>%-y=E~4S4l{Q-AhJN=y2svm?F;AR1ZvDNe=VH@u;&1#ElT&rPMedIzvonDqeZ`1$+%MupBXs}`Qvkz zfgg-mo*CInyR>l*&r*o`foAMv-xi&x#}~sYEva}dhM28r8*VMyXejX z%>6LR8*&NJI7IwaeJZo)&t&kqENp_pbdMmnB=jaelLQh&$my+U zDM{)RyFV9HA|HYgXF5wJLfWRI)$Z3$l-jg%vODr^@6XwHScDJ!XsN?QQ{Gt{RLZYh zE?x(j6L)f8vQuomOTGO^32>D*~tm zIq4Ck+Qk^@&T^2_b(>Jmnx4ab%J6P^E94GHnd zhpwT*ZSsqxn?M}NyphCDVfw(U(77ZZT9QlJ-oIl4`ps*C`u7`f_BaP4Mkpt9o*p8J zbc0(=tyd6^e|6BUJEFh1&jD1^R{Li_3gh+D+oWLlRDjgl70ZWB=044pKhaa-EJoJcsdMxIpuIq}%sFOL*#IFYmWw%=gzaXjyWtGWS1MLZC! zc`?`oL^uu=q$%Q1M1Dkq6d}$52CdTH${L*CT>Zc1f4=jn$tP+%2#APu=_N>2X@V4~ z0hA^pC<+2XL^?=Qs)QiDcL4<{N)u_)TOdGyfb`x3k{}{I0Svb!gqPnuGw(d_fAD@d zU+%egXJ^kj*Iw7|?Af~yU)s!wypjEQHO8I)8?9Zp{jpo~PA*BDGQ6UM&n!qp+&CwZ z&l_6|e?jR(eHklg9}n>v?;2{ub=6GA7hUq-)ddTEka!E(@DpSlg?$N#(3&7aaltK( z+QyVZBrLjMwYmSUu~nD~C4e;9eYjf3p8V*UvM-zbv7V)4t3 z(h%e5*}Y|Z-HZL3;)fBc&aSC6PBw|EdoP$ve;;uwJkh=U?G}d~NbvpE^}-AB=hhht z{KZT3m{Zu01jgv1DwtYw|CX`Wqk^vc#OCHhXP_%&uG)^kJsa*+KPbzp148St&5-`%()BR^-RjUa ze{>9)>fEQQdP$$%tyaU~0`oQ=T&lidFpVm=;I1|g3FFtt*;UBbA`0P(64^=mBgf3U z93*M-V<2s9uNVq51`6AvGCR}00pWkUf1|fY&Diy?sJ(1%j$o8CDEem~2fQ+++khr$ zG6N@vcc+e$LLbg_vXIr&Uewo4{pE%UFAQChuMX#jgl2sKDxjox|X_ zz*JkEH|Nv^@CB$9s>#Z!VS+?bV>@bHgVeP7__wbsAJuogT&KOhkB{;$G&s+~e?0I_ zb3OT@NJ40mx>3j)I4;3C035fk`O|lV&UmW$y#^xS%?h`xq^MtxWE4^ zA`YBZ9T9LQG-N4!U)1->1~opzQ0&bKU%&WJnUfXCcM26;j>${MxAkIoN$Z1D=;7zo zg@6y?@*IwcTr-J#dk&NC`+&aie?&>cbhV;nO&!CZdu0w5ueEAUKYB>To2&5|&#{Ay z(u-Aa2wjp3n&5^u0PbRHVpP52kMi!CHcid0_39Zhs=9u7-xRN==E~~fVgIU5Z?aZQ z9W8}v41^}iC^9ENSrts8ZF$B;rx0|7ESuO7y2tUDdrKC(ToxA9oSdIBe;W6!k9`tv zu3x7ryEn#H_{SA8#XsY|}FOeS^u z46@`Sv19QFU4mKP;t}*Rf7w`UuhxIic@4VA*PHrJN6ZQI?9aFo&3(fQ3eRt|m0ec4 z^Dh9K5<|8jJ#_bm-Ns8qdXkm#&HCB9R#izIizq&M>fAgFQ(u&UxA)Q|E#^#S8gQ#7 z_jcA>PNWBBEW8YruP_S}xiIuCu;=duK(FxO^>ZWYE-azndWFQQWgc(1lOsmGfsHCon5z3MnW` z2<=T_+lI^HkE-#2f0tphy+;6!Pw|x^R_j}v|EG<-?$QU(=jMa0`e{^x4>++mxVmwlDh`jFq>R>!ti*ec7@C+2WZ#AK~x(G3wL+BIOty|Lh zWTPO=jW=i(FXj;UNMeo6UJ4)Ye=*qmQK%Y|N)>+;V#SxFj2gsTIvQe*)MRLGBH3(2 z9vMxzwE1Otd-^S#%hOnQ-DzqLRWj@TR7$j35t>t%ePVuAHxV zX;E|D_E!X{V>?x;m9=y_m6{7P)^7wAbTh#h5FgY(aMFFt`_Btc zr|*$ZY4o%3;K;wsi8+@yHF;v5;JLcv9D{T79_kQJjuRbH_p)AYuvvHC5N#-9zt42_ z-Uso5ev?J3DXdsuN;l-4Eo&1YptohWmvfObf1BG_8HGP$TTxA|4NLWBqaS@%z5bIb zg=7gc$6M@JlGL_4dB^>D3UJ`~s?wN;YzCf<9`}ik2P+gT_GSBhsmZR4v_m_^gOMS?CLF^9ba>1uyEwb!D(`sjZQ=8O-(fFqDDHIZN* zgzTDz@@^Iv@+!>u4P&oy6K>Qea3Dhm_-CVq_SVy;VUyhX0UQ$DTL=}fYd|uEI=SE2 zqf>gTNBb*l?W@sm1#Xi043{26I^zese+rqb`=IrLX#{)X*K=b1*d7<_Md4cF%ez0b zGHH}sp<&ve4V8q|ajz%V9ke>}CtAqi_TP!1;QXic>U^AeZv>;fFe!{mQDO^B$L1&S z%$xT}wTG;Vx#HfM4w^H6{X3kbk*^>$EB0V_yiEJWkOvkgMpXaq&-@V-!#^#Ef31WW zRMO>EyFwPs+85Q020Jp<)|;LI?G(_C2!PaqiUUxxWp)_R^5tJo>`#o^a18?3B; zuLsi(zV-d$XBt3vylpJlu`m3i$#R-Tk#J@9F6y@xnK-KQ?{StFF;-Zgb&%K#PaEKQ z>)nD@B$`B$Ih=8Hr*XTDYK}RRe;e4nqM{Y8Gf;o}pRC+e%B3}e;)HtvgQun-Wo)$` z8oA%^rG9%Y=^{N(%Me)v*+BKrST_K|0T)~hRJP0sWrmvInCZ?rtbHwk`tEvPe}E4^%b<7M{6CLd z{QftC=QtQE=jd}-&j0W>iO@K|HC?(vZf%+Q@y0PLprovmF;n)2y?r9Wsp_-TFh*M;hhe97CGF%*)A ztTjWC2@KF{p7JYTpLc0i9a)Vl?Z~W_e%bJ9RJ!z`%^vkXQROTA8v^>xsfe<`CUA4GWA_)X!UPs&6reA6vq{q&dUh-5VQU_Wh?3iPi47l4u$P!xC! ziCL6Qu$}73y3{G1-3xK@tLHG(mZj$+ELV(aQj7fVY+R)xf0Uuzqt9V2+C~*ZUz51!=Wf`riD`F;4JsH{#-+>dEY_!0JC>J&+A&uu zEg#MF{PN^kG^AmE4p8ISwE;BF>ylzw?+fouprEmJub^~H7lnHo=ID;~YINHI3QS!}{r!_Z2E_#qz4-lF zUh@?waHR-iZ2u6C5EKvAgEHCJ3_3f0ePAxzq#AO_Cdagel=*F{lV#e=MzyiMy{p{a z$qU5cWzIm@ye)UkT*V^p-pDeZA4qe27ERx6d54aUfBh}w9h`LwE=6#h z3rAvk=NcirTjr*nVfMuz;t`YFC5GZZZ+R>(=N(so7;khhejH*AOV<+Oe)8nFEo3%V)Mt6g*zG_sLwJNoXQC}y zAN2R}C7~ms--Z7pSpSzXp1$dNyC02E#_k52)sn=saWpwLO8PnShs7qft3!+(7XDgJ zrrZ@C60ZHU|8uGD|FOr5w$ELUf9W+U53C=?NJ_~(eSZ?J!ol&%J?#(cybE%EYRhb; zvD(k1`7Xat3HrmT6rW9k9=r4KyjU}=@Z~#&#<2uJWrEU&Hvif+FpFE;+v;xDkZ&dm z+967}(^7gmhfecCG1aRq=jzhg2#=0OD3=AU=}dX7H8f_5l4y5)gf4%xe|ehL^A+Ss z&Hox*1>?{K!Rvqm3PV(}t~)n=zbAJoHw&8AXl!%MVC=iWrxvm~0%>&y3SWdhZm~a2 z!8rrP9p9U7YV#FTECYZY(FoD%Trfq5rO9e6py=zR(q?o|2vyiq57Jh3-n zmFX+x&m5W^ohd0iPy*>}|ly+I3YJpcIz>}@lNsL6Cr1ghJf zqs~CupDcRbo6`LeC?Ipzuibm4IfaE>80{dpOZM6Wr(aVh+ia963|?zuSvM3R(Sm{q zmlMUto$<7MCI-4bxch___$!zTi77e*T{r`|?uVVkM5+5=&OlcKf4!_)^jqQ?xD1bu zI_P{~E5wO<2@UW_b87i_NnAwiQ@F~yI0?k|>TRnw@}nuTzDDlIm+h5H@r$^=sKBi!7Y((T{&s+mjq$GSDVf9&n0*oPClUZRLxqw?3o zEu5VkFlT@fzm4Gf-1N|9HN)*9O-fCwoBW&?0| z6>lz@mz23{fAUQUxd(?bZ>z#yTkhJo`y3Dro(bOd8k_df0=KPF!|X6Marx}I{S>-G zFNI~#-{v0XzgG-OI^@*!z{l_85(q@Qt0f z7IBcvfcVfx{rIAe(*5lS(y^M!2klfWjVmvC2EWnEe+)<#pjY`^)8{vjt(YV1)BJfF z7*!&dLh^&e7DMJ}{QF>=7-&M4Ab768iU5xMOo(on(>vO>;2r(!`ZccE^N?nI^y2Q% zy_?qRzu#&8BS`eDg5fivTnOf3%2d8Ar6>Kj?Qw6+}!Y&s{yd0^!A&WShI9K#jt)63vf!=FAC ze1U^0qgL6mIVf8I`Nr{^vAYVg7VnvF+2En{+9>Fd>t9WEoe@vzzq@RYxc0$9r|F%{ ze`L$k42ob9+ZZM^+OLMiM$PCOkPhal46sVb77K`J5PPcKW%|q8#9Z{GIbKto>@m}j z(4llpbZwqJB7GfJ=B!xD70A%j?MxGs_`Da+-0@a;PHi%| zsDdW0-r$kP3^rawJ0PY6B|;mt^Q)^TfBRp$ z&jSzchF>Rm?MxBYP%CURU&dj^6~I!Tc@uADx1UjfdsAZ5I&F55-FvpORC&proDCQw zMne|6mm6wdTp2F8`-{@5kZQgGUCQ0EEebnOLQ>nL2)8yP z2v=wx(;N-J_angDe?9fA{N`vUosijh^sPO-Rd}CJ!L9f^V2(%QG_x5>*MA zReo*#8lG10Bah(RbFxxSAYp9l%jQRXiZ?A5Tc-C!o>#RQmHw#ZI=ZM;k^fOBfWw;j z27kmW6H|`zyV)n5k_a>5c>h4_$4|EaE-JmZd6D^Q4#YyHw`U*}3T3NsfBK;^K2p`O zzo__~n#aW6MaTi&rZrN)PjnV>8)?v@q8i8x5X;E(i1*zZ~_7Jp)ngRf$=0 zLIi9rH761cd~GZROggcr?jxPzz}L_o*$PSIU=i7Md>2Z$SF!4PSTOk(bhx@CEV)ll zrd@ay#q|_u<^8E-aEe;xf9WPWgoA%{emu>nIs?^HSd2>7=!^-GI+@LGKI>cF$E|13Bf1J;}#tA zIWK+t-*akUoGu*j>QOog?G>BOnpJFT1-65H4&^b+3ihA$h?f96s*t!>*Hl0pM> zpbKLdi=I6BeM8Cw$s*csmBEnOfPEl+>&Nvhv+mIzLp02fgUB31rW3F5Axe>^at7kU|f5s*Z&RnB&D;&-Op#7wJ~AMRm`Tg?g(S_ZAhXtyGq z7d?xmIrKMBdGY{dp+5Q{=%P z3*L}JG-eawEcK>c#~2at#+q9M8}S2{hf=d(OgtK!sHOCse=o-BA!`g@kuFAr3fs+9 z`u!TjEn@vw_aNy9uC$1CRt9xhzt;M8AIG8Z1BI6%)Nl|?1)s7LjH^jJ1M$jA`I)e~ z3-Y%%;Paz`Z+eA`B~?|G#PhG0xUzV_SmN7HekF(Y<_pikJV;7peMBzWUsGhRn7|ov zC1)o5gU7Uff1yof)*tWNO$vcVAZnIgzY$l;k1J;&7P7=zZpiBp_a_-f07vEGX2Fub zDT=WH>Y5fh-o)mRWO@Hz%^@cf;%1mBWx|$gTwRh-bRSO_SF9@AqhYm#;-$0)m!)P{ zHK;xyetU^YY+(bpExge1*3viw5pM$?7%v*iEbs3ye=JuRLTybpvPm_sAp1rd)F+xR zQBHKvm32AhrFE&Ct=ea@4uLr~)Cnx$dQuP-ou%J1#<1k-^K+1YyBuX#Weoc86>_tF zc;L}@I8=u*#Ef(cCNQpf6Xu35Ko`)T)@uQN*cyhKC^BD>N$g(Z9XmFZ;_!AxENXbG zkUZR_f3noS|B*sRzC?h=P-w^x31x|XL2MrM=x{OoaJNlEe}YXzho9lo_=ez;)H6`m zAWh;6>bbvT(Jvth&?aayLff^hslJe`*f^P1`SQlAjFH0VmRphzUnp3PpaF~iAjNh< zjM*%Vc8z&{adE8oL^i^bELpxe_m+YXIcS*Ae?pnuHs<=!O+#Y&=J{jq)vcQbm0roK zoK=+y$ff>e4^Q>tnEUm-vD(Ct!)MkD+{(`T`%?a4Q;-xoLdu%ie>=Mr>OeeMvXhX4 z+P3qt!!pzJq$yetedcH5+w{QyNV6++nHl(5OV7lT_|CUYGsCYGXeZ{5H!R;Ip&-nP zf1xoJD?(GFUyz$CLSA)-E_26ht@4@9qz7T5i@iUp^@YEmft*zS+tR-a(E5}CO(GS| zQ7}VHkXM6&j#5HhKabQkXQAj1qHtG@kF|>(?2iE~4zgs1i;3%G3k>MOla;gfji!AC z+|kFANxF;u^!7$Wk`O7W$D04v%!SP_e~U_|w3&Q^_fVMeNoZ6kb@%7M{FS{m>}lig zwDQ1E6PFp`nH*P<93obKtYb!$b#KifS?^639SX_>r<)Hf!XS745!7(je+239f+;vj z8^Oh4t=cn?i*?YHkzY`V+Uo~(-L)2l4+?N2uG(L=pqvQf65tGk6K|D>Ak&t>f7}-6 zqW`)p)jK5r8!hzoO#C3?>olt%1-kU?mqW&%PECS9bC|-|!hX*xH>)}qHsw-aq%Grx zd&;kumli*!Ze_>EKQIldy5gfB4jp;_DUoW^A1)6#6Y8IYg10GZc&8-nu08s#SM08D zNU(K7xYze*&pHrh#_WR!gHoTNe?xYe#$l~AAZD&}`~luFC;fML1Q87US9z2qi3^DJ zg2`cm8q$K8$^*PaACFzuBv)p$-4yQUXqHdBFiZ=pFM|I#0LqAdp=_25t6NAeMXy)} z>?N{1`_5Qt-O`wPg_Kt_n_LR;{pyW9CWtl?s|D2etnxL0<8%CeLD&nYe-Xi7w*}ep zRhhrsOP8KOmkc}=RjMPs5^puX&|Bv=Z4J^AjKF&F!PxL7sQp;DdR=Ek-yvYqcK{^x zE_Osnxt&zT{`fuJPMc{T?;pn^_w(wz;a^!GK$)n9|3Nmu!;3J?(H=Mx#=xn^$%`iZ ztyS2Je}CN{xZ@>z&HME?OW0ihD*aKCkqPh_%R_cAU1W~#6z-f* z=fQUKn^bO3J$Dm^e2nJ3clq5O=-sn5mr*zpaL4}lB345hFoOxD2lh50jS3x_Hhd_~ zw~3q*<}EhYz$2-$V{*wMSll76g2T(96$BCusfQ-2yW%~KG|V1Me;u8H68r@vQ+kyl z3#4?ee{m(IhUkahl0-?&O2)+qlB0_(mLxVs5pbn2An|%JJ~vK(SNOU5 zdA$jG-MT(E?0@$fX`jDp;1_v{FoyB$STi;w_EV1AiUcbcnZA)3_2F;@z zDssirQi;n3PrH7tjUX)W7QMF2(+Hm86*TJ{+02plZ&PYjLM&SlkM!&dZ`NO?b+lvj z!t9B-j%+NlSDG4U(kIIrxZkTuo7=x~+uB10g%V`Gmw7~x*JmtNwwUz!V9(m6#2#n+ zf{82VNR}Voe*?bSh+Lx9!SDCVa$Byr3yL-+^yaL_OF6vGy45(g@T1%j`pIxzpBr>t zUhj^nH`O{EbQ%p8b6=4n^ktq#$Gj+D_7XB-MOC{$n(z8b`os*`i}byFxbd!Qj*Fny zm8DvOOhE+OE61oSmo5Qiq?_oT#fM*H;g+114Auh(e?uC#SoN{CzJ9q$q&CDwHe8c= z>L_$^)u63ouAzet)zry}tZkh5%_z3sYxN{`3GHpeKgg3>)QVX*%KG=l7D@mU-vG)! zSY$YuJzGbW?p>osfc7{{x4fxv!phBh_ruC?>@B-^2%f0+aV$rxV{J=ZTNY8~RXhMLRil~(Lb`n-q|O$gNPQf8~a%O-fgm9dLPvxdxm zOw_MCEpH6vw9;5|&8KQ?n6jVUgT43A3r_i4x;U#xqswQ=;|ybu)CC8oQW0qVSEvAY z=0t4HZ&IHsI9jTx3HibJ+s_?W(WYk0d-vZte>9X#uuo?srbTRl8_RY&z&nS29Lps$ zy(dmZIGf=6b;;fgVjYPDQQZ%IPL@HC9Rk!Uv|BUOj`=do?KB%Fe-fG{jv1@gxp%`_NUHh`HH19Nl_OVg^WJYU8?o24=MxX|^H+VknXU-h*AzETg| z*uRoi`RdP)I>v$FOIPIe^uG>>;1##BL+dT#Uo;YSr@3;JikvYW5)Wtn7;+`Y3)6u*lOT8}>(?Q#F1J?hF+P4AKQ|ZSkBgg)0WeI~TcpXs-P2 z3n?H;zz2_!ya??tZDcWgf6W)=n4Djo?Km5whg^x-8P>T?x4eA)1Al4r_OnP?e{Q`V zB?Rou;Y=ct456+GUVv^xZ^Yecg+DSi3ZcBV|CCGz2*X+19^a+^XiEht^I01H2-ohS z>sLLF@$+JE1HYJU7#a~K4JCWkLzfV2|6U6PuDXN#>*~I1ToOuVWCa4ubF~Yer#wJ} za56+TWIwhJP?{M2VJYk?*P!zBf92Irsl~*aubLV#S%PwyFErxhVLN_nbh&!}mu_3# zEc>GwggxZsj(b$@509oD-a8oM%8Nwof#FFRHhV|AY-@zzj;qjXP};^ei#7>y=gDuT12i@BVmSQ^E0kAWkAB1H6*iu?Rr=0_g~3+ zl6Ca}Gj1t#vBWpm7bvN4e{1ffpZFWfdfr|U*UedOuZE7>`Q6&qq8qrP>&dJ01f0rp z2Slde>3ULF$wpB2&KM^#6UI$0-rvOs=?NSSLR>2S%5Qo;IbUqOe1bbR>hOvz;8mBy zj8Owwd&c4f50v|XdTUq0&u;f3xJFA%Q?<#)7vq?1efTuUiKrPJsIyLT_*O1n zt-(0)dU+r^#OR07y%wi`c8y6E@M$Pw5ACB4q7&kG)2=$k0)MgjGoY_a(3s{#-Ux~zQF9Bx8Po{gaSr{u86*7f**E!L}^dYW$b;{ zu{#^udqb?JSA~_eoiUZ_fRSVWoT;EA;R4TL_r{NyeUGGRg0f(CS-PzHJKO;GS8@8NOOFb=1<+UYtLZc z0hv+KJI|+qiWo4vw7P(OO*eCR@oa&KkwMlzf605PAt8{8JvRU14NW*9zEM!HzSERab@ssjAmB&%BG3$o-NTt{vzgmsG2RWG7n>8iwP ztoYgKa{9$^(uhz!)rX1LK?xAM7Ff?EwB97TVcR9+F2FeTwT`(hpL<@KkjzEB>hp+ql|maI0tW>;_$vRqB1i& z{RF?ZvV_P23usCPI_W~BG=)`_3el^_k8X)<>AvOaVU9Wju_G92Y@P?Af7hhPdToRa z?%yq23G^TB!)6G@ssFiU{Z8)^T5~28>%23DAH$S>RyYJX{Gd(E{w(JiNEy>fI=N_Joe<_Lv%x}UWX>!F_ z|Nfki6w_q==J%y0W)hJR$gmwV4EwZ}1L~Xb4Xyt(H(UHlnad*mJV0#3QzQPd+CoTT zfawTHlFZ(R9oTbOuryMK{M@g`IyAcB*)lkYr&5WqyeCdIi>6K&lj6Ix+Cq^Ze&!!Et6JogLY(~ zHSfaS(%}Q;4IUn$7i^MgfICSxP#NnSO%ZfyGukrK##j$+e=iHW4@Ct9*i`ilr?V8w zzud%wxEDWt;(rH&;3M=>^zmaoz*?+iU;;68P601>yx^3)b_Q~5Jp+aD9_}-4oGcU*>#_9M-QX>^Ep;wSjVMSI9%^6r?9d6>2 zE`R;)%}G6he~S#BgWY#TG5M13f!p}9K3j>?4@j3Wf9mbGO&>$5w!tiZv5F=vCf>9D zS;1c#U280E_EtWu#6hyvbh5Rxzz@m%fEmststtmYWmG1M9Or}a=*T&P_Kpi)&5d}L z8!oC6sTAw1pUS_)?~FiDXQ2AdtWNgXBM8gGW8&N9gnfirfXA!fcRUeG=p1fw?xFsx zcRN(&e^TfqQ?>3G+hb&VYM9Bicw$=3P-S2Ws_bdZ^OoqBP8|ez=|2bM`xe73K@O4< z*_mMByM6W%^Ct0F;r+97Tqn zW3fJ}fE^|Wgp`t>k0WlE!Ibe;lrmhBQID16l2MWCfiQhZmZ4MjSZnL`^;@-hKOE-o ze<;q8MQ1CKFIE?R@Ar9OwwF@k!0a`>`GR=qHubsE>rN=vP^g%nD?$J$Q@k zN(mEWYi^!&be>YTAxM}`V(V+0wfsjssNzK}*SD(HYk0c)jmlPz245Pk1^&`aSEjY( zcDSXXEzk#oyZt0TAV~C^um+;B`I$JiP)7JLm_ho*l;0{_pyw>Kd21Hmm6prBf6O!Z zbKuW|oAL?i57*7HWJi1k)}%Lu8%j2421E!SU@8+wf1J)hk+0|U8YUG!W=#ro`a85( zI^5+wtPRKyQV~WzRu{lPu8`Fxmmc8DVtfm(Y>r&}Sa}r8U3E=Hi#A@b(BYj*e;`NM zH`k`N+1!A#Rjp$W;@ym@MsxI`~1TPTYdEdfp~BEz)?EU(`Nh_)*=$l2$+MJ0wqyIe_Q?uyO+4x z5xLee3x5@7`>;bfiLfz__55q6F%-_gZQRUpFNAmVn4&hj;XO<26z*`(Ww;8Wx~kMg za{!FaU6D83a-lvj!Da7;tc5OSt)bo?_e#6T#=v>;pC+r4Ho_Z%8(Z@Y-_sdbmRuKI z5WID$2a<^XW$`7Doj8h5f9d=09)TYooFd#d0aL?)nWRo+%0nwvI&N>y%JsQlIRGJD zvg|J#y!M{-w5bHA%r7A?0->h(tv_5=oVjm4-@G(ulryQ+cv4D)H%;OMD7p4OG5M@I#Dm^FFOEtXmv%7*lwGOuMF+e(MYr^fb{Y zor*{9^Hvc^P#?p{f4RcED6+P@d~|=7Yc_{>ffpspvcufHg7?6 z#OM7jFEB26KIvJFu^U<_Y)RJ&U5OFV{Cgby$Dhp*zU#_KI$~W``pNVDhAH=Pmnd8j zih^@Y!bGskd^(d-FY6|KgI%hmq_d@@amf&A4#PpL=JSm^e`D(7bdfNn(;S#uA^8Px zT6IpfPW;LeifSkCnU8p!f8uS=-Wy5zWXlz4%0GRO&O+ZMGk+dUhfoRw5B`{>jNuCZ8;c!mBzSD5 z>++zNXr>&_K&vI-%wR7vo6}De=_+6|(3NdE&F?dJDmHfpa;z!pyiMlABhh0qZ_C1A zb`EoG;)PdR;)Y-MZgr;{{0y0WOhs2_s!bCC?%I#ge*~Wvz&ZcqQ{mffI^&9^zVOHv zTS~kUt&uz*c({?lGH6!|Jeu8a!uZ@33`Y2138eN4FLvm&0GtT+Djntxp#3>Oush;b15`RO8Z!iB{r^E-t21zdyv4@JP=$FW`3G@1f~nDCnn>MBgAK3PWhTJ0Pqc?#$=*tHrsQPS7RHD zs~zSpB`<1HOS(Zy5&vN2l&T84!4%Ij=n$`WVw3KYKNvdy+fV&OS-!TZ$Il{q%R5av2o*C44<< zRj`xI{e&~^tCg!JIDTurVX4Y|YfDy@fBkA!WKf-)R{HH$-D}UnQuVZRap;~iP)zY-wu!c)uUOqA7zc(gcHa|xK3uZ4$ST*6$eNFso9|b2GQY_ntA`d* zeCWY!+*XII&oybxnk3(8Zw`Ed_7?C*ekis(mZeh}z>L#%K^kgyL@?GS^PRFge*@~; z5^=sZY9=0{yNOH!iD*j_^rPRDwyOuBOB zqIZ$?ibH&PR@fY@L2__#ZUXa4?FBJMJvE&cZmj#!tcyrXB3jB_%9+Q)RJ(fsLz;e1 zMJ5}bt5K@Wlk9$xuUndTCdqVHe~y&}S@G}c5SEJWS!~k9b4-KlU+!Jbk_X?42piRg zMhPlRz;DAG05$9xD6Uh5AYP8?P}7Ui&o-!^DouYD`D!4?;gQwnpvPucdHl>@S6|H= zxI_u7;3aj#!83}I9(u<*Q=WerTsd82HtLhs0rprZ zIM4!$Uuplmxbmm%3`CBSf7bcXIlJ^IK<%2jbnF^ceJX1Z#zNoSoiV9WX_ioVODBQf zr?BT+*gMb>$dnG}Swd18!Gm#UpnlnN0#AtPrNN(`%QH^O;;Ih**Rk?qSJthGB^yy6 zu(0%RU4J_;)%(mrj_$yEOn%zh*g^`{vl%-;x#r%foOn*`e$HD1fAYGMRjE^+}kU-%mzLmD)5*MI_4D3f~7H<+AzsQ6mGR+J|83< zXz3GEy1vy5XCQ}N-b0gV1#oO=JsoP5$;JHJ8EBz-w)qj%?SHn=D}DaqAEORSvv)Ob zFMXN|z@v%E_%jg7!wGGL7ntq9NMGK9-hKjvRt#~Lmp&*aWAr8sfBkqHC;UFFODM4_ zL|PXFTO~{pjR0xF1%cyE=3JDmSX5RpggsDMFNcq0`F>rLZK?Nes=odejj-S4WQEg@ zm`So?{e41yD}Q#Cr#ML}Uktw&As!|`xy4%J`;Vha#&Kto8+B{5IRpCek(QuQVPFi- zD`IbLO8w2}c>rbjX1ykkEXfnslYiT|0y+PxkF(*1J zKbXnJ3Inv6Tg^^~UlvDF^nZ31&NZKR$#}j9g<_CVaDTx~I6DFe!ltn5+tV%lR|i^y zZKlQ(B*83I!7cva1sAtyc;Kkyu-`r)o^tLOAc|~+U%fl7Kq7J%pgN@{*Jgd)`$q5M zUQAib?TX4Moq>E}IEWfosSaeyN$h#-MhX28+66cb`O)PnpRNlTU$bxl{4Z+ToN zu%=hn7DO?2y+0SA0*an<#*H@G*mUx}aP01MrLp)$+`Dw0jXN|M$$y>V|8Ehu|KIIw z;r{?oO9KQH0000807ZMOTmt;xb^MnrK>|{LQ^AVVKoC7E_zy)cDh$hl;z97R;KE!K zg~iLx9x9!hNi&`9P+ghq{JpPwb`@n0fllhxtM^`Y?j@U^AKtuv6$ko4A3oxgc@mtv zyHV&H4|FF?KUnSDp=js16>opte!puU zgn6Ql@Zy1Wki4Or6oa4?%Hrip&!e|&c;B@5uSKV_RM|A13qr#PjLOx29VCx);+QL0 zd1AMbA>$&#{%NN6q?ON=Mm{xhu-r@FHf|jyd#7oX!3nEF_C*`d&qYu!d)f*zs)Q*_ zL6Y>!7E3FQ@U)={banqZfM<-8a}C0OHa4dD^bC+RM{B$r z>KF4Y4tgf;%VMqT;{UDdFqvjU*E8|1Ly#b)cPe{Py_jJ@0olfB*S&}T1P{h7wRrvNVM7#jvmFQ&)3)PPEMkiC3&>7-=?QvFWX_&coKwv6!WPh zoT#qR!ss)w$sjG*77O>dN;7gQLkzfXAe#8-z&aL1B&gMrIYIlxdITxgR$nsbx{5Xi zjl+x%R`#!#058xu%XL01FEX@b>Qocvu6d18}gh|8@U0+`l&-0UjPME*=p+ z{v!esA`*WRVj^N3kL@q7w2DI|9u+z_c;KU5|4^gTuST>muo(;Cep zxh3sJlUIMHp{b><^ZeE8H^wHWX6E(|j!w=lu5P}5 z{sDoYpy2l(qGMv?;uA75KW1g;e9Fx$E-5W5uc)l5Zfb66ZENr7>>3yx8Xg%P8=sh) zUs!)!T3%U&!r(i-qepJA_-{zN&ni|Psk-{grv3enI)p*mV)u1{zdKInEme& zi~2ue_TPy8f4rao5*)0*9}kBTpaQsiRh)mo5BRU5ArBmMYYv1EqTiQGU{neX(E2iU zchNyRrk?#W?P^0FT*M1OnUTQpq`evJ|-^AD^*(+^xbu~Px%fcXo5@^!zl zAA9P*-M_PZdl@jz$pm?Q_YI=}3sP~Oj>`^XLl%7Zb>6;XZMhApvkt2DnNFJta?q0>LR?YD+H-G_sqvVX1%yAV=we;HJ&$fa9@)3kQ>wv7CtO2%#7zYo9Eh4D#2-GT33moL1#CP32xLDvg@B&_ zvK46nW$dI>M@jK57zd7uLUmykzb&@C&{`OkiUXuucH1;hMXWH7t_z$fm-LB&w(!U! zVuHfX-^L3T<|4csJ2`6;N~EY@bB0T#;3YRDm;f8}h(ldfhTR|0L+eDhii_e@NG zS_KNjyJF065z(6ps0^*hR?Mq*yY*!L^p zqRD#+5;XsNekT^8;{gwGg{*B)$I|7{(KT~DirTR#B$m@ZW#Ggj{5>ajLbi=kv|=;@<1fDi`?0$$)TeG{YaU1F5cSZi;*@!TpnooU368GQk7dHxK*9xT z)o$YP!iax&l6%@^IRXmFgU9AfZFP=Chk?P_Wr3tAg|5{4-u~}kTnW;QPELvdz_AVA zVPb9AW{$+hW-XNO_cdvMim<%tl@+?N*xxbtQ_`?&Cx|FeS+1{$TpC#)yR)?xyk(H6 ziK>WKltqqv2DT^Kx2Ni6zR9(uvrsT4Dir0|b1i>OD-e^+y9NIJtSaPS4y0ph3|Q91 zLuyik&#&Gg&zVnDs7dz6$xw#r=ez^IDT#TF`8vN_Vwc+A9`>eh+as~LT}p_m@c{sN zliaWwW!}Y>Q-Te%L%Oa7K9}rMb8DJCHey)T{)!$d!kx7;u#CU|DI@K-k*Ti_=-3<`3)iX0vB|jKo4dTQ zAmy_`&UEPu;BB!4^3YMTb~T)8i;wms#AtsKJijx$rMRE+ab?=a2-#4f!_~5prhkG1 z%IH3&S*}}l*Pm~cGe(s>iKk`&JXyhJ>A;_5Z`SYMAl{B0U}YZcBFnz9bngXrnx%2i zP4f~fCOkDhkRhW1lsugZ&Uy=~Fk+rl^EMUAE&RHmI*6fSq21MLjA&$`OA$>w}5m@uOj6-!p57UJ@4|55Ydc#HiI%F&@F19?C(X7gvZ(iibZBry!<$2cb6mY*OSV0R4zr+|Zdrq++^ z`A>W2L{{Hjn~>>FDTKG2Np@bl9UXuC`zR^V-f&FM`)6zg`UxP)a=xW}$pfG<)c8LJ zXc<8ZXQ$j+I|wK1rVWNVCxER4hi+ON=;!>7i=*5siu%DI0)8E~aX zM8-knTIf)$%H_D*DMAa?6&@8%ZX3$9>aW6tj=55z9t`&NE4Z=$AbdYS@;yXEVFo`F z3`%(bT(zKQ+5454Zs7B<(-^4WDchF$mtHp~=A!vGqrMS86>ty>B5P!%c+|MXHU%OcAuN$s&IMc<*`v_hhRWO0<`Kk;W6qqN7vKlC%z)!-D9pLdBQ( zUnf4^2LRTZ5F2zZtdL~G%KXTDBRP4v*2bm7qUwV#325Be{;YCTvA_j@j@%P_OX6_cN4g`oY)-jO5fdTBqV-HjLkdWZu37zePHas?fw2&F?H-=;K>MEktty zg}?l)_DrMVOv^H)G_!w_;^_FR7MQuOf*r=V4*myOo-Wfsdm)Q$(w*l;>DGYN62BYT zp3$DC@qHqxtc)SsfM|cH@_QKvTjKWLf~>M~QAl(ofA_Ph&SErK^XDAQ>%{s+Y_`nn zH_3ZK>g}I!4IRy$vijcKwhurQkvR#qcF3~WgqA<8W#0&;lVh4$hcB>R3sN1dwLjt7 zzufEgg3-tqWT zwrnlhy})5xd3dv&%EY`_hk@Qo$+0gijy3euhI(h1{EHQz^K4` ztjU`Xm06RMYG8kNil1wFHF*jGe>wzkUtQ9Y|Gq}n7^4wz57`}?p=80AO)Y7{`>~4B zWS_FpY4s{C^$Xcf9saioKQ_(Ne*%vu0Q|)X{~o1|B&BSzTQq^k!vNv`*4r6Tc2EQ} z0nDG!{=;uLLqf!l-(zNIr)T_a8!dl*Q_c}IF3Us3hp7DgtRBO%clth)_#$(Ts2iQ^G!5P`yhP9y;SqB?x;GZKNQe*+ftdwC)R+2g&@V__! zcA4yqDCFUq(ia-ZS#6mW{jNZofVO~HAwvxYqV(bW!D{gg-^JcH>DRfeGR+$AgaoRrLV{H!UE-q+oqcu&H^fl82S_A~1zl9$F1DdYnn($B2hx!5H*iD?ovas<>1 z2w@NsKDiXu5dHnn`b48nhb=*F?&=xi0^a&lfDIN%d23|ZT70Y0@<5)gv+Phly1X^S z$18u&>iK$!qAQEx7*F0m6y2&#BenTyP1Ox+?1W*o6^T;J2<5$rPSW0Rs(7YlZ%w=EZ(LR6iU8cDyf6Q?5 z`3xbu--nF837tMgrUQs%*qpi7!1s`f-d=xw0`tRp3B5{Cp-xu_aFK|>!i{yfccX7XVffGP&W&i6;qjj<1j4^kk)_cPAbvQX@@Y=A(hIfwBH(EDwK= z;K`A23Pt)dj56BZD^wWK7F)3@{pIgFJ~tA3hh%m>=K7L5z8>Oh6YhEwTAx|5X1Z!I z>gw#lG)USlK+C1U@W$ZjUjvI>7;V_c7WL7Y<+7|ef{6b=;E8xaOAEkI zgXX^mOOxb#NsuIL)5LPwHp*LIz{7uI_QVsXDcis<-fZNJ>93@>OuJwYRLO9ca(>k~ ztRb3>14W7`yD)C7omcZ9{UNe)`@)lP@-gx`f&}ZQ{0x4>tTj&sdU9u}qDluSJLpB^ zlAZj7u+kEgr<=|KVsMHV?zjJpWDG0*R(H>e@kK}0S!=8$Nh*IvMCoA_&8`L$zu z{Mh3r6&J5n<)zyllMmg@lqi3`WW+Df%Z93BP59XXbYnxzo+Y~bn>{7$*K4D=$#Efq zTP>K@@3kT{7Yxys>FauB^>TkuW|MhAc{t#dAciM1t{p4N+?DLN!(N0U{S_D5z8oz( z%H%kl)pGvA@08-Mz7Pvo@Gwkx0)%I7fpDMgS2Vs9nk|Bqkv`GZT(nzzCmG;BT{Ko4W6l~9X%g47tAV2 zO5``Oh#j02lUPjXU41z0)lnOLaQIc|QcC*Rlt&o1>1COC4VRhqKRl{qfjBG)WzGEn zhX`;xjC{3un0_fZxh;RbR0IC1=J*?{g5iSHpIZ02$5Hz~I(qO(+#2I1`q>su>{v!9 z3Hc5hVmArV__xd;r{&>Z*S19wCt}-B_)0|2*#@yB^|$s6$tSoRd<+|Dk~Ruv_lqj* zy)NPL^lXIa5VkUyCUyuU!NG-BAPKm*z@7rz9Ce>Kf!-+Jd@d$%%L z7s_1iMrTcnyxYB){N)PusQGzJ?MZGzLVVxvUfbE+Kv2SM8e)<9SJLh+NdoEdGRa@*Gst~92eB9HP1&L zw=O6TF~74sNuRJ)RnjjDy3ct4-~rID;0zZbq;|_D^CJE=qHP-Go38gEEg=zj9~@%_ zN>y^N#h8VNzOfB~^^iLwY>e3$w>;sc_qa3GJ!gMEaZ!yqoI0CdQ3d+eEm(#2 zD7Gto2GE*8$QLCH4(z*Al)5v^{Fd@p1_{fkuVF`=8bp!tPKr_DFM7}q09$+H{os&3 z4B@7yYuE}r79|-blBp1eAZr9QnJLQ|8FR-7nD;o|zpi@#I9t)Hb<9Xx0scToQdRO@ zWvYJ^o75Te0y3`Nr8mVCyh*=z8XJ2Fpn=HTeQs%>1vOm{85{*?bweY`s}3Ik?pYp} zq9Tv8kz*5UJCO{h;v=d~yOBj?6|jckEmF+ux#FH#FWOQ^X2g2(#G0C;F>Y<7Z`5qI z<#m0+?cmjNvQT=l{kNC)GrE9eqS$L>n~kWK11}Ea2DM%eIMJ| zi`y?>RZX6CWx)Ng7`PLZe~+OF61dY>z_-rF#kw^~$;G+AxJ3EZHOC3p4od z?`I0>q%FJ%k8uVH1n2z_vZ`t3pv<8sw`)^OKCij*?Y{FGy=29jR>ZN%WfMV-u8Du7 zR21vGeg#Ec>wHv6;7pmd`)>bk1k0Oxl!PwKHcHb*=sQb<*^LSG0nkeS!9*S1=mX&k zYE2~oC1{W2!$xB6&z67#Wu*mhasIfEXKRzm@}5qPD#GsyTeN=L*Pt`?X8o+ZEYiV} zRiG%rrA)o}#VG9o0->FAYcum_ai@Q#OVVobraN2!_LpG;;lyCh?X3t1GfE`Vp`i{` zJ;M6RbkQ;0sbKGLCaBZ9&!8_kF+zM^uAxNm{Q92I1|_hsmO83}?f-6Xe;{y~KKM;P zPezF3yIE~Yq^!V?3X(@;=Z7{$um9^c^?!obvN>soSH2sT6W`YQnRSIy-c^5AOXw&^ z5amI@cg5i?Vd9{M7ig~HBdWt}`MP{JYvBbCQ^LB7qD}L)ul>+#@~mltc+MhXn+}cbgRMGNe>IyksCux(3E;c zxQC2I$0hyhB(W{g@AN1yj|_k9gn*>aCiQL^lgKaK(!h!}GuLpki?ui3a>2inJzk5f ze2Os|BCTSorZOwvXAb()eszzo+U^lmTWom%P?;Xy)E7Fsa5=tL2OjTt$c1x?u!I(@ z2vmRf5?N2(D3HK9yZ$|m6g9gmQuN>HA=h5Qk2(Rf(@1Cmt6i%??$dvzhvtQQ`qRAl z2{VIFZF33max$ln{#J>?Te>4A+SpX7F(E%-$nsgY1n z`zM)QMcrH0`?BMbTrzzSO{YZ_ZCHkyh)F#G0RE@kz27t?M#lzN= zc^1PSyFebHK=YoSmxH{ej!ltirsf~Xnx3b(teHFKa5Psx=I0jiw(%vWbBTeyWg3qB z@>#Pnv2@)u2g|#lGes@+ig{)kt~JsdQL(KxY!hEVj1;~qt2uw5lQd&+Xqk7&J6QrI zJzt7>`{w0J$!333ZTfD60B}K>9pk)+;e(5kSz8cbTpdN@ODuG&xH@%O$pudC>bV2y z$~guRNmK1v22bUR$QQOKF>=V%p@v4coF(tAvBsFz1)=)V=qnilK_QXS_mS@l_pRK4 z?F%8)d1&@4XHyuXQh<}kai4DrPZqF3X=GV*_P@G)Y8E9)6|FLUIDN~$$& z4~p;oi)?Jog7Ksu>FYL6Tu|p&wE3l(Y>%k9dZnY0UV8HvHXrgjmk=ZArH=}9whZQ8 z>BV#LD~>Bxac4y#eVi0h$TazJ{_1n=yIxo-f)Y6mC(A5r_<)fHvOyg>8bHlv1!EQ# zxmekkn4Rx_2)eOFt~dNmZ6TstTTRQu+7PL*=}ml;+so#4YuCoIRWro_5iT~%qITaN z)AM6hwi*o@1FZiwmNq)QlLDsz@(FHDkmqTGPq%;P|I>Le4*4jX%)b1z%4DkT6*hAQoDZ?ko3Xu~L6B_VG}bKU^8I272HEwM&W-aK!gq~%Id znml}k!-*d;mu}fGbV{OvP)Aj~M23z|A*cJUf#D4U!ynzCX>WibmiDA1W}BGpHhLD5 zuNZ&pX4LPdLGoA+DZwp#=}(ETzx4{!{8pZ&NYm_#Ccd&)CevgzsRsdUpn`C+-0i~H zuBZ*S8>ierZw8{}>u_gr&6i8LHufI`3$s@6$XnQ| zo;FldFlKPA#&c4f-2hDvn>rm}XLe%~m}{vM9Hv?UHaJ$e(Pz|6{mgg2`j)FVp6Wn1 zhpV}zFcJ1=AUj}s9UAbp6n#5T6X<{F&3d0+Z6Q%N{GFm{q_0X~oyF&!)`9{4F5)`r zVj3T#(>#xHuC+j0)iNEwfkG2D^ecf_P3N1MV;T4vkKz{@${*Rs$Czo3_OmC3DWUIx zE_q3nQmCp?U(ppmt(NwAPX-zX7X-E?Nfh~~Gdu+(@kib+cH;ZNF!|ISy54^(;7xsR zy;a>16wLOR>AeMevuuuZNf*xT3XV|v7?s|R_a6}MT@Et#$`?60kjn*WZ_%ByrFWNY z*I{XwsT{-UYgeE%vmbjZk?kH>L~hX3YNb3v>~h_t&NT9U<4YV4OEZ3tgigv-YB^sTmTp zr|_wKLAU@e?ZExMl{;zw9xv6v*X-|KDH*zbhpmTi5c?9s?jrI^dk%jx=*;3n^^&$U zbpM^*sN7_VV0UqZGVxslgzadV3OEb3(Q~cdwBknT32GYwz48(4E}v-eG^&5%J}={p zlR-?&xK&`NPvNBn%;;n|d+hmP)8RcM$}W`_9bhJGyh`O-Y|`1HopWanARb|O#NRn8 zeOTB|A5|~XqJS_L1SWr8EJr%3EFUhVGk#}C9v|3RUs;jhDp zm%FJ^8veKW`ZQ=Qo0z4o!9e+)&045`t>N0b4oQ+XXZp;E;N%?Bp*>h0cs17~_ns&8|noHWnmcZ<0iwQ}Z%Gr)f z+_y9MePIlDMcu4OuPeT#)-58i_Dngtq)z<-K*&1Wvs6KI4yp2gER{}>+l0>}v$NaJ zw*)ChRUo)vTR49%H_X;eNAhi7L&-itJqvToBwLi-tC))HxgPICj+m)&$+6>rW$sc$ zqNjtDHb6c4xEI@1#mY?VWG5=$rFF2$tn0zLd*KT&gBFSNUQov zb?dbaH!6J=^Rm7yuBK-Ojggsy(RbVb2wV>Uk3GzPo(^nsF8EK=x|%SxUMwl&qJ5H< zrm?crENYNryz#Q6HGBLklO1e;#vS&R#e4iC3UsU~Dxh@-Ap_fuOg>%V%AYMT5h{NxEASG)WpMwH+{WSXvUM5cbp=G* z!a^!V{k8~JLYo+kLG>q$C%k4f$N?S3e2&Pi)}X!Conu!0t3{+pGH`F)Ws_yW`PTD~ zIW#Cf)=pFVY@CoKRzPQa|6~R~ly5|zGH8GwLV^DIkN0`=;l%r=)4Zo`p$be@;i!rc znP7kL`7qLCcv-R}D`iumM^?hGe;Nv6xo2pwa^KnQQ@&`y`X#SFtRJ6O!Vw;I4fRPQ z1fEi?L`X?j;SBIxk{d|tV6RKl?JKgy=glR@2`U|1^98sPQePO z25a&6JM2UtBk`!?a+hYCvC(<(T=mX9R$+heuWlgn{#I@7w={b^I~!Qvr5>-UZDtsf z!}{xo7!^M}zT8sk}ksyeSIJQ4f+ti*vcFOxl>U1_hvBB~#N11;O z1m8D_*;HW#zg#ma~E!2jHj;E1Hj?n70!2X#s+v8H|cqidI5;`bW;NTW!o#Vi$x}f+Tw6mE626? z(0EE|C`qkP7o_I%bhj~bJ>jpczeax$)*qQH<;B!U(RJ0`VAOt28l>&{Uo z$%!2Fy5#8)aAhzPtFmnUzP`10*=qV5eqMAg5KiR!7BUW94^#@!^HPtp1v(1l#=w1X zqjT*fpBT36{9@K5Xjrf!xBP~ZNp`GpHSxANZ2wk!b!{N?D_e%q7=PVl=PQ5O7tT)L zR`7U%S+BbdwQgQ2zYt6w?@^q0BNOyBgU_|N-!Hdaqw#r-;%{X&YNngzd%_Y_+vHHT zvH&_1SY_pAZk*3|RsP<;awJ_}xFthkLp1xthi9EztuG18=o`!dw;2hlJ`!>?|9nP(10Y2Npcty%apzh<5&XN7M zc#0H#6h8m=S;)D5iVcx7IWq2n@q8@qObhTMLo9z=F*hJ1Agbg- zAR|JB<6byyA#Zfp>$j4^P~JtX5%n_@wWdz678iHO$GZYPk>AtaRNFNTCo2?c+-Uk5 zbCCQ}f1Q-JZN{0>3n5`&0sTtpW+9*?)I*p^GTMauN$!r`13*B`Yp#JkY%-h>!sn)C zgkq2Ivp~FV&%G;S{T6?99Ddz>a0U5&{`qnb@&@gCD-8v|m{oM#*3~%y%kf3{Td5CT z$z~tlC)cj(a~pBzaPv#*Fh(23y)`R_4*8}L;*X}%tkuQj!;UAXUM_1zJpd*$M*NaR zH;EH1G$PfbshTYvlXClXw!ZeU>6acur$4B;On;y^|4|3@Ek%EquMN(a-&H%A)}GX^ zz;?Ji;f!HEVeXg`tQxN&_GbNIFQOwRnS6U#c8-4Y(vQZrkI7!|=aKkrz$v{>=GCHCJE9v+O=V1I@AK7)7j=K%oUAL)5zy&wvv zMNZ%I25;+dbuS2&X#r(SR*q^4yDO@8@IGQom?^%xs=aHih0Vmommi&O-w_*S)$T18 z`W%^Pzuxt*2ehc3k(8M>sD2%zJy%La5I}HdJiDd0^|^mwym>YObwNBIn?U)dYYcfo z&oal`xH+^ts9GWilCb*>@K^GHU8N6J<2a?;QiTCMcUbrSE}G}Xoh z3okRWG>*i4Mt?p4T*#rBqXpb4Ryc#3yv@jhDygxG=Q9)2+!Q4hP^05lZ|!xW8f^q- zdD2Z&^uT{Mum=FGG#}Sm|c4Ar-&)GMM0bA7(O&< zErthOwjG{gL(ziXw(@~1F`YhNs-O>)XaAJ%Ae{dVJPv(pK34n-%t< zO-i(P1xBN7(a9$1#;*{1Lzcr^`L_*?x9_r${2p_Dt<4SIW>kI^J$|J^hfzg|BILG} zsQZ7Q>>o~~-ov@Gr$^eEO0}G0;riJi`ghp6vF>r79N*!biL0`)-Wq|j!DZ_pdI zWq+&*k?pAG+3G1#f*i zzfRMDSl_q&g#5AYldV0@iLPeHkLRlFIv9NPatSyLZC{FR*6?c9@ugdvQuexBq8ES6 z%3!3~j;ba2MUupJd64*}BtSNB^f@MBE{q<1G&(A)LKDV%bkjSCus3dAls-HyTM!m) z*?5DPv=o1WlSM)Kc(C(KR#a7=J^21dZ!1kW{mued0iLMcLe6q9SJv}`GXZ+Undvj_ zW`P$8$YkGC@4qY}FLEhP6BE)>3^9Mep8dD9&8KKz5ezVY!uY|>EbfhAhE5+gMD?y% z#d8Sl3k#VFACxgRQKoC}P1Mt;*xdMIZ`gT0qsRI|jb&ZP`%iuuOpRRxE~cv7GVf2U zd$4O^vC@#-9YdHWM?0b28)V_7$@uHd1ZsLUdqY~E^!Q#fRV{V!9eVJU{E&aKOEM$q zhxiDe-&>;YXvQ%v6*0cNEBXKRY4_;8G}xf0d0A>Y)|!05wi&h5j@$nDoIQ#CVI;~dix_p5 zM&P|N>I2AP+>|!3HPUoa0PufHtJlG;2&>>DpqEjZ0rM8co4Nh8RsZ)^aslz2e}1Ul zZ`V`WFLyV1{^B}}!`)+$w~lXLalSL*lgq?wtY!xl#vyK{w;RIxB6it%EEUkU^1`=@*J+8C7Cwh`n=&*5@^SL&TI@X$P@M;ioPT!Wam^k&>r6-wY z?9};uvavfjB0~jGE=Kku44wT!OV<3z#nagRWZJ!s#*Wli?Gd!=FCZqTb~s;MAHR73 zxb;+MEeV0u$}QaF9e;l@MMqB%a(1<)>98b()Q}N06x{Q;+!Nm~q=EJ}PX_|^ydCAy zCqIAPeb!?dVUq4xpK|Ob{U9^7{k|j9)L^sPgGnooozM4h#a zU!6RKcM$;ucfZ&VEj<889{|7xN=R?j1HcjFqesN*t3N+nJ==$&fDo|H zfpZ>3vso!{UgbpLK|%i%AWeB6=-x&^|+M?{kfiS8-T`PNH?{nj$bA2L)W=#qhJ zAM*{1p!g=a|$=+@b=p9C_RR9xa80sIrO`<+_5Lma{ue7 z`g|2y9SA>#Iu99uWHlB&rF6Y$)X8dg6jnsJ&NH>l%ywE+v=bRhKTUIeDii{bI4Qj2 z$H{*~Mei1TMG@J=np^i&VEj;FU&Fqcf-;1m*12UHlJT~#yJpEfL!Hq&iIZ4uZ^ac& zRweHHZ5)vzS(S%j5{p9jLau@A&?_kNW8K#takzHa1bKG$97Ine1Ze&$`ge?QXqvIjq>v%I)oCr zY|(j6y59V9J5*|qSv719;x`K9+LjW;I7aa8O2c~TyFG%oz2cX8iNi?HaI06tCQyHe zC5RZXN@I9|#ENL^PtpPDti&HjHEu4E#k&<8>K&~lt^UFUD0z&E^DU|J1-qS^w0eD+noyrhtET!EY<3?dHdE8ED{0u4KvK)^VmQ4`zp#dSV}AdY!$Z zCRD*id$Am^XulH`gg{%qk0XmwvPgUHxwuip0x(Ds)#8sM)=99n5vpu=+L9?jQb`H7fg}hywvie) zXlwTSaw|9ataTZt&{_|vYZD2Aop>6@+)qdV>lDD8kJXiRN!rPYs9M4-1;EyN_M*3? zAxT*R?10>i-ll@kqJ6Sc>)ar`i^3oEgX~#S3LLv2#{5=0Q0{*;$M@m@ys3BWtjZGT z&hu0?7+#z--}MExh@+1zD>Iu18xK`u;%Ce^A2EO8ex1__5koesKW!3 z%FyCS(LqVDW_QPs`CgSHccEhnor1YIH~7OiwvNQ3x&D_gwYo6OcPSS(M4M*lLO4m= zrV{FhxKO~krGV9iJN{bw^s{^FueNPBa}24BHbYq&lyZ2(~G zR@Z~Af@qX2Vg~oZ$P+JaZ0txd2dST8kIH%gBp{4d)?|O)*FZiFK~AM=#dUit7|*>^ z-2mZ38zQ$BXfJr3f^J9NywUf$`l8l?tvh$0mKT&ccSYKxJz$;NC+Pg)2LSawX>X$9 zE0p?BI2q=NSKTPAPE@C{sr6aHCQr&LZpI`{3ga(s$!vgZ@a>I;lL74&%lkB}9mUiU zhy&UY7HWSpwAF0g0Gm|Mli~ipEPd-?xv9o8A7tazwK=PizV~9(Q(^*3ZXaNPpL9RJ zh3Afuy%oc#x1qy#Qkl>n4{GyBm!thd3K13|i7}vUI2R0w=21W)B3i9U{`m3!u zS5qX=a2YbEagR4u8@qq8|HsbiZ}{8d){~1knag$#MmLd2B#T7 zuS}Z1A>TA4=Mk1c7NvEg*Sy(Rmk5hH*<2Ffs?pYTUOg>*EGCl0M{FEuTU+*2Fpo@) z2kC^x8air()zjEHVUG3#17(pR>-4AmtLLjMUyaJ50in*we7NIuZEWB3$BkhRNNSzvHkq8O!a+7XS7w{apyF*7nN$iL_1htW?`QZm;G7SdOkh z)rxo5-p<2;B`fZ^#t=6J4yS?UBK}AT`VY(oYSX{O6t%wA8|U6``sMksT^l4#CQL`T zEHncra?fn+9a@;W4No2USMbuuL#WVa*T#SH!N;dk;cxRoLY|0xTzkeaOp*EMYC-U+ z@(a`i;I_%00dO(B8Vetux*qP)RfEK6*DI=5Kq>-0S*5!LOtA!7iUOK+36vye#im@dfoEno&W0U#sypRErqIWKPno2nrP1`GxZemok4%y z=&It;m`pzaTJTa66qURyL}OZ%f=>r!=k#UD!CauFWz_xBM+A~bZ9nBoX! z&;uaqBbY2!tsU7rR7du6b8iW{$4YfNn5jBv_I&=w3rS|bzRc^|z^}J!AJ1N!Ov{Hy zditjlZ?2(jhOl*&==IH*?|H7?Y59Lv42J~mP2q;UE^r5O%j|CGW?8N70764V6T*1H z{`P-lB}nVc(*7d10`?E0@u!NffVsV`b!%d3*j&kFt5o4i7DTlloVm$!!o)$3_F>b} zVEhLFLH74jZU|3rXH$3KFuj*oWxqd*WZe(n>Sopft-_FJZyY4?_}4zp;D3JwW8Ecy z4Pjf+@^qagYgYMIcCrf@diX=aWk-`hadHw_%5iGwdRpYuw;kLO-c#ON?0!nJFZ0+* zTy}onOE1F^8dGM6F zzJ1#Z7uxbpk1VxZ&jTJLq?mt!j^-vQU)6S$)SP@=O@<{cIy9L~49^)B=7Z(*HITX3 z|5bHvdYZ>0(5%J+!KWt)Ez5OMMJ*XYhCyn%gZNp0CNM>8-pJGK<>AwW#-rem!uIe` zeD$X85Pf=dIcwXMZCTL1hW1%|hGgY0sk90Mi?Y!6Tb=`UatOiw=+%Fw0umAhVM(=^ zT{iEfw#;>K?XJa>e2V{;V@Hvn`=XwDFq~1*FzA*8NR3v9F@t>7TaZKWt5fstjHM}K zL2<@gmbcx_PY9lWTd4q?2u-NsB6?+mqU%9sPwvq7ld}MG*x1`Wp3mLrQ@IAnQm?LBYzpLO zhyaF^?Y6mVv16P(7caFCT$LF4yHEE_E)cPKzwaS7lm`J+F4h$De%F2(s1xkUw%5s# zSvo}fa{bI*=Qh&!lMetYMZHSUEqt+Mtm~?kY1QV)+^WC*GththnZYMdj<}KM5d|Pn z480GG;MdN^rPuocV&rs;lufey%Nl00t(Tag)o6ii31mQ^F_;#hTu|o{ZB|Hup^jV-aTd9OMUJVW6X7=fJpvDaQDvsCdFYO5tEpZ=uFz~!;ux<#DR+UjdaWS@Sucg&_p-e{OD zN@I`I%fInM@SD#?4nfP^AJShEP5gu z=cXAC8h+q$z7ck=ooy?x&$J}Pqjec2$-xHzWh;AB80j+jQ67!_emq$23aCfMN;mF- zXyxg0w$OhYXP+eI)H?41H0H2~(Jq6Wx8>$AQqhZ{({#Bsrl|R0TA32LD zk}%%}R9`2S&-i|sO2bDFUcE!^$0-v-(L_+)WC11V9mxEpf{tQMe#pW4gvk4y)0XJm zcm{>F6yQ&=G<=#iY;nn0nYp9A6B!bYLLg4o$s~gyA`<;Ep@dV!fA@1+fkc~ag^mt$lR4Sa%+8d zD9G9Ilf)3B8@5H`^SH%UK}JW~7;*R{$eti-5!n{V%|9+S{$U(u^v_eqB64N)#&$0m zh8W;O+5rvN#>Uhq>=qBSB_F^-w~+VMJ(^Pef)1by}(hU+Qt22yLdDQrxI zU9|OC>$e3kv-E_kGVc-P)zzXIXLq)?~ngX(-oV0ry>Y29!U40Yno99oX2=3aXfJMQ-CN3yAZ&u?{{XhzsyRp6nhw>`x?9LCZ$ z*K*Z6&x^{=>B2^;clsSG2of5qdupcsh!~9`bUOSAP*JZt`P$u@ut#VF3^LGRsqCwH z!bn2?PAmA2EW84NRRn?_8~Rq(Z4Fv6de$|3%~;-d4z?3cTy))xJmknnkh^p!fyW7GcZOuD~xp& zeTXPU{>-V=5Mq*z{;fDxyVE9t?ZrceV`=%~wQN2It_XI7;PwOHJ%pg^uDIJb1hxfm zmCX~W3#?kxgHuX3-qL+)JJ;Kv3VcH3oT0hav<@iQ{kdl}c%{+@p=v{tzn4-WU7c2c znkvt9r2Y8HO8E~fmugYt$zO%+lfYku{hxr$SFL$T82knv8f4YqSVMhSqZe!i5E4Utl6%UU;{bWI%PM>{F2at(!lwPtU8o=^dQWiQgR zrh3J7Q+iIi$4GFBEF^9Yp>FeHcFYde)+PV#=`XSUyc@O-l{g!$dsd8TXVm`iJ<06l z?bxHo-@990Dca9Eu!~_x0U&n7n2EvnnTsu&%~5;gWu0}PMSgaBOE=`4mP&2|%XT-` zY`)DdKyG!U;kONZt;fa~9qC$sZ#GzT9JuTa9%h;!1&8H(E9wcV24sDAVWj4eW7Z#k|2%^~8{Ss< zJ4ZGZFLsC>OZJLAK}y?G^Q%YsW7V6w(m33NVZ-im8V-S40(XyzeXjiuFb>Kw3}+4e z>|5&KJwLLnC$if^(yFaMSaY_mc!CPgDJjTNdj;1$;M;*_xNf&N+(UB2nsvT)gYHa8 zU1xzqHy>E}(pY=Nf%2YzwYnF>DbIIBR#lv=%vpBip|G*$V{H5>^NF0wkZFOE3(ih2 zns+yLu#g-r`wGQkzM0TV33jaf;m^#S%aJWAU`F+nEArFWdxD>@e`V+#;Dz?vq-~O- z)!{1S7*ljSd*tP$u-3qh=l78Evc2zuYHXd}GQ=8;|Ik@fI%EZZJReqe=`&`|_gW2a zyhb=a0Ejs++40crubK<3=NcU5{Se+|+Trcpp9#q`EPP(%8Vshbu9qeNZcL0(QcRk? zUl0E;rq27F4L5B2v8z_?Ep1U%tM(46HL6Ofy=n_$kEqyt7d6VaXi=N0P0ZMPkC=&~ zMo4HQHJ?7m`yTIq@%{<-b==o^e$HRc%b-_sq)KUKKtl8lhunCs{pypNv{JCeI)DF? zcy)S5ABz3W55p&Ss1>=muJ2SFe!xy91(m^6aiu@AyL4lV+;?K#(>8{`=l(n?AilpF z4$>y1qdl2nlGhcX_Yb?szBk30W{AiLT|#`c$+9bG1{(N(8;ib-{#fh#PVuYDRz7~a7s}64Q_ZY_Ub9_D#;h|6o_+q1%WH zljzX<(AVdGs!2AT4SIx^R{|9-KFJe-{@yzjP3;L`fs(I{t-h%7IK;oL^dTC!ncie< z)Jw9KSo(?UN~m(V5wD*;g3F?&$dCtKRGS#^aYFVEkHmq!L4B=*%_mt{tGyZU?p4s1 z7?BxW?8Z~v+ENz_HtW)okY@7E<8@~sr)MzT1Ln-$w?Hu;tc^M@MW{sKq^O@Ztv9tiN2|!THf8I-V*l&uxUdycI2nV+81M_jMz}RyOcr)ZjdP+%~6`31UgY z!fF*-s5ZlD(Jq1i3^rjoms!NSGBY!{oH8e7trZ=!N&mJ%q(y>Q2^KgW6(VSt&1+UbofdH;goJjWmSX;-X#Nw31;|Ot zHBzV8bpJj42bC?aHlIb_#=Zu>YP2J7C>4oy4NeE|OZbGV>Qnd#5+@{lLp&60n``6- z>Yn=bw;HgM&DTyy&0ya&=bl!t`6dE?qEI#i7+KU!Q!sY(*?bK=?4AFa?U&CoT6RgV zo3jNU$@~}{XE3BpbX;Y3Uir>E;fvIZRC3<30lI`T6C%(;pE*&tfP|ggeWYikRgJr_ zH2H_N`s}SVr58^eiVTwA?a9PnEB3pAT{MJ$(-JM*3S$K0pf>&HkfS`anJamJr%Auh zuQ%E9Mz$^e9bHBpb7h_eF|TRjBKx#T2rqD3qA@kDZ$b)&`YHUI3u}vdQ(XkM)O_w3 zi;I(4(v;v-nK*mihb4r$q|p=HdRb?npfECm57MPp_^3G3sVsM-3)&xbViXdnc8GHoAgE2{sYTRHC! zCBZHezR;G*CV^zZ^&??H-rhm{odv~=)MI6LqTEx--oz7z9gO*YPLuwB%k2dzsI-XY zLfWqT`?BJMfb-r4(M;PC6I(j4k!H^)68N|$O8WO$GEj>(hF4gdjXrd$JtPf7iO%g(awFF%n&#A_+Ls(* zO8@nJi*n?x)4-VAyQ5`)N5Er%{|F%vNAdjnzZ-FwJ=P?_-y(#fw|Pc=TZFwfc0QIW z()d&kD{s^ah_?K1tYDpei>YnqE6Mw(H}h)Fm9Mf>zFs?BOi6dRb!?v zY^7&X=sTebdyW9|bUTDFnh;)idX{N_L9n!-^p2Jp1e&rL*o$7+M*B8>h?)r(Cwo@E#3h#}btSK=! z1DhDr(#1QnEp4GmOBD^6<`v!rNe*;pO= zJ41eF?Z1q1ZTZ^jjFw5V^#w5L zDKJFpjkxk76s)#wRl9)-jR>Pj1<}`wr>0DW8`gJ|2_g z0#Nw4<#u43Fz5X>DmHoOg~l2c-8|&Rd>?gx->%s`c>eHT69?B;iqAvJWX211QWH9Z zOQ8%M&1Re?(>r+~<3r;y4)hX`Ulb=@WuSSYK-69`T`F9Rry62BGBL5xpxQtgkn6#h zq!`EbH@JRP4r3((jXU}R`4?;A+FRnJ*XLGlZqfKzAQF>dS9P2@ka;s)MECzUY>_bRy;+D$YP{qO zCW9~^?d2WnJMHk(aSSjd|9Mbi=Y2)8*p{U1Ytc=>RqUr*7hxCki!4T@3$v*>+sbm> zjwem%2U1G7j;FYtg>*@euMB_U5I5_lAfrrEHDnhFeZEr0fv# ziuruWtV+|Q)^`YXZs@Tv>gwz|vfXY-U>JleOrjJn@g&a2OU;(gne9LeW3`qctTlN= z&*ad+1yFX|!^l8@JzWoRG{-TJn+WV?AFsxyFKF2u)o01314O?~WSGdjX$@q5-hg?* z!65Dzw(U}x7V}cqg6S<;4h6eXSsHzG;-BQF7Q>a)Rv?xX7+Q`D3>5Q^ehCb}U7_s7DtkA=m^*wniBg z=t#Q6$f^eX^w~hPmyK(Gyc9@ewE#oP zBy-KT{tC~&=6$y9_KnFQk_d39WQ1!#gw@{y26P!Uv13vj6u5`02kmun1@g~a>epY{ zTdaat;EfvTu>@K`ujBpyxNiaR){k*}n9ugLPzw}G>jo9U=erKsRR5#!VSn(@DC8*I z5u`6qCwCah+G&V0{zCSD(dG_FhLEGz(l%ZF8}}7DI%=x)tFaC6L^Rp{fo|t;#v?g7 z2F4!tI_>7AM!l8pej$YoFw(hq>K;PmPwDJfywtinxv|z{K)kc+aiSb;30)T%AgDVI zqD1jeROhz3`z;ieg$4@fvlT5MEIMag=9vEPjmGEMCy&?ACRyKqU%&DpDR|34Kv-gL z0V!R~$aRAKAgewhtZ(MPV84JCM}{8ETpbUPiZFLFW0C9kA?rX=eh?g?NxXDg?dy`i zE|O*@?3GOU7g_Fm$71zj{oTkS%9^*?-k444sgyNGg2gH%4XhwuZM{#-5`h=;GP%U#i>oZ^HL+5 zs*RUqa_AeG5_gd-f#{GEXwGg*Q|QKjB)6zZ>ztARI>lZa+THkU zZHRA-FPg0+kx_VNNQjxN5GawJI;4l~8%DnJJ+Q$hn7OuV6kMF(seRPJ{^0A+!M;KH zF3Zm+WLLTuRd~nwcs>%HFCE-t!fN_QaWZ0=5D|_RH8L6>hxN}8N zAn^@ySNo5ygg%=0TTo3?O?;Mqlmgp&ppc}XaS`ohkIIjmj_s2K^OFewTL9}mkmkw} zb!j2*h^>ne{zt`1nzrAWveAy8`2M4A6z(B^H=vZC2q6>b;}*jQHOz+cx)f6V#!wTy z@>S@;cwegieBBye4a*H5fq4aM)Zn& zYHsLeRmeIT6yHO3kNwnS%5N#MyKZBD)R}||>s29#y~7q1qG@afZ~g@nXNlZnDNLY$ zxwj{CUo(HAn~O33Mk+fy7yZ0F6HzJ79v@8**tQ`*xS{RDQG>*-oTiSZz7lMKZ#vT* z*sLn<*d^265lJbbM#BSpKy(-scCX;NNXfhU$_)XVpl;sD1I?c+Z25aB*SRrPmO+6U>^2y7K4B*$rilf;%k2=|! zhwGxI?m+5cWwo?C>k2(g$}RR#GaX@9N=vzd2*h1628mWU7H|A&w@YHb5-;1= zQ!b&GPdB!ek^P|&sW$d};>`YzhAOCoU{>X_`6F#GjFIr{cn1^8N|@F~Z_*u}8w#1J z`5BdTHZklG1w0vz%FqaZl8$d@z6D_Zpy{_mlttjuTPjqe+r97CCkr0I=RIep^JnXl zErcGtVF!wjD3gO6uuF0Ng$au{+ywDj%|HlBBw)kJSsgo{P0n63`*>MBgJq-TOW;^2!!9><)K!7Jl!M@P+V_e#fnUa#ov34?=Zs`i%2L zJK(PagPs)8zRJH`2o_NpG%nzsRs!So?hr0}H`{SM`Mwe*kHcXt-ilnDLF8<*?pv2v z?%fPEO`fdK2@sWQUTO^1rauqU{Hapc(-l$_^6dG?F+69sTobEky{e6^{w&L=cfs8D z(9s@_1pTNJI1~ndbm35#K>ey&xFRbVp3UzRbaZal85vYsq1Q?`k|?valvPV>Z!np>2?YirSFx1@*tOJlo z8@oNwK-JbCZYmtV8idEZ!F(Wd&CnZLKm)5-J;i9O+7#Q_wU{+7uzwXuccn5frRASA z!D}3+t1F8U_8;57N?>4323r~`Ifl8 zr}nm3*Rc=WVLEMl=YXMSRBj(m7*pPJ2+$wZ)u{R z97eCKc<|mGR?}BnFQ)h!VHF)J9LWw>1P4-&gw$wEa3DC9ynhVr$~g~>iuVYx%Q5eU zKGKAL0JjH9SV5K;cgJL8S(cOs(vIk?oGf4P!wR35WDkI3PRVjm0!KvyugWv;D?ixP3PfI4Wc0X zEH?Q=LxR|lxn`QwX7j!*k6mOh8b-<6pIRufm)z+2mjg$a%%cd$ZYC=U20MQ(685pdvc(n!;Yx+@OaB`srz2J@$XeNsy3HbMLH*IB-S=0{IZQw z>}fS?=il4{h?sSZusrG2&^dM7+Zlz!_e!!X_$B&gN$OtM@N%HR8_s!Vu0NB1%6loI zkQ*6r0aIU+P7U#%f#IaN*75!1G+ikF})JQ5iM1h z&ij2rS!iSK-&g7bv)m6aG(d+x1(6<_7u1B{gk2@|_*lAx3KaPZ?8S0C78hp$C7IFd zydR3gIk`;2P-4vB8yW0QYFo*u^IV-%IXQc`OeJyl>s30C*eRj7#R%t=s~78raCXKYc5XAAHZCr4$~-_4{PA2H>}e zt?`qG3spfwkJmQ#FB;D*ZT_~>c%63bioT`B+kA~MQPSPI)MK`GVO*b35KSu=EoRdH z{xT~@opW<ZTQcnLGS?Q4R%w58CB?4x8Ba2<{`r((q&cpf5sU=GPZiABi^;)T#qIxZx;NNiG`&@~Dv1aVe z7nMOy%zKoX)EI<;lS;B9E`2vhKyK>Y2U^vWMYv{PC~)zL5tSBypAZ7}r=PhJ6sgwc zVD$uPpB!tYe6*b$5xF_`@}iUe!^}jqi;U~b4`YS#&cP(hEp@pUx{`%?+bwHHU@>Zf zueP!rX^DQR*4pDMry+mnqVU}=O)Xux=NvJ~3OvQaL^lH!Iuh#K!xSn{a)jSvdzj@X zf@5q6tG(@>{d9MK53G3>7LC7mC5@#QeHeIhPt8P=tEvjn8~2Q;h^{cqQIA=)^cx|q z*IF27NEn4^9PjQQUVB_aY*0MEYsu#`J%1W%48<_E&BBda9~?MK{<1+l^R_UTpkifp(12{MHg}_t$w|EdOo_WN6Cs z!C1oEd*z&ea!*vzS1Q;kG;6QTJ#CzsJLXEaf{;j-IoQP+F|S6?#ItjH(=3S4mn}fd|UTPw*bmh{_X0Vg@R)#CT=aNvMrJwYC-rBAA z?@riwMA$*oQN!5(G2FLrQI(eC(qcT&fM?&`K3GJ5hkbwc@+cLpzmjhBAF1)`e;)wy zVyrHq^w(uw{H`}jGoB_U*d&XVIE*KU8NZI3=~qctSxQ$``gD#)deOCp5#Ce4P=VQ% z3XUQcW(5YuI(L3w^4~Wfq@gnURNF{65wrKnekLKw6_95ymp-%xW_p54ywv>n+I(P) z%tcs#8i>sW>(bsGTN7P7i**q26bIq0dtyHwE55-j2L3y0UoR@-HXZR=8di z#$R!lH0G^_c)okd=9p}PW$8V{Zp;y6=#(hv=~v2 z3e4|-+PAkQD}+yUOhCzfG_&Db#!ExDfICPrgv*U2L10LS6S{b_c)F^)AT0c|xjlQ| zUZW*1z{uA9VanC;dOQ*5m)%ZHyZ}dkR~f31Z1aG3B*TXC^Rv@=M`G=u0Be9@*+s6L zve$dxw>38m1Ov|`j=J77Mg)It5`L5L8pf3U%HH%`NHfTzs6ylPXJAM)eKQQdMKT5g zUP0!R6us@yb+I#U8&7de#@OC4R0z+gTfR`Il%P+A-(fT~JRpHD!13=_vW1U-sdf7E zp+d58{$Z)sB-6jUNnI5FG|IMrOL4vhFmh`gPrsD42~v$W#z4+xi1@A-*WMauf02!W zac)<@MYcoU&-Gns6uuLfbrOCs@6=>s#xSe(RnfBV=aG#_3$KtyZ+cbTbeDoDOc`7a zXWyEzyX*;k^{wwrzePc}JcjR9TKViat-*CgpX1T!a}VWhbFvp1G2}t^jIc*9tfYnRh&s-oKq& zv4r{}aGX^&S{g*D2agRwmcD|q^ zy7ZsS^3lt~#~!=%A(#p~{cTy8c8Itf+xJpH%r_I~BXw7D3!4K4yb{+r*0aA?92Y(R z)U4c@o21Gukf+7`jJazGQF#ntlg+c~?zHnuzTzU)W+l*7W=V4UmxBlb+dGe-l z{tC|9prush)c(MpUx&!9izEA2y`=?`dH_liBhA` zE_tcl-oZ86UQ!lSQ6au|M#gh)KN_ny?EXHuu}7Gv<)0{nVGjuQU$34;BGAD8 z&ks)!VN{I}uKb@Mg{7@1 zM!d7_ZZS1Dz;CEJHvNFvNmB{GJPR4uJoS(Q6tZ`6Rv2ZJH+!lWCaQews3y`htH~MU@$B>< z7y63?qjd{^aG|k(8)txpOyNzULhtVr%SkKNF{|C5Ed{gEkGUW8Ix-X(OFnF6AC{9o z^9h|$$U%g~Z*XA4QekG`x_$FxNmmd9?oqLmjSlwDyoA&oa>(H<*Xy0L%g+y7%-jL! zPDLHuP?QSz77$Z|R$;MO-4Er(u03gA`0v8u$1+@hQp@PYKy;g3e&&f6?uR%ng=IGf zj1p@yC{zI_3PN)h5)rV2{z6nD0)`o9FSD(}hy2|$iFx1A!dgea8!ORAsq!dqpD+W% zRTxfSf!HL&Rvc|%<^Gu&A*a8+Ut7}B>c7m2)kh{%^wgg88s!0%YOx=T&22y$3{!XMAUZ4N*K~27+$`Gnh(8L`WkkZ zkSHiT8B@+b&ikr0k1U{I{TnB{K*r;j9|l$|1xX;GwFlO$vw!sH)_<77f4FMLm!FmFt+z^osepkp{1S zE!nv0hc$crT+jIuM@y8S@-zIJ`KI-Xy1(xoc}k(d>TS@tF2H-^;d(G`vzH|R_;l8< z<1+s@X>=?*tQ>`3o8o-;p0{8j$s6B)D*i#J#goc(1U(GXFdD32PTEIBeTaRO?r^`(w~d^C&b`n))sw{> zoR^(c>PkIxfc%D{rXL)qd@H(l|J{>_5hcfuVfV`Y(}2J7WHM`A8La+df7L$hvhWwh z$-jMq+_%PcX_696*a42bM ziF{UxkxKYJ4*gpQ@Oh76&MY(CFT4X|Epjn8&O0^l!(TvZYLcI{ImwBt_LW~A4@LC; z``11H5rcCWMLOXb9Z&dNO&h!bB=S;3xPP8|X{EAl(odJlBR79sYk_DNOUhU}vS6|} zpx@87eZ#jV{~}L++kv-@vf#gKR%}lHv$L(Gn5OmD+rx}5SnIV3{|DC_cCw6d~ zi(5hzuAILrd=vF*;Y;d?<$ST$9L88?Jl16yuVIAjHKCq=t^5h-2@j6z#pvkcirqqG zTRT2Arue+&;Lm5^P3?PXEZribgb6kzN%j z7rMkl0w>s`PFP`vGgr3NP~T@wTK>A4FQzeHz{hngrgQJL@Aqimi^-Nm92o>LBX0(7 zKyX|c;yQHp1c5Az!Tb*KLC?LO^LL5VrO71CyE7^Gm&K};6oX_s6>9FD{T+rh+>%^5 zpuhir9rq}gw0^6wVO*29ex0l8&!|c7IkG#s+gT-dla89Ab{m`xV_b$dAbd8* zbK<6+X9(q*Kv_;3+@BoCNLCmOF%g}YVTiCkW7}~EZW~0#_l5-yNl;d$=<{aQa>`i0 zcWPu7)4%xw>*%fvu53#}XKYBC#NvSW0)7F1>EZVe9qTOmUX(6;o)z*U(BN&cxUIwmso|X;xVN_e zhs&2T1!MiQtANmH#$AJGpSltKHUQfN#Q?V_KJFj&g>phiAqHbTtcJtFS4K3Ms{`AA zCk=qXhZI-A{iE#v`F#{bKEs)ZHLLW0rm}9HhcSM$)+|=rG6Yw+y}aOj?O_KK>5R$r z`l_u%+OxIosk*Rq2&7jM!v*8RAUvzOEbZgj&4;L8zuDnQLA6}y*1Ms=jQ$a5AXHIcUb20e;;|Pn}ZO4rGw+x)I~Kd@+TQ|!`rB9q~}v#M&0pk%H?~< zczUMFEt>?V*!GOO;lxI7Jj9vA6fONp5oO0*JzxpP={T|!g8Aw-_!A;d?Q(~N3*H&g z7H9T=R5fE^Ml{(gUw<^_g{Yg%ojmBsew(Q~C!(8GU0E4Xa^~Tf{p|IuPWR}4`=q+d z=N3@JKp2yB8vQTdHRzdPst$d*<))f)YT=h=^jKL15N_N;5iGW0SIzeG?qJKbUm?(j zVA#Lu(VQjb@g+xR#=^`ggi2f2V|HU!F2QDP=!s;;%TuDinoLB0u7FK?F*hXL@xO9x9Xb&A5rYx?R}LM6~mcTlnWN0Q?v$k@}#kUlF17GX7w^- zI5S$CD%+Bz)4^ntLiqik`#iqIHxo#T6qq!{fC>en@C{D3fA@pUuN#piQKW{A$XZBv zxsg$`e8wZy@UxC`=EDsRoLF49OIj>)FtcVKJ?mBXjS!{Up8o0rGPwDPEj8L}|Neo@ zcRa2z!8|AF7T~Coa8b~Ig34!J1o04Nv}BN3^9lAvhwgh0$wsCKPoGJWdr$e~4fgS3s7lT>FBOkMfLgsm}VfM9gz04T0*OTHattBG6VEZ2t&iD zKBuJeZFPB{g{9p;`Z0r0uw1s>jHW7aGLg+wn2ZhW+Rdu91op$M$k(|mF;tDhA);Bt zJ;~F_!AX|Uya*r&nIYbCz})AeHd$x{H2s6-G#-K;z8YsaXC)QTO$n!}5UFn8foops z^eZI1ODkKxr}i{|+a=@m(}B4A)JwrQdo)YMr5;Q#ZtDcGZho$d&H67zAQ^6A%T!sL z{!wKG-$E76XV;@hPD(Q9^#T@$vW-Pi_EV2W zL;k+Tbh@p>3@^pUuxb4pKwRD6gpd{?A|%M3f1*+Ul`sVJhix|{ro!f2 zc94M(-S(V+&98rs)i|bdlwaE2fFr$F&qJ?_m^1|Psm)rUF_Jj0lN6< z{R^cT0wbEd26YJ5?J6xB;4eWAG-};A7{J|2MISuw(<(9k`+A4JUX>%117{NpV!kqn zGo818>%i`2*6BLCiQo%)YJ?3fmZ$a_J*f{IBi4)kmq`j-LM_nFgB&! zfp%-p`zao}hzKjGgNh4}R!XyX(S0ue{a4aFt7Ye<1*y(OvFm--MFHalbZ3A6^M`@j zUkV}c?$rwkf{Jll;_0iwi9jZqK=*^wLG)XH*b4Wk4!ucvn6n9PYx=%e z16j0KSG9cD@HpjL()dWW^711sDv2$C8+ z&qHNAZPuP!tuuiGF z;^)XNL?5~ZAH&ou%nv;A<%l7wXCl3-L}XD^-t)MQ*>L&7)c5%ATL2|(7U~*k81mG0 zadV{}F03QH&_7IqvB|x&6^l8iB|27E`sT(laFf54qW9K9tl@yA*>H-xI+gG53|l!L zk8i|gr`OFxtZ2R1VD6Qfo6C}aF-Jg*559iH|8%4Rv^?{SF`Agqw$=U`JmE1>>qfOT z2de&nT&JULT0au;IO1(P?e#8ff6I;VH2ht3x0@L5tI|&wdqLjq&$q;IsIOfyz^h8n zLh&&-=gHHjF;D3xA|_w!LZCyvr-Uj~Y{{U1Dne_%yRj@j^V28WbUUDb>M6x7Ap9;? zmvM08DK@kaTcz9j(L%R&`4@+SLGE)FpO>)xx@XUNB(xqRXx!nF8+v6@MKH0%J*vl@ z49UzQghBUae@}pBS4sukmy(^bjg#M#LO&M#qMREPzQ(ajnh%9f3K`c*M@e$3Qhk_zo=6CJR6egN1gT{S@Em0uRE`Y zs|ql5aS2F$^{$I^Q3>en9USH?T2Z0{_URCBsJ348~6)zsWUOWJ7*5B=?v1pY)kLuG&3B85#NYCXw5pVZNivR z+TGKvzde zKpH!-DEom}ZcHK~ee7;a(3XgvJ^@uq=yUrS!l*Z0_Pymh?ZJq@@bHu%$<8}_Rrsm+ zkfGI|hvk;rZ6ifwUo#7Q2?o>uuR_~$svo1NHyg@H_;zN0G*6rP4N;3GLTy z8-xyXrFok-%f3%Wek&$hEXc;|=iZD2WX4^p3T2PJV|oHTzH@)CoJZ&~;DFKeX58%kZXD zie%-Okk}~E3qB0<1>JTXiv>&)`?{)Dm%dyA?^SMa95BT6m?FTZQe~rv?%{P& zQ}7A>IyC=vnkF1a8?kjQx@q1v1Ce>K;7=l$fm~lFoa(>WmD{?9kSC#-46_T1vLD*u zX(Ke@=$>U9zFqobUk44HGnr5QD*PaWZRo$Gm=x}1Nw4tABND*fO@e2Un@!_+EbN(* zdZjvlU6eHUGdC-@%SYP0`6jI`AA0%W4$Tk@;|S%@3IE^RBWksaIIUUt-`xLuW3HyS z;d?`j2nK_)BJs42vh9=GLRXstR6cFM>jGTJFS&! z%dxXv35|v_xMp93MP3D=$$`!haP{hSt(EnE2^=X z?Wa`4ci{r~TEc^WGFcX{B)x4~{d?V7kyEFR_5ca02w1ENwhj3q@i3oMQ)1-%7hckT z{~WxnH#VeN{@Hw?`Sha9+3!vTZVj_QYDm^MBg?Q=a+Q=Izr3tlKvEaU)zfVBsaw*7 z$GLg|+HAVx)i;+~KAMO@BhJi!sSjnYhoRrD3t!61{)8hwH%MGXd{_6yD;ppe(SpDC zwM`$&1}r;vQZW^IlJPqL?wOh_-!S8UV&ia(FS1WwwWZ#StYb=JO;@pd0Ld3gg)Cj=aXD3gWgZiTH1^_gV1gYwb8n)GO(Uk?^` zFKGrpNoBl#kU5g)nEHTsi2b8~oXu0g2iil-#}}X)+gdPob3f;U&5C8+C*}coXOPC)^f zRgR1!qF=3B>8e2fE9ur*kQ^Zs0TO9g6t>3&CP8+vUESqDL=y!~%ncef-gl48FrwSZ zk;$No2Nd$mi%{RU*F`sf9MCEw?0*{%W?{hUSEv6>`_zT9V%3VmWh>St`T6LaZvANA zaQK~Jim10Rntw@?daYISJM~V|i1!8{x&fXWb*7`9H98X-~{(#ay zenPHQ*9`NdXOq zVa`DI1C_>>qvM<|m6;<^-i4CRIggtD7k2QLRFk;i=h~+$&HsISeMe4u)WVd|poxn) z?`gf#E%}8M87!2(iBX=+TRH$cK*Yai0CPRZ7j|ZXGg$7kWQP7sm)lyJzM;d`W^IL0 zgWJgy9P&c5%KDn|_+j6Af6a;m?E~$f8QMRWKHtm~(m`O_s16%O&9C2@st7J4KFz^C z=gl4+9vJ1T;ZxO{3yolX3Pk8r?Bo~hka5$3)Cq^1Nf=8ghb!k~age(Q<`#fr18>I- zx8@0JjMeA8ONrIz_L7OMtG^1_7QB%@K>p(lCFu&?VuHcw%r;Ihe?@rrRQHR^sR+bD zMeK89Y@j@S#n6UQP|iRd8&ndd{5!7@v976y9cV5qR_E!IGRBRRnVO5!kzzc$rE(^J zFvUI?FK{xwmxcZZgw17O6sIojs%F9D1cc{MjbI{`fbUeZ0D07)S%x1Py#Z8JF&<0OrI1#^s( zr8}ZSSE&%M6VB0A%bhr#==%H@*fTqBI*ljxow6PrhCpk|O#|Px$in%8x*v7qYXP zR7aTWdH;31S%xF}zMC=rGQFhHiBC4)b(SQRH2)(S&T7`(P=I(L``&S>Uzz7(6{F$H z7Z7N&Pf8y|A>2_nEGn*hi*+6_!i(iQ3;J)N4dZrJWAuo#0v{tzWlboMy>Jf zReKd<1~KL*$x(MKkJ3ydH#y3F0j`LqVbPtowrrfSf7ow77#-+_m86Mk+Nt_;e)&Y{ znek4uAgoW*7OfW-`e?R`2bwt6AIjX+d@1v(#PyBG;_n@XPoxZ0p~w5SxtWZEN0;i4 zX{&pnQLvW;?`=J5m%t6)G(BufqVO*LidY;>k;!YqH{U~Z~L z?#y>Rk8m_;^n9`#Caf^XM6A8S@zddIVG_FC&m%@iDO)oKxBrx_bx0AbtyX$&A9=2S z`8IW~k@^7eE^)Ovnb~k52H+pYtfcLRYZ%NMT{+MxCv@%UXTPaL6zjN&ma;|IXG~z! ze;zKoo7j=#eTsSI>Pot|vLRcNIN!min*18m zL?HWuEZm>2HvYkis%5C2M#JKt^=GGhZ$+v%8j1#rt4Drd^gkgArGbi*&mx!IkHFT# zHnE{X1h?%vicQzS6YWc8%mBW|cL%v{f7Qsq8J`ef{CJG@qa!cjZpx+zAoP58NgBI^ zCeOqf_9)OLH44SF&H5*49dG%|_t<8Xt-pSo+Rw4?Ld2GyOno*b{ra!xrc0yDa7UM~ zLW5&SIB(%#?Tji@tyHk$V8n5e&bO9)%)L(^#S>NUP!~iKhbdq^k%mUIFg+X*?KTfAluXWHqCs>&0Gea2m_u4K?-_kl>fSR5FY4uWFJp z4$Mgc=5{PICwc#*aM<#5(tBf~q{3PQGYLfJnh%<>>NhlWOYkoiyG~9}YX(dkjz=VZe|T-YLB4vP z0NCo@Dzc$n8n_XhsS&Bw_|m>2_7fZXiLC$pq7hqNZ~i|_omD^+Uf736ONn%Yv`7gO z(j_Gzh|)2-#z3Toj1Z(71O%j8O1ejkl#-T_+vwZ?aY%jt@4NbL&egd)@B4e7=jOcf z$3`iZ8oV3g_5^{+=_$`Ie@2c*(8)AVHJoY1VdpI@9!zmv(vYl?u?^&x77w3Lp|EsD zHa~&meTpmYWxV6OvBJGZ^vtePQkjrw%`efYO6SZb86m$!Md~0FrV8Yw{l6`x-VNu| za?s*((FRm*N}ND)S0%RguGY+Pt={&IIg3CWTBd+6sUia-9A@Q&e@@x262euKhKl3* zW^a6o4{dmRf7R3%cg%adg<=O+TA?f20`nXXj@QFv_Ws|;b!VLHP}3e<5cVF>XF*)y zO&CIljVzmtY|<{$vnb`&j_+l=YG`_56msFft`E_=p^rCNaJc<;G0FNekkN2KYE1Jou<4-^p%VDnY7yYBgWK_RQr)>apbs;==OTb48)|DC* z{|v&xwis9>cN)dpaT+V2IMY_%uks@dU4maB)jOOerb`behFOOX*f3M^^(1_d`~cK8 z-_2Vt4dk6i{E$%hVek0FZDt1q6g(pvOz^F`vINMhMqbbbM9;H<*&S7 zssX$_fBrL{*2AO}RI3$}O0c~qFJt-Vd|gG*8DY$VF2a2b(|VH|i`DCHUgd6~c)p|# z)VY>1rJ3xX>81Z!ZTVgoDy=RFqIG6qv^UV6M1ml}% zH7s$WpQu8j&;X3P$0M&}#S0&=3+K~S3*c<3f1Qs^Hz?Xe9-p`eb{^}j~eN_whW~;TRF%HX`$Qq14~+(XiuDhjQ~uK9a6&|Oxu7` zaI_WXxKJBx89F2XTIbh=zUpj~q`9hm zfAylm!p9f2ytF%O>sDcQZR}&hXzwEe8F#>zl3>WFOzS@#tXI^H8|v6$#D+E2>AxaD zoEHDoV2kImNNY$|{#f;9H;Z{C{|a|Lyg@ISOCa4<2FcQ&^V^w+uDv=j|D#B5o+iUp z+SoI9jHcdfBs?#1Qqi#sAMSnEO;vu#e_{|RxdMFTUa%sE(C9qFI+!hN+na}15|EK1 zdN3ujvUT!k#<-)4_BnWF{v#k8F2bfQl~Y59`?FL|g0ApcFX^MvH{Z6u0^~Lc&Syj8 zIBEW9+t2(7rFG3zVu(AOj8mosc?A~Vw$Kxrj8|5=F+EA2#5bMJX0|Tm*Gs)of5dVQ zQn5lMa6xW$Hc(w0xCW>m07f0Z)B$LO3tC)0INrjSjS%B>r#_jeKF{1~f{JafYyfV! zb5Syp$D$z|5Pq-;C|JluhX4GyGVNz;ZO#5Q3p0<)1h=v6VGrrrBCHQbja8dQpAJq( z!Jap2Z#F4Y)rp3TanOs@m32o6f2N4pu&ib6k<O^#~hQv&h~{5_ldTzNIF7 zYyOmx%s(ky-uSW0UC=kvHq%rn>$SoL?VZ$GF=0lx2FUX?jGS-|EdGbtx!$G_ALe#j z+3R*#PjtwjOaWu-_eN30eVig$ZeB5HX-)M|ot&>qDZD_iMUIU_OY(XVe>~{LGGyK> z<>+-h>GK=adExa-&~+ipIDAnEzF~d+&|hoe_eaxV6S>QhqC-<<$`CrYk5OId4TdJ2 zYDm0Ml6L){gbs5l)_puG7suMJ@knhqz^;42H+*(eqj+RiQa8`$DV5ECii2jT@@^dd z>BN{55-C|j_&04-bLChif8+7!`&6Q*_)o)vuaVOOV6p!WN(0ol537(0R9!|8-#sAa z50I;Tm~1%16ycS7l!86n(S z9z_3ZegM#p^Gtk8NRhC$Rqza_i_IM=pzlQrU^Aj)E;azJY9}L_f7p~CEpeBwCofqa zey@^_DeikQr zE_O0?-s>I#RDlZPf9OI0cQZ>N3`<-ED8heHU(%rxScBM#^V*{=joaHstpX8tJeu%N z=4lEd>eq%iWo?`gI++3zb#R9CR8TIo+*_3XuO&xL>d(id7FInfeEE1?@iJh*^`B){ zY>%j(Vi}K0N}zhUfTA@>+`oU*Ll&BKoi4E#zt@?-9z=thf9y&Jgb8#YRl%5O!W|j! z)FuLX6ywo37(Ob!`7COx8IHb|J~-1Ac?H68j^*Id30tV8D4=Ai>7GGbkMRA= zG0KTbk{}DG-q;(U3-TU7ZiN^J6UT1u3O#B`8E({WvsB5Tzk&0`RpLNa znEF^hAl3e}=}I8ZUy&s+DcARBx>nkN8b!l4fu|l|f3jbgYVR*RIR+tf6ofjZzY)c@ zw(;6Wd_~djmpnmuQ<}4*!04eMBHe-ZqI`W0>6FQOozu^-@~(5v zQG>(pNT^GMc~m3&BauHZK2h*K_WTlzne2^cd^@iYTIGHZU}#}N+<{57epy`Wb%YU_ zD@;mLbiePQ_WUx|-bj$9#ew(TeVqielqg!*e~NRe#~BR!2zQouAoaC_!%5EQc22c> zvNK)z1Uo*PIlcF%KULxI@vc6>+*SJ^<;O#R94^&Yf}~jzCmXNkf(tF2YU$@2>1if- zKK%~gd+-zw#PK3)@MA#OjdH1L&c(lCJyGsCba!(SB?w>2_3_9;l zf2=kak~yrb_5%zEl-VXir1>Nc{CKWo%;k_d z4Mq-1ELFVU3JB!&$bG7{X~fmB>xZ_HlXpPCj(qc1C3Vsdx!FJL59aYM{@&2+qPHh2 z5^nr3T7!S*bZVBZ^}n0fjwX0~?fvK1e@Q2AZld1vD(r!<3cJvSuM6852os6~#;0$d zw3I_m_2wimm+ZS`er@cVNU*6pM^?dzo2s9Y>T|39(V z=0Mm?nfs+T&{Ai0ggeUWdh-|Ynv|odGuh9f*GvSz&@UuQj|uZLw(r>6JLeVLf3R9w z5r(Ovd<;viw9$ksHH-P$Pe{=ot!JEdt_1U-sSt4p!8@#RUQ`8gJhK`5Fk3A9QJfig za#bt3EpVMQj%zB!cuG?OPpTRdFhmQPMtVg_#b=2Hc{?z5@`i`}ig4^p9ex>yjyt@h zde$pDEcY$s9Uhi)B);1ix%QD=e+(5Govynixk0tjU%D4yaF7wzK}|MF)Wo=afcac{|)h1$5`ee*$^cn7#Xf89&~C zc&573VL7N}eg^U&=O}?lmjo*-&GegFLYPInRRK&qOa;K{Iita4E1sV4>;D$KEE)dh3$WdK zXS87n_aW*|A7LN7f9|f#4s(-EXr0*roEvFgxAW=NuakXbInT2&I95iGtF+QQN!G&r z+1`qKn>RO2;i~RX&XvVe{|a!7T)J54`EdR+X-C#i-V$=+DWbwV!WOx}+yxz$>AMFw zE^RFh)<@m3;et4NHoL$|JC@cysH4vEyxW|7`xT321?r!?e+FokB{3$Z7fzdOONfG| zkajN^_f0S^y3!`q!7soWt072a+Z_I@+eJ}Dy#0f`mp~5`gpL84Qks?J|GegkfA*z7#IsLs#t1 z5MiW$b}m-{#pku#lRT=vYy}~V*JVIsbGm<4F|4Sv-_DDoaXP?n$7YJ>-8~>PO%F|y zkbiY*)=TOb50{XxErNtNFScC2u>0k!U(22!U!*qCI%_4%*|m|C(IGl?8Rowg*p|e# zbg@AOe=@}Jn9M6!qQ|QRk1u*aTA%D9^jmd!o{yBRG&ntt8S%Z2m#0^mT75zi4QbQD zt`#H-Lj@UXYurL8F$;?dqN0CNIo5!a#N?!&6Gj^B1FG_MIxAhS;}SW4PM7~FilL$h zDc$ZkS5|RB?=ajJtD!Eqv^O984#%LEX*&VCe-lQm9;Tpk==KlQp+J&jBJ|-YIaGO8 zfdq33{B>iuIey5y=pe(S|MqBy8Pyea!e>1z?(CK@LGW_)Xv5b;Ec)2xKb~uQ**4z| zEDd!raB=aXioY;>?uTrLO)@nryF259=%1@H?7u4=9P1+TQQ>?o7Atwma`QZD^7KSR zf5A&2!LeyxLBQ+#F!fHlX`u8gg*J2gmyWU2AGwIaNZ8v3PibX_YDorTok~E`IR!U7 zANH0k^e|M-=Hq^T6h@vg$p!9pSnjlhq8Nd;$pR8DDIgt0YqQxREN5;ory}{JkHPLCTQ^yskyC6>B;?zkC9^ZTV^ zYx^gni*OV(uhDnwxMAC@Z>MAilqwv%n3E?WM|ZLkSxsq*?kI+w>#ax{MY(0eaOLf< zZ@zsxT%zCNpiF*)iMmmkERN5akza9zW$5pAPkwOkIYRasRk7j9z0&f3vh5 z>vzY7#@_?zp>F8CjaN&5z^_*N7;~hi>T9hb(i4-HMHJO+Sup~xt1Hc^--TUAo;W(Q znz5W-6eQWmWBHQOOZtnI6K}+b%kZ^`Mk4DKWTn2eCN$PK{hBDPF4bvp*nPkC(3M~> z{*=|-LMu*5bZviTqCyuoQybA3e>KwIAaJ%Cj~7ClV{8Q3>{hY^TT(5c(cX>Q{X zhyn#svv)71en~niis!NujA+|=!PDc9XbZ1}J%@O%owk5Bh#=pWP{bu-e~}6r4)~+j z3uTTeTzHOgy1q{GjZARyt=qJ2LB>cW$>8jYmU%JHZiddu8pq))bA?4GqL1f5r#pdSm|p>2XcESoXxk_0p8!OvLP%2VcjdAwmxmsQ}0I zqt~e4XsNDi?qyw<>WQT)&Xu}uq4L#gQuSw%r&0{>`p|%9TOmEJ1Y>u6X1HE$Tm$w4 z6&2`X8+=mw9`H73tRa^4yQ2SW!G^UOh zyp-JDN=-WGmOhX?u~^Z^x$H8)iLY>MF$Ofph7@_RStQ5Qe?N3;Cr@LLNXf;e!Y>^JA&g`(w;>-Er zTT9DVZ#{^3G-!qj_zkt4o~i@bX!kbApydBqnbyFae;`KsM{5dHt#w|x@s9gIt~Ajv zu*#C?oZFT3y7e!CGrfO}yQ85DMRJbgLJKDmq>=NXyAm(R{30-_tpqo~6s8qvHk{L{ zWWsex9<&!GYo(tMC9oz{86tiYm~Mpov}tzp9D^Ro=WGj>+n4N`J|@3?&0f@<{RU+g z_u}@re=7~XW5xFT5#Bj07i$svd3OCCFz}Y6V#|G<_nINu4zT>CDPr{6ra+RivOO%3-Z!6!`t zh8_u_#q{>E>)PPb>)h@Ev`RetonMq#=XkM1W5LX2nUdFh^U_-n2Kk9;B@-fZ9d1VA zel7YPl6x)h4ulflJ;DJ8ITV`~xV{|9e`Q4;ZOvU=qrUHW(=YT<7wgJ>`#k!a32zeM z3nYb_S3!w!jHr49`7n#=4ez=#wq+z=BtWBr*I(RISNqYZL1w-M37O1uMio4%%{$@S zHjg`g{g4nTC{x;1QNgviq;8K5`IC#79kbDF6AhZ|SL4hQY50!dm2;NM|KR9`e+$=z z{rK<7Ag=p4=Fs97I@zGT?YG~ggTWe^+!L2&%1SchFDhYdZ0VEcz-8sW0s@#kMEZ2t z0$M=h3#4xu`gX^IjVPO6M9kgnosdOx`vQd>*=w0b)(fd(ST9rNTW$^BH8x`yicmd9 z!#nDwz}1cBrcRA!4R4Xb2MS}ge|bE_zP?l(3~IkkIQ;6=))}!RaSHmuPENJflTXe) z(4E9rV#YLYK2~{grt@XzJz#AU!v7EeUHGSb?E<+*IxT&+o#|%fFZjI<8D0LD?mDSEXZRRlh33R79{clQx8lLf3 zs%B_c(gM~}G~R)9q5gEMe>>jDzi0l>eubc)T?OIGoB`TwHXa@SnX5OOI|>c`Xv95W zGxJX3EYy{Xt~4jddJLUM2nt9Q)@A9h9Qtr5{FI;pX`cIy?b*?D70yDA7g)3#C#?cs zxg}7C4dkEV?r%rDlyo2z*_x8yt?$*Ad}{gyA~YD?2jfk*fwYcPe~zOn`KK&{5C=3? zFY&`9puNqhMt8Gq8<=RQ!EDO{n|_(9@5)Bds+B1@Y!IY$mt z@v68_p+0`Q8eO*>`X;EwBLI~FKML6ATS(3V9CTA+--C(G9!&(0m*MJAJHN-DevjAf zl9hU(Jtc#Gv?`U?9nO2elL^ryo%kS1WY5c?h zxZcaqmKkBM3M}J_OD6fT=vv)w%Ue4Clb5U+R?oiPOs7aEf5}SXRbd@2a~d(z$MNN^ zAIr)n%d?QRN;;Gy%e(C|93DXhqj6m6Jk)INuiqVgee_yb5 zmA#)`*Dmv{f3paRXx`rX;$@aWm-kas?oU83O(9MTlZ&DnveY>`>Xy2MT!`WCvjm(z zHsfBS(`$IT8#Fa5{-z*J>Y|X!5VLgDhWtAD-?UB=6T9PwWV~Z;QKr$G9hi9N!=4X~ zi7tGHX7`!2zAY+kX~7BBxLG(XBgn6gn_ba5CWOR6e_v6q^GO8s{(WfPPwU;G`q;lQ zHx8RU_@jBIaM;7d=GtegBEQ>~T829dMrm4g6-$NHUyRYf9uSEe?th$J!}aRSN-jn% z!5xP0jPt%p4F9})X?iv~L>~~#j82?LqfKqTPN6VBJRdX^xS_aA|JyC9d)wy@ANW`iby005Dq4!Gaw)!gqqpnV^KIIf$LANJS?)IT zRj|*{bUfmu)(Mk*sg3dcV{^E_) zfKDYS;L`i~bFi*y91Zd#<*e+u_t}MFt0#=d8Ye#hWK^(d91niqdAR>iqjc6Ye%h?| z*glcO>G^Izhl7pvwJ^?q2~{yd^$2Pj#qEl?UV)E#GE@V}CM+ayRKQMU?B zcAD=Np&C|Z(f8stnzREI?G5o;&)Q2r_!)@MA6gMpUS+1QKvPV ziLV!f(o`OROQVpg_kghO;qDC+tRzEdf1_vts>gmUM={7aVld#*IQ)TAFXOkc*ZwMn zoBrx59DZ&!W)R&=9bA2ooA6@m9XIw}K4W8^HjShZFN@K$$82{ejGiR54hI~ZZHq7k zt3!VbJfO@)wUWsxq)!tlrywfSq5gC0Kfghpe`<}= z4cz*>)X#RMXqbVK(vu}A zcE7C<>6NSUC#08;RH^G9&R!$}QEdr+UWq0H$WSC_+KOdE)415zVhM)TqQiJn=IL9h zu6uxenBr6Rho|Z|r-xJQ; z1q`Vh1?@p6MJ)qn9d#D$!m(t|sL4_5|=sTtzOsM1-pqp z$M{eHA}X_Sa(i5{b#cztdO&+S^<*^BbbPsub?RR z-E+KJQ7>aHl*EqA*JbUKZPP@$k?(QW72(w_4h&kqMU5SRYy4n!$3qJ%9x(?gZvi3XE#Z%Xa~ZY`7F&ZRno z%TLz#ioX^wQwNf|m%l7!UGdfw>Bv)fcsnn_Ec8vKdk7njRw)-tXa&jb7c5*JLl)4w%`{8@bDCH(_?q{L zOG(8xN083aEaNM z>JO%jE#nIM)*mlQ0vQhk-6)ng$+%itlBVA^bUmLe9PV|K(64~MhkdBb+94yCi%B9E z>;wFZP{&4=okoSQEw2x^%Ed!p^)xO>vR0R6nknqge=GZOS3Ry0E~TJ7< z8>jB(*w7E=jBi;8N66CC+kbX{ieDVo{w54CEYt@j2zEew;WQAtrVFRg5SUE2H4jR0 zvB1dzyz_L7e$cgYs!>QH`q>MUP{~F#QMC2-Gf4WI?s?dwy?i0J;zbVyRa9WfyhbtH zp-QK7e@B*qm=Np~;IQ4e6_KJ%0^rnw5EUUT?d`Ed8Q<`S@ z+l`7O?$`|i9EvK6r2<~S1Vq^A6diKlU6V<5wd{3_Z^v-l^YkO^NzHCPnT*! zmB7J-({-taTo3(i#=1OnVQisV-CGl2ojIgG$cj7o~Gv&h* zaV%(vOPKRrB;4Hfqr;CiFm1=6H%8{!TfR9Ox(An;*G-qcx8IM7?e~q#(>8Xejq=?_EN~l_CDhD1Tex0t=rLAy3_+ zjU4sw`7g#I?!&>l+C9xJuq?ffe8wc9f8X@xoAwVL7JV{O-6f#?RM2N2f9H=}JHYe#F@zxkxaCqRIr*+qf3VuP zVYntZCqYoMP&3-iM77N!?3*BQ>whr=IaI?+B3?&t>gH~+9<;7IEyXDqR9RkGSZT@2 zSXyS8IZ!C+LVh#$^L}F}q>{qk)+gLaLE<&%6nv1@$^Cbaf3eB~i*r)RfOU3OOU%G@$zcfV{JL(nr990~7K5vO zN&m=%ei^pmac(;c6~#@6sqU{o$1d>BdqASaU9e6(uG(~#V8!~tsF0{OrKD%DJ|S6@ zJ7wT=IJvit9hS$U8P`#}au7;InH;9@rg$&~_ZBWg*7~<|Am*ky?qp0ge--_>!RYr( z)hJ&1W5Dqp-`(WLrRWwa${WvlsWlx-n_H+I<`|qX zAp=H04^hg4$>fk^o9+cDLGk(_3T(05G7f%j7^f%?w>Xp=0fo9$Hh%q--C7faXR1l` z<X&r!)iB{skf5C^=g)wdLU#D(urI8@pnilT!~!^=Yk$BYY1Xh`!SsUqOaLs`AHV<82CALG$k#Y1U+C^ zigS5)&_)9H30Xi2HU6c*g*k-h^XWEJR74}=-*hs%)0niF?dv-`)Es5 z6GC>Q+Bsti#E>M`)z;qVZ0=>6_V&dI-3mu+0e#6T$V^mTn7;iThvg)^@n5(Ty|12~(a(xJ4RsoF9^{?9#8YqY8vV?d(CG2$cvOtv=VG=h_nsK|8 z2Q-kbk$k33u^^-ne?-6Kvq|guJ)mM=1YkcWGdkI60-lHs!y5wVV#m> z?DL+@J=|l>r_>-sMlQU!1M!H06li#u6he7OYXRj#TC zg}+x(a$4+u&X9=gss_?oAt09UOSzuQNT2HcR=s~2>-oj2eBG%PCle})U7YP#!sF>V z4cF|4CIyteZeEdUhj4^vs>n&tWei{VrJE>GQkknpr*w$PJ3h^wtNzV@OFUJeB{{Je zC3Y*2f5pOjZ>1YwJ!Oqt>7)171%DY2$uNs=9NmiOQ)vtIwsiuA~{e#-VXFG-cw>&0RIfHsz$V4zJR+m zO_Ww%{|5QKxU5#jzi2{J;omr8`2PG{Q(UYHfA(d^yG3kN_v&F|UzP$O=pG*+=1o1T zxmz#kadfKG`0@~c#p7r;bp;8x%>q@}A65lS`F{<(%s70^d8~oHRpd+UR;=0YKdSJw z2gP{RMJ?C@#(B%a4%E^SVG84eOFAe6YBOB*parY~;&rh`V4w=}H?m~f?QKmvW9TDn zfA~fn$>UBz&-{^rx;Wnlo|J)IiWO^_6c`55 zNRE?3#2n|kjUmEu{TZ24GI3j6ti7LO(Inf~-PDUcJy89vF{}%UzDqXwEylAx6eKay zvc}Vq$rPh-VdlW2_|;p5@$Mmhkp36qOa@c_LgdDt(0=3;i(aJgj{? z|8(}2r(PcCvKg;Wf-)GO)R($-Fi)Gdn*UGWP<|iSMLafxx>#Lp zrcG|o?g4ggEP62(ipof@a0ZR@M^9TTr1D7~6N#`r@kzeAs;9u5#?^$d?sZNgf3nMR zH5N@Zq<=n|q>F43D=FS*{QJB_>TleI+ljf)RwY%X zyDb9`kGpn-!WlqOr$#Ii$O7e)iC5rtSy()@NxM5W1}p2wbpQR3_mqhz*546N$UsKm zF0g^uc8L#p4`u3|7f7K(g!QLo~ zbDZ7nxlZh}QTap)VkF#jZaH0kYh{&V@LdK6? zk)rnIwc*7T|3qqZne5Igsc0JrQq#Q8eRe||v!dtVN-uDxWo0NEjne&;;n~954{ z{SKXeyWB9J1fP*wfFZ67e|xTll^F;o@9R`sHG_=UEJ}fPntIwYsYL%-F58(H!D)EG z-n?b0amkJOL8h}C7=vW|WgP3J(@bd7ZW?ODq^onj*2d$iz$NfiA5(sNEYBWbSbPYo zgC!X@`8N`MkxXORxqjgA3M4|KVfcAO9Hu0H4+t-?9{caoryO96@G`AY^qX{@*zC9; zN&jYp%%xLTNeLVlG@R~MJ6m=KHkJ(r)){8&ALdnQ*|E3u3<@I`ia$NZm$6Z2$b0`D zGU*<&{*eW$jSU@8e7qiB0u;A!L7+1eUw_UJ?JzC@ElBj>B4YhN38#&u;( zes-CKesCxld|6o(e9SS928-^%FWoRQ zaIj!$%lwVLe}f$o$?HMT?`dn&{WT>tiZC}Nb)$#+;q(oWk=t=RHIf zbZf1CuNJSx%j&*j379?szadj?4mq<=pex2-_|KP^{B+8(CbdL0_kc6ez4s7*_)jBd zKd|vEy=!{XV4{oXI5KV zX9)^?Z~OQTYqsJl^0@G!K>ywI#Sk)F&~WF33@s3{J1ld_U_wQ9+jLE0Ajo2Y;1w*` zNR|lx0U+LTw*B6r6pAj0{umJHC4z$W%u9-~uZ%M^ga(2>?u1gU1P<~peHebwGhesb zSWMfZe~ZMA9BV-`=P#+XV2O^)?yAsvQXKaH{W;&|sRH)0O%s;PhL1Ch4=06K9R-B9 z$)5hYZIr+BR(mk-lF93y#6h#Qb%q&P7x zj7=4m6+TvZ$ibfvq_&tu=B9Z{UduFgFf2Z+35CVGnU&KNK4s1;AcU@)Rt_lmp!!ku zf6S}Kxq>B=A6}qF;q>F!Ho9`wx)fpfh-OlCNYOFV8+l{ZSyjCo{L`RgwyyEUSik z9rP78V4BSunnREzcNP42uck9~KO5mu^qm|g5GrUGuM zsH}-0&Q?sg<3HnVdt3h%fe>&Kjqto;d~c=sm69j9AdGBXFnMsJMcwK~{+C$uBBnKA zi6uX;%AIkNRqOqfwHo4Ialx0?^qJ=prDcXdp_P$T?oxVF+V0N-WSrXgB+#kFK)Zm?;Hy%FaGx9 zfpsaF&cKU5^(9J#2eTDXo;Q4VSq?>Ra=vK^7siwu=zY?VtX#};{{skIA`gYq}=U} zc~(iXzfA+17x`IwT?nH;k*XC~Wr5e_@x*dBO*&aD!T{ttKG<> z#7OHCC4m53+gl7HqWFj0*@(yIH68B32EHymUWXmII+bCce?5@;yW1)SRlgX|n&#f9 z{~n(=#SQk@Tng+o;}Z*mJHO_+(%yOPGw(o10K*|Sr)k9E6xgu@{c?^UD`GB4om+Yu z&NBtYn#WSEew4_Pc5D9_?TTSmA03;{f)pR3j{hvQT-Mi4V&?kt_0kT~HgxSkA0|xn ze+Nk__OdB@auG$H4-NnOZ* zFz6dUFv?mB-f0@u&5YVg(zVtamYDLioh_pA0+9z?ee&-4JK^}D6bhGrXPAzB*tNNm z3zk9I&6c&nUgqpKr+7(;m>|Q@IhX_;v-1_;51w%AQAjX$Y z_knYGpm6#SLuq3vm!pqxO+rJX{0|9a>n6R_&%4om#$zX$m^+3WV_X@U^3^>+H(WNf z(P8oKXI4WLMQ{6=dbN7w!?LlpDfy*dT+{HZe>;$-;>SEx`_lBX`~Xv;kyKTUqrr>{Q$9^g71@dFp94fR5{*TEPe2lDk#DdpDh0e4Ro1N4$a ziVy5Z{>q5XbQoDN)BhN3B?1H)3>465T(d!P2VpFWn-Q;n`Ia%}bW3Xkj~VosZ5NS= zf10QfmvkSNh$!Rt&t3-}P9xq9%iROws0OK`{X3(>??`afMoqI~H@fxaH3?Y~vN1q& zO9njZv3G&It7YL0Z>ON4{V+xx@LC;8ojwGkiZ}dE-8WZoyI7kHv~>F0{F6aqb54iR zsfG`ZE3Dztoo8yhd#7P{0yt1r$fS}af6fxU7P3A9w~!2Vac!yoqS-okVyFw=*7n}z zeojp{p4a~<2~zm5X|t{trM!H4N7MV6p%c2UH1C@`6N>C|>S+r<&==jxdT!O^VQjX0 zZkS=Tnjk8aic1A!O9pz`6~s{s_khnbNI2w7yPGRC$bI&p@#Rn6%te#+RGwGbe~|Pa zt9T>{5NK#Ke=oY10!J_c!^8B(cDtdIBLm*<=WN6Z3(pAqQL>wVjuqW~I4Mj@2Y3G_ zuGDhzcSt$rU)HfWrs8OW>W=Nu;zGuG!}yni;gY}MvW5?B>5nPq@^$IB`0DH}SbG=a<Pj5;eN5;NHe$ZwH#N7jct?k%4 z7>sN_Yp^v7sFEp(wy#Df4$;-ZOfz@dXT{auUWOy zHK#jaiNRy{O>^>_piI~7H^v)KE`K~@@?piO&`(>lcf61$-H11BsZjmzR#w7@nwcxF zY8x&7$8FkZz@Vc!z2g*tpTM?kM=2x?ZrHUX8MHwqH0?!*pp!nXQmie{;Qc%M#PoM{ zyFmUX8|?k{Cqi3-3;D~^sL+^=rsU*5T0)Nvq7VdJ)LXIqU2dgh52m!^6n_Zzgjg^A zI~p+efaB+-%}r&^z0R$l`=4=TYJv7%eP#kQ=}_}t42!Q}QFz zZ&FEW3B#+dOcnPCf`%*JEq^G9Lk14kQLIm(%EZ0?&YDi3+$!#c?#jPWD`z7dJ0t~N z){IF-3kverPjPDh`rytv^DJ4P`J6Q!)kA|=Rs17)h)SHUq5$dZ-S+^k`HF_y%9f_O zb!(e_B_3?cNe1{q+qrgInuyILec+~-2^{a&w_W-LIGPHlqeP1%fPc%~_RN~?Sxok< z{-|*`-bu5T0uy*nKb0b7d)SeT2vdULqRaO3i7@)ppAmX%xN5`b$>2iLmu>#7C*t|> zY^j9+2QoR|`jk6G*MIh_kW;(Q1;rxChiG;%RGlCTF<&q;0(w7(+ z?CgkH;gZni37@5q-J%jjX)1is2l!Zs)`=;omrPcbruMbUSTvXaeh@3dknWvHm_GVq zq*4(CLOKn_LzGeaLk*q5&-E~VL!X65D4zsnea;NdHUI3NB7dr~e>iV{;|f;S#Py)} zFOZZA6W%sM#QB9+$+=I=^=$p@7dOEK3S_f5yYC=GJ^a5^Wax~lcJTxIlvqD-pk``? z6HS=>5_C33vWqU|mo-g2S7Gc95Kq^^&GCOux9UO8Hvi5+@vicAEQwP0G4~rj0vNn7 zp8UW`extz|4uAC#h^1g;yj;v@?(~u^6nd`9k!sI4u9r91d=Swgk-Cz75o{r1iOd>O zZtikjYP}Q23d);jN#s5$W9gP5^^Ra`&?<4W zF%uwh=A)J-_G$h_s6>KuVT^%2!99PK@78>+Om+aHo`2n)MVfjkogUTt9?a%HFp$`f zi)0#=-RU){MovfgU$(&{Fn$X^S{xT)PayGzZg0*UErn!{xzP<|5lO*8*uU%mx;GjC z=mM526#4kJMuELN`1`^vam$x}`mWnzE{cFGifK{lBVk)siH}=J8E9a0R!r?5gJce| zsN>~Y;eT4&=I|%Q4!lj=E1G>Wcq-N=dJ+Ajhc^9nzi*Q-7cuj>ytL~Yl0|XHCXvGI z52>(rKW}BI+^@1w47EO0P(55#Jzrl-__~g>?X2gy;Q^*Uj4ogAqlvs@lJ*f^A|`z5 z-{}bF!y5(9I-3>41z*;{VijJKw$?e3I)p6oTz>;;zM28EE#ScCVq{2PbY%$G>Yw5D zK4nz+0xS)f8u{xgdg4`venA+|G@_UU8tZkjl;657fzht?`-BpTlpQP~GO}Mp0l5)~ zVNz{?qq7X4HPB|cUJI96ttv2_9Q*g9uqki_RH{?jJ^6u|0cCbnTl8DV&){D<>*4C#qM%CLKd_KNVtHo=nM31jswqKQHsXsIlet#yW^P%biVQO2KLg)fID*T&wv%Ww|x5;{3 zN&iP)InL;ds$6pEhRU$nHNBCrRCSi7-dh%f zyM&T;MzURJkHeXT>`e$|`&!xS>_Z6IoOx#x?ht4H`uzo;=Xt;0ul0PM=ku|dxDva zmeBLtN${;Z7t1{xnU=7pqh25K2ROQgvIi#+`J&BpsB=IFkL->@s8@*PepTu&Z591PBBuu#Sv zRP>z8y=-(h*D15ukJ*Z$<2vzpBfOz1z+H!E=jsaLZzLoXj3`GbFj1VDeK?vc5XfQg zhtR>&`T;34cBvn^vJ7RI^?#|X)Fu_Ifm@L$^GHdo6?H>ZoWuxUHt(=&NxD}v%e!3v z&uI|rW57b%!XQ&{gD4EaSLn?1YFZlhG*ASbK+y7TrvNk5i}!6-mC)&krVeu#oX0}W z>8ne{3Oj|Xw=ZoS1xDKX*0=O^e!b(IYxZXlH+Ixt()`Io5vl7Ar8IEZHkk;=$( zX}x=ctDQ7fj%OMEOF1EpO<8D&UX7`m;#3j0kdWky58a>W+3rvT=)=yN08RuKS4zZd zoJC(r1ZKK^1VnDajcf&tE1apnAg%DwdXPNGR!CZCLO?(C_yx(r+rWpo*x?;|fWfWh ziV0OIe+9ZF;1{R-dw*r69WzN^`#7fs-1t%emCkUzzI3!pnco`A;mOjS-F~*z&f3Xp zk)Q#-)i0^W_4i1`A~M#+8F?HZ5oa)07N7|qb^b94$z8L<hM)vHy*ZC)NBl(9KFf0e0+VPb7HEn)IHdBZKH9929q zB15qZ<~(2RBIH~n=keYhdBEis&YZ#@;(Iq-HErs(jkI9W*&Oz`oWudzh81=PZgqvY z;ufGD7+2ZgytakJDWVK)Z=p)(g^ZD;TJ2yW7XM!@Sk_k*Owp+bsUugUuT$J+yy}R}{ zS}J#>-hU5s$wu4qu|PXtu+U0vljwQhVFE1REGIY}{&YU6pC>r=Wd=Mkt2KkQkV|EM(+Y@2r< zp?`oMO_7rOQVU+#DNHguoi#)Z@=c5!zjx=Up-!=qxq5zG^sgpzh2=3;1#yWKl|Vsq z3Q24hP*j$}41U+BNdKeo?{ZfUF1Gv!zMAJG`efS+;%|Ri3@ARC#w%r9!r%jjHBAAE zN|gQ=PO*2nC#RF`JtiDpb1%w6d-hieeqm_h~oH`gM ztE!dj4-mlbgS@l!ZQ}1UT2m|RJtP3Mwf`NF272NmSII(^h{XGZ9hXo0ftHBBKz-AFuYUj7i|*aSq0j!vl~`d5-v5Aw}99n z_FXF)g}0>^1V-X=I7GF+h09@9L0a-jE;EaEV4qt5Pc9eiztw0(7+mKg#c{oB;d!Xw zaQohVInI_MjeJ7Y(2UW?R}Ei6yw0~;@BK!t$fR~1F=WVMTRJ(f@=NHS6unJd{=TGJcjb2&1Q~`--qo`&P*gf$e1e+BbLQ*)IFV_mY))I z*Um+dYO7=Pc?|^6(AbNBI5nfmL#AIx&x<#cvG?yA0mHsLes@R#;ENRx}a0Fw#N%K+#O{&y@&&Z9)G5jmLNKCxYikC#pop| z66K45dmGh`f3QL4FYk@S7V5C6+kE)>Yd!t^{s>}eO_Zs+D@UfsUm#LJPbLbXh6&`q z)NH>6e0n-AEc__xbcCD#`$kQ!>JPtz2kCF$G7syMWf7>m>YyD=Kl~72yWsqtC;vH< z7DPPzUgueSet)F1DKc;jCe<*0#H6X*7Wwi>=|0b$=0h6k>5@>G)DmmA_kuWS-^ge|Ny9ISF`;mYVcK_gM5cz? zhL5S6SSWK-dOArCq$@=h44o?ZqY1t4pagd}VeXJY-hZEU>f0<1c;c=aQc%>7}wqOA-=;!2` zEN*5MdFt0%E%X>_xN#f>tP`7$HG+%P1->&jem*sEWCHV%^2ljG7pdzxVKewQWM_b zK(04gqIKeThnxca5U4knR?yh0U`atEWnC)S?GPF;(r6JroLp!kOg>$)xp+JGS^EhvqP!r39r2Q&KEw` z_7@R#jBS)HF?5PZv#-Kzj-#L+?m-SR=`KRY^G?>FCkyra?I%v}#BuuRQ~M^iK93zN zJD0yv%Mte7{T%Q%1L%nKTY$|J&;gmm7k}-^{?V>}wb=vyY+#Aqf5tULn*6deJde$r z6zPz=&?A%O|EuCVPcvlDAt_;qh#OpY95_RsG$0K<4S{9`aU*mZQdJr$y%A$Pny6!v zMdF%J0Y|dQ&_jd1)f}~+TL767$Z46kGah?$)`O142FOo0$Le^OEj~|5SNl;b?0?3; zvbv!Gu)>X$%_uwE0@wz9J&w(tt!TV{WM!EjLKm2^ji4$>G6Pn{?G^PGe|w~8w{J%O zai7`W-^n-psnoS|1%;F!vb?J^IS0+3KD*RF23cF5Y4JWdlNVA{drn}&KPE-dm^#Xx zhIDwT+C($2HimN?b-C9Iu6y`#1%D*uREv@$h^J>&n=OJ4-%ehE`Svje6gnjCI|k?B z9zKaRpL=4j)~Pb3h)$cjpx_mP&Mm+mF8wg$Maw_NTUp>Kr@y^5;60OC_~pMhd@WiZ|U^kE*Xv|aTmdh?|ihEv0FK!3oaH)YCr zZal7&yBMW^XN`HF0iYBu_>DRj>5T;{=`F}(HWRpVL`rNh?Z$7K7hnDS9xNjDKs>fi z$oAtEdG7R)eLLSSLOJ?$moGuTd7^SP*YQp#ny7KQdPwrgd5DC@sNOH}nT{X$ocq$Y@F7Z-0`=_mxc0;X;JDpzKcpmg+B6;s1QGlVs=EOh+-Iyl4k# z=Vb#Y;vYq?$Y)S!?nMaj{Mb?9Sow2VFnNBn zwFw#v;%aFjbh}pt=Av&DK^FsBm+-{z@wDIiFQ-lGjtBWctJhffsadT{S<}K`nhs(3 z_f*Boj`IKX*rVb0>HE|DM7wnT0Qlid2p^6r54y%hPw<$1KX%$lw-+Ra^!v{FzK?Wa zVL_|y$FV{&XFk!6rGK$&B?KOf%9SK^3yqY4`HG9y_>&EVhbj1Vm9-jT0k8VWrP`1B z^i(a*y4uaK|L7ctaJUb-1^gMKg3PIpb5wB(33PmkrAFE#yE)7AoXc^(noO;;AEnBj zZ2y5?=kCe_DSt5sKU9vA4rWk_`5p&R zuQ3%qX;XJ-M_P-R`U+%_w{cXAvi%^rcopUO-5E{Q_FkIh?-(T^?9zC)x~jU06N^SK z3rfryd(d}aw;@_A@sB_nd|mci&IvM8O2cb$)L6|13S+cxK`)l;q0@zBf;X_GH926e z{!&9;$D@KfHGlQz(pM@fvL9DwxT!3zUU|JpE^5mG?Y7RzlRTpBBLyt_34gPC$FxKD z<*!{>>YA`GjpRhgGglhug=p_#2>KTAnW}0q7~9V7ti;pUbbe4>T`k-2Bee0*WG!mV zl)1*BUXX1uX}z^18Yy21|JWBKi#v;{4^*_CoR~kE`hN^Tn?!w&&>v?3Sj#Pv=?+!> z!pw!VjI|=(KszJNN_r2&IN8!CjR=`Vsd&!HQYW9CC(-h1Kb{|qq0mZo zMFiI#H-f{Zu>nNe8w|5nuFYMRT~klql!~)7wJ{|wmn9R~A}SZa4#YPE(?NE1u$5|h zxmX!UW81_D0a%Sm`gonr-^7-#Ze**n8uqP~bALn!ci7{@T9gq{iBxf|{5n3(3=A^?se_|q+a>XBJTv>K%`f>R#W(-xZc+mqD}EI_MdI3A`((N1E~BXL2a-dH#UE4)DSzD3(Hw_t!4HvM{wK-+sBtGveLMGM|CxW| z$3nQ##UzTUoa!Tq`!D+}0}d2j54TssHRR4%*e{erh+jKb{{;t13=QY$1+FRDVcbfto!`Wg0da(8q9dq~MjE4c3fQwQBdJ)SB7 z8)c8XK8ZGeQH(PG;A&kC+*nDCoAFR_U$kR_>fY4ymsAaHE-}j(_?DXDC=B zRI1eexMSo2qDH7g%=W0qb%3quUD8O9`4!RR`cyHlBMI|F+74)>Nu5%#)o zh?1QUI%`0At(_m7YdTpbj~LccqiZOLG2f zsN_o<=0%#yNy;U^M{DsybLHJYso*d!2Jbctxna!T+{TL0o$_vsZ-4v*NMDD``6x~G zLgraDY}_lrm3jx%3QK)XE@XldQzz$0D($Hobt}A zLD^p3O>%J&Dft%&WV_iC)>LRL;D^19N+NZ*Us;vSc!pi6ai{U*$!`6YhImThjejdODwsygp^W@6nhB@z z@MEvfgBeTCy2Sp?=*ed?h?ZBli|yoV*NUgl>CL@{bu!J4{p_9;V#) z+J`-;IMS>Yj(_w@CS9O+PySanPTf0j%SDB4gf~WVGnp=NWJ$HJW?2y z`cC!SGt5KpJ0a$@16SOR54fRQA(;4pa`{tKv--iJ5r4bmePt@2Ck5L&JL4D`pMMgW zaJUxb&-nzmc&zzBN|~|<#ROax zvUm>9Gk=ZWcu+Lb*Z;S*Ejxg+`$v(!*ie$`Er1F^UN&4QT{)AHzjMZFfowE3x=4W~ z-)v5Q^2lEhRADvQG&EUfNyG9DhxD`uU}XNxDrMm5jc~BW%mC0gk-x5_Icmmfwv0dj zJL3P&NF;Av3r~Ey%Hm*t!myuLXZ&Y)P4shCP=6fu7QndOC5 z+U}An_DQO`Z!<=oDCS6viJ^{UGy=8emgGat3gs5lnV4oIOj@FsHwEl2b&=vvIbs}@ zCO4JurvYp}TDT+>%sCq~>Z1c2m1)sZ<6~=WkvF6Nzn6}Q`7HV<=WR2bo5(26l>_YydDpNxNXjKVW)Gtf#eUg(?>3>FHX^!_RX4sy zCKBPqpXYcBu=^n)7`FWGGLwOq&EivV8};bMT-^|^BhbT?JhY89IiIETnbG^JHuV_DWZzo4Gk zY^2-|B6CkRjK1JmwfxQ%-uXV@I#9rG!GN{6#Lm0sZO&F(su@j5$bgsZ*qSA2g_GxY z=lv`8*S{^d&1AwG=BpdgZPn=)-+w-OlJu)++RJV|&<*(Hn_)?P9eYFPG_Ecp>H?J7 zJI6%5%r|WMyenC~yyX^}!PlA44gljl7M&R7s>AjlUA};WF&1lb8nhYo;Ei6Iw`1mJ zKiyZPe-VaXax%;)i4xFSE;Vb&zGSC`vU*KaO%QccEO{uu zs6y*zMBd!L1-##7jC(vK6@SO3mkHZEFHBxTdDxzRvaRVAXVZG~fAE~jhoxtb9*vAm z#%5o$Mt+5qhcDMT#q-R^)+Xe392)j$-9t%5l$!DtoJ^jrY#KA zsJ=-pQ5OL~mv0(c3FLTktyXfO<#>08{Z@A$Br?J;%-odmc=r}E1oMrNd z`hYU}&xN_K`C3@Gj^=Gpwp@Q5QEUFQQzrlejfX_5Rt*l3lYg&%i(Gj~XVx#oDAFDG z{_bUysHokgTj*t@E3_>S4ZWFx^GF{>#?-U@)57gM>Po3R1~q7*qnwma)6DOsv>eM> z!*{>(pPS^J=zfE(4ZF0godg?$gsi2qzBIBTgtm0~mREpxI2N*f0Td1b#gbM$S04!0 z1>3Dn;fMfCTYpl;cAdqb8z*l(R2mXhEEH+bkXGDjZ@zWF*|JJJp=@_e_6j+96<7wQ z@QuEaMy9j4yytwc>H*2Uw@h6=9igk_<=11US%_JqLb%H^mv8l>na6>dh%&kpI=FzM)UYBU^Ul&YWC^- zPW&gZyH^*Zpf`BhUcuMoKume*Aram;LgTtANA4 zK(hZkDxxZR#6@}oUQ(|ich;ogU*}>ZjW}SrbDjAYX3dLk>1V;fI}9cr84iN$t~Z*( zgM@A-mFd$hK*zn74AHtWdQC08>)_nEG1XMgsp^mW@lk-NaXKU&2YeHiAHVQP`M$e|aEFN^e#S z-&eUcv&L)Ca=>|2Wx|(HBfh}0UO&CGx9r3goiPfG%WQauo=ZKp5m?Eb86ie1{!lm2 z3{>OvxE@lr*{r2I0q)pySqta1CX-!6^nap)p5kTVq&n%G<$}?ZI#YDhg_HkU8c)O% z=_g5#pozJ!*~=s$;juq>f>cKLL)Z_aks2IvGc}}}dHIAW7hvJ>U!d61eUXECOuVe6 zDebz`SBUnm`Iq65q~!e8a>eI+D!9YX(v=2YMw1PElLMbzs$m&J4Xvh(Z(M~wsDHAk z>liF1dJL@d;S|QektTST%D_^B*(J)qv?g|8j;^%P^U8CKa_nC)j zsQ&-f(MdI(My0cHdK7jNBP-SGS+q0DR2@iRR8!$7GxSY_X^iZ#3Q4#C?{Bgf-uq9F z2bj*X#$s0gp=e?vvrzTjPa|hoEq^1dU+~ODJW{+^TpSgDK0tg29QXbhdp8?1(Ap4x zX^`nn{-hAuHtCh4s*ryt)jsCu+-uc(>dbYar1(I!0n5jeUWa#A=9cH2v$P_m_hRB+ z9}JjHJ_366J+Vx z0J{ZF{g^aa8}r~0gp5))7N({HjhIC0Ko!zooF3;X-A|QhIfvM≫2xw6`gJSv#+I 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 EFmypS$1g%LKa3h;P>$4>1)wwDfGMS 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