sailkick-boat 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,6 +242,15 @@ so the boat's own app opens with no password, fully offline. Everything else in
242
242
  config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
243
243
  login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
244
244
 
245
+ It also fills in **`boat.perfKey`**, which the cloud only sends to a logged-in session.
246
+ Without it the app has no identity and the **performance data cloud** — the recorded
247
+ (TWA, STW) samples behind the polar plot, baked to `/perf/<perfKey>/estimate.json` — never
248
+ loads, even though the bake itself is cached and reachable. The key is derived rather than
249
+ configured: the app server takes a boat's Influx bucket and its perf directory from the
250
+ same identity, so the bucket minus its `_raw` suffix *is* the perf key, for a UUID account
251
+ and a grandfathered slug one alike. An unpaired boat has no bucket, so `boat` is left
252
+ exactly as the cloud sent it.
253
+
245
254
  ## Routes, polars and settings — stored on the boat
246
255
  The app reads and writes these through `/api/profile/*`. On the cloud that router is
247
256
  session-gated, and the mirror can never satisfy it: the caching GET path forwards no
@@ -255,9 +264,33 @@ directory (atomic writes, saves serialized so a burst from the route panel can't
255
264
  itself). Same envelopes as the cloud, so the app can't tell the difference — and route
256
265
  planning now works with no uplink at all, which is when you actually want it.
257
266
 
258
- **This copy is boat-local and does not sync.** A route saved on board stays on board; a
259
- route saved in the web app stays in the cloud. Merging the two needs conflict resolution
260
- worth designing properly rather than guessing at, so for now they are simply separate.
267
+ **This copy is boat-local.** A route saved on board stays on board; a route saved in the
268
+ web app stays in the cloud. They are not reconciled in the background but you can copy
269
+ either way, item by item, from the **Sync polars & routes** page in the Signal K Webapps
270
+ menu.
271
+
272
+ ### Sync page — copying polars and routes to and from the cloud
273
+
274
+ Sign in once with your sailkick account and the page lists polars and routes on both
275
+ sides, marking each **in sync**, **differs**, **boat only** or **cloud only**, with a
276
+ button to copy it either way. Nothing is deleted, and an item that already exists is only
277
+ overwritten after a confirmation.
278
+
279
+ The plugin holds the session, not the browser. That is what makes this possible at all:
280
+ the cloud session cookie is `HttpOnly; SameSite=Lax; Secure`, and the boat serves the app
281
+ over plain HTTP on a LAN address — a browser will not store a Secure cookie on an http
282
+ origin, will not send a Lax cookie cross-site, and blocks an https page from fetching http
283
+ at all. Those are all *browser* rules; the plugin is an ordinary HTTP client talking https
284
+ to the cloud, so none of them apply, and the browser only ever talks to the boat.
285
+
286
+ **Only the session is stored, never the password** — it buys a session and is discarded.
287
+ The session lasts 30 days, after which the page asks you to sign in again. The endpoints
288
+ live on the plugin's own router, which sits behind Signal K's security, rather than on the
289
+ open mirror port where anyone on the boat's wifi could use them.
290
+
291
+ Items are matched by **name**, since ids are assigned independently on each side. So a
292
+ polar you refined in the web app appears as *cloud only* — or *differs* if the boat has an
293
+ older one of the same name — and one click brings it aboard.
261
294
 
262
295
  ## Local history (offline Trends + track)
263
296
  **One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
package/index.js CHANGED
@@ -10,6 +10,7 @@ const { createBackfill } = require('./lib/backfill')
10
10
  const { createAis } = require('./lib/ais')
11
11
  const { createAisTargets } = require('./lib/ais/targets')
12
12
  const { createProfile } = require('./lib/profile')
13
+ const { createCloud } = require('./lib/cloud')
13
14
  const { resolveAccountConfig } = require('./lib/account')
14
15
 
15
16
  // sailkick-boat: one Signal K plugin, two independently-toggleable modules —
@@ -98,6 +99,7 @@ module.exports = function (app) {
98
99
  let ais = null
99
100
  let aisTargets = null
100
101
  let profile = null
102
+ let cloud = null
101
103
  let proxyPort = null // what the launcher page needs to build its links
102
104
  let pairedSlug = null
103
105
  let statusTimer = null
@@ -266,9 +268,17 @@ module.exports = function (app) {
266
268
  if (upstream.ignored) {
267
269
  ;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
268
270
  }
271
+ // Boat identity for the app, derived — never guessed. The cloud only fills
272
+ // config.boat for a logged-in session and the mirror forwards no cookie, so the app
273
+ // ran with no identity and public/engine/polar-cloud.js could not find its data
274
+ // cloud (it keys on boat.perfKey). The app server defaults `bucket` and `perfKey`
275
+ // from the same identity (server/auth/registry.js), so the bucket minus its _raw
276
+ // suffix IS the perf key — for a UUID account and a grandfathered slug one alike.
277
+ const perfKey = b && b.bucket ? String(b.bucket).replace(/_raw$/, '') : null
269
278
  const pOpts = {
270
279
  sailkickUrl: upstream.url,
271
280
  storeDir: store,
281
+ boat: perfKey ? { perfKey, slug: (b && b.slug) || perfKey } : null,
272
282
  proxyPort: (proxyPort = p.proxyPort == null ? 8080 : p.proxyPort),
273
283
  localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
274
284
  localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
@@ -326,6 +336,16 @@ module.exports = function (app) {
326
336
  profile = null
327
337
  }
328
338
 
339
+ // Cloud account session, for copying polars and routes between the boat and the
340
+ // cloud copy (the Sync page). Cookie only — see lib/cloud/index.js.
341
+ try {
342
+ cloud = createCloud(app, { upstream: SAILKICK_APP_URL, timeoutMs: PROXY_TUNING.requestTimeoutMs })
343
+ cloud.start()
344
+ } catch (e) {
345
+ (app.error || console.error)('[sailkick-boat] cloud session start failed: ' + e.message)
346
+ cloud = null
347
+ }
348
+
329
349
  if (p.serveTelemetry !== false) {
330
350
  try {
331
351
  telemetry = createTelemetry(app, {})
@@ -452,6 +472,7 @@ module.exports = function (app) {
452
472
  try { if (history) history.stop() } catch {}
453
473
  try { if (aisTargets) aisTargets.stop() } catch {}
454
474
  try { if (profile) profile.stop() } catch {}
475
+ try { if (cloud) cloud.stop() } catch {}
455
476
  try { if (ais) ais.stop() } catch {}
456
477
  try { if (backfill) backfill.stop() } catch {}
457
478
  try { if (proxy) proxy.stop() } catch {}
@@ -462,6 +483,7 @@ module.exports = function (app) {
462
483
  ais = null
463
484
  aisTargets = null
464
485
  profile = null
486
+ cloud = null
465
487
  proxyPort = null
466
488
  pairedSlug = null
467
489
  proxy = null
@@ -485,6 +507,27 @@ module.exports = function (app) {
485
507
  version: (() => { try { return require('./package.json').version } catch { return null } })()
486
508
  })
487
509
  })
510
+ // Cloud account endpoints for the Sync page. Deliberately HERE and not on the mirror:
511
+ // the plugin router sits behind Signal K's own security (this path answers 401
512
+ // unauthenticated, the mirror answers 200), so the cloud session is no more exposed
513
+ // than the admin UI. On :8080 it would be handed to anyone on the boat's wifi.
514
+ router.all('/cloud/*', (req, res) => {
515
+ if (!cloud) { res.status(503).json({ ok: false, code: 'off', message: 'plugin not enabled' }); return }
516
+ cloud.handle(String(req.params[0] || ''), req, res)
517
+ })
518
+ // The boat's OWN profile, mirrored onto this router so the Sync page can read both
519
+ // sides same-origin. It is already served on the mirror for the app itself, but the
520
+ // page is served from Signal K on :3000 and a cross-port fetch would need CORS.
521
+ router.all('/profile*', (req, res) => {
522
+ if (!profile) { res.status(503).json({ ok: false, code: 'off', message: 'plugin not enabled' }); return }
523
+ const rest = String(req.params[0] || '')
524
+ // profile.handle() parses an /api/profile/... url; give it one — query string
525
+ // included, or ?section= would be silently dropped and return the whole profile.
526
+ const qs = req.url.indexOf('?')
527
+ const url = '/api/profile' + rest + (qs >= 0 ? req.url.slice(qs) : '')
528
+ const shim = new Proxy(req, { get: (t, k) => (k === 'url' ? url : t[k]) })
529
+ profile.handle(shim, res)
530
+ })
488
531
  router.get('/p/*', (req, res) => {
489
532
  if (proxy) proxy.handleGet(req, res)
490
533
  else res.status(503).send('proxy not enabled')
@@ -0,0 +1,226 @@
1
+ 'use strict'
2
+
3
+ // Authenticated client for the boat's own cloud account, so the app on board can copy
4
+ // polars and routes to and from the cloud copy.
5
+ //
6
+ // Why the PLUGIN holds the credential rather than the browser: the cloud session cookie
7
+ // is `HttpOnly; SameSite=Lax; Secure` (server/auth/session.js), and the boat serves the
8
+ // app over plain HTTP on a LAN address. A browser will not store a Secure cookie on an
9
+ // http origin, will not send a Lax cookie cross-site, and blocks an https page from
10
+ // fetching http at all — so no arrangement of buttons in the browser can bridge the two.
11
+ // Every one of those is a BROWSER rule. Here the plugin is an ordinary HTTP client
12
+ // talking https to the cloud, so none of them apply, and the browser only ever talks to
13
+ // the boat, same-origin.
14
+ //
15
+ // COOKIE ONLY. The password is used once to obtain a session and is never written to
16
+ // disk: a stored password would let the boat act as the account indefinitely, which is a
17
+ // large escalation over the bucket-scoped write token sync uses. The cost is that the
18
+ // session expires (30 days, server/auth/session.js TTL_SEC) and has to be renewed by
19
+ // hand. That trade was chosen deliberately.
20
+ //
21
+ // These endpoints are mounted on the PLUGIN ROUTER, not the open mirror on :8080. The
22
+ // plugin router sits behind Signal K's own security (verified: /plugins/sailkick-boat/*
23
+ // answers 401 unauthenticated, the mirror answers 200), so the cloud session is no more
24
+ // exposed than the admin UI itself. On the mirror it would be handed to anyone on the
25
+ // boat's wifi.
26
+
27
+ const fs = require('fs')
28
+ const fsp = fs.promises
29
+ const path = require('path')
30
+
31
+ const COOKIE_NAME = 'sk_session'
32
+ const MAX_BODY_BYTES = 8 * 1024 * 1024 // a polar CSV is tiny; this is just a sane ceiling
33
+
34
+ function createCloud (app, options = {}) {
35
+ const log = (m) => (app.debug ? app.debug('[cloud] ' + m) : console.log('[sailkick-boat:cloud]', m))
36
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:cloud] ' + m) : console.error('[sailkick-boat:cloud]', m))
37
+
38
+ const upstream = String(options.upstream || '').replace(/\/+$/, '')
39
+ const dataDir = options.dataDir || (app.getDataDirPath && app.getDataDirPath()) || '.'
40
+ const file = options.sessionFile || path.join(dataDir, 'cloud-session.json')
41
+ const timeoutMs = options.timeoutMs || 20000
42
+
43
+ let session = null // { cookie, slug, expiresAt }
44
+
45
+ function load () {
46
+ try {
47
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'))
48
+ if (j && j.cookie) session = j
49
+ } catch { /* absent or corrupt — simply logged out */ }
50
+ if (session && session.expiresAt && Date.now() > session.expiresAt) {
51
+ log('stored cloud session had expired — logged out')
52
+ session = null
53
+ remove()
54
+ }
55
+ }
56
+
57
+ async function persist () {
58
+ try {
59
+ await fsp.mkdir(path.dirname(file), { recursive: true })
60
+ const tmp = `${file}.tmp-${process.pid}`
61
+ await fsp.writeFile(tmp, JSON.stringify(session), { mode: 0o600 })
62
+ await fsp.rename(tmp, file)
63
+ } catch (e) { warn('could not persist the cloud session: ' + e.message) }
64
+ }
65
+ function remove () { try { fs.unlinkSync(file) } catch {} }
66
+
67
+ // Pull our cookie out of a Set-Cookie response. getSetCookie() is the correct API when
68
+ // several cookies come back; fall back for older runtimes.
69
+ function readCookie (headers) {
70
+ const all = typeof headers.getSetCookie === 'function'
71
+ ? headers.getSetCookie()
72
+ : [headers.get('set-cookie')].filter(Boolean)
73
+ for (const raw of all) {
74
+ const first = String(raw).split(';')[0]
75
+ if (first.startsWith(COOKIE_NAME + '=')) {
76
+ const maxAge = /max-age=(\d+)/i.exec(raw)
77
+ return { cookie: first, expiresAt: maxAge ? Date.now() + Number(maxAge[1]) * 1000 : null }
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ async function login (slug, password) {
84
+ if (!upstream) return { ok: false, code: 'no-upstream', message: 'no cloud host configured' }
85
+ let r
86
+ try {
87
+ r = await fetch(`${upstream}/api/auth/login`, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ slug, password }),
91
+ signal: AbortSignal.timeout(timeoutMs)
92
+ })
93
+ } catch (e) {
94
+ return { ok: false, code: 'offline', message: `cannot reach ${upstream} (${e.message})` }
95
+ }
96
+ if (r.status === 401) return { ok: false, code: 'bad-credentials', message: 'wrong boat name or password' }
97
+ if (!r.ok) return { ok: false, code: 'login-failed', message: `the cloud replied HTTP ${r.status}` }
98
+ const got = readCookie(r.headers)
99
+ if (!got) return { ok: false, code: 'no-session', message: 'the cloud accepted the login but sent no session' }
100
+
101
+ let name = slug
102
+ try { const j = await r.json(); name = (j && j.boat && (j.boat.slug || j.boat.name)) || slug } catch {}
103
+ session = { cookie: got.cookie, slug: name, expiresAt: got.expiresAt }
104
+ await persist()
105
+ log(`logged in to ${upstream} as ${name}${got.expiresAt ? `; session valid until ${new Date(got.expiresAt).toISOString().slice(0, 10)}` : ''}`)
106
+ return { ok: true, ...status() }
107
+ }
108
+
109
+ function logout () {
110
+ session = null
111
+ remove()
112
+ log('cloud session cleared')
113
+ return { ok: true, ...status() }
114
+ }
115
+
116
+ function status () {
117
+ if (!session) return { loggedIn: false, upstream }
118
+ return {
119
+ loggedIn: true,
120
+ upstream,
121
+ slug: session.slug,
122
+ expiresAt: session.expiresAt || null,
123
+ expiresInDays: session.expiresAt ? Math.max(0, Math.round((session.expiresAt - Date.now()) / 86400000)) : null
124
+ }
125
+ }
126
+
127
+ // One authenticated request against the cloud. Never throws: the caller is an HTTP
128
+ // handler and a boat is offline most of the time.
129
+ async function request (apiPath, { method = 'GET', body = null } = {}) {
130
+ if (!session) return { ok: false, status: 401, code: 'logged-out', message: 'not logged in to the cloud' }
131
+ let r
132
+ try {
133
+ r = await fetch(upstream + apiPath, {
134
+ method,
135
+ headers: {
136
+ Cookie: session.cookie,
137
+ Accept: 'application/json',
138
+ ...(body ? { 'Content-Type': 'application/json' } : {})
139
+ },
140
+ body: body || undefined,
141
+ signal: AbortSignal.timeout(timeoutMs)
142
+ })
143
+ } catch (e) {
144
+ return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud (${e.message})` }
145
+ }
146
+ // A 401 means the session died (expired, or revoked by a logout elsewhere). Clear it
147
+ // so the page shows a login form instead of silently failing every button.
148
+ if (r.status === 401) {
149
+ session = null
150
+ remove()
151
+ warn('the cloud session is no longer valid — log in again on the Sync page')
152
+ return { ok: false, status: 401, code: 'logged-out', message: 'the cloud session expired — log in again' }
153
+ }
154
+ const text = await r.text().catch(() => '')
155
+ let json = null
156
+ try { json = JSON.parse(text) } catch {}
157
+ return { ok: r.ok, status: r.status, json, text }
158
+ }
159
+
160
+ // ---- HTTP surface, mounted on the plugin router --------------------------------
161
+ const send = (res, code, obj) => {
162
+ res.statusCode = code
163
+ res.setHeader('Content-Type', 'application/json')
164
+ res.end(JSON.stringify(obj))
165
+ }
166
+
167
+ function readJson (req) {
168
+ return new Promise((resolve) => {
169
+ let size = 0
170
+ const chunks = []
171
+ req.on('data', (c) => {
172
+ size += c.length
173
+ if (size > MAX_BODY_BYTES) { req.destroy(); resolve(undefined); return }
174
+ chunks.push(c)
175
+ })
176
+ req.on('error', () => resolve(undefined))
177
+ req.on('end', () => {
178
+ const raw = Buffer.concat(chunks).toString('utf8')
179
+ if (!raw.trim()) return resolve(null)
180
+ try { resolve(JSON.parse(raw)) } catch { resolve(undefined) }
181
+ })
182
+ })
183
+ }
184
+
185
+ // `rest` is the path after /cloud — '', 'status', 'login', 'logout', 'profile/...'
186
+ async function handle (rest, req, res) {
187
+ const [head, ...tail] = String(rest || '').replace(/^\/+/, '').split('/')
188
+ const method = req.method === 'HEAD' ? 'GET' : req.method
189
+
190
+ if (head === 'status' && method === 'GET') return send(res, 200, { ok: true, ...status() })
191
+
192
+ if (head === 'logout' && method === 'POST') return send(res, 200, logout())
193
+
194
+ if (head === 'login' && method === 'POST') {
195
+ const body = await readJson(req)
196
+ if (!body || typeof body !== 'object') return send(res, 400, { ok: false, code: 'bad-body', message: 'send {slug, password}' })
197
+ const r = await login(String(body.slug || '').trim(), String(body.password || ''))
198
+ return send(res, r.ok ? 200 : (r.code === 'bad-credentials' ? 401 : 502), r)
199
+ }
200
+
201
+ // Everything under /cloud/profile is relayed verbatim to the cloud's own profile API,
202
+ // so polars and routes need no per-section code here — the shapes already match the
203
+ // boat's local copy (lib/profile/index.js mirrors the same router).
204
+ if (head === 'profile') {
205
+ const body = (method === 'POST' || method === 'PUT') ? JSON.stringify(await readJson(req)) : null
206
+ const target = '/api/profile' + (tail.length ? '/' + tail.map(encodeURIComponent).join('/') : '')
207
+ const r = await request(target, { method, body })
208
+ if (!r.ok && r.code) return send(res, r.status === 401 ? 401 : 502, { ok: false, code: r.code, message: r.message })
209
+ res.statusCode = r.status
210
+ res.setHeader('Content-Type', 'application/json')
211
+ return res.end(r.text || '{}')
212
+ }
213
+
214
+ send(res, 404, { ok: false, code: 'not-found', message: `${req.method} /cloud/${rest} not found` })
215
+ }
216
+
217
+ function start () {
218
+ load()
219
+ log(session ? `cloud session for "${session.slug}" loaded` : 'no cloud session — log in on the Sync page to copy polars and routes')
220
+ }
221
+ function stop () { session = null }
222
+
223
+ return { start, stop, handle, status, login, logout, request, _file: () => file }
224
+ }
225
+
226
+ module.exports = { createCloud, COOKIE_NAME }
@@ -93,9 +93,11 @@ function createProxy (app, options) {
93
93
  history: options.history || null,
94
94
  aisTargets: options.aisTargets || null,
95
95
  profile: options.profile || null,
96
+ boat: options.boat || null, // { perfKey, slug } — patched into /api/config, see serveConfig
96
97
  openAccess: options.openAccess !== false
97
98
  }
98
99
  log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
100
+ if (cfg.boat && cfg.boat.perfKey) log(`boat identity for the app: perfKey=${cfg.boat.perfKey} (performance data cloud at /perf/${cfg.boat.perfKey}/)`)
99
101
 
100
102
  // Cache-manifest poller: auto-refresh a dataset lazily when the cloud
101
103
  // announces a new bake. Tiles are otherwise pinned (no time-based expiry).
@@ -293,6 +295,26 @@ function createProxy (app, options) {
293
295
  if (j && typeof j === 'object') {
294
296
  j.auth = { ...(j.auth || {}), required: false } // single-tenant boat: no cloud login
295
297
  if (cfg.history && cfg.history.available()) j.historyAvailable = true // served locally (InfluxDB or ring)
298
+ // The cloud fills `boat` only for a logged-in session, and the mirror forwards no
299
+ // cookie — so it always arrived as null and the app had no identity. Harmless for
300
+ // most of the UI, but public/engine/polar-cloud.js keys the performance data cloud
301
+ // on boat.perfKey and throws 'no boat identity' without it, so the polar cloud was
302
+ // simply absent on the boat while the bake sat cached and reachable.
303
+ //
304
+ // perfKey is not guessed: server/auth/registry.js defaults `bucket` and `perfKey`
305
+ // from the same identity, so the bucket minus its _raw suffix IS the perf key —
306
+ // for UUID accounts and for grandfathered slug ones alike.
307
+ if (cfg.boat && cfg.boat.perfKey) {
308
+ j.boat = {
309
+ ...(j.boat || {}),
310
+ perfKey: cfg.boat.perfKey,
311
+ slug: cfg.boat.slug || cfg.boat.perfKey,
312
+ name: (j.boat && j.boat.name) || cfg.boat.slug || cfg.boat.perfKey,
313
+ // NEVER readOnly: that flag exists for public visitor sessions, and it hides
314
+ // the editing affordances. The owner at the chart table has full control.
315
+ readOnly: false
316
+ }
317
+ }
296
318
  out = Buffer.from(JSON.stringify(j))
297
319
  ct = 'application/json'
298
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/public/index.html CHANGED
@@ -51,6 +51,7 @@
51
51
  <div class="grid">
52
52
  <a class="card" id="desktop" href="#"><strong>Open Sailkick</strong><span>Full app — chart, trends, weather</span></a>
53
53
  <a class="card" id="mobile" href="#"><strong>Open on phone</strong><span>Mobile view — swipeable instrument decks</span></a>
54
+ <a class="card" href="sync.html"><strong>Sync polars &amp; routes</strong><span>Copy between this boat and the cloud</span></a>
54
55
  </div>
55
56
 
56
57
  <div class="note" id="note">Locating the mirror…</div>
@@ -0,0 +1,233 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sailkick · Sync</title>
7
+ <!--
8
+ Copy polars and routes between this boat and the cloud account.
9
+
10
+ Both sides are read through the PLUGIN ROUTER (/plugins/sailkick-boat/...), never the
11
+ mirror on :8080 — that router is behind Signal K's own security, and it keeps this page
12
+ same-origin with both sides so no CORS is involved.
13
+
14
+ Items are matched by NAME, not id: ids are assigned independently on each side, so the
15
+ same polar has two different ids. Nothing is ever overwritten without a second, explicit
16
+ click, and nothing is deleted at all.
17
+ -->
18
+ <style>
19
+ :root { color-scheme: light dark; --bg:#f6f7f9; --fg:#12161c; --mut:#5c6672; --card:#fff; --line:#e2e6eb;
20
+ --accent:#0b6ea9; --ok:#1d7a4c; --warn:#b4791f; --err:#b4453a }
21
+ @media (prefers-color-scheme: dark) {
22
+ :root { --bg:#11151a; --fg:#e8edf2; --mut:#96a1ad; --card:#171c23; --line:#252c35;
23
+ --accent:#4aa8e0; --ok:#4cc38a; --warn:#d9a441; --err:#e0736a }
24
+ }
25
+ * { box-sizing: border-box }
26
+ body { margin:0; padding:1.5rem 1rem 3rem; background:var(--bg); color:var(--fg);
27
+ font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
28
+ main { max-width:60rem; margin:0 auto }
29
+ h1 { margin:0 0 .2rem; font-size:1.4rem; display:flex; align-items:center; gap:.5rem }
30
+ h1 img { border-radius:.35rem }
31
+ h2 { font-size:1rem; margin:1.6rem 0 .5rem; color:var(--mut); font-weight:600;
32
+ text-transform:uppercase; letter-spacing:.04em }
33
+ .sub { color:var(--mut); margin:0 0 1.2rem }
34
+ .bar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; padding:.8rem 1rem; background:var(--card);
35
+ border:1px solid var(--line); border-radius:.6rem; margin-bottom:1rem }
36
+ .bar .grow { flex:1 1 auto }
37
+ input { padding:.45rem .6rem; border:1px solid var(--line); border-radius:.4rem;
38
+ background:var(--bg); color:var(--fg); font:inherit; min-width:9rem }
39
+ button { padding:.45rem .8rem; border:1px solid var(--line); border-radius:.4rem; background:var(--card);
40
+ color:inherit; font:inherit; cursor:pointer }
41
+ button:hover:not(:disabled) { border-color:var(--accent) }
42
+ button:disabled { opacity:.4; cursor:default }
43
+ button.primary { background:var(--accent); border-color:var(--accent); color:#fff }
44
+ table { width:100%; border-collapse:collapse; background:var(--card);
45
+ border:1px solid var(--line); border-radius:.6rem; overflow:hidden }
46
+ th, td { padding:.55rem .7rem; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle }
47
+ th { font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; color:var(--mut) }
48
+ tr:last-child td { border-bottom:none }
49
+ td.act { text-align:right; white-space:nowrap }
50
+ .tag { font-size:.75rem; padding:.1rem .45rem; border-radius:.3rem; border:1px solid var(--line); color:var(--mut) }
51
+ .tag.ok { color:var(--ok); border-color:var(--ok) }
52
+ .tag.diff { color:var(--warn); border-color:var(--warn) }
53
+ .tag.only { color:var(--accent); border-color:var(--accent) }
54
+ .msg { padding:.7rem 1rem; border-radius:.5rem; border:1px solid var(--line); background:var(--card); margin:.8rem 0 }
55
+ .msg.err { border-color:var(--err); color:var(--err) }
56
+ .msg.ok { border-color:var(--ok) }
57
+ .empty { color:var(--mut); padding:.8rem .7rem }
58
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em }
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ <h1><img src="icon.png" alt="" width="26" height="26">Sync polars &amp; routes</h1>
64
+ <p class="sub">Copy between this boat and your sailkick cloud account. Nothing is deleted, and nothing is
65
+ overwritten without asking.</p>
66
+
67
+ <div class="bar" id="authbar"><span class="grow">Checking the cloud session…</span></div>
68
+ <div id="msg"></div>
69
+
70
+ <h2>Polars</h2>
71
+ <div id="polars"></div>
72
+ <h2>Routes</h2>
73
+ <div id="routes"></div>
74
+ </main>
75
+
76
+ <script>
77
+ (function () {
78
+ var API = '/plugins/sailkick-boat';
79
+ var $ = function (id) { return document.getElementById(id); };
80
+ var esc = function (s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
81
+ var state = { loggedIn: false, boat: {}, cloud: {} };
82
+
83
+ function say (text, kind) {
84
+ $('msg').innerHTML = text ? '<div class="msg ' + (kind || '') + '">' + esc(text) + '</div>' : '';
85
+ }
86
+
87
+ function j (path, opts) {
88
+ return fetch(API + path, Object.assign({ credentials: 'same-origin' }, opts || {}))
89
+ .then(function (r) { return r.json().catch(function () { return {}; })
90
+ .then(function (b) { return { status: r.status, body: b }; }); });
91
+ }
92
+
93
+ // ---- identity ---------------------------------------------------------------
94
+ // Ids are per-side, so a polar present in both has two different ones. Name is the
95
+ // only stable handle across the boundary.
96
+ function key (item) { return String(item && item.name || '').trim().toLowerCase(); }
97
+
98
+ // Same name — same thing? Compare what the user actually authored, not the wrapper:
99
+ // the CSV for a polar, the geometry for a route. updatedAt and id always differ.
100
+ function same (a, b, section) {
101
+ if (section === 'polars') return String(a.csv || '') === String(b.csv || '');
102
+ return JSON.stringify(a.path || null) === JSON.stringify(b.path || null) &&
103
+ JSON.stringify(a.destination || null) === JSON.stringify(b.destination || null);
104
+ }
105
+
106
+ function rows (section) {
107
+ var boat = state.boat[section] || [], cloud = state.cloud[section] || [];
108
+ var names = {}, out = [];
109
+ boat.forEach(function (i) { names[key(i)] = { name: i.name, boat: i }; });
110
+ cloud.forEach(function (i) { (names[key(i)] = names[key(i)] || { name: i.name }).cloud = i; });
111
+ Object.keys(names).sort().forEach(function (k) { out.push(names[k]); });
112
+ return out;
113
+ }
114
+
115
+ function render (section, el) {
116
+ var list = rows(section);
117
+ if (!list.length) {
118
+ el.innerHTML = '<div class="msg"><span class="empty">Nothing on either side yet.</span></div>';
119
+ return;
120
+ }
121
+ var html = '<table><tr><th>Name</th><th>On the boat</th><th>In the cloud</th><th></th></tr>';
122
+ list.forEach(function (r, i) {
123
+ var tag, act = '';
124
+ if (r.boat && r.cloud) {
125
+ var eq = same(r.boat, r.cloud, section);
126
+ tag = eq ? '<span class="tag ok">in sync</span>' : '<span class="tag diff">differs</span>';
127
+ if (!eq) {
128
+ act = '<button data-a="pull" data-s="' + section + '" data-i="' + i + '">⤓ cloud → boat</button> ' +
129
+ '<button data-a="push" data-s="' + section + '" data-i="' + i + '">⤒ boat → cloud</button>';
130
+ }
131
+ } else if (r.cloud) {
132
+ tag = '<span class="tag only">cloud only</span>';
133
+ act = '<button class="primary" data-a="pull" data-s="' + section + '" data-i="' + i + '">⤓ copy to boat</button>';
134
+ } else {
135
+ tag = '<span class="tag only">boat only</span>';
136
+ act = '<button data-a="push" data-s="' + section + '" data-i="' + i + '">⤒ copy to cloud</button>';
137
+ }
138
+ html += '<tr><td>' + esc(r.name) + ' ' + tag + '</td>' +
139
+ '<td>' + (r.boat ? '✓' : '—') + '</td>' +
140
+ '<td>' + (r.cloud ? '✓' : '—') + '</td>' +
141
+ '<td class="act">' + (state.loggedIn ? act : '') + '</td></tr>';
142
+ });
143
+ el.innerHTML = html + '</table>';
144
+ el.querySelectorAll('button[data-a]').forEach(function (b) {
145
+ b.addEventListener('click', function () { copy(b.dataset.a, b.dataset.s, Number(b.dataset.i), b); });
146
+ });
147
+ }
148
+
149
+ // ---- copy one item ----------------------------------------------------------
150
+ function copy (dir, section, idx, btn) {
151
+ var r = rows(section)[idx];
152
+ var from = dir === 'pull' ? r.cloud : r.boat;
153
+ var onto = dir === 'pull' ? r.boat : r.cloud;
154
+ if (!from) return;
155
+ if (onto && !confirm('"' + r.name + '" already exists on the ' + (dir === 'pull' ? 'boat' : 'cloud') +
156
+ ' and differs.\n\nOverwrite it with the ' + (dir === 'pull' ? 'cloud' : 'boat') + ' copy?')) return;
157
+
158
+ // Carry only the authored fields — never the source id or updatedAt, which belong to
159
+ // the side they came from.
160
+ var body = {};
161
+ Object.keys(from).forEach(function (k) { if (k !== 'id' && k !== 'updatedAt') body[k] = from[k]; });
162
+
163
+ var base = dir === 'pull' ? '/profile/' + section : '/cloud/profile/' + section;
164
+ var path = onto ? base + '/' + encodeURIComponent(onto.id) : base;
165
+ btn.disabled = true;
166
+ say('Copying "' + r.name + '"…');
167
+ j(path, { method: onto ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
168
+ .then(function (res) {
169
+ if (res.status >= 200 && res.status < 300) { say('Copied "' + r.name + '".', 'ok'); return load(); }
170
+ say('Could not copy "' + r.name + '": ' + (res.body.message || 'HTTP ' + res.status), 'err');
171
+ btn.disabled = false;
172
+ })
173
+ .catch(function (e) { say('Could not copy: ' + e.message, 'err'); btn.disabled = false; });
174
+ }
175
+
176
+ // ---- load both sides --------------------------------------------------------
177
+ function load () {
178
+ return j('/cloud/status').then(function (s) {
179
+ state.loggedIn = !!(s.body && s.body.loggedIn);
180
+ authbar(s.body || {});
181
+ // Both sides use the same section endpoints, so the response shape is identical:
182
+ // { ok, polars: [...] } / { ok, routes: [...] }.
183
+ var want = [j('/profile/polars'), j('/profile/routes')];
184
+ if (state.loggedIn) want.push(j('/cloud/profile/polars'), j('/cloud/profile/routes'));
185
+ return Promise.all(want).then(function (r) {
186
+ state.boat.polars = (r[0].body && r[0].body.polars) || [];
187
+ state.boat.routes = (r[1].body && r[1].body.routes) || [];
188
+ state.cloud.polars = state.loggedIn ? ((r[2].body && r[2].body.polars) || []) : [];
189
+ state.cloud.routes = state.loggedIn ? ((r[3].body && r[3].body.routes) || []) : [];
190
+ if (state.loggedIn && r[2].status === 401) { state.loggedIn = false; authbar({ loggedIn: false }); }
191
+ render('polars', $('polars'));
192
+ render('routes', $('routes'));
193
+ });
194
+ }).catch(function (e) { say('Cannot reach the plugin: ' + e.message, 'err'); });
195
+ }
196
+
197
+ function authbar (s) {
198
+ var bar = $('authbar');
199
+ if (s.loggedIn) {
200
+ bar.innerHTML = '<span class="grow">Signed in to the cloud as <b>' + esc(s.slug) + '</b>' +
201
+ (s.expiresInDays != null ? ' · session expires in ' + s.expiresInDays + ' days' : '') + '</span>' +
202
+ '<button id="out">Sign out</button>';
203
+ $('out').addEventListener('click', function () {
204
+ j('/cloud/logout', { method: 'POST' }).then(function () { say(''); load(); });
205
+ });
206
+ } else {
207
+ bar.innerHTML = '<span class="grow">Sign in to your sailkick account to see the cloud side.</span>' +
208
+ '<input id="slug" placeholder="boat name" autocomplete="username">' +
209
+ '<input id="pw" type="password" placeholder="password" autocomplete="current-password">' +
210
+ '<button class="primary" id="in">Sign in</button>';
211
+ var go = function () {
212
+ var slug = $('slug').value.trim(), pw = $('pw').value;
213
+ if (!slug || !pw) return;
214
+ $('in').disabled = true; say('Signing in…');
215
+ j('/cloud/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
216
+ body: JSON.stringify({ slug: slug, password: pw }) })
217
+ .then(function (res) {
218
+ if (res.body && res.body.loggedIn) { say('Signed in. Your password is not stored — only the session.', 'ok'); return load(); }
219
+ say((res.body && res.body.message) || 'Sign in failed', 'err');
220
+ $('in').disabled = false;
221
+ })
222
+ .catch(function (e) { say('Sign in failed: ' + e.message, 'err'); $('in').disabled = false; });
223
+ };
224
+ $('in').addEventListener('click', go);
225
+ $('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
226
+ }
227
+ }
228
+
229
+ load();
230
+ })();
231
+ </script>
232
+ </body>
233
+ </html>