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.
* 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 <html lang> 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
* Add optional Terms of Service gate for account creation
Self-hosters can set an undocumented `termsOfService` path in ServerConfig
pointing at a plaintext file. When set, account creation is gated: the server
answers POST /api/account/create with 451 + the TOS text and a content-hash
version. The client shows a scrollable dialog; accepting resubmits with the
accepted version, declining discards the provisional server settings.
Disabled by default (null path); existing servers are unaffected.
* Cover TOS repository and 451 client handling; harden error-body parsing
Adds direct tests for TermsOfServiceRepository (FakeFileSystem: absent/missing/
blank/populated file, stable and content-derived version) and ServerAccountApi
(201 success, 451 -> TermsOfServiceRequiredException, malformed 451 -> default
failure).
Also broadens error-body parse handling: ktor raises ContentConvertException
(not kotlinx SerializationException) on malformed JSON, so both the create-account
451 path and the shared defaultFailureHandler now catch it and fall back to a
graceful failure instead of letting it escape as an unhandled coroutine exception.
* Fail fast when termsOfService points at a missing or blank file
A configured but unreadable/empty TOS path previously made challenge() return
null, silently disabling the terms gate and letting accounts be created with no
terms at all. resolveServerConfig now validates the path at startup and aborts
(as it already does for unparseable config), so a misconfiguration can't quietly
drop the legal gate.
* Resolve a relative termsOfService path against the config file's directory
A bare `termsOfService = "tos.txt"` previously resolved against the server's
working directory, so a terms file sitting next to config.toml wasn't found.
Relative paths now resolve against the config file's own directory; absolute
paths are unchanged.
Under compileSafety=true the Koin compiler plugin emits a synthetic
dsl_single hint for every definition in a plugin-processed module{},
encoding the named() qualifier as a value-parameter NAME. Kotlin/Native's
IdSignature ignores parameter names, so the three same-type qualified
CoroutineContext dispatchers (main/default/io) collapse to one signature
and fail klib serialization with a SignatureClashDetector AssertionError.
Move the three dispatcher definitions into a plugin-less module in :base
(dispatcherModule) and include it from mainModule. No hints are generated
for them, so the clash is gone and compileSafety stays on for every target
including iOS. All dispatcher consumers use runtime inject(named()), never
auto-wiring, so nothing the plugin validates depends on these definitions;
the DISPATCHER_* constants stay in :common so no consumer changes.
Adopt the Koin Kotlin compiler plugin (io.insert-koin.compiler.plugin 1.0.2)
and convert :common's DI from classic DSL to the plugin DSL
(org.koin.plugin.module.dsl), enabling compile-time DI graph validation
(compileSafety = true) while keeping the centralized module{} layout.
- Convert singleOf/factoryOf/scopedOf(::X) -> single<X>()/factory<X>()/scoped<X>()
across mainModule (root + ProjectDefScope block), migratorModule, and the
android/desktop/ios platformModule + exampleProjectModule actuals.
- Provider functions and when/config bodies use create(::fn) to keep auto-wiring
and validation; explicit-lambda and named() bindings stay classic.
- Add koin-annotations (BOM-managed) for @Provided; mark SandboxFileAccess
@Provided (its definition lives in the desktop app module, external to :common).
- Relocate the iOS startKoin into a plugin-less bootstrapKoin() helper in :base so
common's iOS compilation is no longer a full-graph (A3) aggregator.
Green with compileSafety=true on desktop, android, common metadata, and
desktopTest (1404 tests). iOS/Native is blocked by an upstream plugin bug: the
generated dsl_single hint functions encode named() qualifiers as parameter names,
which K/N's IdSignature ignores, so same-type qualified definitions (the three
CoroutineContext dispatchers) clash during klib serialization. Documented inline.
The repository moved from github.com/Wavesonics/hammer-editor to
github.com/Darkrock-Studios/hammer-editor. Update all URLs across
source, build scripts, web templates, docs, store metadata, and
test fixtures.
Quick-capture story ideas as tagged markdown blobs in a new Project
Selection tab, stored one file per idea in .ideas/ and promotable into
a project. Offline-first; syncs as a phase inside the account sync
session (shape-agnostic server storage, hash-baseline conflicts,
tombstone/outbox deletion, ideasStateHash skip for unchanged sets).
Unifies idea + project tag suggestions behind AccountTagService.
Projects can now be tagged in Project Settings, with suggestions drawn
from the user's other projects. Tags show as chips on project rows, and
a new search bar on the project list filters by name and #tag using the
same query syntax as Global Search (parser extracted to a shared
data/search module). Conflict resolution gets a Tags row, picked as a
unit like the other project-data fields.
Sync safety:
- Tags hash with zero bytes when empty, so all existing hashes (synced
baselines and server rows) stay byte-identical; golden-pin tests
enforce this.
- The server now stores project data as an opaque blob with a
client-supplied hash (like entities), validating only that the
payload decodes; undecodable rows heal via re-upload. Adding fields
to ProjectData no longer requires server changes.
- Fast-forward records the hash of what was actually stored, so an
out-of-date client can no longer strip and delete fields a newer
build added. Documented in SYNCING-PROTOCOL.md.
- HAMMER_PROTOCOL_VERSION bumped to 3: older servers decode project
data destructively and would silently drop tags.
UI: the redundant projects-page heading is removed; search reveals via
a masthead toggle. New design-system pieces: HdClearGlyph and
HdCollapseGlyph (drawn glyphs, replacing misaligned text "×" and the
ambiguous double-X in search strips) and an HdSearchRow molecule now
shared by all four searchable screens.
Rework the move-detection post-pass into a single paragraph-level pass and
replace the overlap-based stable/moved gating with proper range subtraction.
- A hunk that merged a moved paragraph with an adjacent edit now surfaces the
edit: delete/insert spans are the hunk minus the moved/stable ranges, instead
of dropping the whole hunk when it merely touched a stable/moved paragraph.
- A fully reordered match set (LIS backbone of one) is treated as all-moved, so
a swap like A,B <-> B,A marks both paragraphs moved instead of electing one as
a spurious stable anchor.
Drops the separate pure-hunk detection path in favor of the unified detector.
Replace the project name in sync endpoint paths with the server-issued
projectId. The server resolves the ProjectDefinition from the DB by id;
the create endpoint takes the name as a query param. Updates client APIs,
tests, and the syncing protocol doc.
* Harden sync projectId refactor: protocol bump, 410 for missing project, path encoding
- Bump HAMMER_PROTOCOL_VERSION so version-mismatched clients fail fast via the
protocol gate instead of hitting silent 404s on the renamed routes.
- requireProjectDef responds 410 Gone (not 404) for a missing project, so the
download_entity client can't mistake a vanished-mid-sync project for a deleted
entity and silently abandon undownloaded entities.
- Re-apply encodeUrlPathSegment to the projectId path segment and restore the
path-encoding test so a reserved character can't escape the URL template.
- Thread movedStyle into SceneConflict and SceneDraftConflict so relocated
paragraphs render blue instead of falling back to delete/insert; make the
style a required argument so it can't be silently dropped again.
- Add a semantic `moved` color to HammerExtendedColors (theme-keyed, dark-safe)
and drop the hard-coded Color(0xFF3A5FA0) duplicated across two files.
- Extract the duplicated split-highlight effect in DraftCompareUi into a shared
DiffHighlightEffect.
- Hoist the whitespace Regex to a single instance and compute each hunk's
move/stable overlaps once in the emit loop instead of rescanning.
Scene and group titles are stored wrapped as `order~name~id`, so a leading
dot or a Windows reserved word (CON, PRN, COM0-9, LPT0-9, ...) can never
collide on disk for them — yet name validation rejected them as if the title
were a raw filesystem basename. Project names, which become directories
verbatim, keep the strict rules.
- ProjectNameValidator.validate gains usedAsRawFilename (default true); the
leading-dot and reserved-name checks now only apply to raw filenames.
Trailing dot/space stay rejected for everyone (the on-disk encoder strips
them). Reserved set extended with COM0/LPT0.
- Thread the flag through ProjectsRepository.validateFileName and
SceneRepository.validateSceneName.
- Consolidate the six naming dialogs onto a shared rememberNameValidation
hook keyed by NameKind (Project vs SceneItem), so strictness lives in one
place instead of a per-call-site boolean.
Promote the client's project/file-name validation rules out of :common's
ProjectsRepository and into a shared ProjectNameValidator in :base, resolving
the long-standing TODO on the old length-only stub.
Project URLs are now /story/{slug}-{id} (and /a/{penName}/{slug}-{id}), where the
trailing id is a stable 6-char base62 hash of the project's uuid and the slug is
purely cosmetic and never parsed back. This dissolves the slug round-trip bug:
the slug can be as pretty as we like because resolution matches the embedded id
against the user's projects, not the name. A bare /story/{id} (no slug) also
resolves, for short URLs.
On app open, sync made ~4 HTTP round-trips per project even when nothing
had changed. Add a batched pre-sync probe: the client sends a project-wide
content hash for each eligible project in one request, and the server
returns which ones still match so the client can skip syncing them.
- base: ProjectContentHasher (entity hashes + project-data hash, computed
identically on client and server) and probe request/response DTOs
- server: POST /projects/{userId}/sync_probe, recomputing each project's
hash from stored state; skips projects with an in-flight sync session;
400 on a malformed body
- client: cache the hash in the project journal (written at FinalizeSync,
invalidated on any content mutation via one chokepoint); probe is
best-effort and degrades to a full sync on any failure
- backstop: never skip a project with pending entity or project-data work,
so a stale cache can't drop a change
Writing activity is excluded by design (per-device, conflict-free).
Tests: ProjectContentHasher, client/server probe units, and an e2e suite
covering client/server hash agreement, the session gate, auth, and 400s.
SerializationException, dropping the IllegalArgumentException branch that
readJsonOrNull already has. tomlkt doesn't route every decode failure
through SerializationException: a stale stats.toml (old schema, e.g. a
non-integer key in Map<Int, Int>) throws a raw NumberFormatException.
That uncaught exception killed the parallelMap worker for the affected
project, nulled its result slot, and filterNotNull() dropped it from the
project selection list — while sync, a different path, still showed it.
Add a readTomlOrNull helper mirroring readJsonOrNull that absorbs the full
set tomlkt can throw on bad input (SerializationException,
IllegalArgumentException incl. NumberFormatException, IllegalStateException
from parser errors, IOException), with an onError callback so callers keep
their site-specific logging. Schema-version checks run after decode, so they
can't rescue a decode that throws first.
Migrate every TOML read site to it: ProjectStatisticsCacheReader,
StatisticsDatasource, ProjectDataDatasource, ProjectsListComponent,
ReferenceIndexDatasource, WritingActivityDatasource, and
SceneMetadataDatasource (which previously had no error handling at all).
Stale caches are now treated as a miss and recalculated.
Add ReadTomlOrNullTest covering each exception family and a
ProjectStatisticsCacheReaderTest reproducing the original crash.
Sync, background jobs, API/email send, and UI boundaries legitimately
catch broadly; annotate each with @Suppress and a reason, and log the
exception where it was previously swallowed.
New ProseDiff module: word-level Myers diff (kotlin-multiplatform-diff)
over markdown text, with syntax stripped via the JetBrains parser and
offsets mapped back to source. Merges adjacent edits into single hunks,
emits anchors at each boundary for scroll sync, and includes a git-style
slider that aligns pure insert/delete blocks to line boundaries. Covers
plain-text diffing, prepared/reusable inputs, and an OffsetMap that maps
a position on one side to its counterpart.
Draft compare: highlights DELETED (red/strikethrough) and INSERTED
(green/underline) spans via non-destructive RichSpans in each pane's
rendered coordinate space, recomputed off-thread on a 500ms debounce.
Synchronized scrolling (compose-texteditor 2.0.7) drives either pane from
the other off raw scroll pixels with an echo guard. Both editor states are
hoisted so highlights and sync work in compact and expanded layouts and
survive theme changes. A DIFF toggle gates highlights and sync.
Adds `created` and `lastEdited` timestamps to SceneMetadata, stamped
on every autosave tick in SceneEditorRepository.recordSceneActivity.
StatisticsService rolls them up into a new "Last Edited" tile on the
project home dashboard, and the Android widget reads the same field
from the stats cache.
The timestamps ride the sync wire on ApiProjectEntity.SceneEntity and
participate in EntityHasher.hashScene. On download, local values are
preserved when the server didn't ship them. No conflict resolution —
last write wins.
EntityHasher hash functions had their defaults removed so call sites
can't silently miss a newly added field; this caught a latent bug in
the second markForSynchronization hash call that was relying on
defaults for archived/confirmedReferences/dismissedReferences.
In a worktree, .git is a file pointer rather than a directory, so the
Copy task's destination ancestor validation fails at configuration time.
Register a no-op stub instead — hooks install when the main checkout
builds.
Renames ProjectTheme.color1/color2 to primary/secondary and pipes them
into a Material 3 ColorScheme via material-kolor's dynamicColorScheme.
The user's secondary seeds both the Material 3 secondary and tertiary
palettes, the Rainbow palette style keeps them in distinct hue lanes
instead of collapsing toward the primary, and surfaceTint is redirected
to secondary so elevated surfaces (cards, dialogs, top bars) pick up
the accent.
The override is hoisted to the platform entry points
(ProjectEditorWindow.AppContent on desktop, ProjectRootActivity.Content
on Android) so a single ProjectThemeOverride wraps the NavigationRail,
ProjectRootUi, FAB stack, and confirm-close dialogs. ProjectRootUi and
ProjectRootFab no longer self-wrap.
All five project FABs (notes, encyclopedia, timeline, scene group,
scene) explicitly draw containerColor/contentColor from
secondaryContainer so the second color is the obvious accent on the
heaviest visible chrome.
New project-data sync phase runs before entity transfer. The blob is a
single per-project row on the server, hash-checked for conflicts; the
client surfaces a per-field pick-a-side resolver UI when both sides
edited concurrently. Author name is a text field, custom theme uses a
clickable color swatch backed by skydoves/colorpicker-compose, and the
word-count goal is cadence + count. Each opt-in section nulls out its
data when unchecked so the wire signal is unambiguous.
Counts words written per save (multiset diff vs. an in-memory pre-edit
baseline so deletions don't reduce credit), rolls them into per-device
sessions logged at scenes/.activity/{deviceId}.toml, and syncs them as a
new step in the project sync pipeline. Server is treated as dumb
per-(project, deviceId) blob storage; client does GET → merge own slot
locally → POST. Cross-device conflicts can't happen because no device
ever writes another device's slot.
Collapse the two duplicate "given a complete ApiProjectEntity, compute
its hash" implementations into a single canonical fun hash() declared
on the sealed interface, with one implementation per subclass living
right next to the field declarations. Behaviorally equivalent - all
hash outputs are byte-for-byte unchanged because the new method bodies
call the same EntityHasher.hashX functions the wrappers used to call,
with the same arguments.
The hash impls now live in the same data class as the field
declarations, so adding a field forces the author to update the hash
method four lines below it. The fan-out hash bug class - which has
bitten us three times across this work - can no longer appear by way
of two parallel implementations drifting apart, because there is now
only one path for entity-shaped callers.
Production changes:
- ApiProjectEntity: added abstract fun hash(): String to the sealed
interface. Implemented per subclass (Scene, Note, TimelineEvent,
EncyclopediaEntry, SceneDraft) by delegating to the corresponding
EntityHasher.hashX function.
- ServerEntitySynchronizer: hashEntity is now open with a default
impl that delegates to entity.hash(). Subclasses inherit it.
- 5 server synchronizers (Scene, Note, TimelineEvent, Encyclopedia,
SceneDraft): deleted their hashEntity override and unused
EntityHasher import.
- EntityHasherExt.kt: deleted. Was a 53-line wrapper that was the
source of the Tier 2 silent-failure bug.
- ProjectEntityDatabaseDatasource and ProjectRoutes: three call
sites switched from EntityHasher.hashEntity(e) to e.hash().
Test changes:
- 25+ test call sites migrated from EntityHasher.hashEntity(X) to
X.hash() across e2e tests, server-side synchronizer tests,
EntityHashSensitivityTest, and EntityHasherExtTest.
- Orphan EntityHasher / hashEntity imports stripped.
- EntityHasherExtTest is preserved (now asserts entity.hash() equals
a direct EntityHasher.hashX(...) call with the entity's fields as
explicit arguments). The data-driven parity check still catches
"hash impl swapped two argument bindings," which the structural
EntityHashSensitivityTest does not.
Net diff: -78 lines across 19 files. 811 tests pass.
Layer 2 (the markForSynchronization assemblers in client repositories
that construct hash inputs from disparate local sources) is still
duplicated but is now defended end-to-end by the Tier 3 E2E tests. A
future refactor could collapse those by constructing transient
ApiProjectEntity instances and calling .hash(), at the cost of one
extra allocation per dirty-mark; deferred for now.
Add Tier 2 server-side sync coverage. Two more hash bugs surfaced and
got fixed in the process - both of the same shape as the hash collision
caught by Tier 1: a serialized field on the DTO was silently absent
from the digest, so two clients with different values for that field
would never converge through sync.
Bugs fixed:
EntityHasherExt.hashEntity (the wrapper used by
ProjectEntityDatabaseDatasource.storeEntity to compute the hash
persisted in the database) was not forwarding the new
confirmedReferences, dismissedReferences, or aliases parameters to
the underlying hashScene/hashEncyclopediaEntry calls. The
per-synchronizer hashEntity overrides were correct, but the
storage-path wrapper was not - so the server's persisted hash always
disagreed with the client's hash for any entity with non-empty new
fields. Data round-tripped fine via JSON, but every sync would
report drift, trigger a re-download, and never settle. Fixed by
forwarding the missing fields.
EntityHasher.hashSceneDraft was not digesting SceneDraftEntity.sceneId
(the FK linking a draft to its scene). This bug pre-dates the
reference-index work and was found by the new sensitivity test on
its very first run. Fixed by adding sceneId to hashSceneDraft (with
a default of 0 for backward source compatibility) and threading it
through the four call sites.
Tests added/strengthened:
EntityHashSensitivityTest (new, server) - one test per
ApiProjectEntity subtype that uses the @Serializable descriptor as
the source of truth for "what fields exist," then asserts every
field affects the hash. The descriptor coverage check is the
forcing function: adding a field to a DTO immediately fails this
test until the author declares a mutation for it, which then fails
the sensitivity loop until the hasher is updated. No more silent
passes from the parallel-paths-drift-together failure mode that let
the EntityHasherExt bug ship.
EntityHasherExtTest - added a SceneEntity with non-empty
confirmedReferences and dismissedReferences plus an
EncyclopediaEntryEntity with non-empty aliases. The pre-existing
parity test was structurally correct ("extension call must equal
direct call") but trivially passing because both sides used
defaults. Now exercising real values, it would have caught the
EntityHasherExt bug.
ServerSceneSynchronizerTest, ServerEncyclopediaEntrySynchronizerTest
- createNewEntity / createExistingEntity now include non-empty refs
/ aliases, so the inherited Hash Entity, Save Entity, and Load
Entity tests automatically extend their coverage to the new fields.
Add Tier 1 sync regression coverage for the reference index work, and
fix a hash-collision bug surfaced by the new tests.
Bug fix in EntityHasher.hashScene:
Confirmed and dismissed reference sets were digested through identical
d.update(ref, buf) calls with no boundary marker between them, so
{confirmed=[7], dismissed=[]} hashed identically to
{confirmed=[], dismissed=[7]}. A user confirming an entry then
dismissing it would produce no hash change, the server would not
detect a change, and other clients would never sync the transition.
Each section is now prefixed with its size to delimit them
unambiguously. (Side effect: this changes the hash for all scenes,
including those with empty refs, forcing a one-time re-push on first
sync after upgrade.)
Tests added:
- EntityHasherTest: 6 cases for the silent-failure class - hashScene
is sensitive to confirmedReferences and dismissedReferences, sets
are order-independent, the two sets contribute distinctly,
hashEncyclopediaEntry is sensitive to aliases, and alias order is
significant (List, not Set).
- SceneSynchronizerTest: verify ClientSceneSynchronizer.getEntityHash
feeds metadata.confirmedReferences into the hash so refactors that
drop the field are caught at test time.
- ClientEncyclopediaSynchronizerTest (new file): verify reIdEntity
calls referenceRemapper.remapEntryReferences after re-IDing the
entry, defending the cross-entity sync wiring so encyclopedia ID
conflicts during sync don't silently leave stale references in
scene metadata.
Groundwork for tracking which scenes encyclopedia entries appear in.
Adds aliases to encyclopedia entries and confirmedReferences /
dismissedReferences to scene metadata, plumbed through sync DTOs,
hashes, and client/server synchronizers so the new state round-trips.
Add client-side validation for email, password, and URL, along with shared validation utilities
Server error messages can now properly bubble up to the UI
* Moving all data into the server database
* Server projects now have UUIDs
* Client is now sending the project server ID
* Implemented Projects sync E2E test
* E2E sync test now does uploading and downloading!
* JVM 21