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
In doing so, I added a lot more test infrastructure
Added CResult, which is similar to SResult, and will be used in more places to report user facing error