- Conflict resolution: onEncyclopediaEntryConflict now carries aliases and
excludeFromDictionary into the local entity; "keep local" was stripping
both, and with the flag in the entity hash that meant a conflict that
could never converge (the alias drop predates this feature)
- ViewEntryComponent: one saveEntry(transform) helper replaces six
hand-threaded updateEntry call sites and loads content from disk when
the async state load hasn't landed, so a save can no longer revert
aliases or the exclusion flag to defaults
- SpellCheckRepository: SupervisorJob + guarded checker calls so a
platform failure can't kill the settings collector; appliedCandidates
recorded only after a successful apply so failures retry
- ProjectDictionaryService: initialize() instead of init side effects,
cancel-before-clear plus an isActive check to keep a racing rebuild
from re-adding a closed project's words, spellCheckSettings.enabled
added to the rebuild gate, injectDefaultDispatcher(), and
loadEntriesImperative() now returns the defs
- temporaryProjectTask scopes skip the dictionary service, ending the
checker churn during bulk sync and import
- HdHairlineToggleRow gains an enabled param (semantic disable, no
ripple) replacing three hand-rolled Box+alpha idioms
ReferenceIndexConfig.default() now enables all five EntryTypes, so Thing,
Event, and Idea entries get linked to the scenes that mention them. The
metadata panel already bucketed refs into Characters/Locations/Other, so
the new types land in Other with their own glyphs.
Add-reference search takes a type set and applies it before the result
cap, so one tab can no longer starve another out of the slice. The
appearances chart filters to PERSON to stay true to its label; the
separate connections total still spans all types.
Entry names and aliases become session-only words (AppLocal scope, never
the OS dictionary) while their project is open, cleared on close. Words
are tokenized to single words and filtered against the base dictionary
so only unknown spellings are added.
Three levels of control, all live-reactive:
- Global toggle in Spell Check settings (SpellCheckerSettings, default on)
- Per-project toggle (ProjectData.encyclopediaDictionary, synced;
hashed only when false so existing hashes are stable)
- Per-entry exclusion (EntryContent.excludeFromDictionary, synced;
hashed only when true, same zero-bytes-at-default rule as aliases)
SpellCheckRepository holds session words keyed by ProjectDef and emits a
fresh checker instance whenever the effective word set changes, so open
editors re-run their full scan; the same path re-applies words on locale
change and re-enable. ProjectDictionaryService (ProjectDefScope, eagerly
started in initializeProjectScope) rebuilds the word set from the
encyclopedia on entry changes, debounced, and clears it on scope close.
Account sync pushed renames before deletions, so renaming a project into
a name still held by one queued for deletion violated the server's
UNIQUE(name, user_id). Phases now run delete -> rename -> create.
The violation also escaped as a 500: ProjectDao maps SQLSTATE 23505 to a
ProjectNameTaken failure and /rename answers 409 Conflict.
Also in here:
- Renames queued against a server-tombstoned project id are dropped.
They could only 404 and requeue every session.
- API error messages fall back to English. The locale bundles have no
base Messages.properties to parent through, so a key present only in
English threw MissingResourceException and made the response a 500 for
every other locale.
- Rename's ProjectNotFound reported "Invalid project name".
* Add scene selection to story export
Mirrors the web scene-limited shares: ExportOptions.sceneIds (null = entire
story, empty fails closed), filtered at the single chapter-building point so
all five formats renumber automatically. The export dialog is now fully
controlled by the retained component state, so in-dialog edits survive
rotation (#885 class).
* Address export scene-selection review findings
- beginProjectExport reads the scene tree off the click handler
- Dialog options freeze while the non-blocking file picker is open, and all
three platform pickers snapshot options before their suspend
- Cancel discards in-dialog edits again; restore happens in onDismissed so
nothing flashes during the close animation
- A full selection confirms as no limit, so empty chapter groups still export
- Sceneless projects hide the limit toggle; empty groups get no checkbox
- Options column scrolls on short screens instead of pushing the buttons off
- Group rows are toggleable with checkbox semantics for TalkBack
- Picker list/row lifted into the design system (HdPickerList/HdPickerRow),
now shared with MoveSceneDialog
* Add a scrollbar to HdPickerList
Overlays the standard MpScrollBarList inside the picker list, covering both
the export scene selector and the move-scene destination list.
On project close the Koin project scope closes before Compose disposal
destroys components, so SceneMetadataPanelComponent.onDestroy crashed on
its first lazy resolve. Resolve onDestroy dependencies eagerly via a new
projectGet() helper so the final metadata flush survives project close.
Also: the account sync log claimed a server create failed when only the
local project id save failed (new dedicated string), and the local
create failure string read backwards and lacked its placeholder.
* Address review findings on the metadata flush and sync log strings
- Skip the onDestroy metadata flush until the initial load has completed, so an early destroy cannot overwrite stored metadata with the empty default
- Run the reference scrub off the lifecycle thread and catch flush errors
- Resolve ProjectRootComponent.syncJournal eagerly (read in onStop)
- Correct the projectGet KDoc: a post-close resolve can also silently hit a fresh uninitialized scope
- Pass serverProject.name (not toString) to the local-create failure log, and rename its key so stale translations get re-queued
- Narrow the id-save try block so an updateSyncData failure no longer logs a wrong failure line after the success line
* Narrow the destroy-flush catch to IOException and cover the failure branches
Order numbers are a contiguous 0-based sequence, so the zero-padded order
field must be as wide as the highest order, not as wide as the child count.
Those two disagree at every power of ten: adding the tenth child to a
directory left single-digit names on disk while every path computation
padded to two, so nothing in that directory could be read back.
The damage was silent. Reads returned "" (loadSceneMarkdownRaw swallows the
IOException), so an affected group exported as blank and opened as a blank
editor. A save then wrote to the padded name, creating a rival file with the
same scene id next to the real one. Nested scenes were hit hardest, since a
mis-padded group takes out its whole subtree.
- countScenes is the last order index; all three padding sites use it, and
the misnamed SceneDatasource.getLastOrderNumber (raw count) is gone.
rationalizeChildren follows the same convention.
- getGroupChildPathsById keeps the name-first path instead of associateBy's
last-wins, so rationalizeTree, validateScenePaths and ScenePathIndex agree
on which file owns a duplicate id. The sync pass no longer rewrites the
directory around the stale copy and discards the newer one.
- Content is addressed by resolveSceneContentPath: resolved by id from disk,
computed only for a scene with no file yet.
Recovery of projects already carrying a stray duplicate is left to a
follow-up migration.
* Keep unsaved edits through a device rotation
Fixes#885. Rotating an Android device rebuilds the composition while the
Decompose component survives (both activities use retainedComponent), so any
draft the UI kept in a plain remember was gone by the time the new composition
ran.
Story Ideas editor: the draft (title, body, tags, uncommitted tag token, edit
mode, and the saved baseline) moves into StoryIdeasComponent and is stashed in
the stateKeeper, so it also survives process death. createIdea/saveIdea give
way to saveDraft(), which reads the draft, folds in a typed-but-uncommitted
tag, and re-baselines off what was actually stored.
Also fixed, same failure mode:
- Encyclopedia entry view re-seeded its rememberSaveable draft from the last
saved content on every fresh composition, which undid the restore.
- Tag lists on the note, timeline event, and encyclopedia entry create screens.
- The uncommitted token inside HdHairlineTagField.
- The date field on the timeline event create screen.
- The draft name in the scene save-draft dialog.
* Address review findings on the rotation state fix
- Encyclopedia entry: only skip the seed once the fields already hold the
entry, so starting an edit before the async load lands can't save an
empty name and body over it.
- Story ideas: a save that finishes after the editor moved on no longer
stamps its result onto whichever draft is open now.
- Ideas browse: sort mode and tag filter survive a configuration change,
like the search text already did.
Nested groups were indented with an off-by-one that put a depth-2 group at
the same start as its parent, and groups and scenes used different indent
formulas so nothing shared a grid.
- One indent vocabulary in the design system: HdIndentStep, hdIndentFor,
and Modifier.hdIndentRails for per-ancestor hairline guides.
- Groups get a chevron at every depth, not just the top level, plus a
scene-count greeble.
- Section rules hang at the group's own indent; top level chapter breaks
still cut the full panel.
- The drag insert line uses the same indent metric as the rows.
* Fix desktopTest compile: coVerify for suspend setProjectId
* Let users widen the editor on wide screens with a drag handle
The scene editor and focus mode share a persisted editorMaxWidth
(700-1400dp, GlobalSettings) set by dragging a new HdResizeHandle
gutter on the column's start edge. Rendered width stays
min(window, userMax); the handle only appears when the window is
wide enough, and double-click resets. Live drag width is UI-local
via rememberHdResizeHandleState and persists once on drag end.
The sync journal (sync.json) and StoredProjectData.lastSyncedHash record what one
specific server confirmed, but nothing scoped them to that server. Once a project
pointed at a different server project, an untouched project still read as "already
in sync", so ProjectDataSyncOperation took its fast-forward branch and overwrote
local project data with the new server's empty copy: author name and project
settings gone, and nothing uploaded.
ProjectsRepository now clears the baseline whenever the server project id actually
changes, covering cross-server merge, create-account, removeServer, and the 410
stale-id path in FetchServerDataOperation.
The clear is deliberately narrow. The journal is rewritten rather than deleted:
syncedHashes, cachedProjectHash, hashAlgoVersion, currentSyncId and lastSync are
reset, while dirty, newIds, deletedIds and lastId stay, since they are local work
the new server has never seen; dirty entries drop originalHash so an upload cannot
forge a conflict against a server that never agreed to it. Project data keeps its
data and loses only lastSyncedHash, which is what makes the next sync upload
instead of fast-forward. For an open project the clear goes through
ProjectDataRepository under its lock, because clearing the file alone would leave
the stale hash live in memory and write it straight back out. The whole clear runs
before the new id is written, so a failure between the two leaves the project
pointing at the old server rather than at the new one with a stale baseline.
removeProjectId and setProjectId are suspend now, which makes
AccountSettings.removeServer() suspend too. User content is never touched.
Writing-activity logs are also server-scoped and also survive a move; tracked
separately in #892.
syncCreatedProjects ran createProjectsOnServer before
createLocalProjectsFromServer, so a local project sharing a name with a server
project was uploaded first and only then had its id overwritten by the name
match. The server dedupes createProject by name, so this usually handed back the
existing uuid, but where the local name was a sanitized form of the server name
it left an orphan duplicate for every other device to download.
Adoption now runs first and reports the names it claimed, so the upload pass
skips them and a queued creation the server already holds under that name is
withdrawn instead of retrying the duplicate on every future sync.
Adoption itself is no longer name-only. A local project is claimed only when its
id is absent from the live server set, so one already bound to a live server
project is never re-pointed at a same-named one, and two server names that
sanitize to a single local name are first-wins rather than last-wins. Tombstoned
ids stay out of that set, so a project whose server copy was deleted elsewhere
can still be claimed.
Server setup popped an unskippable "Remove Local Content?" confirm on both the
Create-account and first-time Log In paths, and ran the delete before the network
call, so a typo'd password or a declined ToS still wiped every local project.
Only prompt when the answer matters: logging in to a different server while
holding real local work. Create-account never deletes, re-auth against the
configured server never prompts, and a login holding only the bundled example
drops it silently. Replace requires a second destructive confirmation.
The wipe now runs in the Success branch, so a failed setup costs nothing. The
component owns the decision and re-reads the projects directory when it acts;
the UI only ever asks for the non-destructive setup.
Also releases the pending setup before the wipe suspends, so a close landing in
that window cannot delete the freshly obtained tokens, and guards the
example-project drop with a same-server check so a token-expiry re-auth keeps it.
A page of dialogue reached readers as one packed block. CommonMark reflows
prose: single newlines become spaces and any run of blank lines collapses to
one paragraph break, so what an author sees while writing was not what anyone
else saw. Prose is now laid out as it was typed, on every surface that renders
it: every newline starts a new line, every blank line is a blank line.
- base/markdown/ProseHtml.kt holds that layout as AST-level generating
providers, shared by the server's pages and the client's EPUB export. Lists,
tables, code and headings keep markdown's own layout by construction, so the
old fence-tracking preprocessor is gone.
- parseProseMarkdown does the same for the DOCX, RTF and PDF exports, with a
ProseBlock.Blank for an authored blank line. A body paragraph carries space
after it only where prose ends, so lines that run on sit tight and the
indent parts them.
- Quoted passages keep their lines, and a quote's '>' markers no longer leak
into the text of a continuation line in the document exports.
Walking every construct the flavour can emit turned up three more faults. A
stripped element keeps its text, so tables (which the sanitizer allowed none
of) arrived as their cells run together; they are allowed and styled now,
column alignment included. An ordered list starting at 5 rendered as 1. Line
endings went unnormalized once the preprocessor that had done it incidentally
was removed.
The story's declared language now reaches the prose on the author's own page
as well as the public one, so a French story is not hyphenated by the rules of
whatever locale the author reads Hammer in.
RENDER_VERSION goes to v4 and moves into the fingerprint: it keyed the disk
cache but not the ETag, so a bump alone would have served stale prose to
anyone holding the old validator.
* Fix sync failure when order padding width changes during rationalizeTree
rationalizeTree computed each destination path with per-segment order
padding read from live disk counts, which its own moves mutate mid-pass.
When a sync dropped a directory across a digit boundary (10 -> 9 items),
later items computed ancestor names that did not exist on disk yet and
atomicMove failed with FileNotFoundException.
The pass now derives padding from the tree's child counts and moves
parents before children, building each destination from the parent's
already-finalized path, so the layout is computed once and every
destination directory exists.
* Pad rationalizeTree from disk-present children and reuse path snapshot
Padding now counts only children that resolve on disk, keeping written
names recomputable by getSceneFilePath when a tree child is missing.
Snapshot each directory's child paths once instead of rescanning after
every move, and extract the shared childScenePath helper.
* Tighten rationalizeChildren doc comment
Always-on Allowed Users list and public signup page
The whitelist toggle is gone: every server now enforces the allowed users
list, and the web UI, routes, and message keys are renamed to "Allowed
Users". The Kotlin, database, and REST names are unchanged.
Adds a public /signup page so an allowed user can create their own account
from the web instead of only through the app. It goes through
AccountsComponent.createAccount, so the allowed users list and the Terms of
Service challenge are enforced, and the POST shares the login rate limit.
A not-allowed email is audited like a failed login.
Wires the dormant DataMigrator into startup with one-time completion markers
in server_config, and adds a migration that seeds the list with every
non-deleted account.
* Move Dark Rock Studios attribution above the community section on About
Replaces the "Manuscript" section with a "Studio" section carrying the
made-by attribution and the button that opens the Cairn overlay, giving
it top billing above Community.
Hoists the Cairn overlay from AboutAppUi up to the nav shell
(ProjectSelectScaffold on Android/iOS, ProjectSelectionWindow's Content
on desktop) so its full-screen ceremony covers the nav rail/bottom bar
too, not just the About content pane.
* Force x11 until Nucleus fixes it's bug
* Handle F3 and Ctrl+Alt+S on the project window
Both shortcuts hung off a Modifier.onPreviewKeyEvent in ProjectRootUi, so
they only fired when focus sat inside the project subtree and did nothing
from the home screen. Move them to the window onKeyEvent that already owns
Esc, Ctrl+W, Ctrl+Q and Ctrl+Shift+F.
F3 now goes through ProjectRoot.startProjectSync(), which opens the sync
modal only for server-linked projects, restoring the gate the old sync menu
item had.
* Match shortcut modifiers exactly and run them pre-focus
F3 and Ctrl+Alt+S were hand-rolled in the window's when-chain, which
dropped the exact-modifier matching onKeyShortcut enforced: Ctrl+F3 and
Ctrl+Alt+Shift+S both fired. Extract that predicate as
KeyEvent.matchesShortcut and use it for both.
Move the two to onPreviewKeyEvent as well. onKeyEvent only runs when
nothing on the focus path consumed the key, so a focused editor could
swallow them; the docs claimed otherwise. The other window shortcuts stay
on onKeyEvent so a focused component can still handle Esc first.
* Give Android and iOS the project shortcuts back
Moving F3 and Ctrl+Alt+S to the desktop window left the other platforms
with nothing. Add ProjectShortcutHost, which ProjectRootScaffold binds
while the project UI is composed, and drive it from each platform's own
key hook: Activity.dispatchKeyEvent on Android and UIKit key commands on
iOS. Both are focus independent, which the Compose modifiers were not.
The iOS container lives in Swift because keyCommands is an Objective-C
category member and Kotlin cannot override those.
* Bind the project shortcuts in one place
Desktop kept its own copy of the save-all action while Android and iOS went
through ProjectShortcutHost. Move the binding down to ProjectRootUi, the one
composable all three platforms render, so each host only detects keys and
calls the host object.
That also lets the window drop the snackbar state and coroutine scope it had
hoisted purely to run the action.
* 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.
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.
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.
* 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.
* 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.
* 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