Files
inkwell/frontend/src/views/SyncView.vue
T
bvandeusenandClaude Opus 5.5 921c0a47bd Real plurals: the web's sync messages and Android's tag counts
Web: one counted(n, one, other) helper. The sync screen's three "(s)"
messages (attachments that didn't download, changes refused, files that
didn't upload) now say "1 file" / "2 files". The six hand-written ternaries
(SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too.

Android: tags_count was two strings chosen in code, and is now <plurals>.
The delete confirmation read "It is on 1 notes … removed from all of them"
and now has a one form of its own.

Operator decision on #5371, 2026-10-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:57:09 -04:00

723 lines
26 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import PageHeader from "../components/PageHeader.vue";
import DesktopMenuEntry from "../components/DesktopMenuEntry.vue";
import {
SUGGESTED_CAPTURE_SHORTCUT,
capture as captureBridge,
onSynced,
sync as syncBridge,
updates as updateBridge,
type CaptureShortcut,
type Compatibility,
type LastCycle,
type ProbeResult,
type RevokeOutcome,
type SyncOutcome,
type SyncStatus,
type UpdateChannel,
type UpdateStatus,
} from "../desktop/bridge";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
import { toastOnFailure } from "../composables/useAction";
import { counted } from "../counted";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
// state, not an incomplete setup — the app is local-first and fully usable having
// never touched this screen. The copy has to carry that, or every new user will
// think something is broken.
const ui = useUiStore();
const status = ref<SyncStatus | null>(null);
const pending = ref(false);
const loading = ref(true);
// Connect form
const url = ref("");
const mode = ref<"password" | "token">("password");
const email = ref("");
const password = ref("");
const token = ref("");
const deviceName = ref("");
const probing = ref(false);
const probe = ref<ProbeResult | null>(null);
const probeError = ref("");
const linking = ref(false);
const linkError = ref("");
const linkedAs = ref("");
const degraded = ref<string[]>([]);
const syncing = ref(false);
const syncError = ref("");
const lastResult = ref("");
// The most recent cycle, including the automatic ones nobody pressed a button for.
const lastCycle = ref<LastCycle | null>(null);
// Shown only for a failed AUTOMATIC cycle: a failed button press already says so in
// `syncError`, and a success needs no more than the "Last synced" time moving.
const autoError = computed(() =>
lastCycle.value && !lastCycle.value.ok && lastCycle.value.trigger !== "manual"
? lastCycle.value.error
: null,
);
/** Set only when unlinking left the token valid server-side (see revokeWarning). */
const unlinkWarning = ref("");
const linked = computed(() => status.value?.linked === true);
/** Only offer to connect once a probe has said the server is usable. */
const canLink = computed(() => {
if (!probe.value || probe.value.compatibility.status === "incompatible") return false;
return mode.value === "token" ? token.value.trim().length > 0 : email.value.trim().length > 0 && password.value.length > 0;
});
function describe(c: Compatibility): string {
if (c.status === "ok") return "Fully compatible.";
if (c.status === "degraded") {
return `Compatible, but these features aren't available on this server: ${c.unavailable.join(", ")}.`;
}
return c.reason;
}
// --- 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 an
// Inkwell server. ---
const channel = ref<UpdateChannel>("stable");
const update = ref<UpdateStatus | null>(null);
const checking = ref(false);
const installing = ref(false);
const updateError = ref("");
const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// Where updates come from: null is Fabled-Git. Learned from the check, because the
// check is what reads it (update.rs, `read_source`), so the screen shows the source
// that was actually asked.
const source = ref<string | null>(null);
// The server worth offering as a source: the one already chosen, else the one this
// app syncs with. With neither there is nothing to choose, and no choice is shown.
const offeredServer = computed(
() => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null,
);
// The two choices on the update card, each a radio with a name and a line on what
// choosing it means.
interface UpdateOption {
title: string;
detail: string;
checked: boolean;
pick: () => Promise<void>;
}
const sourceOptions = computed<UpdateOption[]>(() => [
{
title: "Fabled-Git",
detail: "Where Inkwell is published, on the channel below.",
checked: source.value === null,
pick: () => switchSource(null),
},
{
title: offeredServer.value ?? "",
detail:
"The build that server holds. It passes on official builds only: every update is checked against the signature built into this app first.",
checked: source.value !== null,
pick: () => switchSource(offeredServer.value),
},
]);
const channelOptions = computed<UpdateOption[]>(() => [
{
title: "Stable",
detail: "Released versions only.",
checked: channel.value === "stable",
pick: () => switchChannel("stable"),
},
{
title: "Development",
detail: "Every build that passes CI. Newer, and less tested.",
checked: channel.value === "dev",
pick: () => switchChannel("dev"),
},
]);
// Each set of choices, and when it is offered. A server holds one build, so a
// channel only means something on the forge.
const updateChoices = computed(() => [
{ legend: "Updates from", name: "update-source", shown: !!offeredServer.value, options: sourceOptions.value },
{ legend: "Channel", name: "update-channel", shown: source.value === null, options: channelOptions.value },
]);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
// 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 = errorMessage(e, "Couldn't save the shortcut.");
} finally {
savingShortcut.value = false;
}
}
async function checkUpdates() {
checking.value = true;
updateError.value = "";
try {
update.value = await updateBridge.check();
channel.value = update.value.channel;
source.value = update.value.source;
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
} finally {
checking.value = false;
checkedOnce.value = true;
}
}
// After a channel or source switch: the last answer described somewhere else, so it
// means nothing now. Forget it and ask again.
async function recheck() {
update.value = null;
checkedOnce.value = false;
await checkUpdates();
}
async function switchChannel(next: UpdateChannel) {
if (next === channel.value) return;
updateError.value = "";
try {
channel.value = await updateBridge.setChannel(next);
await recheck();
} catch (e) {
updateError.value = errorMessage(e, "The update channel couldn't be changed.");
}
}
async function switchSource(next: string | null) {
if (next === source.value) return;
updateError.value = "";
try {
source.value = await updateBridge.setSource(next);
await recheck();
} catch (e) {
updateError.value = errorMessage(e, "The update source couldn't be changed.");
}
}
async function installUpdate() {
installing.value = true;
updateError.value = "";
try {
// On success the app restarts and this never returns; reaching the next line
// means it failed.
await updateBridge.install();
} catch (e) {
updateError.value = errorMessage(e, "The update couldn't be installed.");
} finally {
installing.value = false;
}
}
async function refresh() {
try {
channel.value = await updateBridge.channel();
} catch {
// 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();
lastCycle.value = await syncBridge.last();
} catch {
status.value = null;
} finally {
loading.value = false;
}
}
async function runProbe() {
probing.value = true;
probeError.value = "";
probe.value = null;
try {
probe.value = await syncBridge.probe(url.value);
} catch (e) {
probeError.value = errorMessage(e, "Couldn't reach that server.");
} finally {
probing.value = false;
}
}
async function connect() {
linking.value = true;
linkError.value = "";
try {
const result = await syncBridge.link({
url: probe.value?.base_url ?? url.value,
email: mode.value === "password" ? email.value.trim() : undefined,
password: mode.value === "password" ? password.value : undefined,
token: mode.value === "token" ? token.value.trim() : undefined,
name: deviceName.value.trim() || undefined,
});
status.value = result.status;
linkedAs.value = result.identity.email;
degraded.value =
result.compatibility.status === "degraded" ? result.compatibility.unavailable : [];
// Never keep the secrets around after they've been exchanged for a token.
password.value = "";
token.value = "";
probe.value = null;
ui.showToast(`Connected to ${result.status.server_url}.`);
await syncNow();
} catch (e) {
linkError.value = errorMessage(e, "Couldn't link to that server.");
} finally {
linking.value = false;
}
}
/** Show what a cycle did. Used for the button's result and the background's alike. */
function showOutcome(outcome: SyncOutcome) {
status.value = outcome.status;
const received = outcome.pull.notes_applied + outcome.pull.notes_deleted;
const sent = outcome.push.created + outcome.push.applied;
const blobs = outcome.pull.blobs_downloaded;
const parts: string[] = [];
if (sent > 0) parts.push(`sent ${sent}`);
const up = outcome.push.uploaded;
if (up > 0) parts.push(`uploaded ${counted(up, "file", "files")}`);
if (received > 0) parts.push(`received ${received}`);
if (blobs > 0) parts.push(counted(blobs, "attachment", "attachments"));
lastResult.value = parts.length ? `Synced — ${parts.join(", ")}.` : "Already up to date.";
// Attachments that didn't arrive are retried next sync, so this is a note, not
// an error — but saying nothing would leave a missing image unexplained.
if (outcome.pull.blobs_failed > 0) {
const failed = counted(outcome.pull.blobs_failed, "attachment", "attachments");
lastResult.value += ` ${failed} didn't download — they'll retry on the next sync.`;
}
// Rejections are the server refusing a specific change — surfaced, never
// swallowed, because only the person can resolve them. The background cycle does
// not resend them on its own (autosync.rs), so this stays until they're fixed.
// A file refused for good is listed once, here, and not retried; the editor says so
// on the file itself from then on.
const problems: string[] = [];
if (outcome.push.rejected > 0) problems.push(`${counted(outcome.push.rejected, "change", "changes")} the server wouldn't accept`);
if (outcome.push.upload_failed > 0) problems.push(`${counted(outcome.push.upload_failed, "file", "files")} didn't upload`);
syncError.value = problems.length ? `${problems.join(" and ")}: ${outcome.push.errors.join("; ")}` : "";
}
async function syncNow() {
syncing.value = true;
syncError.value = "";
try {
showOutcome(await syncBridge.now());
pending.value = await syncBridge.hasPending();
} catch (e) {
syncError.value = errorMessage(e, "Sync failed.");
} finally {
syncing.value = false;
}
}
/**
* Advice for an unlink whose server-side revoke didn't land — empty when it did.
* Never a toast: a toast disappears, and "your token is still live" is exactly the
* kind of thing someone comes back to this screen to check.
*/
function revokeWarning(outcome: RevokeOutcome): string {
if (outcome.status === "unsupported") {
return "This server is older than in-app sign-out, so this device's token had to be left in place. Revoke it in the web app under Account → Linked devices.";
}
if (outcome.status === "failed") {
return `${outcome.reason} Until it's revoked, this device's token still works — you can revoke it in the web app under Account → Linked devices.`;
}
return "";
}
async function disconnect() {
if (
!window.confirm(
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. This device's access token is revoked, so it can't be used to reach the server again.",
)
) {
return;
}
await toastOnFailure(async () => {
const result = await syncBridge.unlink();
status.value = result.status;
linkedAs.value = "";
degraded.value = [];
lastResult.value = "";
unlinkWarning.value = revokeWarning(result.revoked);
ui.showToast(
result.revoked.status === "revoked"
? "Disconnected, and this device is signed out on the server."
: "Disconnected. This device now works offline only.",
);
}, "Couldn't disconnect.");
}
// A background cycle finished — redraw from it, so this screen is never staler
// than the board behind it.
let stopSyncListener: (() => void) | null = null;
onMounted(() => {
void refresh();
void onSynced((cycle) => {
lastCycle.value = cycle;
if (cycle.outcome) showOutcome(cycle.outcome);
void syncBridge
.hasPending()
.then((p) => (pending.value = p))
.catch(() => {});
}).then((stop) => {
stopSyncListener = stop;
});
});
onBeforeUnmount(() => stopSyncListener?.());
</script>
<template>
<div class="page-shell">
<PageHeader title="Sync" />
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
<!-- Linked -->
<template v-else-if="linked">
<section class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Connected to
<span class="font-mono text-xs">{{ status?.server_url }}</span>
</p>
<p v-if="linkedAs" class="mt-0.5 text-xs text-neutral-400">as {{ linkedAs }}</p>
<p class="mt-1 text-xs text-neutral-400">
Last synced {{ formatDateTime(status?.last_sync_at ?? null) }}
<span v-if="pending"> · unsent changes on this device</span>
</p>
<p class="mt-0.5 text-xs text-neutral-400">
Syncs on its own while the app is open: at launch, soon after you edit, and every
few minutes.
</p>
</div>
<div class="flex shrink-0 gap-2">
<BaseButton :loading="syncing" @click="syncNow">Sync now</BaseButton>
<BaseButton variant="ghost" @click="disconnect">Disconnect</BaseButton>
</div>
</div>
<p v-if="lastResult && !syncError" class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{{ lastResult }}
</p>
<p v-if="degraded.length" class="mt-3 text-xs text-amber-600 dark:text-amber-400">
This server doesn't support: {{ degraded.join(", ") }}. Everything else syncs normally.
</p>
<p v-if="syncError" class="mt-3 form-error">{{ syncError }}</p>
<p v-else-if="autoError" class="mt-3 text-xs text-amber-600 dark:text-amber-400">
The last automatic sync ({{ new Date(lastCycle!.at_ms).toLocaleTimeString() }}) didn't
go through: {{ autoError }} It will keep trying on its own.
</p>
</section>
<p class="text-xs text-neutral-400">
Your notes live on this device either way — syncing just keeps a server copy in step, so
other devices can catch up.
</p>
</template>
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
<template v-else>
<!-- The one exception to that framing: an unlink whose server-side revoke
didn't land leaves a live credential behind, and the person who unlinked
to retire a machine has to be told plainly rather than by a toast. -->
<section
v-if="unlinkWarning"
class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10"
role="alert"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
This device's token is still valid on the server
</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300/80">{{ unlinkWarning }}</p>
</section>
<section
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
aria-live="polite"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Working offline on this device
</p>
<p class="mt-1 hint">
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>
<form class="flex flex-col gap-4" @submit.prevent="probe ? connect() : runProbe()">
<div class="flex items-end gap-3">
<BaseInput
id="server-url"
v-model="url"
label="Server address"
placeholder="notes.example.com"
autocomplete="url"
class="flex-1"
/>
<BaseButton type="button" variant="ghost" :loading="probing" @click="runProbe">
Check
</BaseButton>
</div>
<p class="-mt-2 text-xs text-neutral-400">
Uses https unless you type http:// yourself.
</p>
<p v-if="probeError" class="form-error">{{ probeError }}</p>
<!-- What answered, BEFORE any credentials are handed over -->
<div
v-if="probe"
class="rounded-xl border p-3 text-sm"
:class="
probe.compatibility.status === 'incompatible'
? 'border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30'
: 'border-neutral-200 dark:border-neutral-800'
"
>
<p class="font-medium text-neutral-800 dark:text-neutral-100">
{{ 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>
</p>
<p
class="mt-1 text-xs"
:class="
probe.compatibility.status === 'incompatible'
? 'text-red-700 dark:text-red-300'
: 'text-neutral-500 dark:text-neutral-400'
"
>
{{ describe(probe.compatibility) }}
</p>
</div>
<template v-if="probe && probe.compatibility.status !== 'incompatible'">
<fieldset class="flex flex-col gap-3">
<legend class="mb-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Sign in
</legend>
<div class="flex gap-4 text-sm">
<label class="flex items-center gap-2">
<input v-model="mode" type="radio" value="password" class="accent-brand" />
Email and password
</label>
<label class="flex items-center gap-2">
<input v-model="mode" type="radio" value="token" class="accent-brand" />
Paste a device token
</label>
</div>
<template v-if="mode === 'password'">
<BaseInput
id="sync-email"
v-model="email"
label="Email"
type="email"
autocomplete="username"
/>
<BaseInput
id="sync-password"
v-model="password"
label="Password"
type="password"
autocomplete="current-password"
/>
</template>
<template v-else>
<BaseInput
id="sync-token"
v-model="token"
label="Device token"
placeholder="Paste the token from Account → Linked devices"
/>
</template>
<BaseInput
id="sync-device-name"
v-model="deviceName"
label="Name for this device (optional)"
placeholder="e.g. My laptop"
/>
</fieldset>
<p v-if="linkError" class="form-error">{{ linkError }}</p>
<div>
<BaseButton type="submit" :loading="linking" :disabled="!canLink">
<Icon name="sync" /> Connect and sync
</BaseButton>
</div>
</template>
</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 hint">
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 form-error">
{{ 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 hint">
Press {{ shortcut.shortcut }} anywhere to capture a note.
</p>
<p v-else class="mt-2 hint">
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">
<h2 class="text-sm font-semibold">App updates</h2>
<p class="mt-1 hint">
This is version {{ update?.current_version ?? "—" }}.
</p>
<template v-for="group in updateChoices" :key="group.name">
<fieldset v-if="group.shown" class="mt-4">
<legend class="section-label">
{{ group.legend }}
</legend>
<div class="mt-2 flex flex-col gap-2">
<label v-for="o in group.options" :key="o.title" class="flex items-start gap-2 text-sm">
<input type="radio" class="mt-1" :name="group.name" :checked="o.checked" @change="o.pick()" />
<span>
<span class="font-medium">{{ o.title }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
</span>
</label>
</div>
</fieldset>
</template>
<p
v-if="update?.blocked_reason"
class="mt-4 rounded-lg bg-black/5 px-3 py-2 text-sm text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
{{ update.blocked_reason }}
</p>
<div v-if="updateAvailable" class="mt-4 rounded-lg bg-brand/10 px-3 py-2.5">
<p class="text-sm font-medium">Version {{ update?.available }} is available.</p>
<p v-if="update?.notes" class="mt-1 whitespace-pre-line text-sm text-neutral-600 dark:text-neutral-300">
{{ update.notes }}
</p>
</div>
<p
v-else-if="checkedOnce && !updateError"
class="mt-4 hint"
>
<template v-if="source">You're on the newest build {{ source }} has.</template>
<template v-else>
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
</template>
</p>
<p v-if="updateError" class="mt-4 form-error">{{ updateError }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<BaseButton variant="ghost" :loading="checking" @click="checkUpdates">
Check for updates
</BaseButton>
<BaseButton
v-if="updateAvailable && update?.can_install"
:loading="installing"
@click="installUpdate"
>
Install and restart
</BaseButton>
</div>
</section>
<!-- The AppImage's menu entry, which the first-run prompt says can be undone
from here. Hides itself on every other build. -->
<DesktopMenuEntry />
</div>
</template>