rename: the apps say Inkwell — web, desktop, Android and server strings
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 58s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m50s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 11m37s

ThoughtSync is renamed Inkwell ("Fabled Inkwell" in full; Scribe note 5071).
This is step 1 of milestone 481: every string a person reads in the running
apps. Identities installed clients depend on are deliberately untouched — the
Tauri productName (it derives the .deb Package: field), identifier and binary
name, applicationId, X-ThoughtSync-* headers, the export's app marker, env vars,
module and crate names.

- web: title, PWA manifest (name "Fabled Inkwell", short_name "Inkwell"),
  offline page, icon labels, build labels, prompts, notification title
- server: site_name default, import error, link-preview User-Agent
- 0030: a stored site_name of exactly the old default follows the rename. The
  Settings page saves every key, so most servers hold "ThoughtSync" without an
  admin ever having chosen it; a name they typed is left alone
- desktop: window title, default device name, local-mode site name, log line
- android: app_name and the strings that name the app
- core: probe and compatibility messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:16:14 -04:00
co-authored by Claude Opus 5.5
parent fb469ed73a
commit f806e35d41
30 changed files with 108 additions and 57 deletions
@@ -0,0 +1,51 @@
"""a stored site_name of the old default follows the rename to Inkwell
Revision ID: 0030
Revises: 0029
Create Date: 2026-10-06
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
registry default moves with it. On its own that only reaches servers whose settings
table has no `site_name` row — and most servers have one, because the Settings page
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
So the row is rewritten, but only when it holds exactly the old default. A name the
admin actually typed is theirs and is left alone; the old default is the one value we
can be sure nobody chose.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for.
## Downgrade
Puts the old default back on a row holding exactly the new one. A server where the
admin typed "Inkwell" themselves between the two cannot be told apart from one this
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
would have shown for either.
"""
from alembic import op
import sqlalchemy as sa
revision = "0030"
down_revision = "0029"
branch_labels = None
depends_on = None
_OLD = '"ThoughtSync"'
_NEW = '"Inkwell"'
def _swap(old: str, new: str) -> None:
op.get_bind().execute(
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
{"old": old, "new": new},
)
def upgrade() -> None:
_swap(_OLD, _NEW)
def downgrade() -> None:
_swap(_NEW, _OLD)
+2 -2
View File
@@ -122,7 +122,7 @@
</intent-filter>
<!--
Capture without opening the app first: Share → ThoughtSync from
Capture without opening the app first: Share → Inkwell from
anywhere, and the selection toolbar in any text field.
text/plain ONLY, and image/* deliberately absent. Nothing in this
@@ -139,7 +139,7 @@
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "ThoughtSync" would say who rather than what.
Share, where "Inkwell" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with ThoughtSync's configuration" — so they are shown as-is rather than
* with Inkwell's configuration" — so they are shown as-is rather than
* replaced with a generic string that would throw away the only useful part.
*/
private fun Exception.describe(): String = message ?: "Something went wrong."
+5 -5
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ThoughtSync</string>
<string name="app_name">Inkwell</string>
<!-- Search bar -->
<string name="search_hint">Search your notes</string>
@@ -164,7 +164,7 @@
<string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
<string name="reminder_open_settings">Open settings</string>
<string name="reminder_inexact_title">Reminders may arrive late</string>
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
@@ -181,7 +181,7 @@
<string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
<string name="update_permission_body">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
<string name="update_permission_action">Allow installing</string>
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
@@ -196,14 +196,14 @@
<!-- Unlinked -->
<string name="sync_offline_title">Working offline on this device</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
<string name="sync_address_label">Server address</string>
<string name="sync_address_hint">notes.example.com</string>
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
<string name="sync_check">Check</string>
<string name="sync_probe_failed">Couldn\'t reach that server</string>
<string name="sync_link_failed">Couldn\'t connect</string>
<string name="sync_server_generic">ThoughtSync server</string>
<string name="sync_server_generic">Inkwell server</string>
<string name="sync_server_version">v%1$s</string>
<string name="sync_compat_ok">Fully compatible.</string>
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
+5 -5
View File
@@ -1,4 +1,4 @@
//! HTTP transport to a ThoughtSync server.
//! HTTP transport to an Inkwell server.
//!
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
@@ -151,8 +151,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
/// Ask a server who it is and whether we can sync with it.
///
/// `Err` means we never got a usable answer (bad address, unreachable, not a
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
/// `Err` means we never got a usable answer (bad address, unreachable, not an
/// Inkwell server). A server that answers but is *incompatible* comes back `Ok`
/// with a verdict — that distinction matters, because the two need very different
/// messages: one is "check what you typed", the other is "update something".
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
@@ -170,12 +170,12 @@ pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
return Err(unexpected_status(&base_url, status));
}
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
// Something answered 200 that isn't an Inkwell server (a router login page, a
// captive portal). Report the address, not the parse error, which would mean
// nothing to the person reading it.
let server: ServerInfo = response.json().await.map_err(|_| {
format!(
"{base_url} responded, but not with ThoughtSync's configuration. \
"{base_url} responded, but not with Inkwell's configuration. \
Is that the right address?"
)
})?;
+4 -4
View File
@@ -146,7 +146,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
return incompatible(
&format!(
"This server requires client protocol v{floor} or newer; this app \
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
speaks v{CLIENT_PROTOCOL_VERSION}. Update Inkwell."
),
true,
);
@@ -234,7 +234,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
let with_scheme = match trimmed.split_once("://") {
Some((scheme, rest)) => {
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
// be a ThoughtSync server; reject rather than fail confusingly later.
// be an Inkwell server; reject rather than fail confusingly later.
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
@@ -259,7 +259,7 @@ mod tests {
/// A server matching this client exactly, which each test then degrades.
fn current_server() -> ServerInfo {
ServerInfo {
site_name: Some("ThoughtSync".into()),
site_name: Some("Inkwell".into()),
version: Some("0.1.0".into()),
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
@@ -282,7 +282,7 @@ mod tests {
fn server_without_protocol_fields_is_too_old() {
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
let info = ServerInfo {
site_name: Some("ThoughtSync".into()),
site_name: Some("Inkwell".into()),
version: Some("0.0.9".into()),
..Default::default()
};
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn config_get(db: State<'_, Db>) -> PublicConfig {
};
// Offline defaults: no signups, no server-side URL unfurling (needs network).
PublicConfig {
site_name: "ThoughtSync".to_string(),
site_name: "Inkwell".to_string(),
allow_registration: false,
// 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.
+1 -1
View File
@@ -51,7 +51,7 @@ pub struct LinkResult {
/// A recognizable default, so a server's device list doesn't fill up with "Device".
fn default_device_name() -> String {
format!("ThoughtSync desktop ({})", std::env::consts::OS)
format!("Inkwell desktop ({})", std::env::consts::OS)
}
fn trimmed(value: &Option<String>) -> Option<&str> {
+2 -2
View File
@@ -189,7 +189,7 @@ pub fn run() {
capture::capture_done,
])
.run(tauri::generate_context!())
.expect("error while running the ThoughtSync desktop app");
.expect("error while running the Inkwell desktop app");
}
/// Match the window's own background to the theme the UI is about to render in.
@@ -267,7 +267,7 @@ fn log_event(level: String, message: String) {
fn log_environment(app: &tauri::App) {
use tauri::Manager;
log::info!(
"ThoughtSync desktop {} starting ({} {})",
"Inkwell desktop {} starting ({} {})",
display_version(),
std::env::consts::OS,
std::env::consts::ARCH,
+1 -1
View File
@@ -15,7 +15,7 @@
"windows": [
{
"label": "main",
"title": "ThoughtSync",
"title": "Inkwell",
"width": 1100,
"height": 780,
"minWidth": 480,
+2 -2
View File
@@ -20,8 +20,8 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="ThoughtSync" />
<title>ThoughtSync</title>
<meta name="apple-mobile-web-app-title" content="Inkwell" />
<title>Inkwell</title>
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ThoughtSync">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Inkwell">
<!-- Maskable variant: full-bleed background so platform masks (circle, squircle,
rounded-square) never clip the glyph. The graph sits inside the central 80%
safe zone. Same mark as icon.svg. -->

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 914 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ThoughtSync">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Inkwell">
<!-- Rounded-tile app icon: brand-yellow background + a small knowledge-graph
constellation (a hub node linked to three thoughts) evoking linked notes. -->
<rect width="512" height="512" rx="112" fill="#F5C518"/>

Before

Width:  |  Height:  |  Size: 942 B

After

Width:  |  Height:  |  Size: 938 B

+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "/",
"name": "ThoughtSync",
"short_name": "ThoughtSync",
"name": "Fabled Inkwell",
"short_name": "Inkwell",
"description": "Capture a fleeting thought in a second — a self-hosted Keep-style board that grows into a lightweight second brain.",
"start_url": "/",
"scope": "/",
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#F5C518" />
<title>Offline · ThoughtSync</title>
<title>Offline · Inkwell</title>
<style>
:root {
color-scheme: light dark;
@@ -107,7 +107,7 @@
</g>
</svg>
<h1>You're offline</h1>
<p>ThoughtSync needs a connection to your server to load. Reconnect and try again.</p>
<p>Inkwell needs a connection to your server to load. Reconnect and try again.</p>
<button type="button" onclick="location.reload()">Retry</button>
</main>
</body>
+1 -1
View File
@@ -1,4 +1,4 @@
// ThoughtSync service worker — installable shell, deliberately NOT offline-first.
// Inkwell service worker — installable shell, deliberately NOT offline-first.
//
// This exists so the web app satisfies the PWA install criteria ("Add to Home
// Screen") and shows a friendly page when a navigation happens with no network.
+1 -1
View File
@@ -41,7 +41,7 @@ const desktopApp = isDesktop();
// a different question and Sync answers it separately.
const buildVersion = computed(() => config.version || "unknown");
const buildLabel = computed(
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
() => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
);
async function removeView(f: SavedFilter) {
@@ -5,7 +5,7 @@ import BaseButton from "./BaseButton.vue";
import { isDesktop, desktop } from "../desktop/bridge";
import { useUiStore } from "../stores/ui";
// First-run prompt (Linux AppImage only) offering to add ThoughtSync to the
// First-run prompt (Linux AppImage only) offering to add Inkwell to the
// applications menu so it behaves like an installed app instead of a loose file.
// Shown once; the choice is remembered. Users who decline can integrate later from
// Account. No-op on the web build and on already-integrated / non-AppImage runs.
@@ -36,7 +36,7 @@ async function integrate() {
await desktop.integrate();
localStorage.setItem(DISMISS_KEY, "1");
show.value = false;
ui.showToast("ThoughtSync added to your applications menu.");
ui.showToast("Inkwell added to your applications menu.");
} catch (e) {
ui.showToast((e as { message?: string }).message ?? "Couldn't add to applications.");
} finally {
@@ -50,15 +50,15 @@ async function integrate() {
v-if="show"
align="center"
panel-class="w-full max-w-md shadow-xl"
aria-label="Add ThoughtSync to your applications"
aria-label="Add Inkwell to your applications"
@close="dismiss"
>
<div class="p-6">
<h2 class="text-lg font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
Add ThoughtSync to your applications?
Add Inkwell to your applications?
</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
We'll add a menu entry so you can launch ThoughtSync from your app launcher, and keep a stable
We'll add a menu entry so you can launch Inkwell from your app launcher, and keep a stable
copy in <code class="font-mono text-xs">~/Applications</code> so it isn't lost from Downloads.
You can undo this any time from your account.
</p>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useNotesStore } from "../stores/notes";
import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui";
// Sidebar counterpart to Export: pick a .zip (a ThoughtSync export for round-trip,
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip,
// or a Google Keep Takeout archive) and import its notes. Additive — never
// overwrites existing notes.
const notes = useNotesStore();
@@ -43,7 +43,7 @@ async function onFile(e: Event) {
type="button"
class="nav-link w-full disabled:opacity-60"
:disabled="busy"
title="Import a ThoughtSync export or a Google Keep Takeout zip"
title="Import an Inkwell export or a Google Keep Takeout zip"
@click="pick"
>
<Icon name="upload" /> {{ busy ? "Importing…" : "Import" }}
+1 -1
View File
@@ -55,7 +55,7 @@ export interface PublicConfig {
// Public, unauthenticated app config (site name, whether signups are open).
export const useConfigStore = defineStore("config", () => {
const siteName = ref("ThoughtSync");
const siteName = ref("Inkwell");
const allowRegistration = ref(true);
const version = ref("");
const enableUrlUnfurl = ref(true);
+1 -1
View File
@@ -49,7 +49,7 @@ export const useReminderStore = defineStore("reminders", () => {
});
if (osEnabled.value) {
try {
new Notification("ThoughtSync reminder", { body: title });
new Notification("Inkwell reminder", { body: title });
} catch {
/* notifications unavailable — the toast still fired */
}
+3 -3
View File
@@ -128,7 +128,7 @@ onMounted(() => {
</header>
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
Link the ThoughtSync desktop or mobile app to sync your notes. Create a device token here, then
Link the Inkwell desktop or mobile app to sync your notes. Create a device token here, then
paste it into the app when it asks to connect. You can revoke a device at any time.
</p>
@@ -142,8 +142,8 @@ onMounted(() => {
<p class="mt-0.5 text-xs text-neutral-400">
{{
desktopStatus.is_integrated
? "ThoughtSync is in your applications menu."
: "Add ThoughtSync to your applications menu to launch it like an installed app."
? "Inkwell is in your applications menu."
: "Add Inkwell to your applications menu to launch it like an installed app."
}}
</p>
</div>
+1 -1
View File
@@ -198,7 +198,7 @@ const emptyState = computed(() => {
if (startState.value)
return {
title: "Your notes live on this device",
subtitle: "ThoughtSync works fully offline — no account, nothing to sign in to.",
subtitle: "Inkwell works fully offline — no account, nothing to sign in to.",
};
return { title: "No notes yet", subtitle: "Your captured thoughts will show up here." };
});
+1 -1
View File
@@ -96,7 +96,7 @@ async function submit() {
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'}`"
:title="`Inkwell server build ${config.version || 'unknown'}`"
>
{{ config.version || "unknown" }}
</p>
+1 -1
View File
@@ -38,7 +38,7 @@ onMounted(load);
<span v-else-if="reminders.osEnabled" class="text-xs text-neutral-400">Notifications on ✓</span>
</div>
<p class="mb-5 text-xs text-neutral-400">
Due reminders pop up while ThoughtSync is open. Background alerts arrive with the desktop &amp; mobile apps.
Due reminders pop up while Inkwell is open. Background alerts arrive with the desktop &amp; mobile apps.
</p>
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't load reminders" @retry="load">
+5 -5
View File
@@ -69,8 +69,8 @@ function describe(c: Compatibility): string {
}
// --- App updates (M10.9). Independent of sync: an unlinked, server-less install
// still updates itself, which is why the feed is the release host and not a
// ThoughtSync server. ---
// still updates itself, which is why the feed is the release host and not an
// Inkwell server. ---
const channel = ref<UpdateChannel>("stable");
const update = ref<UpdateStatus | null>(null);
const checking = ref(false);
@@ -374,8 +374,8 @@ onMounted(refresh);
Working offline on this device
</p>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Everything works without a server — your notes are stored on this machine. Connect a
ThoughtSync server if you want them to reach your other devices.
Everything works without a server — your notes are stored on this machine. Connect an
Inkwell server if you want them to reach your other devices.
</p>
</section>
@@ -410,7 +410,7 @@ onMounted(refresh);
"
>
<p class="font-medium text-neutral-800 dark:text-neutral-100">
{{ probe.server.site_name || "ThoughtSync server" }}
{{ probe.server.site_name || "Inkwell server" }}
<span v-if="probe.server.version" class="text-xs font-normal text-neutral-400">
v{{ probe.server.version }}
</span>
+1 -1
View File
@@ -5,7 +5,7 @@ export default {
theme: {
extend: {
colors: {
// ThoughtSync accent — a Keep-ish warm yellow.
// Inkwell accent — a Keep-ish warm yellow.
brand: {
DEFAULT: "#F5C518",
600: "#D9A800",
+2 -2
View File
@@ -286,7 +286,7 @@ async def export_notes():
@bp.post("/import")
@login_required
async def import_notes():
"""Import notes from an uploaded zip — either a ThoughtSync export (round-trip)
"""Import notes from an uploaded zip — either an Inkwell export (round-trip)
or a Google Keep Takeout archive. Additive: imported notes are appended, never
overwriting existing ones. Returns a per-run summary."""
files = await request.files
@@ -312,7 +312,7 @@ async def import_notes():
specs, source = _read_import_specs(zf, budget)
if not specs:
return json_error(
"no importable notes found — expected a ThoughtSync export or a Google Keep Takeout zip", 400
"no importable notes found — expected an Inkwell export or a Google Keep Takeout zip", 400
)
imported = 0
+1 -1
View File
@@ -32,7 +32,7 @@ class SettingDef:
# in the admin Settings UI with a working default — no migration, no env var.
REGISTRY: list[SettingDef] = [
SettingDef(
"site_name", "string", "ThoughtSync", "Site name", "Shown in the header and the browser tab.", "General"
"site_name", "string", "Inkwell", "Site name", "Shown in the header and the browser tab.", "General"
),
SettingDef(
"allow_registration",
+1 -1
View File
@@ -27,7 +27,7 @@ from urllib.parse import urljoin, urlparse
MAX_REDIRECTS = 3
TIMEOUT_S = 5.0
MAX_BYTES = 512 * 1024
USER_AGENT = "ThoughtSync-LinkPreview/1.0"
USER_AGENT = "Inkwell-LinkPreview/1.0"
_REDIRECT_CODES = {301, 302, 303, 307, 308}