Commit graph

1410 commits

Author SHA1 Message Date
Adam Brown
cfac12ed5f Lib bumps
Remove kotlinx.datetime
2026-08-04 00:27:59 -07:00
Adam Brown
71a13d9e7b
Normalize tag needles for search and suggestions (#844)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* 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.
2026-08-03 23:33:26 -07:00
Adam Brown
c8eb4b874d
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]
2026-08-03 23:17:09 -07:00
Adam Brown
6d129c1768
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.
2026-08-03 22:26:31 -07:00
Adam Brown
1b312bc3b2
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 <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
2026-08-03 22:22:00 -07:00
Adam Brown
ad2553908b
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]
2026-08-03 22:07:22 -07:00
Adam Brown
22eef19f4e
Unify search matching on resolved Markdown (#831)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* 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.
2026-08-02 10:23:17 -07:00
Adam Brown
b9df69de84
Fix tag search dropping bodiless items and ignoring entry names (#833)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
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
2026-08-02 01:19:39 -07:00
Adam Brown
e7cdad3cdc
Allow accented and non-latin characters in tags (#829)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* 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.
2026-08-01 22:40:47 -07:00
Adam Brown
c51fc1ff8b
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.
2026-08-01 21:16:35 -07:00
Adam Brown
8edd78f052
Cover the remaining host validation cases from #797 (#819)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
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.
2026-08-01 14:21:50 -07:00
Adam Brown
f21fc6e26b
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
2026-08-01 13:30:58 -07:00
Adam Brown
707fa8dece
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.
2026-08-01 12:17:55 -07:00
Adam Brown
cace720c9a
Detect Setext and bold chapter titles on Markdown import (#807)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* 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.
2026-07-31 21:15:47 -07:00
Adam Brown
94c3465b0e
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
2026-07-31 21:15:45 -07:00
Adam Brown
5acba72867
Tell self-hosters when the server is not serving HTTPS (#800)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
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
2026-07-30 22:16:41 -07:00
Adam Brown
3a85c461dd
Stop large story imports from freezing the app
Some checks are pending
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
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.
2026-07-30 21:10:36 -07:00
Adam Brown
f043cc4c0b
Sync translations from Crowdin, and fix the glob that skipped strings.xml (#777)
* Fix the crowdin.yml glob that was skipping strings.xml

Crowdin's * requires at least one character, so strings*.xml matched the
16 strings_*.xml files but never strings.xml itself. That file had been
silently absent from every sync. The composeResources values directory
only holds value resources, so *.xml is the safe form.

The android pattern is left alone: that directory also contains theme.xml
and ic_launcher_background.xml, which must not be uploaded.

* Sync translations from Crowdin

First download since the string cleanup and the export-processor fix.

- Removes the 98 retired keys from all six locales, so every values-* now
  carries the same 984 keys as values/.
- Drops Crowdin's spurious \' and \" from the Compose resources. Compose
  never unescaped those, so they were rendering on screen.
- Picks up 38 newly added server strings, which arrive as English until
  they are translated.
2026-07-27 12:30:51 -07:00
Adam Brown
6314756c62 Clean up unused and mislabeled string resources
Remove 98 string resources that have no reference anywhere in the source.

Fix five strings that were wrong or misleading:
- notes_delete_toast_success printed the note's numeric id ("Note 4 Deleted").
  Notes have no title, so the toast no longer names the note at all.
- encyclopedia_create_entry_toast_tag_too_short is shown for an empty entry
  name, not a tag. There is no TAG_TOO_SHORT error. Renamed and reworded.
- backup_manager_delete_content_description is a visible button label, not a
  content description. Renamed.
- splash_subtitle duplicated about_description. All six locales already
  translated them identically, so the splash screen now uses about_description.
- "Time Line" is now "Timeline", matching the glossary and every other screen.
2026-07-26 20:54:14 -07:00
Adam Brown
5b17fbf429
Make clients HTTPS-only; add dev self-signed cert support (#743)
Remove the HTTP/HTTPS protocol picker and all ssl plumbing from the
client. Persisted server settings now always resolve to HTTPS, so a
legacy ssl=false server.json is upgraded on load. ServerSettings.ssl is
kept only as an internal seam for the plain-HTTP integration-test server.

Android: drop the permissive network_security_config so cleartext
traffic uses the secure platform default (blocked).

Server keeps its plain HTTP connector for reverse-proxy deployments. In
--dev with no sslCert configured, it now generates and persists a
self-signed keystore (hammer_data/dev-selfsigned.jks) and serves TLS on
a non-privileged port (8443 by default). The desktop --dev client trusts
that cert for loopback hosts only; remote hosts still get full cert and
hostname validation.
2026-07-17 00:52:41 -07:00
Adam Brown
70d902894c
Add optional Terms of Service gate for account creation (#742)
* 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.
2026-07-16 21:02:48 -07:00
Adam Brown
0b6fff1a71 Enable #tag search in Encyclopedia via shared parseQuery pattern
The Encyclopedia search box previously stripped `#` tokens on every
keystroke, making a literal `#` impossible to type and tag search
unusable. Standardize on the same combined-query idiom the project
list and Global Search use: the field holds raw text, and `parseQuery`
pulls `#tag` needles out at filter time.

- Search field binds to the raw query; `#tag` stays as literal text and
filters by tag (substring, case-insensitive, AND-combined) with the
parsed tags shown as reflected chips.
- Tag membership resolves through the shared TagIndexService; the UI
observes the TagIndex so results recompute when the index rebuilds.
- Tapping a tag on an entry card appends `#tag` to the query.
- Name matching stays whitespace-insensitive ("darkforest" finds
"Dark Forest").
2026-07-16 00:49:10 -07:00
Adam Brown
18965d93a5
Merge pull request #740 from Darkrock-Studios/worktree-koin-compiler-plugin-spike
Migrate :common DI to Koin compiler plugin DSL (compile-time safety)
2026-07-15 20:27:09 -07:00
Adam Brown
8c0af18dcc
Merge pull request #738 from Darkrock-Studios/ios-crash-handler
Add iOS uncaught-exception handler with crash dump logging
2026-07-15 16:19:19 -07:00
Wavesonics
ded3dcd504 Slim down DI bootstrap/dispatcher doc comments 2026-07-15 15:56:58 -07:00
Wavesonics
6f3051fa8d Fix iOS build: move qualified dispatchers to a plugin-less module
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.
2026-07-15 11:57:44 -07:00
Adam Brown
1fe142725b Migrate :common DI to Koin compiler plugin DSL with compile-time safety
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.
2026-07-14 23:37:54 -07:00
Adam Brown
88087fabc6 Strengthen test suites: raise detection power, fix test bugs
Repo-wide audit of the common desktopTest and server test suites:
- Replace vacuous assertions and any()-stub echoes with argument-exact
  stubs, captured-argument assertions, and persistence round-trips
- Fix genuine test bugs: cross-test fixture leak, stubs that never
  matched, tests exercising the wrong branch or fixture
- Drop brittle pins: private-field reflection, incidental call counts,
  locale-dependent formatting
- Delete zero-assertion and duplicate tests superseded by stronger ones
- Extract SYNC_DATE_PATTERN constant shared by prod and test
- Document testing philosophy in DEVELOPMENT.md
2026-07-14 22:39:21 -07:00
Adam Brown
18abe134b5
Merge pull request #737 from Darkrock-Studios/chore/darkrock-studios-repo-urls
Update repo references to Darkrock-Studios org
2026-07-14 22:08:13 -07:00
Wavesonics
9b95f4e5b8
Add iOS uncaught-exception handler with crash dump logging
Installs a Kotlin/Native setUnhandledExceptionHook alongside the iOS file
logger, mirroring the desktop and Android handlers. Logs the throwable and
writes a synchronous crash-<ts>.txt to the logs dir before handing back to
terminateWithUnhandledException so the OS still produces a crash report.

Only Kotlin exceptions (including those crossing the Kotlin/Obj-C interop
boundary) reach the hook; pure Obj-C NSExceptions and native signals are not
caught. Not yet build-verified: Apple targets require a macOS toolchain.
2026-07-14 22:08:05 -07:00
Wavesonics
89447fc4c4
FileLogger will now include crash stack traces on Android and Desktop 2026-07-14 22:01:37 -07:00
Adam Brown
876f421a9d Add component tests for previously untested Decompose components
138 new tests: ProjectRoot (+both routers), ProjectSynchronization,
StoryEditor (+Details/List routers), SceneList, FocusMode, drafts,
Notes, Encyclopedia, CreateEntry, OutlineOverview, BackupManager,
ProjectHome (replacing the empty stub), and ProjectSettings.

Extracts two menu-id string literals into component constants so
tests reference them instead of duplicating the literals.
2026-07-14 10:55:47 -07:00
Adam Brown
94dae03333 Update repo references to Darkrock-Studios org
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.
2026-07-13 16:31:11 -07:00
Adam Brown
e8c87d049a Fix Encyclopedia image selection on Android
The suspend FileKit dialog API resolves the ActivityResultRegistry from a
global set only by FileKit.init(). It was never called on any activity, so
the picker in ProjectRootActivity (encyclopedia) either targeted a stale
registry (result never delivered) or threw and was silently swallowed.
Call FileKit.init(this) in both activities' onCreate.

Once the picker returned a file, readExternalFile crashed: stageIntoCache
hands it a plain cache file path, but AndroidExternalFileIo forced every
path through ContentResolver (content:// only). Branch on URI scheme so
file paths are read directly, matching desktop/iOS.

Concise alternative if you prefer a shorter body:

Fix Encyclopedia image selection on Android

- Call FileKit.init(this) in both activities so the suspend picker API has
a live ActivityResultRegistry; without it the encyclopedia picker failed
silently.
- AndroidExternalFileIo.readExternalFile now handles plain file paths (not
just content:// URIs), fixing the crash on the staged cache path.
2026-07-12 23:24:59 -07:00
Adam Brown
bd9c94376a Merge branch 'develop' into l10n_develop
# Conflicts:
#	common/src/commonMain/composeResources/values-zh-rCN/strings.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_account_settings.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_desktop.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_encyclopedia.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_projects_list.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_scene_list.xml
#	common/src/commonMain/composeResources/values-zh-rCN/strings_sync.xml
#	server/src/main/resources/i18n/Messages_zh_CN.properties
2026-07-12 16:00:13 -07:00
Adam Brown
a8f270b16c New translations strings_project_home.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 13:04:43 -07:00
Adam Brown
23a9d2b73a New translations strings_ideas.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 13:04:42 -07:00
Adam Brown
c6f713c987 New translations strings_sync.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 13:04:40 -07:00
Adam Brown
5117369168 Remove Chinese support
The text editor does not current support RTL languages, or even non-latin alphabets. So this doesn't make sense in the rest of the UI currently.
2026-07-12 12:14:00 -07:00
Adam Brown
d0061a839d New translations strings_globalsearch.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 11:52:06 -07:00
Adam Brown
696bc1deee New translations strings_globalsearch.xml (Italian)
[ci skip]
2026-07-12 11:52:05 -07:00
Adam Brown
84837afe39 New translations strings_globalsearch.xml (Spanish)
[ci skip]
2026-07-12 11:52:04 -07:00
Adam Brown
ee81b15be6 New translations strings_timeline.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 11:51:55 -07:00
Adam Brown
899911878d New translations strings_timeline.xml (Italian)
[ci skip]
2026-07-12 11:51:54 -07:00
Adam Brown
5194860f77 New translations strings_timeline.xml (Spanish)
[ci skip]
2026-07-12 11:51:53 -07:00
Adam Brown
8da4b2bb62 New translations strings_scene_editor.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 11:51:52 -07:00
Adam Brown
386c902aaf New translations strings_scene_editor.xml (Italian)
[ci skip]
2026-07-12 11:51:50 -07:00
Adam Brown
53fa7fe864 New translations strings_scene_editor.xml (Spanish)
[ci skip]
2026-07-12 11:51:49 -07:00
Adam Brown
8933e87d87 New translations strings_project_select_navigation.xml (Portuguese, Brazilian)
[ci skip]
2026-07-12 11:51:48 -07:00
Adam Brown
d6625f8fa9 New translations strings_project_select_navigation.xml (Italian)
[ci skip]
2026-07-12 11:51:47 -07:00