19 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 0ecdbfe257 desktop.yml: every job has its own deadline (#5409)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 31s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m56s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m54s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 5m46s
Android / Build the server image (push) Successful in 1s
Run 8830's Linux bundle sat on a rust-lld link whose 17 threads were all
parked on futexes until the runner's own 30-minute cap ended it. verify, the
Linux and Windows builds get 20 minutes (warm runs take ~4, and a cold one
compiles the tree in about two), the manifest 5. Kept under the runner's cap
so these are the limits that fire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:37:34 -04:00
bvandeusenandClaude Opus 5.5 78810d8832 DRY pass #3: core tests open their store and seed a label one way (#5373)
Five test modules wrote the same db() over memory_conn, and portable.rs
unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the
same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are
cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:17:30 -04:00
bvandeusenandClaude Opus 5.5 1b128a9374 DRY pass #3: a secret setting is the text input with a password type (#5373)
SettingsView drew secret and plain text settings as two inputs differing only
in type, autocomplete and the saved-value placeholder; one input binds those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:16 -04:00
bvandeusenandClaude Opus 5.5 9dcdb43568 DRY pass #3: both sync work requests take one network-and-backoff policy (#5373)
enable and pushSoon each set the CONNECTED constraint and the 30-second
exponential backoff; B.online() on WorkRequest.Builder sets both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:04 -04:00
bvandeusenandClaude Opus 5.5 91ec677025 DRY pass #3: checklist near-misses live in the grammar fixture (#5373)
test_notes.py and derive.rs each kept the same six lines of prose that look
like a checklist item. Four were already task_lines cases in grammar.json;
the other two (empty brackets, no bullet) join them, so the web suite now
tests them too, and both hand lists go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:15:17 -04:00
bvandeusenandClaude Opus 5.5 0e21b8e7f1 DRY pass #3: SyncView's update choices are one fieldset, twice (#5373)
The source and channel fieldsets were the same markup over different options.
updateChoices holds each set with its legend, radio name and when it is
offered, and one v-for draws them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:14:10 -04:00
bvandeusenandClaude Opus 5.5 31d8b1e65b DRY pass #3: unlinking a device by id or by its own token is one tail (#5373)
revoke_self and revoke_device each loaded the caller's DeviceToken, deleted
it, committed and audited DEVICE_UNLINKED; _unlink_device(which) is that,
owner-scoped as before, with each route keeping only how it names the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:39 -04:00
bvandeusenandClaude Opus 5.5 498b23789c DRY pass #3: one one-time secret reveal (#5373)
AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:18 -04:00
bvandeusenandClaude Opus 5.5 a0249564e2 DRY pass #3: the web asks one question about a password's length (#5373)
RegisterView and ResetPasswordView each wrote the min-length check and its
message; config.passwordRefusal(password) is that check, the web's side of
auth._password_refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:12:32 -04:00
bvandeusenandClaude Opus 5.5 8c45725a2a DRY pass #2, batch 8, F17: a person and an account have one shape each (#5372)
Web: GroupList's Person was adapters/repo's Member field for field, and
AccountList's Account was the session store's User; both now import them.
The bridge's Identity stays, as the Tauri mirror of the core's struct.

Server: auth._serialize_user and accounts' _serialize_account wrote the same
four fields. serialize.serialize_user is serialize_person plus is_admin, and
the account list adds created_at to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:11:29 -04:00
bvandeusenandClaude Opus 5.5 0d82e26eea DRY pass #2, batch 8, F20: drawer titles and tag colour names come from strings.xml (#5372)
Destination carried its English title as a field, and NoteTint its colour
name; every other word on screen is a string resource. Destination.title()
now reads nav_notes/reminders/archive/trash (a tag's destination keeps its
name), and NoteTint.label is a @StringRes. Error fallbacks raised in view
models and the install receiver stay as constants: they are made outside
composition, with no Context to read a resource from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:10:15 -04:00
bvandeusenandClaude Opus 5.5 cba428461c DRY pass #2, batch 8: the Shared-with-me facet is tested once (#5372)
sharing.test.ts round-tripped shared=with_me, which facets.test.ts already
does with every facet; its unknown-value case moves there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:08:38 -04:00
bvandeusenandClaude Opus 5.5 45c286a71f DRY pass #2, batch 8: inkwell.notes stops re-exporting its siblings (#5372)
notes/__init__ re-exported 17 names so it could stand in for the old single
module. Only sync.py and test_notes.py used that, and each now imports from
the module that defines the name; __all__ and the nine imports __init__ held
only for it go. sync.py also drops sa_delete, func, live and NoteLabel,
unused since 8eff5f6 (#5382). The integration test's patch on
inkwell.notes.schedule_unfurls stays: the routes call it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:08:24 -04:00
bvandeusenandClaude Opus 5.5 7392ca2e97 DRY pass #2, batch 8, F20: takeShared's two SEND branches are one (#5372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:07:24 -04:00
bvandeusenandClaude Opus 5.5 fb95c9b66f DRY pass #2, batch 8, F20: one foreground observer, one foreground latch (#5372)
FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:07:02 -04:00
bvandeusenandClaude Opus 5.5 63abff8681 DRY pass #2, batch 8: the Rust and ffi docs (#5372)
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:43 -04:00
bvandeusenandClaude Opus 5.5 2ce68f578a DRY pass #2, batch 8: stale comments (#5372)
useNoteList no longer names a Search view; the desktop adapter's M10.7 plan
gave way to sync under the local core; local.ts's sharing comment sat above
settings; AccountList and password_resets still said there was no mail path;
NoteEditor kept an orphan checklist-flag comment, a textarea comment from
before blocks, and the link-preview comment above the file picker; the
serialize docstring's growth plan is now what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:03:50 -04:00
bvandeusenandClaude Opus 5.5 e911f1d757 DRY pass #2, batch 8: the web's leftovers (#5372)
- notes/datetime.ts formatLocalDay had no caller; removed.
- style.css set body in two consecutive blocks; they are one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:01:13 -04:00
bvandeusenandClaude Opus 5.5 fae03c7eca install.sh reads INKWELL_SERVER, INKWELL_CHANNEL and INKWELL_TOKEN (#5372)
The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:01:13 -04:00
59 changed files with 411 additions and 567 deletions
+8
View File
@@ -91,6 +91,7 @@ jobs:
verify:
name: Web tests, clippy, Rust tests and rustfmt
needs: [decide]
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -146,6 +147,11 @@ jobs:
build:
name: Tauri desktop (Linux)
needs: [decide, verify]
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
# runs to the runner's own 30-minute cap, as run 8830 did on a rust-lld link whose
# threads had all deadlocked (#5409). Kept under that cap so this one is the limit
# that fires, whatever the runner is configured with.
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -339,6 +345,7 @@ jobs:
windows:
name: Windows installer (cross-compiled)
needs: [decide, verify]
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -471,6 +478,7 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
timeout-minutes: 5
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
@@ -131,8 +131,8 @@ class MainActivity : ComponentActivity() {
private fun takeShared(intent: Intent?): SharedIn? {
val incoming =
when (intent?.action) {
Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE ->
SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
else -> null
}
@@ -430,7 +430,7 @@ private fun ReminderAlarms(core: Inkwell) {
refresh()
}
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
ForegroundTransitions(onForeground = { refresh() })
LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
@@ -461,17 +461,9 @@ private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
// Waits for a link rather than dropping the trip: linking a device while the app is
// in front checks as soon as it is linked.
OnEachForeground(ready = linked, act = onCheck)
}
/**
@@ -508,28 +500,18 @@ private fun AutomaticSync(
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
}
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(
onForeground = { wanted = true },
// Ready once `loading` is done, so the decision waits for the stored link to be
// READ. At first composition `linked` is still false because nothing has looked in
// the database yet, and acting on that would skip the sync on every cold start.
OnEachForeground(
ready = !state.loading,
onBackground = {
// Unsent work follows the person out of the app. Without this, a note
// written on a phone that then goes into a pocket for the night does
// not reach the desktop until the app is opened again by hand.
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
},
)
// Keyed on `loading` so the decision waits for the stored link to be READ. At
// first composition `linked` is still false because nothing has looked in the
// database yet, and acting on that would skip the sync on every cold start.
LaunchedEffect(wanted, state.loading) {
if (!wanted || state.loading) return@LaunchedEffect
// Consumed here, so this fires exactly once per trip to the foreground
// however many times the effect restarts. Writing a key from inside the
// effect does restart it — but there is no suspension point between here
// and the call below, so the block runs to completion before the
// recomposition that would cancel it can be scheduled.
wanted = false
) {
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
if (enabled && state.linked && worthIt) onSync()
}
@@ -9,6 +9,7 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
import java.util.concurrent.TimeUnit
/**
@@ -38,8 +39,7 @@ object SyncSchedule {
fun enable(context: Context) {
val request =
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.online()
.build()
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
@@ -68,8 +68,7 @@ object SyncSchedule {
fun pushSoon(context: Context) {
val request =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.online()
.build()
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
@@ -79,11 +78,14 @@ object SyncSchedule {
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
}
private fun networkRequired(): Constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
/** What both requests share: a network to run on, and the same backoff. */
private fun <B : WorkRequest.Builder<B, *>> B.online(): B =
setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
private const val PERIODIC = "inkwell-periodic-sync"
private const val PUSH = "inkwell-push-pending"
@@ -470,6 +470,16 @@ private fun NavigationDrawer(
}
}
@Composable
private fun Destination.title(): String =
when (this) {
Destination.Notes -> stringResource(R.string.nav_notes)
Destination.Reminders -> stringResource(R.string.nav_reminders)
Destination.Archive -> stringResource(R.string.nav_archive)
Destination.Trash -> stringResource(R.string.nav_trash)
is Destination.WithLabel -> name
}
@Composable
private fun DrawerRow(
destination: Destination,
@@ -477,7 +487,7 @@ private fun DrawerRow(
onOpen: (Destination) -> Unit,
) {
NavigationDrawerItem(
label = { Text(destination.title) },
label = { Text(destination.title()) },
selected = destination == current,
onClick = { onOpen(destination) },
modifier = Modifier.padding(horizontal = 12.dp),
@@ -30,27 +30,17 @@ import kotlinx.coroutines.withContext
* empty board.
*/
sealed interface Destination {
val title: String
data object Notes : Destination
data object Notes : Destination {
override val title = "Notes"
}
data object Reminders : Destination
data object Reminders : Destination {
override val title = "Reminders"
}
data object Archive : Destination
data object Archive : Destination {
override val title = "Archive"
}
data object Trash : Destination {
override val title = "Trash"
}
data object Trash : Destination
data class WithLabel(
val id: String,
override val title: String,
val name: String,
) : Destination
}
@@ -80,10 +80,9 @@ sealed interface EditorAction {
/**
* Mark the reminder dealt with.
*
* Distinct from [ClearReminder] even though the core does the same thing to
* the column today: this is where recurrence advancement lands when it is
* built, so a recurring reminder finished through the generic clear would
* silently stop recurring.
* Distinct from [ClearReminder]: a recurring reminder moves on to its next
* occurrence here, and only a one-off is cleared, so a recurring reminder
* finished through the generic clear would stop recurring.
*/
data object CompleteReminder : EditorAction
@@ -1,36 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*
* Shared by the editor and the capture sheet. Both are places where text exists
* only in a composable until something writes it down, and the process can be
* killed while backgrounded without either of them being told again.
*/
@Composable
fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -2,8 +2,12 @@ package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -11,28 +15,31 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
* shade — and a sync per shade-pull is not automatic sync, it is a stutter, just as
* a save per dialog would save the editor mid-sentence.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Two callers, wanting opposite halves of it: automatic sync uses the return to
* decide whether to fetch, and the reminder notice uses it to re-read a
* permission the person may have just changed in the system settings.
* Callers want different halves of it: automatic sync uses both, the reminder
* notice and the update card use the return to re-read a permission the person may
* have just changed in the system settings, and the editor uses the leaving to
* write down text that otherwise exists only in a composable — the process can be
* killed while backgrounded without it being told again.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
* before any note existed, or saving the first composition's empty text over a
* full note.
*/
@Composable
fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
onForeground: () -> Unit = {},
onBackground: () -> Unit = {},
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* Run [act] once per trip to the front, as soon as [ready] holds.
*
* A trip that arrives before [ready] is held, not dropped: it runs when [ready]
* turns true. [onBackground] is passed through to [ForegroundTransitions].
*
* The trip is consumed inside the effect, so [act] fires once however many times
* the effect restarts. Writing a key from inside the effect does restart it — but
* there is no suspension point between consuming it and the call, so the block
* runs to completion before the recomposition that would cancel it is scheduled.
*/
@Composable
fun OnEachForeground(
ready: Boolean,
onBackground: () -> Unit = {},
act: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = onBackground)
LaunchedEffect(wanted, ready) {
if (!wanted || !ready) return@LaunchedEffect
wanted = false
act()
}
}
@@ -142,7 +142,7 @@ fun NoteEditorScreen(
BackHandler(onBack = leave)
// The system picker, for any type: a note can carry a PDF as readily as a photo.
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
// Leaving for it stops this screen, so the flush on leaving has already saved the text by
// the time the files come back.
val pickFiles =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
@@ -152,7 +152,7 @@ fun NoteEditorScreen(
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
ForegroundTransitions(onBackground = flush)
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
@@ -340,7 +340,7 @@ private fun EditorOverlays(
* How long typing has to stop before the note is written.
*
* Long enough that a normal sentence is one write, short enough that nothing
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* meaningful is at risk if the app dies. The flush on close and on leaving the app still
* cover the window between the last keystroke and this elapsing.
*/
private const val AUTOSAVE_IDLE_MS = 1_000L
@@ -1,8 +1,10 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.inkwell.R
/**
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
@@ -27,7 +29,7 @@ import androidx.compose.ui.graphics.Color
* acid against the neutral surfaces.
*/
data class NoteTint(
val label: String,
@StringRes val label: Int,
val lightBackground: Color,
val lightBorder: Color,
val darkBackground: Color,
@@ -121,7 +123,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
"default" to
NoteTint(
label = "Default",
label = R.string.tint_default,
lightBackground = Color(0xFFFFFFFF),
lightBorder = Color(0xFFE5E5E5),
darkBackground = Color(0xFF171717),
@@ -135,7 +137,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"red" to
NoteTint(
label = "Red",
label = R.string.tint_red,
lightBackground = Color(0xFFFEF2F2),
lightBorder = Color(0xFFFECACA),
darkBackground = Color(0x66450A0A),
@@ -149,7 +151,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"orange" to
NoteTint(
label = "Orange",
label = R.string.tint_orange,
lightBackground = Color(0xFFFFF7ED),
lightBorder = Color(0xFFFED7AA),
darkBackground = Color(0x66431407),
@@ -163,7 +165,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"yellow" to
NoteTint(
label = "Yellow",
label = R.string.tint_yellow,
lightBackground = Color(0xFFFFFBEB),
lightBorder = Color(0xFFFDE68A),
darkBackground = Color(0x66451A03),
@@ -177,7 +179,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"green" to
NoteTint(
label = "Green",
label = R.string.tint_green,
lightBackground = Color(0xFFF0FDF4),
lightBorder = Color(0xFFBBF7D0),
darkBackground = Color(0x66052E16),
@@ -191,7 +193,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"teal" to
NoteTint(
label = "Teal",
label = R.string.tint_teal,
lightBackground = Color(0xFFF0FDFA),
lightBorder = Color(0xFF99F6E4),
darkBackground = Color(0x66042F2E),
@@ -205,7 +207,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"blue" to
NoteTint(
label = "Blue",
label = R.string.tint_blue,
lightBackground = Color(0xFFEFF6FF),
lightBorder = Color(0xFFBFDBFE),
darkBackground = Color(0x66172554),
@@ -219,7 +221,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"purple" to
NoteTint(
label = "Purple",
label = R.string.tint_purple,
lightBackground = Color(0xFFFAF5FF),
lightBorder = Color(0xFFE9D5FF),
darkBackground = Color(0x663B0764),
@@ -233,7 +235,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"pink" to
NoteTint(
label = "Pink",
label = R.string.tint_pink,
lightBackground = Color(0xFFFDF2F8),
lightBorder = Color(0xFFFBCFE8),
darkBackground = Color(0x66500724),
@@ -247,7 +249,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"gray" to
NoteTint(
label = "Gray",
label = R.string.tint_gray,
lightBackground = Color(0xFFF5F5F5),
lightBorder = Color(0xFFD4D4D4),
darkBackground = Color(0xFF262626),
@@ -48,7 +48,6 @@ fun ReminderNotice() {
canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context)
},
onBackground = {},
)
if (canNotify && canBeExact) return
@@ -524,7 +524,7 @@ private fun ColourDialog(
) {
Swatch(tint, dark)
Text(
text = tint.label,
text = stringResource(tint.label),
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
@@ -53,7 +53,7 @@ fun UpdateCard(
// be up after someone granted it and came back, and the offer below it would
// look blocked when it is not (family idea #5103, practice 9).
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) }, onBackground = {})
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
// The system answers an install through a BroadcastReceiver, which has no way
// back into a view model. This is the seam.
@@ -7,6 +7,20 @@
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Tags</string>
<string name="nav_notes">Notes</string>
<string name="nav_reminders">Reminders</string>
<string name="nav_archive">Archive</string>
<string name="nav_trash">Trash</string>
<string name="tint_default">Default</string>
<string name="tint_red">Red</string>
<string name="tint_orange">Orange</string>
<string name="tint_yellow">Yellow</string>
<string name="tint_green">Green</string>
<string name="tint_teal">Teal</string>
<string name="tint_blue">Blue</string>
<string name="tint_purple">Purple</string>
<string name="tint_pink">Pink</string>
<string name="tint_gray">Gray</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
+8 -10
View File
@@ -291,11 +291,9 @@ impl Inkwell {
/// Clear the reminder, marking it dealt with.
///
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
/// thing: the core reserves this one for "the reminder fired and is finished",
/// which is where recurrence advancement lands when it is built. A UI that
/// called the generic clear instead would silently stop recurring reminders
/// from recurring the day that changes.
/// Distinct from `NoteEdit::ClearRemindAt`: a recurring reminder moves on to its
/// next occurrence here, and only a one-off is cleared. A UI that called the
/// generic clear instead would stop a recurring reminder from recurring.
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::complete_reminder(&conn, &id)
@@ -539,11 +537,6 @@ impl Inkwell {
Ok(revoked.into())
}
/// Run one full sync: push local changes, then pull the server's.
///
/// The only sync entry point, on purpose. Push and pull exist separately inside
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
/// edits — the ordering isn't a suggestion, it's what keeps them.
/// The Android client the linked server is offering, if any.
///
/// `None` covers two different-looking situations that are one answer to the
@@ -592,6 +585,11 @@ impl Inkwell {
.map_err(CoreError::network)
}
/// Run one full sync: push local changes, then pull the server's.
///
/// The only sync entry point, on purpose. Push and pull exist separately inside
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
/// edits — the ordering isn't a suggestion, it's what keeps them.
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
let (base_url, token) = self.credentials()?;
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
+2 -1
View File
@@ -358,7 +358,8 @@ impl From<core_models::Label> for Label {
/// What the board is asking for. Mirrors the core's `ListQuery`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteQuery {
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
/// "trash" | "archived"; anything else is the board. The core doesn't reject an
/// unknown one, so a typo shows the board rather than failing.
pub view: String,
pub label_id: Option<String>,
pub sort: Option<String>,
-15
View File
@@ -611,21 +611,6 @@ mod tests {
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
}
#[test]
fn items_reject_near_misses() {
// Each of these is prose, and each has been someone's bug report somewhere.
for body in [
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
] {
assert!(extract_items(body).is_empty(), "should be prose: {body}");
}
}
#[test]
fn items_accept_star_bullets_and_indentation() {
// `*` because markdown.ts already takes it for a plain bullet.
+17
View File
@@ -88,6 +88,23 @@ pub(crate) fn memory_conn() -> rusqlite::Result<Connection> {
Ok(conn)
}
/// A test's store: [`memory_conn`], which can't fail on a fresh in-memory database.
#[cfg(test)]
pub(crate) fn test_db() -> Connection {
memory_conn().expect("in-memory db")
}
/// A label this device holds, by id and name, for tests that need one in place.
#[cfg(test)]
pub(crate) fn seed_label(conn: &Connection, id: &str, name: &str, dirty: i64) {
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', ?3)",
rusqlite::params![id, name, dirty],
)
.expect("seed label");
}
/// A one-line count summary of the store, for the startup log.
pub fn summary(db: &Db) -> String {
let conn = match db.conn() {
+1 -1
View File
@@ -595,7 +595,7 @@ mod tests {
use super::*;
fn store() -> (Connection, BlobStore, std::path::PathBuf) {
let conn = crate::local::memory_conn().unwrap();
let conn = crate::local::test_db();
let blobs = crate::sync::blobs::scratch("portable");
let dir = blobs.root().to_path_buf();
(conn, blobs, dir)
+1 -3
View File
@@ -81,9 +81,7 @@ pub fn sweep_if_unlinked(conn: &Connection) -> rusqlite::Result<Option<usize>> {
mod tests {
use super::*;
fn db() -> Connection {
crate::local::memory_conn().expect("in-memory db")
}
use crate::local::test_db as db;
/// A trashed note of a given age, stamped in the format the CLIENT writes
/// (`...Z`, millisecond precision — see `store::now`).
+2 -2
View File
@@ -1,6 +1,6 @@
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
//! offline note can later sync 1:1 with the server. Each syncable row carries local
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
//! `sync_revision` + `dirty` bookkeeping (consumed by `sync::push` and `sync::pull`);
//! `#tags` are NOT stored as such (derived at query time into labels), matching
//! docs/sync.md.
//!
@@ -250,7 +250,6 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
/// Bring the database up to the latest schema. Idempotent.
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// at all. `labels.color` is untouched — that is the colour that survived.
@@ -356,6 +355,7 @@ const STEPS: &[Step] = &[
Step::Sql(SCHEMA_V13),
];
/// Bring the database up to the latest schema. Idempotent.
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
+1 -3
View File
@@ -1123,9 +1123,7 @@ mod tests {
use super::*;
fn db() -> Connection {
crate::local::memory_conn().expect("in-memory db")
}
use crate::local::test_db as db;
fn note(conn: &Connection, body: &str) -> Note {
create_note(
+2 -2
View File
@@ -223,7 +223,7 @@ pub(crate) fn scratch(tag: &str) -> BlobStore {
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ts-blobs-{}-{n}-{tag}", std::process::id()));
let dir = std::env::temp_dir().join(format!("inkwell-blobs-{}-{n}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
BlobStore::new(dir).expect("scratch blob store")
}
@@ -326,7 +326,7 @@ mod tests {
#[test]
fn serving_refuses_a_path_that_isnt_a_hash() {
// Delegated to `path`, so the traversal guard is the same one `store` uses.
publish_root(std::env::temp_dir().join("ts-blobs-serve-guard"));
publish_root(std::env::temp_dir().join("inkwell-blobs-serve-guard"));
let (status, _, body) = serve("/../../etc/passwd", None);
assert_eq!(status, 404);
assert!(body.is_empty());
+69 -69
View File
@@ -594,75 +594,6 @@ fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_share_names_a_person_or_a_group() {
assert_eq!(
ShareTarget::Member("u1".into()).body("view"),
serde_json::json!({ "user_id": "u1", "permission": "view" })
);
assert_eq!(
ShareTarget::Group("g1".into()).body("edit"),
serde_json::json!({ "group_id": "g1", "permission": "edit" })
);
let raw = r#"{"id":"s2","member":null,
"group":{"id":"g1","name":"Family","member_count":3},
"permission":"edit"}"#;
let share: NoteShare = serde_json::from_str(raw).expect("a group share reads");
assert!(share.member.is_none());
assert_eq!(share.group.expect("group").member_count, 3);
// A server from before groups sends no `groups` at all.
let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory");
assert!(older.groups.is_empty());
}
#[test]
fn urls_join_without_doubling_slashes() {
// normalize_base_url has already stripped any trailing slash, so plain
// concatenation is correct — this pins that assumption.
assert_eq!(
config_url("https://notes.example.com"),
"https://notes.example.com/api/config"
);
assert_eq!(
device_login_url("https://notes.example.com"),
"https://notes.example.com/api/auth/device-login"
);
assert_eq!(
me_url("https://notes.example.com"),
"https://notes.example.com/api/auth/me"
);
assert_eq!(
revoke_self_url("https://notes.example.com"),
"https://notes.example.com/api/auth/devices/self"
);
}
#[test]
fn revoke_outcome_serializes_tagged_for_the_frontend() {
// The UI decides between "signed out on the server" and "still valid, go
// revoke it" by reading this tag, so its shape is part of the contract.
let json = serde_json::to_string(&RevokeOutcome::Failed {
reason: "offline".into(),
})
.expect("outcome serializes");
assert!(json.contains("\"status\":\"failed\""), "got {json}");
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
}
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
);
}
}
/// The Android client a linked server can hand out.
///
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
@@ -782,3 +713,72 @@ pub async fn download_client(
std::fs::rename(&partial, dest)
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_share_names_a_person_or_a_group() {
assert_eq!(
ShareTarget::Member("u1".into()).body("view"),
serde_json::json!({ "user_id": "u1", "permission": "view" })
);
assert_eq!(
ShareTarget::Group("g1".into()).body("edit"),
serde_json::json!({ "group_id": "g1", "permission": "edit" })
);
let raw = r#"{"id":"s2","member":null,
"group":{"id":"g1","name":"Family","member_count":3},
"permission":"edit"}"#;
let share: NoteShare = serde_json::from_str(raw).expect("a group share reads");
assert!(share.member.is_none());
assert_eq!(share.group.expect("group").member_count, 3);
// A server from before groups sends no `groups` at all.
let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory");
assert!(older.groups.is_empty());
}
#[test]
fn urls_join_without_doubling_slashes() {
// normalize_base_url has already stripped any trailing slash, so plain
// concatenation is correct — this pins that assumption.
assert_eq!(
config_url("https://notes.example.com"),
"https://notes.example.com/api/config"
);
assert_eq!(
device_login_url("https://notes.example.com"),
"https://notes.example.com/api/auth/device-login"
);
assert_eq!(
me_url("https://notes.example.com"),
"https://notes.example.com/api/auth/me"
);
assert_eq!(
revoke_self_url("https://notes.example.com"),
"https://notes.example.com/api/auth/devices/self"
);
}
#[test]
fn revoke_outcome_serializes_tagged_for_the_frontend() {
// The UI decides between "signed out on the server" and "still valid, go
// revoke it" by reading this tag, so its shape is part of the contract.
let json = serde_json::to_string(&RevokeOutcome::Failed {
reason: "offline".into(),
})
.expect("outcome serializes");
assert!(json.contains("\"status\":\"failed\""), "got {json}");
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
}
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
);
}
}
+4
View File
@@ -8,6 +8,10 @@
//! - `client` — HTTP transport: the handshake call and device-token auth.
//! - `state` — the persisted link record (server, token, change-feed cursor).
//! - `engine` — one full cycle: push local changes, then pull the server's.
//! - `link` — linking a device to a server and unlinking it, one flow for every client.
//! - `pull` / `push` — the two halves of that cycle.
//! - `wire` — the delta-feed JSON shapes, as the server sends them.
//! - `blobs` — attachment bytes on disk, filed under their own sha256.
//! - `sharing` — the Share dialog's calls, straight to the server (#5175).
//!
//! The UI surface that drives this lives in whichever client is wrapping the crate,
+3 -15
View File
@@ -508,9 +508,7 @@ pub async fn run(
mod tests {
use super::*;
fn db() -> Connection {
crate::local::memory_conn().expect("in-memory db")
}
use crate::local::test_db as db;
fn note(id: &str, revision: i64) -> wire::Note {
wire::sample_note(id, revision)
@@ -738,12 +736,7 @@ mod tests {
#[test]
fn server_label_replaces_a_local_duplicate_by_name() {
let conn = db();
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
[],
)
.expect("seed local label");
crate::local::seed_label(&conn, "local-id", "Work", 1);
let server = wire::Label {
id: "server-id".into(),
@@ -768,12 +761,7 @@ mod tests {
// plain delete would strip the label off them with nothing to repair it.
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("seed note");
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
[],
)
.expect("seed local label");
crate::local::seed_label(&conn, "local-id", "Work", 1);
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag)
VALUES ('n1', 'local-id', 0)",
+3 -15
View File
@@ -704,9 +704,7 @@ mod tests {
use super::*;
use crate::local::store;
fn db() -> Connection {
crate::local::memory_conn().expect("in-memory db")
}
use crate::local::test_db as db;
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
@@ -754,12 +752,7 @@ mod tests {
let conn = db();
seed_note(&conn, "n1", 1);
for (id, name, via_tag) in [("manual", "Manual", 0), ("tagged", "Tagged", 1)] {
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
params![id, name],
)
.expect("seed label");
crate::local::seed_label(&conn, id, name, 0);
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', ?1, ?2)",
params![id, via_tag],
@@ -877,12 +870,7 @@ mod tests {
let conn = db();
seed_note(&conn, "n1", 0);
for (id, name) in [("src", "Source"), ("dst", "Target")] {
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
params![id, name],
)
.expect("seed label");
crate::local::seed_label(&conn, id, name, 0);
}
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', 'src', 0)",
+1 -3
View File
@@ -309,9 +309,7 @@ pub fn status(conn: &Connection) -> rusqlite::Result<Status> {
mod tests {
use super::*;
fn db() -> Connection {
crate::local::memory_conn().expect("in-memory db")
}
use crate::local::test_db as db;
#[test]
fn fresh_store_is_unlinked() {
+2
View File
@@ -26,6 +26,8 @@
{ "line": "+ [ ] plus bullet", "item": null },
{ "line": "- [y] x", "item": null },
{ "line": "- [ ] x", "item": null },
{ "line": "- [] empty brackets", "item": null },
{ "line": "[ ] no bullet at all", "item": null },
{ "line": "text - [ ] x", "item": null },
{ "line": "- milk", "item": null },
{ "line": "", "item": null }
+9 -9
View File
@@ -22,7 +22,7 @@
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
# Served from `dev` rather than `main`: `main` exists but trails day-to-day work by
@@ -62,12 +62,12 @@ Inkwell desktop installer.
--channel dev from the forge: rolling build from the latest green push to `dev`
-h, --help this text
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass
The options can also come from INKWELL_SERVER and INKWELL_CHANNEL. Through a pipe, pass
them after `--`: curl -fsSL <url> | sh -s -- --channel dev
From a server, the download needs a device token: make one in the web app under
Account → Linked devices and paste it when asked. A token typed at the prompt is
revoked again once the download is done. TS_TOKEN supplies one without a prompt,
revoked again once the download is done. INKWELL_TOKEN supplies one without a prompt,
and is left alone, since whoever set it is managing it.
USAGE
}
@@ -75,12 +75,12 @@ USAGE
# The address of the server that served this script. Written by that server
# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the
# forge serves, which is what keeps the forge path the default there.
TS_SERVER_DEFAULT=""
INKWELL_SERVER_DEFAULT=""
# --- channel ----------------------------------------------------------------
channel="${TS_CHANNEL:-stable}"
channel_asked="${TS_CHANNEL:-}"
server="${TS_SERVER:-$TS_SERVER_DEFAULT}"
channel="${INKWELL_CHANNEL:-stable}"
channel_asked="${INKWELL_CHANNEL:-}"
server="${INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}"
while [ $# -gt 0 ]; do
case "$1" in
--channel)
@@ -200,11 +200,11 @@ say "Installing ${version:-unknown} from $server"
# The token, from the environment or from the person at the keyboard. Read from the
# TERMINAL, not stdin: through `curl | sh`, stdin is this script.
token="${TS_TOKEN:-}"
token="${INKWELL_TOKEN:-}"
prompted=""
if [ -z "$token" ]; then
{ [ -r /dev/tty ] && [ -w /dev/tty ]; } ||
die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN."
die "the download needs a device token and there's no terminal to ask on; set INKWELL_TOKEN."
printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty
printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty
printf 'Token: ' > /dev/tty
+2 -1
View File
@@ -90,7 +90,8 @@ mod tests {
fn root() -> (PathBuf, PathBuf) {
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("iw-crossover-{}-{n}", std::process::id()));
let root =
std::env::temp_dir().join(format!("inkwell-crossover-{}-{n}", std::process::id()));
(root.join("old"), root.join("new"))
}
+1 -1
View File
@@ -558,7 +558,7 @@ mod tests {
fn scratch_path(tag: &str) -> std::path::PathBuf {
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("ts-{tag}-{}-{n}", std::process::id()))
std::env::temp_dir().join(format!("inkwell-{tag}-{}-{n}", std::process::id()))
}
/// The channel `update_check` would actually use.
+2 -3
View File
@@ -2,9 +2,8 @@
// `api/client` in stores/views.
//
// The desktop shell runs fully offline against the on-device SQLite core
// (`local.ts`, over Tauri `invoke`); the web build uses REST. A server-connected
// desktop (opt-in sync) comes in M10.7 — at which point this becomes
// `isDesktop() && !hasConfiguredServer() ? local : rest`.
// (`local.ts`, over Tauri `invoke`); the web build uses REST. A desktop linked to a
// server still reads and writes locally: the core syncs underneath.
import { isDesktop } from "../desktop/bridge";
import { rest } from "./rest";
+3 -2
View File
@@ -84,14 +84,15 @@ export const local: Repo = {
titles: () => invoke<TitleEntry[]>("notes_titles"),
},
// Sharing is between accounts on a server, so these go to the linked one (#5175).
// Unlinked, each rejects with the core's explanation, which the dialog shows.
// The server's admin settings aren't reached from the desktop, linked or not.
settings: {
list: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
save: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)),
},
// Sharing is between accounts on a server, so these go to the linked one (#5175).
// Unlinked, each rejects with the core's explanation, which the dialog shows.
shares: {
directory: () => invoke<Directory>("shares_directory"),
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
+9 -15
View File
@@ -1,32 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useSessionStore } from "../stores/session";
import { useSessionStore, type User } from "../stores/session";
import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue";
import OneTimeSecret from "./OneTimeSecret.vue";
import { useRowAction, useLoad } from "../composables/useAction";
// Admin: the accounts on this instance, and a password reset link for any of them
// (#5173). There is no mail path, so the admin hands the link over.
interface Account {
id: string;
email: string;
display_name: string;
is_admin: boolean;
}
// (#5173). The admin hands the link over; a person can also mail one to themselves
// when the server can send email (#5266).
const session = useSessionStore();
const accounts = ref<Account[]>([]);
const accounts = ref<User[]>([]);
const { loading, error, load } = useLoad(async () => {
accounts.value = (await api.get<{ accounts: Account[] }>("/api/accounts")).accounts;
accounts.value = (await api.get<{ accounts: User[] }>("/api/accounts")).accounts;
}, "Couldn't load accounts.");
const { busy, act } = useRowAction();
// The reset link just made, and whose it is. Never retrievable again once dismissed.
const fresh = ref<{ link: string; email: string } | null>(null);
async function resetLink(account: Account) {
async function resetLink(account: User) {
const self = account.id === session.user?.id;
const warning = self
? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included."
@@ -51,9 +45,9 @@ onMounted(() => {
<section class="flex flex-col gap-5">
<h2 class="section-label">People</h2>
<OneTimeLink
<OneTimeSecret
v-if="fresh"
:link="fresh.link"
:value="fresh.link"
:note="`Password reset link for ${fresh.email}. It works once, within an hour.`"
@done="fresh = null"
/>
+6 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import type { Member } from "../adapters/repo";
import { errorMessage } from "../api/errors";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
@@ -11,24 +12,18 @@ import { useRowAction, useLoad } from "../composables/useAction";
// the instance's; anyone can share with it, and a note shared with it reaches whoever
// is in it at the time, so adding or removing someone here changes what they see.
interface Person {
id: string;
display_name: string;
email: string;
}
interface Group {
id: string;
name: string;
members: Person[];
members: Member[];
}
const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]);
const accounts = ref<Member[]>([]);
const { loading, error, load } = useLoad(async () => {
const [g, a] = await Promise.all([
api.get<{ groups: Group[] }>("/api/groups"),
api.get<{ accounts: Person[] }>("/api/accounts"),
api.get<{ accounts: Member[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
@@ -41,7 +36,7 @@ const picks = ref<Record<string, string>>({});
const byName = (a: Group, b: Group) => a.name.localeCompare(b.name);
function outside(group: Group): Person[] {
function outside(group: Group): Member[] {
const inside = new Set(group.members.map((m) => m.id));
return accounts.value.filter((a) => !inside.has(a.id));
}
@@ -102,7 +97,7 @@ function addMember(group: Group) {
);
}
function removeMember(group: Group, person: Person) {
function removeMember(group: Group, person: Member) {
void act(
group.id,
async () => replace(await api.del<Group>(`/api/groups/${group.id}/members/${person.id}`)),
+3 -3
View File
@@ -5,7 +5,7 @@ import { errorMessage } from "../api/errors";
import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import OneTimeLink from "./OneTimeLink.vue";
import OneTimeSecret from "./OneTimeSecret.vue";
import { toastOnFailure, useLoad } from "../composables/useAction";
// Admin: invite one person to register while registration stays closed (#5172).
@@ -97,9 +97,9 @@ onMounted(() => {
<section class="flex flex-col gap-5">
<h2 class="section-label">Invites</h2>
<OneTimeLink
<OneTimeSecret
v-if="freshLink"
:link="freshLink"
:value="freshLink"
note="Send this to the person you're inviting."
@done="freshLink = ''"
/>
+6 -12
View File
@@ -47,9 +47,6 @@ function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
// One element per block, keyed by the block's id — the only thing about a block that
@@ -489,12 +486,6 @@ async function uploadFile(file: File) {
}
}
// ---- link previews ----
//
// Nothing to trigger any more: the server unfurls a note's URLs in the background
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
// is left here is removing one you don't want — the editor is the only place with
// room to offer that, and the card deliberately doesn't.
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
@@ -638,7 +629,10 @@ function revPreview(rev: NoteRevision): string {
device until you remove it.
</p>
<!-- Fetched automatically after each save; removable here and nowhere else. -->
<!-- The server unfurls a note's URLs in the background after each save
(`unfurl_queue.py`) and the preview arrives on a later read. Removing one
happens here and nowhere else: the editor has room to offer it, and the
card deliberately doesn't. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
@@ -698,8 +692,8 @@ function revPreview(rev: NoteRevision): string {
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<!-- No checklist component. The items are blocks above, each one line of the
body, which is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
@@ -2,22 +2,27 @@
import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue";
// A link carrying a token the server keeps only a hash of, so this is the one time
// it can be shown: invites (#5172) and password reset links (#5173).
// A secret the server keeps only a hash of, so this is the one time it can be shown:
// invite links (#5172), password reset links (#5173) and device tokens.
const props = defineProps<{
link: string;
/** What the link is, shown above it. */
note: string;
}>();
const props = withDefaults(
defineProps<{
value: string;
/** What it is, shown above it. */
note: string;
/** What to call it on the copy button and in the toast. */
noun?: "link" | "token";
}>(),
{ noun: "link" },
);
const emit = defineEmits<{ done: [] }>();
const ui = useUiStore();
async function copy() {
try {
await navigator.clipboard.writeText(props.link);
ui.showToast("Link copied.");
await navigator.clipboard.writeText(props.value);
ui.showToast(props.noun === "token" ? "Token copied." : "Link copied.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
@@ -31,9 +36,15 @@ async function copy() {
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ link }}</code
>{{ value }}</code
>
<button
type="button"
class="icon-btn shrink-0"
:title="`Copy ${noun}`"
:aria-label="`Copy ${noun}`"
@click="copy"
>
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copy">
<Icon name="copy" />
</button>
</div>
+2 -2
View File
@@ -2,12 +2,12 @@ import { ref, type Ref } from "vue";
import type { Note } from "../stores/notes";
import { errorMessage } from "../api/errors";
// The load-a-list-of-notes scaffold every recall view (Search, Timeline, Reminders)
// The load-a-list-of-notes scaffold every recall view (Timeline, Reminders)
// hand-rolled: an items ref + loading/error flags + a load() that fills them and never
// leaves a half-state. The view supplies just the fetcher; wire the returned refs into
// <AsyncState> and pass `items` to <EmptyState>/cards.
//
// `fetcher` returns the notes to show (or [] — e.g. an empty search box). A thrown
// `fetcher` returns the notes to show. A thrown
// ApiError's `.error` becomes the message; otherwise `fallbackError` is shown.
export function useNoteList(
fetcher: () => Promise<Note[]>,
-6
View File
@@ -50,12 +50,6 @@ export function addLocalDays(d: Date, days: number): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
}
// Format a local Date as "YYYY-MM-DD" (the shape an <input type="date"> reads).
export function formatLocalDay(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// --- Trash retention. The server permanently deletes a trashed note once it's older
// than `trash_retention_days` (0 = keep forever). Counting down from the note's own
// deleted_at is what turns that from a surprise into a policy: a card in Trash can
+4
View File
@@ -21,4 +21,8 @@ describe("the board's filter URL", () => {
expect(facets).toEqual({});
expect(facetCount(facets)).toBe(0);
});
it("ignores a shared value it doesn't know", () => {
expect(facetsFromQuery({ shared: "by_me" })).toEqual({});
});
});
-14
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import { canEditText, isOwnNote } from "./sharing";
import { facetCount, facetsFromQuery, facetsToQuery } from "./facets";
import type { Note } from "../stores/notes";
const note = (extra: Partial<Note>): Note => ({ ...({} as Note), ...extra });
@@ -21,16 +20,3 @@ describe("how a note is held", () => {
expect(canEditText(note({ permission: "view" }))).toBe(false);
});
});
describe("the Shared with me facet", () => {
it("round-trips through the URL and counts as one filter", () => {
const facets = facetsFromQuery({ shared: "with_me" });
expect(facets).toEqual({ shared: "with_me" });
expect(facetsToQuery(facets)).toEqual({ shared: "with_me" });
expect(facetCount(facets)).toBe(1);
});
it("ignores a value it doesn't know", () => {
expect(facetsFromQuery({ shared: "by_me" })).toEqual({});
});
});
+9
View File
@@ -73,6 +73,14 @@ export const useConfigStore = defineStore("config", () => {
const minPasswordLength = ref(8);
const loaded = ref(false);
// Why the server would refuse this password, said before asking it; null when it
// would take it. The server checks again (auth._password_refusal).
function passwordRefusal(password: string): string | null {
return password.length < minPasswordLength.value
? `Password must be at least ${minPasswordLength.value} characters.`
: null;
}
async function load(): Promise<void> {
if (loaded.value) return;
try {
@@ -106,6 +114,7 @@ export const useConfigStore = defineStore("config", () => {
clients,
passwordResetByEmail,
minPasswordLength,
passwordRefusal,
loaded,
load,
reload,
-3
View File
@@ -35,9 +35,6 @@ body,
body {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
body {
@apply bg-neutral-50 text-neutral-900 antialiased;
}
+8 -39
View File
@@ -8,6 +8,7 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
import OneTimeSecret from "../components/OneTimeSecret.vue";
import PageHeader from "../components/PageHeader.vue";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
@@ -71,15 +72,6 @@ async function link() {
}
}
async function copyToken() {
try {
await navigator.clipboard.writeText(freshToken.value);
ui.showToast("Token copied to clipboard.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(id: string, name: string) {
if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return;
try {
@@ -147,37 +139,14 @@ onMounted(() => {
Hides itself when the server holds none. -->
<ClientDownloads />
<!-- One-time token reveal -->
<div
<OneTimeSecret
v-if="freshToken"
class="mb-6 rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Copy this token now — it won't be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshToken }}</code
>
<button
type="button"
class="icon-btn shrink-0"
title="Copy token"
aria-label="Copy token"
@click="copyToken"
>
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshToken = ''"
>
Done
</button>
</div>
class="mb-6"
:value="freshToken"
noun="token"
note="This device's token."
@done="freshToken = ''"
/>
<!-- Create a device token -->
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
+2 -5
View File
@@ -24,11 +24,8 @@ const error = ref("");
const loading = ref(false);
async function submit() {
error.value = "";
if (password.value.length < config.minPasswordLength) {
error.value = `Password must be at least ${config.minPasswordLength} characters.`;
return;
}
error.value = config.passwordRefusal(password.value) ?? "";
if (error.value) return;
loading.value = true;
try {
await session.register(email.value, password.value, displayName.value, invite.value || undefined);
+2 -5
View File
@@ -25,11 +25,8 @@ const error = ref("");
const loading = ref(false);
async function submit() {
error.value = "";
if (password.value.length < config.minPasswordLength) {
error.value = `Password must be at least ${config.minPasswordLength} characters.`;
return;
}
error.value = config.passwordRefusal(password.value) ?? "";
if (error.value) return;
if (password.value !== confirm.value) {
error.value = "The two passwords don't match.";
return;
+3 -11
View File
@@ -139,20 +139,12 @@ onMounted(load);
>
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
</select>
<input
v-else-if="it.secret"
:id="it.key"
type="password"
autocomplete="new-password"
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
class="w-56 field"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
/>
<input
v-else
:id="it.key"
type="text"
:type="it.secret ? 'password' : 'text'"
:autocomplete="it.secret ? 'new-password' : undefined"
:placeholder="it.secret && it.is_set ? 'Saved — type to replace' : undefined"
class="w-56 field"
:value="String(it.value)"
@input="it.value = ($event.target as HTMLInputElement).value"
+23 -30
View File
@@ -147,6 +147,13 @@ const channelOptions = computed<UpdateOption[]>(() => [
},
]);
// Each set of choices, and when it is offered. A server holds one build, so a
// channel only means something on the forge.
const updateChoices = computed(() => [
{ legend: "Updates from", name: "update-source", shown: !!offeredServer.value, options: sourceOptions.value },
{ legend: "Channel", name: "update-channel", shown: source.value === null, options: channelOptions.value },
]);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
@@ -650,36 +657,22 @@ onBeforeUnmount(() => stopSyncListener?.());
This is version {{ update?.current_version ?? "—" }}.
</p>
<fieldset v-if="offeredServer" class="mt-4">
<legend class="section-label">
Updates from
</legend>
<div class="mt-2 flex flex-col gap-2">
<label v-for="o in sourceOptions" :key="o.title" class="flex items-start gap-2 text-sm">
<input type="radio" class="mt-1" name="update-source" :checked="o.checked" @change="o.pick()" />
<span>
<span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
</span>
</label>
</div>
</fieldset>
<!-- A server holds one build, so a channel only means something on the forge. -->
<fieldset v-if="source === null" class="mt-4">
<legend class="section-label">
Channel
</legend>
<div class="mt-2 flex flex-col gap-2">
<label v-for="o in channelOptions" :key="o.title" class="flex items-start gap-2 text-sm">
<input type="radio" class="mt-1" name="update-channel" :checked="o.checked" @change="o.pick()" />
<span>
<span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
</span>
</label>
</div>
</fieldset>
<template v-for="group in updateChoices" :key="group.name">
<fieldset v-if="group.shown" class="mt-4">
<legend class="section-label">
{{ group.legend }}
</legend>
<div class="mt-2 flex flex-col gap-2">
<label v-for="o in group.options" :key="o.title" class="flex items-start gap-2 text-sm">
<input type="radio" class="mt-1" :name="group.name" :checked="o.checked" @change="o.pick()" />
<span>
<span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
</span>
</label>
</div>
</fieldset>
</template>
<p
v-if="update?.blocked_reason"
+2 -7
View File
@@ -18,6 +18,7 @@ from .models.user import User
from .password_resets import issue
from .proxy import client_address
from .responses import not_found, parse_uuid
from .serialize import serialize_user
bp = Blueprint("accounts", __name__, url_prefix="/api/accounts")
@@ -26,13 +27,7 @@ logger = logging.getLogger(__name__)
def _serialize_account(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
"created_at": iso(user.created_at),
}
return {**serialize_user(user), "created_at": iso(user.created_at)}
@bp.get("")
+15 -32
View File
@@ -19,6 +19,7 @@ from .models.device_token import DeviceToken
from .models.user import User
from .proxy import client_address
from .responses import json_error, not_found, parse_uuid, too_many
from .serialize import serialize_user
from .ratelimit import (
register_by_address,
reset_mail_by_account,
@@ -63,15 +64,6 @@ SETUP_CLOSED = (
DEVICE_NAME_CAP = 100
def _serialize_user(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
}
def _sign_in(user: User) -> None:
session[SESSION_KEY] = str(user.id)
session[EPOCH_KEY] = user.session_epoch
@@ -313,7 +305,7 @@ async def register():
)
detail = "first account, admin" if is_first else ("by invite" if invite_id else None)
await audit.record(audit.REGISTERED, user_id=user.id, email=email, detail=detail)
return jsonify(_serialize_user(user)), 201
return jsonify(serialize_user(user)), 201
async def _check_credentials(db, email: str, password: str, route: str) -> User | None:
@@ -362,7 +354,7 @@ async def login():
_sign_in(user)
logger.info("sign-in ok email=%s from=%s", email, client_address())
await audit.record(audit.SIGN_IN, user_id=user.id, email=email)
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
@bp.post("/logout")
@@ -379,7 +371,7 @@ async def me():
if user is None:
_sign_out()
return _unauthenticated()
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
@bp.get("/storage")
@@ -469,7 +461,7 @@ async def reset_password():
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
)
await audit.record(audit.PASSWORD_RESET, user_id=user.id, email=user.email, detail=_unlinked(unlinked))
return jsonify(_serialize_user(user))
return jsonify(serialize_user(user))
async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int:
@@ -619,7 +611,7 @@ async def device_login():
)
await db.commit()
await audit.record(audit.DEVICE_LINKED, user_id=user.id, email=email, detail=row.name)
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
return jsonify({"token": token, "device": _serialize_device(row), "user": serialize_user(user)}), 201
@bp.post("/devices")
@@ -667,21 +659,9 @@ async def revoke_own_device():
# using" is meaningless rather than merely unauthorized. The web app
# revokes by id.
return json_error("no device token was presented", 400)
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(
DeviceToken.token_hash == hash_token(token),
# Owner-scoped like every other device route. The hash already pins
# a single row; the guarantee shouldn't rest on one column.
DeviceToken.user_id == g.user_id,
)
)
if row is None:
return not_found()
await db.delete(row)
await db.commit()
await audit.record(audit.DEVICE_UNLINKED, user_id=g.user_id, email=await _email_of(db), detail=row.name)
return jsonify({"ok": True})
# Owner-scoped like every other device route. The hash already pins a single
# row; the guarantee shouldn't rest on one column.
return await _unlink_device(DeviceToken.token_hash == hash_token(token))
@bp.delete("/devices/<device_id>")
@@ -690,10 +670,13 @@ async def revoke_device(device_id: str):
did = parse_uuid(device_id)
if did is None:
return not_found()
return await _unlink_device(DeviceToken.id == did)
async def _unlink_device(which):
"""Delete the caller's device token matching `which`, and say so in the audit log."""
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(DeviceToken.id == did, DeviceToken.user_id == g.user_id)
)
row = await db.scalar(select(DeviceToken).where(which, DeviceToken.user_id == g.user_id))
if row is None:
return not_found()
await db.delete(row)
+3 -3
View File
@@ -12,7 +12,7 @@ editing a URL by hand, which is the friction `client_dist.py` exists to remove.
## Which way it fails
A server that can put arbitrary text into a script a person pipes to `sh` is the
whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value
whole attack. So the substitution is ONE variable, `INKWELL_SERVER_DEFAULT`, given a value
that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell
reads as anything but characters — and the script checks the shape again before it
uses it. If the address cannot pass, the route refuses rather than serving a script
@@ -45,7 +45,7 @@ SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" /
# The one line the server rewrites. The script carries it exactly like this, and
# `render` refuses a script that does not, rather than serving one with no address in.
PLACEHOLDER = 'TS_SERVER_DEFAULT=""'
PLACEHOLDER = 'INKWELL_SERVER_DEFAULT=""'
# What a server address may look like before it goes into a shell script: a scheme,
# a host name or IPv4 address, an optional port and an optional path. Deliberately
@@ -81,7 +81,7 @@ def render(script: str, address: str) -> str | None:
"""`script` with `address` as its default server, or None if it cannot be done safely."""
if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1:
return None
return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'")
return script.replace(PLACEHOLDER, f"INKWELL_SERVER_DEFAULT='{address}'")
@bp.get("/install.sh")
+1 -36
View File
@@ -4,10 +4,7 @@ The bulk of the shared logic lives in cohesive sibling modules — serialization
(`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle.
External callers import from `inkwell.notes` (see `__all__`); those names are
re-exported here so the package is a drop-in replacement for the old module."""
`bp` is defined in `_bp` so every module can import it without a cycle."""
from __future__ import annotations
import io
@@ -20,7 +17,6 @@ from quart import Response, g, jsonify, request, send_file
from sqlalchemy import func, literal_column, select
from ..auth import login_required
from ..colors import normalize_color
from ..common import coerce_bool, iso, parse_dt
from ..config import Config
from ..db import session_scope
@@ -41,15 +37,12 @@ from .body import write_body
from .helpers import (
ALLOWED_IMAGE_MIMES,
VALID_FILTERS,
_attachment_ext,
_get_editable,
_get_owned,
_get_visible,
_header_filename,
_safe_filename,
_slugify,
apply_filter,
derive_display_title,
is_empty_note,
note_visible,
store_attachment,
@@ -62,40 +55,12 @@ from .import_export import (
_ImportTooLarge,
_OverStorageLimit,
_create_imported_note,
_keep_spec,
_native_spec,
_note_markdown,
_read_import_specs,
_usec_to_dt,
)
from .tags import (
_lift_and_reconcile_tags,
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _labels_for_notes, _serialize_note, _serialize_notes
__all__ = [
"bp",
"derive_display_title",
"is_empty_note",
"parse_list_items",
"parse_tags",
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_lift_and_reconcile_tags",
"_serialize_notes",
"_safe_filename",
"_attachment_ext",
"_header_filename",
"_slugify",
"_keep_spec",
"_native_spec",
"_usec_to_dt",
]
@bp.get("")
@login_required
async def list_notes():
+1 -2
View File
@@ -1,7 +1,6 @@
"""Password resets: an admin makes a one-hour link for an account (#5173).
The app has no mail path, so a forgotten password used to need a hand on the database
(#2939 §2). This reuses what invites established: a random token, only its hash
Before this, a forgotten password needed a hand on the database (#2939 §2). This reuses what invites established: a random token, only its hash
kept, the link shown once to the admin who hands it over.
Using the link sets a new password and signs the account out everywhere. Its web
+8 -1
View File
@@ -2,7 +2,7 @@
shapes the API returns, so a field name or format is defined once. Each entity has a
BASE serializer with the fields common to every context; callers layer on the
extras their surface needs (usage count in the labels API, sync_revision/purged_at
in sync deltas). Grows as sections adopt it (labels here; note/user/device later)."""
in sync deltas). Labels and people so far."""
from __future__ import annotations
from .common import iso
@@ -32,3 +32,10 @@ def serialize_person(user: User) -> dict:
choosing them takes and all a fellow member needs to know. The share directory,
a share's recipient and a group's members all show people this way."""
return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
def serialize_user(user: User) -> dict:
"""An account as its owner sees it: the person plus whether they are an admin.
Sign-in, /me and device login answer with this; the admin's account list adds
when each was made."""
return {**serialize_person(user), "is_admin": user.is_admin}
+6 -9
View File
@@ -18,26 +18,23 @@ import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
from sqlalchemy import delete as sa_delete
from sqlalchemy import func, select
from sqlalchemy import select
from .auth import login_required
from .colors import normalize_color
from .common import iso, parse_dt
from .db import session_scope
from .labeling import live, named, reconcile_manual_labels, resolve_owned_label_ids, tombstone_label
from .models.label import Label, NoteLabel
from .labeling import named, reconcile_manual_labels, resolve_owned_label_ids, tombstone_label
from .models.label import Label
from .models.note import Note
from .models.note_attachment import NoteAttachment
from .models.note_link_preview import NoteLinkPreview
from .models.share_revocation import ShareRevocation
from .note_state import OWN_FIELDS, join_state, revision_for, set_own_state
from .notes import (
_serialize_notes,
normalize_color,
normalize_recurrence,
)
from .notes.body import write_body
from .notes.helpers import _get_editable, _get_visible, note_visible, store_attachment
from .notes.recurrence import normalize_recurrence
from .notes.serialize import _serialize_notes
from .responses import json_error, not_found, parse_uuid
from .retention import purge_note
from .serialize import serialize_label_sync
+6 -6
View File
@@ -7,7 +7,7 @@ from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address
# DB-free, like test_client_dist: the route reads `public_url` from the live cache
# rather than from the database, which is what makes it testable here.
SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n"
SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}}\"\n"
@pytest.fixture(autouse=True)
@@ -31,10 +31,10 @@ def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once(
def test_render_writes_the_address_into_the_one_variable():
body = render(SCRIPT, "https://notes.example.com")
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body
assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in body
assert PLACEHOLDER not in body
# Nothing else moved.
assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT
assert body.replace("INKWELL_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT
@pytest.mark.parametrize(
@@ -97,14 +97,14 @@ async def test_the_route_serves_the_script_pointed_at_this_server(app):
assert resp.status_code == 200
assert resp.mimetype == "text/plain"
body = await resp.get_data(as_text=True)
assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body
assert "INKWELL_SERVER_DEFAULT='http://notes.example.com'" in body
assert body.startswith("#!/bin/sh")
async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch):
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com")
resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"})
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True)
assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True)
async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch):
@@ -113,7 +113,7 @@ async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkey
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'")
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
assert resp.status_code == 500
assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True)
assert "INKWELL_SERVER_DEFAULT" not in await resp.get_data(as_text=True)
async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path):
+13 -28
View File
@@ -11,27 +11,27 @@ from inkwell.colors import NOTE_COLORS
from inkwell.models.note import Note
from inkwell.notes.checklist import append_item, parse_items, set_item_checked, strip_marker
from inkwell.unfurl_queue import detect_urls
from inkwell.notes import (
_ImportBudget,
from inkwell.colors import normalize_color
from inkwell.notes.helpers import (
_attachment_ext,
_header_filename,
_safe_filename,
_slugify,
derive_display_title,
is_empty_note,
parse_list_items,
)
from inkwell.notes.import_export import (
NATIVE_APP_MARKERS,
_ImportBudget,
_keep_spec,
_native_spec,
_note_markdown,
_read_import_specs,
_safe_filename,
_slugify,
_usec_to_dt,
derive_display_title,
is_empty_note,
next_occurrence,
normalize_color,
normalize_recurrence,
parse_list_items,
parse_tags,
)
from inkwell.notes.import_export import NATIVE_APP_MARKERS
from inkwell.notes.tags import split_body_tags
from inkwell.notes.recurrence import next_occurrence, normalize_recurrence
from inkwell.notes.tags import parse_tags, split_body_tags
@pytest.fixture
@@ -548,21 +548,6 @@ def test_parse_items_between_paragraphs():
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
@pytest.mark.parametrize(
"body",
[
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
],
)
def test_parse_items_rejects_near_misses(body):
assert parse_items(body) == []
def test_parse_items_accepts_star_bullets_and_indentation():
# `*` because markdown.ts already takes it for a plain bullet.
body = "* [ ] star\n - [x] indented"