Commit graph

3406 commits

Author SHA1 Message Date
Adam Brown
ce54ad7054 Update AGP
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
2026-08-04 00:32:23 -07:00
Adam Brown
e2906293bc Upgrade Rich Text Editor 2026-08-04 00:30:42 -07:00
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
5ab2afabee Guard the English message bundle against dropped keys
English is the fallback bundle every locale resolves through, so a key the
server looks up but Messages_en.properties does not carry throws
MissingResourceException at request time. A Crowdin sync rewrote the English
source file and dropped 129 live keys, which 500ed the home page, account
refresh, and account deletion.

The test scans msg/Msg.r/localizedMsg call sites and mustache msg.* references
and asserts each resolves in English. Parity is checked against referenced keys
rather than the translation files, which Crowdin owns and which carry strings
that have outlived their use in the templates.
2026-08-03 23:24:26 -07:00
Adam Brown
ba727a1a2d
Keep the drop target live while the list autoscrolls (#845) 2026-08-03 23:18:16 -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
eadb412805
Update org.jetbrains:markdown to 0.7.8 (#846)
Patch release: nested code fences parse correctly, reference link
labels must immediately follow the link text, and username@host is
recognized as part of a URL.
2026-08-03 22:58:46 -07:00
Adam Brown
3509321b24
Resolve drop targets against drawn row geometry (#842)
* Resolve drop targets against drawn row geometry

* Report row geometry in display order and keep the insert line live
2026-08-03 22:34:19 -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
83c8d4feab
Fix scene drag grabbing the wrong row (#839)
* Fix scene drag grabbing the wrong row

The drag gesture resolves the pressed row against
LazyListLayoutInfo.visibleItemsInfo, which reports each item's target
offset. With animateItem() sliding rows into place, a row is drawn
somewhere other than where layoutInfo says it is, so a press right after
a move grabs whichever row now owns that target slot: the one below.

Drop the placement animation so the layout geometry the whole drag stack
reads is the geometry on screen. Fades on add/remove are kept.

* Resolve the grabbed row by hit test so placement can animate

* Scope the drag candidate to the pointer that claimed it
2026-08-03 21:20:12 -07:00
Adam Brown
a523cd521b
Float the scroll-away footers over their lists (#834)
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 Outline Overview strip was a sibling in the scene list's Column, so
showing and hiding it resized the LazyColumn. Combined with the rule that
pins it open once the list bottoms out, that formed a feedback loop: the
strip appears, the list shrinks, the list can scroll forward again, the
strip hides, the list grows, and it repeats every frame. Fixes #827.

Extract HdScrollAwayFooter, which hosts the strip as an overlay in the
same Box as the list and reports its measured height for the list's bottom
content padding, so the last row still clears it. Hiding now waits until
the list has consumed more downward scroll than the strip's own height,
which keeps a list whose only scroll room is that padding from hiding and
immediately re-pinning.

Apply it to the project list's create bar too, which had the same
resize-on-hide shape.

* Hide the scroll-away footer on direction, not distance

The distance threshold reset on any upward pixel, so the jitter in a real
drag kept it from ever accumulating and the strip hid only sometimes.
Responsiveness matters more here than the narrow case it was guarding:
a list whose only scroll room is the strip's own bottom padding can still
hide the strip and re-pin it at the bottom.
2026-08-02 23:19:38 -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
804d9cb75c
Harden the remaining E2E workflow clicks against dropped taps (#822)
Reuse clickUntil for the navigational and menu clicks in the timeline,
scene-list, scene-editor and project-lifecycle tests. Also waits for the
add-menu to open before picking an item, which previously ran with no wait
at all.

Mutating confirms are left as single clicks: those buttons stay on screen
across the async create, so a retry would persist a second item.

* Make clickUntil prove the target existed before reporting success

clickUntil skips the click while its matcher resolves to nothing, so a landed
condition that is already true returned success having clicked nothing, and a
target that never rendered failed as "click never took effect".

Track whether a click was injected: never return without one, and report a
target that never appeared as its own failure. Drops the per-site pre-waits
that duplicated this.
2026-08-01 17:01:18 -07:00
Adam Brown
210696f66a
Send htmx callers somewhere when access is denied (#823)
Both access-control plugins built their htmx denial with createHTML().div, whose result was discarded, so the response was an empty <html></html> and the caller saw nothing happen.

Redirect by header instead of trying to deliver a message in the body. htmx acts on HX-Redirect before it decides what to do with the body, which matters here: it discards the body of a 4xx outright, and StatusPages answers a 401 with the whole unauthorized page, which must not end up swapped into a fragment. Browser requests keep the redirect they already had, so both kinds of caller now land in the same place.
2026-08-01 16:33:35 -07:00
Adam Brown
4804534151
Add visible scrollbars to scrolling screens (#820)
Fixes #787. Settings and other long screens had no scrollbar, so on
Linux there was nothing to indicate the page scrolled at all.

Adds ScrollState, LazyGridState and LazyStaggeredGridState scrollbars
alongside the existing LazyListState one, and wires them into the
screens that scroll: account and project settings, About, project
stats, the encyclopedia/notes/timeline/story-idea detail views and
browse grids, the outline reading column and chapter rail, the scene
metadata panel, and the shared reorderable list.

Compose ships no staggered-grid scrollbar adapter, so that one is
implemented over the state's ScrollIndicatorState.

Desktop draws the bar as a hatched rail: an outlineVariant hairline on
the scrollable edge with 45 degree hatching behind an opaque thumb, so
the hatch that remains marks the part of the document still to come.
Both are drawn only while there is somewhere to scroll. Android keeps
its transient fading thumb, now themed rather than black on white, and
iOS keeps its native indicators.

Screens overlay the bar with scrollBarOverlay(); full-bleed content
reserves MpScrollBarGutter so the rail does not sit on top of it.
2026-08-01 15:45:52 -07:00
Adam Brown
199bc7dc6d
Show error toasts on htmx error responses (#818)
htmx discards the body of a 4xx, so the out-of-band toasts the dashboard's rejection paths send never reached the user.

The toast helpers mark error responses with X-Hammer-Swap-Error and toast.js swaps the ones carrying it. Marking each response rather than every 4xx keeps the swap away from responses that are not swap payloads: a bare respond(BadRequest) would blank its target, and a 404 or 401 would land a whole error page inside it. Toast-only errors also reswap to none so the toast lands without emptying the request's target.

Delete-account no longer needs its 200-on-error workaround.

Fixes #812
2026-08-01 15:43:48 -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
23502a6ea1
Stop the encyclopedia E2E test failing on a dropped tap (#817)
createEntryThenOpenIt intermittently timed out waiting for the create FAB
to disappear: the tap on the new entry card never became an onClick, so the
browse list never navigated.

The card is present, displayed and correctly positioned when this happens.
Its centre resolves to the card's own clickable rather than the type stamp
or a tag chip, the outgoing create screen is gone well before the card
appears, and the card's bounds are stable from the frame it renders. Only
the software renderer CI uses reproduces it, at roughly one run in six.

clickUntil re-injects the click until the expected outcome shows up, the
same way typeIntoEditor already re-injects keystrokes. It stops re-clicking
once the matcher resolves to nothing, so a slow but successful navigation
does not get a second click on the next screen.
2026-08-01 13:40:13 -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
dd9774c34c
Self-service account deletion (#815)
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
* Add self-service account deletion

Users can delete their account from the web dashboard danger zone. The
account is soft-deleted: locked out of login and sync, all stories
unpublished, pen name released, data retained for a configurable window
(accountDeletion.retentionDays, default 30 days) during which an admin
can restore it from the users page. A daily job permanently purges
accounts past the window. Admin accounts cannot be deleted; the guard is
enforced in the SQL, the service, and the UI.

* Harden account deletion edge cases from review

softDelete verifies the deleted flag actually landed before running its
destructive steps, and retries re-run the idempotent cleanup so a partial
failure heals; markDeleted leaves an already-deleted row untouched so
retries never extend retention. Tokens of soft-deleted accounts are
hidden inside the token query itself, restoring the whitelist-off
single-query bearer auth path. Re-registration against a soft-deleted
email returns the pending-deletion message instead of a misleading
"account exists", and the delete dialog warns that the email stays
reserved. Shared test account builder replaces per-file duplicates.
2026-08-01 02:59:30 -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
eeba953897 Fix download link 2026-07-31 17:06:16 -07:00
Xander Smeets
ff8b278195
Set file permissions correctly immediately (#799)
Some checks are pending
Build CI / iOS UI tests (push) Waiting to run
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
PublishInternal / publish-google-play (push) Waiting to run
Perform the chown on the files copied into /opt/hammer during the COPY instruction, instead of during a separate RUN instruction. This prevents the files from being 'duplicated' due to having been changed on the layer for the RUN instruction. Effectively, this reduces the size of the layer for the RUN instruction from about 211 MB to approximately 0.
2026-07-31 08:50:51 -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
22520baca3
Fix flaky iOS scene-editor save UI test (#786)
tapUntilGone gated the save tap solely on isHittable. Under CI load the
top-bar save button's accessibility frame can never settle to hittable
within the timeout, so the loop waited out the whole budget without ever
tapping and failed with 'Element still present after 20.0s'.

Keep the proven isHittable hit-tested fast path and add a geometry
fallback: once the button's frame has settled (fully below the status
bar, ruling out the transient dead-pixel position, and stable across two
samples) tap it via tapCenter. This guarantees a tap eventually fires
even when isHittable never flips true, while still avoiding the
dead-pixel/scroll-away hazard the isHittable gate originally fixed.
2026-07-28 22:38:18 -07:00
Adam Brown
54538da8a6
Stop the E2E teardown racing the project-scope flush (#780)
The instrumented tests delete their project directory in teardown, but
finish() and waitForIdleSync() only drain the main thread. The Koin
project-scope close flushes scene buffers on IO coroutines after that, so a
straggler write can land between deleteRecursively emptying the directory and
removing it. The rmdir then fails with "failed to delete <project dir>".

Seen on develop in both ProjectLifecycleTest and SceneEditorWorkflowTest, so
it is the shared teardown rather than any one test.

There is no signal to await for that scope close, so retry the delete until
the flushes stop. Each attempt removes whatever reappeared, and it still fails
loudly after 15s rather than hiding a real leak.

ProjectLifecycleTest now uses the same helper instead of deleting inline.
2026-07-27 14:35:52 -07:00
Adam Brown
fdeab5b992 Prepared for release: v3.7.2 2026-07-27 13:45:42 -07:00
Adam Brown
f9f57ca510
Redesign the home page for writers (#779)
It was very oriented towards technical details before, now it's more about hammer's value for writters
2026-07-27 13:44:55 -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
34af8d3013
Rename the Android string resources to strings_android.xml (#776)
Crowdin's Custom Post-Export Processor only receives a file's name, not its
path, so it could not tell the Android resources apart from the Compose ones
while both were called strings.xml. Everything Crowdin manages under
android/src/main/res/ now carries the _android.xml suffix, which the export
snippet uses to leave AAPT's quote escaping intact.

AAPT does not care what the file is called, and crowdin.yml already globs
strings*.xml, so neither needed changing.
2026-07-27 08:49:05 -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
74850d9fb2 Clean up, the feature is implemented now 2026-07-25 10:17:17 -07:00
Adam Brown
b1c06e791b
Add Docker support for self-hosting the sync server (#771)
* Add Docker support for self-hosting the sync server

Provide an official Docker image for self-hosting the Hammer sync server,
alongside the existing Java executable distribution.

- docker/Dockerfile: slim, non-root runtime image on a glibc base
  (eclipse-temurin:21-jre-jammy). Runs unprivileged because the embedded
  PostgreSQL binaries refuse to run as root, and stays on glibc because those
  binaries are not musl-compatible. Pins user.home to /data so a single volume
  holds the database, caches, keyring, and config. The image packages the
  pre-built application distribution rather than compiling from source, since
  :server depends on :base and a source build would need the Android SDK.
- docker/docker-compose.yml, config.example.toml, README.md: turnkey
  self-hosting with a data volume and optional host-managed config.
- .github/workflows/publish-docker.yml: builds the distribution and publishes a
  multi-arch (amd64/arm64) image to GHCR on release. One build serves both
  arches because the distribution is pure JVM bytecode with the embedded
  PostgreSQL binaries for every OS/arch bundled inside the jars.
- .dockerignore: trims the build context to just the built distribution.
- docs/HOW-TO-RUN-A-SERVER.md: document the Docker path and drop the stale
  "Eventually we'll add Docker images" note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ

* Docker: pre-create data dir so config bind mount works unprivileged

Bind-mounting a config file at /data/hammer_data/config.toml would make Docker
create the parent dir as root when it doesn't already exist, leaving the
non-root server unable to write pgdata. Pre-create and chown /data/hammer_data
in the image so a named volume initializes with it hammer-owned, and clarify the
config provisioning paths in the Docker README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KJos31QSeA2YHkCU44nNZ

* Docker: configurable host port and a BOM caveat for config.toml

Both found while testing the image end to end on Windows.

Compose merges `ports` lists by appending, so the hardcoded 8080 mapping could
not be overridden from an override file. Drive it from HAMMER_HTTP_PORT instead,
which is the usual escape hatch when the host port is already taken.

A config.toml saved as UTF-8 with a BOM fails with an UnexpectedTokenException
pointing at line 1, which reads like a syntax error in the file. Notepad and
PowerShell's Out-File -Encoding utf8 both write one, so call it out.

* Docker: note the one-time GHCR package visibility step

The package is created automatically on first publish, but may land private,
which would break the anonymous docker pull the docs point self-hosters at.
Left as a manual step because making a package public cannot be undone.

* Docker: note why the packages:write permission block is required

The repo default workflow token is read-only, so the explicit block is what
makes the GHCR push work rather than 403.

* Docker: document external PostgreSQL, add init for orphan reaping

Remote storage already worked (the DI picks RemotePostgresDatabase and the
schema initializer runs on first connect), but nothing in the Docker setup
showed how to use it. Add a commented-out postgres service with the matching
depends_on, and a README section covering it.

The service mounts its volume at /var/lib/postgresql: postgres 18+ images abort
startup when the mount is at the older /var/lib/postgresql/data path.

Call out that storage.remote.useSsl defaults to true, which fails against a
plain postgres container that serves no TLS.

init: true reaps orphans from the embedded PostgreSQL process tree. Shutdown was
already graceful without it - the start script execs, so the JVM is PID 1 and
runs its shutdown hooks on SIGTERM - but the JVM does not reap orphans.

* Docker: split out a dedicated hosting doc and fix review findings

Move the Docker hosting guide to docs/HOW-TO-RUN-A-SERVER-DOCKER.md and reduce
the inline section in the main guide, plus docker/README.md, to pointers so the
three cannot drift apart.

Fixes:
- Pin user.home through SERVER_OPTS rather than JAVA_OPTS. The start script
  appends both, so an operator setting JAVA_OPTS for heap was silently moving the
  data directory to /home/hammer, stranding the volume and starting an empty
  database.
- Publish the plain HTTP port on 127.0.0.1 by default, overridable with
  HAMMER_HTTP_BIND. Published ports bypass host firewall rules, so the previous
  0.0.0.0 default could put cleartext credentials on the internet.
- Warn that bindHosts must not be set under Docker; it binds the container
  loopback, leaving the server unreachable but still reporting healthy.
- Gate the release trigger on the +server tag convention so client-store-only
  releases no longer republish and move latest.
- Add a ref input so a dispatch that names a version builds that ref.
- Correct the claim that cert paths resolve relative to the data directory, and
  document that renewals need a container restart.
- generate-keyring example now writes to the volume with --out.
- storage.remote host is postgres, matching the compose service name.

Also trims the narrating comments across the Dockerfile, compose file, and
workflow.

* Docker: document running the admin CLI subcommands in a container

The embedded PostgreSQL holds an exclusive lock on pgdata, so subcommands that
read the database (prune-key --role content, --converge-dry-run) cannot run
while the server container is up. It fails safely rather than corrupting
anything, but the operator has to stop the server first, and nothing said so.

Adds a table of which commands need the database, the stop/run/start sequence,
and a key-rotation walkthrough noting that a rotated keyring only takes effect
on restart. The lock is embedded-specific; remote storage has no such
constraint.

* Docs: drop em dashes from the Docker hosting guide

* Docs: drop em dashes from the server hosting guide

* Remove PageSpeed PDFs accidentally added to this branch

These were untracked working-tree files swept in by a `git add -A`; they are
unrelated to the Docker work and stay on disk.

* Pin the docker/* actions to commit SHAs

Matches how every other third-party action in this repo is referenced, and
clears the supply-chain findings Codacy raised on the PR. Kept within the major
versions the workflow was written against rather than moving to the newer
majors, since the workflow has not run yet.

* Clarify why +server releases publish the Docker image

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 09:53:04 -07:00
Adam Brown
ba1f7fd2c2
Address PageSpeed Insights findings for the web frontend (#772)
hammer.ink scored 90 desktop / 61 mobile on Performance (LCP 11.1s,
FCP 4.2s) with a 1.3 MiB hero image and ~3.7s of render-blocking CSS
dominating the cost. No build step is added; generated artifacts were
produced by one-time local commands and committed.

Hero image: masthead.jpg (2976x1200, 1.28 MiB) replaced with WebP
variants (163 KiB desktop, 54 KiB mobile) plus a JPEG fallback via
image-set(), preloaded with fetchpriority="high". Preload media queries
exactly complement the .masthead breakpoint so only one variant is
fetched.

Render-blocking: error.css is now delivered per-page via page_stylesheet
instead of on every request; Lora moved out of an @import in base.css to
a head <link> with preconnects; Font Awesome loads at media="print" and
is promoted by a new async-css.js with a <noscript> fallback; htmx is
deferred.

Fonts: font-display: swap on Kingthings, plus a WOFF2 (137 -> 50 KiB)
listed ahead of the TTF.

Caching: asset URLs carry ?v=<build version>. Versioned requests get
max-age=31536000, immutable; unversioned ones keep the existing 1 day /
7 day windows so stylesheet-internal references cannot go stale.

Accessibility: aria-labels on the download selects and links, heading
hierarchy fixed so each page reads h1 -> h2 -> h3 (the per-page
site-title h1 is now a span), and .footer-version raised to 6.9:1
contrast. New keys land in Messages_en.properties only.

Also sets Cross-Origin-Opener-Policy: same-origin.
2026-07-25 09:52:35 -07:00
Adam Brown
3375d81b55 Prepared for release: v3.7.1 2026-07-23 14:36:28 -07:00
Adam Brown
562b736bc9 Split store release notes from the full changelog
App stores reject release notes that describe anything but the app, so
the release dialog now has a Full tab and an App stores tab. Play,
F-Droid, macOS, iOS and Flathub take the app-only notes; CHANGELOG.md
and the GitHub release keep the full text.

Entries are filtered by audience tag: an opening [Fix/Web] tag, with or
without a leading dash, and a single-word Web:/Server: label. Prose that
merely mentions a server keeps its bullet. Everything removed is listed
on the App stores tab.

Truncation now works on whole entries, so a skipped bullet takes its
continuations and sub-bullets with it, pruned headers do not spend
budget, and the ellipsis appears only when something was left out.

A release with no store notes leaves the store metadata untouched
instead of blanking it.
2026-07-23 14:12:46 -07:00
Adam Brown
d2c13b72df Update privacy policy 2026-07-21 15:56:41 -07:00
Adam Brown
a79d2c2652
Rename SqliteTestDatabase to SharedPostgresTestDatabase (#766)
The class is backed by embedded Postgres, not SQLite, and the old name gave
no hint that the instance is a handle onto one process-wide database shared
by every test in the JVM. Document that its truncate is the only isolation
boundary, so anything that can write on its own schedule must be shut down
before a test ends.

* Serialize access to the shared test FakeFileSystem

The fake is shared by the test thread, the server's Jetty threads and the
client's dispatcher threads, and it is not thread-safe: it tracks open files
in a plain ArrayList. An autosave write on the client's dispatcher thread
while a sync read on the JUnit thread iterated that list threw
ConcurrentModificationException, failing the sync and with it
StaleAllocatorAfterSyncTest.

Wrap it in a SynchronizedFileSystem that locks every call, including the
returned Source/Sink/FileHandle — the fake mutates its open-file list when
those close, not only when they open.
2026-07-21 15:36:17 -07:00
Adam Brown
66826fdf10
Fix Mac App Store publish hanging forever on build processing (#769)
Fix Mac App Store publish hanging forever on build processing

The Mac App Store release job hung indefinitely on "Publish to App Store",
spinning until Actions' 6h default killed the (10x-billed) macOS runner:

    Waiting for App Store Connect to finish processing the new build
    (3.7.0 - 24) for MAC_OS

Root cause: the release lane ran upload_to_testflight with the build-processing
wait enabled. That wait lives in FastlaneCore::BuildWatcher, which polls each
build's processingState until it reads complete. For macOS builds the App Store
Connect API never reliably reports processingState as VALID, so it polls
forever even though the upload succeeded and the build is usable. iOS is
unaffected because its processingState does flip. deliver's select_build only
avoids the same hang when handed an explicit build number (direct lookup); a
nil/"latest" number routes it back into BuildWatcher.

- Fastfile (mac): skip_waiting_for_build_processing on the upload, and always
  submit by an explicitly resolved build number so deliver takes the
  direct-lookup path and never re-enters BuildWatcher. Widen the submit retry
  budget for the release flow. iOS keeps its working wait-based flow.
- Workflows: add timeout-minutes: 120 to both App Store jobs as a hard cost
  backstop, and a desktop_submit dispatch lane on the Mac workflow (mirrors
  iOS) to submit an already-uploaded build without a rebuild.
- Add an Announce Release workflow plus a shared discord-release-message.sh
  script, so a release's Discord message can be posted for a given tag when the
  automated notify job was skipped (e.g. a cancelled run). notify now builds
  its message from the same script, so the two can't drift.
2026-07-21 15:33:17 -07:00
Adam Brown
8742202de5 Prepared for release: v3.7.0 2026-07-21 09:10:11 -07:00
Adam Brown
7ab8d656a7
Bump material-kolor to 5.0.0 (#764)
* update testcontainers

* Update material-kolor to 5.0.0

* Regenerate desktop aboutlibraries metadata
2026-07-21 09:04:04 -07:00