* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
The delete dialog called deleteProject from its click handler, so the
metadata read, recursive delete and sync data write all blocked the UI
thread. Run them on the IO dispatcher.
---------
Co-authored-by: Sean Li <22557931+sean0407@users.noreply.github.com>
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* Give the snap libhunspell and dictionaries so spell check works
The strict snap sees core24's /usr, which has neither, so spell check never
loaded a dictionary. Stage libhunspell, mount the hunspell-dictionaries
content snap over /usr/share/hunspell, and point JNA at the staged library.
* Test that markdown editors underline misspellings in loaded text
* Point the snap's spell check at its dictionaries through DICPATH
platform-spellcheckerkt 1.4.0 searches DICPATH, so the /usr/share/hunspell
layout is no longer needed.
* Format bar: a quote that saves, toggles as the editor has them, and disabled states
The quote button toggles the editor's quote block, written as "> ", where its inline
style exported as nothing. Bold, italic and strikethrough use the editor's toggle and
show active only when the whole selection has the style. In a table cell the block
buttons are disabled and the rule button does nothing; undo and redo show disabled
when there is nothing to undo or redo.
base.css fills hovered pagination buttons with the accent color, which the
authors and feed outline styles didn't override. Disabled buttons also picked
up the filled hover.
* Keep unsaved scene buffers when the project scope closes
Scope close deleted every temp buffer, so an Android activity destroyed in the background lost unsaved edits. Only clean buffers are cleared now, Discard discards explicitly, export reads unsaved buffers, and the Android back handler observes dirty state.
* Discard the buffer of an archived or deleted scene without reloading
* Save the buffer of an archived or deleted scene without throwing
* Drop a scene's unsaved buffer and temp file when it is deleted
* Gate sync behind the unsaved-work dialogs and make Discard really discard
Sync flushed every dirty scene buffer in prepareForSync, so an automatic sync on
close committed scenes the user had just chosen to discard, and manual syncs
silently saved everything. startProjectSync now builds the same confirmation set
as requestClose (scenes, notes, encyclopedia, timeline) and ends in Sync instead
of Complete, so the existing dialogs run before the sync modal opens.
The Discard buttons previously only cleared the confirmation; they now revert the
scene buffers to disk and drop in-progress entity edits (or pop a create screen)
through a new Router.discardUnsaved hook.
* Close the remaining ungated sync paths and harden discard
Route the Home screen's Sync action through startProjectSync so it hits the
gate, ignore startProjectSync while a close request is pending, and stop the
scene synchronizer from persisting dirty buffers in prepareForSync and
finalizeSync: upload content and hashes already come from disk, and every
interactive sync is now confirmed up front, so the flush only ever committed
buffers nobody was asked about (crash-recovery buffers during the startup
account sync, for one).
ProjectRoot.discardUnsaved is now a suspend call that reverts scene buffers off
the main thread, and the entity components check and discard across their
whole stack rather than only the active destination.
* Refresh open views when their entity changes underneath them
Note, timeline event, encyclopedia entry and scene metadata views loaded
once and went stale after sync. They now follow their repository flows,
keeping an in-progress edit and only moving its baseline.
* Address review findings on open-view refresh
- An untouched open edit follows the synced change; only a dirty edit keeps its draft
- Entry reloads are serialized, survive I/O errors, and only run for the open entry
- A note deleted elsewhere closes its view unless a dirty edit is open
- Synced scene metadata is no longer marked dirty or written back by the panel
- Sync keeps the local draft name; reference use cases name their update source
* Catch specific exceptions in the entry refresh
* Offer in-app updates on the GitHub desktop builds
AppUpdater in :common with a no-op for every store build, and a Nucleus
backed NucleusAppUpdater in :desktop that checks shortly after launch and
every six hours while the new automaticUpdateChecks setting allows it. One
action, update(), downloads, verifies and hands the installer over; MSI,
EXE, DEB, RPM and AppImage installs relaunch on the new version, macOS
installs are told and sent to the release page until the signed-zip work
lands. A notice strip above the project list, a status row on the About
screen, and a settings toggle, each with previews and render tests.
* Keep an offered update on screen while it is re-checked
A check only shows Checking when nothing better is known, so the six-hourly
re-check no longer blinks the banner away. The updater's shared fields are
volatile, the banner and the About row share one state-to-text mapping,
the download test hands the installer over through a CompletableDeferred,
and the desktop platform settings component gets a test for the new toggle.
* Lay the update notice on the page and allow an environment override
The banner now renders inside the project list under the masthead, inset
to the page gutter, instead of as a strip above the whole content pane
butting against the nav rail. HAMMER_UPDATE_BASE_URL stands in for the
system property so an installed build can be pointed at a local server,
which is how the update from a 3.10.99 test build to the published 3.11.0
MSI was exercised end to end. The download test cleans up its staging
directory, as the real installer script does.
* Give the Android project selection preview fake the update members
* Fix backup restore nesting the project inside itself
Backup archives are rooted at the project's directory name, so unzipping
into the project directory left it without a project.toml at its root and
the project vanished. Restore now extracts to a staging directory and
swaps the unwrapped project into place, leaving the existing project
untouched if extraction fails.
The Backup Manager also lists backups of projects that no longer exist,
and reports a failed restore.
* Harden backup restore swap and missing-project listing
Move the existing project aside instead of deleting it so a failed swap
puts it back, serialize restores that share the staging directory, and
list a missing project once when it has both legacy and current backup
names.
* Stamp builds with their package format and publish update metadata
-Pformat=<token> is resolved in buildSrc, baked into BuildMetadata, and
printed in the startup banner next to the channel; every packaging step
passes its own. The MSI UpgradeCode is pinned to the value every installed
copy already carries. writeUpdateMetadata renders latest*.yml from the
attached installers and the release workflow uploads them to the draft
before it publishes. HammerReleaseProvider points the Nucleus updater at
releases/latest/download so partial-release tags cannot break downloads.
* Hash installers where they are built instead of re-downloading them
Each platform job now writes a <installer>.sha512 sidecar beside what it
attaches and hands the sidecars to the final job as a workflow artifact.
That job assembles latest*.yml from them and attaches the yml with the same
release action every other job uses, so nothing is downloaded back from the
draft and nothing depends on looking a draft release up by tag.
Three new options in the Export Story dialog, from user feedback on Word, EPUB and PDF exports.
- Number chapter titles: off by default for every format. On prefixes each title with its position in the headings and the Contents page. Word, RTF, PDF and Markdown used to number always; EPUB never did and now can.
- Font: Georgia, Times New Roman, Garamond, Arial, Verdana or Courier New for Word, RTF and EPUB. PDF keeps its built-in face.
- Keep blank lines between paragraphs: on by default. Off drops a lone blank line so the indent is the only separator; a run of two or more, and the boundary between scenes, still leaves one break.
* Derive the Mac App Store build mode from the distribution channel
The App Store build was detected two ways: Gradle read -PmacOsAppStoreRelease
for packaging and passed -Dhammer.app.store=true so IS_APP_STORE could gate
runtime behaviour. The channel enum already knows the same fact from
-Pchannel=mac-app-store, which build-appstore.sh passed alongside.
isAppStoreRelease now resolves from the channel token, the JVM property and
IS_APP_STORE are gone, and the three runtime readers (sandbox startup, the
bookmark-backed file access binding, the in-app changelog gate) branch on
DistributionChannel.current like every other per-vehicle rule.
* Share the channel resolver across build scripts and name the App Store check
Project.distributionChannel() in buildSrc owns the -Pchannel and F-Droid
detection, so :base and :desktop cannot disagree on the channel.
DistributionChannel.isMacAppStore replaces three spelled-out comparisons,
and supportsInAppChangelog is a pure function of the channel with a test
pinning the table. Docs corrected: section 1 is done, GitHubProvider only
uses the REST API for pre-release channels, and the App Store build also
needs -PbuildNumber.
Dark theme with a pure black background and base surface. Applies to project color themes and Android dynamic color too.
Offer the OLED theme on Android and iOS only
Read the live story as prose, one chapter at a time, from the story
editor details pane. Opens from the scene list footer, the scene editor
top bar, or the editor menu.
- Scenes in a chapter are stitched together with an ornament between
them; a toggle shows scene headings instead.
- While the reader is open the scene list works as a table of contents,
and its highlight follows the scene being read.
- Reader font size is its own setting. Text is selectable.
- Chapter and scene position survive rotation and process death.
Chapter collection is now shared with export, and the outline's chapter
pieces move into the design system. MarkdownView gains opt-in
importDuringComposition and isSelectable. The footer's outline button
uses a new short "Outline" string.
* Update ComposeTextEditor to 3.0.2
Styles move to TextEditorState.richTextStyles, headings use the block API so
they export as headings, and shortcuts match on layoutKey. Markdown pins
ParagraphSeparator.NEWLINE to keep the 2.8 on-disk format.
* Make the editor text size a render property
Rich text styles are a fixed set with em-relative headings, so the body size
lives on the editor text style and changing it rewrites no document. The
size steps through a ladder, shows its value in the format bar menu, and
answers Ctrl+Plus, Minus and 0.
* Recover from an undecryptable auth token keyset instead of crashing
Exclude the encrypted token prefs from backups, and attach the R8 mappings
to GitHub releases.
* Reset the Keystore master key along with the token prefs
2.6.0 was built against Compose 1.11 and lacks the IME members 1.12 made
abstract, so focusing an editor on iOS threw IrLinkageError on
ImeComposeStateAdapter.text and killed the app.
URL, numeric, tag, word-list, and delete-confirmation fields inherited
sentence capitalization and autocorrect, which inserted a space after
'.' in server URLs.
The desktop implementation of calculateWindowSizeClass reads an AWT window,
which does not exist under the Tao backend, and its LocalWindow reference was
removed in Compose 1.12. Size classes now derive from layout constraints.
Signed-in readers can leave kudos at the end of a publicly published
story: up to four craft chips and one reaction. There are no comments
or ratings, so there is nothing to moderate.
- Kudos are anonymous. Authors see per-chip totals on their story page;
the public page shows a chip's name, never a count, once three readers
pick it.
- The reader card loads as its own HTMX fragment on the last page, so
the story page's ETag never depends on kudos. Private shares never
show it.
- Authors can turn kudos off per story; existing kudos are kept.
- New story_kudos and story_kudos_opt_out tables (migration 9.sqm).
Chip keys are a code enum, rows are purged in both directions on
account deletion, and soft-deleted givers drop out of the counts.
- Story URL resolution is shared between the story page, the read
beacon, and the kudos routes.
Only count runs of non-whitespace containing a letter or digit, so
horizontal rules, heading, bullet and blockquote markers are not words.
Move countWords to :base and use it on the server too.
Headers take the library's own transformation, free text gets a side
inset and sync log entries are capped at five lines so they stay inside
a round bezel. The capture splash holds until the project list loads,
list content modifiers are remembered per item, and one app theme is
shared by the manifest and the splash style.
Branded launch: core-splashscreen theme shows the launcher icon on black
for MainActivity and CaptureActivity.
Play listing names the Capture tile and the complication.
Every TransformingLazyColumn item now uses the Material 3 scroll
transformation so buttons, headers and text shrink and fade at the
bezel instead of being clipped on round displays. The custom
screenContentPadding helper is dropped; the scaffold's padding already
includes the horizontal inset.
Add a project-scoped user spelling dictionary (#939)
Writers can whitelist words per project: "Add to dictionary" on a flagged word in
the scene editor and focus mode, plus an add/remove word list in Project Settings
under Spell checking.
Words live in ProjectData.dictionaryWords so they sync with the project. A conflict
confined to the word list merges both sides by union with no resolver; other
conflicting fields still go through the existing resolver with the dictionary
unioned. The union is verbatim, so a word a newer build stored under laxer rules
is never deleted server-side. Sync writes also re-apply any project-data edit made
after the phase snapshot, so an edit landing mid-sync is no longer clobbered.
ProjectDictionaryService now feeds user words to the checker alongside encyclopedia
words, independent of the encyclopedia toggle. HdHairlineTagField and the new
HdHairlineWordListField share an extracted HdHairlineChipInput.
Encyclopedia entry titles and scene draft names were still on the old
restricted set while project and scene names had moved to the shared
ProjectNameValidator. Both now use that validator, so punctuation like
`. , ! ? : ( ) & - "` works, and draft names accept non-Latin letters.
Both filename formats move to the `~` delimiter, since `-` is now a legal
name character:
encyclopedia type~id~name.toml, images type~id~image.ext
drafts sceneId~draftId~name~timestamp.md
Names round-trip through encodeForFilename/decodeFromFilename, so
OS-forbidden characters become lookalikes on disk. Legacy patterns are
kept for reads, and path resolution falls back to the legacy filename,
because unlike scenes these two rebuild the filename from the def rather
than scanning the tree.
Migration2_3 renames existing files and PROJECT_DATA_VERSION goes to 3.
It canonicalises names through the encoder, otherwise a name ending in a
space (which the old rules allowed) would migrate to a file that no
rebuilt path could ever match. Draft names are now trimmed at the save
and sync boundaries the way entry names already were.
A synced name containing a path separator is no longer rejected outright;
it is encoded to a lookalike and stays one path segment, matching how
scene names already behave. Containment rests on the isWithin guard. The
reserved `~` delimiter is still rejected.
A standalone watch client that dictates notes into subscribed projects
and story ideas, then syncs them on its own session.
- New :wear module: capture activity, tile and complication, project
subscriptions, sync log, and a WorkManager periodic plus
after-capture sync behind a single SyncCoordinator
- Pairing over the Wearable Data Layer: the phone confirms the request
and mints a session for the watch's install via the new
/api/account/pair_install endpoint. Play Services builds only; the
F-Droid build ships without it
- Manual sign-in on the watch, with an Android 17 local network
permission check before contacting a LAN server
- Plaintext sync is opt-in: typing an http:// URL selects it, with a
warning in server setup. HTTPS stays the default, and legacy
settings always restore as HTTPS
- Account sync extracted into SyncAccountUseCase, shared by the phone
project list and the watch
- temporaryProjectTask ref-counts concurrent users so one task no
longer closes a scope another is still using
- Wear version codes are offset from phone codes so both can ship in
one Play listing
- Crash handler and FileLogger moved into common for reuse
The iOS build now ships under the same App Store record as macOS, so the
home page and README link to id6770841038.
* Offer the iOS app with a smart app banner on the home page
* Say why a login failed instead of answering a bare 401
A failed login gave nobody anything to work with. StatusPages matched status 401
and re-responded with a bare status for API calls, discarding the HttpResponseError
the route had just written, and the client's own 401 branch discarded whatever
body did survive in favour of a generic string. Two layers erasing the same
message, so a self-hoster saw "401 Unauthorized" in the log, an empty body on the
wire, and a generic message in the app.
StatusPages now leaves API responses alone, since the routes answer 401 with
their own body, and the client parses the body for every status and only falls
back to a generic message when the server sent nothing usable.
On top of that:
- HttpResponseError carries an optional errorCode from a shared ApiErrorCode
vocabulary. It is optional and the shared serializer ignores unknown keys, so
it is compatible with servers and clients on either side of this change.
- Account creation failures use accurate statuses rather than a blanket 409:
400 for a policy or email violation, 409 for a real conflict, 403 for the
whitelist. Login answers 403 when the whitelist was the problem, since that is
not a credential failure.
- The login path logs which failure occurred. The response still cannot
distinguish an unknown account from a wrong password, because that would let
anyone enumerate users, but the operator's log now can.
The docs gain the password policy (8 to 64 characters, no complexity rule,
nothing stripped or truncated) and the log lines to look for, which is what the
reporter of #835 asked for.
* Allow the round-trip test accounts now that Allowed Users is always on
Only the first account on a server is exempt, so every later address the test
creates has to be on the list or account creation answers 403.
* Point the login-failure note at the Allowed Users section
The section was renamed, so the anchor was dead.
* Cover the account error statuses and the client's failure body
AccountErrorCodeTest walks the create and refresh failures a client branches on:
existing email, email pending deletion, malformed email, short password, unknown
refresh token. ApiFailureBodyTest covers the other end, where a server message
has to survive instead of being replaced by the generic one.
RecordingStrRes moves to its own file so both API tests can use it.
* Mark the password error codes as non-secrets for semgrep
* Add a Copy Diagnostics action to the About screen
Puts the startup banner (version, channel, OS/JVM) and the last 200 lines
of the current log on the clipboard, so a bug report can carry its own
diagnostics instead of asking the reporter to find, zip, and attach a log.
The blob always includes the banner: a missing log costs the reporter the
log, not the whole report.
The buttons move into a FlowRow so three of them wrap on a narrow window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Include the latest crash dump in Copy Diagnostics
* Only report the XDG session in the desktop banner on Linux
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Switch the desktop Ktor engine to OkHttp
`java.net.http.HttpClient`'s constructor opens an NIO `Selector`, whose
Windows wakeup pipe is an AF_UNIX socket. The MSIX sandbox rejects the
connect with EINVAL, so every Microsoft Store build hard-crashes on the
first network call, before any request is sent (JDK-8312215, open since
Java 17 with no fix in sight). OkHttp uses blocking socket IO and is
unaffected.
Ships unconditionally rather than gated on the distribution channel:
OkHttp works everywhere, and a second engine would be a second code path
to test forever. Android already uses it.
Includes the unchecked-IO mapping from `fix/unchecked-io-network-errors`,
so a client that cannot be built at all surfaces as a network error
rather than taking down the app.
* Add an Export Logs button to the desktop About screen
Opening the log directory hands the shell a path that does not exist under
an MSIX container: the app's writes to %LOCALAPPDATA% are redirected into
the package's LocalCache, but reads fall through, so the app sees the
directory and Explorer does not. Snap confinement will do the same on
Linux. Exporting a zip to a location the user picks works on every
distribution vehicle, which makes it the one support instruction that
never needs a per-channel caveat.
Also walk up to the nearest existing ancestor before handing a directory
to the shell, so the open button degrades instead of erroring.
* Bake the distribution channel into the build
Per-vehicle rules (self-update, store payment policy, "get the app" links,
sandbox-aware paths) were tracked by hand. `-Pchannel=<token>` now resolves
to a `DistributionChannel` constant, defaulting to DEV, and an unknown
token fails the build rather than quietly shipping a store binary as DEV.
Every release pipeline passes its own channel; the existing F-Droid build
flags map to FDROID without their call sites changing.
Flat enum rather than capability flags: every channel-conditional branch is
a greppable `when` on the type. Flags can emerge later from actual
duplication.
The startup banner and all three crash dumps now carry the channel. That
line pays for itself immediately: this investigation started from a crash
log that did not say which build produced it.
* Un-redirect container paths before handing them to the shell
MSIX filesystem redirection is asymmetric: the app's writes under
%LOCALAPPDATA% land inside the package container, but reads fall through,
so File.exists() is true from inside the container and the app is
satisfied. Explorer runs outside it and correctly reports nothing at the
literal path, which is the "location is not available" dialog the
open-logs button produced on the Store build.
Rewrite the path into the container for the Microsoft Store channel, both
where it is shown and where it is handed to the shell. The package family
name is hardcoded: Windows derives its hash suffix from the publisher ID,
so it is not in AppxManifest.xml and cannot be computed from it, and it
only changes if the Store identity does.
`Windows.Storage.ApplicationData.Current.LocalCacheFolder` is the native
answer but means a WinRT dependency on a KMP desktop target for one path
lookup. Worth revisiting only if more packaged-app APIs are needed.
* Cover the About log section with a render test, document the channel
* Suppress TooGenericExceptionCaught on the unchecked IO catch
* Map unchecked IO failures to a real network error message
The JDK builds its `java.net.http.HttpClient` lazily on the first request,
and `HttpClientImpl`'s constructor wraps a failed `Selector.open()` in an
`UncheckedIOException`. On Windows that selector is a loopback socket pair,
so a machine with a firewall, VPN filter driver, or exhausted ephemeral port
range fails with `java.io.IOException: Unable to establish loopback
connection` before a request is ever sent.
`UncheckedIOException` is a `RuntimeException`, so it slipped past every
catch in `Api.makeRequest` and surfaced in the sync log as a raw Java class
name. Route it through the same mapping, with a dedicated message: nothing
reached the network, so this must not read as a server or connectivity
problem.
* Only log "Sync complete!" when the sync actually succeeded
`handleSyncFailure` calls `onComplete`, which logged the success line
unconditionally, so a failed sync ended with an error immediately followed
by "Sync complete!". Thread the outcome through `onComplete` and gate the
log on it; the progress reset still runs either way.
* Suppress TooGenericExceptionCaught on the unchecked IO catch
`SYNCING-PROTOCOL.md` makes adding a field to a synced model a client-only
change: the server stores content as an opaque blob, and a peer that predates
the field is expected to drop it on decode. That only holds if the decode
ignores unknown keys.
Every `ContentNegotiation` install used a bare `json()`, which is Ktor's
`DefaultJson`: `encodeDefaults` and `isLenient`, but no `ignoreUnknownKeys`. So
instead of dropping the field, an older peer failed the whole sync. A 3.6.0
client hitting project data written by 3.9 got:
Sync failed: Illegal input: Unexpected JSON token at offset 72:
Encountered an unknown key 'language' at path: $.data
`createJsonSerializer` already sets `ignoreUnknownKeys`, but it is meant for
human-facing files: `prettyPrint` would put tabs and newlines in every request
body, and `coerceInputValues` would silently coerce bad values on the wire.
Split the two: files keep that one, machine-facing content gets
`createNetworkJsonSerializer`, resolved through `NetworkJsonQualifier`.
Applies to the three `ContentNegotiation` installs and the four classes that
decode wire content with an injected `Json`: `ServerProjectApi` (entity
payloads), `ProjectDataApi` and `ServerIdeasApi` (conflict bodies), and
`GithubVersionCheckDataSource`. The file-facing injections are unchanged.
This cannot reach already-shipped builds; 3.6 through 3.9.x keep failing
against projects a newer client has touched.
* Stop credential fields from auto-capitalizing what is typed
HdHairlineField defaults to KeyboardCapitalization.Sentences, and neither the
server setup dialog nor the reauthentication dialog overrode it on their email or
password fields. An IME that honours CAP_SENTENCES on a password variation
capitalizes the first character as it is entered, so the account is created with
a password the user cannot reproduce in a browser, where nothing capitalizes
anything. The failure looks exactly like a rejected password.
HdPasswordField and HdEmailField pin the keyboard options that credential entry
needs (no auto-capitalization, no autocorrect) so a screen cannot forget them.
HdPasswordField also absorbs the show/hide toggle that both dialogs had inlined
identically, and carries the 8 to 64 character rule as a field hint, formatted
from PasswordValidator so it cannot drift from what the server enforces.
* Add a design-system preview for the credential fields
Covers the password field masked with its length hint, revealed with an error,
and the email field beside them.
deliver_with_retry treated every error as transient. It exists for App Store
Connect's eventual consistency around build lookup and attachment, but a
validation failure will fail identically forever — the first 3.9.7 iOS submit
spent all six attempts and five minutes restating "App screenshot missing"
before surfacing it.
Errors matching a narrow set of validation patterns now raise immediately. The
list is deliberately narrow: anything unmatched still retries, since a new
permanent error costs a few wasted attempts while a misclassified transient one
costs a failed release. Matching is on message text rather than exception class
because Spaceship raises UnexpectedResponse for transient faults too.
Permanent errors now print the whole message. App Store Connect puts the
summary on line 1 and the actual reasons underneath, and the retry logging only
ever printed the first line, which is part of why the real cause was hard to
see.
submission_landed? still runs first, so the case where deliver raises after the
submission already landed is unaffected.
Setting screenshots_path was still not enough. deliver globs
<screenshots_path>/<locale>/*.png flat and does not descend into per-device
subdirectories — only appleTV and iMessage are special-cased. The images were
in en-US/APP_IPHONE_65/ and en-US/APP_IPAD_PRO_3GEN_129/, one level too deep,
so deliver kept finding an empty set and reporting success for uploading none.
Those APP_* folder names are App Store Connect API display types, which deliver
never reads: it derives the device from the image's pixel dimensions. So the
files now sit flat in fastlane/screenshots/ios/en-US/, out of the metadata tree
entirely, and screenshots_path points there.
Filenames avoid "app_ipad_pro_129" and the "12.9"/"2nd generation" pair,
because 2732x2048 is ambiguous between the 2nd- and 3rd-gen iPad Pro and
deliver disambiguates on the path. Without those strings it resolves to
APP_IPAD_PRO_3GEN_129, which is what App Store Connect asks for.
Verified against deliver 2.237.0's own glob and resolution table: 15 images
discovered, 7 APP_IPHONE_65 and 8 APP_IPAD_PRO_3GEN_129, none unmapped.
Turning off skip_screenshots was not enough on its own. deliver keeps
screenshots_path as a separate option from metadata_path and defaults it to
./fastlane/screenshots, which this repo does not have — the images live beside
the metadata under fastlane/metadata/ios/<locale>/APP_*.
So deliver looked at an empty set, reported "Successfully uploaded all
screenshots" having uploaded none, and the v3.9.7+ios-app-store submit failed
with "App screenshot missing (APP_IPHONE_65)". The iOS platform newly added to
the macOS record starts with no screenshots, so there was nothing already in
App Store Connect to fall back on.
overwrite_screenshots is set because deliver_with_retry can call deliver
several times, and each attempt would otherwise add another copy of every
screenshot to the version.
iOS and macOS were shipping as two separate App Store listings under two bundle
IDs. The supported model is Universal Purchase: one app record, one bundle ID,
one platform entry per OS. The binaries stay separate — each platform is still
built and uploaded independently with its own version stream and screenshots.
Points the iOS target and all four fastlane iOS identifiers at
com.darkrockstudios.apps.hammer, adds the for_platform :ios block the Appfile
was missing, and drops the hyphen from the iOS listing name, which only existed
because the unhyphenated name was taken by our own macOS record.
Stops skipping screenshot upload on the iOS lane. That was safe while iOS had
its own record and screenshots carried forward between versions; the iOS
platform newly added to the macOS record starts empty, and App Store Connect
refuses to submit a version with no iPhone screenshots.
Adds a shared concurrency group to the two App Store publish workflows. Both now
target the same App Store Connect record, and a full release tag starts them in
parallel. This is mutual exclusion rather than a needs: edge — neither waits on
the other's result, and a targeted single-platform tag skips the other job
before it reaches the gate.
The dialog gets an "Auto-publish the GitHub release" checkbox, off by
default. When checked, prepareForRelease appends an `Auto-Publish: true`
trailer to the tag message; set-release-body strips it from the release
body and, once every build job passes, marks the release as the latest
production release, which starts the store publish workflow.
The tag name could not carry the flag: a `+` suffix there reads as a
partial release and would gate off every store. Publishing uses
RELEASE_PAT rather than GITHUB_TOKEN, because GitHub does not start
workflow runs from events triggered by the default token.
The dialog grew a section, so the frame is taller to keep the changelog
editor its original height.
The pre-commit hook runs Gradle, so git's hook environment leaks into the
daemon. Gradle blanks rather than unsets those vars on later builds, and a
blank GIT_INDEX_FILE makes git read an empty index: releasePreFlightChecks
saw every tracked file as deleted and aborted the release.
Glance instantiates ActionCallback implementations reflectively, but its
consumer rule only keeps the class, not its members. R8 stripped the no-arg
constructor, so every widget tap threw NoSuchMethodException in minified
builds.
prepareForRelease has appended a "Full changelog:" footer pointing at the
GitHub release page to every store's notes since v3.7.0. App Store review
rejects release notes carrying that link — it offers the app outside the
App Store — and iOS and macOS submissions from v3.7.0 on have come back as
invalid metadata. v3.6.1, the last release to ship, predates the footer.
formatStoreNotes and storeNotesLength now take a nullable footer URL, and
the Apple path passes null. Google Play keeps the link: its 500 character
limit means the notes really are truncated there, so the footer is the only
way to reach the full text.
Also strips the stale v3.9.1 footer out of the two committed
release_notes.txt files, so a resubmission of the current version does not
ship it while waiting on the next prepareForRelease.
renderPaginated never walked the scene tree, so share pages came out in
entity id order and any project not written front to back rendered
shuffled. Scenes now flatten depth first, siblings by order, with
unreachable ones appended rather than dropped.
A scene-limited share reads only its own scenes and the groups above
them, found via each scene's path, and its validator covers those groups
so a chapter swap no longer serves a stale order. Bumped RENDER_VERSION,
without which cached shares would keep serving the old order.
Chapter headings render as the author wrote them, unnumbered.
Adds a `timezone` config setting, with `HAMMER_TIMEZONE` and `TZ` as
environment-variable fallbacks, applied at startup so both rendered page
timestamps and log lines use it.
ProGuard stripped org.apache.commons.logging.impl.LogFactoryImpl, which
LogFactory resolves by name, so PDDocument's static init threw
ExceptionInInitializerError on every PDF export from a packaged build.
Only pdfkmp-jvm pulls in PDFBox, so Android is unaffected.
Export failures also went uncaught in ExportDirectoryPicker on all three
platforms, killing the app instead of showing the existing failure toast.
The Mac App Store build's first-run folder picker ran through AWT, which
claims NSApp before Tao installs its event loop — the app then runs on
with no window and no crash. Move the picker and its alerts into the
Objective-C helper so nothing touches AWT before nucleusApplication().
Localized dialog text now comes from SandboxStrings, which reads the
packaged Compose Resources .cvr value files directly; getString() can't
be called pre-Compose because resolving a ResourceEnvironment
initializes AWT for the density qualifier. Value files layer English
under each locale so a key Crowdin hasn't round-tripped falls back to
real text rather than its own name. SandboxStringsTest guards the format
coupling, since a Compose upgrade would otherwise only surface as raw key
names on an App Store first launch.
Also declare the clang flags as an input on compileMacosBookmarksLib so a
flags-only edit can't leave a stale dylib for signMacAppResources to sign
into the bundle, and document the sandboxed config and data paths in
SUPPORT.md.
- Conflict resolution: onEncyclopediaEntryConflict now carries aliases and
excludeFromDictionary into the local entity; "keep local" was stripping
both, and with the flag in the entity hash that meant a conflict that
could never converge (the alias drop predates this feature)
- ViewEntryComponent: one saveEntry(transform) helper replaces six
hand-threaded updateEntry call sites and loads content from disk when
the async state load hasn't landed, so a save can no longer revert
aliases or the exclusion flag to defaults
- SpellCheckRepository: SupervisorJob + guarded checker calls so a
platform failure can't kill the settings collector; appliedCandidates
recorded only after a successful apply so failures retry
- ProjectDictionaryService: initialize() instead of init side effects,
cancel-before-clear plus an isActive check to keep a racing rebuild
from re-adding a closed project's words, spellCheckSettings.enabled
added to the rebuild gate, injectDefaultDispatcher(), and
loadEntriesImperative() now returns the defs
- temporaryProjectTask scopes skip the dictionary service, ending the
checker churn during bulk sync and import
- HdHairlineToggleRow gains an enabled param (semantic disable, no
ripple) replacing three hand-rolled Box+alpha idioms
ReferenceIndexConfig.default() now enables all five EntryTypes, so Thing,
Event, and Idea entries get linked to the scenes that mention them. The
metadata panel already bucketed refs into Characters/Locations/Other, so
the new types land in Other with their own glyphs.
Add-reference search takes a type set and applies it before the result
cap, so one tab can no longer starve another out of the slice. The
appearances chart filters to PERSON to stay true to its label; the
separate connections total still spans all types.
Entry names and aliases become session-only words (AppLocal scope, never
the OS dictionary) while their project is open, cleared on close. Words
are tokenized to single words and filtered against the base dictionary
so only unknown spellings are added.
Three levels of control, all live-reactive:
- Global toggle in Spell Check settings (SpellCheckerSettings, default on)
- Per-project toggle (ProjectData.encyclopediaDictionary, synced;
hashed only when false so existing hashes are stable)
- Per-entry exclusion (EntryContent.excludeFromDictionary, synced;
hashed only when true, same zero-bytes-at-default rule as aliases)
SpellCheckRepository holds session words keyed by ProjectDef and emits a
fresh checker instance whenever the effective word set changes, so open
editors re-run their full scan; the same path re-applies words on locale
change and re-enable. ProjectDictionaryService (ProjectDefScope, eagerly
started in initializeProjectScope) rebuilds the word set from the
encyclopedia on entry changes, debounced, and clears it on scope close.
#909 installs EnglishFallbackMessageResolver, so every call.t/Msg.r site
already resolves an untranslated key against English. The helper added in
#915 duplicated that for three call sites, and its comment described a
hazard that no longer exists.
The German-locale rename conflict test stays; it passes through the
resolver instead.
Account sync pushed renames before deletions, so renaming a project into
a name still held by one queued for deletion violated the server's
UNIQUE(name, user_id). Phases now run delete -> rename -> create.
The violation also escaped as a 500: ProjectDao maps SQLSTATE 23505 to a
ProjectNameTaken failure and /rename answers 409 Conflict.
Also in here:
- Renames queued against a server-tombstoned project id are dropped.
They could only 404 and requeue every session.
- API error messages fall back to English. The locale bundles have no
base Messages.properties to parent through, so a key present only in
English threw MissingResourceException and made the response a 500 for
every other locale.
- Rename's ProjectNotFound reported "Invalid project name".
* Add scene selection to story export
Mirrors the web scene-limited shares: ExportOptions.sceneIds (null = entire
story, empty fails closed), filtered at the single chapter-building point so
all five formats renumber automatically. The export dialog is now fully
controlled by the retained component state, so in-dialog edits survive
rotation (#885 class).
* Address export scene-selection review findings
- beginProjectExport reads the scene tree off the click handler
- Dialog options freeze while the non-blocking file picker is open, and all
three platform pickers snapshot options before their suspend
- Cancel discards in-dialog edits again; restore happens in onDismissed so
nothing flashes during the close animation
- A full selection confirms as no limit, so empty chapter groups still export
- Sceneless projects hide the limit toggle; empty groups get no checkbox
- Options column scrolls on short screens instead of pushing the buttons off
- Group rows are toggleable with checkbox semantics for TalkBack
- Picker list/row lifted into the design system (HdPickerList/HdPickerRow),
now shared with MoveSceneDialog
* Add a scrollbar to HdPickerList
Overlays the standard MpScrollBarList inside the picker list, covering both
the export scene selector and the move-scene destination list.
ktor-i18n's ResourceBundleMessageResolver threw MissingResourceException
for any key a translation had not picked up yet, since the locale bundles
have no parent bundle to inherit from. Every Msg.r/call.t site turned that
into a 500, most visibly on /signup: rejecting a not-allowed email needs
api_allowedusers_rejected, which Crowdin had not yet delivered to de.
Fixes#883 (registration half)
Argon2 hashing went through JNA, which extracts libjnidispatch.so at runtime.
On Linux JNA ignores java.io.tmpdir and uses $XDG_CACHE_HOME, falling back to
<user.home>/.cache, and the Docker image sets user.home=/data. That puts the
shared object on the data volume, so hosts mounting it noexec cannot load it,
and signup and login returned a 500 (#884).
Argon2PasswordHasher derives with BouncyCastle instead, reading the variant,
version, and cost parameters back out of the stored PHC string. Its output is
byte-identical to libargon2 for the same inputs, so existing hashes keep
verifying; the tests pin that against hashes generated by the C implementation.
JNA is still on the runtime classpath via the CLI's terminal library, so the
image also points XDG_CACHE_HOME at its own layer.
Windows reports AltGr as Ctrl+Alt, so typing @ on a Turkish-Q or German
QWERTZ layout matched the loose Ctrl+Q quit test and closed the app.
Window shortcut matching moves into WindowShortcuts.kt, built on the
exact-modifier matchesShortcut helper, which also tightens Ctrl+W and
Ctrl+Shift+F.
* Move save-all off Ctrl+Alt+S to Ctrl+Shift+S
Ctrl+Alt+S is itself an AltGr chord on Windows, so it fired and ate the
character on layouts that map AltGr+S, such as Polish (s with acute).
Rebound on desktop, Android, and iOS, and Android's global search now
matches its modifiers exactly like the rest.
No shortcut uses Alt now, which is what keeps AltGr keystrokes reaching
the editor.
* Keep Koin hint classes out of test detection
Add email search to the Allowed Users admin page
Filter the list by full or partial email, matched case-insensitively anywhere in
the address. The query survives sorting, paging, remove and both edit dialogs.
The input sits in the page shell, not the swapped fragment, so it keeps focus and
caret while typing; the fragment renders its view state as hidden inputs that the
search box and add form pull in via hx-include.
Also fixes three faults on this path: paginated queries ordered on date_added
alone, letting LIMIT/OFFSET repeat and skip rows that share a timestamp; "Page 1
of 0" from a mustache section on a boxed Integer; and a dead htmx:afterSwap
handler reading an always-empty query string.
On project close the Koin project scope closes before Compose disposal
destroys components, so SceneMetadataPanelComponent.onDestroy crashed on
its first lazy resolve. Resolve onDestroy dependencies eagerly via a new
projectGet() helper so the final metadata flush survives project close.
Also: the account sync log claimed a server create failed when only the
local project id save failed (new dedicated string), and the local
create failure string read backwards and lacked its placeholder.
* Address review findings on the metadata flush and sync log strings
- Skip the onDestroy metadata flush until the initial load has completed, so an early destroy cannot overwrite stored metadata with the empty default
- Run the reference scrub off the lifecycle thread and catch flush errors
- Resolve ProjectRootComponent.syncJournal eagerly (read in onStop)
- Correct the projectGet KDoc: a post-close resolve can also silently hit a fresh uninitialized scope
- Pass serverProject.name (not toString) to the local-create failure log, and rename its key so stale translations get re-queued
- Narrow the id-save try block so an updateSyncData failure no longer logs a wrong failure line after the success line
* Narrow the destroy-flush catch to IOException and cover the failure branches
Order numbers are a contiguous 0-based sequence, so the zero-padded order
field must be as wide as the highest order, not as wide as the child count.
Those two disagree at every power of ten: adding the tenth child to a
directory left single-digit names on disk while every path computation
padded to two, so nothing in that directory could be read back.
The damage was silent. Reads returned "" (loadSceneMarkdownRaw swallows the
IOException), so an affected group exported as blank and opened as a blank
editor. A save then wrote to the padded name, creating a rival file with the
same scene id next to the real one. Nested scenes were hit hardest, since a
mis-padded group takes out its whole subtree.
- countScenes is the last order index; all three padding sites use it, and
the misnamed SceneDatasource.getLastOrderNumber (raw count) is gone.
rationalizeChildren follows the same convention.
- getGroupChildPathsById keeps the name-first path instead of associateBy's
last-wins, so rationalizeTree, validateScenePaths and ScenePathIndex agree
on which file owns a duplicate id. The sync pass no longer rewrites the
directory around the stale copy and discards the newer one.
- Content is addressed by resolveSceneContentPath: resolved by id from disk,
computed only for a scene with no file yet.
Recovery of projects already carrying a stray duplicate is left to a
follow-up migration.
* Add per-share scene restriction table and DAO plumbing
* Carry scene restrictions through ProjectAccessRepository
* Filter public story renders to a share's scene set
* Add scene selection to the private share dialog
* Mount share and publish-warning dialogs outside the sticky sidebar
* Keep the share dialog open on a rejected create and let expired shares' passwords be reused
* Drop scene path fixes swept in from another branch
These three files belong to the order-padding fix in #896, not to the
share work. They were picked up by a broad add and are reverted here so
this PR carries only the share changes.
* Harden scene-limited shares per code review
Atomic duplicate-password check under a project-row lock, shared scene-set validation that rejects group ids (review requests included), Set-based fail-closed restriction model, one clock for expiry, live scene counts that surface dead shares, symmetric child-row cleanup, surfaced tree-load failures, api_error_unknown fallback, and dedup of the sheet-dialog CSS.
* Keep unsaved edits through a device rotation
Fixes#885. Rotating an Android device rebuilds the composition while the
Decompose component survives (both activities use retainedComponent), so any
draft the UI kept in a plain remember was gone by the time the new composition
ran.
Story Ideas editor: the draft (title, body, tags, uncommitted tag token, edit
mode, and the saved baseline) moves into StoryIdeasComponent and is stashed in
the stateKeeper, so it also survives process death. createIdea/saveIdea give
way to saveDraft(), which reads the draft, folds in a typed-but-uncommitted
tag, and re-baselines off what was actually stored.
Also fixed, same failure mode:
- Encyclopedia entry view re-seeded its rememberSaveable draft from the last
saved content on every fresh composition, which undid the restore.
- Tag lists on the note, timeline event, and encyclopedia entry create screens.
- The uncommitted token inside HdHairlineTagField.
- The date field on the timeline event create screen.
- The draft name in the scene save-draft dialog.
* Address review findings on the rotation state fix
- Encyclopedia entry: only skip the seed once the fields already hold the
entry, so starting an edit before the async load lands can't save an
empty name and body over it.
- Story ideas: a save that finishes after the editor moved on no longer
stamps its result onto whichever draft is open now.
- Ideas browse: sort mode and tag filter survive a configuration change,
like the search text already did.
The editor was passed enabled = hasReceivedInitialBuffer, and that flag was
only set after runFullSpellCheck() returned. enabled = false makes the editor
read-only and unfocusable, so opening a scene left it rendering and scrolling
but refusing clicks until the whole document had been checked.
Set the flag as soon as the content is loaded. Spell check spans are
decoration and never emit edit operations, so the edit-operation collector
that flag also gates still starts against populated content.
* Bump ComposeTextEditor to 2.4.3
Picks up the spell check scan running off the caller's dispatcher instead of
resuming on it once per word.
Nested groups were indented with an off-by-one that put a depth-2 group at
the same start as its parent, and groups and scenes used different indent
formulas so nothing shared a grid.
- One indent vocabulary in the design system: HdIndentStep, hdIndentFor,
and Modifier.hdIndentRails for per-ancestor hairline guides.
- Groups get a chevron at every depth, not just the top level, plus a
scene-count greeble.
- Section rules hang at the group's own indent; top level chapter breaks
still cut the full panel.
- The drag insert line uses the same indent metric as the rows.
The scene picker was a scroller inside another scroller, capped at 260px, so
it stayed cramped no matter how tall the window was. The dialog body is now
the only scroll region and the list renders at full height inside it.
The "All scenes / Select all" row was position:sticky inside that inner
scroller, with side margins and container padding around it, so rows showed
through the gap above it and the gutters beside it. It is now a plain header
bar above the list, outside the scroller.
The dialog also sat inside the story sidebar, which is position:sticky and
therefore a stacking context its z-index could not escape, leaving it painted
under the site header. Moved the container out to a sibling of <main>.
Short viewports get dvh units, trimmed dialog padding, and a full-bleed sheet
under 600px wide.
* Fix desktopTest compile: coVerify for suspend setProjectId
* Let users widen the editor on wide screens with a drag handle
The scene editor and focus mode share a persisted editorMaxWidth
(700-1400dp, GlobalSettings) set by dragging a new HdResizeHandle
gutter on the column's start edge. Rendered width stays
min(window, userMax); the handle only appears when the window is
wide enough, and double-click resets. Live drag width is UI-local
via rememberHdResizeHandleState and persists once on drag end.
The sync journal (sync.json) and StoredProjectData.lastSyncedHash record what one
specific server confirmed, but nothing scoped them to that server. Once a project
pointed at a different server project, an untouched project still read as "already
in sync", so ProjectDataSyncOperation took its fast-forward branch and overwrote
local project data with the new server's empty copy: author name and project
settings gone, and nothing uploaded.
ProjectsRepository now clears the baseline whenever the server project id actually
changes, covering cross-server merge, create-account, removeServer, and the 410
stale-id path in FetchServerDataOperation.
The clear is deliberately narrow. The journal is rewritten rather than deleted:
syncedHashes, cachedProjectHash, hashAlgoVersion, currentSyncId and lastSync are
reset, while dirty, newIds, deletedIds and lastId stay, since they are local work
the new server has never seen; dirty entries drop originalHash so an upload cannot
forge a conflict against a server that never agreed to it. Project data keeps its
data and loses only lastSyncedHash, which is what makes the next sync upload
instead of fast-forward. For an open project the clear goes through
ProjectDataRepository under its lock, because clearing the file alone would leave
the stale hash live in memory and write it straight back out. The whole clear runs
before the new id is written, so a failure between the two leaves the project
pointing at the old server rather than at the new one with a stale baseline.
removeProjectId and setProjectId are suspend now, which makes
AccountSettings.removeServer() suspend too. User content is never touched.
Writing-activity logs are also server-scoped and also survive a move; tracked
separately in #892.
syncCreatedProjects ran createProjectsOnServer before
createLocalProjectsFromServer, so a local project sharing a name with a server
project was uploaded first and only then had its id overwritten by the name
match. The server dedupes createProject by name, so this usually handed back the
existing uuid, but where the local name was a sanitized form of the server name
it left an orphan duplicate for every other device to download.
Adoption now runs first and reports the names it claimed, so the upload pass
skips them and a queued creation the server already holds under that name is
withdrawn instead of retrying the duplicate on every future sync.
Adoption itself is no longer name-only. A local project is claimed only when its
id is absent from the live server set, so one already bound to a live server
project is never re-pointed at a same-named one, and two server names that
sanitize to a single local name are first-wins rather than last-wins. Tombstoned
ids stay out of that set, so a project whose server copy was deleted elsewhere
can still be claimed.
Server setup popped an unskippable "Remove Local Content?" confirm on both the
Create-account and first-time Log In paths, and ran the delete before the network
call, so a typo'd password or a declined ToS still wiped every local project.
Only prompt when the answer matters: logging in to a different server while
holding real local work. Create-account never deletes, re-auth against the
configured server never prompts, and a login holding only the bundled example
drops it silently. Replace requires a second destructive confirmation.
The wipe now runs in the Success branch, so a failed setup costs nothing. The
component owns the decision and re-reads the projects directory when it acts;
the UI only ever asks for the non-destructive setup.
Also releases the pending setup before the wipe suspends, so a close landing in
that window cannot delete the freshly obtained tokens, and guards the
example-project drop with a same-server check so a token-expiry re-auth keeps it.
A page of dialogue reached readers as one packed block. CommonMark reflows
prose: single newlines become spaces and any run of blank lines collapses to
one paragraph break, so what an author sees while writing was not what anyone
else saw. Prose is now laid out as it was typed, on every surface that renders
it: every newline starts a new line, every blank line is a blank line.
- base/markdown/ProseHtml.kt holds that layout as AST-level generating
providers, shared by the server's pages and the client's EPUB export. Lists,
tables, code and headings keep markdown's own layout by construction, so the
old fence-tracking preprocessor is gone.
- parseProseMarkdown does the same for the DOCX, RTF and PDF exports, with a
ProseBlock.Blank for an authored blank line. A body paragraph carries space
after it only where prose ends, so lines that run on sit tight and the
indent parts them.
- Quoted passages keep their lines, and a quote's '>' markers no longer leak
into the text of a continuation line in the document exports.
Walking every construct the flavour can emit turned up three more faults. A
stripped element keeps its text, so tables (which the sanitizer allowed none
of) arrived as their cells run together; they are allowed and styled now,
column alignment included. An ordered list starting at 5 rendered as 1. Line
endings went unnormalized once the preprocessor that had done it incidentally
was removed.
The story's declared language now reaches the prose on the author's own page
as well as the public one, so a French story is not hyphenated by the rules of
whatever locale the author reads Hammer in.
RENDER_VERSION goes to v4 and moves into the fingerprint: it keyed the disk
cache but not the ETag, so a bump alone would have served stale prose to
anyone holding the old validator.
* Fix sync failure when order padding width changes during rationalizeTree
rationalizeTree computed each destination path with per-segment order
padding read from live disk counts, which its own moves mutate mid-pass.
When a sync dropped a directory across a digit boundary (10 -> 9 items),
later items computed ancestor names that did not exist on disk yet and
atomicMove failed with FileNotFoundException.
The pass now derives padding from the tree's child counts and moves
parents before children, building each destination from the parent's
already-finalized path, so the layout is computed once and every
destination directory exists.
* Pad rationalizeTree from disk-present children and reuse path snapshot
Padding now counts only children that resolve on disk, keeping written
names recomputable by getSceneFilePath when a tree child is missing.
Snapshot each directory's child paths once instead of rescanning after
every move, and extract the shared childScenePath helper.
* Tighten rationalizeChildren doc comment
Always-on Allowed Users list and public signup page
The whitelist toggle is gone: every server now enforces the allowed users
list, and the web UI, routes, and message keys are renamed to "Allowed
Users". The Kotlin, database, and REST names are unchanged.
Adds a public /signup page so an allowed user can create their own account
from the web instead of only through the app. It goes through
AccountsComponent.createAccount, so the allowed users list and the Terms of
Service challenge are enforced, and the POST shares the login rate limit.
A not-allowed email is audited like a failed login.
Wires the dormant DataMigrator into startup with one-time completion markers
in server_config, and adds a migration that seeds the list with every
non-deleted account.
* Move Dark Rock Studios attribution above the community section on About
Replaces the "Manuscript" section with a "Studio" section carrying the
made-by attribution and the button that opens the Cairn overlay, giving
it top billing above Community.
Hoists the Cairn overlay from AboutAppUi up to the nav shell
(ProjectSelectScaffold on Android/iOS, ProjectSelectionWindow's Content
on desktop) so its full-screen ceremony covers the nav rail/bottom bar
too, not just the About content pane.
* Force x11 until Nucleus fixes it's bug
The instance band now runs the message through MarkdownService, so admins can
use links, emphasis, and lists in it. Output is sanitized by the same OWASP
policy the About page uses, and a message that sanitizes down to nothing no
longer paints an empty band.
Nothing in the desktop app needs the JBR anymore, so go back to OpenJDK
(Temurin 21) everywhere:
- Remove the JETBRAINS vendor pin from the desktop toolchain and from the
launcher that jpackage bundles as the app runtime
- Replace the setup-jbr composite action with actions/setup-java; the
retry logic only existed to survive rate limits on the jetbrains
distribution's release listing
- Swap the JBR SDK tarball for Temurin 21 in the snap and flatpak builds,
and verify the snap download's checksum
- Standardize the remaining 'adopt' setup-java steps on 'temurin'
* Record web sign-ins in the security audit trail
The web login form never called recordLoginAttempt, so only app clients
hitting /account/login showed up on the Security monitoring page.
Wire /login in, auditing what the server actually allowed: a whitelist
rejection is recorded as a failure, matching the API path. Move the
monitoring gate into SecurityRepository so both routes honor the
loginTrackingEnabled and storeLoginIp settings by construction, store
blank emails as null to keep them out of the per-account brute-force
queries, and log rather than propagate a failed audit write so it can't
break the sign-in. Rate limit the web login POST like the other two
login entry points.
* Suppress TooGenericExceptionCaught on the audit write
The catch is deliberately broad: no failure mode of recording an attempt
may break the sign-in that triggered it.
updateMarkdownStyles rebuilds every line from its raw text, which drops
the ParagraphStyle that indents lists and blockquotes. Each font size
step therefore flattened them, and the gutter markers are positioned
against that indent.
CommonMark has no strikethrough, so `~~struck~~` reached every server
rendered page as literal tildes while bold and italic worked. The editor
that writes the content parses GFM, so the two disagreed.
MarkdownService now parses GFM, matching the editor. GFM emits
strikethrough as `<span class="user-del">`, which the sanitizer would
unwrap for having no allowed attributes, so it is rewritten to `<del>`
and `del` is allowed through. Bumps RENDER_VERSION so cached story pages
regenerate.
The review page had the same gap for its own reason: parseInlineMarkdown
only branched on `*` and `_`. It now handles `~~` and threads the flag
through to both render sites.
Carries the fix for typed text losing its body style when nothing
neighbours the caret, which left every newly typed paragraph and list
rendering at the default size instead of the reader's chosen one.
* Handle F3 and Ctrl+Alt+S on the project window
Both shortcuts hung off a Modifier.onPreviewKeyEvent in ProjectRootUi, so
they only fired when focus sat inside the project subtree and did nothing
from the home screen. Move them to the window onKeyEvent that already owns
Esc, Ctrl+W, Ctrl+Q and Ctrl+Shift+F.
F3 now goes through ProjectRoot.startProjectSync(), which opens the sync
modal only for server-linked projects, restoring the gate the old sync menu
item had.
* Match shortcut modifiers exactly and run them pre-focus
F3 and Ctrl+Alt+S were hand-rolled in the window's when-chain, which
dropped the exact-modifier matching onKeyShortcut enforced: Ctrl+F3 and
Ctrl+Alt+Shift+S both fired. Extract that predicate as
KeyEvent.matchesShortcut and use it for both.
Move the two to onPreviewKeyEvent as well. onKeyEvent only runs when
nothing on the focus path consumed the key, so a focused editor could
swallow them; the docs claimed otherwise. The other window shortcuts stay
on onKeyEvent so a focused component can still handle Esc first.
* Give Android and iOS the project shortcuts back
Moving F3 and Ctrl+Alt+S to the desktop window left the other platforms
with nothing. Add ProjectShortcutHost, which ProjectRootScaffold binds
while the project UI is composed, and drive it from each platform's own
key hook: Activity.dispatchKeyEvent on Android and UIKit key commands on
iOS. Both are focus independent, which the Compose modifiers were not.
The iOS container lives in Swift because keyCommands is an Objective-C
category member and Kotlin cannot override those.
* Bind the project shortcuts in one place
Desktop kept its own copy of the save-all action while Android and iOS went
through ProjectShortcutHost. Move the binding down to ProjectRootUi, the one
composable all three platforms render, so each host only detects keys and
calls the host object.
That also lets the window drop the snackbar state and coroutine scope it had
hoisted purely to run the action.
* Give rendered stories real book typography
Lists sat at the prose margin with their items packed together, thematic
breaks ran the full column width, and any run of blank lines an author
typed collapsed to a single paragraph gap.
- MarkdownService emits a break for each blank line past the first, so
deliberate white space survives CommonMark's collapsing. Runs are
capped and fenced code is left alone.
- StoryRendererService separates sibling scenes with a blank line;
without it the last paragraph of one scene and the first of the next
parsed as a single paragraph.
- story.css styles every element markdown can emit: indented lists with
accent markers and breathing room, a centered scene-break rule with an
ornament, a tinted blockquote panel, code and links, h3-h6, and ragged
right prose below 600px where justification opens rivers.
* Indent every prose paragraph, including a page's first
Each page opens with the scene's heading, so the flush-left rule for a
paragraph following a heading swallowed the indent on the first line of
every page. Drop the rule entirely: paragraphs after a scene break, list,
or quote now indent too, so the prose is uniform.
* Keep blank-line spacing to the prose that asked for it
Review of the branch turned up four ways the blank-line work reached
further than intended.
- markdownToSafeHtml takes preserveBlankLines, defaulting off. Bios, the
About page, the privacy policy and the review frontend render with
CommonMark's collapsing again; only story rendering opts in.
- A code fence is now tracked by its delimiter and length, so a ~~~ line
inside a ``` block no longer ends it and leaks a literal <br /> into the
code. A blank run between two indented lines is left alone, which covers
indented code blocks and fences nested in list items.
- The chapter heading no longer carries a leading newline; with
appendScene's trailing blank line it made a run long enough to render a
break above every heading.
- RENDER_VERSION goes to v2 so cached story pages re-render.
* Fold the group word count into the scene walker
buildGroupMarkdown had its own copy of writeGroupChildren, differing only
by a word-count accumulator, so every change to how scenes are separated
had to be made twice. writeGroupChildren now returns the count and the
single-group export uses it.
Also brings the Story Prose table in the design system doc back in line
with the rules story.css actually carries.
* Recreate projects whose cached server id is dead
A local project keeps the server project id it was assigned on a previous
sync. Account sync only created a project on the server when that id was
missing, so a project holding an id the server has no record of was skipped
and per-project sync went straight to begin_sync with it, failing 410 Gone
with no path back.
Treat a cached id the server neither lists nor has tombstoned as dead and
recreate the project. A tombstoned id still means a real server-side delete
and keeps its existing handling.
* Judge project id liveness on raw sync data, and heal on 410
Two gaps in the stale-id recovery:
Liveness was judged against the server list processProjectSyncData had
already filtered, which drops projects queued for deletion. A project
whose folder came back after a local delete looked unknown to the server
and was recreated, undoing the deletion. Judge against the raw begin_sync
response instead, where a queued-for-deletion project is still listed.
The per-project pipeline only created a project when its id was missing,
so syncing from inside a project still failed 410 forever. Discard the id
when begin_sync answers 410, which returns the project to the never-synced
state so the next sync recreates it.
Document both in the syncing protocol.
* Withdraw sync queue entries that can never succeed
A queued rename keyed on an id the server never issued failed every
session forever, writing a red line into the sync log each time with no
way to clear it. Drop it without sending: recreation already covers the
rename, since it creates the project under its current local name.
A project deleted before its first sync left its name queued for
creation, so the next sync created an empty project on the server and
pushed it to every device, then failed resolving a definition for the
deleted directory and repeated forever. Withdraw the queued creation on
delete, and drop queued names with no local project so existing bad
state heals.
Horizontal rules, images and code fences live in the editor's rich span
manager rather than in the AnnotatedString, so seeding an editor from
getInitialEditorContent dropped every block. Import the markdown instead.
Loading this way exposed several things that depended on the old seed:
- The spell checker's one-shot scan is keyed on the checker, not the
content, and importMarkdown emits no edit operations, so a scene opened
once the dictionary was cached was never checked. Run a full check after
loading.
- updateMarkdownStyles remapped heading styles before the extension's
config setter rebaked them, leaving the rebake unable to strip the old
style and appending a duplicate span per heading per config change.
- Block lines render as a lone space, which the prose diff could not tell
from a blank line, so a scene break added or removed between draft and
current went unmarked. sceneDiffText swaps in length-preserving
sentinels to keep the diff in the editor's coordinate space.
Also hoist the Draft Compare editors above the width breakpoint so a
resize no longer resets the merge pane while mergedContent points at the
torn-down editor, give both panes the markdown config effect so they
restyle together, and move the markdown export off the main thread.
Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy.
Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request.
Suggestion chips were focusable, so pressing one pulled focus off the
tag input and fired commit-on-focus-loss, which added the raw draft and
cleared it. That emptied the strip mid-gesture, so the release never
reached onSelect.
The previous Crowdin sync PR (#847) overwrote strings_sync.xml from a
stale copy that predated the project-language feature (#838), silently
deleting 7 keys still referenced by ProjectInfoSettingsUi.kt,
ProjectSettingsUi.kt, and ProjectDataConflict.kt. That broke
compileAndroidMain and every other target across all client builds.
The app called the GitHub releases API from ProjectSelectionComponent.init
to decide whether to show an "update available" dialog. That is an
unprompted network request before the user has done anything, which has
been raised as a privacy concern.
The release notes are already in the repo at release time, so bake them
in instead. prepareForRelease writes the new CHANGELOG.md entry to
common/src/commonMain/composeResources/files/changelog.md and commits it
alongside the version bump, so the resource cannot drift from the version
in libs.versions.toml.
On load, ChangelogRepository compares the baked entry's version against
lastSeenChangelogVersion in global settings and shows a "What's New"
dialog when they differ. Dismissing records the version. Fresh installs
are seeded as already-seen, so only upgrading users get the popup;
the old lastDismissedUpdateVersion key is dropped, which is safe because
the TOML serializer ignores unknown keys.
About drops its update check entirely and gains two buttons: Changes,
which reopens the dialog with no network, and GitHub Release, which opens
the browser.
VersionCheckRepository stays for the protocol mismatch dialog, which only
appears after the user has connected to a sync server and exists to tell
them which version to get. It keeps its automatic check and is now that
repository's only consumer.
Links in the notes are clickable via linkifyChangelog, which handles
[label](url) and bare urls, including urls containing parentheses. It is
deliberately not a markdown renderer: entries lead with [New] / [Fix]
tags that a real parser mangles.
The first-run /setup page now hosts an email/password form that creates
the initial admin account, signs the operator into a web session, and
redirects to /admin. The app-based setup steps remain as a secondary
section. POST is rate limited alongside login and re-checks hasUsers()
to guard races and double submits.
* 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.
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.
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.
* Match global search against projected Markdown prose
Search compared queries against raw Markdown, so backslash escapes and
emphasis markers sitting between words caused misses, and snippets
rendered storage syntax.
Flatten stored Markdown to the prose a reader sees before matching:
escapes resolve to their literal character, and paired emphasis or code
delimiters are dropped. Pairing follows CommonMark flanking rules, so
literal markers in imported or hand-edited content survive; user_name
and a bare *** divider are left alone.
Timeline dates are a plain-text field and are not projected. Derived
titles go through the same projection as the note list so the two views
agree, and blank projections fall back to the raw source so marker-only
content still gets a title and still appears in tag searches.
Fixes#811
* Scope Markdown pairing to paragraphs and narrow the title change
Delimiter pairing used a single document-wide stack, so an unpaired
asterisk or a backtick used as an apostrophe paired with an unrelated
one paragraphs away. Both characters were deleted, and runs between a
bogus code span were marked inert, which left real emphasis in place and
defeated the cross-markup matching this is for.
Pair within a paragraph only. Emphasis still spans a soft line break.
Strip leading blockquote, heading and bullet markers from every line
rather than only from derived titles, so a title and its snippet agree.
Ordered-list markers are left alone: one line cannot tell "1. Draft" from
"1984. The year everything changed".
Titles now take the first non-blank line of the whole projection instead
of projecting a line in isolation, so emphasis closing on the next line
still pairs.
Revert firstNonBlankLine to its verbatim behavior. Routing it through the
projection reached into Browse Notes, Story Ideas and sync conflict
labels and unescaped backslashes there, corrupting stored paths.
Skip the raw fallback scan when the query holds no character the
projection can remove, which makes it provably redundant.
* Project Markdown in a reusable scan workspace
Global search re-projected every document on every keystroke, allocating
about 36x the source in garbage each pass: one object per delimiter run,
two lists, a StringBuilder and an output String, per scene, per search.
MarkdownProjector holds that work in buffers it keeps. Delimiter runs
move into parallel primitive arrays, the projected prose lands in a char
buffer that is matched in place rather than turned into a String, and
every buffer grows to the widest document seen and is then reused. A full
scan of a 1.25M word project drops from 31.7MB of garbage to none, at the
same wall time.
Two smaller wins came with it: the source is copied into a flat array so
the scan and render index an array instead of paying a CharSequence call
per character on both passes, and the ASCII punctuation test became four
range checks instead of a scan over a 32 character string.
The projector cannot be a field on the use case. Cancelling a search is
cooperative, so the outgoing scan can still be running when the next one
starts, and the two would share a buffer. MarkdownProjectorPool lends one
per scan and keeps it afterwards, so the buffers survive to the next
keystroke without forcing the four scans to run one at a time.
projectMarkdownToPlainText stays as the one-shot convenience over the
same code, so the behaviour is defined in one place. MarkdownProjectionTest
passes unchanged, which is the point: the rewrite is internal.
* Read scenes straight into the scan buffer
Search re-read every scene from disk on each keystroke and took a String
back for each one, about 11.5MB of garbage per pass over a 300k word
project before any matching happened.
SceneDatasource can now decode a scene into buffers the caller owns and
return the char count, so a scan reuses one pair of buffers instead of
taking a string per file. ScanBuffers is the contract; MarkdownProjector
implements it, so the bytes land in the same workspace that projects
them. The same scan now costs 0.42MB at the same wall time.
The bytes are pulled in bulk and decoded from an array. Reading a byte at
a time off a BufferedSource was measurably slower than Okio's own
readUtf8, and the decode was never the expensive part.
Matching follows the text into the buffer: findProjectedMatch works over
whatever the projector holds, and the raw-markup fallback searches the
source buffer, so neither path needs the document as a string. Only the
snippet window is copied out, and only on a hit.
SearchProjectUseCaseTest stubbed loadSceneMarkdownRaw, which the scene
path no longer calls. The stub now fills the buffer it is handed, which
is what the collaborator actually does.
* Carry the projection across every search surface
Rebasing onto develop put #831's unification and #821's projection in the
same tree, and they disagreed.
#831 made four surfaces share one rule, markdownContains, which resolves
escapes only. Global search now projects, so leaving markdownContains
alone would have re-split the surfaces it had just joined: Notes, Timeline
and Story Ideas would still miss a phrase spanning "**emphasis**". It
projects now too, and mirrors the same fallback.
The two PRs also pulled opposite ways on the raw-source fallback. #831
pinned that searching the storage form of prose must not work, and in the
same breath that literal "**Chapter**" must still be found. Under the
projection those need different answers, and #821's gate, "the query holds
any character the projection could remove", cannot give them: a backslash
and an emphasis marker are both removable, so honouring one broke the
other.
containsInlineMarkup replaces it. Only emphasis and code markers open the
fallback, because spelling those out is someone hunting for markup.
Escapes and block markers do not, because "well\-known" is the storage
form of prose and nobody types it. Both of #831's assertions hold.
matchOrPreview keeps the fallback argument and the empty-snippet chain
from #833, which the projection commits had dropped, so a tag-only search
still returns bodiless items.
* Match the query as literal text and nothing else
Global search resolved the query against the prose on screen, then, for a
query containing an emphasis or code marker, searched the raw storage form
as well. That second pass was the query being read as markup: typing
"**Chapter**" found a document whose prose reads "Chapter", because the
asterisks were matched against the source rather than against what the
document displays.
The rule is now one line. A query is literal text, matched against the
prose the document renders as, and there is no second interpretation. The
asterisks are not on screen, so typing them finds nothing.
Nothing about escapes changes, and this is easiest to see in the case that
motivated it. Text stored as "well\-known" renders as "well-known" and is
found by typing that; the storage form is not. Text stored as "well\-known"
renders as "well\-known", backslash and all, and typing that backslash
finds it. Markers the projection leaves alone, "5*4" and "user_name", are
matched where they sit, because there they are prose.
Three assertions pinned the behaviour that has gone, one per surface, and
each now states the opposite. containsInlineMarkup and the projector's
source-side accessors existed only to serve the fallback and go with it.
* Drop a comma from a test name so iOS can compile it
Kotlin/Native rejects a comma in a backticked identifier, so the whole
iosTest compilation failed on one test name. Desktop accepts it, and
compileIosMainKotlinMetadata only covers main, so nothing local caught it.
:common:compileTestKotlinIosSimulatorArm64 does, and runs on a non-Mac
host.
* Add per-project language setting (#754)
An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.
Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.
The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.
* Fix review findings in the project-language feature
createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.
The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.
* Enforce single-owner persisted formats
The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.
The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.
* Burn down the last raw TOML I/O outside datasources
ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.
With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.
* Pass seedDefaultLanguage in the Android instrumented-test harness
* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
* 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
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.
* 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.
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
* 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.
* 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.
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.
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.
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.
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
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.
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.
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
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.
* 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.
* 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.
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
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.
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
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.
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.
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
1188 changed files with 61072 additions and 10210 deletions
- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size
- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs
- Android and iOS: an OLED theme, the dark theme on a pure black background
- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings
- Text editor 3.0: Huge improvements in input and performance
- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu
- Text editor: Delete in the right-click menu
- Turkish translation
- Web: a fifth reader reaction on public stories, "Surprised me"
Improvements
- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene
- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft
- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored
- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always
- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts
- Android: a launch splash screen on every supported version
- Translations and dependencies updated
Fixes
- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983]
- Export: unsaved scene edits were left out of the exported file [#983]
- Restoring a backup from the Backup Manager made the project disappear from the project list
- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes
- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps
- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again
- iOS: a crash when tapping into a text editor
- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL
- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs")
## [3.11.0] - 2026-9-24
New features
- Web: signed-in readers can leave kudos at the end of a publicly published story, picking up to four craft chips and one reaction. Kudos are anonymous: authors see per-chip totals on their story page, and a chip appears on the public page once three readers have picked it. Authors can turn kudos off for any story
Improvements
- Word counts no longer count standalone Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words. The server now counts the same way, so both give the same number
- Android and Wear OS builds are now shrunk and obfuscated with R8
- Translations and dependencies updated
Fixes
- Text editor: numpad navigation keys and numpad Enter now work
- Text editor: keyboard (IME) edits are applied right away, and the keyboard is told about each one only once
- Text editor: spelling underlines stay on the right words while you keep typing
- Text editor: the formatting you picked for new text is kept after unrelated updates
- Text editor: fixed crashes and misplaced carets when drawing the cursor or moving it during a layout change
- Wear OS: pairing with the phone app works in release builds
## [3.10.1] - 2026-9-23
- Fix Android Wear release
## [3.10.0] - 2026-9-20
New features
- A Wear OS watch app: dictate notes into your projects and capture story ideas from your wrist, then sync them on their own. Pair it from your phone, or sign in on the watch directly
- Add words to a project's own spelling dictionary, either from "Add to dictionary" on a flagged word in the editor or from the word list in Project Settings under Spell checking. The list syncs with the project
- Encyclopedia entry names and draft names accept the same characters project and scene names already did: punctuation like . , ! ? : ( ) & - " and non-Latin letters
- Copy Diagnostics on the About screen puts your version, system details, recent log, and newest crash report on the clipboard, so a bug report can carry its own diagnostics
- Desktop: Export Logs saves a zip wherever you choose, which works even where opening the log folder does not
Improvements
- A failed login says what actually went wrong instead of answering with a bare 401, and a self-hosted server logs which failure occurred
- Account creation reports policy problems, real conflicts, and Allowed Users rejections distinctly
- Builds record which store or channel they came from, which shows up in diagnostics
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Web: the home page offers the iOS app, which now shares one App Store listing with macOS
Fixes
- Desktop (Microsoft Store): the app crashed on its first network call and could not sync at all
- Client: email and password fields no longer capitalize the first character as you type, which could create an account with a password you could not reproduce elsewhere
- Client: a network failure occurring before any request was sent showed a raw Java error instead of a real message
- Text editor: the spelling suggestions menu could stick on "Loading" when the editor re-anchored it
- Sync: clients on different versions no longer fail an entire sync over a field one side does not recognize
- Sync: a failed sync no longer ends by logging "Sync complete!"
- Web: previous and next page links on the community feed and authors pages
## [3.9.8] - 2026-9-12
iOS Release fix
## [3.9.7] - 2026-9-4
iOS Release fix
## [3.9.6] - 2026-9-3
Release fix
## [3.9.5] - 2026-9-1
Release fix
## [3.9.4] - 2026-8-22
Fix widgets not doing anything
## [3.9.3] - 2026-8-22
Bug fixes
## [3.9.2] - 2026-8-21
- Fix MacOS Crash
- Fix Android widgets not working
## [3.9.1] - 2026-8-18
- Fix sharing scene order
- Fix PDF export crash
- Fix MacOS crash on start
- Added configurable timezone for server admins
## [3.9.0] - 2026-8-16
New features
- Export only the scenes you choose, in every export format
- Private web shares can be limited to a selection of scenes
- Encyclopedia entry names and aliases feed the spell check dictionary, so your invented names stop being flagged as misspelled. Controllable globally, per project, and per entry
- Scenes now connect to every kind of Encyclopedia entry, not just People and Places
- Drag the editor's edge to set how wide it gets on large screens
- Server: search the Allowed Users list by email
Improvements
- Logging in to a new server asks whether to merge or replace, and only asks when local work is actually at stake
- Published stories and every export now lay prose out the way the editor shows it, line for line
- A scene opens for typing immediately instead of waiting for spell check to finish
- Scene tree nesting is easier to read
- Save all moved from Ctrl+Alt+S to Ctrl+Shift+S, so AltGr layouts keep their characters
- Desktop (Linux): runs natively on Wayland instead of being forced through XWayland
- Web: the editorial review dialog's scene list uses the full height of the window
- Italian translation added, and translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Server: Argon2 password hashing no longer loads a native library, which fixes signup and login on hosts that mount the data volume noexec
Fixes
- Client: scenes in a folder holding exactly ten items could go blank and export empty
- Client: sync could fail when a folder crossed a ten-item boundary
- Client: unsaved edits survive a device rotation on Android
- Client: AltGr chords no longer close the app on Turkish, German, and Polish layouts
- Client: crash when closing a project with the scene metadata panel open
- Sync: renaming a project onto the name of one you had deleted
- Sync: project settings could be wiped when a project moved to a different server
- Sync: a local project sharing a name with a server project could leave a duplicate behind
- Server: sync could hang indefinitely on hosts with a shallow entropy pool
- Server: concurrent sync sessions could be handed colliding sync ids
- Server: API errors returned a 500 instead of falling back to English when a translation was missing
- Web: Allowed Users paging could repeat and skip entries
## [3.8.2] - 2026-8-11
New features
- Web: public sign-up page, so an allowed user can create an account without installing the app
- Server: the Allowed Users list is now always enforced; the old whitelist toggle is gone
Improvements
- Server: web sign-ins are now recorded in the security audit trail, alongside app sign-ins
- Server: admin instance messages render as Markdown, so links, emphasis, and lists work in them
- About: Dark Rock Studios attribution moved into its own "Studio" section with a full-screen Cairn overlay
- Desktop: dropped the JetBrains Runtime requirement; builds run on stock Temurin OpenJDK now
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
Fixes
- Desktop (macOS): fixed a crash on launch
- Desktop (Linux/Snap): fixed a crash on launch
## [3.8.1] - 2026-8-8
Improvements
- Desktop window decoration migrated to the Nucleus TAO backend
- Server: trustProxyForwarding option reads real client IPs from X-Forwarded-* headers when running behind a reverse proxy, so the login rate limiter, login audit trail, and story reader counts reflect actual clients instead of the proxy
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Rendered stories now use real book typography: indented lists, a centered scene-break ornament, tinted blockquotes, and deliberate blank-line spacing between paragraphs
Fixes
- Client: text editor no longer drops block structure (horizontal rules, images, code fences) when a scene loads
- Client: font size changes no longer flatten list and blockquote indents
- Client: typed text after a size change could lose the paragraph's body style
- Client: F3 and Ctrl+Alt+S sync shortcuts work from anywhere in the project window
- Client: tag suggestion chips could add the typed prefix instead of the selected tag
- Sync: projects with a stale cached server id could get stuck failing "410 Gone" instead of recreating
- Web: ~~strikethrough~~ rendered as literal tildes instead of struck-through text
## [3.8.0] - 2026-8-4
https://hammer.ink/blog/news/hammer-3-8-0
### New features
- Set the language a project is written in, and have spell check follow it
- Self-service account deletion from the web dashboard, with an admin restore window
- Import and export documents as HTML, alongside Markdown
- "What's New" release notes shown in-app with no network request, and reopenable from About
- Import preview warns when a scene comes in unusually large, so a failed chapter split doesn't quietly collapse a manuscript into one scene
### Improvements
- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps
- Text editor: platform-correct key bindings on macOS, and AltGr chords type their character instead of firing shortcuts
- Large Markdown imports are dramatically faster: 1600 scenes went from 37 seconds to about 3
- Search matches the rendered prose, resolving Markdown escapes and formatting
- Tag search matches accented and non-latin characters, and searches entry names as well as bodies
- Scene drag and drop stays accurate while the list autoscrolls
- Chapter detection on Markdown import understands Setext-style and bold titles
- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Server: the setup page can create the initial admin account
- Server: a self-hosted server not serving HTTPS now reports a clear error instead of "Network error"
- Server: smaller Docker image
- Web: error responses show a toast instead of failing silently
### Fixes
- Client: importing a very large story no longer freezes the app
- Client: scene drag could grab the wrong row
- Client: literal `**` and `_` left around emphasis imported from RTF
- Client: sync server URLs are normalized consistently when saved
- Client: a number of text editor crashes around undo, paste, and joined paragraphs
- Web: htmx requests denied access landed on a blank page instead of being redirected
<stringname="note_widget_config_project_checkbox">Ajouter la Note au projet</string>
<stringname="note_widget_config_project_checkbox_explained">Si aucun projet n\'est sélectionné, on vous demandera de choisir un projet à chaque fois.</string>
<stringname="stories_list_widget_description">Hammer · Bibliothèque -- Ouvrir une histoire</string>
<stringname="story_info_widget_description">Hammer · Histoire — aperçu des statistiques pour un projet</string>
<stringname="story_info_widget_description">Hammer · Histoire — Aperçu des statistiques pour un projet</string>
<stringname="story_info_widget_config_title">Widget d\'info pour une histoire :</string>
<stringname="title_activity_story_info_widget_config">Configurer le Widget d\'info pour une histoire</string>
<stringname="story_info_widget_unconfigured">Appuyez pour configurer</string>
<stringname="wear_pairing_masthead">APPAIRER LA MONTRE</string>
<stringname="wear_pairing_title">Se connecter à %1$s sur Hammer ?</string>
<stringname="wear_pairing_body">La montre sera synchronisée en tant que %1$s avec sa propre session. Vous pouvez la déconnecter depuis la montre à tout moment.</string>
<stringname="wear_pairing_not_signed_in">Connectez-vous au serveur sur ce smartphone en premier, puis appairez la montre de nouveau.</string>
<stringname="note_widget_config_project_checkbox">Aggiungi note al progetto specificato</string>
<stringname="note_widget_config_project_checkbox_explained">Se non viene selezionato alcun progetto, ti verrà chiesto di sceglierne uno ogni volta.</string>
<stringname="stories_list_widget_description">Hammer · Libreria — apri una storia</string>
<stringname="story_info_widget_description">Hammer · Storia — statistiche rapide per un progetto</string>
<stringname="story_info_widget_config_title">Widget info storia:</string>
<stringname="title_activity_story_info_widget_config">Configura widget info storia</string>
<stringname="story_info_widget_unconfigured">Tocca per configurare</string>
<stringname="wear_pairing_title">Accedere come %1$s su Hammer?</string>
<stringname="wear_pairing_body">L’orologio si sincronizzerà come %1$s con una propria sessione. Puoi disconnetterlo dall’orologio in qualsiasi momento.</string>
<stringname="wear_pairing_not_signed_in">Accedi prima a un server di sincronizzazione su questo telefono, poi associa nuovamente l’orologio.</string>
tabHint("Goes to CHANGELOG.md and the GitHub release. Write everything here."),
JScrollPane(changeLog),
null,
)
valstoreTab=editorTab(
tabHeader(
"The app-only base every listing starts from. Entries tagged for the web or server are filtered out, because stores reject notes about anything but the app. Google Play and Apple follow this text on their own tabs; Flathub publishes it as it stands, on releases to every store.",
storeStatus,
listOf(storeResync),
),
JScrollPane(storeNotes.editor),
null,
)
valplayTab=editorTab(
tabHeader(
"Goes to Google Play and F-Droid, which read the same file. 500 characters, including the link to the full notes on GitHub.",
playStatus,
listOf(playResync,trimButton),
),
JSplitPane(
JSplitPane.VERTICAL_SPLIT,
JScrollPane(playNotes.editor),
JScrollPane(playPreview).apply{
border=BorderFactory.createTitledBorder("Google Play preview")
},
).apply{
resizeWeight=0.6
border=null
},
playCount,
)
valappleTab=editorTab(
tabHeader(
"Goes to the iOS and Mac App Stores. 4000 characters, and no link: App Store review rejects notes pointing at the GitHub releases page.",
appleStatus,
listOf(appleResync),
),
JScrollPane(appleNotes.editor),
appleCount,
)
valchangelogTabs=JTabbedPane().apply{
addTab("Full",fullTab)
addTab("App stores",storeTab)
addTab("Google Play",playTab)
addTab("Apple",appleTab)
}
/** The platforms the current scope selection targets, empty if the selection is incomplete. */
funcurrentPlatforms():Set<Platform>=when(scope){
@ -212,30 +384,20 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
ReleaseScope.SERVER_ONLY->setOf(Platform.SERVER)
}
// The store editor mirrors a filtered copy of the full notes until it is