Compare commits
19
Commits
027ad6f672
...
0ecdbfe257
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+2
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -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 = ''"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-11
@@ -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,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[]>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user