From 0f10581d278a380928e207e5c174fd0a08d8d46b Mon Sep 17 00:00:00 2001 From: Byron Harris Date: Sat, 27 Jun 2026 07:43:22 -0500 Subject: [PATCH] Implement the backup command Back up the entire account by dumping the CouchDB/Cloudant sync database directly, since the public API has no bulk-export endpoint. Output matches Marvin's GUI backup format (a JSON array of docs, with the internal _rev omitted except on conflicted records) and is written to MarvinBackup-.json by default. - Read all docs via _all_docs?include_docs=true&conflicts=true, paginated, with a per-request timeout and cross-page dedupe (live read, not a snapshot) - Strip _rev to match the GUI format; keep it on conflicted records as Marvin's marker and print a conflict warning - Add syncServer/syncDatabase/syncUser/syncPassword config keys and matching --sync-* flags (per-run alternative that keeps syncPassword out of config) - Move backup out of the desktop-only set: it talks to CouchDB directly, not the desktop or public API - Extract the CouchDB logic into backup_couch.ts with unit tests covering pagination, _design exclusion, _rev/_conflicts handling, dedupe, and grouping Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + README.md | 58 ++++++++++- TODO.md | 1 - src/commands/backup.ts | 168 +++++++++++++++++++++++++++++- src/commands/backup_couch.ts | 116 +++++++++++++++++++++ src/commands/backup_couch_test.ts | 99 ++++++++++++++++++ src/commands/config.ts | 48 +++++++++ src/index.ts | 13 ++- src/options.ts | 40 +++++++ src/types.ts | 4 + 10 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 src/commands/backup_couch.ts create mode 100644 src/commands/backup_couch_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a21c8..340db8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Unreleased +* Added "backup" command (full-account export via the CouchDB sync database) +* Added syncServer/syncDatabase/syncUser/syncPassword config keys + # 0.5.1 (2023-03-24) * Fixed default marvin-cli.json location on linux * Updated deno and dependencies diff --git a/README.md b/README.md index e6b8e1d..c941514 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ COMMANDS: get - Read an arbitrary document from your database today - List Tasks and Projects that are scheduled today tracking - Get the currently tracked task + backup - Back up your entire account to a JSON file update - Update a Task, Project, or other (not yet implemented) delete - Delete a Task, Project, or other (not yet implemented) help - Help about any command @@ -44,11 +45,64 @@ DESKTOP COMMANDS: run - Start the desktop app (not yet implemented) quickAdd - Open desktop quick add list - List Tasks/Projects, optionally filtered - backup - Trigger backups (not yet implemented) restore - Restore backups (not yet implemented) quit - Shut down the app (not yet implemented) ``` +# Backup + +`marvin backup` dumps your entire account to a JSON file (an array of +records, mirroring Marvin's own backup format). Marvin's public API has no +bulk-export endpoint, so this reads directly from your CouchDB/Cloudant sync +database. Find the sync credentials in the API strategy settings or at +https://app.amazingmarvin.com/?api. + +You can pass the credentials per run with `--sync-server`, `--sync-database`, +`--sync-user`, and `--sync-password`. Passing `--sync-password` this way keeps +it out of the plaintext config file: + +```bash +marvin backup \ + --sync-server https://... \ + --sync-database XYZ \ + --sync-user XYZ \ + --sync-password "$MARVIN_SYNC_PASSWORD" +``` + +Alternatively, save them once with `marvin config` so you can just run +`marvin backup` (note this writes them, including `syncPassword`, to the +plaintext config file): + +```bash +marvin config syncServer https://... +marvin config syncDatabase XYZ +marvin config syncUser XYZ +marvin config syncPassword XYZ +``` + +Either way, control the output with `-o`: + +```bash +marvin backup # writes MarvinBackup-.json +marvin backup -o my-backup.json +marvin backup -o - # write to stdout +``` + +The backup also surfaces sync conflicts: if any records are in an unresolved +conflict state, a warning is printed and those records keep their internal +`_rev` field as a marker (matching Marvin's own backups). Resolve them via +Marvin's Database Check. + +This is a live read of the database, not a guaranteed point-in-time snapshot — +for the most consistent result, back up when Marvin isn't actively syncing. + +This is equivalent to Marvin's +[GUI backup](https://help.amazingmarvin.com/en/articles/2278325-how-to-backup-your-data) +with the **Device Local Settings/Data** option left unchecked: it includes +everything in the sync database, but not data that isn't synced between your +devices (trashed items, which sections are opened/closed, etc.), which the sync +database doesn't hold. + # Installation Download a release for your platform [here](https://github.com/amazingmarvin/marvin-cli/releases). @@ -62,4 +116,4 @@ Download a release for your platform [here](https://github.com/amazingmarvin/mar # Configuring -Get your API Token from https://app.amazingmarvin.com/pre?api (or find it in the API strategy settings) then run `marvin config apiToken XYZ` or use the `--api-token` command line option. +Get your API Token from https://app.amazingmarvin.com/?api (or find it in the API strategy settings) then run `marvin config apiToken XYZ` or use the `--api-token` command line option. diff --git a/TODO.md b/TODO.md index 8796d3e..35251cb 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,6 @@ * Finish help command * Implement run command * Implement quickAdd command -* Implement backup command * Implement restore command * Implement quit command * Go through help text diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 5349b31..ad98dbe 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -1,4 +1,170 @@ +import { getOptions, globalOptionHelp } from "../options.ts"; import { Params, Options } from "../types.ts"; +import { type CouchDoc, fetchAllDocs, uuidOf } from "./backup_couch.ts"; + +// Back up the entire Marvin account by dumping the CouchDB/Cloudant sync +// database directly. The public API has no bulk-export endpoint, but every +// document lives in a single sync database that we can read with the +// syncServer/syncDatabase/syncUser/syncPassword credentials from the API +// strategy settings. The CouchDB plumbing lives in backup_couch.ts. export default async function backup(params: Params, cmdOpt: Options) { - console.log("Not yet implemented"); + if (cmdOpt.help) { + console.log(backupHelp); + Deno.exit(0); + } + + if (params.length !== 0) { + console.error(backupHelp); + Deno.exit(1); + } + + const opt = getOptions(); + + const missing = ["syncServer", "syncDatabase", "syncUser", "syncPassword"] + .filter((key) => !opt[key as keyof typeof opt]); + if (missing.length > 0) { + console.error( + `Missing sync credentials: ${missing.join(", ")}.\n` + + `The backup command reads your Marvin data directly from its CouchDB\n` + + `sync database. Grab syncServer, syncDatabase, syncUser, and\n` + + `syncPassword from the API strategy settings (or\n` + + `https://app.amazingmarvin.com/?api) and save them with e.g.\n` + + ` marvin config syncServer https://...\n` + + ` marvin config syncDatabase ...\n` + + ` marvin config syncUser ...\n` + + ` marvin config syncPassword ...`, + ); + Deno.exit(1); + } + + const server = (opt.syncServer as string).replace(/\/+$/, ""); + const database = encodeURIComponent(opt.syncDatabase as string); + const baseUrl = `${server}/${database}`; + const authHeader = "Basic " + btoa(`${opt.syncUser}:${opt.syncPassword}`); + + let result: { docs: CouchDoc[]; conflictedIds: string[] }; + try { + result = await fetchAllDocs(baseUrl, authHeader); + } catch (err) { + console.error(`Backup failed: ${errMessage(err)}`); + Deno.exit(1); + } + + const { docs, conflictedIds } = result; + const json = JSON.stringify(docs, null, 2); + + // Where to write: -o/--output, defaulting to a dated file in the current + // directory. Use "-o -" to write to stdout instead. + const output = typeof cmdOpt.output === "string" ? cmdOpt.output : null; + const toStdout = output === "-"; + const dest = toStdout ? null : (output ?? defaultFilename()); + + if (toStdout) { + console.log(json); + } else { + try { + Deno.writeTextFileSync(dest as string, json); + } catch (err) { + console.error(`Failed to write backup to "${dest}": ${errMessage(err)}`); + Deno.exit(1); + } + } + + // Diagnostics go to stderr so they don't corrupt piped JSON, and respect + // --quiet. The conflict warning (below) is printed regardless. + if (!opt.quiet) { + console.error( + `Backed up ${docs.length} records to ${toStdout ? "stdout" : dest}`, + ); + } + reportConflicts(conflictedIds); + Deno.exit(0); } + +// Warn about records in a sync-conflict state, counting them the way Marvin's +// Database Check does (see uuidOf). Printed even with --quiet, since it signals +// a data-integrity problem. +function reportConflicts(conflictedIds: string[]): void { + if (conflictedIds.length === 0) { + return; + } + const groups = new Set(conflictedIds.map(uuidOf)).size; + console.error( + `⚠ ${groups} unresolved sync conflict(s) detected ` + + `(${conflictedIds.length} conflicted record(s)) — ` + + `resolve via Marvin's Database Check`, + ); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function defaultFilename(): string { + // Match the name the Marvin GUI uses, e.g. MarvinBackup-20260626.json + // (local date, YYYYMMDD). + const d = new Date(); + const stamp = `${d.getFullYear()}` + + `${String(d.getMonth() + 1).padStart(2, "0")}` + + `${String(d.getDate()).padStart(2, "0")}`; + return `MarvinBackup-${stamp}.json`; +} + +export const backupHelp = ` +marvin backup [-o ] + +Back up your entire Marvin account. + +This reads every record directly from your Marvin CouchDB/Cloudant sync +database (the public API has no bulk-export endpoint) and writes them to a +JSON file as an array of records. The format mirrors Marvin's own backup +files (the internal "_rev" field is omitted), so it can be inspected or +re-imported. + +If any records are in an unresolved sync-conflict state, a warning is printed +and those records keep their "_rev" field as a marker (just like Marvin's own +backups) — resolve them via Marvin's Database Check. + +Note: this is a live read of the database, not a guaranteed point-in-time +snapshot; for the most consistent result, back up when Marvin isn't actively +syncing. + +This is equivalent to Marvin's GUI backup with "Device Local Settings/Data" +left unchecked: it includes everything in the sync database, but not data that +isn't synced between your devices (trashed items, which sections are +opened/closed, etc.), which the sync database doesn't hold. + +You must first configure your sync credentials (one-time): + marvin config syncServer https://... + marvin config syncDatabase ... + marvin config syncUser ... + marvin config syncPassword ... +Find these in the API strategy settings or at +https://app.amazingmarvin.com/?api. + +EXAMPLE: + # Write a timestamped backup file to the current directory + $ marvin backup + + # Write to a specific file + $ marvin backup -o my-backup.json + + # Write to stdout (e.g. to pipe elsewhere) + $ marvin backup -o - + +OPTIONS: + -o, --output + Where to write the backup. Defaults to MarvinBackup-.json + in the current directory (matching the Marvin GUI's naming). Use "-" + to write to stdout. + + --sync-server + --sync-database + --sync-user + --sync-password + Provide sync credentials for this run instead of reading them from + config. Useful for keeping syncPassword out of the plaintext config + file, e.g. --sync-password "$MARVIN_SYNC_PASSWORD". + +${globalOptionHelp} +`.trim(); diff --git a/src/commands/backup_couch.ts b/src/commands/backup_couch.ts new file mode 100644 index 0000000..aabf301 --- /dev/null +++ b/src/commands/backup_couch.ts @@ -0,0 +1,116 @@ +// CouchDB/Cloudant access for the `backup` command. Kept free of any config, +// filesystem, or process I/O so it can be unit-tested with a mocked `fetch` +// (see backup_couch_test.ts). + +// deno-lint-ignore no-explicit-any +export type CouchDoc = Record; + +// How many documents to fetch per request when paginating _all_docs. Keeping +// this bounded avoids huge responses / timeouts on large accounts. +export const BATCH_SIZE = 1000; + +// Abort a single page request that hangs, so a dead connection fails fast +// instead of waiting forever. +export const REQUEST_TIMEOUT_MS = 30_000; + +// Page through _all_docs?include_docs=true&conflicts=true, returning every +// document (design documents excluded, since they are CouchDB internals rather +// than Marvin data and aren't part of Marvin's own backup format). +// +// To mirror Marvin's GUI backup format we drop the CouchDB-internal `_rev` +// field, which is present on every document over the API but which the GUI +// omits — except on documents whose revision tree is in a conflicted state, +// where the GUI keeps `_rev` as a marker. `conflicts=true` makes CouchDB add a +// `_conflicts` array to exactly those documents, so we use it to decide which +// records keep `_rev` (and we strip the `_conflicts` array itself, which the +// GUI doesn't emit). The `_id`s of conflicted records are returned so the +// caller can warn about them. +// +// `batchSize` is injectable so tests can exercise pagination without thousands +// of fixtures. +export async function fetchAllDocs( + baseUrl: string, + authHeader: string, + batchSize: number = BATCH_SIZE, +): Promise<{ docs: CouchDoc[]; conflictedIds: string[] }> { + const docs: CouchDoc[] = []; + const conflictedIds: string[] = []; + const seen = new Set(); + let startkey: string | null = null; + + for (;;) { + let url = + `${baseUrl}/_all_docs?include_docs=true&conflicts=true&limit=${batchSize}`; + if (startkey !== null) { + // startkey is inclusive, so skip=1 avoids re-fetching the boundary doc. + url += `&startkey=${encodeURIComponent(JSON.stringify(startkey))}&skip=1`; + } + + let res: Response; + try { + res = await fetch(url, { + method: "GET", + headers: { "Authorization": authHeader }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new Error(`request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`); + } + throw err; + } + + if (!res.ok) { + const text = await res.text(); + throw new Error(`[${res.status}] ${text || res.statusText}`); + } + + const body = await res.json(); + const rows: { id: string; doc?: CouchDoc }[] = body.rows ?? []; + if (rows.length === 0) { + break; + } + + for (const row of rows) { + // Skip design docs (CouchDB internals) and any id already collected. The + // dedupe guards against a row reappearing across pages if the database + // changes mid-dump — this is a live read, not a point-in-time snapshot. + if (!row.doc || row.id.startsWith("_design/") || seen.has(row.id)) { + continue; + } + seen.add(row.id); + const doc = row.doc; + if (Array.isArray(doc._conflicts) && doc._conflicts.length > 0) { + // Conflicted: keep `_rev` as Marvin's conflict marker, drop the + // `_conflicts` array (which the GUI backup doesn't include). + delete doc._conflicts; + conflictedIds.push(doc._id); + } else { + // Normal record: the GUI backup omits `_rev`. + delete doc._rev; + } + docs.push(doc); + } + + // Loop control uses the raw rows (not the deduped docs): a short page means + // we've reached the end, and the next startkey is the last raw id. + if (rows.length < batchSize) { + break; + } + startkey = rows[rows.length - 1].id; + } + + return { docs, conflictedIds }; +} + +// Group key for a conflicted record's _id. Recurring-task occurrences share one +// UUID (the _id is "YYYY-MM-DD_"), so a single conflicted recurring task +// appears as several conflicted occurrence records; grouping by the trailing +// UUID yields a count that matches Marvin's Database Check. This mirrors +// marvin-backup-tools' check_integrity.py so the two agree. It's an approximate +// grouping (a non-occurrence id that happened to contain "_" would group oddly), +// but it only affects the warning count, never the backed-up data. +export function uuidOf(id: string): string { + const i = id.indexOf("_"); + return i === -1 ? id : id.slice(i + 1); +} diff --git a/src/commands/backup_couch_test.ts b/src/commands/backup_couch_test.ts new file mode 100644 index 0000000..55b6cc3 --- /dev/null +++ b/src/commands/backup_couch_test.ts @@ -0,0 +1,99 @@ +import { assertEquals } from "https://deno.land/std@0.184.0/testing/asserts.ts"; +import { type CouchDoc, fetchAllDocs, uuidOf } from "./backup_couch.ts"; + +// Replace globalThis.fetch with `impl` for the duration of `fn`, restoring it +// afterward. +async function withFetch( + impl: (url: string) => Response, + fn: () => Promise, +): Promise { + const original = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request) => + Promise.resolve(impl(String(input)))) as typeof fetch; + try { + await fn(); + } finally { + globalThis.fetch = original; + } +} + +function jsonResponse(rows: unknown[]): Response { + return new Response(JSON.stringify({ total_rows: rows.length, rows }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +// A faithful _all_docs mock: serves a sorted store honoring limit/startkey/skip. +function couchServer(store: CouchDoc[]): (url: string) => Response { + const sorted = [...store].sort((a, b) => + a._id < b._id ? -1 : a._id > b._id ? 1 : 0 + ); + return (url: string) => { + const q = new URL(url).searchParams; + const limit = Number(q.get("limit")); + const skip = Number(q.get("skip") ?? "0"); + const startkeyRaw = q.get("startkey"); + let rows = sorted; + if (startkeyRaw !== null) { + const startkey = JSON.parse(startkeyRaw); + rows = rows.filter((d) => d._id >= startkey); + } + rows = rows.slice(skip, skip + limit); + return jsonResponse( + rows.map((d) => ({ id: d._id, key: d._id, value: { rev: d._rev }, doc: d })), + ); + }; +} + +Deno.test("fetchAllDocs: paginates, excludes _design, handles _rev/_conflicts", async () => { + const store: CouchDoc[] = [ + { _id: "_design/x", _rev: "1-x", views: {} }, + { _id: "a", _rev: "1-a", db: "Tasks", title: "A" }, + { _id: "b", _rev: "1-b", db: "Tasks", title: "B" }, + { _id: "c", _rev: "2-c", _conflicts: ["1-old"], db: "Tasks", title: "C" }, + { _id: "d", _rev: "1-d", db: "Categories", title: "D" }, + { _id: "e", _rev: "1-e", db: "Goals", title: "E" }, + ]; + + await withFetch(couchServer(store), async () => { + // batchSize 2 forces several pages over the 5 real docs. + const { docs, conflictedIds } = await fetchAllDocs("http://h/db", "Basic x", 2); + + assertEquals(docs.map((d) => d._id), ["a", "b", "c", "d", "e"]); + // Design doc excluded. + assertEquals(docs.some((d) => d._id.startsWith("_design")), false); + // _rev stripped from normal records... + assertEquals("_rev" in docs.find((d) => d._id === "a")!, false); + assertEquals("_rev" in docs.find((d) => d._id === "d")!, false); + // ...but kept on the conflicted one, with _conflicts removed. + const c = docs.find((d) => d._id === "c")!; + assertEquals(c._rev, "2-c"); + assertEquals("_conflicts" in c, false); + assertEquals(conflictedIds, ["c"]); + }); +}); + +Deno.test("fetchAllDocs: dedupes ids repeated across pages", async () => { + // Simulate a row reappearing across pages (e.g. a concurrent change). The + // server ignores paging params and just replays overlapping pages. + const pages = [ + [{ id: "a", doc: { _id: "a", _rev: "1-a" } }, { id: "b", doc: { _id: "b", _rev: "1-b" } }], + [{ id: "b", doc: { _id: "b", _rev: "1-b" } }, { id: "c", doc: { _id: "c", _rev: "1-c" } }], + [], + ]; + let call = 0; + await withFetch(() => jsonResponse(pages[call++] ?? []), async () => { + const { docs } = await fetchAllDocs("http://h/db", "Basic x", 2); + assertEquals(docs.map((d) => d._id), ["a", "b", "c"]); + }); +}); + +Deno.test("uuidOf: groups occurrence ids by trailing uuid", () => { + assertEquals(uuidOf("2026-01-01_abc"), "abc"); + assertEquals(uuidOf("plainid"), "plainid"); + const groups = new Set( + ["2026-01-01_abc", "2026-01-02_abc", "plain"].map(uuidOf), + ); + assertEquals(groups.size, 2); +}); diff --git a/src/commands/config.ts b/src/commands/config.ts index 6ce0e94..5026cb6 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -10,6 +10,10 @@ const whitelist = [ "publicHost", "apiToken", "fullAccessToken", + "syncServer", + "syncDatabase", + "syncUser", + "syncPassword", "target", "quiet", ]; @@ -32,6 +36,10 @@ export default async function config(params: Params, cmdOpt: Options) { publicHost, apiToken, fullAccessToken, + syncServer, + syncDatabase, + syncUser, + syncPassword, target, quiet, } = options; @@ -42,10 +50,27 @@ export default async function config(params: Params, cmdOpt: Options) { if (!fullAccessToken) { fullAccessToken = ""; } + if (!syncServer) { + syncServer = ""; + } + if (!syncDatabase) { + syncDatabase = ""; + } + if (!syncUser) { + syncUser = ""; + } + if (!syncPassword) { + syncPassword = ""; + } if (!cmdOpt["with-secrets"]) { apiToken = apiToken.replace(/.{20}$/, "..."); fullAccessToken = fullAccessToken.replace(/.{20}$/, "..."); + // Fully redact syncPassword rather than reusing the tokens' "elide last + // 20 chars" rule above: that rule keeps a prefix (and would reveal the + // whole value for a password shorter than 20 chars), which isn't safe + // for a password. + syncPassword = syncPassword ? "..." : ""; } if (cmdOpt.json) { @@ -56,6 +81,10 @@ export default async function config(params: Params, cmdOpt: Options) { publicHost, apiToken, fullAccessToken, + syncServer, + syncDatabase, + syncUser, + syncPassword, target, quiet, }, null, 2)); @@ -66,6 +95,10 @@ export default async function config(params: Params, cmdOpt: Options) { console.log(`publicHost: ${publicHost}`); console.log(`apiToken: ${apiToken}`); console.log(`fullAccessToken: ${fullAccessToken}`); + console.log(`syncServer: ${syncServer}`); + console.log(`syncDatabase: ${syncDatabase}`); + console.log(`syncUser: ${syncUser}`); + console.log(`syncPassword: ${syncPassword}`); console.log(`target: ${target}`); console.log(`quiet: ${quiet}`); } @@ -156,6 +189,21 @@ PROPERTIES: you supply it with the --full-access-token option with each run, or here once. + marvin config syncServer + marvin config syncDatabase + marvin config syncUser + marvin config syncPassword + Credentials for direct access to your Marvin CouchDB/Cloudant sync + database. These are used by the "backup" command (and, eventually, + "restore"), which read/write your data directly rather than through the + public API. Find these values in the API strategy settings or at + https://app.amazingmarvin.com/?api (look for syncServer, + syncDatabase, syncUser, and syncPassword). + + These are stored in your config dir alongside the other settings. + syncPassword is a secret and is elided from "marvin config" output + unless you pass --with-secrets. + marvin config target desktop|public|default Set "target" to "desktop" to tell marvin-cli to only connect to the desktop app's local API server (unless you run marvin-cli with the diff --git a/src/index.ts b/src/index.ts index 71cc3e5..98bd730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,10 @@ const cmdArgs = parse(Deno.args, { "file", "api-token", "full-access-token", + "sync-server", + "sync-database", + "sync-user", + "sync-password", "output", "filter", // Supply an advanced filter ], @@ -108,11 +112,14 @@ for (const key in cmdOpt) { setOptions(knownOpt); +// Commands that require the desktop app's local API server. Note "backup" is +// deliberately NOT listed: it talks directly to the CouchDB sync database, not +// the desktop or public API, so it works regardless of target and must not be +// forced to "desktop" or rejected under --public. const desktopOnly = [ "run", "quickAdd", "list", - "backup", "restore", "quit", ]; @@ -165,6 +172,7 @@ COMMANDS: get - Read an arbitrary document from your database today - List Tasks and Projects that are scheduled today tracking - Get the currently tracked task + backup - Back up your entire account to a JSON file update - Update a Task, Project, or other delete - Delete a Task, Project, or other help - Help about any command @@ -173,8 +181,7 @@ DESKTOP COMMANDS: run - Start the desktop app quickAdd - Open desktop quick add list - List Tasks/Projects, optionally filtered - backup - Trigger backups - restore - Restore backups + restore - Restore backups (not yet implemented) quit - Shut down the app `.trim()); } diff --git a/src/options.ts b/src/options.ts index cda938f..4364fa6 100644 --- a/src/options.ts +++ b/src/options.ts @@ -60,6 +60,10 @@ export function setOptions(cmdOpt: Options) { publicHost: "serv.amazingmarvin.com", apiToken: null, fullAccessToken: null, + syncServer: null, + syncDatabase: null, + syncUser: null, + syncPassword: null, target: "default", json: false, quiet: false, @@ -96,6 +100,26 @@ export function setOptions(cmdOpt: Options) { opt.fullAccessToken = savedFullAccessToken; } + const savedSyncServer = getSavedVal("syncServer"); + if (typeof savedSyncServer === "string") { + opt.syncServer = savedSyncServer; + } + + const savedSyncDatabase = getSavedVal("syncDatabase"); + if (typeof savedSyncDatabase === "string") { + opt.syncDatabase = savedSyncDatabase; + } + + const savedSyncUser = getSavedVal("syncUser"); + if (typeof savedSyncUser === "string") { + opt.syncUser = savedSyncUser; + } + + const savedSyncPassword = getSavedVal("syncPassword"); + if (typeof savedSyncPassword === "string") { + opt.syncPassword = savedSyncPassword; + } + const savedTarget = getSavedVal("target"); if (savedTarget === "desktop" || savedTarget === "public" || savedTarget === "default") { opt.target = savedTarget; @@ -125,6 +149,22 @@ export function setOptions(cmdOpt: Options) { opt.fullAccessToken = cmdOpt["full-access-token"]; } + if (typeof cmdOpt["sync-server"] === "string") { + opt.syncServer = cmdOpt["sync-server"]; + } + + if (typeof cmdOpt["sync-database"] === "string") { + opt.syncDatabase = cmdOpt["sync-database"]; + } + + if (typeof cmdOpt["sync-user"] === "string") { + opt.syncUser = cmdOpt["sync-user"]; + } + + if (typeof cmdOpt["sync-password"] === "string") { + opt.syncPassword = cmdOpt["sync-password"]; + } + if (cmdOpt.desktop) { opt.target = "desktop"; } else if (cmdOpt.public) { diff --git a/src/types.ts b/src/types.ts index 4a91b23..0bafc9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,10 @@ export type ResolvedOptions = { publicHost: string, apiToken: string|null, fullAccessToken: string|null, + syncServer: string|null, + syncDatabase: string|null, + syncUser: string|null, + syncPassword: string|null, target: "desktop" | "public" | "default", json: boolean, quiet: boolean,