Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2854a029 | ||
|
|
62338bb0a4 | ||
|
|
1a41373347 | ||
|
|
729d0dadf1 | ||
|
|
23a61365da | ||
|
|
6c0153be1e | ||
|
|
10ea15bef0 | ||
|
|
42e06da576 | ||
|
|
c8318c323a | ||
|
|
cc50812a86 | ||
|
|
1e54b80f15 | ||
|
|
550a34d8e2 | ||
|
|
193dfb9e94 | ||
|
|
8c7553d619 | ||
|
|
d838b27518 | ||
|
|
a69159e562 | ||
|
|
c40916699b | ||
|
|
ef418a8c92 | ||
|
|
fd1e4ae487 | ||
|
|
8a75e5f340 | ||
|
|
d2f9d316cf | ||
|
|
ff6e99eb62 | ||
|
|
ef8aa9340f | ||
|
|
f992439588 | ||
|
|
544cf72735 |
+24
-36
@@ -323,53 +323,41 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||
# app for their machine from the server holding their notes — without an
|
||||
# account on this forge, which is private (issue 2091) and is why serving them
|
||||
# from a release page was never an option for anybody but the operator.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||
# the price of the product being complete (rule 23), and the AppImage is not
|
||||
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||
# so a server without one cannot serve in-app updates to anyone.
|
||||
#
|
||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
# NEVER fails the build — see the script. A platform with nothing published
|
||||
# means the server advertises nothing for it and the UI hides that download,
|
||||
# which is a supported state and the only one available before that platform's
|
||||
# first build has ever published.
|
||||
- name: Fetch the clients to bake in
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
mkdir -p client
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
|
||||
# client; :latest serves the stable one. This read `download/dev`
|
||||
# unconditionally until M314 step 3, on every branch — so every stable
|
||||
# server shipped a dev-channel APK to anyone who downloaded the client
|
||||
# from it. Not a versioning gap; a plain defect, fixed here because this
|
||||
# is the step that gave `stable` an APK to point at.
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||
# until M314 step 3, on every branch — so every stable server shipped a
|
||||
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||
# rather than inside the script: the caller is what knows which image it is
|
||||
# building.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
echo "Baking in the $channel client."
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
sh packaging/fetch-clients.sh "$channel" client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -162,6 +162,14 @@ jobs:
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export THOUGHTSYNC_DISPLAY_VERSION
|
||||
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
|
||||
cargo tauri build \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -347,6 +355,14 @@ jobs:
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export THOUGHTSYNC_DISPLAY_VERSION
|
||||
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
|
||||
updater='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -447,6 +463,12 @@ jobs:
|
||||
# match the binary it points at is an updater that never settles. It must
|
||||
# be `key`: this value is matched against bundle filenames.
|
||||
version="$(sh packaging/version.sh key desktop)"
|
||||
# The version a PERSON reads, published beside the manifest as
|
||||
# `thoughtsync-desktop.json`. The image build reads it to describe the
|
||||
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||
# anything from its own checkout — which would be a different commit
|
||||
# whenever the desktop did not rebuild.
|
||||
display="$(sh packaging/version.sh display desktop)"
|
||||
# Both channels are rolling: the manifest lands on the same release that
|
||||
# holds the bundles, and the previous build's bundles are dropped once it
|
||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||
@@ -460,4 +482,5 @@ jobs:
|
||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||
esac
|
||||
export PRUNE_OLD_ASSETS=true
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||
bash desktop/packaging/write-manifest.sh
|
||||
|
||||
Generated
+67
@@ -1286,6 +1286,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1405,6 +1415,24 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "global-hotkey"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -3947,6 +3975,21 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
|
||||
dependencies = [
|
||||
"global-hotkey",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.9.0"
|
||||
@@ -4196,6 +4239,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-updater",
|
||||
"thoughtsync-core",
|
||||
@@ -5577,6 +5621,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -5587,6 +5648,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
+13
-7
@@ -24,17 +24,23 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||
# fetches the newest published build of each into ./client immediately before this
|
||||
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||
# `docker compose pull` delivers new ones with no file copying by hand.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# ~104 MB of this image is that set, almost all of it the AppImage.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the releases are private, and a
|
||||
# token used inside a build ends up in the build context or a layer.
|
||||
#
|
||||
# LAST of the COPYs, deliberately: this directory changes on every build, so
|
||||
# putting it above the `pip install` layer would invalidate that layer every time.
|
||||
#
|
||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||
# that step never ran. An image with no APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
# that step never ran. An image with no clients — or with some and not others — is
|
||||
# a supported state: the server advertises what it has and the web UI hides the
|
||||
# rest (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
@@ -99,15 +99,53 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ThoughtSync"
|
||||
android:usesCleartextTraffic="true">
|
||||
<!--
|
||||
launchMode="singleTop" exists for the SHARE filters below.
|
||||
|
||||
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||
intent, so onNewIntent already worked for that one. A share intent is
|
||||
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
||||
and nothing here can add a flag to it. Without singleTop declared on the
|
||||
activity itself, every share while the app is running would stack a
|
||||
second MainActivity on top of the first: a second view model, a second
|
||||
board, and a back press that lands on a stale copy of the same app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.ThoughtSync">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
Capture without opening the app first: Share → ThoughtSync from
|
||||
anywhere, and the selection toolbar in any text field.
|
||||
|
||||
text/plain ONLY, and image/* deliberately absent. Nothing in this
|
||||
app can create an attachment — the core has `delete_attachment` and
|
||||
no counterpart, and the FFI exposes neither. Claiming images in the
|
||||
share sheet would put this app in front of people for a job it
|
||||
cannot do and fail after they had chosen it.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
The label is what appears in the text-selection menu beside Copy and
|
||||
Share, where "ThoughtSync" would say who rather than what.
|
||||
-->
|
||||
<intent-filter android:label="@string/capture_process_text">
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The `versionName` of the INSTALLED package, or null when it cannot be read.
|
||||
*
|
||||
* From the package manager rather than from `BuildConfig`: this reports what is
|
||||
* actually on the phone, which is the question both callers are asking — a bug
|
||||
* report reading the foot of Sync, and a server log reading the client header. It
|
||||
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
|
||||
*
|
||||
* Returns null rather than a fallback string, because the two callers want
|
||||
* different ones: the UI wants a localized "unknown" from string resources, the
|
||||
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
|
||||
* with no version tags, the artifact's self-report is the only answer to "which
|
||||
* build is this?", so a missing name must read as missing and never as a plausible
|
||||
* default that nothing can contradict.
|
||||
*/
|
||||
fun Context.installedVersionName(): String? =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||
@@ -33,6 +33,8 @@ import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.TagsScreen
|
||||
import com.fabledsword.thoughtsync.ui.TagsViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||
import com.fabledsword.thoughtsync.ui.olderThan
|
||||
@@ -51,12 +53,24 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
private val requestedNote = mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Text shared into the app from elsewhere, waiting to become a note.
|
||||
*
|
||||
* Same shape and same reason as [requestedNote]: a share that arrives while
|
||||
* the app is already running lands in [onNewIntent], long after the
|
||||
* composition was built, so a piece of state it is already reading is the only
|
||||
* way in. The activity is `singleTop` in the manifest precisely so that this
|
||||
* path exists for an intent another app built.
|
||||
*/
|
||||
private val sharedText = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val app = application as ThoughtSyncApplication
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
sharedText.value = takeSharedText(intent)
|
||||
|
||||
setContent {
|
||||
ThoughtSyncTheme {
|
||||
@@ -67,7 +81,7 @@ class MainActivity : ComponentActivity() {
|
||||
// than render an empty board that looks like data loss.
|
||||
StoreUnavailableScreen(reason = app.openFailure)
|
||||
} else {
|
||||
App(core, requestedNote)
|
||||
App(core, requestedNote, sharedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
sharedText.value = takeSharedText(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +107,58 @@ class MainActivity : ComponentActivity() {
|
||||
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the text a share or a text selection brought in, and CONSUME it.
|
||||
*
|
||||
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
|
||||
* intent it was launched with, so without removing the extras a rotation would
|
||||
* replay the share and mint the same note again, with nothing on screen to
|
||||
* explain where the duplicates were coming from.
|
||||
*/
|
||||
private fun takeSharedText(intent: Intent?): String? {
|
||||
val shared =
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> intent.takeSendText()
|
||||
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
|
||||
else -> null
|
||||
}
|
||||
return shared?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared text, with a subject line above it when the sender gave one.
|
||||
*
|
||||
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
|
||||
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
|
||||
* the core names a note by its first line — so this is not decoration, it is what
|
||||
* turns a board of identical-looking links into a board you can scan.
|
||||
*
|
||||
* `distinct` because plenty of apps put the same string in both, and a note that
|
||||
* says the URL twice is worse than one that says it once.
|
||||
*/
|
||||
private fun Intent.takeSendText(): String? {
|
||||
val body = getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
removeExtra(Intent.EXTRA_TEXT)
|
||||
removeExtra(Intent.EXTRA_SUBJECT)
|
||||
return listOfNotNull(subject, body)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
/** The selection from another app's text field, via the selection toolbar. */
|
||||
private fun Intent.takeProcessText(): String? {
|
||||
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||
removeExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||
return text
|
||||
}
|
||||
|
||||
/** Which screen is up. Exactly one at a time. */
|
||||
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
@@ -104,7 +167,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
* both cover the display completely, so keeping the board's two-column grid
|
||||
* measuring and recomposing underneath one would be pure waste.
|
||||
*
|
||||
* Still no navigation library. Three destinations, each entered from exactly one
|
||||
* Still no navigation library. Four destinations, each entered from exactly one
|
||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||
* state that actually matters (which note is open, whether this device is linked)
|
||||
* already lives in view models.
|
||||
@@ -113,6 +176,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private fun App(
|
||||
core: ThoughtSync,
|
||||
requestedNote: MutableState<String?>,
|
||||
sharedText: MutableState<String?>,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val board: BoardViewModel =
|
||||
@@ -135,6 +199,15 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// Cleared the same way and for the same reason: without it every later
|
||||
// recomposition would capture the shared text again as a new note.
|
||||
LaunchedEffect(sharedText.value) {
|
||||
sharedText.value?.let {
|
||||
board.captureShared(it)
|
||||
sharedText.value = null
|
||||
}
|
||||
}
|
||||
|
||||
ReminderAlarms(core)
|
||||
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||
@@ -149,6 +222,13 @@ private fun App(
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Tag writes reach the board two ways at once: the drawer lists tags, and the
|
||||
// board may be LOOKING at one that a delete or a merge just removed. Both are
|
||||
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
|
||||
val tags: TagsViewModel =
|
||||
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
val settings = remember(context) { SyncSettings(context) }
|
||||
@@ -161,6 +241,9 @@ private fun App(
|
||||
val screen =
|
||||
when {
|
||||
showingSync -> Screen.SYNC
|
||||
// Above the editor: tags are reached only from the board's drawer, so
|
||||
// there is never an open note underneath one to go back to.
|
||||
showingTags -> Screen.TAGS
|
||||
editing != null -> Screen.EDITOR
|
||||
else -> Screen.BOARD
|
||||
}
|
||||
@@ -188,6 +271,18 @@ private fun App(
|
||||
onInstallOutcome = update::consumeInstallOutcome,
|
||||
)
|
||||
|
||||
Screen.TAGS ->
|
||||
TagsScreen(
|
||||
state = tags.state,
|
||||
onClose = { showingTags = false },
|
||||
onCreate = tags::create,
|
||||
onRename = tags::rename,
|
||||
onColour = tags::setColour,
|
||||
onDelete = tags::remove,
|
||||
onMerge = tags::merge,
|
||||
onDismissError = tags::dismissError,
|
||||
)
|
||||
|
||||
Screen.EDITOR ->
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
@@ -218,6 +313,7 @@ private fun App(
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onManageTags = { showingTags = true },
|
||||
onSearch = board::search,
|
||||
onCompose = board::compose,
|
||||
onToggleItem = board::toggleItem,
|
||||
@@ -247,6 +343,7 @@ private fun App(
|
||||
// The sync screen has no back handler of its own, so one lives here. The
|
||||
// editor keeps its own, because it has to save the open note before leaving.
|
||||
BackHandler(enabled = showingSync) { showingSync = false }
|
||||
BackHandler(enabled = showingTags) { showingTags = false }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.core.setClientAgent
|
||||
|
||||
/**
|
||||
* Opens the shared Rust core once, for the process lifetime.
|
||||
@@ -30,6 +31,14 @@ class ThoughtSyncApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Introduce this app to any server it links to, before anything can sync.
|
||||
// The core cannot name us — the same crate is compiled into the desktop app,
|
||||
// and it used to announce every phone as `thoughtsync-desktop` carrying the
|
||||
// core crate's own version. "unknown" rather than a guess when the package
|
||||
// manager will not say (note 3127 §5).
|
||||
setClientAgent("thoughtsync-android", installedVersionName() ?: "unknown")
|
||||
|
||||
try {
|
||||
val handle = ThoughtSync(filesDir.absolutePath)
|
||||
core = handle
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -74,6 +75,7 @@ fun BoardScreen(
|
||||
onOpenNote: (Note) -> Unit,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
@@ -138,6 +140,10 @@ fun BoardScreen(
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onManageTags = {
|
||||
onManageTags()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -367,6 +373,7 @@ private fun NavigationDrawer(
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
@@ -380,18 +387,36 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
// The header renders even with no tags, unlike the rows below it: the
|
||||
// manage screen is where you go to MAKE the first one, and hiding the
|
||||
// way in until one exists would be a door that appears only once you
|
||||
// are already inside. It is an action ON the section rather than a row
|
||||
// in it, so it cannot be mistaken for one more lens.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
IconButton(onClick = onManageTags) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.tags_manage),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
|
||||
@@ -123,7 +123,7 @@ class BoardViewModel(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
@@ -162,13 +162,34 @@ class BoardViewModel(
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
/**
|
||||
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
|
||||
*
|
||||
* Public because the Tags screen owns operations this board cannot see: a
|
||||
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
|
||||
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
|
||||
* nothing forever. Without the fallback, tidying up tags could strand the board
|
||||
* on a permanently empty lens whose only escape is the drawer.
|
||||
*
|
||||
* A rename needs no fallback: the id survives, and re-listing gives the drawer
|
||||
* the new name. A rename that MERGED is a delete of one of the two, which this
|
||||
* catches by id like any other.
|
||||
*/
|
||||
fun refreshLabels() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.onSuccess { state = state.copy(labels = it) }
|
||||
.onSuccess { labels ->
|
||||
state = state.copy(labels = labels)
|
||||
val lens = state.destination
|
||||
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||
open(Destination.Notes)
|
||||
}
|
||||
}
|
||||
// A drawer that cannot list labels is a degraded drawer, not a
|
||||
// broken board — the notes are still there. Failing quietly here
|
||||
// beats an error banner over working content.
|
||||
// beats an error banner over working content. The lens is left
|
||||
// alone in this case on purpose: "I could not read the tags" is not
|
||||
// evidence that this one is gone.
|
||||
.onFailure { state = state.copy(labels = emptyList()) }
|
||||
}
|
||||
}
|
||||
@@ -235,6 +256,32 @@ class BoardViewModel(
|
||||
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture text shared into the app from somewhere else, and open it.
|
||||
*
|
||||
* The note is CREATED here rather than opened as a pre-filled draft, and that
|
||||
* is the whole design of this path. The editor only flushes when its text
|
||||
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
|
||||
* draft arriving already full of the shared text is a draft with nothing to
|
||||
* save — share a link, press back without typing, and it would be gone. A
|
||||
* share has already said "keep this"; making the row first is what honours it.
|
||||
*
|
||||
* Opening the editor afterwards is then free of that risk: the note exists,
|
||||
* back leaves it alone, and adding a line of context is optional rather than
|
||||
* load-bearing.
|
||||
*/
|
||||
fun captureShared(text: String) {
|
||||
val content = text.trim()
|
||||
if (content.isEmpty()) return
|
||||
// A share is a new sitting even if the editor was already open on
|
||||
// something, so the field must be re-keyed onto what arrives. `createFrom
|
||||
// Draft` deliberately does not bump this — it is written for the autosave
|
||||
// case, where re-keying mid-typing would be the bug.
|
||||
draftDismissed = false
|
||||
state = state.copy(editingSession = state.editingSession + 1)
|
||||
createFromDraft(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||
@@ -387,7 +434,7 @@ class BoardViewModel(
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
@@ -425,9 +472,13 @@ class BoardViewModel(
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
* While a search is running the QUERY is re-run rather than the board's
|
||||
* destination — running `load` here would replace the hits with the whole
|
||||
* board, which is why this branch exists at all. It used to keep the existing
|
||||
* list instead, and that was right for a note whose place in the pile changed
|
||||
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||
* with a snackbar saying it was gone, until the query happened to re-run
|
||||
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
@@ -439,10 +490,8 @@ class BoardViewModel(
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
withContext(Dispatchers.IO) {
|
||||
if (state.searching) core.searchNotes(state.query) else load(state.destination)
|
||||
}
|
||||
// On IO, not here: re-deriving the alarm reads every note
|
||||
// that carries a reminder, and this line runs on the main
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.core.LinkPreview
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
private val PREVIEW_RADIUS = 8.dp
|
||||
|
||||
/**
|
||||
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||
*
|
||||
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
|
||||
* agree on what counts as "this note is a link", or the same note reads as a card
|
||||
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
|
||||
* which is why the surrounding whitespace is tolerated rather than rejected.
|
||||
*/
|
||||
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
|
||||
|
||||
/**
|
||||
* The preview for a note that is nothing but a URL, or null.
|
||||
*
|
||||
* Null covers three different situations that all render the same way — the body
|
||||
* is not a lone URL, the server has not unfurled it yet, or it never could. The
|
||||
* card falls back to showing the URL as text in every one of them, so it is never
|
||||
* blank and the link is never unreachable.
|
||||
*
|
||||
* A note written on the phone and not yet synced is permanently in the middle
|
||||
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
|
||||
* pull. That is the honest behaviour and it has to look deliberate, which showing
|
||||
* the URL does.
|
||||
*/
|
||||
fun loneUrlPreview(note: Note): LinkPreview? {
|
||||
if (!LONE_URL.matches(note.body)) return null
|
||||
val url = note.body.trim()
|
||||
return note.previews.firstOrNull { it.url == url }
|
||||
}
|
||||
|
||||
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
|
||||
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
|
||||
|
||||
/**
|
||||
* A fetched link preview, in one of two sizes.
|
||||
*
|
||||
* [compact] is a single row — one line of title and the site — for a URL mentioned
|
||||
* *inside* a note that has its own words. The note is the thing; the link is a
|
||||
* footnote to it. Full size is for a note that IS a URL, where the link is the
|
||||
* note and a compact strip would be a card with nothing on it.
|
||||
*
|
||||
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
|
||||
* third-party address, so drawing it would have this app fetch from whatever host
|
||||
* a link happens to point at — on a phone, on possibly metered data, and as the
|
||||
* first image loading anywhere in this client. That is a decision about privacy
|
||||
* and data use rather than a rendering detail, so the text card ships and the
|
||||
* image is left to be asked for (Scribe #3307).
|
||||
*/
|
||||
@Composable
|
||||
fun LinkPreviewCard(
|
||||
preview: LinkPreview,
|
||||
compact: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||
// instead (Scribe #3110).
|
||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||
val padH = if (compact) 8.dp else 10.dp
|
||||
val padV = if (compact) 6.dp else 8.dp
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = padH, vertical = padV),
|
||||
) {
|
||||
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
|
||||
Text(
|
||||
text = site.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
// The URL stands in for a missing title so the row always says SOMETHING
|
||||
// about where it goes.
|
||||
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
|
||||
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// First thing to go when there is no room — the compact row is a footnote and
|
||||
// a description would make it the loudest part of the card.
|
||||
if (!compact) {
|
||||
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,16 +123,37 @@ fun NoteCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// A note that is NOTHING but a URL renders as its preview and nothing
|
||||
// else — printing the raw address under a card that already says where it
|
||||
// goes is saying the same thing twice, badly. Until the unfurl lands, or
|
||||
// if it never does, `preview` is null and the body falls through to
|
||||
// NoteBody, which shows the URL. Never a blank card.
|
||||
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||
// first line of the body IS the note's name, at the same weight as the rest of
|
||||
// it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
if (lonePreview != null) {
|
||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||
// under the note's own words rather than stacked on top of them. Putting
|
||||
// them above would set a stranger's headline where the note's first line
|
||||
// should be — the web learned that in M13 and moved them down.
|
||||
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
note.previews.forEach { preview ->
|
||||
LinkPreviewCard(preview = preview, compact = true)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
if (note.body.isBlank() && note.previews.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -31,12 +31,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.thoughtsync.installedVersionName
|
||||
|
||||
/**
|
||||
* Opt-in server pairing.
|
||||
@@ -120,10 +122,38 @@ fun SyncScreen(
|
||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||
)
|
||||
}
|
||||
|
||||
BuildLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||
* bottom of its rail (#3181).
|
||||
*
|
||||
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
||||
* self-report is the only answer to "which build is this?" — so it renders
|
||||
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
||||
* like a layout bug and a plausible default cannot be caught by anything.
|
||||
*
|
||||
* The read itself is `installedVersionName()`, shared with the client header the
|
||||
* app sends its server: one answer to "which build is on this phone", so the line
|
||||
* a person quotes in a bug report and the line in the server's log cannot disagree.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuildLine() {
|
||||
val context = LocalContext.current
|
||||
val unknown = stringResource(R.string.build_unknown)
|
||||
val version = remember(context) { context.installedVersionName() ?: unknown }
|
||||
Text(
|
||||
text = version,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── linked ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
|
||||
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||
private val SWATCH = 22.dp
|
||||
|
||||
/** What the row's overflow menu is currently asking about. */
|
||||
private sealed interface TagDialog {
|
||||
data class Rename(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||
data class ConfirmMerge(
|
||||
val tag: Label,
|
||||
val into: Label,
|
||||
val name: String,
|
||||
) : TagDialog
|
||||
|
||||
data class Merge(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Delete(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Colour(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag management: list, create, rename, recolour, delete, merge.
|
||||
*
|
||||
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||
* because a desktop has room to float one over the board; on a phone this is a
|
||||
* place you visit to tidy up, and a full screen is what that is.
|
||||
*
|
||||
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||
* control in the product. That is why the swatch is a first-class tap target on
|
||||
* every row rather than something behind the overflow menu.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagsScreen(
|
||||
state: TagsState,
|
||||
onClose: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onColour: (String, String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onMerge: (String, String) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tags_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.tags_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||
// local SQLite call and usually finishes before this is seen at all.
|
||||
if (state.busy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// The same banner the board and the editor use. A third way of saying
|
||||
// "that did not work" would be a third thing to keep consistent.
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
|
||||
NewTagField(
|
||||
enabled = !state.busy,
|
||||
onCreate = onCreate,
|
||||
)
|
||||
|
||||
if (!state.loading && state.tags.isEmpty()) {
|
||||
EmptyTags()
|
||||
}
|
||||
|
||||
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||
// whole height would measure the list against space the field and the
|
||||
// banner have already taken — pushing the end of the list off-screen.
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(state.tags, key = { it.id }) { tag ->
|
||||
TagRow(
|
||||
tag = tag,
|
||||
enabled = !state.busy,
|
||||
onColour = { dialog = TagDialog.Colour(tag) },
|
||||
onRename = { dialog = TagDialog.Rename(tag) },
|
||||
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val open = dialog) {
|
||||
null -> Unit
|
||||
|
||||
is TagDialog.Rename ->
|
||||
RenameDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags,
|
||||
onDismiss = { dialog = null },
|
||||
onRename = { name ->
|
||||
dialog = null
|
||||
onRename(open.tag.id, name)
|
||||
},
|
||||
// Renaming onto a name another tag holds MERGES the two, and that
|
||||
// cannot be undone by repeating it, so the confirmation replaces
|
||||
// this dialog rather than the rename just happening.
|
||||
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||
)
|
||||
|
||||
is TagDialog.ConfirmMerge ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onRename(open.tag.id, open.name)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Merge ->
|
||||
MergeDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags.filter { it.id != open.tag.id },
|
||||
onDismiss = { dialog = null },
|
||||
onMerge = { target ->
|
||||
dialog = null
|
||||
onMerge(open.tag.id, target.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Delete ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||
// The count is the part that makes the consequence real — "it is on
|
||||
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||
// from the LIST, the only call the core populates a count on.
|
||||
body =
|
||||
open.tag.count
|
||||
?.takeIf { it > 0 }
|
||||
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||
?: stringResource(R.string.tags_delete_body),
|
||||
footnote = stringResource(R.string.tags_delete_from_text),
|
||||
confirm = stringResource(R.string.tags_delete_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onDelete(open.tag.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Colour ->
|
||||
ColourDialog(
|
||||
tag = open.tag,
|
||||
onDismiss = { dialog = null },
|
||||
onPick = { key ->
|
||||
dialog = null
|
||||
onColour(open.tag.id, key)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `#` on the name, everywhere it is spoken about.
|
||||
*
|
||||
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||
* be talking about something else.
|
||||
*/
|
||||
private fun hash(name: String): String = "#$name"
|
||||
|
||||
@Composable
|
||||
private fun NewTagField(
|
||||
enabled: Boolean,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
if (text.isNotBlank()) {
|
||||
onCreate(text)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.tags_new_hint,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||
Text(stringResource(R.string.tags_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||
* #2949) and this is the natural place to say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyTags() {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagRow(
|
||||
tag: Label,
|
||||
enabled: Boolean,
|
||||
onColour: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onMerge: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = labelTintFor(tag.name, tag.color)
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.clickable(enabled = enabled, onClick = onColour),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = hash(tag.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
)
|
||||
Text(
|
||||
text = countLabel(tag.count),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.tags_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||
// dialog cannot open underneath a menu still hanging over it.
|
||||
val close = { menuOpen = false }
|
||||
MenuItem(R.string.tags_rename, close, onRename)
|
||||
MenuItem(R.string.tags_merge, close, onMerge)
|
||||
MenuItem(R.string.tags_delete, close, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero is its own sentence, not "0 notes".
|
||||
*
|
||||
* A count of null means the core did not populate one — only `list_labels` does —
|
||||
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||
* rather than as empty.
|
||||
*/
|
||||
@Composable
|
||||
private fun countLabel(count: Long?): String =
|
||||
when {
|
||||
count == null -> ""
|
||||
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||
count == 1L -> stringResource(R.string.tags_count_one)
|
||||
else -> stringResource(R.string.tags_count, count.toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename, with the merge caught before it happens.
|
||||
*
|
||||
* The collision is detected HERE, against the list, rather than from what the
|
||||
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||
* Matching is case-insensitive because the core's is.
|
||||
*/
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onWouldMerge: (Label, String) -> Unit,
|
||||
) {
|
||||
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||
val trimmed = text.trim()
|
||||
val clash =
|
||||
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||
val submit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
clash != null -> onWouldMerge(clash, trimmed)
|
||||
else -> onRename(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||
text = {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
hint = R.string.tags_new_hint,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||
Text(stringResource(R.string.tags_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge, with the direction stated and the survivor named.
|
||||
*
|
||||
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||
* depend on which way round it was typed — this one is deliberate, so the
|
||||
* direction the person chooses IS the intent and is honoured. The price of that
|
||||
* is that the direction has to be unmissable, which is why the body names the tag
|
||||
* that stops existing and every row here is the one that survives.
|
||||
*/
|
||||
@Composable
|
||||
private fun MergeDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onMerge: (Label) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (others.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
others.forEach { other ->
|
||||
val tint = labelTintFor(other.name, other.color)
|
||||
Text(
|
||||
text = hash(other.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMerge(other) }
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette, and since the per-note picker was removed this is the only place in
|
||||
* the product a colour is chosen.
|
||||
*
|
||||
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||
* back to that.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColourDialog(
|
||||
tag: Label,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
NOTE_TINTS.forEach { (key, tint) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(key) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = tint.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (key == tag.color) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirm: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Everything the Tags screen renders from.
|
||||
*
|
||||
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||
*/
|
||||
data class TagsState(
|
||||
val loading: Boolean = true,
|
||||
val tags: List<Label> = emptyList(),
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create, rename, recolour, delete and merge tags.
|
||||
*
|
||||
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||
* operations over the same core the desktop uses.
|
||||
*
|
||||
* ## Why every write re-lists
|
||||
*
|
||||
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||
*
|
||||
* ## Renaming can merge
|
||||
*
|
||||
* `rename_label` folds two tags together when the new name is one another tag
|
||||
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||
* tag whose id is not the one passed in, and it can make another tag stop
|
||||
* existing. The screen asks first; this view model does not, because a
|
||||
* confirmation belongs to the surface with a person in front of it.
|
||||
*
|
||||
* ## Threading
|
||||
*
|
||||
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||
* the core marks the rows dirty and the next sync carries them.
|
||||
*/
|
||||
class TagsViewModel(
|
||||
private val core: ThoughtSync,
|
||||
/**
|
||||
* Called after any write that landed.
|
||||
*
|
||||
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||
* current destination may BE one of these tags — deleting or merging that one
|
||||
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||
* explicitly is less magic than a shared event bus and makes the dependency
|
||||
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||
*/
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(TagsState())
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.fold(
|
||||
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||
// the only thing on the display, and an empty list here
|
||||
// would read as "you have no tags" rather than "I couldn't
|
||||
// look".
|
||||
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
// Find-or-create in the core: typing a name that exists in another case
|
||||
// attaches the existing tag rather than minting a near-duplicate.
|
||||
write { it.createLabel(trimmed) }
|
||||
}
|
||||
|
||||
fun rename(
|
||||
id: String,
|
||||
name: String,
|
||||
) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
write { it.renameLabel(id, trimmed) }
|
||||
}
|
||||
|
||||
fun setColour(
|
||||
id: String,
|
||||
colour: String,
|
||||
) = write { it.setLabelColor(id, colour) }
|
||||
|
||||
fun remove(id: String) = write { it.removeLabel(id) }
|
||||
|
||||
/**
|
||||
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||
*
|
||||
* Directional and not undone by repeating it — the caller has to have said
|
||||
* which one survives before this runs, because afterwards there is nothing
|
||||
* left to read the direction from.
|
||||
*/
|
||||
fun merge(
|
||||
sourceId: String,
|
||||
targetId: String,
|
||||
) {
|
||||
if (sourceId == targetId) return
|
||||
write { it.mergeLabels(sourceId, targetId) }
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one store write, then re-list and tell the board.
|
||||
*
|
||||
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||
* out of here can leave the screen stuck with its controls disabled.
|
||||
*/
|
||||
private fun write(block: (ThoughtSync) -> Unit) {
|
||||
if (state.busy) return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
val failure =
|
||||
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||
.exceptionOrNull()
|
||||
val tags =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.getOrDefault(state.tags)
|
||||
state =
|
||||
state.copy(
|
||||
tags = tags,
|
||||
busy = false,
|
||||
error = failure?.describeTagFailure(),
|
||||
)
|
||||
// Even a FAILED write can have changed the store — a merge that threw
|
||||
// partway still moved rows — so the board is told either way.
|
||||
onStoreChanged()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core reports problems as one error type carrying a message meant to be
|
||||
* shown, so the message is used when there is one.
|
||||
*/
|
||||
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||
@@ -6,7 +6,7 @@
|
||||
<string name="search_hint">Search your notes</string>
|
||||
<string name="search_clear">Clear search</string>
|
||||
<string name="nav_open">Open navigation</string>
|
||||
<string name="nav_labels">Labels</string>
|
||||
<string name="nav_labels">Tags</string>
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
@@ -42,7 +42,7 @@
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_remove_label">Remove tag</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_saving">Saving…</string>
|
||||
@@ -52,7 +52,7 @@
|
||||
<string name="editor_done">Done</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
<string name="editor_labels">Tags…</string>
|
||||
<string name="editor_archive">Archive</string>
|
||||
<string name="editor_unarchive">Unarchive</string>
|
||||
<string name="editor_trash">Move to trash</string>
|
||||
@@ -66,11 +66,61 @@
|
||||
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||
<string name="editor_delete_forever_confirm">Delete</string>
|
||||
|
||||
<!-- Quick capture from outside the app: the share sheet and the text-selection
|
||||
toolbar. "New note" says what happens; the activity's own label would say
|
||||
who it happens in. -->
|
||||
<string name="capture_process_text">New note</string>
|
||||
|
||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||
schema still says Label, and no string here needs to know that. -->
|
||||
<string name="tags_manage">Manage tags</string>
|
||||
<string name="tags_title">Tags</string>
|
||||
<string name="tags_back">Back</string>
|
||||
<string name="tags_new_hint">New tag</string>
|
||||
<string name="tags_create">Create</string>
|
||||
<string name="tags_count">%1$d notes</string>
|
||||
<string name="tags_count_one">1 note</string>
|
||||
<string name="tags_count_none">No notes yet</string>
|
||||
<string name="tags_empty_title">No tags yet</string>
|
||||
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="tags_actions">More actions</string>
|
||||
<string name="tags_colour">Colour</string>
|
||||
<string name="tags_colour_of">Colour for %1$s</string>
|
||||
|
||||
<string name="tags_rename">Rename</string>
|
||||
<string name="tags_rename_title">Rename %1$s</string>
|
||||
<string name="tags_rename_confirm">Rename</string>
|
||||
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||
<string name="tags_rename_merges_confirm">Merge</string>
|
||||
|
||||
<string name="tags_merge">Merge into…</string>
|
||||
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||
<!-- The survivor is named in the button, not just the title: this is the one
|
||||
operation here that repeating does not undo. -->
|
||||
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||
|
||||
<string name="tags_delete">Delete</string>
|
||||
<string name="tags_delete_title">Delete %1$s?</string>
|
||||
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_confirm">Delete</string>
|
||||
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||
row cannot un-write the word, so it comes back on that note's next edit —
|
||||
said here rather than left as a surprise. -->
|
||||
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||
|
||||
<string name="tags_cancel">Cancel</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="label_picker_title">Labels</string>
|
||||
<string name="label_new_hint">Type a label and press enter</string>
|
||||
<string name="label_from_tag">from #tag</string>
|
||||
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="label_picker_title">Tags</string>
|
||||
<string name="label_new_hint">Type a tag and press enter</string>
|
||||
<string name="label_from_tag">from the text</string>
|
||||
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="picker_next">Next</string>
|
||||
<string name="picker_set">Set</string>
|
||||
<string name="picker_time_title">Pick a time</string>
|
||||
@@ -200,4 +250,9 @@
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_dismiss">Dismiss</string>
|
||||
|
||||
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
|
||||
a real state (a bare `gradlew assembleDebug` with no override) and saying
|
||||
so is better than an empty line that reads as a layout bug. -->
|
||||
<string name="build_unknown">unknown</string>
|
||||
</resources>
|
||||
|
||||
@@ -333,6 +333,65 @@ impl ThoughtSync {
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Rename a label. Every note carrying it follows, because notes reference it
|
||||
/// by id and never by name.
|
||||
///
|
||||
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
|
||||
/// row is the survivor — it keeps its id and colour and takes the new spelling.
|
||||
/// Matching is case-insensitive, like `find_or_create_label`.
|
||||
///
|
||||
/// So this call can return a label whose id is NOT the one passed in, and it can
|
||||
/// make another label stop existing. A UI over it should say so before calling:
|
||||
/// the merge cannot be undone by repeating it, and here it is reachable by a
|
||||
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
|
||||
/// deliberately does not, because a confirmation belongs to the surface that has
|
||||
/// a person in front of it, not to the store.
|
||||
///
|
||||
/// `store::rename_label` and the server's PATCH implement the same rule, so the
|
||||
/// phone, the desktop and the web agree on which row survives.
|
||||
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::rename_label(&conn, &id, &name)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Recolour a label.
|
||||
///
|
||||
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
|
||||
/// side), not a hex value — the point of the shared palette is that a colour
|
||||
/// picked on the phone resolves to the same swatch on the web and the desktop,
|
||||
/// which a literal colour could not promise across themes.
|
||||
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::set_label_color(&conn, &id, &color)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
|
||||
/// carrying it, which is the thing a confirmation dialog has to say out loud.
|
||||
///
|
||||
/// A `#tag` in a body will re-derive the label on the next edit of that note.
|
||||
/// That is correct rather than a leak: the text mandates it, and deleting the
|
||||
/// row cannot un-write the word.
|
||||
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::remove_label(&conn, &id).map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Fold `source` into `target` and return the survivor.
|
||||
///
|
||||
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
|
||||
/// UI over this has to name the survivor before it runs, because afterwards
|
||||
/// there is nothing left to read the direction from.
|
||||
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::merge_labels(&conn, &source_id, &target_id)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
@@ -520,6 +579,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
|
||||
local::derive::render_item(&text, checked)
|
||||
}
|
||||
|
||||
/// Tell the core which app it is running inside, and which build of it.
|
||||
///
|
||||
/// Android has to say so because the core cannot: the same crate is compiled into
|
||||
/// the desktop app, and it used to announce every phone in the field as
|
||||
/// `thoughtsync-desktop` carrying the CORE crate's version — a number no build
|
||||
/// stamps and nobody has seen. The honest value is the installed package's own
|
||||
/// `versionName`, which is what Kotlin passes here.
|
||||
///
|
||||
/// Called once from `ThoughtSyncApplication.onCreate`, before anything can sync.
|
||||
#[uniffi::export]
|
||||
pub fn set_client_agent(name: String, version: String) {
|
||||
compat::set_client_agent(&name, &version);
|
||||
}
|
||||
|
||||
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
||||
/// walking the body line by line knows which lines are boxes and what is in them.
|
||||
#[uniffi::export]
|
||||
@@ -892,6 +965,81 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
||||
/// OLDER row is the survivor.
|
||||
///
|
||||
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
||||
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
||||
/// a person means by typing an existing tag's name onto this one.
|
||||
#[test]
|
||||
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
|
||||
// share a timestamp, and then the tie-break is under test instead of the age
|
||||
// rule this test is about.
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let one = app.create_note(draft("milk")).expect("note one");
|
||||
let two = app.create_note(draft("stamps")).expect("note two");
|
||||
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
|
||||
.expect("tag one");
|
||||
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
|
||||
.expect("tag two");
|
||||
|
||||
// The YOUNGER one is renamed onto the older's name, in a different case —
|
||||
// matching is case-insensitive, and the survivor takes the spelling asked for.
|
||||
let survivor = app
|
||||
.rename_label(newer.id.clone(), "Grocery".to_string())
|
||||
.expect("a rename onto an existing name merges instead of failing");
|
||||
|
||||
assert_eq!(
|
||||
survivor.id, older.id,
|
||||
"the older row is the one that survives"
|
||||
);
|
||||
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
|
||||
|
||||
let all = app.list_labels().expect("list");
|
||||
assert_eq!(all.len(), 1, "the two became one");
|
||||
assert_eq!(all[0].id, older.id);
|
||||
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
|
||||
/// still leaves the older row standing — it just changes its name.
|
||||
///
|
||||
/// This is the whole reason age decides rather than "whoever already held the
|
||||
/// name": otherwise the survivor depends on which way round someone typed it,
|
||||
/// and two devices tidying the same pair would disagree about which id exists.
|
||||
#[test]
|
||||
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let survivor = app
|
||||
.rename_label(older.id.clone(), "errands".to_string())
|
||||
.expect("rename");
|
||||
|
||||
assert_eq!(survivor.id, older.id, "age wins in this direction too");
|
||||
assert_eq!(survivor.name, "errands");
|
||||
assert_ne!(
|
||||
survivor.id, newer.id,
|
||||
"the younger row is the one that went"
|
||||
);
|
||||
assert_eq!(app.list_labels().expect("list").len(), 1);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
||||
/// crate's dev-dependencies to assert one field is well-formed.
|
||||
fn chrono_free_parse(raw: &str) -> usize {
|
||||
|
||||
@@ -820,7 +820,57 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
||||
load_label(conn, &id)
|
||||
}
|
||||
|
||||
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
|
||||
///
|
||||
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
|
||||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
||||
/// reached the user as database internals. Merging is the operator's call, and it
|
||||
/// is the reading that matches what a person means — typing an existing tag's name
|
||||
/// onto this one says "these are the same thing."
|
||||
///
|
||||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
||||
/// that already held the name" because age is the property neither participant's
|
||||
/// role can change: rename A→B and rename B→A must land on the same survivor, or
|
||||
/// the result depends on which way round someone happened to type it. Ties (two
|
||||
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
|
||||
/// still deterministic.
|
||||
///
|
||||
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
|
||||
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
|
||||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||||
let clash: Option<(String, String)> = conn
|
||||
.query_row(
|
||||
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
|
||||
params![name, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some((other_id, other_created)) = clash {
|
||||
let mine_created: String =
|
||||
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
|
||||
// and lexicographic order IS chronological order — no parsing needed.
|
||||
let (survivor, doomed) = if other_created <= mine_created {
|
||||
(other_id, id.to_string())
|
||||
} else {
|
||||
(id.to_string(), other_id)
|
||||
};
|
||||
// Reuse the merge rather than re-implement it: it is the only place that
|
||||
// knows to mark every affected NOTE dirty before the delete cascades the
|
||||
// membership rows away, which is what makes the merge reach the server.
|
||||
merge_labels(conn, &doomed, &survivor)?;
|
||||
// The survivor may still carry the old spelling — it is the one that keeps
|
||||
// existing, so it is the one that has to end up named what was asked for.
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), survivor],
|
||||
)?;
|
||||
return load_label(conn, &survivor);
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), id],
|
||||
|
||||
+50
-2
@@ -17,6 +17,7 @@
|
||||
//! `docs/sync.md` for the policy that governs when those numbers move.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
///
|
||||
@@ -172,10 +173,41 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who this client says it is, set once by the host application at startup.
|
||||
///
|
||||
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
|
||||
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
|
||||
/// the desktop app AND the Android app — so every phone in the field announced
|
||||
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
|
||||
/// version of the CORE crate, a number no build stamps and no user has ever seen,
|
||||
/// while the thing a reader of that header wants is the app's own build (note 3127
|
||||
/// §5 — with no version tags, the artifact's self-report is the only answer to
|
||||
/// "which build is this?").
|
||||
///
|
||||
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
||||
/// the process and a second caller should be ignored rather than race the first.
|
||||
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
|
||||
///
|
||||
/// Call once at startup, before any sync. Calling twice is not an error and the
|
||||
/// first name wins; not calling it at all is visible in the header rather than
|
||||
/// silently plausible.
|
||||
pub fn set_client_agent(name: &str, version: &str) {
|
||||
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
|
||||
}
|
||||
|
||||
/// Headers this client puts on every request to a linked server, so the server can
|
||||
/// log or gate on client identity without a separate handshake round-trip.
|
||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
|
||||
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
||||
// today, which is exactly why a wrong value could sit in it for months: the
|
||||
// first person to look at a server log is the first person who could catch it,
|
||||
// and only if what they see is obviously a host that never introduced itself.
|
||||
let agent = CLIENT_AGENT
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
|
||||
[
|
||||
("X-ThoughtSync-Client", agent),
|
||||
(
|
||||
@@ -374,10 +406,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn client_headers_identify_app_and_protocol() {
|
||||
// Sets the process-wide agent, which is why this test also owns the
|
||||
// assertion about it: a second test calling `set_client_agent` would race
|
||||
// this one for the OnceLock, and whichever lost would see the other's name.
|
||||
// One test, both branches, in order.
|
||||
assert!(
|
||||
client_headers()[0]
|
||||
.1
|
||||
.starts_with("thoughtsync-unidentified/"),
|
||||
"a host that never introduced itself must say so"
|
||||
);
|
||||
|
||||
set_client_agent("thoughtsync-test", "2026.08.31.1204");
|
||||
let headers = client_headers();
|
||||
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
||||
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
|
||||
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
|
||||
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
||||
|
||||
// First name wins — a second host cannot rename a running process.
|
||||
set_client_agent("thoughtsync-impostor", "0");
|
||||
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
|
||||
# current build, so the tag alone names the newest one. `stable` only became one in
|
||||
# M314 step 3, when `main` started publishing — before that it was a manifest-only
|
||||
# pointer at whatever `v*` tag somebody had last cut.
|
||||
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
|
||||
# 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
|
||||
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||
#
|
||||
@@ -86,36 +89,13 @@ esac
|
||||
# --- resolve the release for this channel -----------------------------------
|
||||
say "Finding the latest ThoughtSync build on the $channel channel…"
|
||||
|
||||
# ONE lookup for both channels now. Each is a release whose tag never moves and whose
|
||||
# assets are pruned to the current build, so the tag alone names the newest build on
|
||||
# that channel — which is exactly what an installer wants and what the in-app updater
|
||||
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
|
||||
# are pruned to the current build, so the tag alone names the newest build on that
|
||||
# channel — which is exactly what an installer wants and what the in-app updater
|
||||
# already reads.
|
||||
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
|
||||
die "the $channel channel has nothing published yet."
|
||||
|
||||
# TRANSITIONAL — delete with the rest of the old scheme (M314 step 7).
|
||||
#
|
||||
# `stable` existed before this as a manifest-ONLY pointer: `latest.json` naming a
|
||||
# version whose bundles lived on a separate `v<version>` release. Between this commit
|
||||
# and the first merge to `main` it still looks like that, and `stable` is the DEFAULT
|
||||
# channel — so without this fallback `curl … | sh` is broken for everyone in that
|
||||
# window. It costs nothing once main has published: the grep finds the bundles and
|
||||
# this branch never runs again.
|
||||
if [ "$channel" = "stable" ] && ! printf '%s' "$json" | grep -q "releases/download/stable/[^\"]*\.\(AppImage\|deb\|pkg\.tar\)"; then
|
||||
say "stable has no bundles of its own yet — falling back to the version its manifest names."
|
||||
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
|
||||
stable_version="$(printf '%s' "$manifest" |
|
||||
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
|
||||
sed -E 's/.*"([^"]+)"$/\1/')"
|
||||
if [ -n "$stable_version" ]; then
|
||||
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
|
||||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
|
||||
else
|
||||
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
|
||||
die "no stable build published yet — try --channel dev, or merge to main."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
|
||||
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
|
||||
asset_url() {
|
||||
|
||||
@@ -24,6 +24,11 @@ set -euo pipefail
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
||||
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
|
||||
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
|
||||
# The version a PERSON reads, published beside the manifest so the image build can
|
||||
# describe the bundles it bakes in without re-deriving anything. Required rather
|
||||
# than defaulted: a missing value here would silently publish a sidecar naming the
|
||||
# wrong build, and there is nothing downstream that could catch it.
|
||||
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
|
||||
|
||||
# The manifest is published to the release that HOLDS the bundles. There is no
|
||||
# second place any more.
|
||||
@@ -116,27 +121,47 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "==> Manifest:"
|
||||
cat "$work/latest.json"
|
||||
|
||||
# The manifest goes on the same release the bundles were just read from — which is
|
||||
# also the one `publish-release.sh` created or refreshed moments earlier, so it is
|
||||
# Both files go on the same release the bundles were just read from — which is also
|
||||
# the one `publish-release.sh` created or refreshed moments earlier, so it is
|
||||
# guaranteed to exist by the time this runs.
|
||||
target_id="$release_id"
|
||||
target_assets="$assets"
|
||||
|
||||
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
||||
# and this file is rewritten on every publish by design.
|
||||
old_id="$(printf '%s' "$target_assets" \
|
||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
|
||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||
if [ -n "${old_id:-}" ]; then
|
||||
echo "==> Removing the previous latest.json (id $old_id)"
|
||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
# and these files are rewritten on every publish by design.
|
||||
replace_asset() {
|
||||
local path="$1" name="$2" escaped old_id
|
||||
escaped="${name//./\\.}"
|
||||
old_id="$(printf '%s' "$assets" \
|
||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
|
||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||
if [ -n "${old_id:-}" ]; then
|
||||
echo "==> Removing the previous $name (id $old_id)"
|
||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
echo "==> Uploading $name to $RELEASE_TAG"
|
||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \
|
||||
-F "attachment=@$path" >/dev/null
|
||||
}
|
||||
|
||||
echo "==> Uploading latest.json to $RELEASE_TAG"
|
||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
|
||||
-F "attachment=@$work/latest.json" >/dev/null
|
||||
replace_asset "$work/latest.json" "latest.json"
|
||||
|
||||
echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
|
||||
# The version pair, for whoever needs to describe these bundles without rebuilding
|
||||
# them — today the image build, which bakes the desktop clients in and writes each
|
||||
# one a sidecar (`packaging/fetch-clients.sh`).
|
||||
#
|
||||
# It is published HERE, beside the manifest, because this is the step that speaks
|
||||
# for what the channel serves: both files are written in the same breath from the
|
||||
# same two values, so they cannot disagree about which build is current. A consumer
|
||||
# deriving the version from its own checkout instead would describe these bytes
|
||||
# with whatever commit it happened to be on.
|
||||
#
|
||||
# No `size` or `sha256` — those are per-artifact and there are four. Whoever
|
||||
# downloads a bundle measures the bytes it actually got, which is the only way to
|
||||
# tell a truncated download from a whole one.
|
||||
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
|
||||
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
|
||||
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
|
||||
|
||||
echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)."
|
||||
|
||||
# --- prune superseded builds from a rolling channel ---------------------------
|
||||
#
|
||||
@@ -169,7 +194,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
|
||||
# every desktop push regardless. That is exactly what happened on run
|
||||
# 4098, which swept the APK run 4092 had just published.
|
||||
case "$asset_name" in
|
||||
latest.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
||||
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
||||
*"$APP_VERSION"*) continue ;;
|
||||
esac
|
||||
echo " removing $asset_name"
|
||||
|
||||
@@ -64,3 +64,8 @@ tauri-plugin-log = "2"
|
||||
# the plugin declares android support level "none", which is why the Android client
|
||||
# gets a server-served update path instead (Scribe note 2725).
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
|
||||
# concept of a global shortcut, and its half of this feature is a share-sheet
|
||||
# intent filter instead.
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
fn main() {
|
||||
// Cargo does NOT track an `option_env!` variable on its own — the macro is
|
||||
// expanded at compile time and nothing records that the crate depends on it.
|
||||
// So without this line, a cached `target/` would keep a binary reporting
|
||||
// whatever version the previous build baked, and the footer would confidently
|
||||
// name the wrong build. The desktop lane has no cache today, which is exactly
|
||||
// why this is easy to forget the day one is added.
|
||||
//
|
||||
// See DISPLAY_VERSION in `src/commands/local.rs`.
|
||||
println!("cargo::rerun-if-env-changed=THOUGHTSYNC_DISPLAY_VERSION");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Core capability for the main ThoughtSync window.",
|
||||
"windows": ["main"],
|
||||
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
|
||||
"windows": ["main", "capture"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Quick capture: a system-wide hotkey that opens a small window to type into.
|
||||
//!
|
||||
//! The point is capture WITHOUT the app. Bringing the whole board forward to write
|
||||
//! one line is the friction this removes, so the shortcut opens a small window of
|
||||
//! its own rather than focusing `main` — and that window closes itself the moment
|
||||
//! the note is saved.
|
||||
//!
|
||||
//! ## Why the shortcut is configurable, and why it starts unset
|
||||
//!
|
||||
//! A global shortcut is the one setting in this app that can collide with software
|
||||
//! it knows nothing about. Whatever default is picked is a key combination taken
|
||||
//! away from something on somebody's machine, silently, at install time. So there
|
||||
//! is no default: the feature is off until someone chooses a combination, and
|
||||
//! choosing one is how it turns on.
|
||||
//!
|
||||
//! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in
|
||||
//! the frontend, not here. It is a UI affordance — a starting point put in front of
|
||||
//! someone — and this side accepts any combination the OS will take, so a constant
|
||||
//! here would be a second copy of a string only the UI ever reads.
|
||||
//!
|
||||
//! ## Failure has to be visible
|
||||
//!
|
||||
//! Registering can fail — the combination may already be held by the window
|
||||
//! manager or another app, and on Wayland a compositor may refuse global grabs
|
||||
//! outright. A hotkey that quietly does nothing is worse than one that was never
|
||||
//! offered, because there is nothing to look at and nothing to fix. So the stored
|
||||
//! shortcut and the LIVE registration are reported separately: see
|
||||
//! [`CaptureShortcut`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
use thoughtsync_core::local::{store, Db};
|
||||
|
||||
const SHORTCUT_PREF: &str = "capture_shortcut";
|
||||
|
||||
/// The window the hotkey opens. Also the label the capability file grants to.
|
||||
pub const CAPTURE_WINDOW: &str = "capture";
|
||||
|
||||
/// Emitted to the main window after a capture is saved, so the board reloads.
|
||||
///
|
||||
/// The two windows hold separate copies of the frontend and therefore separate
|
||||
/// Pinia stores; nothing in the capture window's store can reach the board's. The
|
||||
/// note is already in SQLite by the time this fires — this only says "look again".
|
||||
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
|
||||
|
||||
/// The stored shortcut and whether it is actually live.
|
||||
///
|
||||
/// Two fields rather than one because they genuinely disagree: a combination can
|
||||
/// be saved and refuse to register, and the person needs to be told which of those
|
||||
/// they are looking at. `registered: false` with a non-empty `shortcut` is the
|
||||
/// "something else already has this" case.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureShortcut {
|
||||
/// The stored combination, or empty when quick capture is off.
|
||||
pub shortcut: String,
|
||||
/// Whether the OS accepted it. Always false when `shortcut` is empty.
|
||||
pub registered: bool,
|
||||
}
|
||||
|
||||
fn stored(db: &Db) -> Result<String, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(store::pref(&conn, SHORTCUT_PREF)
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Open (or focus) the capture window.
|
||||
///
|
||||
/// Reused rather than recreated: holding one window and showing it is what makes
|
||||
/// the second press feel instant, and it means a half-typed capture survives the
|
||||
/// window being dismissed and reopened.
|
||||
///
|
||||
/// `always_on_top` and `center` because this is summoned over whatever you were
|
||||
/// doing — a capture window that opens behind the app you called it from has
|
||||
/// failed at the only thing it does.
|
||||
fn open_capture_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// `index.html?capture=1` rather than a `/capture` path: the bundled assets are
|
||||
// served as files, so a path with no file behind it is a 404 in the production
|
||||
// build even though it routes fine under the dev server. A query string is
|
||||
// carried through untouched and the router reads it on boot.
|
||||
let built = WebviewWindowBuilder::new(
|
||||
app,
|
||||
CAPTURE_WINDOW,
|
||||
WebviewUrl::App("index.html?capture=1".into()),
|
||||
)
|
||||
.title("Quick capture")
|
||||
.inner_size(520.0, 220.0)
|
||||
.min_inner_size(360.0, 160.0)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.skip_taskbar(true)
|
||||
.build();
|
||||
|
||||
match built {
|
||||
Ok(window) => {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// Never a panic and never fatal: failing to open a capture window must not
|
||||
// take down an app whose board is working fine.
|
||||
Err(e) => log::error!("could not open the capture window: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `shortcut`, replacing whatever was live.
|
||||
///
|
||||
/// Unregisters everything first rather than tracking the previous binding: this
|
||||
/// app owns exactly one global shortcut, so "all of ours" and "the old one" are
|
||||
/// the same set, and keeping a copy of it is one more thing to get out of step.
|
||||
fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> {
|
||||
let manager = app.global_shortcut();
|
||||
let _ = manager.unregister_all();
|
||||
if shortcut.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed: Shortcut = shortcut
|
||||
.parse()
|
||||
.map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?;
|
||||
manager
|
||||
.on_shortcut(parsed, |app, _shortcut, event| {
|
||||
// Pressed only. Without this the window is opened on the press AND on
|
||||
// the release, and the second one lands on the window the first opened.
|
||||
if event.state == ShortcutState::Pressed {
|
||||
open_capture_window(app);
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Something else on this system is already using it ({e})."))
|
||||
}
|
||||
|
||||
/// Restore the stored shortcut at startup.
|
||||
///
|
||||
/// Best-effort by construction: a combination that worked when it was chosen can
|
||||
/// be taken by something installed later, and the app must still open. The failure
|
||||
/// is logged and the UI will show it as not registered when the settings screen is
|
||||
/// next opened.
|
||||
pub fn restore(app: &AppHandle, db: &Db) {
|
||||
let shortcut = match stored(db) {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
log::warn!("could not read the capture shortcut: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match register(app, &shortcut) {
|
||||
Ok(()) => log::info!("quick capture is on: {shortcut}"),
|
||||
Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result<CaptureShortcut, String> {
|
||||
let shortcut = stored(&db)?;
|
||||
// Asked of the manager rather than remembered from startup: the answer can
|
||||
// have changed since, and a settings screen that reports a stale success is
|
||||
// the exact thing this pair of fields exists to prevent.
|
||||
let registered = !shortcut.is_empty()
|
||||
&& shortcut
|
||||
.parse::<Shortcut>()
|
||||
.map(|s| app.global_shortcut().is_registered(s))
|
||||
.unwrap_or(false);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut,
|
||||
registered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a shortcut and make it live, or clear it with an empty string.
|
||||
///
|
||||
/// Registers BEFORE storing, so a combination the system refuses is not written
|
||||
/// down as though it worked — the person would reopen the settings and find it
|
||||
/// listed as their shortcut while nothing happened when they pressed it.
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_set(
|
||||
shortcut: String,
|
||||
app: AppHandle,
|
||||
db: State<'_, Db>,
|
||||
) -> Result<CaptureShortcut, String> {
|
||||
let wanted = shortcut.trim().to_string();
|
||||
register(&app, &wanted)?;
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"quick capture shortcut {}",
|
||||
if wanted.is_empty() {
|
||||
"cleared".to_string()
|
||||
} else {
|
||||
format!("set to {wanted}")
|
||||
}
|
||||
);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut: wanted.clone(),
|
||||
registered: !wanted.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hide the capture window and tell the board to reload.
|
||||
///
|
||||
/// Hidden rather than closed so the next press has a window to show instead of one
|
||||
/// to build. Called after a save and on Escape alike; `saved` is what decides
|
||||
/// whether the board is told to look again.
|
||||
#[tauri::command]
|
||||
pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
if saved {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
// Failure here is cosmetic — the note is saved either way and the board
|
||||
// will show it on its next load — so it is logged, not raised.
|
||||
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
|
||||
log::warn!("could not tell the board about a capture: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,7 +31,9 @@ pub fn config_get(db: State<'_, Db>) -> PublicConfig {
|
||||
PublicConfig {
|
||||
site_name: "ThoughtSync".to_string(),
|
||||
allow_registration: false,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
// The build a person reads, baked at compile time — see crate::display_version
|
||||
// for why this is neither CARGO_PKG_VERSION nor the updater's ordering key.
|
||||
version: crate::display_version().to_string(),
|
||||
enable_url_unfurl: false,
|
||||
trash_retention_days: retention_days.max(0) as u32,
|
||||
}
|
||||
|
||||
@@ -9,10 +9,42 @@
|
||||
//! remains here is the Tauri command surface (`commands`), desktop integration
|
||||
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
|
||||
|
||||
mod capture;
|
||||
mod commands;
|
||||
mod integration;
|
||||
mod update;
|
||||
|
||||
/// The build a PERSON reads, baked in by the desktop lane at compile time.
|
||||
///
|
||||
/// Lives at the crate root because it has two readers — `config_get`, which puts it
|
||||
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
|
||||
/// spent several issues on one fact held in two places (2181, 2182, 2183).
|
||||
///
|
||||
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
|
||||
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
|
||||
/// plausible-looking default — note 3127 §5 makes this string the only answer to
|
||||
/// "which build is this?" now that there are no version tags, so there is nothing
|
||||
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
|
||||
/// answer.
|
||||
///
|
||||
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
|
||||
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
|
||||
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
|
||||
/// Cargo.toml is what reached the UI and the log regardless of what was built.
|
||||
///
|
||||
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
|
||||
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
|
||||
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
|
||||
/// values, two audiences. `update.rs` deliberately still reads the key, through
|
||||
/// `app.package_info().version`, because a comparator is exactly what it is.
|
||||
const DISPLAY_VERSION: Option<&str> = option_env!("THOUGHTSYNC_DISPLAY_VERSION");
|
||||
|
||||
/// The baked build, or the honest "I cannot say". The only way in — the const is
|
||||
/// private so no caller can reach past the fallback.
|
||||
pub(crate) fn display_version() -> &'static str {
|
||||
DISPLAY_VERSION.unwrap_or("unknown")
|
||||
}
|
||||
|
||||
// The store and the sync engine live in the shared `thoughtsync-core` crate, which
|
||||
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
|
||||
// names so every call site below reads exactly as it did when they were modules of
|
||||
@@ -22,6 +54,12 @@ use thoughtsync_core::{local, sync};
|
||||
pub fn run() {
|
||||
use tauri_plugin_log::{Target, TargetKind};
|
||||
|
||||
// Introduce ourselves to any server this app links to, BEFORE anything can sync.
|
||||
// The core cannot work this out — it is compiled into the Android app too — so
|
||||
// the header says "desktop" only because the desktop says so here, and carries
|
||||
// the build a person can read rather than the core crate's own version.
|
||||
sync::compat::set_client_agent("thoughtsync-desktop", display_version());
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
harden_linux_webkit_rendering();
|
||||
|
||||
@@ -43,6 +81,10 @@ pub fn run() {
|
||||
// build without a signing key still starts normally and simply reports that
|
||||
// updates aren't configured.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// The quick-capture hotkey. Registering the combination itself happens in
|
||||
// `setup`, once the store is open and can be asked which one to use — the
|
||||
// plugin only has to exist before then.
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
// Attachment bytes are served to the webview from the local blob store
|
||||
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
|
||||
// the webview is created; the directory it reads from arrives later, in
|
||||
@@ -81,6 +123,9 @@ pub fn run() {
|
||||
// in this directory saying which one the user picked (issue 2183).
|
||||
update::adopt_installer_channel(&db, &dir);
|
||||
sweep_local_trash(&db);
|
||||
// Before the store is handed to the app: `restore` needs to read the
|
||||
// stored shortcut out of it, and after `manage` the Db has moved.
|
||||
capture::restore(app.handle(), &db);
|
||||
app.manage(db);
|
||||
// Attachment bytes live beside the database, filed by content hash, so a
|
||||
// synced image is readable with no network (M10.7d).
|
||||
@@ -139,6 +184,9 @@ pub fn run() {
|
||||
update::update_channel_set,
|
||||
update::update_check,
|
||||
update::update_install,
|
||||
capture::capture_shortcut_get,
|
||||
capture::capture_shortcut_set,
|
||||
capture::capture_done,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the ThoughtSync desktop app");
|
||||
@@ -219,8 +267,8 @@ fn log_event(level: String, message: String) {
|
||||
fn log_environment(app: &tauri::App) {
|
||||
use tauri::Manager;
|
||||
log::info!(
|
||||
"ThoughtSync desktop v{} starting ({} {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"ThoughtSync desktop {} starting ({} {})",
|
||||
display_version(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
|
||||
@@ -36,6 +36,10 @@ pinning.
|
||||
If you want a specific build — testing something, or holding back — drop it in
|
||||
`/var/thoughtsync/client/` and it wins over the image's copy.
|
||||
|
||||
That directory is shared with the desktop clients the server hands out, and
|
||||
**precedence is decided per platform**: dropping in an APK overrides the baked APK
|
||||
and leaves every other client alone. It is one directory, not one choice.
|
||||
|
||||
Two files, both required:
|
||||
|
||||
| File | What it is |
|
||||
|
||||
@@ -27,6 +27,23 @@ const ui = useUiStore();
|
||||
// Sync is a desktop-app concern: the web build already IS the server's UI.
|
||||
const desktopApp = isDesktop();
|
||||
|
||||
// The build, for the dim line at the foot of the rail (#3181).
|
||||
//
|
||||
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an
|
||||
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
|
||||
// tags gone this is the only answer to "which build is this?", so it has to be
|
||||
// either right or visibly absent.
|
||||
//
|
||||
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
|
||||
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
|
||||
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
|
||||
// names the thing the person is actually looking at. A linked server's version is
|
||||
// a different question and Sync answers it separately.
|
||||
const buildVersion = computed(() => config.version || "unknown");
|
||||
const buildLabel = computed(
|
||||
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
|
||||
);
|
||||
|
||||
async function removeView(f: SavedFilter) {
|
||||
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
|
||||
try {
|
||||
@@ -415,7 +432,7 @@ async function signOut() {
|
||||
@click="drawer = false"
|
||||
></div>
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
:class="drawer ? 'translate-x-0' : ''"
|
||||
>
|
||||
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
|
||||
@@ -424,18 +441,18 @@ async function signOut() {
|
||||
</RouterLink>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Tags</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
|
||||
title="Edit labels"
|
||||
aria-label="Edit labels"
|
||||
title="Manage tags"
|
||||
aria-label="Manage tags"
|
||||
@click="managing = true"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
|
||||
<RouterLink
|
||||
v-for="lb in labels.items"
|
||||
:key="lb.id"
|
||||
@@ -527,6 +544,17 @@ async function signOut() {
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
|
||||
short and lets it simply follow when the nav has scrolled.
|
||||
`select-all` because the one thing anybody does with this is copy it
|
||||
into a bug report. -->
|
||||
<p
|
||||
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
|
||||
:title="buildLabel"
|
||||
>
|
||||
{{ buildVersion }}
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||||
|
||||
@@ -11,18 +11,16 @@ withDefaults(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
|
||||
download has to be an <a> (only an anchor can carry an href and hand the
|
||||
transfer to the browser), so the shape has to live somewhere both elements
|
||||
can wear it. The disabled: variants stay local — an anchor has no
|
||||
:disabled, so they are not shared and never were. -->
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="
|
||||
variant === 'primary'
|
||||
? 'bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700'
|
||||
: 'text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800'
|
||||
"
|
||||
class="btn disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
// Every client this server holds, with the one that fits the visitor on top.
|
||||
//
|
||||
// Five artifacts is where a downloads page turns into a table of filenames and
|
||||
// stops being a product. So this LEADS with the download that fits the machine
|
||||
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
|
||||
// because a wrong guess must cost a person nothing.
|
||||
import { computed } from "vue";
|
||||
import { useConfigStore, type ClientRelease } from "../stores/config";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
|
||||
|
||||
/**
|
||||
* Which OS is asking, from the user agent.
|
||||
*
|
||||
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
|
||||
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
|
||||
* before the broad one that would otherwise swallow it.
|
||||
*
|
||||
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
|
||||
* sends still carries the platform token, which is the only thing being asked
|
||||
* for, and one code path beats two for a guess that is allowed to be wrong.
|
||||
*/
|
||||
function detectFamily(ua: string): Family {
|
||||
if (/Android/i.test(ua)) return "android";
|
||||
if (/Windows/i.test(ua)) return "windows";
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return "mac";
|
||||
// CrOS lands here on purpose: a Chromebook's Linux container is a Debian one,
|
||||
// which is the first thing the Linux group offers.
|
||||
if (/Linux|X11|CrOS/i.test(ua)) return "linux";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// What to lead with per family, in the order someone on it should see them.
|
||||
//
|
||||
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
|
||||
// pacman — there is no more specific answer to be had, so the three are named for
|
||||
// the DISTRO a person knows rather than the package format they may not.
|
||||
//
|
||||
// macOS and iOS lead with nothing. There is no build for either, and an empty
|
||||
// lead is the honest way to say so — see `missingPlatform` below.
|
||||
const LEAD: Record<Family, string[]> = {
|
||||
android: ["android"],
|
||||
windows: ["windows"],
|
||||
linux: ["linux-deb", "linux-pacman", "linux-appimage"],
|
||||
mac: [],
|
||||
ios: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
const FAMILY_TITLE: Record<Family, string> = {
|
||||
android: "Android",
|
||||
windows: "Windows",
|
||||
linux: "Linux",
|
||||
mac: "macOS",
|
||||
ios: "iOS",
|
||||
other: "This machine",
|
||||
};
|
||||
|
||||
// Read once. The UA does not change while the page is open, and making it
|
||||
// reactive would only invite someone to think it could.
|
||||
const family = detectFamily(navigator.userAgent);
|
||||
|
||||
// The lead offers this server actually holds. A platform in LEAD that the server
|
||||
// has no build for simply is not here — the guess never conjures a download.
|
||||
const lead = computed(() =>
|
||||
LEAD[family]
|
||||
.map((id) => config.clients[id])
|
||||
.filter((c): c is ClientRelease => Boolean(c)),
|
||||
);
|
||||
|
||||
const others = computed(() => {
|
||||
const leading = new Set(lead.value.map((c) => c.platform));
|
||||
// Object.values keeps the server's own PLATFORMS order, which is a deliberate
|
||||
// one (phone first, then the desktop bundles) and not worth re-deciding here.
|
||||
return Object.values(config.clients).filter((c) => !leading.has(c.platform));
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
const out: { title: string; releases: ClientRelease[]; prominent: boolean }[] = [];
|
||||
if (lead.value.length) {
|
||||
out.push({ title: FAMILY_TITLE[family], releases: lead.value, prominent: true });
|
||||
}
|
||||
if (others.value.length) {
|
||||
out.push({
|
||||
// Without a lead there is no "other" — the whole list is the choice.
|
||||
title: lead.value.length ? "Other platforms" : "Choose a platform",
|
||||
releases: others.value,
|
||||
prominent: false,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Said plainly, so a Mac reads as "not yet" rather than as a page that failed to
|
||||
// find its own downloads.
|
||||
const missingPlatform = computed(() =>
|
||||
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
|
||||
);
|
||||
|
||||
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
|
||||
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
|
||||
// only distinction that matters at the small end.
|
||||
function readableSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Nothing at all on a server with no clients — a brand-new instance before its
|
||||
first image carrying them. An empty section would be a promise it can't keep. -->
|
||||
<section v-if="groups.length" class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Get the apps</h2>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
Served by this server, so they always speak the same sync protocol.
|
||||
</p>
|
||||
<p v-if="missingPlatform" class="mt-1 text-xs text-neutral-400">
|
||||
There's no {{ missingPlatform }} build yet.
|
||||
</p>
|
||||
|
||||
<div v-for="group in groups" :key="group.title" class="mt-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-400">{{ group.title }}</p>
|
||||
<ul class="mt-2 flex flex-col gap-2">
|
||||
<li
|
||||
v-for="client in group.releases"
|
||||
:key="client.platform"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
<!-- `unknown` rather than a blank or a plausible default: with no
|
||||
second source to contradict it, a wrong version here is a wrong
|
||||
answer nothing can catch. Not knowing which build it is, is also
|
||||
not a reason to withhold the download. -->
|
||||
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
|
||||
v-if="client.platform === 'linux-appimage'"
|
||||
>, and the only one that updates itself in place</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and
|
||||
the browser's own download manager handles the transfer better than
|
||||
anything this app would do with a blob. It wears `.btn` — the same
|
||||
definition BaseButton wears, so the two cannot drift.
|
||||
|
||||
`download` carries no filename because the server already names the
|
||||
file in its Content-Disposition, which browsers prefer over this
|
||||
attribute anyway — a value here would be inert and read as if it
|
||||
weren't. -->
|
||||
<a
|
||||
:href="client.url"
|
||||
download
|
||||
class="btn shrink-0"
|
||||
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -108,7 +108,7 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
|
||||
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
|
||||
>
|
||||
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
|
||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Tags</span>
|
||||
<button
|
||||
v-for="lb in labels.items"
|
||||
:key="lb.id"
|
||||
|
||||
@@ -46,7 +46,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative">
|
||||
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
|
||||
<button type="button" class="icon-btn" title="Tags" aria-label="Tags" @click="open = !open">
|
||||
<Icon name="tag" />
|
||||
</button>
|
||||
<div
|
||||
@@ -56,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<input
|
||||
v-model="filter"
|
||||
type="text"
|
||||
placeholder="Label note…"
|
||||
placeholder="Tag note…"
|
||||
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
|
||||
/>
|
||||
<ul class="max-h-48 overflow-y-auto">
|
||||
|
||||
@@ -67,7 +67,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<template>
|
||||
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Manage labels</h2>
|
||||
<h2 class="text-sm font-semibold">Manage tags</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
@@ -76,7 +76,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<input
|
||||
v-model="newName"
|
||||
type="text"
|
||||
placeholder="Create label"
|
||||
placeholder="Create tag"
|
||||
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
</form>
|
||||
@@ -87,7 +87,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
|
||||
:class="labelDot(lb)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
|
||||
aria-label="Change label color"
|
||||
aria-label="Change tag color"
|
||||
@click="openColor(lb.id)"
|
||||
/>
|
||||
<input
|
||||
@@ -104,8 +104,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
v-if="canMerge"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Merge into another label"
|
||||
aria-label="Merge into another label"
|
||||
title="Merge into another tag"
|
||||
aria-label="Merge into another tag"
|
||||
@click="openMerge(lb.id)"
|
||||
>
|
||||
<Icon name="merge" />
|
||||
@@ -113,8 +113,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete label"
|
||||
aria-label="Delete label"
|
||||
title="Delete tag"
|
||||
aria-label="Delete tag"
|
||||
@click="labels.remove(lb.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -160,7 +160,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
||||
No labels yet — create one above.
|
||||
No tags yet — create one above, or write a #tag in a note and it becomes one.
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
@@ -165,6 +165,63 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- idle autosave ----
|
||||
//
|
||||
// This editor used to write ONLY on close, and the reason was cost: a body write
|
||||
// snapshotted a revision, so saving often meant a version history of thirty
|
||||
// snapshots of one paragraph being typed. The price was durability — a tab closed
|
||||
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
|
||||
//
|
||||
// That trade is gone. Both engines now coalesce snapshots to one per editing
|
||||
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
|
||||
// write costs a write. Writing on an idle pause is what collects the refund; the
|
||||
// Android editor already does the same.
|
||||
const AUTOSAVE_MS = 1000;
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cancelAutosave(): void {
|
||||
if (autosaveTimer !== null) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function autosave(): Promise<void> {
|
||||
// `flush` returns without writing while a save is in flight, which would silently
|
||||
// drop everything typed since that save began. Re-arming rather than skipping is
|
||||
// what keeps that from being a lost paragraph.
|
||||
if (saving.value) {
|
||||
scheduleAutosave();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await flush();
|
||||
} catch {
|
||||
// Swallowed on purpose. An autosave that interrupts typing with an error is
|
||||
// worse than one that waits for the next pause, and `close` still surfaces a
|
||||
// real failure at the moment the person is looking at the editor.
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutosave(): void {
|
||||
cancelAutosave();
|
||||
autosaveTimer = setTimeout(() => {
|
||||
autosaveTimer = null;
|
||||
void autosave();
|
||||
}, AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
|
||||
// never persisted, so that an accidental keystroke or a type-to-compose never
|
||||
// litters the board — and an autosave that created the row would take that away
|
||||
// without anyone asking for it. Materialising a compose on first keystroke is a
|
||||
// separate decision (Scribe #2967), not a side effect of this one.
|
||||
watch(body, () => {
|
||||
if (!isCreate.value) scheduleAutosave();
|
||||
});
|
||||
|
||||
onBeforeUnmount(cancelAutosave);
|
||||
|
||||
function resetCompose(): void {
|
||||
noteId.value = null;
|
||||
setBody("");
|
||||
@@ -225,6 +282,10 @@ async function finish(): Promise<void> {
|
||||
|
||||
// Persist (create in compose, save in edit) and close the editor.
|
||||
async function close(): Promise<void> {
|
||||
// Cancelled first: a timer that fires during the leave animation would write
|
||||
// through a component on its way out, after `flush` has already saved the same
|
||||
// text.
|
||||
cancelAutosave();
|
||||
await flush();
|
||||
await finish();
|
||||
}
|
||||
@@ -233,6 +294,7 @@ async function close(): Promise<void> {
|
||||
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
|
||||
// compose already persisted by a rich action, closes normally (saving its text).
|
||||
async function dismiss(): Promise<void> {
|
||||
cancelAutosave();
|
||||
if (isCreate.value) {
|
||||
await finish();
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
interface TauriGlobal {
|
||||
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
|
||||
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
|
||||
// windows, each with its own Pinia stores — a note saved in one is invisible to
|
||||
// the other until something says so, and an event is the only channel between
|
||||
// them that does not involve polling SQLite.
|
||||
event?: {
|
||||
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -192,3 +199,49 @@ export const updates = {
|
||||
*/
|
||||
install: () => invoke<void>("update_install"),
|
||||
};
|
||||
|
||||
|
||||
// --- Quick capture (#1899) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* The stored hotkey and whether the OS actually accepted it.
|
||||
*
|
||||
* They disagree more often than you would like: a combination can be saved and
|
||||
* refuse to register because a window manager or another app already holds it,
|
||||
* and on Wayland a compositor may refuse global grabs entirely. `registered:
|
||||
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
|
||||
* to say so — a hotkey that silently does nothing is worse than none, because
|
||||
* there is nothing to look at and nothing to fix.
|
||||
*/
|
||||
export interface CaptureShortcut {
|
||||
/** The stored combination, or "" when quick capture is off. */
|
||||
shortcut: string;
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
/** Offered as a starting point, never applied on the user's behalf. */
|
||||
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
|
||||
|
||||
/** Fired at the main window after a capture is saved. */
|
||||
const CAPTURED_EVENT = "thoughtsync://captured";
|
||||
|
||||
export const capture = {
|
||||
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
|
||||
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
|
||||
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
|
||||
/** Hide the capture window; `saved` decides whether the board is told to reload. */
|
||||
done: (saved: boolean) => invoke<void>("capture_done", { saved }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `handler` whenever a note is captured in the other window.
|
||||
*
|
||||
* Returns an unlisten function, or a no-op on the web build and on any desktop
|
||||
* runtime that does not expose the event API — the board simply keeps showing
|
||||
* what it has until its next load, which is a stale list rather than a broken one.
|
||||
*/
|
||||
export async function onCaptured(handler: () => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen(CAPTURED_EVENT, () => handler());
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ const router = createRouter({
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
},
|
||||
{
|
||||
// The quick-capture window (#1899). Its own route because it is its own
|
||||
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
|
||||
// hotkey in a browser tab and nothing to summon it.
|
||||
path: "/capture",
|
||||
name: "capture",
|
||||
component: () => import("../views/CaptureView.vue"),
|
||||
meta: { requiresAuth: true, requiresDesktop: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresDesktop && !isDesktop()) {
|
||||
return { name: "board" };
|
||||
}
|
||||
// The capture window is opened at `index.html?capture=1` rather than at
|
||||
// `/capture`, because the bundled assets are served as files and a path with no
|
||||
// file behind it 404s in the production build — it only routes under the dev
|
||||
// server. A query string survives that, and this is where it becomes a route.
|
||||
if (to.query.capture === "1" && to.name !== "capture") {
|
||||
return { name: "capture" };
|
||||
}
|
||||
// Deliberately NOT applied to /login and /register: bouncing those on desktop
|
||||
// would loop against the requiresAuth guard above the moment a session is
|
||||
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
|
||||
|
||||
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
|
||||
// The Android build this server can hand out. Absent — not null — when it has
|
||||
// none, so `v-if` on it is the whole test; see client_dist.py.
|
||||
export interface AndroidClient {
|
||||
// One client build this server can hand out. Platforms it holds nothing for are
|
||||
// ABSENT from the map rather than present-and-null, so a key test is the whole
|
||||
// question; see client_dist.py.
|
||||
//
|
||||
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
|
||||
// payload. That one is deliberately NARROWER — it only ever reads
|
||||
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
|
||||
// the fields below that Android does not use. Widening it to match this is not a
|
||||
// tidy-up: every phone in the field runs the current shape.
|
||||
export interface ClientRelease {
|
||||
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
|
||||
platform: string;
|
||||
// What a person calls it, named for the DISTRO rather than the package format
|
||||
// ("Debian / Ubuntu", not ".deb"). The server owns this wording so the five
|
||||
// labels cannot drift apart across the surfaces that show them.
|
||||
label: string;
|
||||
version: string;
|
||||
// What decides "is this newer". The name is for people and sorts like a string.
|
||||
version_code: number;
|
||||
//
|
||||
// Not one type across platforms, deliberately: Android's is an integer because
|
||||
// Android's own install gate compares one, and the desktop's is Tauri's semver
|
||||
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
|
||||
// the shape is honest rather than to be read.
|
||||
version_code: number | string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
// A PATH, never an absolute URL — the client joins it to the server it is
|
||||
// already talking to.
|
||||
url: string;
|
||||
// Present only for the AppImage: the minisign signature the desktop updater
|
||||
// checks before replacing the running binary.
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
@@ -20,7 +43,14 @@ export interface PublicConfig {
|
||||
enable_url_unfurl: boolean;
|
||||
// How many days a note survives in Trash before the server purges it. 0 = forever.
|
||||
trash_retention_days: number;
|
||||
android_client?: AndroidClient;
|
||||
// Every client this server holds, keyed by platform id. Absent on a server that
|
||||
// holds none, and absent on the desktop's own offline config — the Tauri build
|
||||
// answers `config_get` locally and has no clients to hand out.
|
||||
//
|
||||
// `/api/config` also carries `android_client`, which is NOT declared here: it
|
||||
// exists for phones in the field polling for their own update, not for this app,
|
||||
// and reading it here would be a second path to the same fact.
|
||||
clients?: Record<string, ClientRelease>;
|
||||
}
|
||||
|
||||
// Public, unauthenticated app config (site name, whether signups are open).
|
||||
@@ -33,9 +63,9 @@ export const useConfigStore = defineStore("config", () => {
|
||||
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
|
||||
// when the server is actually purging is the wrong way to be wrong.
|
||||
const trashRetentionDays = ref(30);
|
||||
// Null until proven otherwise: a server with no APK, and an older server that
|
||||
// never had the field, both correctly show no download.
|
||||
const androidClient = ref<AndroidClient | null>(null);
|
||||
// Empty until proven otherwise: a server with no clients, and an older server
|
||||
// that never had the field, both correctly offer no downloads.
|
||||
const clients = ref<Record<string, ClientRelease>>({});
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
version.value = cfg.version;
|
||||
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
|
||||
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
|
||||
androidClient.value = cfg.android_client ?? null;
|
||||
clients.value = cfg.clients ?? {};
|
||||
} catch {
|
||||
// Keep defaults if the config endpoint is unreachable.
|
||||
} finally {
|
||||
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
version,
|
||||
enableUrlUnfurl,
|
||||
trashRetentionDays,
|
||||
androidClient,
|
||||
clients,
|
||||
loaded,
|
||||
load,
|
||||
reload,
|
||||
|
||||
@@ -33,7 +33,35 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
}
|
||||
|
||||
async function rename(id: string, name: string): Promise<void> {
|
||||
// Renaming onto a name another tag already holds MERGES the two — server-side
|
||||
// and in the local store, identically. Detected from the LIST rather than from
|
||||
// the response: the survivor is whichever row is older, so it may well be the
|
||||
// one we asked to rename, and an id that still matches proves nothing happened.
|
||||
const absorbing = items.value.find(
|
||||
(lb) => lb.id !== id && lb.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (absorbing) {
|
||||
// A merge cannot be undone by repeating it, and here it is reachable by a
|
||||
// typo in a text field — so it asks, the way deleting one does. The counts
|
||||
// are named because "40 notes" is the part that makes the consequence real.
|
||||
const mine = items.value.find((lb) => lb.id === id);
|
||||
const confirmed = window.confirm(
|
||||
`A tag called "${absorbing.name}" already exists.\n\n` +
|
||||
`Renaming will MERGE these two into one tag named "${name}", carrying ` +
|
||||
`every note from both (${mine?.count ?? 0} + ${absorbing.count ?? 0}). ` +
|
||||
"The notes are kept; one of the two tags stops existing, and that cannot " +
|
||||
"be undone.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const updated = await repo.labels.rename(id, name);
|
||||
if (absorbing) {
|
||||
// One row is gone and the survivor's count grew, and this response carries no
|
||||
// count — reload rather than guess which of the two we are now holding.
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
const idx = items.value.findIndex((lb) => lb.id === id);
|
||||
// The single-label PATCH doesn't recompute the count — keep the one we have.
|
||||
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
|
||||
@@ -53,7 +81,7 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
// notes themselves survive; only the membership goes, which is the part people
|
||||
// most need reassuring about.
|
||||
const label = items.value.find((lb) => lb.id === id);
|
||||
const subject = label ? `the label "${label.name}"` : "this label";
|
||||
const subject = label ? `the tag "${label.name}"` : "this tag";
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${subject}?\n\n` +
|
||||
"It will be removed from every note that uses it, on every device you sync " +
|
||||
|
||||
@@ -253,6 +253,35 @@ body {
|
||||
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
|
||||
}
|
||||
}
|
||||
/* THE button shape — the ONE definition of it in the app.
|
||||
*
|
||||
* It lives here, in the components layer, rather than inside BaseButton.vue,
|
||||
* because not every button in this app is a <button>. A DOWNLOAD has to be an
|
||||
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
|
||||
* browser's own download manager handles that transfer better than anything the
|
||||
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
|
||||
* second copy of its class list for anchors is how a page ends up with two
|
||||
* kinds of primary button that drift apart.
|
||||
*
|
||||
* So: BaseButton.vue wears these, and so does any anchor that must read as a
|
||||
* button. Neither owns the look.
|
||||
*
|
||||
* The `disabled:` variants are NOT here on purpose — an anchor has no
|
||||
* :disabled. BaseButton adds them itself, which is exactly the split: shared
|
||||
* where it is shared, local where the element differs.
|
||||
*/
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
|
||||
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
|
||||
dark:focus-visible:ring-offset-neutral-950;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
|
||||
}
|
||||
.nav-link {
|
||||
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
|
||||
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
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 { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
|
||||
|
||||
@@ -12,14 +13,10 @@ import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
const devices = useDevicesStore();
|
||||
const ui = useUiStore();
|
||||
// The Android build this server holds, if it holds one. Null on a server with no
|
||||
// APK — the card below is hidden rather than offering a download that 404s.
|
||||
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
|
||||
// component that fetches its own config would race the one that does.
|
||||
const config = useConfigStore();
|
||||
|
||||
function readableSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
const error = ref("");
|
||||
const newName = ref("");
|
||||
const creating = ref(false);
|
||||
@@ -160,35 +157,9 @@ onMounted(() => {
|
||||
</BaseButton>
|
||||
</section>
|
||||
|
||||
<!-- The Android client this server hands out (hidden when it has none) -->
|
||||
<section
|
||||
v-if="config.androidClient"
|
||||
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Android app</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
Version {{ config.androidClient.version }} ·
|
||||
{{ readableSize(config.androidClient.size) }} · served by this server, so it always
|
||||
speaks the same sync protocol.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A plain anchor, not BaseButton and not a fetch: this is 55 MB, and the
|
||||
browser's own download manager handles it better than anything this app
|
||||
would do with a blob. Styled to match BaseButton's primary variant,
|
||||
which is a <button> and cannot carry an href. -->
|
||||
<a
|
||||
:href="config.androidClient.url"
|
||||
:download="`thoughtsync-${config.androidClient.version}.apk`"
|
||||
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-brand px-4 py-2.5
|
||||
text-sm font-semibold text-neutral-900 shadow-sm transition hover:bg-brand-600
|
||||
active:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
|
||||
dark:focus-visible:ring-offset-neutral-950"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</section>
|
||||
<!-- Every client this server holds, the one that fits this machine on top.
|
||||
Hides itself when the server holds none. -->
|
||||
<ClientDownloads />
|
||||
|
||||
<!-- One-time token reveal -->
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
|
||||
import FilterBar from "../components/FilterBar.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
|
||||
import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge";
|
||||
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
@@ -191,7 +191,7 @@ const emptyState = computed(() => {
|
||||
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
||||
if (currentView.value === "archived")
|
||||
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
|
||||
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
|
||||
if (currentLabel.value) return { title: "No notes with this tag", subtitle: "Tag a note to see it here." };
|
||||
// Says what "no account" actually means for the notes about to be written here.
|
||||
// A new user otherwise has no way to tell whether this thing is storing their
|
||||
// thoughts locally, silently waiting for a login, or quietly sending them off.
|
||||
@@ -227,8 +227,21 @@ onMounted(() => {
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
// A note written in the quick-capture window lands in the same SQLite file but a
|
||||
// different Pinia store — this window has no way to know unless it is told.
|
||||
// Registered as a promise because the listener is set up asynchronously, and
|
||||
// unregistered on the way out so a board that has been navigated away from does
|
||||
// not keep reloading itself.
|
||||
let stopCaptureListener: (() => void) | null = null;
|
||||
onMounted(() => {
|
||||
void onCaptured(() => void reload()).then((stop) => {
|
||||
stopCaptureListener = stop;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onBoardKey);
|
||||
stopCaptureListener?.();
|
||||
ui.boardCardFocused = false;
|
||||
});
|
||||
watch([currentView, currentLabel, facetKey], reload);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// The quick-capture window: one field, and two ways out.
|
||||
//
|
||||
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
|
||||
// the person was doing. Everything here is shaped by that: no shell, no nav, no
|
||||
// board — a window that arrives uninvited has to be finishable in one gesture and
|
||||
// leave nothing behind if it isn't.
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { capture } from "../desktop/bridge";
|
||||
|
||||
const body = ref("");
|
||||
const field = ref<HTMLTextAreaElement | null>(null);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
// Focused on arrival, and after a save. The whole feature is "press the keys and
|
||||
// start typing" — a window that needs a click first has not saved anyone a step.
|
||||
await nextTick();
|
||||
field.value?.focus();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const content = body.value.trim();
|
||||
// Nothing typed is not an error, it is a change of mind — the same reading the
|
||||
// board takes of tapping + and walking away.
|
||||
if (!content) {
|
||||
void capture.done(false);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await repo.notes.create({ body: content });
|
||||
body.value = "";
|
||||
await capture.done(true);
|
||||
} catch {
|
||||
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
|
||||
// away the only copy of something the person just wrote, to report a problem
|
||||
// they could otherwise retry their way out of.
|
||||
error.value = "Couldn't save that. Your text is still here — try again.";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
|
||||
// a capture interrupted by something more urgent is still there on the next
|
||||
// press — which is the behaviour that makes it safe to press Escape.
|
||||
void capture.done(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
|
||||
>
|
||||
<textarea
|
||||
ref="field"
|
||||
v-model="body"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||
placeholder="Write it down…"
|
||||
aria-label="New note"
|
||||
@keydown.esc.prevent="dismiss"
|
||||
@keydown.enter.ctrl.prevent="save"
|
||||
@keydown.enter.meta.prevent="save"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- The shortcuts are written down rather than assumed: this window is seen
|
||||
rarely and briefly, and it is the only place they are discoverable. -->
|
||||
<p class="text-xs text-neutral-400">
|
||||
<kbd>Ctrl</kbd>/<kbd>⌘</kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
|
||||
</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,6 +88,18 @@ async function submit() {
|
||||
>Create one</RouterLink
|
||||
>
|
||||
</p>
|
||||
|
||||
<!-- The build, on the one screen a person can reach WITHOUT an account.
|
||||
"I can't sign in" is a bug report like any other and it needs a build
|
||||
number; requiring a login to read one would withhold it from exactly the
|
||||
people who cannot get past this page. `/api/config` is public, so this
|
||||
costs nothing that was not already public (#3181). -->
|
||||
<p
|
||||
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
|
||||
:title="`ThoughtSync server build ${config.version || 'unknown'}`"
|
||||
>
|
||||
{{ config.version || "unknown" }}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import {
|
||||
SUGGESTED_CAPTURE_SHORTCUT,
|
||||
capture as captureBridge,
|
||||
sync as syncBridge,
|
||||
updates as updateBridge,
|
||||
type CaptureShortcut,
|
||||
type Compatibility,
|
||||
type ProbeResult,
|
||||
type RevokeOutcome,
|
||||
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
|
||||
|
||||
const updateAvailable = computed(() => !!update.value?.available);
|
||||
|
||||
// --- 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
|
||||
// this installation on this machine.
|
||||
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
|
||||
const shortcutDraft = ref("");
|
||||
const savingShortcut = ref(false);
|
||||
const shortcutError = ref("");
|
||||
|
||||
async function saveShortcut(value: string) {
|
||||
savingShortcut.value = true;
|
||||
shortcutError.value = "";
|
||||
try {
|
||||
shortcut.value = await captureBridge.setShortcut(value);
|
||||
shortcutDraft.value = shortcut.value.shortcut;
|
||||
} catch (e) {
|
||||
// The message comes from the core and names the actual reason — "something
|
||||
// else is already using it" reads very differently from "that is not a
|
||||
// shortcut this system understands", and both are things you can act on.
|
||||
shortcutError.value = String((e as { message?: string }).message ?? e);
|
||||
} finally {
|
||||
savingShortcut.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
checking.value = true;
|
||||
updateError.value = "";
|
||||
@@ -126,6 +154,13 @@ async function refresh() {
|
||||
// An older build without the update commands — leave the default showing
|
||||
// rather than blocking the whole Sync screen on it.
|
||||
}
|
||||
try {
|
||||
shortcut.value = await captureBridge.shortcut();
|
||||
shortcutDraft.value = shortcut.value.shortcut;
|
||||
} catch {
|
||||
// Older build without the capture commands. Same reading as the channel
|
||||
// above — show the default rather than block the screen.
|
||||
}
|
||||
try {
|
||||
status.value = await syncBridge.status();
|
||||
pending.value = await syncBridge.hasPending();
|
||||
@@ -452,6 +487,65 @@ onMounted(refresh);
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
|
||||
updates: a hotkey that writes to the local store needs no server. -->
|
||||
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Quick capture</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
A system-wide shortcut that opens a small window to write a note in, without
|
||||
bringing this one forward.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex items-end gap-3">
|
||||
<BaseInput
|
||||
id="capture-shortcut"
|
||||
v-model="shortcutDraft"
|
||||
label="Shortcut"
|
||||
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
|
||||
class="flex-1"
|
||||
/>
|
||||
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
|
||||
<BaseButton
|
||||
v-if="shortcut.shortcut"
|
||||
variant="ghost"
|
||||
:loading="savingShortcut"
|
||||
@click="saveShortcut('')"
|
||||
>
|
||||
Turn off
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{ shortcutError }}
|
||||
</p>
|
||||
|
||||
<!-- Stored and LIVE are reported separately because they can disagree: a
|
||||
combination another app grabbed first is saved here and does nothing when
|
||||
pressed, and saying only "your shortcut is X" would be a lie with a
|
||||
keystroke attached. -->
|
||||
<p
|
||||
v-else-if="shortcut.shortcut && !shortcut.registered"
|
||||
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ shortcut.shortcut }} is saved but isn't active — something else on this
|
||||
system is holding it. Try a different combination.
|
||||
</p>
|
||||
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Press {{ shortcut.shortcut }} anywhere to capture a note.
|
||||
</p>
|
||||
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Off. There's no default on purpose — any combination picked for you is one
|
||||
taken away from something else on your machine.
|
||||
<button
|
||||
type="button"
|
||||
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
|
||||
>
|
||||
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
|
||||
has never touched a server still updates itself. -->
|
||||
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Collect every client this image should hand out, into one directory.
|
||||
#
|
||||
# fetch-clients.sh <dev|stable> <destdir>
|
||||
#
|
||||
# The server serves clients from `DATA_DIR/client/` or from the copy baked into the
|
||||
# image (`client_dist.py`). This is what fills the second one. It runs in CI, right
|
||||
# before `docker build`, and writes the FIXED filenames that module looks for.
|
||||
#
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients;
|
||||
# `:latest` serves stable ones. Passed in rather than derived here, because the
|
||||
# caller is the thing that knows which image it is building.
|
||||
#
|
||||
# NEVER FAILS. A platform with nothing published means the server advertises
|
||||
# nothing for it and the UI hides that download — a supported state, and the only
|
||||
# one available before a platform's first build has ever published. Turning eight
|
||||
# fetches into eight ways to redden an otherwise fine lane would be strictly worse
|
||||
# than shipping an image that offers four clients instead of five.
|
||||
#
|
||||
# WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run
|
||||
# `version.sh display desktop` here — this job has the checkout, after all. It is
|
||||
# wrong: this commit may not be the commit the channel is serving. A push touching
|
||||
# only `src/` does not rebuild the desktop, so the channel still holds an older
|
||||
# build, and a locally-derived version would describe those bytes with this
|
||||
# commit's number. The size check in `client_dist.py` would not catch it, because
|
||||
# the size IS measured from the real file — it would sail through and lie about the
|
||||
# version only. So the version comes from the channel, beside the bytes it
|
||||
# describes, and only `size`/`sha256` are measured here.
|
||||
set -eu
|
||||
|
||||
channel="${1:?usage: fetch-clients.sh <dev|stable> <destdir>}"
|
||||
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
|
||||
|
||||
case "$channel" in dev|stable) : ;; *)
|
||||
echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
|
||||
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
|
||||
BASE="$SERVER/$REPO/releases/download/$channel"
|
||||
|
||||
mkdir -p "$dest"
|
||||
|
||||
# Authenticated when we have a token — these releases are private (issue 2091), so
|
||||
# on this instance we always do. Anonymous still works against a public fork.
|
||||
fetch() {
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
|
||||
else
|
||||
curl -fsSL -o "$2" "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# One field out of a small flat JSON object. `grep`/`sed` rather than a parser
|
||||
# because this runs in the CI image's busybox sh and adding a jq dependency to buy
|
||||
# one string is not a trade worth making. The sidecars are written by us and are
|
||||
# one level deep.
|
||||
field() {
|
||||
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \
|
||||
| head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/'
|
||||
}
|
||||
|
||||
bytes() { wc -c < "$1" | tr -d ' '; }
|
||||
digest() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
|
||||
# The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from
|
||||
# the file that actually landed, so a truncated download cannot be described as a
|
||||
# whole one.
|
||||
sidecar() {
|
||||
_file="$1"; _out="$2"; _name="$3"; _code="$4"
|
||||
# `version_code` is QUOTED here, and that is not a slip. This function only ever
|
||||
# writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.<minutes>` — which
|
||||
# unquoted is not valid JSON at all, so every sidecar this wrote would fail to
|
||||
# parse and the server would advertise nothing. Android's sidecar is a different
|
||||
# file, copied verbatim from its lane, and keeps its integer.
|
||||
printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \
|
||||
"$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out"
|
||||
}
|
||||
|
||||
echo "==> Collecting the $channel clients"
|
||||
|
||||
# --- Android -----------------------------------------------------------------
|
||||
#
|
||||
# Its sidecar is published whole by the Android lane — an APK keeps its version in
|
||||
# a binary AXML manifest, so the values are recorded where they were already known.
|
||||
# Copied verbatim rather than rebuilt here.
|
||||
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
|
||||
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
|
||||
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would read a
|
||||
# sidecar describing an APK that is not there, or an APK it cannot state a
|
||||
# version for.
|
||||
echo "::warning::No Android client on the $channel channel — this image ships without one."
|
||||
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
|
||||
fi
|
||||
|
||||
# --- desktop -----------------------------------------------------------------
|
||||
#
|
||||
# One sidecar on the channel carries the version PAIR for all four bundles, because
|
||||
# they are one build: `version_name` is what a person reads, `version_code` is the
|
||||
# ordering key, and the key is also what the bundle filenames are stamped with.
|
||||
# Written by `write-manifest.sh`, which is the step that speaks for what the channel
|
||||
# serves.
|
||||
bake_desktop() {
|
||||
desk="$dest/.desktop-release.json"
|
||||
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
|
||||
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
|
||||
rm -f "$desk"
|
||||
return 0
|
||||
fi
|
||||
|
||||
name="$(field "$desk" version_name)"
|
||||
key="$(field "$desk" version_code)"
|
||||
rm -f "$desk"
|
||||
|
||||
if [ -z "$name" ] || [ -z "$key" ]; then
|
||||
echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients."
|
||||
return 0
|
||||
fi
|
||||
echo " desktop $name (key $key)"
|
||||
|
||||
# Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them,
|
||||
# and what `write-manifest.sh` already selects on. Constructed rather than
|
||||
# discovered from the release's asset list: one shape, no JSON walk, and a name
|
||||
# that does not resolve is caught by the fetch failing rather than by matching
|
||||
# the wrong file.
|
||||
#
|
||||
# `<platform id>|<published name>|<name on disk>`
|
||||
for row in \
|
||||
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
|
||||
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
|
||||
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
|
||||
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
|
||||
do
|
||||
id="${row%%|*}"; rest="${row#*|}"
|
||||
remote="${rest%%|*}"; local_name="${rest#*|}"
|
||||
|
||||
if ! fetch "$BASE/$remote" "$dest/$local_name"; then
|
||||
echo "::warning::$channel has no $remote — this image ships without the $id client."
|
||||
rm -f "$dest/$local_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
# The AppImage is the only bundle that replaces itself in place, so the updater
|
||||
# verifies a signature before it does. Without one it is not servable as an
|
||||
# update, and `client_dist.py` treats it as absent rather than offering it
|
||||
# unverifiable — so drop the bundle too rather than baking 95 MB nothing can use.
|
||||
if [ "$id" = "linux-appimage" ]; then
|
||||
if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then
|
||||
echo "::warning::$remote has no signature on $channel — dropping the AppImage."
|
||||
rm -f "$dest/$local_name" "$dest/$local_name.sig"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
|
||||
echo " $id $(bytes "$dest/$local_name") bytes"
|
||||
done
|
||||
}
|
||||
|
||||
bake_desktop
|
||||
|
||||
echo "==> Baked in:"
|
||||
ls -l "$dest"
|
||||
@@ -1,10 +1,16 @@
|
||||
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
|
||||
|
||||
# The FALLBACK version, used only when APP_VERSION is absent from the environment —
|
||||
# i.e. running from a checkout rather than from an image. A built image always has
|
||||
# it, derived from the server's own shipped file set (packaging/version.sh), so this
|
||||
# string never reaches a deployed instance and bumping it changes nothing a user
|
||||
# sees. Kept because a package needs a version and "unknown" is not a valid one for
|
||||
# packaging metadata; the honest "I cannot say" for a running server is APP_VERSION
|
||||
# being missing, which app.py already handles.
|
||||
# PACKAGING METADATA, and nothing else. Not the version any running server reports.
|
||||
#
|
||||
# A built image carries APP_VERSION in the environment, derived from the server's
|
||||
# own shipped file set (packaging/version.sh); `app.py` reads that and reports an
|
||||
# explicit "unknown" when it is absent, so this string never reaches a user and
|
||||
# bumping it changes nothing anybody sees.
|
||||
#
|
||||
# It exists because a Python package needs a version and "unknown" is not a legal
|
||||
# one here. It used to double as app.py's fallback, which meant a server run from a
|
||||
# checkout confidently reported `0.2.0` — a real-looking version naming no build
|
||||
# that exists. Note 3127 §5 is why that matters more than it reads: with version
|
||||
# tags gone, a build's self-report is the only answer to "which build is this?",
|
||||
# and there is nothing left to catch it lying.
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+18
-5
@@ -11,7 +11,6 @@ from datetime import timedelta
|
||||
from quart import Quart, jsonify, send_from_directory
|
||||
from quart.sessions import SecureCookieSessionInterface
|
||||
|
||||
from . import __version__
|
||||
from .auth import bp as auth_bp
|
||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||
from .config import Config
|
||||
@@ -64,7 +63,20 @@ def create_app() -> Quart:
|
||||
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
||||
# database. before_serving swaps in the real, DB-persisted key before serving.
|
||||
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
||||
# The RUNNING build, or an explicit "unknown" — never the packaging fallback.
|
||||
#
|
||||
# This read `os.environ.get("APP_VERSION", __version__)`, so a server started
|
||||
# from a checkout reported `0.2.0`: a real-looking version that names no build
|
||||
# anybody could get. `__init__.py` already claimed the honest answer was
|
||||
# "APP_VERSION being missing, which app.py already handles" — it did not, and a
|
||||
# comment asserting a behaviour two files away from the code is how that stayed
|
||||
# true-sounding for months.
|
||||
#
|
||||
# It matters more than it used to. Note 3127 §5 removed version tags, so this
|
||||
# string is the only answer to "which build is this?" and nothing exists to
|
||||
# contradict it when it is wrong. `__version__` stays where it belongs, as
|
||||
# packaging metadata, which is the one place "unknown" is not a legal value.
|
||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown"
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
# Auto-mark the session cookie Secure on HTTPS requests (see the interface above).
|
||||
@@ -192,9 +204,10 @@ def create_app() -> Quart:
|
||||
# linking — while it still has no token and possibly no account — to decide
|
||||
# whether it can talk to this server, and which optional features to offer.
|
||||
data.update(protocol_advertisement())
|
||||
# Which Android client this server can hand out, if any. Absent rather than
|
||||
# null when it has none, so the web UI hides the download instead of
|
||||
# offering a button that 404s.
|
||||
# Which CLIENTS this server can hand out, if any — the whole set under
|
||||
# `clients`, plus the older `android_client` key that phones in the field
|
||||
# still read. Absent rather than null when it has none, so the web UI hides
|
||||
# a download instead of offering a button that 404s.
|
||||
data.update(client_advertisement())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
+283
-71
@@ -1,4 +1,4 @@
|
||||
"""The server hands out the Android client it is in step with.
|
||||
"""The server hands out the clients it is in step with.
|
||||
|
||||
## Why the server, and not a release page
|
||||
|
||||
@@ -11,43 +11,69 @@ It also keeps the two in step by construction. Client and server already
|
||||
negotiate a sync protocol version before linking, so a server that also serves
|
||||
the client cannot hand out a phone it is unable to talk to.
|
||||
|
||||
## Where the file comes from
|
||||
That argument was never Android-specific, which is why this module now serves a
|
||||
TABLE of platforms rather than the one it was written for.
|
||||
|
||||
Two places, checked in that order:
|
||||
## Where the files come from
|
||||
|
||||
Two places, checked in that order, per platform:
|
||||
|
||||
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
|
||||
operator who wants a SPECIFIC build drops it there and it wins.
|
||||
2. the copy baked into the image at build time — CI fetches the newest published
|
||||
Android build into every image, so `:dev`, `:latest` and `:<version>` all
|
||||
carry a client and `docker compose pull` delivers a new one with nothing
|
||||
copied by hand.
|
||||
build of each client into every image, so `:dev` and `:latest` both carry a
|
||||
full set and `docker compose pull` delivers new ones with nothing copied by
|
||||
hand.
|
||||
|
||||
The precedence is the point: the image is the default, and a person who wants to
|
||||
override it should not have to fight it. The baked copy sits inside the package
|
||||
rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the
|
||||
image wrote there would be hidden the moment one is attached.
|
||||
|
||||
**Precedence is decided per platform, not for the set.** A drop-in `.deb` does
|
||||
not shadow the baked APK. The alternative — first directory holding anything
|
||||
wins — would mean replacing one client silently retracts the other four.
|
||||
|
||||
## What a platform needs on disk
|
||||
|
||||
Two files, and both must be present:
|
||||
|
||||
- `thoughtsync.apk` — the client
|
||||
- `thoughtsync-android.json` — `{version_name, version_code, size, sha256}`
|
||||
- the artifact, at a FIXED name (`thoughtsync.deb`, not
|
||||
`ThoughtSync_2026.08.30.0307_amd64.deb`)
|
||||
- its sidecar, `{version_name, version_code, size, sha256}`
|
||||
|
||||
The sidecar exists because an APK's version lives in a binary AXML manifest that
|
||||
Python cannot read without the Android build tools. CI writes it beside the APK
|
||||
at publish time, where the real values are already known.
|
||||
**Fixed names, and the version only in the sidecar.** A version-stamped filename
|
||||
would force this module to glob, and a glob over a directory an operator can drop
|
||||
files into is how you serve the older of two builds — `write-manifest.sh` carries
|
||||
a comment about exactly that, from the time it advertised a new version while
|
||||
pointing at an old binary.
|
||||
|
||||
The sidecar exists because a version is not reliably readable from the artifact:
|
||||
an APK keeps it in a binary AXML manifest Python cannot parse without the Android
|
||||
build tools, and a `.deb` or an AppImage would each need a different unpacker. CI
|
||||
writes the sidecar where the real value is already known.
|
||||
|
||||
The AppImage needs a THIRD file, `thoughtsync.AppImage.sig`. That is the minisign
|
||||
signature the desktop updater verifies before replacing the running binary, and a
|
||||
bundle that cannot be verified cannot be offered as an update — so a missing
|
||||
signature makes the AppImage absent rather than merely unsigned.
|
||||
|
||||
## Absence is normal
|
||||
|
||||
A server with no APK advertises nothing, and the web UI hides the download
|
||||
rather than offering a button that 404s. Same for a mismatched pair: if the
|
||||
sidecar's recorded size does not match the file on disk, the two did not arrive
|
||||
together and the server says it has nothing rather than serving one build while
|
||||
describing another.
|
||||
A server with no client for a platform advertises none, and the web UI hides that
|
||||
download rather than offering a button that 404s. Same for a mismatched pair: if
|
||||
the sidecar's recorded size does not match the file on disk, the two did not
|
||||
arrive together, and the server says it has nothing rather than serving one build
|
||||
while describing another.
|
||||
|
||||
This is the ONLY state available before a platform's first build has ever
|
||||
published, so it is an ordinary answer and never an error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, send_from_directory
|
||||
@@ -55,10 +81,106 @@ from quart import Blueprint, jsonify, send_from_directory
|
||||
from .auth import login_required
|
||||
from .config import Config
|
||||
|
||||
APK_NAME = "thoughtsync.apk"
|
||||
MANIFEST_NAME = "thoughtsync-android.json"
|
||||
DOWNLOAD_PATH = "/api/client/android/download"
|
||||
APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Platform:
|
||||
"""One installable client, and where its files sit."""
|
||||
|
||||
id: str
|
||||
# What a person calls it. Named for the DISTRO rather than the package format
|
||||
# ("Debian / Ubuntu", not ".deb") — someone knows which system they run and
|
||||
# does not necessarily know which packaging it uses.
|
||||
label: str
|
||||
artifact: str
|
||||
sidecar: str
|
||||
mimetype: str
|
||||
# An updater-verifiable bundle: `<artifact>.sig` must be present too, and its
|
||||
# contents are published with the metadata. Only the AppImage, because it is
|
||||
# the only bundle that can replace itself in place — a package-manager install
|
||||
# cannot, by design (see the desktop's update.rs).
|
||||
signed: bool = False
|
||||
# Whether this platform's ordering key is an INTEGER.
|
||||
#
|
||||
# `version_code` is "whatever this platform's comparator reads", and that is not
|
||||
# one type. Android's is an int because Android's own install gate compares one,
|
||||
# and it must stay a JSON number — `ClientRelease` in core/src/sync/client.rs
|
||||
# declares it `i64` and a string would fail to deserialize on every phone in the
|
||||
# field. The desktop's is Tauri's semver key, `1.0.<minutes>`, which is the value
|
||||
# its updater compares and is not an integer at all.
|
||||
#
|
||||
# Coercing everything to int was inherited from the days when Android was the
|
||||
# only platform, and would have rejected every desktop sidecar written.
|
||||
code_is_int: bool = True
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
return f"{self.artifact}.sig"
|
||||
|
||||
@property
|
||||
def download_path(self) -> str:
|
||||
return f"/api/client/{self.id}/download"
|
||||
|
||||
|
||||
# ONE definition of what this server can hand out. Every route, the /api/config
|
||||
# advertisement and the tests all read this table; adding a platform is adding a
|
||||
# row.
|
||||
PLATFORMS: tuple[Platform, ...] = (
|
||||
Platform(
|
||||
id="android",
|
||||
label="Android",
|
||||
# UNCHANGED, and it must stay unchanged: the Android lane publishes these
|
||||
# exact names, CI bakes them in under them, and clients in the field poll
|
||||
# `/api/client/android`. Renaming them to match the pattern below would
|
||||
# buy tidiness and strand every installed phone.
|
||||
artifact="thoughtsync.apk",
|
||||
sidecar="thoughtsync-android.json",
|
||||
mimetype="application/vnd.android.package-archive",
|
||||
),
|
||||
Platform(
|
||||
id="linux-deb",
|
||||
label="Debian / Ubuntu",
|
||||
artifact="thoughtsync.deb",
|
||||
sidecar="thoughtsync-linux-deb.json",
|
||||
mimetype="application/vnd.debian.binary-package",
|
||||
code_is_int=False,
|
||||
),
|
||||
Platform(
|
||||
id="linux-pacman",
|
||||
label="Arch / CachyOS",
|
||||
artifact="thoughtsync.pkg.tar.zst",
|
||||
sidecar="thoughtsync-linux-pacman.json",
|
||||
mimetype="application/zstd",
|
||||
code_is_int=False,
|
||||
),
|
||||
Platform(
|
||||
id="linux-appimage",
|
||||
label="Other Linux (AppImage)",
|
||||
artifact="thoughtsync.AppImage",
|
||||
sidecar="thoughtsync-linux-appimage.json",
|
||||
# No registered type for an AppImage, and guessing one buys nothing: it is
|
||||
# served as an attachment either way, and octet-stream is the answer that
|
||||
# cannot be wrong.
|
||||
mimetype="application/octet-stream",
|
||||
signed=True,
|
||||
code_is_int=False,
|
||||
),
|
||||
Platform(
|
||||
id="windows",
|
||||
label="Windows",
|
||||
artifact="thoughtsync-setup.exe",
|
||||
sidecar="thoughtsync-windows.json",
|
||||
mimetype="application/vnd.microsoft.portable-executable",
|
||||
code_is_int=False,
|
||||
),
|
||||
)
|
||||
|
||||
BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS}
|
||||
|
||||
# The Android names, still importable under their old spellings because docs and
|
||||
# the CI lane refer to them. Derived from the table rather than restated, so the
|
||||
# two cannot drift.
|
||||
APK_NAME = BY_ID["android"].artifact
|
||||
MANIFEST_NAME = BY_ID["android"].sidecar
|
||||
|
||||
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
|
||||
# is a volume mount, and a file the image wrote there would vanish behind it.
|
||||
@@ -67,103 +189,193 @@ BAKED_ROOT = Path(__file__).resolve().parent / "client"
|
||||
bp = Blueprint("client_dist", __name__)
|
||||
|
||||
|
||||
def _read(root: Path) -> dict | None:
|
||||
"""The build in one directory, or None.
|
||||
def _read(root: Path, platform: Platform) -> dict | None:
|
||||
"""One platform's build in one directory, or None.
|
||||
|
||||
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
|
||||
sidecar that describes a different file are all the same answer to the only
|
||||
question being asked — "is there a client here I can honestly offer?" — and
|
||||
that answer is no.
|
||||
Never raises. A missing directory, an unreadable sidecar, malformed JSON, a
|
||||
sidecar that describes a different file and — for a signed bundle — a missing
|
||||
signature are all the same answer to the only question being asked, "is there
|
||||
a client here I can honestly offer?", and that answer is no.
|
||||
"""
|
||||
try:
|
||||
size = (root / APK_NAME).stat().st_size
|
||||
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
size = (root / platform.artifact).stat().st_size
|
||||
meta = json.loads((root / platform.sidecar).read_text(encoding="utf-8"))
|
||||
version = str(meta["version_name"])
|
||||
code = int(meta["version_code"])
|
||||
# See `code_is_int`. Android's must parse as an integer; the desktop's is
|
||||
# Tauri's semver key and is carried through as written.
|
||||
code = int(meta["version_code"]) if platform.code_is_int else str(meta["version_code"])
|
||||
recorded = int(meta["size"])
|
||||
digest = str(meta["sha256"])
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
if not str(code).strip() or not version.strip():
|
||||
# A sidecar can be well-formed and still say nothing. An empty version is
|
||||
# not a version, and it would render as a blank on the download card.
|
||||
return None
|
||||
|
||||
# The pair has to describe one build. A sidecar left behind by a previous
|
||||
# release would otherwise advertise a version this server cannot serve, and the
|
||||
# phone would download something other than what it was promised.
|
||||
# client would download something other than what it was promised.
|
||||
if recorded != size:
|
||||
return None
|
||||
|
||||
return {
|
||||
release = {
|
||||
"platform": platform.id,
|
||||
"label": platform.label,
|
||||
"version": version,
|
||||
# What Android actually compares. `version` is for people; a name is a
|
||||
# string and sorts like one, which is not how "is this newer" works.
|
||||
# What a comparator reads. `version` is for people; a name is a string and
|
||||
# sorts like one, which is not how "is this newer" works.
|
||||
"version_code": code,
|
||||
"size": size,
|
||||
# Computed by CI over the same bytes it uploaded, so a client can tell a
|
||||
# truncated download from a complete one BEFORE handing it to the
|
||||
# installer. Not a trust anchor — the signature is that.
|
||||
# truncated download from a complete one BEFORE handing it to an installer.
|
||||
# Not a trust anchor — the signature is that.
|
||||
"sha256": digest,
|
||||
"url": DOWNLOAD_PATH,
|
||||
# A PATH, never an absolute URL: the client joins it to the base it is
|
||||
# already linked to, so a compromised or misconfigured server cannot
|
||||
# redirect the download somewhere else. `core/src/sync/client.rs` relies on
|
||||
# this and says so.
|
||||
"url": platform.download_path,
|
||||
}
|
||||
|
||||
if platform.signed:
|
||||
# The signature travels WITH the metadata rather than behind its own route.
|
||||
# It is ~100 bytes, it is public wherever these bundles are published, and
|
||||
# the updater needs the version and the signature in the same breath — one
|
||||
# request that cannot return a signature belonging to a different build.
|
||||
try:
|
||||
sig = (root / platform.signature).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
if not sig:
|
||||
return None
|
||||
release["signature"] = sig
|
||||
|
||||
def _resolve() -> tuple[Path, dict] | None:
|
||||
"""Which directory this server serves from, and what is in it.
|
||||
return release
|
||||
|
||||
|
||||
def _resolve(platform: Platform) -> tuple[Path, dict] | None:
|
||||
"""Which directory this server serves a platform from, and what is in it.
|
||||
|
||||
The operator's drop-in beats the baked copy — someone who deliberately put a
|
||||
build on the volume wants that build, not whatever the image happened to ship
|
||||
with. A directory holding a broken or half-copied pair does NOT shadow the
|
||||
image: it simply is not a client, so the search moves on.
|
||||
build on the volume wants that build, older or not. A directory holding a
|
||||
broken or half-copied pair does NOT shadow the image: it simply is not a
|
||||
client, so the search moves on.
|
||||
"""
|
||||
for root in (Path(Config.client_root()), BAKED_ROOT):
|
||||
release = _read(root)
|
||||
# Both wrapped in Path(), not just the first. The asymmetry was arbitrary and it
|
||||
# fails quietly: `_read` catches the TypeError a str `/` str raises and reports
|
||||
# "no client here", so a directory that is perfectly fine reads as empty.
|
||||
for root in (Path(Config.client_root()), Path(BAKED_ROOT)):
|
||||
release = _read(root, platform)
|
||||
if release is not None:
|
||||
return root, release
|
||||
return None
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None if it holds none."""
|
||||
resolved = _resolve()
|
||||
def release(platform_id: str) -> dict | None:
|
||||
"""What build of one client this server holds, or None if it holds none."""
|
||||
platform = BY_ID.get(platform_id)
|
||||
if platform is None:
|
||||
return None
|
||||
resolved = _resolve(platform)
|
||||
return resolved[1] if resolved else None
|
||||
|
||||
|
||||
def advertisement() -> dict:
|
||||
"""The `/api/config` fragment describing this server's Android client.
|
||||
def releases() -> dict[str, dict]:
|
||||
"""Every client this server can hand out, keyed by platform id.
|
||||
|
||||
An empty dict when there is none, so the key is ABSENT rather than null — a
|
||||
client testing for the key gets one unambiguous answer instead of having to
|
||||
distinguish "no client" from "old server that never had this field".
|
||||
Platforms it holds nothing for are ABSENT rather than present-and-null, so a
|
||||
caller can test for the key instead of distinguishing "no build" from "a
|
||||
server that never had this platform".
|
||||
"""
|
||||
release = android_release()
|
||||
return {"android_client": release} if release else {}
|
||||
found = {p.id: _resolve(p) for p in PLATFORMS}
|
||||
return {pid: r[1] for pid, r in found.items() if r is not None}
|
||||
|
||||
|
||||
@bp.get("/api/client/android")
|
||||
async def android_metadata():
|
||||
"""Version and digest without the 55 MiB. What an updater polls."""
|
||||
release = android_release()
|
||||
if release is None:
|
||||
return jsonify({"error": "this server has no Android client"}), 404
|
||||
return jsonify(release)
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None.
|
||||
|
||||
Kept as its own name because the back-compatible `/api/config` key below is
|
||||
about Android specifically, and because saying so reads better than
|
||||
`release("android")` at the two call sites that mean the phone.
|
||||
"""
|
||||
return release("android")
|
||||
|
||||
|
||||
@bp.get(DOWNLOAD_PATH)
|
||||
def advertisement() -> dict:
|
||||
"""The `/api/config` fragment describing this server's clients.
|
||||
|
||||
Two keys, deliberately, and the older one is not deprecated here:
|
||||
|
||||
`clients` is the whole table, which is what the web UI renders the downloads
|
||||
section from.
|
||||
|
||||
`android_client` is what phones in the field already read. It costs one
|
||||
duplicated dict to not strand every installed Android client, and retiring it
|
||||
is a later decision made when nothing polls it — not a tidy-up done in the
|
||||
change that introduces its replacement.
|
||||
|
||||
Both are ABSENT rather than null when empty, so a client testing for a key gets
|
||||
one unambiguous answer instead of having to distinguish "no client" from "an
|
||||
old server that never had this field".
|
||||
"""
|
||||
data: dict = {}
|
||||
found = releases()
|
||||
if found:
|
||||
data["clients"] = found
|
||||
if "android" in found:
|
||||
data["android_client"] = found["android"]
|
||||
return data
|
||||
|
||||
|
||||
@bp.get("/api/client")
|
||||
async def client_index():
|
||||
"""Everything this server holds, in one request.
|
||||
|
||||
The downloads UI needs all five to decide what to lead with, and five requests
|
||||
to answer one question is five chances to render half a page.
|
||||
"""
|
||||
return jsonify({"clients": releases()})
|
||||
|
||||
|
||||
@bp.get("/api/client/<platform_id>")
|
||||
async def client_metadata(platform_id: str):
|
||||
"""Version and digest without the payload. What an updater polls.
|
||||
|
||||
Public, because a client has to be able to ask "is there something newer?"
|
||||
cheaply — before it has a token, in the case of a first pairing.
|
||||
|
||||
An unknown platform and a platform with no build both 404. They are the same
|
||||
answer to the caller ("not here"), and distinguishing them would only tell an
|
||||
unauthenticated stranger which platforms this build of the server knows about.
|
||||
"""
|
||||
found = release(platform_id)
|
||||
if found is None:
|
||||
return jsonify({"error": f"this server has no {platform_id} client"}), 404
|
||||
return jsonify(found)
|
||||
|
||||
|
||||
@bp.get("/api/client/<platform_id>/download")
|
||||
@login_required
|
||||
async def android_download():
|
||||
"""The APK itself.
|
||||
async def client_download(platform_id: str):
|
||||
"""The bytes themselves.
|
||||
|
||||
Authenticated — by session cookie from a browser, or by device bearer token
|
||||
from a client updating itself; `login_required` accepts either. The metadata
|
||||
above is public because a client has to be able to ask "is there something
|
||||
newer?" cheaply, but the bytes are not for anyone who can reach the port.
|
||||
above is public; the bytes are not for anyone who can reach the port.
|
||||
"""
|
||||
resolved = _resolve()
|
||||
platform = BY_ID.get(platform_id)
|
||||
if platform is None:
|
||||
return jsonify({"error": f"unknown client platform '{platform_id}'"}), 404
|
||||
resolved = _resolve(platform)
|
||||
if resolved is None:
|
||||
return jsonify({"error": "this server has no Android client"}), 404
|
||||
# From the SAME directory the advertisement came from, or a drop-in appearing
|
||||
# between the two calls would serve bytes the metadata does not describe.
|
||||
return jsonify({"error": f"this server has no {platform_id} client"}), 404
|
||||
# From the SAME directory the metadata came from, or a drop-in appearing between
|
||||
# the two calls would serve bytes the metadata does not describe.
|
||||
root, _ = resolved
|
||||
response = await send_from_directory(root, APK_NAME, mimetype=APK_MIMETYPE)
|
||||
response = await send_from_directory(root, platform.artifact, mimetype=platform.mimetype)
|
||||
# Without this some browsers try to render it, and Android's download handler
|
||||
# wants a filename to hand to the package installer.
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="{platform.artifact}"'
|
||||
return response
|
||||
|
||||
@@ -35,12 +35,15 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def client_root(cls) -> Path:
|
||||
"""Where the Android APK this server hands out lives.
|
||||
"""Where an operator DROPS IN clients for this server to hand out.
|
||||
|
||||
Under DATA_DIR rather than baked into the image: the APK is ~55 MiB and an
|
||||
install that never touches Android should not carry it. Being on the same
|
||||
mounted volume as uploads also means an operator drops a build there once
|
||||
and container recreation does not lose it. See client_dist.py.
|
||||
One directory for every platform; `client_dist.py` picks files out of it by
|
||||
name. Under DATA_DIR because it is a mounted volume: a build placed here
|
||||
survives container recreation, and it beats the copy baked into the image,
|
||||
which is the whole point of the directory existing.
|
||||
|
||||
Empty is the ordinary case — the image ships its own set and most operators
|
||||
never touch this.
|
||||
"""
|
||||
return Path(cls.DATA_DIR) / "client"
|
||||
|
||||
|
||||
+63
-24
@@ -27,6 +27,36 @@ async def _label_note_count(db, label_id) -> int:
|
||||
)
|
||||
|
||||
|
||||
async def _merge_into(db, source: Label, target: Label) -> None:
|
||||
"""Move every note tagged with `source` onto `target`, then delete `source`.
|
||||
|
||||
Shared by the explicit `/merge` route and by a rename that lands on a name
|
||||
another tag already holds — those are the same operation, and having one body
|
||||
is what stops them drifting into two answers for one question.
|
||||
|
||||
Note-body `#tags` are NOT rewritten, so a note whose body still literally
|
||||
contains the source `#tag` will re-mint that tag on its next edit. Retiring a
|
||||
tag means editing it out of the text; a known, documented nuance.
|
||||
"""
|
||||
# Notes already carrying the target: a note can't hold the same label twice
|
||||
# (composite PK), so the source attachment there is just dropped as a dup.
|
||||
target_notes = set(
|
||||
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
|
||||
)
|
||||
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
|
||||
by_note = {r.note_id: r.via_tag for r in source_rows}
|
||||
# Delete the source attachments first, then re-insert under the target — moving
|
||||
# by delete+insert avoids mutating a composite primary-key column in place.
|
||||
for row in source_rows:
|
||||
await db.delete(row)
|
||||
await db.flush()
|
||||
for note_id, via_tag in by_note.items():
|
||||
if note_id not in target_notes:
|
||||
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
|
||||
await db.delete(source)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _get_owned_label(db, label_id: str) -> Label | None:
|
||||
lid = parse_uuid(label_id)
|
||||
if lid is None:
|
||||
@@ -58,10 +88,15 @@ async def create_label():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return json_error("label name is required", 400)
|
||||
return json_error("tag name is required", 400)
|
||||
async with session_scope() as db:
|
||||
# Idempotent: creating an existing label just returns it.
|
||||
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
|
||||
# Idempotent: creating an existing tag just returns it. Case-INSENSITIVE,
|
||||
# like the clients' `find_or_create_label` — a case-sensitive match here was
|
||||
# the path that MINTED the "Groceries" beside "groceries" pair that no synced
|
||||
# client can hold, since their `labels` index is unique on `lower(name)`.
|
||||
existing = await db.scalar(
|
||||
select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower())
|
||||
)
|
||||
if existing is not None:
|
||||
return jsonify(_serialize_label(existing)), 200
|
||||
label = Label(owner_id=g.user_id, name=name, color=normalize_color(data.get("color")))
|
||||
@@ -81,17 +116,37 @@ async def update_label(label_id: str):
|
||||
return json_error("nothing to update", 400)
|
||||
name = (data.get("name") or "").strip() if has_name else None
|
||||
if has_name and not name:
|
||||
return json_error("label name is required", 400)
|
||||
return json_error("tag name is required", 400)
|
||||
async with session_scope() as db:
|
||||
label = await _get_owned_label(db, label_id)
|
||||
if label is None:
|
||||
return not_found()
|
||||
if has_name:
|
||||
# Case-INSENSITIVE, matching the clients' `find_or_create_label` and the
|
||||
# local store's `lower(name)` unique index. The Postgres constraint here
|
||||
# is on the raw name, so the database would happily hold "Groceries"
|
||||
# beside "groceries" — but no synced client can store both, so letting
|
||||
# one be made is letting a pull fail later on a phone.
|
||||
clash = await db.scalar(
|
||||
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
|
||||
select(Label).where(
|
||||
Label.owner_id == g.user_id,
|
||||
func.lower(Label.name) == name.lower(),
|
||||
Label.id != label.id,
|
||||
)
|
||||
)
|
||||
if clash is not None:
|
||||
return json_error("a label with that name already exists", 409)
|
||||
# Renaming onto an existing tag MERGES the two rather than failing:
|
||||
# typing an existing tag's name onto this one says they are the same
|
||||
# thing. The OLDER row survives and takes the new spelling — age is
|
||||
# the one property that does not depend on which of the two the
|
||||
# caller happened to be renaming, so A→B and B→A agree. Ties go to
|
||||
# the incumbent. Mirrors `store::rename_label` exactly.
|
||||
if clash.created_at <= label.created_at:
|
||||
survivor, doomed = clash, label
|
||||
else:
|
||||
survivor, doomed = label, clash
|
||||
await _merge_into(db, doomed, survivor)
|
||||
label = survivor
|
||||
label.name = name
|
||||
if has_color:
|
||||
label.color = normalize_color(data.get("color"))
|
||||
@@ -128,24 +183,8 @@ async def merge_label(label_id: str):
|
||||
if source is None or target is None:
|
||||
return not_found()
|
||||
if source.id == target.id:
|
||||
return json_error("cannot merge a label into itself", 400)
|
||||
# Notes already carrying the target: a note can't hold the same label twice
|
||||
# (composite PK), so the source attachment there is just dropped as a dup.
|
||||
target_notes = set(
|
||||
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
|
||||
)
|
||||
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
|
||||
by_note = {r.note_id: r.via_tag for r in source_rows}
|
||||
# Delete the source attachments first, then re-insert under the target — moving
|
||||
# by delete+insert avoids mutating a composite primary-key column in place.
|
||||
for row in source_rows:
|
||||
await db.delete(row)
|
||||
await db.flush()
|
||||
for note_id, via_tag in by_note.items():
|
||||
if note_id not in target_notes:
|
||||
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
|
||||
await db.delete(source)
|
||||
await db.flush()
|
||||
return json_error("cannot merge a tag into itself", 400)
|
||||
await _merge_into(db, source, target)
|
||||
count = await _label_note_count(db, target.id)
|
||||
await db.commit()
|
||||
return jsonify(_serialize_label(target, count))
|
||||
|
||||
@@ -27,3 +27,50 @@ async def test_unknown_api_route_404s(app):
|
||||
client = app.test_client()
|
||||
resp = await client.get("/api/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- the version a running server reports ------------------------------------
|
||||
#
|
||||
# Note 3127 §5 removed version tags, so this string is the only answer to "which
|
||||
# build is this?" and nothing exists to contradict it when it is wrong. That makes
|
||||
# the FALLBACK the interesting case rather than the happy path: it used to be
|
||||
# `__version__`, so a server run from a checkout reported `0.2.0` — a real-looking
|
||||
# version naming no build anybody could obtain.
|
||||
|
||||
|
||||
async def reported_version() -> str:
|
||||
"""What a freshly built app tells /api/health it is.
|
||||
|
||||
Built per call rather than through the `app` fixture: the value is read from the
|
||||
environment in `create_app`, so an app constructed before `monkeypatch` ran would
|
||||
answer about the wrong environment.
|
||||
"""
|
||||
client = create_app().test_client()
|
||||
return (await (await client.get("/api/health")).get_json())["version"]
|
||||
|
||||
|
||||
async def test_the_version_is_whatever_the_environment_says(monkeypatch):
|
||||
monkeypatch.setenv("APP_VERSION", "2026.08.29.0443")
|
||||
assert await reported_version() == "2026.08.29.0443"
|
||||
|
||||
|
||||
async def test_no_version_in_the_environment_reports_unknown(monkeypatch):
|
||||
"""The honest "I cannot say", not a plausible default.
|
||||
|
||||
Also asserted against `__version__` by name rather than against the literal it
|
||||
happens to hold, so bumping the packaging version cannot make this pass for the
|
||||
wrong reason.
|
||||
"""
|
||||
from thoughtsync import __version__
|
||||
|
||||
monkeypatch.delenv("APP_VERSION", raising=False)
|
||||
reported = await reported_version()
|
||||
assert reported == "unknown"
|
||||
assert reported != __version__
|
||||
|
||||
|
||||
async def test_an_empty_version_reports_unknown_too(monkeypatch):
|
||||
"""`APP_VERSION=` is what a mis-set build arg looks like, and an empty string
|
||||
renders as a blank space rather than as a missing value."""
|
||||
monkeypatch.setenv("APP_VERSION", "")
|
||||
assert await reported_version() == "unknown"
|
||||
|
||||
+321
-69
@@ -5,7 +5,16 @@ import pytest
|
||||
|
||||
from thoughtsync import client_dist
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
|
||||
from thoughtsync.client_dist import (
|
||||
APK_NAME,
|
||||
BY_ID,
|
||||
MANIFEST_NAME,
|
||||
PLATFORMS,
|
||||
advertisement,
|
||||
android_release,
|
||||
release,
|
||||
releases,
|
||||
)
|
||||
from thoughtsync.config import Config
|
||||
|
||||
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is
|
||||
@@ -13,11 +22,39 @@ from thoughtsync.config import Config
|
||||
# `/api/config`: the route is a one-line merge of this dict into a payload whose
|
||||
# other half needs a database, and testing it here tests the part that can be wrong.
|
||||
#
|
||||
# The two routes below ARE exercised, because neither opens a session: the metadata
|
||||
# route only stats files, and the download's 401 is returned before any token
|
||||
# lookup.
|
||||
# The routes below ARE exercised, because none opens a session: the metadata route
|
||||
# only stats files, and the download's 401 is returned before any token lookup.
|
||||
|
||||
PAYLOAD = b"not really an apk, but the server only ever stats it"
|
||||
PAYLOAD = b"not really a client, but the server only ever stats it"
|
||||
|
||||
# Every test that is about the MECHANISM rather than about one platform runs
|
||||
# against all of them. The bugs this module can have — a sidecar describing a
|
||||
# different build, a half-finished copy shadowing a good one — are not
|
||||
# platform-specific, and a suite that only ever exercised Android is how the other
|
||||
# four would ship untested.
|
||||
ALL_IDS = [p.id for p in PLATFORMS]
|
||||
|
||||
# `version_code` is "whatever this platform's comparator reads", and that is not one
|
||||
# type. Android's install gate compares an integer; the desktop's updater compares
|
||||
# Tauri's semver key. The tests carry both shapes for the same reason the module
|
||||
# does — a suite that only ever wrote integers would pass while every desktop
|
||||
# sidecar CI writes was being rejected.
|
||||
ANDROID_CODE = 3503708
|
||||
DESKTOP_CODE = "1.0.3503707"
|
||||
|
||||
|
||||
def code_for(platform_id: str):
|
||||
return ANDROID_CODE if BY_ID[platform_id].code_is_int else DESKTOP_CODE
|
||||
|
||||
|
||||
def coded(platform_id: str, value: int):
|
||||
"""`value` in the shape that platform's sidecar carries.
|
||||
|
||||
A test writing `version_code=300` gets `300` back from Android and `"300"` from
|
||||
a desktop platform, because the module preserves each platform's own comparator
|
||||
type rather than flattening both to int.
|
||||
"""
|
||||
return value if BY_ID[platform_id].code_is_int else str(value)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -26,7 +63,7 @@ def _empty_baked_client(tmp_path, monkeypatch):
|
||||
|
||||
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
|
||||
would pass anyway — but only by accident of where they are run. A built image
|
||||
has a real APK there, and a test that silently depends on which tree it is in
|
||||
has real clients there, and a test that silently depends on which tree it is in
|
||||
is one that will eventually lie.
|
||||
"""
|
||||
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
|
||||
@@ -38,109 +75,251 @@ def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict:
|
||||
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
|
||||
root = root if root is not None else Config.client_root()
|
||||
def place(
|
||||
platform_id: str = "android",
|
||||
payload: bytes = PAYLOAD,
|
||||
root: Path | None = None,
|
||||
signature: str | None = "a signature",
|
||||
**overrides,
|
||||
) -> dict:
|
||||
"""Put one platform's client + sidecar where the server looks.
|
||||
|
||||
Overrides corrupt the pair. `signature=None` withholds the `.sig` a signed
|
||||
bundle needs, which is its own failure mode rather than a variant of the others.
|
||||
"""
|
||||
platform = BY_ID[platform_id]
|
||||
root = root if root is not None else Path(Config.client_root())
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / APK_NAME).write_bytes(payload)
|
||||
(root / platform.artifact).write_bytes(payload)
|
||||
meta = {
|
||||
"version_name": "0.1.216",
|
||||
"version_code": 216,
|
||||
"version_name": "2026.08.30.0307",
|
||||
"version_code": code_for(platform_id),
|
||||
"size": len(payload),
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
meta.update(overrides)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8")
|
||||
(root / platform.sidecar).write_text(json.dumps(meta), encoding="utf-8")
|
||||
if platform.signed and signature is not None:
|
||||
(root / platform.signature).write_text(signature, encoding="utf-8")
|
||||
return meta
|
||||
|
||||
|
||||
def test_absent_client_is_advertised_as_nothing_at_all():
|
||||
"""The KEY is missing, not null.
|
||||
# --- the table ---------------------------------------------------------------
|
||||
|
||||
A client testing for it then gets one unambiguous answer rather than having to
|
||||
tell "this server has no APK" apart from "this server predates the feature".
|
||||
|
||||
def test_every_platform_has_its_own_filenames():
|
||||
"""Two platforms sharing an artifact or a sidecar name would overwrite each
|
||||
other in the one directory they all live in — silently, and the survivor would
|
||||
be whichever was copied last."""
|
||||
artifacts = [p.artifact for p in PLATFORMS]
|
||||
sidecars = [p.sidecar for p in PLATFORMS]
|
||||
assert len(set(artifacts)) == len(artifacts)
|
||||
assert len(set(sidecars)) == len(sidecars)
|
||||
assert not set(artifacts) & set(sidecars)
|
||||
|
||||
|
||||
def test_the_android_names_are_the_ones_already_published():
|
||||
"""Pinned because renaming them is a tidy-up that strands every installed phone.
|
||||
|
||||
The Android lane publishes these exact names and CI bakes them in under them.
|
||||
"""
|
||||
assert android_release() is None
|
||||
assert APK_NAME == "thoughtsync.apk"
|
||||
assert MANIFEST_NAME == "thoughtsync-android.json"
|
||||
|
||||
|
||||
# --- absence is an ordinary answer -------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_an_absent_client_is_no_client(platform_id):
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
def test_a_server_holding_nothing_advertises_no_keys_at_all():
|
||||
"""The KEYS are missing, not null.
|
||||
|
||||
A client testing for one then gets an unambiguous answer rather than having to
|
||||
tell "this server has no client" apart from "this server predates the feature".
|
||||
"""
|
||||
assert releases() == {}
|
||||
assert advertisement() == {}
|
||||
|
||||
|
||||
def test_a_present_client_is_advertised_with_what_android_compares():
|
||||
place_client()
|
||||
advertised = advertisement()["android_client"]
|
||||
assert advertised["version"] == "0.1.216"
|
||||
# The integer is what decides "is this newer", not the name — a name is a
|
||||
# string and sorts like one.
|
||||
assert advertised["version_code"] == 216
|
||||
assert advertised["size"] == len(PAYLOAD)
|
||||
assert advertised["url"].endswith("/download")
|
||||
|
||||
|
||||
def test_a_sidecar_describing_a_different_build_counts_as_no_client():
|
||||
"""The likeliest real corruption: a new APK copied over an old sidecar.
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_a_sidecar_describing_a_different_build_counts_as_no_client(platform_id):
|
||||
"""The likeliest real corruption: a new artifact copied over an old sidecar.
|
||||
|
||||
Serving one build while advertising another is worse than serving none — the
|
||||
phone would compare versions against a promise the bytes do not keep.
|
||||
client would compare versions against a promise the bytes do not keep.
|
||||
"""
|
||||
place_client(size=999_999)
|
||||
assert android_release() is None
|
||||
assert advertisement() == {}
|
||||
place(platform_id, size=999_999)
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
def test_an_unreadable_sidecar_counts_as_no_client():
|
||||
place_client()
|
||||
(Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8")
|
||||
assert android_release() is None
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_an_unreadable_sidecar_counts_as_no_client(platform_id):
|
||||
place(platform_id)
|
||||
sidecar = Path(Config.client_root()) / BY_ID[platform_id].sidecar
|
||||
sidecar.write_text("{ this is not json", encoding="utf-8")
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
def test_a_sidecar_missing_a_field_counts_as_no_client():
|
||||
root = Config.client_root()
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_a_sidecar_missing_a_field_counts_as_no_client(platform_id):
|
||||
platform = BY_ID[platform_id]
|
||||
root = Path(Config.client_root())
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / APK_NAME).write_bytes(PAYLOAD)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8")
|
||||
assert android_release() is None
|
||||
(root / platform.artifact).write_bytes(PAYLOAD)
|
||||
(root / platform.sidecar).write_text(json.dumps({"version_name": "x"}), encoding="utf-8")
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client():
|
||||
root = Config.client_root()
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id):
|
||||
platform = BY_ID[platform_id]
|
||||
root = Path(Config.client_root())
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}))
|
||||
assert android_release() is None
|
||||
(root / platform.sidecar).write_text(
|
||||
json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app):
|
||||
place_client()
|
||||
resp = await app.test_client().get("/api/client/android")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["version_code"] == 216
|
||||
def test_androids_code_must_be_an_integer():
|
||||
"""`ClientRelease` in core/src/sync/client.rs declares it `i64`. A string here
|
||||
would fail to deserialize on every phone in the field, so a sidecar carrying one
|
||||
is not a client this server can honestly offer."""
|
||||
place("android", version_code="1.0.3503707")
|
||||
assert release("android") is None
|
||||
|
||||
|
||||
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app):
|
||||
resp = await app.test_client().get("/api/client/android")
|
||||
assert resp.status_code == 404
|
||||
def test_the_desktop_keeps_tauris_semver_key_verbatim():
|
||||
"""It is not an integer and must not be coerced into one: this is the value the
|
||||
desktop updater compares, and `1.0.3503707` truncated to `1` orders against
|
||||
nothing."""
|
||||
place("linux-deb")
|
||||
assert release("linux-deb")["version_code"] == "1.0.3503707"
|
||||
|
||||
|
||||
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app):
|
||||
"""Anyone who can reach the port may ask what version exists; only an account
|
||||
or a linked device may pull the 55 MiB."""
|
||||
place_client()
|
||||
resp = await app.test_client().get("/api/client/android/download")
|
||||
assert resp.status_code == 401
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_an_empty_version_name_counts_as_no_client(platform_id):
|
||||
"""A sidecar can be well-formed and still say nothing. A blank would render as
|
||||
an empty space on the download card, which reads as a layout bug."""
|
||||
place(platform_id, version_name="")
|
||||
assert release(platform_id) is None
|
||||
|
||||
|
||||
def test_an_unknown_platform_is_not_a_client():
|
||||
assert release("blackberry") is None
|
||||
|
||||
|
||||
# --- what a present client reports -------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
def test_a_present_client_reports_what_a_comparator_reads(platform_id):
|
||||
place(platform_id)
|
||||
found = release(platform_id)
|
||||
assert found["version"] == "2026.08.30.0307"
|
||||
# The integer is what decides "is this newer", not the name — a name is a string
|
||||
# and sorts like one.
|
||||
assert found["version_code"] == code_for(platform_id)
|
||||
assert found["size"] == len(PAYLOAD)
|
||||
assert found["platform"] == platform_id
|
||||
# A PATH, not an absolute URL: the client joins it to the base it is already
|
||||
# linked to, so a server cannot redirect the download elsewhere.
|
||||
assert found["url"] == f"/api/client/{platform_id}/download"
|
||||
assert not found["url"].startswith("http")
|
||||
|
||||
|
||||
def test_the_android_payload_still_carries_every_field_it_used_to():
|
||||
"""Phones in the field parse this. Fields may be ADDED — `ClientRelease` in
|
||||
core/src/sync/client.rs is a plain serde struct and ignores what it does not
|
||||
know — but none of these may move or change meaning."""
|
||||
place("android")
|
||||
found = android_release()
|
||||
for key in ("version", "version_code", "size", "sha256", "url"):
|
||||
assert key in found, key
|
||||
assert found["url"] == "/api/client/android/download"
|
||||
|
||||
|
||||
# --- the signed bundle -------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_appimage_publishes_its_signature_with_its_version():
|
||||
"""One request returns both, so an updater cannot pair a version with a
|
||||
signature belonging to a different build."""
|
||||
place("linux-appimage", signature="minisign output here")
|
||||
assert release("linux-appimage")["signature"] == "minisign output here"
|
||||
|
||||
|
||||
def test_an_appimage_without_a_signature_is_absent_rather_than_unsigned():
|
||||
"""It is the only bundle that replaces itself in place, and an update the app
|
||||
cannot verify is one it will refuse. Offering it unverifiable would turn a
|
||||
missing file into a failed install on the user's machine."""
|
||||
place("linux-appimage", signature=None)
|
||||
assert release("linux-appimage") is None
|
||||
|
||||
|
||||
def test_an_empty_signature_file_is_not_a_signature():
|
||||
"""A truncated copy leaves a zero-byte file, which reads as present."""
|
||||
place("linux-appimage", signature=" \n")
|
||||
assert release("linux-appimage") is None
|
||||
|
||||
|
||||
def test_only_the_appimage_carries_a_signature():
|
||||
"""A package-manager install cannot replace itself in place, so nothing verifies
|
||||
one and claiming a signature would imply an update path that does not exist."""
|
||||
for platform_id in ALL_IDS:
|
||||
place(platform_id)
|
||||
found = releases()
|
||||
assert "signature" in found["linux-appimage"]
|
||||
for platform_id in ALL_IDS:
|
||||
if platform_id != "linux-appimage":
|
||||
assert "signature" not in found[platform_id], platform_id
|
||||
|
||||
|
||||
# --- the set, and precedence within it ---------------------------------------
|
||||
|
||||
|
||||
def test_a_platform_the_server_lacks_is_simply_not_in_the_set():
|
||||
"""Not null, not an error — a server holding some clients and not others is the
|
||||
ordinary state, and the UI hides what is absent."""
|
||||
place("android")
|
||||
place("windows")
|
||||
found = releases()
|
||||
assert set(found) == {"android", "windows"}
|
||||
|
||||
|
||||
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
|
||||
"""The ordinary case for a self-hoster who just pulled the image."""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
||||
assert android_release()["version_code"] == 300
|
||||
|
||||
|
||||
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
|
||||
"""Someone who deliberately put a build on the volume wants that build."""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
place_client(version_name="0.1.99", version_code=99)
|
||||
advertised = android_release()
|
||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
||||
place("android", version_code=99)
|
||||
# Lower version and all — precedence is about intent, not about newness. An
|
||||
# operator pinning an older client is doing it on purpose.
|
||||
assert advertised["version_code"] == 99
|
||||
assert android_release()["version_code"] == 99
|
||||
|
||||
|
||||
def test_precedence_is_decided_per_platform_not_for_the_whole_set():
|
||||
"""THE trap this table introduces. Dropping in one client must not retract the
|
||||
other four — "first directory holding anything wins" would mean overriding the
|
||||
APK silently takes the desktop downloads offline."""
|
||||
for platform_id in ALL_IDS:
|
||||
place(platform_id, root=client_dist.BAKED_ROOT, version_code=300)
|
||||
place("android", version_code=99)
|
||||
found = releases()
|
||||
assert found["android"]["version_code"] == 99
|
||||
for platform_id in ALL_IDS:
|
||||
if platform_id != "android":
|
||||
assert found[platform_id]["version_code"] == coded(platform_id, 300), platform_id
|
||||
assert set(found) == set(ALL_IDS)
|
||||
|
||||
|
||||
def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
|
||||
@@ -149,6 +328,79 @@ def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
|
||||
This is the failure the copy-order advice in docs/android-distribution.md is
|
||||
about, and the server should ride it out rather than go dark.
|
||||
"""
|
||||
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||
place_client(size=999_999) # sidecar describing a different build
|
||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
||||
place("android", size=999_999) # sidecar describing a different build
|
||||
assert android_release()["version_code"] == 300
|
||||
|
||||
|
||||
# --- the advertisement -------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_advertisement_carries_the_whole_table():
|
||||
place("linux-deb")
|
||||
assert set(advertisement()["clients"]) == {"linux-deb"}
|
||||
|
||||
|
||||
def test_the_advertisement_still_carries_the_key_phones_already_read():
|
||||
"""Not deprecated in the change that introduces its replacement. An installed
|
||||
Android client reads `android_client`, and one duplicated dict is what it costs
|
||||
to not strand it."""
|
||||
place("android")
|
||||
data = advertisement()
|
||||
assert data["android_client"] == data["clients"]["android"]
|
||||
|
||||
|
||||
def test_no_android_client_means_no_android_key_even_when_others_are_present():
|
||||
place("windows")
|
||||
data = advertisement()
|
||||
assert "android_client" not in data
|
||||
assert set(data["clients"]) == {"windows"}
|
||||
|
||||
|
||||
# --- routes ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, platform_id):
|
||||
place(platform_id)
|
||||
resp = await app.test_client().get(f"/api/client/{platform_id}")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["version_code"] == code_for(platform_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app, platform_id):
|
||||
resp = await app.test_client().get(f"/api/client/{platform_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_an_unknown_platform_404s_like_an_absent_one(app):
|
||||
"""Same answer to the caller either way, and telling them apart would only tell
|
||||
an unauthenticated stranger which platforms this build of the server knows."""
|
||||
resp = await app.test_client().get("/api/client/blackberry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_the_index_returns_everything_in_one_request(app):
|
||||
place("android")
|
||||
place("linux-appimage")
|
||||
resp = await app.test_client().get("/api/client")
|
||||
assert resp.status_code == 200
|
||||
assert set((await resp.get_json())["clients"]) == {"android", "linux-appimage"}
|
||||
|
||||
|
||||
async def test_the_index_is_an_empty_set_rather_than_a_404(app):
|
||||
"""A server with no clients has an answer; it is just an empty one. 404 here
|
||||
would make the UI treat "nothing to offer" as a broken endpoint."""
|
||||
resp = await app.test_client().get("/api/client")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["clients"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app, platform_id):
|
||||
"""Anyone who can reach the port may ask what version exists; only an account or
|
||||
a linked device may pull the payload."""
|
||||
place(platform_id)
|
||||
resp = await app.test_client().get(f"/api/client/{platform_id}/download")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -543,3 +543,90 @@ async def test_a_revision_inside_the_window_blocks_another(db, owner):
|
||||
await db.commit()
|
||||
|
||||
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
|
||||
|
||||
|
||||
async def test_renaming_a_tag_onto_an_existing_one_merges_into_the_older(app_client, db):
|
||||
"""Renaming a tag onto a name another tag holds merges them, and the OLDER row
|
||||
is the survivor — whichever side the caller happened to be renaming.
|
||||
|
||||
Runs against a real database because the whole question is about `created_at`
|
||||
ordering and the note_labels rows moving, neither of which a unit test sees.
|
||||
|
||||
Age decides, rather than "the one that already held the name", so that renaming
|
||||
A→B and renaming B→A land on the same row. If the incumbent won, the survivor
|
||||
would depend on which way round someone typed it, and two clients racing the
|
||||
same tidy-up would disagree about which id still exists.
|
||||
"""
|
||||
reg = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "tags@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert reg.status_code == 201
|
||||
|
||||
# Two separate requests, so two transactions and two distinct `func.now()`s.
|
||||
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
|
||||
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
|
||||
|
||||
one = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
|
||||
two = await (await app_client.post("/api/notes", json={"body": "stamps"})).get_json()
|
||||
await app_client.put(f"/api/notes/{one['id']}/labels", json={"label_ids": [older["id"]]})
|
||||
await app_client.put(f"/api/notes/{two['id']}/labels", json={"label_ids": [newer["id"]]})
|
||||
|
||||
# Rename the YOUNGER onto the older's name, with different casing — matching is
|
||||
# case-insensitive, and the survivor must end up spelled the way we asked.
|
||||
resp = await app_client.patch(f"/api/labels/{newer['id']}", json={"name": "Grocery"})
|
||||
assert resp.status_code == 200
|
||||
survivor = await resp.get_json()
|
||||
|
||||
assert survivor["id"] == older["id"], "the older row is the one that keeps existing"
|
||||
assert survivor["name"] == "Grocery", "the survivor takes the spelling that was asked for"
|
||||
|
||||
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
|
||||
assert len(listing) == 1, "the two became one"
|
||||
assert listing[0]["id"] == older["id"]
|
||||
assert listing[0]["count"] == 2, "it carries every note from both sides"
|
||||
|
||||
|
||||
async def test_the_rename_merge_survivor_does_not_depend_on_the_direction(app_client, db):
|
||||
"""The mirror of the test above: rename the OLDER onto the younger's name. The
|
||||
older still survives — it just changes its name — so the two directions agree."""
|
||||
reg = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "tags2@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert reg.status_code == 201
|
||||
|
||||
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
|
||||
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
|
||||
|
||||
resp = await app_client.patch(f"/api/labels/{older['id']}", json={"name": "errands"})
|
||||
assert resp.status_code == 200
|
||||
survivor = await resp.get_json()
|
||||
|
||||
assert survivor["id"] == older["id"], "age wins in this direction too"
|
||||
assert survivor["name"] == "errands"
|
||||
assert newer["id"] != older["id"]
|
||||
|
||||
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
|
||||
assert [lb["id"] for lb in listing] == [older["id"]]
|
||||
|
||||
|
||||
async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one(app_client, db):
|
||||
"""A case-sensitive match here used to mint "Groceries" beside "groceries". No
|
||||
synced client can hold both — their `labels` index is unique on `lower(name)` —
|
||||
so the pair was a pull that would fail later, on a phone, with no UI in the path.
|
||||
"""
|
||||
reg = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "tags3@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert reg.status_code == 201
|
||||
|
||||
first = await app_client.post("/api/labels", json={"name": "groceries"})
|
||||
assert first.status_code == 201
|
||||
second = await app_client.post("/api/labels", json={"name": "Groceries"})
|
||||
assert second.status_code == 200, "an existing tag is returned, not a second one made"
|
||||
|
||||
assert (await second.get_json())["id"] == (await first.get_json())["id"]
|
||||
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
|
||||
assert len(listing) == 1
|
||||
|
||||
Reference in New Issue
Block a user