mirror of
https://github.com/unabyss/obsidian-plugin.git
synced 2026-07-22 07:47:17 +00:00
Initial commit: Unabyss Obsidian plugin.
Two-way OAuth sync between Obsidian vaults and Unabyss memory, with manifest-first delta upload, exports puller, and GitHub release workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
4af453d1e7
30 changed files with 6825 additions and 0 deletions
83
.github/workflows/release.yml
vendored
Normal file
83
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
name: Release Obsidian Plugin
|
||||
|
||||
# Triggered by pushing a SemVer tag (v*). Builds the production bundle
|
||||
# and attaches main.js, manifest.json, and logo SVGs to the GitHub
|
||||
# Release - the same artefacts the Obsidian Community Plugins store
|
||||
# expects to download from the GitHub release.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Build production bundle
|
||||
run: pnpm run build
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${GITHUB_REF##*/}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify manifest version matches tag
|
||||
run: |
|
||||
MANIFEST_VERSION="$(node -e 'process.stdout.write(require("./manifest.json").version)')"
|
||||
if [ "$MANIFEST_VERSION" != "${{ steps.version.outputs.version }}" ]; then
|
||||
echo "manifest.json version ($MANIFEST_VERSION) does not match tag (${{ steps.version.outputs.version }})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect release artefacts
|
||||
run: |
|
||||
mkdir -p release
|
||||
cp main.js manifest.json logo-dark.svg logo-light.svg release/
|
||||
if [ -f styles.css ]; then cp styles.css release/; fi
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Unabyss Obsidian Plugin ${{ steps.version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
release/main.js
|
||||
release/manifest.json
|
||||
release/logo-dark.svg
|
||||
release/logo-light.svg
|
||||
release/styles.css
|
||||
fail_on_unmatched_files: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build output (committed for releases; gitignored during dev iteration)
|
||||
main.js
|
||||
*.js.map
|
||||
|
||||
# Local plugin state if the folder is symlinked into a vault during dev
|
||||
data.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Unabyss
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
120
LOCAL_INSTALL.md
Normal file
120
LOCAL_INSTALL.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Local install guide
|
||||
|
||||
A focused guide for installing the current branch's build of the
|
||||
Unabyss Obsidian plugin into a local Obsidian vault. For end-user /
|
||||
release-channel install paths (Community Plugins store, BRAT), see
|
||||
`README.md`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Obsidian Desktop installed.
|
||||
- A vault you can test against (real or a throwaway one - recommended
|
||||
for first install since the plugin's `data.json` ends up under
|
||||
`.obsidian/plugins/unabyss/` and outbound sync starts pushing notes
|
||||
once you connect).
|
||||
- `pnpm` available locally if you want to rebuild (the repo has
|
||||
`pnpm-lock.yaml` and uses `pnpm@11.1.3`).
|
||||
|
||||
## Option A - Drop-in install (fastest)
|
||||
|
||||
`main.js` is built on release tags (~16 KB, generated alongside
|
||||
`manifest.json`), so you can install without any tooling after
|
||||
downloading a GitHub release or running `pnpm run build` locally.
|
||||
|
||||
1. Find your vault path. In Obsidian: **Settings -> About -> Show
|
||||
vault folder** (or just remember where you created it). Call it
|
||||
`<vault>`.
|
||||
2. Create the plugin folder:
|
||||
|
||||
```bash
|
||||
mkdir -p "<vault>/.obsidian/plugins/unabyss"
|
||||
```
|
||||
|
||||
3. Copy the artefacts from this repository:
|
||||
|
||||
```bash
|
||||
cp main.js manifest.json logo-dark.svg logo-light.svg \
|
||||
"<vault>/.obsidian/plugins/unabyss/"
|
||||
```
|
||||
|
||||
4. Open Obsidian, go to **Settings -> Community plugins**.
|
||||
- If you see "Restricted mode is on", click **Turn on community
|
||||
plugins** (Obsidian's safety prompt for third-party code).
|
||||
5. In the same panel, scroll to **Installed plugins**, find
|
||||
**Unabyss**, and toggle it on. If it doesn't appear, hit the
|
||||
refresh icon next to "Installed plugins".
|
||||
6. The plugin's settings tab will appear under **Community plugins ->
|
||||
Unabyss**. Configure per `README.md` (API base URL, include
|
||||
folders, export target folder, etc.).
|
||||
|
||||
If Obsidian was already open before you copied the files, either
|
||||
restart it or use the **Reload app without saving** command from the
|
||||
command palette so it picks up the new plugin directory.
|
||||
|
||||
## Option B - Rebuild from source before installing
|
||||
|
||||
Only needed if you've changed `src/**` since the last build, or you
|
||||
want sourcemaps for debugging.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build # tsc -noEmit + production esbuild bundle
|
||||
```
|
||||
|
||||
Then run the copy step from Option A.
|
||||
|
||||
## Option C - Live development symlink
|
||||
|
||||
If you'll iterate on the plugin and want changes to land in the vault
|
||||
without re-copying:
|
||||
|
||||
1. Make sure the plugin folder does not already exist as a real
|
||||
directory, then symlink the repo folder in:
|
||||
|
||||
```bash
|
||||
ln -s "$(pwd)" "<vault>/.obsidian/plugins/unabyss"
|
||||
```
|
||||
|
||||
2. Run the esbuild watcher so `main.js` rebuilds on save:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
3. In Obsidian, install the **Hot Reload** community plugin (by
|
||||
`pjeby`) to auto-reload Unabyss on each rebuild - or just
|
||||
disable/enable the toggle under **Settings -> Community plugins**
|
||||
after each change.
|
||||
|
||||
Notes on this option:
|
||||
|
||||
- `.gitignore` ignores `data.json`, so your local OAuth tokens won't
|
||||
be staged accidentally.
|
||||
- The symlink approach means Obsidian will write `data.json` directly
|
||||
into the repo working tree (still gitignored), so be aware if
|
||||
you're switching branches.
|
||||
|
||||
## Verifying the install
|
||||
|
||||
- Open Obsidian's developer console (`Ctrl/Cmd+Shift+I`). On enable,
|
||||
you should see no red errors from `plugin:unabyss`.
|
||||
- In **Settings -> Community plugins -> Unabyss**, the settings tab
|
||||
should render with **Connect**, the outbound / inbound toggles, and
|
||||
the **Force full resync** button described in `README.md`.
|
||||
- If you're pointing at a non-prod backend, set **API base URL** (under
|
||||
Advanced) before clicking **Connect** (the OAuth consent page is
|
||||
derived from this host as `https://app.<host>`).
|
||||
|
||||
## Uninstall / reset
|
||||
|
||||
```bash
|
||||
rm -rf "<vault>/.obsidian/plugins/unabyss"
|
||||
```
|
||||
|
||||
Then in Obsidian, **Settings -> Community plugins** -> refresh. If
|
||||
you had connected, also click **Disconnect** before deleting the
|
||||
folder (or revoke the session from the Unabyss web app's
|
||||
connected-accounts page) so the refresh tokens are blacklisted
|
||||
server-side - otherwise the leftover tokens in `data.json` remain
|
||||
valid until expiry.
|
||||
184
README.md
Normal file
184
README.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Unabyss Obsidian Plugin
|
||||
|
||||
Two-way OAuth sync between your Obsidian vault and your Unabyss memory.
|
||||
|
||||
- Push your notes into Unabyss as incremental, change-aware deltas.
|
||||
- Pull Unabyss-generated exports back into a folder you pick inside the vault.
|
||||
- File-change-driven outbound sync with a 5-second debounce, plus an
|
||||
hourly safety-net timer that runs both directions regardless of
|
||||
events.
|
||||
|
||||
## Features
|
||||
|
||||
- OAuth 2.0 + PKCE connect / disconnect against the Unabyss backend.
|
||||
- Manifest-first delta protocol: only files whose content hash the
|
||||
server doesn't already have are uploaded.
|
||||
- Inbound puller: every export in `GET /api/exports/changed-since/`
|
||||
is mirrored as `<slugified-title>.md` in your chosen folder.
|
||||
- Configurable behaviour when an export is deleted in Unabyss:
|
||||
leave the local file, delete it, or move it to a `Deleted/`
|
||||
subfolder.
|
||||
- Per-direction enable / disable toggles - either direction can be
|
||||
turned off without uninstalling the plugin.
|
||||
- Per-direction live progress indicator inside the settings tab.
|
||||
- Force-full-resync button that wipes the local hash cache + inbound
|
||||
watermark and re-checks every file with the server.
|
||||
- Per-note 1 MiB size cap (oversize notes are skipped with a notice).
|
||||
- Local hash + mtime cache to skip re-hashing unchanged files.
|
||||
|
||||
## Installation
|
||||
|
||||
### Obsidian Community Plugins (recommended)
|
||||
|
||||
1. Open Obsidian -> Settings -> Community plugins -> Browse.
|
||||
2. Search for **Unabyss**, click **Install**, then **Enable**.
|
||||
|
||||
### BRAT sideload (beta releases)
|
||||
|
||||
1. Install the [BRAT](https://github.com/TfTHacker/obsidian42-brat)
|
||||
community plugin.
|
||||
2. Open Obsidian -> Settings -> Community plugins -> BRAT ->
|
||||
**Add Beta plugin** and paste this repository's URL.
|
||||
3. Enable the **Unabyss** plugin in Settings -> Community plugins.
|
||||
|
||||
### Manual install
|
||||
|
||||
1. Clone this repository.
|
||||
2. From the repository root run:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. Copy `main.js`, `manifest.json`, `logo-dark.svg`, and
|
||||
`logo-light.svg` into `<vault>/.obsidian/plugins/unabyss/`
|
||||
(create the folder if it doesn't exist).
|
||||
4. Enable the plugin in Settings -> Community plugins.
|
||||
|
||||
## Configuration
|
||||
|
||||
Open Settings -> Community plugins -> Unabyss.
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|------------------------------------------|---------------------------|-------|
|
||||
| API base URL | `https://api.unabyss.com` | Set to your self-hosted backend if applicable. The plugin opens `https://app.<host>` in your browser for the consent page; point at the API host you have if your deploy doesn't follow the `api.` / `app.` convention. |
|
||||
| Account | (empty until you connect) | Click **Connect** to start the OAuth PKCE flow. |
|
||||
| Sync outbound | on | Master switch for the Obsidian -> Unabyss direction. When off, neither file-change events, the hourly timer, nor the manual button sends notes to Unabyss. |
|
||||
| Include folders | (empty = whole vault) | Vault folders included by outbound sync. Click **Add folder** to pick from a list of every folder in the vault. Empty = sync everything. |
|
||||
| Sync inbound | on | Master switch for the Unabyss -> Obsidian direction. When off, the hourly timer skips inbound and the manual button refuses to run it. |
|
||||
| Export target folder | (empty) | Vault folder where Unabyss exports are written. Inbound sync requires this to be set. Auto-completes vault folders as you type. |
|
||||
| When an export is deleted in Unabyss | Leave the local file alone | Choose between **Leave**, **Delete (system trash)**, and **Move to a `Deleted/` subfolder** inside your target folder. |
|
||||
| Force full resync | - | Wipes the local manifest cache + inbound watermark and immediately runs an outbound sync so the server's hash-diff guard re-establishes the truth. |
|
||||
|
||||
## Usage
|
||||
|
||||
### First connect
|
||||
|
||||
1. Click **Connect** in the settings tab. Your browser opens the
|
||||
Unabyss consent page; sign in if needed and click **Allow**.
|
||||
2. Obsidian focuses back to your vault. The settings tab now reads
|
||||
"Connected as `<your email>`".
|
||||
|
||||
### Outbound (Obsidian -> Unabyss)
|
||||
|
||||
- Once connected and with the include-folder list configured to your
|
||||
taste, every modify / create / delete on a markdown file kicks off a
|
||||
debounced outbound sync after **5 seconds** of quiet.
|
||||
- Manual: **Settings -> Unabyss -> Manual sync -> Sync now** (or the
|
||||
command palette: `Unabyss: Sync outbound now`).
|
||||
- The hourly safety-net timer fires both directions regardless of
|
||||
file events, so the plugin recovers from missed events and external
|
||||
file-system writes.
|
||||
|
||||
### Inbound (Unabyss -> Obsidian)
|
||||
|
||||
- Pick an **Export target folder** in settings.
|
||||
- Each export gets written as `<slug>.md` (e.g.
|
||||
`daily-summary-2026-01-15.md`). On slug collision with a different
|
||||
export, the plugin appends `-<first-6-of-uuid>` to the filename so
|
||||
no unrelated note is overwritten.
|
||||
- Every written file carries a stable trailer line at the bottom:
|
||||
`<!-- unabyss-export-id: <uuid> -->`. Do not delete this line if
|
||||
you want the plugin to recognise the file as the same export on the
|
||||
next sync.
|
||||
- Soft-deleted exports honour your **When an export is deleted in
|
||||
Unabyss** setting.
|
||||
- Manual: **Settings -> Unabyss -> Manual sync -> Sync now** or the
|
||||
command palette: `Unabyss: Sync inbound now`.
|
||||
|
||||
### Disconnecting
|
||||
|
||||
Click **Disconnect** in the settings tab. The plugin calls
|
||||
`POST /api/oauth/revoke/` to blacklist its refresh tokens server-side
|
||||
and clears the local `data.json` token entries. You can also revoke
|
||||
from the Unabyss web app's connected-accounts page if your machine is
|
||||
inaccessible.
|
||||
|
||||
## Security and threat model
|
||||
|
||||
This plugin stores OAuth tokens in
|
||||
`<vault>/.obsidian/plugins/unabyss/data.json`, which is **plaintext
|
||||
JSON on disk**.
|
||||
|
||||
- Anyone with read access to your vault folder can read these tokens
|
||||
and use them to access your Unabyss account until you click
|
||||
**Disconnect** (which revokes them server-side).
|
||||
- Tokens are also synced by Obsidian Sync, iCloud, Dropbox, or any
|
||||
other sync layer you configure for your `.obsidian/` directory.
|
||||
- If you suspect your tokens were leaked, click **Disconnect** in the
|
||||
plugin settings; the OAuth refresh tokens are blacklisted
|
||||
immediately on the server. You can also revoke from the Unabyss web
|
||||
app's connected accounts page.
|
||||
|
||||
OS-keychain integration is on the roadmap. If plaintext token storage
|
||||
is unacceptable for your threat model, do not install this plugin yet.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Connect failed: state_mismatch"** - the browser tab took too
|
||||
long to complete the consent flow, or you opened multiple Connect
|
||||
tabs. Click Connect again.
|
||||
- **"Authentication has expired"** - the refresh-token rotation
|
||||
failed. Click Disconnect (or Connect again, which clears the stale
|
||||
state).
|
||||
- **Sync rejects notes with `hash_mismatch`** - this is the server
|
||||
rejecting a row whose content changed between the manifest scan
|
||||
and the body upload. Re-running Sync resolves it.
|
||||
- **Inbound sync skips everything** - the **Export target folder**
|
||||
setting is empty. Pick a folder via the type-ahead in settings.
|
||||
- **"Inbound target X exists but is not a folder"** - the chosen
|
||||
target path is a file. Pick a different folder.
|
||||
- **Exports keep coming back after I delete them locally** - your
|
||||
**When an export is deleted in Unabyss** setting is "Leave"; the
|
||||
plugin won't touch local files even when the server soft-deletes
|
||||
the source export, but it will keep re-writing the file if the
|
||||
export is *not* deleted server-side. Delete the export in the web
|
||||
app first.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev # esbuild watch mode
|
||||
pnpm run typecheck # tsc -noEmit
|
||||
pnpm test # jest unit suite
|
||||
pnpm run build # production bundle
|
||||
```
|
||||
|
||||
The unit suite covers:
|
||||
|
||||
- PKCE S256 round-trip (`tests/oauth.test.ts`).
|
||||
- Manifest cache load/save/clear semantics
|
||||
(`tests/manifestCache.test.ts`).
|
||||
- Slugify + collision-suffix policy (`tests/slugify.test.ts`).
|
||||
- Inbound watermark advancement on partial-page failure
|
||||
(`tests/syncInbound.test.ts`).
|
||||
|
||||
Anything that touches the Obsidian runtime (Vault, App, Plugin,
|
||||
`requestUrl`) is dependency-injected at the call site, so the tests
|
||||
do not require Electron.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
47
esbuild.config.mjs
Normal file
47
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
If you want to view the source, please visit the GitHub repository of this plugin.
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
24
jest.config.cjs
Normal file
24
jest.config.cjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Jest configuration for the Unabyss Obsidian plugin's unit tests.
|
||||
*
|
||||
* Tests live under ``tests/`` and target the side-effect-free helpers
|
||||
* (PKCE, manifest cache, slug + collision policy, inbound watermark
|
||||
* advancement). Anything that touches the Obsidian runtime (the App,
|
||||
* Plugin lifecycle, Vault, requestUrl) is dependency-injected at the
|
||||
* call site so the tests never need a real Electron host.
|
||||
*
|
||||
* The ``obsidian`` module is mocked via the ``moduleNameMapper`` shim
|
||||
* in ``tests/__mocks__/obsidian.ts``.
|
||||
*/
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
rootDir: ".",
|
||||
testMatch: ["<rootDir>/tests/**/*.test.ts"],
|
||||
moduleNameMapper: {
|
||||
"^obsidian$": "<rootDir>/tests/__mocks__/obsidian.ts",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.ts$": ["ts-jest", { tsconfig: "<rootDir>/tests/tsconfig.json" }],
|
||||
},
|
||||
};
|
||||
9
logo-dark.svg
Executable file
9
logo-dark.svg
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#181817"/>
|
||||
<g fill="white">
|
||||
<circle cx="4" cy="4" r="3" opacity="0.28"/><circle cx="12" cy="4" r="3" opacity="0.26"/><circle cx="20" cy="4" r="3" opacity="0.89"/><circle cx="28" cy="4" r="3" opacity="0.65"/>
|
||||
<circle cx="4" cy="12" r="3" opacity="0.56"/><circle cx="12" cy="12" r="3" opacity="0.7"/><circle cx="20" cy="12" r="3" opacity="0.88"/><circle cx="28" cy="12" r="3" opacity="0.69"/>
|
||||
<circle cx="4" cy="20" r="3" opacity="0.57"/><circle cx="12" cy="20" r="3" opacity="0.26"/><circle cx="20" cy="20" r="3" opacity="0.62"/><circle cx="28" cy="20" r="3" opacity="0.5"/>
|
||||
<circle cx="4" cy="28" r="3" opacity="0.74"/><circle cx="12" cy="28" r="3" opacity="0.08"/><circle cx="20" cy="28" r="3" opacity="0.13"/><circle cx="28" cy="28" r="3" opacity="0.98"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 910 B |
9
logo-light.svg
Executable file
9
logo-light.svg
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#ffffff"/>
|
||||
<g fill="#181817">
|
||||
<circle cx="4" cy="4" r="3" opacity="0.28"/><circle cx="12" cy="4" r="3" opacity="0.26"/><circle cx="20" cy="4" r="3" opacity="0.89"/><circle cx="28" cy="4" r="3" opacity="0.65"/>
|
||||
<circle cx="4" cy="12" r="3" opacity="0.56"/><circle cx="12" cy="12" r="3" opacity="0.7"/><circle cx="20" cy="12" r="3" opacity="0.88"/><circle cx="28" cy="12" r="3" opacity="0.69"/>
|
||||
<circle cx="4" cy="20" r="3" opacity="0.57"/><circle cx="12" cy="20" r="3" opacity="0.26"/><circle cx="20" cy="20" r="3" opacity="0.62"/><circle cx="28" cy="20" r="3" opacity="0.5"/>
|
||||
<circle cx="4" cy="28" r="3" opacity="0.74"/><circle cx="12" cy="28" r="3" opacity="0.08"/><circle cx="20" cy="28" r="3" opacity="0.13"/><circle cx="28" cy="28" r="3" opacity="0.98"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 912 B |
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "unabyss",
|
||||
"name": "Unabyss",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Two-way OAuth sync between your Obsidian vault and your Unabyss memory.",
|
||||
"author": "Unabyss",
|
||||
"authorUrl": "https://unabyss.com",
|
||||
"fundingUrl": "https://unabyss.com",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "obsidian-plugin-unabyss",
|
||||
"version": "1.0.0",
|
||||
"description": "Two-way OAuth sync between your Obsidian vault and Unabyss.",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"typecheck": "tsc -noEmit -skipLibCheck",
|
||||
"test": "jest",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"plugin",
|
||||
"unabyss"
|
||||
],
|
||||
"author": "Unabyss",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"jest": "^29.7.0",
|
||||
"obsidian": "^1.5.7",
|
||||
"ts-jest": "^29.1.5",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.3"
|
||||
}
|
||||
2925
pnpm-lock.yaml
Normal file
2925
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
245
src/apiClient.ts
Normal file
245
src/apiClient.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* Typed HTTP client for the Unabyss backend.
|
||||
*
|
||||
* Wraps the manifest-first plugin endpoints
|
||||
* (`/api/ingest/obsidian/manifest-chunks/`, `.../notes/upload/`,
|
||||
* `.../sync-finalize/`), the dashboard endpoints
|
||||
* (`/api/ingest/obsidian/vaults/...`), the inbound exports listing
|
||||
* (`/api/exports/changed-since/`), and the JWT refresh endpoint
|
||||
* (`/api/auth/token/refresh/`).
|
||||
*
|
||||
* Authentication: every authenticated call attaches
|
||||
* ``Authorization: Bearer <access>``. On a 401 response, the client
|
||||
* runs `rotateRefreshToken` once, swaps in the new access/refresh
|
||||
* pair, and retries the original request. A second 401 propagates
|
||||
* upward so the caller can surface a "Reconnect" UI affordance.
|
||||
*/
|
||||
|
||||
import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian";
|
||||
import { normalizeApiBaseUrl, rotateRefreshToken } from "./oauth";
|
||||
import {
|
||||
AuthState,
|
||||
ExportRow,
|
||||
ManifestChunkRequest,
|
||||
ManifestChunkResponse,
|
||||
NoteUploadRequest,
|
||||
NoteUploadResponse,
|
||||
PaginatedResponse,
|
||||
SyncFinalizeRequest,
|
||||
SyncFinalizeResponse,
|
||||
VaultListResponse,
|
||||
VaultRow,
|
||||
} from "./types";
|
||||
|
||||
/** Saver hook so the apiClient can persist refreshed tokens without owning settings. */
|
||||
export type AuthSaver = (auth: AuthState) => Promise<void>;
|
||||
|
||||
/** Reset hook called when refresh fails terminally so the plugin can prompt re-auth. */
|
||||
export type AuthClearHook = (reason: string) => Promise<void>;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
|
||||
constructor(status: number, message: string, bodyText: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.bodyText = bodyText;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the refresh-on-401 path also fails (the user must reconnect). */
|
||||
export class AuthExpiredError extends Error {
|
||||
constructor(message = "Authentication has expired. Reconnect to continue syncing.") {
|
||||
super(message);
|
||||
this.name = "AuthExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiClientOptions {
|
||||
apiBaseUrl: string;
|
||||
auth: AuthState;
|
||||
saveAuth: AuthSaver;
|
||||
clearAuth: AuthClearHook;
|
||||
}
|
||||
|
||||
export class UnabyssApiClient {
|
||||
private apiBaseUrl: string;
|
||||
private auth: AuthState;
|
||||
private readonly saveAuth: AuthSaver;
|
||||
private readonly clearAuth: AuthClearHook;
|
||||
private refreshInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiBaseUrl = normalizeApiBaseUrl(opts.apiBaseUrl);
|
||||
this.auth = opts.auth;
|
||||
this.saveAuth = opts.saveAuth;
|
||||
this.clearAuth = opts.clearAuth;
|
||||
}
|
||||
|
||||
updateBaseUrl(apiBaseUrl: string): void {
|
||||
this.apiBaseUrl = normalizeApiBaseUrl(apiBaseUrl);
|
||||
}
|
||||
|
||||
updateAuth(auth: AuthState): void {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
async postManifestChunk(body: ManifestChunkRequest): Promise<ManifestChunkResponse> {
|
||||
return this.requestJson<ManifestChunkResponse>({
|
||||
method: "POST",
|
||||
path: "/api/ingest/obsidian/manifest-chunks/",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async postNoteUpload(body: NoteUploadRequest): Promise<NoteUploadResponse> {
|
||||
return this.requestJson<NoteUploadResponse>({
|
||||
method: "POST",
|
||||
path: "/api/ingest/obsidian/notes/upload/",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async postSyncFinalize(body: SyncFinalizeRequest): Promise<SyncFinalizeResponse> {
|
||||
return this.requestJson<SyncFinalizeResponse>({
|
||||
method: "POST",
|
||||
path: "/api/ingest/obsidian/sync-finalize/",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async listVaults(): Promise<VaultRow[]> {
|
||||
const response = await this.requestJson<VaultListResponse>({
|
||||
method: "GET",
|
||||
path: "/api/ingest/obsidian/vaults/",
|
||||
});
|
||||
return response.results ?? [];
|
||||
}
|
||||
|
||||
async getChangedExports(updatedAfter: string, limit = 100, offset = 0): Promise<PaginatedResponse<ExportRow>> {
|
||||
const params = new URLSearchParams({
|
||||
updated_after: updatedAfter,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return this.requestJson<PaginatedResponse<ExportRow>>({
|
||||
method: "GET",
|
||||
path: `/api/exports/changed-since/?${params.toString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async requestJson<T>(opts: {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const response = await this.sendWithRefresh(opts);
|
||||
return response.json as T;
|
||||
}
|
||||
|
||||
private async sendWithRefresh(opts: {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): Promise<RequestUrlResponse> {
|
||||
const initial = await this.sendOnce(opts);
|
||||
if (initial.status !== 401) {
|
||||
ensureSuccess(initial);
|
||||
return initial;
|
||||
}
|
||||
try {
|
||||
await this.refreshTokens();
|
||||
} catch (err) {
|
||||
await this.clearAuth("refresh_failed");
|
||||
throw new AuthExpiredError();
|
||||
}
|
||||
const retried = await this.sendOnce(opts);
|
||||
if (retried.status === 401) {
|
||||
await this.clearAuth("retry_unauthorized");
|
||||
throw new AuthExpiredError();
|
||||
}
|
||||
ensureSuccess(retried);
|
||||
return retried;
|
||||
}
|
||||
|
||||
private async sendOnce(opts: {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): Promise<RequestUrlResponse> {
|
||||
const params: RequestUrlParam = {
|
||||
url: `${this.apiBaseUrl}${opts.path}`,
|
||||
method: opts.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.accessToken}`,
|
||||
},
|
||||
throw: false,
|
||||
};
|
||||
if (opts.body !== undefined) {
|
||||
params.contentType = "application/json";
|
||||
params.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return requestUrl(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize concurrent 401-driven refreshes so only one rotation
|
||||
* runs at a time. Concurrent callers await the same promise; once
|
||||
* it resolves, ``this.auth`` carries the new pair for every
|
||||
* waiting request.
|
||||
*/
|
||||
private async refreshTokens(): Promise<void> {
|
||||
if (this.refreshInFlight) {
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
this.refreshInFlight = (async () => {
|
||||
try {
|
||||
const tokens = await rotateRefreshToken(this.apiBaseUrl, this.auth.refreshToken);
|
||||
this.auth = {
|
||||
...this.auth,
|
||||
accessToken: tokens.access,
|
||||
refreshToken: tokens.refresh,
|
||||
};
|
||||
await this.saveAuth(this.auth);
|
||||
} finally {
|
||||
this.refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSuccess(response: RequestUrlResponse): void {
|
||||
if (response.status < 400) {
|
||||
return;
|
||||
}
|
||||
const body = describeApiError(response);
|
||||
throw new ApiError(response.status, body.message, body.text);
|
||||
}
|
||||
|
||||
function describeApiError(response: RequestUrlResponse): { message: string; text: string } {
|
||||
const raw = response.text ?? "";
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = response.json as Record<string, unknown> | undefined;
|
||||
if (parsed) {
|
||||
const detail = parsed.detail;
|
||||
const error = parsed.error;
|
||||
if (typeof detail === "string") {
|
||||
message = detail;
|
||||
} else if (error && typeof error === "object" && "message" in error) {
|
||||
const innerMessage = (error as { message?: unknown }).message;
|
||||
if (typeof innerMessage === "string") {
|
||||
message = innerMessage;
|
||||
}
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* swallow JSON parse errors; the HTTP-status message is fine */
|
||||
}
|
||||
return { message, text: raw };
|
||||
}
|
||||
525
src/main.ts
Normal file
525
src/main.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
/**
|
||||
* Plugin entry for the Unabyss Obsidian plugin (Phase 5 - feature complete).
|
||||
*
|
||||
* Responsibilities (Phase 5 superset of Phase 4):
|
||||
*
|
||||
* - Load and persist settings + auth + manifest cache + inbound
|
||||
* watermark to ``data.json``.
|
||||
* - Register the ``obsidian://unabyss/auth-callback`` protocol
|
||||
* handler so the OAuth PKCE flow can deep-link back into the
|
||||
* plugin after the user clicks Allow in their browser.
|
||||
* - Wire commands ("Sync now", "Sync outbound now", "Sync inbound
|
||||
* now", "Force full resync") and the settings tab.
|
||||
* - File-change driven outbound sync with a 5s debounce window
|
||||
* (per ``OUTBOUND_DEBOUNCE_MS``). The handler walks every modify /
|
||||
* create / delete on ``.md`` files inside the user's include scope.
|
||||
* - Hourly safety-net timer (per ``SAFETY_NET_INTERVAL_MS``) that
|
||||
* runs both directions regardless of file events.
|
||||
* - Per-direction enable/disable toggles - when off, neither timer
|
||||
* nor command engages that direction.
|
||||
*
|
||||
* Lifecycle: every owned resource is created in ``onload`` and
|
||||
* disposed in ``onunload`` (timer cleared, debounce cancelled,
|
||||
* protocol-handler registration auto-removed by Obsidian).
|
||||
*/
|
||||
|
||||
import { Notice, Plugin, TAbstractFile, TFile, normalizePath } from "obsidian";
|
||||
import { UnabyssApiClient } from "./apiClient";
|
||||
import { EMPTY_MANIFEST_CACHE, ManifestCache, normalizeManifestCacheData } from "./manifestCache";
|
||||
import { OAuthClient, revokeTokens } from "./oauth";
|
||||
import { ProgressTracker } from "./progress";
|
||||
import { UnabyssSettingTab } from "./settings";
|
||||
import { runInboundSync } from "./syncInbound";
|
||||
import { runOutboundSync } from "./syncOutbound";
|
||||
import {
|
||||
AuthState,
|
||||
DEFAULT_SETTINGS,
|
||||
ManifestCacheData,
|
||||
OAUTH_PROTOCOL_ACTION,
|
||||
OUTBOUND_DEBOUNCE_MS,
|
||||
PluginSettings,
|
||||
SAFETY_NET_INTERVAL_MS,
|
||||
SyncInboundReport,
|
||||
SyncOutboundReport,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* On-disk shape of ``data.json``: plugin settings (with ``auth``
|
||||
* nested) plus the manifest cache. Kept flat so a hand-edit doesn't
|
||||
* require deep-merge logic on the next load.
|
||||
*/
|
||||
interface PluginData {
|
||||
settings: PluginSettings;
|
||||
manifestCache: ManifestCacheData;
|
||||
}
|
||||
|
||||
export default class UnabyssPlugin extends Plugin {
|
||||
settings!: PluginSettings;
|
||||
readonly outboundProgress = new ProgressTracker("outbound");
|
||||
readonly inboundProgress = new ProgressTracker("inbound");
|
||||
|
||||
private manifestCacheData: ManifestCacheData = { ...EMPTY_MANIFEST_CACHE };
|
||||
private manifestCache!: ManifestCache;
|
||||
private api: UnabyssApiClient | null = null;
|
||||
private oauthClient: OAuthClient = new OAuthClient();
|
||||
private outboundDebounceTimer: number | null = null;
|
||||
private safetyNetIntervalHandle: number | null = null;
|
||||
private outboundInFlight = false;
|
||||
private inboundInFlight = false;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadPluginData();
|
||||
this.manifestCache = new ManifestCache(this.manifestCacheData, async (data) => {
|
||||
this.manifestCacheData = data;
|
||||
await this.savePluginData();
|
||||
});
|
||||
this.rebuildApiClient();
|
||||
|
||||
this.registerObsidianProtocolHandler(OAUTH_PROTOCOL_ACTION, async (params) => {
|
||||
await this.handleOAuthCallback(params);
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "unabyss-sync-now",
|
||||
name: "Sync now (both directions)",
|
||||
callback: () => {
|
||||
this.runManualSync().catch((err) => {
|
||||
new Notice(`Unabyss sync failed: ${describeError(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "unabyss-sync-outbound",
|
||||
name: "Sync outbound now",
|
||||
callback: () => {
|
||||
this.runOutboundSync(true)
|
||||
.then((report) => {
|
||||
new Notice(
|
||||
`Unabyss outbound \u2014 ${report.uploaded} uploaded, ` +
|
||||
`${report.deleted} deleted, ${report.restored} restored.`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
new Notice(`Unabyss outbound failed: ${describeError(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "unabyss-sync-inbound",
|
||||
name: "Sync inbound now",
|
||||
callback: () => {
|
||||
this.runInboundSync(true)
|
||||
.then((report) => {
|
||||
new Notice(
|
||||
`Unabyss inbound \u2014 ${report.written} written, ` +
|
||||
`${report.deleted} deleted, ${report.moved} moved.`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
new Notice(`Unabyss inbound failed: ${describeError(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "unabyss-force-full-resync",
|
||||
name: "Force full resync",
|
||||
callback: () => {
|
||||
this.forceFullResync()
|
||||
.then(() => new Notice("Unabyss: force full resync complete."))
|
||||
.catch((err) => new Notice(`Force full resync failed: ${describeError(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
this.addSettingTab(new UnabyssSettingTab(this.app, this));
|
||||
|
||||
this.registerVaultWatcher();
|
||||
this.installSafetyNetTimer();
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
this.oauthClient.abort();
|
||||
this.api = null;
|
||||
this.cancelOutboundDebounce();
|
||||
this.clearSafetyNetTimer();
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.savePluginData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh {@link UnabyssApiClient} from the current settings
|
||||
* + auth state. Called on load, after the OAuth callback succeeds,
|
||||
* after the user edits the API base URL, and after disconnect.
|
||||
*/
|
||||
rebuildApiClient(): void {
|
||||
if (!this.settings.auth) {
|
||||
this.api = null;
|
||||
return;
|
||||
}
|
||||
this.api = new UnabyssApiClient({
|
||||
apiBaseUrl: this.settings.apiBaseUrl,
|
||||
auth: this.settings.auth,
|
||||
saveAuth: async (next) => {
|
||||
this.settings.auth = next;
|
||||
await this.savePluginData();
|
||||
},
|
||||
clearAuth: async (reason) => {
|
||||
console.warn(`Unabyss: clearing auth (${reason}).`);
|
||||
await this.clearAuth();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async beginConnect(): Promise<void> {
|
||||
await this.oauthClient.beginAuthorize(this.settings.apiBaseUrl);
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.settings.auth) {
|
||||
try {
|
||||
await revokeTokens(this.settings.apiBaseUrl, this.settings.auth.accessToken);
|
||||
} catch (err) {
|
||||
console.warn("Unabyss: revoke call failed; clearing local tokens anyway.", err);
|
||||
}
|
||||
}
|
||||
await this.clearAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the manifest cache (paths + inbound watermark) and
|
||||
* immediately re-run outbound sync so the server's hash-diff
|
||||
* guard re-establishes the truth. If the user has outbound
|
||||
* disabled or isn't connected, the cache is still wiped.
|
||||
*/
|
||||
async forceFullResync(): Promise<void> {
|
||||
this.manifestCache.clear();
|
||||
await this.manifestCache.flush();
|
||||
if (this.api && this.settings.outboundEnabled) {
|
||||
await this.runOutboundSync(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual ``Sync now`` from the settings tab / command palette:
|
||||
* runs both directions in sequence, honouring each direction's
|
||||
* enable toggle. Errors in one direction are surfaced but do not
|
||||
* block the other.
|
||||
*/
|
||||
async runManualSync(): Promise<{ outbound: SyncOutboundReport | null; inbound: SyncInboundReport | null }> {
|
||||
let outbound: SyncOutboundReport | null = null;
|
||||
let inbound: SyncInboundReport | null = null;
|
||||
let outboundErr: unknown = null;
|
||||
let inboundErr: unknown = null;
|
||||
|
||||
if (this.settings.outboundEnabled) {
|
||||
try {
|
||||
outbound = await this.runOutboundSync(true);
|
||||
} catch (err) {
|
||||
outboundErr = err;
|
||||
}
|
||||
}
|
||||
if (this.settings.inboundEnabled) {
|
||||
try {
|
||||
inbound = await this.runInboundSync(true);
|
||||
} catch (err) {
|
||||
inboundErr = err;
|
||||
}
|
||||
}
|
||||
|
||||
if (outbound) {
|
||||
new Notice(
|
||||
`Outbound \u2014 ${outbound.uploaded} uploaded, ${outbound.deleted} deleted, ${outbound.restored} restored.`,
|
||||
);
|
||||
}
|
||||
if (inbound) {
|
||||
new Notice(
|
||||
`Inbound \u2014 ${inbound.written} written, ${inbound.deleted} deleted, ${inbound.moved} moved.`,
|
||||
);
|
||||
}
|
||||
if (outboundErr) {
|
||||
new Notice(`Outbound failed: ${describeError(outboundErr)}`);
|
||||
}
|
||||
if (inboundErr) {
|
||||
new Notice(`Inbound failed: ${describeError(inboundErr)}`);
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}
|
||||
|
||||
async runOutboundSync(force = false): Promise<SyncOutboundReport> {
|
||||
if (!force && !this.settings.outboundEnabled) {
|
||||
throw new Error("Outbound sync is disabled in settings.");
|
||||
}
|
||||
if (!this.api) {
|
||||
throw new Error("Connect to Unabyss before running outbound sync.");
|
||||
}
|
||||
if (this.outboundInFlight) {
|
||||
throw new Error("Outbound sync is already running.");
|
||||
}
|
||||
if (!this.settings.vaultId) {
|
||||
this.settings.vaultId = generateVaultId();
|
||||
await this.savePluginData();
|
||||
}
|
||||
this.outboundInFlight = true;
|
||||
this.outboundProgress.start("Scanning vault...");
|
||||
try {
|
||||
const report = await runOutboundSync({
|
||||
app: this.app,
|
||||
api: this.api,
|
||||
cache: this.manifestCache,
|
||||
vaultId: this.settings.vaultId,
|
||||
vaultDisplayName: this.app.vault.getName(),
|
||||
includeFolders: this.settings.includeFolders,
|
||||
progress: this.outboundProgress,
|
||||
});
|
||||
this.outboundProgress.succeed(
|
||||
`Outbound done - ${report.uploaded} uploaded, ${report.deleted} deleted.`,
|
||||
);
|
||||
return report;
|
||||
} catch (err) {
|
||||
this.outboundProgress.fail(describeError(err));
|
||||
throw err;
|
||||
} finally {
|
||||
this.outboundInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runInboundSync(force = false): Promise<SyncInboundReport> {
|
||||
if (!force && !this.settings.inboundEnabled) {
|
||||
throw new Error("Inbound sync is disabled in settings.");
|
||||
}
|
||||
if (!this.api) {
|
||||
throw new Error("Connect to Unabyss before running inbound sync.");
|
||||
}
|
||||
if (!this.settings.exportTargetFolder.trim()) {
|
||||
throw new Error("Pick an export target folder in settings before running inbound sync.");
|
||||
}
|
||||
if (this.inboundInFlight) {
|
||||
throw new Error("Inbound sync is already running.");
|
||||
}
|
||||
this.inboundInFlight = true;
|
||||
try {
|
||||
return await runInboundSync({
|
||||
app: this.app,
|
||||
api: this.api,
|
||||
cache: this.manifestCache,
|
||||
targetFolder: this.settings.exportTargetFolder,
|
||||
deleteBehaviour: this.settings.exportDeleteBehaviour,
|
||||
progress: this.inboundProgress,
|
||||
});
|
||||
} finally {
|
||||
this.inboundInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the per-direction progress tracker when the user toggles a
|
||||
* direction off so a stale "Synced X notes" label doesn't keep
|
||||
* sticking around.
|
||||
*/
|
||||
onDirectionToggleChanged(): void {
|
||||
if (!this.settings.outboundEnabled) {
|
||||
this.cancelOutboundDebounce();
|
||||
this.outboundProgress.reset();
|
||||
}
|
||||
if (!this.settings.inboundEnabled) {
|
||||
this.inboundProgress.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private registerVaultWatcher(): void {
|
||||
const handler = (file: TAbstractFile): void => {
|
||||
if (!this.settings.outboundEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!(file instanceof TFile) || file.extension !== "md") {
|
||||
return;
|
||||
}
|
||||
if (!this.isInOutboundScope(file.path)) {
|
||||
return;
|
||||
}
|
||||
this.scheduleOutboundDebounce();
|
||||
};
|
||||
this.registerEvent(this.app.vault.on("modify", handler));
|
||||
this.registerEvent(this.app.vault.on("create", handler));
|
||||
this.registerEvent(this.app.vault.on("delete", handler));
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", (file) => handler(file)),
|
||||
);
|
||||
}
|
||||
|
||||
private installSafetyNetTimer(): void {
|
||||
this.clearSafetyNetTimer();
|
||||
const handle = window.setInterval(() => {
|
||||
this.runSafetyNetPass().catch((err) => {
|
||||
console.warn("Unabyss: safety-net pass failed", err);
|
||||
});
|
||||
}, SAFETY_NET_INTERVAL_MS);
|
||||
this.safetyNetIntervalHandle = handle;
|
||||
this.registerInterval(handle);
|
||||
}
|
||||
|
||||
private clearSafetyNetTimer(): void {
|
||||
if (this.safetyNetIntervalHandle !== null) {
|
||||
window.clearInterval(this.safetyNetIntervalHandle);
|
||||
this.safetyNetIntervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async runSafetyNetPass(): Promise<void> {
|
||||
if (!this.api) {
|
||||
return;
|
||||
}
|
||||
if (this.settings.outboundEnabled && !this.outboundInFlight) {
|
||||
try {
|
||||
await this.runOutboundSync(false);
|
||||
} catch (err) {
|
||||
console.warn("Unabyss safety-net outbound failed", err);
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.settings.inboundEnabled &&
|
||||
this.settings.exportTargetFolder.trim() !== "" &&
|
||||
!this.inboundInFlight
|
||||
) {
|
||||
try {
|
||||
await this.runInboundSync(false);
|
||||
} catch (err) {
|
||||
console.warn("Unabyss safety-net inbound failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleOutboundDebounce(): void {
|
||||
if (this.outboundDebounceTimer !== null) {
|
||||
window.clearTimeout(this.outboundDebounceTimer);
|
||||
}
|
||||
this.outboundDebounceTimer = window.setTimeout(() => {
|
||||
this.outboundDebounceTimer = null;
|
||||
this.runOutboundSync(false).catch((err) => {
|
||||
console.warn("Unabyss debounced outbound failed", err);
|
||||
});
|
||||
}, OUTBOUND_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private cancelOutboundDebounce(): void {
|
||||
if (this.outboundDebounceTimer !== null) {
|
||||
window.clearTimeout(this.outboundDebounceTimer);
|
||||
this.outboundDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a file path falls inside the user's outbound
|
||||
* include-folder scope. Empty list = whole vault. Matches the
|
||||
* predicate logic in ``syncOutbound.buildIncludeFilter`` so a
|
||||
* file-change event only triggers a debounce when the file would
|
||||
* actually be picked up by the next sync.
|
||||
*/
|
||||
private isInOutboundScope(filePath: string): boolean {
|
||||
const folders = this.settings.includeFolders
|
||||
.map((folder) => folder.trim())
|
||||
.filter((folder) => folder.length > 0)
|
||||
.map((folder) => normalizePath(folder));
|
||||
if (folders.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
return folders.some((prefix) => {
|
||||
if (prefix === "/" || prefix === "") {
|
||||
return true;
|
||||
}
|
||||
return normalizedPath === prefix || normalizedPath.startsWith(prefix + "/");
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPluginData(): Promise<void> {
|
||||
const raw = (await this.loadData()) as Partial<PluginData> | null;
|
||||
const incoming = raw ?? {};
|
||||
const settings: PluginSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(incoming.settings ?? {}),
|
||||
includeFolders:
|
||||
incoming.settings && Array.isArray(incoming.settings.includeFolders)
|
||||
? [...incoming.settings.includeFolders]
|
||||
: [...DEFAULT_SETTINGS.includeFolders],
|
||||
auth: incoming.settings?.auth ?? null,
|
||||
};
|
||||
if (!settings.vaultId) {
|
||||
settings.vaultId = generateVaultId();
|
||||
}
|
||||
this.settings = settings;
|
||||
this.manifestCacheData = normalizeManifestCacheData(incoming.manifestCache);
|
||||
await this.savePluginData();
|
||||
}
|
||||
|
||||
private async savePluginData(): Promise<void> {
|
||||
const data: PluginData = {
|
||||
settings: this.settings,
|
||||
manifestCache: this.manifestCacheData,
|
||||
};
|
||||
await this.saveData(data);
|
||||
}
|
||||
|
||||
private async clearAuth(): Promise<void> {
|
||||
this.settings.auth = null;
|
||||
await this.savePluginData();
|
||||
this.rebuildApiClient();
|
||||
}
|
||||
|
||||
private async handleOAuthCallback(params: Record<string, string>): Promise<void> {
|
||||
try {
|
||||
const auth: AuthState = await this.oauthClient.handleCallback({
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
error: params.error,
|
||||
});
|
||||
this.settings.auth = auth;
|
||||
await this.savePluginData();
|
||||
this.rebuildApiClient();
|
||||
new Notice(`Connected to Unabyss as ${auth.userEmail || "(unknown)"}.`);
|
||||
} catch (err) {
|
||||
new Notice(`Connect failed: ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh UUIDv4 for use as the plugin's stable ``vault_id``.
|
||||
*
|
||||
* The value is opaque to the server (per tech-decisions §Decision 3)
|
||||
* and lives in ``data.json``. ``crypto.randomUUID()`` is available
|
||||
* inside Electron's renderer for modern Obsidian versions; we fall
|
||||
* back to a manual v4 builder for the edge case where it isn't.
|
||||
*/
|
||||
function generateVaultId(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return (
|
||||
hex.slice(0, 8) +
|
||||
"-" +
|
||||
hex.slice(8, 12) +
|
||||
"-" +
|
||||
hex.slice(12, 16) +
|
||||
"-" +
|
||||
hex.slice(16, 20) +
|
||||
"-" +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
174
src/manifestCache.ts
Normal file
174
src/manifestCache.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Local manifest cache backing the manifest-first sync.
|
||||
*
|
||||
* Two responsibilities, persisted side-by-side in the plugin's
|
||||
* `data.json`:
|
||||
*
|
||||
* - **Path index** (``ManifestPathIndex``): ``vault_path ->
|
||||
* { content_hash, mtime }``. Lets the outbound scanner short-circuit
|
||||
* rehashing files whose ``mtime`` matches.
|
||||
* - **Exports watermark** (``exportsUpdatedAfter``): the latest
|
||||
* ``updated_at`` we've successfully reconciled inbound from the
|
||||
* Unabyss exports stream. Persisted so a fresh plugin boot resumes
|
||||
* from the same offset instead of redoing every export ever.
|
||||
*
|
||||
* Mutations write through to disk via ``flush()`` so the caller can
|
||||
* batch many updates inside one sync pass. The server-side hash diff
|
||||
* remains the source of truth for outbound correctness; this cache is
|
||||
* a best-effort optimisation that ``clear()`` (Force full resync) can
|
||||
* wipe without risking divergence.
|
||||
*/
|
||||
|
||||
import { EMPTY_MANIFEST_CACHE, ManifestCacheData, ManifestCacheEntry, ManifestPathIndex } from "./types";
|
||||
|
||||
export type ManifestCachePersistor = (data: ManifestCacheData) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Normalise an arbitrary stored shape into the current
|
||||
* {@link ManifestCacheData} contract.
|
||||
*
|
||||
* Tolerates v1 (flat ``Record<path, entry>``) and v2 (the structured
|
||||
* shape) so users upgrading from Phase 4's plugin don't lose their
|
||||
* existing cache on the first Phase 5 boot.
|
||||
*/
|
||||
export function normalizeManifestCacheData(raw: unknown): ManifestCacheData {
|
||||
if (raw === null || raw === undefined) {
|
||||
return { paths: {}, exportsUpdatedAfter: "" };
|
||||
}
|
||||
if (typeof raw !== "object") {
|
||||
return { paths: {}, exportsUpdatedAfter: "" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (isStructuredShape(record)) {
|
||||
return {
|
||||
paths: normalizePathIndex(record.paths),
|
||||
exportsUpdatedAfter:
|
||||
typeof record.exportsUpdatedAfter === "string" ? record.exportsUpdatedAfter : "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
paths: normalizePathIndex(record),
|
||||
exportsUpdatedAfter: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isStructuredShape(record: Record<string, unknown>): record is {
|
||||
paths?: unknown;
|
||||
exportsUpdatedAfter?: unknown;
|
||||
} {
|
||||
return "paths" in record || "exportsUpdatedAfter" in record;
|
||||
}
|
||||
|
||||
function normalizePathIndex(raw: unknown): ManifestPathIndex {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {};
|
||||
}
|
||||
const next: ManifestPathIndex = {};
|
||||
for (const [path, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const candidate = value as Partial<ManifestCacheEntry>;
|
||||
if (typeof candidate.contentHash !== "string" || typeof candidate.mtime !== "number") {
|
||||
continue;
|
||||
}
|
||||
next[path] = { contentHash: candidate.contentHash, mtime: candidate.mtime };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export class ManifestCache {
|
||||
private paths: ManifestPathIndex;
|
||||
private exportsUpdatedAfter: string;
|
||||
private readonly persist: ManifestCachePersistor;
|
||||
private dirty = false;
|
||||
|
||||
constructor(initial: ManifestCacheData, persist: ManifestCachePersistor) {
|
||||
this.paths = { ...initial.paths };
|
||||
this.exportsUpdatedAfter = initial.exportsUpdatedAfter;
|
||||
this.persist = persist;
|
||||
}
|
||||
|
||||
get(path: string): ManifestCacheEntry | undefined {
|
||||
return this.paths[path];
|
||||
}
|
||||
|
||||
set(path: string, entry: ManifestCacheEntry): void {
|
||||
const existing = this.paths[path];
|
||||
if (existing && existing.contentHash === entry.contentHash && existing.mtime === entry.mtime) {
|
||||
return;
|
||||
}
|
||||
this.paths[path] = { contentHash: entry.contentHash, mtime: entry.mtime };
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop entries whose path is no longer in ``presentPaths``. Called
|
||||
* at the end of an outbound pass so the cache doesn't grow forever
|
||||
* with files the user has since deleted from the vault.
|
||||
*/
|
||||
retainOnly(presentPaths: Iterable<string>): void {
|
||||
const present = new Set(presentPaths);
|
||||
const next: ManifestPathIndex = {};
|
||||
for (const [path, entry] of Object.entries(this.paths)) {
|
||||
if (present.has(path)) {
|
||||
next[path] = entry;
|
||||
} else {
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
this.paths = next;
|
||||
}
|
||||
|
||||
/** Wipe every cached path. Wired to "Force full resync". */
|
||||
clear(): void {
|
||||
if (Object.keys(this.paths).length > 0) {
|
||||
this.paths = {};
|
||||
this.dirty = true;
|
||||
}
|
||||
if (this.exportsUpdatedAfter !== "") {
|
||||
this.exportsUpdatedAfter = "";
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
getExportsWatermark(): string {
|
||||
return this.exportsUpdatedAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the inbound watermark.
|
||||
*
|
||||
* Monotonic-by-default: a smaller watermark replaces a larger one
|
||||
* only if the caller passes an empty string (reset). This matches
|
||||
* the contract documented for ``syncInbound`` - the watermark
|
||||
* never rewinds on partial failure.
|
||||
*/
|
||||
setExportsWatermark(value: string): void {
|
||||
if (value === this.exportsUpdatedAfter) {
|
||||
return;
|
||||
}
|
||||
if (value !== "" && this.exportsUpdatedAfter !== "" && value < this.exportsUpdatedAfter) {
|
||||
return;
|
||||
}
|
||||
this.exportsUpdatedAfter = value;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
snapshot(): ManifestCacheData {
|
||||
return {
|
||||
paths: { ...this.paths },
|
||||
exportsUpdatedAfter: this.exportsUpdatedAfter,
|
||||
};
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
await this.persist(this.snapshot());
|
||||
this.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
export { EMPTY_MANIFEST_CACHE };
|
||||
337
src/oauth.ts
Normal file
337
src/oauth.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* OAuth 2.0 + PKCE (S256) client for the Unabyss plugin.
|
||||
*
|
||||
* Drives the full PKCE dance against the Phase 1 backend
|
||||
* (`/api/oauth/authorize/`, `/api/oauth/token/`, `/api/oauth/revoke/`):
|
||||
*
|
||||
* 1. {@link beginAuthorize} mints a fresh code-verifier / code-challenge
|
||||
* pair, stashes the verifier under `state`, and opens the user's
|
||||
* browser at the frontend's consent URL.
|
||||
* 2. The user clicks "Allow" in the browser; the frontend redirects to
|
||||
* {@link OAUTH_REDIRECT_URI}, which Obsidian's protocol handler
|
||||
* routes back into {@link handleCallback}.
|
||||
* 3. {@link handleCallback} validates `state`, exchanges the code for an
|
||||
* access/refresh JWT pair, fetches the user's email, and persists
|
||||
* the {@link AuthState} via the supplied saver.
|
||||
*
|
||||
* The plaintext-on-disk token storage is intentional and documented in
|
||||
* the README's threat-model section (per requirements §NFR).
|
||||
*/
|
||||
|
||||
import { Notice, requestUrl, RequestUrlResponse } from "obsidian";
|
||||
import {
|
||||
AuthState,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_REDIRECT_URI,
|
||||
OAuthErrorBody,
|
||||
TokenResponse,
|
||||
UserMeResponse,
|
||||
} from "./types";
|
||||
|
||||
const PKCE_VERIFIER_BYTES = 32;
|
||||
const PKCE_STATE_BYTES = 16;
|
||||
const PKCE_METHOD = "S256";
|
||||
const RESPONSE_TYPE = "code";
|
||||
const GRANT_TYPE = "authorization_code";
|
||||
|
||||
interface PendingAuthorization {
|
||||
verifier: string;
|
||||
state: string;
|
||||
redirectUri: string;
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeCallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user-facing consent URL.
|
||||
*
|
||||
* Convention (per user decision): when ``apiBaseUrl`` has an ``api.``
|
||||
* subdomain, swap it for ``app.`` to land on the SvelteKit consent
|
||||
* route; otherwise fall back to the same origin (covers self-hosted
|
||||
* deployments where API and frontend share a host).
|
||||
*/
|
||||
export function deriveConsentUrl(apiBaseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(apiBaseUrl);
|
||||
if (url.hostname.startsWith("api.")) {
|
||||
url.hostname = "app." + url.hostname.slice("api.".length);
|
||||
}
|
||||
url.pathname = "/oauth/authorize";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return apiBaseUrl.replace(/\/$/, "") + "/oauth/authorize";
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip the trailing slash so callers can always append `/api/...` cleanly. */
|
||||
export function normalizeApiBaseUrl(apiBaseUrl: string): string {
|
||||
return apiBaseUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates one in-flight PKCE flow. Stateful inside the plugin
|
||||
* process; a new instance is created on every "Connect" click and
|
||||
* disposed once the callback runs (or on plugin unload).
|
||||
*/
|
||||
export class OAuthClient {
|
||||
private pending: PendingAuthorization | null = null;
|
||||
|
||||
/**
|
||||
* Mint a verifier/challenge pair, persist the verifier in memory
|
||||
* keyed by ``state``, and open the user's browser at the consent
|
||||
* URL. Returns the URL so callers can override the open mechanism
|
||||
* during tests.
|
||||
*/
|
||||
async beginAuthorize(apiBaseUrl: string): Promise<string> {
|
||||
const verifier = generatePkceVerifier();
|
||||
const challenge = await pkceChallenge(verifier);
|
||||
const state = randomBase64Url(PKCE_STATE_BYTES);
|
||||
const consentBase = deriveConsentUrl(apiBaseUrl);
|
||||
const params = new URLSearchParams({
|
||||
response_type: RESPONSE_TYPE,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: OAUTH_REDIRECT_URI,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: PKCE_METHOD,
|
||||
state: state,
|
||||
});
|
||||
const url = `${consentBase}?${params.toString()}`;
|
||||
this.pending = {
|
||||
verifier,
|
||||
state,
|
||||
redirectUri: OAUTH_REDIRECT_URI,
|
||||
apiBaseUrl: normalizeApiBaseUrl(apiBaseUrl),
|
||||
};
|
||||
window.open(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the `obsidian://unabyss/auth-callback?code=...&state=...`
|
||||
* deep-link. Validates `state` against the pending request,
|
||||
* exchanges the code for tokens, fetches the user's email, and
|
||||
* returns the assembled {@link AuthState}. The caller is
|
||||
* responsible for persisting it via `Plugin.saveData()`.
|
||||
*/
|
||||
async handleCallback(params: AuthorizeCallbackParams): Promise<AuthState> {
|
||||
if (!this.pending) {
|
||||
throw new OAuthFlowError(
|
||||
"no_pending_authorization",
|
||||
"No in-flight authorization. Click Connect first.",
|
||||
);
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
if (params.error) {
|
||||
throw new OAuthFlowError(params.error, `Authorization rejected: ${params.error}`);
|
||||
}
|
||||
if (!params.code) {
|
||||
throw new OAuthFlowError(
|
||||
"missing_code",
|
||||
"Authorization callback was missing the code parameter.",
|
||||
);
|
||||
}
|
||||
if (!params.state || params.state !== pending.state) {
|
||||
throw new OAuthFlowError(
|
||||
"state_mismatch",
|
||||
"Authorization callback state does not match. Restart the flow.",
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
apiBaseUrl: pending.apiBaseUrl,
|
||||
code: params.code,
|
||||
codeVerifier: pending.verifier,
|
||||
redirectUri: pending.redirectUri,
|
||||
});
|
||||
const email = await fetchUserEmail(pending.apiBaseUrl, tokens.access);
|
||||
return {
|
||||
accessToken: tokens.access,
|
||||
refreshToken: tokens.refresh,
|
||||
userEmail: email,
|
||||
};
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
hasPending(): boolean {
|
||||
return this.pending !== null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-token rotation against `/api/auth/token/refresh/` (the
|
||||
* existing simplejwt rotation endpoint, which honours the OAuth
|
||||
* `client_type` claim minted by Phase 1). The returned pair MUST
|
||||
* replace the stored access+refresh tokens; the old refresh token is
|
||||
* blacklisted server-side as part of rotation.
|
||||
*/
|
||||
export async function rotateRefreshToken(
|
||||
apiBaseUrl: string,
|
||||
refreshToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const base = normalizeApiBaseUrl(apiBaseUrl);
|
||||
const response = await requestUrl({
|
||||
url: `${base}/api/auth/token/refresh/`,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
throw: false,
|
||||
});
|
||||
assertOk(response, "refresh-token rotation");
|
||||
const body = response.json as Partial<TokenResponse> & { access?: string; refresh?: string };
|
||||
if (!body.access || !body.refresh) {
|
||||
throw new OAuthFlowError(
|
||||
"malformed_refresh_response",
|
||||
"Refresh endpoint returned no access/refresh pair.",
|
||||
);
|
||||
}
|
||||
return { access: body.access, refresh: body.refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every outstanding OAuth refresh token for ``(user, obsidian
|
||||
* client)`` against `/api/oauth/revoke/`. Idempotent server-side; a
|
||||
* second call after disconnect returns 204 with no effect.
|
||||
*/
|
||||
export async function revokeTokens(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<void> {
|
||||
const base = normalizeApiBaseUrl(apiBaseUrl);
|
||||
const response = await requestUrl({
|
||||
url: `${base}/api/oauth/revoke/`,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify({ client_id: OAUTH_CLIENT_ID }),
|
||||
throw: false,
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
const detail = describeError(response, "revoke");
|
||||
new Notice(detail);
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthFlowError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = "OAuthFlowError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ExchangeOptions {
|
||||
apiBaseUrl: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenEndpointResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(opts: ExchangeOptions): Promise<TokenResponse> {
|
||||
const response = await requestUrl({
|
||||
url: `${opts.apiBaseUrl}/api/oauth/token/`,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
grant_type: GRANT_TYPE,
|
||||
code: opts.code,
|
||||
code_verifier: opts.codeVerifier,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: opts.redirectUri,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
const body = response.json as OAuthErrorBody | undefined;
|
||||
const description = body?.error_description || body?.error || `HTTP ${response.status}`;
|
||||
throw new OAuthFlowError(body?.error || "token_exchange_failed", description);
|
||||
}
|
||||
const body = response.json as Partial<OAuthTokenEndpointResponse>;
|
||||
if (!body.access_token || !body.refresh_token) {
|
||||
throw new OAuthFlowError(
|
||||
"malformed_token_response",
|
||||
"Token endpoint returned no access/refresh pair.",
|
||||
);
|
||||
}
|
||||
return { access: body.access_token, refresh: body.refresh_token };
|
||||
}
|
||||
|
||||
async function fetchUserEmail(apiBaseUrl: string, accessToken: string): Promise<string> {
|
||||
const response = await requestUrl({
|
||||
url: `${apiBaseUrl}/api/users/me/`,
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
throw: false,
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
throw new OAuthFlowError(
|
||||
"fetch_user_failed",
|
||||
`Could not fetch user identity (HTTP ${response.status}).`,
|
||||
);
|
||||
}
|
||||
const body = response.json as Partial<UserMeResponse>;
|
||||
return body.email || "";
|
||||
}
|
||||
|
||||
/** Exported for unit-test round-tripping (see `tests/oauth.test.ts`). */
|
||||
export function generatePkceVerifier(): string {
|
||||
return randomBase64Url(PKCE_VERIFIER_BYTES);
|
||||
}
|
||||
|
||||
/** Exported for unit-test round-tripping. */
|
||||
export async function pkceChallenge(verifier: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/** Exported for unit-test round-tripping. */
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function randomBase64Url(byteLength: number): string {
|
||||
const buffer = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(buffer);
|
||||
return base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
function assertOk(response: RequestUrlResponse, context: string): void {
|
||||
if (response.status >= 400) {
|
||||
const description = describeError(response, context);
|
||||
throw new OAuthFlowError("http_error", description);
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(response: RequestUrlResponse, context: string): string {
|
||||
const body = response.json as Partial<OAuthErrorBody> | undefined;
|
||||
if (body?.error_description) {
|
||||
return `${context} failed: ${body.error_description}`;
|
||||
}
|
||||
if (body?.error) {
|
||||
return `${context} failed: ${body.error}`;
|
||||
}
|
||||
return `${context} failed: HTTP ${response.status}`;
|
||||
}
|
||||
134
src/progress.ts
Normal file
134
src/progress.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Minimal per-direction progress observable shared by the sync engines
|
||||
* and the settings tab.
|
||||
*
|
||||
* Each direction owns one {@link ProgressTracker} instance. The settings
|
||||
* tab subscribes to render live "uploaded / written" counters; the sync
|
||||
* engines call {@link ProgressTracker.report} as they advance. Reset on
|
||||
* each new pass so the indicator reflects the current run only.
|
||||
*
|
||||
* No third-party reactive primitive - a pub/sub of unknown size is
|
||||
* overkill for two consumers (settings tab + status notice). A flat
|
||||
* listener list plus an immutable snapshot is enough.
|
||||
*/
|
||||
|
||||
export type SyncDirection = "outbound" | "inbound";
|
||||
|
||||
export type SyncPhase = "idle" | "running" | "ok" | "error";
|
||||
|
||||
export interface ProgressSnapshot {
|
||||
direction: SyncDirection;
|
||||
phase: SyncPhase;
|
||||
/** Total items planned for this pass (notes scanned / exports paged). */
|
||||
total: number;
|
||||
/** Items finalised so far. */
|
||||
done: number;
|
||||
/** Free-form status line ("Uploading bodies", "Polling page 3"). */
|
||||
label: string;
|
||||
/** Last error message when ``phase === "error"``. */
|
||||
error: string;
|
||||
/** Wall-clock of the last update. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type ProgressListener = (snapshot: ProgressSnapshot) => void;
|
||||
|
||||
const INITIAL_LABELS: Record<SyncDirection, string> = {
|
||||
outbound: "Idle.",
|
||||
inbound: "Idle.",
|
||||
};
|
||||
|
||||
export class ProgressTracker {
|
||||
private snapshot: ProgressSnapshot;
|
||||
private readonly listeners = new Set<ProgressListener>();
|
||||
|
||||
constructor(direction: SyncDirection) {
|
||||
this.snapshot = {
|
||||
direction,
|
||||
phase: "idle",
|
||||
total: 0,
|
||||
done: 0,
|
||||
label: INITIAL_LABELS[direction],
|
||||
error: "",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
get value(): ProgressSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: ProgressListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.snapshot);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this direction as actively syncing and zero the counters.
|
||||
* Callers pass an initial label and (when known up-front) a total.
|
||||
*/
|
||||
start(label: string, total = 0): void {
|
||||
this.update({
|
||||
phase: "running",
|
||||
label,
|
||||
total,
|
||||
done: 0,
|
||||
error: "",
|
||||
});
|
||||
}
|
||||
|
||||
report(delta: Partial<Pick<ProgressSnapshot, "label" | "total" | "done">>): void {
|
||||
this.update(delta);
|
||||
}
|
||||
|
||||
succeed(label: string): void {
|
||||
this.update({
|
||||
phase: "ok",
|
||||
label,
|
||||
error: "",
|
||||
});
|
||||
}
|
||||
|
||||
fail(error: string): void {
|
||||
this.update({
|
||||
phase: "error",
|
||||
label: "Failed.",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to the idle phase. Called when the user disables a direction
|
||||
* so stale "Synced X notes" labels don't keep showing.
|
||||
*/
|
||||
reset(): void {
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
phase: "idle",
|
||||
label: INITIAL_LABELS[this.snapshot.direction],
|
||||
total: 0,
|
||||
done: 0,
|
||||
error: "",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private update(delta: Partial<ProgressSnapshot>): void {
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
...delta,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
455
src/settings.ts
Normal file
455
src/settings.ts
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
/**
|
||||
* Settings tab for the Unabyss plugin (Phase 5).
|
||||
*
|
||||
* Adds onto the Phase 4 shell:
|
||||
*
|
||||
* - Native folder picker for the include-folder list AND for the
|
||||
* inbound exports target folder (``FolderSuggestModal`` pattern).
|
||||
* - Per-direction enable / disable toggles.
|
||||
* - "Delete behaviour when an export is deleted in Unabyss"
|
||||
* dropdown (leave / delete / move).
|
||||
* - Per-direction live progress indicator subscribed to the
|
||||
* {@link ProgressTracker} owned by {@link UnabyssPlugin}.
|
||||
* - Working "Force full resync" button that clears the local cache
|
||||
* and immediately re-runs an outbound sync.
|
||||
*
|
||||
* The tab continues to delegate every action back to the host plugin
|
||||
* so authentication state, api client mutation, persistence, and
|
||||
* sync orchestration stay in one place.
|
||||
*/
|
||||
|
||||
import {
|
||||
AbstractInputSuggest,
|
||||
App,
|
||||
Notice,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
SuggestModal,
|
||||
TFolder,
|
||||
} from "obsidian";
|
||||
import type UnabyssPlugin from "./main";
|
||||
import { ProgressSnapshot, ProgressTracker } from "./progress";
|
||||
import { ExportDeleteBehaviour } from "./types";
|
||||
|
||||
type SubscriptionDisposer = () => void;
|
||||
|
||||
export class UnabyssSettingTab extends PluginSettingTab {
|
||||
private readonly plugin: UnabyssPlugin;
|
||||
private readonly disposers: SubscriptionDisposer[] = [];
|
||||
|
||||
constructor(app: App, plugin: UnabyssPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
this.unsubscribeAll();
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
this.renderHeader(containerEl);
|
||||
this.renderAuthState(containerEl);
|
||||
this.renderOutboundSection(containerEl);
|
||||
this.renderInboundSection(containerEl);
|
||||
this.renderSyncActions(containerEl);
|
||||
this.renderAdvancedSection(containerEl);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
|
||||
private renderHeader(containerEl: HTMLElement): void {
|
||||
const header = containerEl.createDiv({ cls: "unabyss-settings-header" });
|
||||
header.style.display = "flex";
|
||||
header.style.alignItems = "center";
|
||||
header.style.gap = "10px";
|
||||
header.style.marginBottom = "18px";
|
||||
|
||||
const logo = header.createEl("img", { cls: "unabyss-settings-logo" });
|
||||
logo.alt = "Unabyss";
|
||||
logo.src = this.pluginLogoResourcePath();
|
||||
logo.style.width = "32px";
|
||||
logo.style.height = "32px";
|
||||
logo.style.flexShrink = "0";
|
||||
|
||||
const title = header.createEl("h2", { text: "Unabyss" });
|
||||
title.style.margin = "0";
|
||||
}
|
||||
|
||||
private pluginLogoResourcePath(): string {
|
||||
const logoFile = document.body.classList.contains("theme-dark")
|
||||
? "logo-dark.svg"
|
||||
: "logo-light.svg";
|
||||
return this.app.vault.adapter.getResourcePath(
|
||||
`.obsidian/plugins/${this.plugin.manifest.id}/${logoFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderApiBaseUrl(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl)
|
||||
.setName("API base URL")
|
||||
.setDesc(
|
||||
"Unabyss API origin. The plugin opens the matching consent page in your browser " +
|
||||
"(api.<host> is rewritten to app.<host> automatically).",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("https://api.unabyss.com")
|
||||
.setValue(this.plugin.settings.apiBaseUrl)
|
||||
.onChange(async (value) => {
|
||||
const trimmed = value.trim() || "https://api.unabyss.com";
|
||||
this.plugin.settings.apiBaseUrl = trimmed;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.rebuildApiClient();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderAuthState(containerEl: HTMLElement): void {
|
||||
const auth = this.plugin.settings.auth;
|
||||
const setting = new Setting(containerEl).setName("Account");
|
||||
|
||||
if (auth) {
|
||||
setting.setDesc(`Connected as ${auth.userEmail || "(unknown)"}`);
|
||||
setting.addButton((btn) =>
|
||||
btn.setButtonText("Disconnect").onClick(async () => {
|
||||
btn.setDisabled(true);
|
||||
try {
|
||||
await this.plugin.disconnect();
|
||||
new Notice("Disconnected from Unabyss.");
|
||||
} catch (err) {
|
||||
new Notice(`Disconnect failed: ${describeError(err)}`);
|
||||
} finally {
|
||||
btn.setDisabled(false);
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setting.setDesc("Not connected.");
|
||||
setting.addButton((btn) =>
|
||||
btn.setCta().setButtonText("Connect").onClick(async () => {
|
||||
btn.setDisabled(true);
|
||||
try {
|
||||
await this.plugin.beginConnect();
|
||||
new Notice("Opened consent page in your browser.");
|
||||
} catch (err) {
|
||||
new Notice(`Connect failed: ${describeError(err)}`);
|
||||
} finally {
|
||||
btn.setDisabled(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutboundSection(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Obsidian \u2192 Unabyss (outbound)" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync outbound")
|
||||
.setDesc(
|
||||
"When off, neither file-change events, the hourly timer, nor the manual button " +
|
||||
"send notes to Unabyss.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.outboundEnabled).onChange(async (value) => {
|
||||
this.plugin.settings.outboundEnabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.onDirectionToggleChanged();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Include folders")
|
||||
.setDesc(
|
||||
"Vault-relative folder paths to sync. Leave empty to sync the whole vault. " +
|
||||
"Use the picker to add folders one at a time.",
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("Add folder").onClick(() => {
|
||||
new FolderSuggestModal(this.app, async (folder) => {
|
||||
const next = [...this.plugin.settings.includeFolders];
|
||||
if (!next.includes(folder.path)) {
|
||||
next.push(folder.path);
|
||||
this.plugin.settings.includeFolders = next;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}
|
||||
}).open();
|
||||
}),
|
||||
);
|
||||
|
||||
this.renderFolderChipList(containerEl, this.plugin.settings.includeFolders, async (next) => {
|
||||
this.plugin.settings.includeFolders = next;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
|
||||
this.renderProgressRow(containerEl, "Outbound status", this.plugin.outboundProgress);
|
||||
}
|
||||
|
||||
private renderInboundSection(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Unabyss \u2192 Obsidian (inbound)" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync inbound")
|
||||
.setDesc(
|
||||
"When off, exports are not written back into the vault and the hourly timer " +
|
||||
"skips this direction.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.inboundEnabled).onChange(async (value) => {
|
||||
this.plugin.settings.inboundEnabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.onDirectionToggleChanged();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Export target folder")
|
||||
.setDesc("Vault folder where Unabyss exports are written. Pick a folder to enable inbound sync.")
|
||||
.addText((text) => {
|
||||
const inputEl = text.inputEl;
|
||||
text.setPlaceholder("Unabyss/Exports")
|
||||
.setValue(this.plugin.settings.exportTargetFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportTargetFolder = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
new FolderInputSuggest(this.app, inputEl, async (folder) => {
|
||||
this.plugin.settings.exportTargetFolder = folder.path;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("When an export is deleted in Unabyss")
|
||||
.setDesc(
|
||||
"Controls what happens locally when Unabyss soft-deletes an export the plugin " +
|
||||
"previously wrote into your vault.",
|
||||
)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("leave", "Leave the local file alone (default)")
|
||||
.addOption("delete", "Delete the local file (system trash)")
|
||||
.addOption("move", "Move to a Deleted/ subfolder")
|
||||
.setValue(this.plugin.settings.exportDeleteBehaviour)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportDeleteBehaviour = value as ExportDeleteBehaviour;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
this.renderProgressRow(containerEl, "Inbound status", this.plugin.inboundProgress);
|
||||
}
|
||||
|
||||
private renderSyncActions(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Manual sync" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync now (both directions)")
|
||||
.setDesc("Run both enabled directions in sequence, same as the hourly timer fires.")
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setCta()
|
||||
.setButtonText("Sync now")
|
||||
.setDisabled(this.plugin.settings.auth === null)
|
||||
.onClick(async () => {
|
||||
btn.setDisabled(true);
|
||||
try {
|
||||
await this.plugin.runManualSync();
|
||||
} catch (err) {
|
||||
new Notice(`Sync failed: ${describeError(err)}`);
|
||||
} finally {
|
||||
btn.setDisabled(this.plugin.settings.auth === null);
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderAdvancedSection(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Advanced" });
|
||||
this.renderApiBaseUrl(containerEl);
|
||||
new Setting(containerEl)
|
||||
.setName("Force full resync")
|
||||
.setDesc(
|
||||
"Clears the local manifest cache + inbound watermark, then runs an outbound sync " +
|
||||
"so the server's hash-diff guard re-establishes the truth.",
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setWarning()
|
||||
.setButtonText("Force full resync")
|
||||
.setDisabled(this.plugin.settings.auth === null)
|
||||
.onClick(async () => {
|
||||
btn.setDisabled(true);
|
||||
try {
|
||||
await this.plugin.forceFullResync();
|
||||
new Notice("Force full resync complete.");
|
||||
} catch (err) {
|
||||
new Notice(`Force full resync failed: ${describeError(err)}`);
|
||||
} finally {
|
||||
btn.setDisabled(this.plugin.settings.auth === null);
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderFolderChipList(
|
||||
containerEl: HTMLElement,
|
||||
folders: string[],
|
||||
save: (next: string[]) => Promise<void>,
|
||||
): void {
|
||||
if (folders.length === 0) {
|
||||
return;
|
||||
}
|
||||
const chipsRow = containerEl.createDiv({ cls: "unabyss-folder-chip-row" });
|
||||
chipsRow.style.display = "flex";
|
||||
chipsRow.style.flexWrap = "wrap";
|
||||
chipsRow.style.gap = "8px";
|
||||
chipsRow.style.marginBottom = "12px";
|
||||
for (const folder of folders) {
|
||||
const chip = chipsRow.createDiv({ cls: "unabyss-folder-chip" });
|
||||
chip.style.padding = "4px 8px";
|
||||
chip.style.border = "1px solid var(--background-modifier-border)";
|
||||
chip.style.borderRadius = "4px";
|
||||
chip.style.display = "flex";
|
||||
chip.style.alignItems = "center";
|
||||
chip.style.gap = "6px";
|
||||
chip.createSpan({ text: folder });
|
||||
const remove = chip.createEl("button", { text: "x" });
|
||||
remove.style.background = "transparent";
|
||||
remove.style.border = "none";
|
||||
remove.style.cursor = "pointer";
|
||||
remove.addEventListener("click", async () => {
|
||||
const next = folders.filter((entry) => entry !== folder);
|
||||
await save(next);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderProgressRow(
|
||||
containerEl: HTMLElement,
|
||||
name: string,
|
||||
tracker: ProgressTracker,
|
||||
): void {
|
||||
const setting = new Setting(containerEl).setName(name);
|
||||
const valueEl = setting.descEl.createSpan();
|
||||
const dispose = tracker.subscribe((snapshot) => {
|
||||
valueEl.setText(formatProgress(snapshot));
|
||||
});
|
||||
this.disposers.push(dispose);
|
||||
}
|
||||
|
||||
private unsubscribeAll(): void {
|
||||
for (const dispose of this.disposers) {
|
||||
dispose();
|
||||
}
|
||||
this.disposers.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProgress(snapshot: ProgressSnapshot): string {
|
||||
const head = `${snapshot.label}`;
|
||||
if (snapshot.phase === "running" && snapshot.total > 0) {
|
||||
return `${head} (${snapshot.done}/${snapshot.total})`;
|
||||
}
|
||||
if (snapshot.phase === "error" && snapshot.error) {
|
||||
return `${head} - ${snapshot.error}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that suggests every folder in the vault. Used by the include-list
|
||||
* "Add folder" button. Mirrors the official ``FolderSuggestModal``
|
||||
* pattern (Obsidian's own UX for the "Move file to..." flow).
|
||||
*/
|
||||
class FolderSuggestModal extends SuggestModal<TFolder> {
|
||||
private readonly onChoose: (folder: TFolder) => void | Promise<void>;
|
||||
|
||||
constructor(app: App, onChoose: (folder: TFolder) => void | Promise<void>) {
|
||||
super(app);
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder("Pick a vault folder...");
|
||||
}
|
||||
|
||||
getSuggestions(query: string): TFolder[] {
|
||||
const folders: TFolder[] = [];
|
||||
const root = this.app.vault.getRoot();
|
||||
const walk = (folder: TFolder): void => {
|
||||
folders.push(folder);
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFolder) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
const needle = query.toLowerCase();
|
||||
return folders.filter((folder) => folder.path.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
renderSuggestion(folder: TFolder, el: HTMLElement): void {
|
||||
el.setText(folder.path === "/" ? "(vault root)" : folder.path);
|
||||
}
|
||||
|
||||
onChooseSuggestion(folder: TFolder): void {
|
||||
Promise.resolve(this.onChoose(folder)).catch((err) => {
|
||||
console.warn("Unabyss: folder pick failed", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline ``AbstractInputSuggest`` attached to the target-folder text
|
||||
* input so users can type-ahead instead of opening a separate modal.
|
||||
*/
|
||||
class FolderInputSuggest extends AbstractInputSuggest<TFolder> {
|
||||
private readonly inputEl: HTMLInputElement;
|
||||
|
||||
constructor(app: App, inputEl: HTMLInputElement, onPick: (folder: TFolder) => Promise<void>) {
|
||||
super(app, inputEl);
|
||||
this.inputEl = inputEl;
|
||||
this.onSelect((folder) => {
|
||||
this.inputEl.value = folder.path;
|
||||
this.close();
|
||||
onPick(folder).catch((err) => {
|
||||
console.warn("Unabyss: folder suggest pick failed", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected getSuggestions(query: string): TFolder[] {
|
||||
const folders: TFolder[] = [];
|
||||
const root = this.app.vault.getRoot();
|
||||
const walk = (folder: TFolder): void => {
|
||||
folders.push(folder);
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFolder) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
const needle = query.toLowerCase();
|
||||
return folders.filter((folder) => folder.path.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
renderSuggestion(folder: TFolder, el: HTMLElement): void {
|
||||
el.setText(folder.path === "/" ? "(vault root)" : folder.path);
|
||||
}
|
||||
}
|
||||
379
src/syncInbound.ts
Normal file
379
src/syncInbound.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Inbound sync engine (Unabyss -> Obsidian).
|
||||
*
|
||||
* Polls ``GET /api/exports/changed-since/?updated_after=<watermark>``,
|
||||
* paginates through the response set, and writes each row into the
|
||||
* user-configured ``exportTargetFolder`` as
|
||||
* ``<slugify(title)>.md``. The bottom of every written file carries a
|
||||
* stable trailer line ``<!-- unabyss-export-id: <uuid> -->`` so the
|
||||
* next sync can recognise a same-slug existing file as the same export
|
||||
* (idempotent overwrite) versus an unrelated user-authored note
|
||||
* (suffix-on-write with ``-<first-6-of-uuid>``).
|
||||
*
|
||||
* Delete behaviour per export ``is_deleted=true`` row is governed by
|
||||
* the per-user ``exportDeleteBehaviour`` setting:
|
||||
*
|
||||
* - ``leave`` : do nothing (default; the on-disk file is treated as
|
||||
* the user's now-detached copy).
|
||||
* - ``delete`` : trash the export file if we own it (trailer matches).
|
||||
* - ``move`` : relocate the file into ``Deleted/`` underneath the
|
||||
* target folder. The trailer stays so a subsequent
|
||||
* restore can find it again.
|
||||
*
|
||||
* Watermark: ``updated_after`` advances **only** after a page is fully
|
||||
* applied. A mid-page failure leaves the watermark at the boundary of
|
||||
* the last fully-applied page so the next pass retries the same rows.
|
||||
*/
|
||||
|
||||
import { App, normalizePath, TFile, TFolder, Vault } from "obsidian";
|
||||
import { UnabyssApiClient } from "./apiClient";
|
||||
import { ManifestCache } from "./manifestCache";
|
||||
import { ProgressTracker } from "./progress";
|
||||
import {
|
||||
DELETED_EXPORTS_SUBFOLDER,
|
||||
EXPORT_TRAILER_PREFIX,
|
||||
EXPORT_TRAILER_SUFFIX,
|
||||
ExportDeleteBehaviour,
|
||||
ExportRow,
|
||||
InboundFileOutcome,
|
||||
SyncInboundReport,
|
||||
} from "./types";
|
||||
|
||||
const INBOUND_PAGE_SIZE = 100;
|
||||
const FALLBACK_WATERMARK = "1970-01-01T00:00:00Z";
|
||||
|
||||
export interface InboundSyncDeps {
|
||||
app: App;
|
||||
api: UnabyssApiClient;
|
||||
cache: ManifestCache;
|
||||
targetFolder: string;
|
||||
deleteBehaviour: ExportDeleteBehaviour;
|
||||
progress?: ProgressTracker;
|
||||
}
|
||||
|
||||
export async function runInboundSync(deps: InboundSyncDeps): Promise<SyncInboundReport> {
|
||||
const target = normalizePath(deps.targetFolder.trim());
|
||||
if (!target) {
|
||||
throw new Error("Pick a vault folder for incoming exports before running inbound sync.");
|
||||
}
|
||||
await ensureFolderExists(deps.app.vault, target);
|
||||
|
||||
const report: SyncInboundReport = {
|
||||
polled: 0,
|
||||
written: 0,
|
||||
deleted: 0,
|
||||
moved: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
watermarkAdvancedTo: deps.cache.getExportsWatermark() || FALLBACK_WATERMARK,
|
||||
};
|
||||
|
||||
deps.progress?.start("Polling exports...", 0);
|
||||
|
||||
let pageOffset = 0;
|
||||
let pageCount = 0;
|
||||
const watermarkInitial = deps.cache.getExportsWatermark() || FALLBACK_WATERMARK;
|
||||
while (true) {
|
||||
const page = await deps.api.getChangedExports(watermarkInitial, INBOUND_PAGE_SIZE, pageOffset);
|
||||
const rows = page.results ?? [];
|
||||
if (rows.length === 0) {
|
||||
break;
|
||||
}
|
||||
pageCount += 1;
|
||||
deps.progress?.report({
|
||||
label: `Polling page ${pageCount}...`,
|
||||
total: report.polled + rows.length,
|
||||
});
|
||||
|
||||
let pageHighest = "";
|
||||
let pageFailed = false;
|
||||
for (const row of rows) {
|
||||
const outcome = await applyExportRow(deps, target, row);
|
||||
report.polled += 1;
|
||||
absorbOutcome(report, outcome);
|
||||
if (outcome.error) {
|
||||
pageFailed = true;
|
||||
}
|
||||
if (!outcome.error && row.updated_at > pageHighest) {
|
||||
pageHighest = row.updated_at;
|
||||
}
|
||||
deps.progress?.report({
|
||||
label: outcome.error ? `Error on ${row.title}` : `Synced "${row.title}"`,
|
||||
done: report.polled,
|
||||
});
|
||||
}
|
||||
|
||||
if (!pageFailed && pageHighest) {
|
||||
deps.cache.setExportsWatermark(pageHighest);
|
||||
report.watermarkAdvancedTo = pageHighest;
|
||||
await deps.cache.flush();
|
||||
} else if (pageFailed) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (rows.length < INBOUND_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
pageOffset += INBOUND_PAGE_SIZE;
|
||||
}
|
||||
|
||||
if (report.errors.length === 0) {
|
||||
deps.progress?.succeed(
|
||||
`Inbound done - ${report.written} written, ${report.deleted} deleted, ${report.moved} moved.`,
|
||||
);
|
||||
} else {
|
||||
deps.progress?.fail(`${report.errors.length} row(s) failed; will retry next sync.`);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
async function applyExportRow(
|
||||
deps: InboundSyncDeps,
|
||||
targetFolder: string,
|
||||
row: ExportRow,
|
||||
): Promise<InboundFileOutcome> {
|
||||
try {
|
||||
if (row.is_deleted) {
|
||||
return await applyDeletion(deps, targetFolder, row);
|
||||
}
|
||||
return await applyUpsert(deps, targetFolder, row);
|
||||
} catch (err) {
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: "",
|
||||
action: "skipped",
|
||||
error: describeError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpsert(
|
||||
deps: InboundSyncDeps,
|
||||
targetFolder: string,
|
||||
row: ExportRow,
|
||||
): Promise<InboundFileOutcome> {
|
||||
const baseSlug = slugifyTitle(row.title) || "untitled";
|
||||
const primaryPath = joinPath(targetFolder, `${baseSlug}.md`);
|
||||
const ownPath = await locateOwnedExport(deps.app.vault, targetFolder, row.id);
|
||||
const targetPath = ownPath ?? (await resolveCollisionPath(deps.app.vault, primaryPath, row.id));
|
||||
const body = appendTrailer(row.markdown, row.id);
|
||||
|
||||
const existing = deps.app.vault.getAbstractFileByPath(targetPath);
|
||||
if (existing && existing instanceof TFile) {
|
||||
await deps.app.vault.modify(existing, body);
|
||||
} else {
|
||||
await deps.app.vault.create(targetPath, body);
|
||||
}
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: targetPath,
|
||||
action: "written",
|
||||
};
|
||||
}
|
||||
|
||||
async function applyDeletion(
|
||||
deps: InboundSyncDeps,
|
||||
targetFolder: string,
|
||||
row: ExportRow,
|
||||
): Promise<InboundFileOutcome> {
|
||||
if (deps.deleteBehaviour === "leave") {
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: "",
|
||||
action: "skipped",
|
||||
};
|
||||
}
|
||||
const ownPath = await locateOwnedExport(deps.app.vault, targetFolder, row.id);
|
||||
if (!ownPath) {
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: "",
|
||||
action: "skipped",
|
||||
};
|
||||
}
|
||||
const existing = deps.app.vault.getAbstractFileByPath(ownPath);
|
||||
if (!(existing instanceof TFile)) {
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: ownPath,
|
||||
action: "skipped",
|
||||
};
|
||||
}
|
||||
if (deps.deleteBehaviour === "delete") {
|
||||
await deps.app.vault.trash(existing, true);
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: ownPath,
|
||||
action: "deleted",
|
||||
};
|
||||
}
|
||||
const trashFolder = joinPath(targetFolder, DELETED_EXPORTS_SUBFOLDER);
|
||||
await ensureFolderExists(deps.app.vault, trashFolder);
|
||||
const movedPath = await resolveCollisionPath(
|
||||
deps.app.vault,
|
||||
joinPath(trashFolder, existing.name),
|
||||
row.id,
|
||||
);
|
||||
await deps.app.fileManager.renameFile(existing, movedPath);
|
||||
return {
|
||||
exportId: row.id,
|
||||
title: row.title,
|
||||
vaultPath: movedPath,
|
||||
action: "moved",
|
||||
};
|
||||
}
|
||||
|
||||
function absorbOutcome(report: SyncInboundReport, outcome: InboundFileOutcome): void {
|
||||
switch (outcome.action) {
|
||||
case "written":
|
||||
report.written += 1;
|
||||
break;
|
||||
case "deleted":
|
||||
report.deleted += 1;
|
||||
break;
|
||||
case "moved":
|
||||
report.moved += 1;
|
||||
break;
|
||||
case "skipped":
|
||||
report.skipped += 1;
|
||||
break;
|
||||
}
|
||||
if (outcome.error) {
|
||||
report.errors.push(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the export target folder (and the optional ``Deleted/``
|
||||
* subfolder) looking for a file whose body carries this export's
|
||||
* trailer. The lookup is O(N-files-in-target-folder), bounded by what
|
||||
* a user would realistically put there.
|
||||
*/
|
||||
async function locateOwnedExport(
|
||||
vault: Vault,
|
||||
targetFolder: string,
|
||||
exportId: string,
|
||||
): Promise<string | null> {
|
||||
const folders = [targetFolder, joinPath(targetFolder, DELETED_EXPORTS_SUBFOLDER)];
|
||||
const trailer = trailerLineFor(exportId);
|
||||
for (const folder of folders) {
|
||||
const node = vault.getAbstractFileByPath(folder);
|
||||
if (!(node instanceof TFolder)) {
|
||||
continue;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
if (!(child instanceof TFile) || child.extension !== "md") {
|
||||
continue;
|
||||
}
|
||||
const text = await vault.cachedRead(child);
|
||||
if (text.includes(trailer)) {
|
||||
return child.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendTrailer(body: string, exportId: string): string {
|
||||
const trailer = trailerLineFor(exportId);
|
||||
const trimmed = body.replace(/\s+$/u, "");
|
||||
return `${trimmed}\n\n${trailer}\n`;
|
||||
}
|
||||
|
||||
export function trailerLineFor(exportId: string): string {
|
||||
return `${EXPORT_TRAILER_PREFIX} ${exportId} ${EXPORT_TRAILER_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify an export title for use as a filename.
|
||||
*
|
||||
* Rules (kept simple to avoid pulling in a runtime dependency):
|
||||
*
|
||||
* - Lowercase ASCII alphanumerics and dashes.
|
||||
* - Non-ASCII letters and punctuation collapse to dashes.
|
||||
* - Repeated dashes collapse to one; leading / trailing dashes drop.
|
||||
* - Truncate to 80 characters so the final ``<slug>-<suffix>.md`` path
|
||||
* fits comfortably inside Obsidian's ``normalizePath`` limit.
|
||||
*/
|
||||
export function slugifyTitle(title: string): string {
|
||||
const lowered = title.toLowerCase();
|
||||
const stripped = lowered.replace(/[^a-z0-9]+/gu, "-");
|
||||
const collapsed = stripped.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
|
||||
return collapsed.slice(0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suffix policy for slug collisions.
|
||||
*
|
||||
* The collision target is "a different file already exists at this
|
||||
* vault-relative path that doesn't carry our trailer". The plugin
|
||||
* appends ``-<first-6-of-uuid>`` to the slug body (before ``.md``)
|
||||
* and rechecks. With 16^6 = ~16M possible suffixes and a slug that
|
||||
* already differs by title, a second collision is statistically
|
||||
* negligible; the function falls back to a UUID-only filename in the
|
||||
* pathological case rather than looping.
|
||||
*/
|
||||
export function buildCollisionPath(primaryPath: string, exportId: string): string {
|
||||
const ext = ".md";
|
||||
const dotIndex = primaryPath.lastIndexOf(".");
|
||||
const body = dotIndex === -1 ? primaryPath : primaryPath.slice(0, dotIndex);
|
||||
const suffix = collisionSuffixFor(exportId);
|
||||
return `${body}-${suffix}${ext}`;
|
||||
}
|
||||
|
||||
export function collisionSuffixFor(exportId: string): string {
|
||||
const stripped = exportId.replace(/-/gu, "");
|
||||
return stripped.slice(0, 6) || exportId.slice(0, 6);
|
||||
}
|
||||
|
||||
async function resolveCollisionPath(
|
||||
vault: Vault,
|
||||
desiredPath: string,
|
||||
exportId: string,
|
||||
): Promise<string> {
|
||||
if (!vault.getAbstractFileByPath(desiredPath)) {
|
||||
return desiredPath;
|
||||
}
|
||||
const candidate = buildCollisionPath(desiredPath, exportId);
|
||||
if (!vault.getAbstractFileByPath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
const ext = ".md";
|
||||
const dotIndex = candidate.lastIndexOf(".");
|
||||
const body = dotIndex === -1 ? candidate : candidate.slice(0, dotIndex);
|
||||
return `${body}-${exportId.slice(0, 8)}${ext}`;
|
||||
}
|
||||
|
||||
function joinPath(folder: string, name: string): string {
|
||||
if (!folder || folder === "/") {
|
||||
return normalizePath(name);
|
||||
}
|
||||
return normalizePath(`${folder.replace(/\/$/u, "")}/${name}`);
|
||||
}
|
||||
|
||||
async function ensureFolderExists(vault: Vault, folder: string): Promise<void> {
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
const existing = vault.getAbstractFileByPath(folder);
|
||||
if (existing instanceof TFolder) {
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
throw new Error(`Inbound target "${folder}" exists but is not a folder.`);
|
||||
}
|
||||
await vault.createFolder(folder);
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
269
src/syncOutbound.ts
Normal file
269
src/syncOutbound.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Outbound sync engine (Obsidian -> Unabyss) - minimum-viable Phase 4
|
||||
* implementation.
|
||||
*
|
||||
* Flow per ``run()`` (manual "Sync now" button):
|
||||
*
|
||||
* 1. Walk every markdown file in the vault via
|
||||
* ``app.vault.getMarkdownFiles()``.
|
||||
* 2. Apply the user's include-folder filter (empty list = whole vault).
|
||||
* 3. Hash each in-scope file with SHA-256 (matching the server's
|
||||
* ``compute_hash(raw_bytes)``). Skip oversize files
|
||||
* (> ``MAX_NOTE_BYTES``).
|
||||
* 4. Persist the computed hashes back into the local manifest cache.
|
||||
* 5. Chunk the hash set into ``MANIFEST_MAX_HASHES_PER_CHUNK`` batches
|
||||
* and POST each to ``manifest-chunks/``.
|
||||
* 6. For every hash the server reports as missing, upload the body
|
||||
* via ``notes/upload/`` in batches of
|
||||
* ``NOTE_BODIES_MAX_PER_REQUEST``.
|
||||
* 7. POST the full hash set to ``sync-finalize/`` and bump the local
|
||||
* ``last_synced_at``.
|
||||
*
|
||||
* The manifest cache is best-effort. Files whose ``mtime`` matches the
|
||||
* cache row skip hash recomputation; the server-side hash diff is the
|
||||
* ultimate source of truth, so a stale or wiped cache produces extra
|
||||
* hashing work but never bad data.
|
||||
*/
|
||||
|
||||
import { App, normalizePath, TFile } from "obsidian";
|
||||
import { UnabyssApiClient } from "./apiClient";
|
||||
import { ManifestCache } from "./manifestCache";
|
||||
import { ProgressTracker } from "./progress";
|
||||
import {
|
||||
MAX_NOTE_BYTES,
|
||||
MANIFEST_MAX_HASHES_PER_CHUNK,
|
||||
NOTE_BODIES_MAX_PER_REQUEST,
|
||||
NoteEnvelope,
|
||||
NoteUploadRejection,
|
||||
SyncOutboundReport,
|
||||
} from "./types";
|
||||
|
||||
interface ScannedNote {
|
||||
path: string;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
sizeBytes: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface OutboundSyncDeps {
|
||||
app: App;
|
||||
api: UnabyssApiClient;
|
||||
cache: ManifestCache;
|
||||
vaultId: string;
|
||||
vaultDisplayName: string;
|
||||
includeFolders: string[];
|
||||
progress?: ProgressTracker;
|
||||
}
|
||||
|
||||
export async function runOutboundSync(deps: OutboundSyncDeps): Promise<SyncOutboundReport> {
|
||||
if (!deps.vaultId) {
|
||||
throw new Error("Outbound sync requires a vault_id. Connect to Unabyss first.");
|
||||
}
|
||||
const filter = buildIncludeFilter(deps.includeFolders);
|
||||
const allMarkdown = deps.app.vault.getMarkdownFiles();
|
||||
const inScope = allMarkdown.filter((file) => filter(file.path));
|
||||
|
||||
deps.progress?.report({ label: "Hashing notes...", total: inScope.length, done: 0 });
|
||||
|
||||
const scanned: ScannedNote[] = [];
|
||||
let skippedOversize = 0;
|
||||
let scannedCount = 0;
|
||||
for (const file of inScope) {
|
||||
const note = await scanNote(deps.app, deps.cache, file);
|
||||
scannedCount += 1;
|
||||
deps.progress?.report({ done: scannedCount });
|
||||
if (note === null) {
|
||||
skippedOversize += 1;
|
||||
continue;
|
||||
}
|
||||
scanned.push(note);
|
||||
}
|
||||
|
||||
deps.cache.retainOnly(scanned.map((row) => row.path));
|
||||
await deps.cache.flush();
|
||||
|
||||
const allHashes = uniqueHashes(scanned);
|
||||
deps.progress?.report({ label: "Diffing against server..." });
|
||||
const missing = await diffAgainstServer(deps.api, deps.vaultId, allHashes);
|
||||
deps.progress?.report({
|
||||
label: `Uploading ${missing.size} missing body(s)...`,
|
||||
total: missing.size,
|
||||
done: 0,
|
||||
});
|
||||
const uploadOutcome = await uploadMissingBodies(
|
||||
deps.api,
|
||||
deps.vaultId,
|
||||
scanned,
|
||||
missing,
|
||||
deps.progress,
|
||||
);
|
||||
deps.progress?.report({ label: "Finalising sync..." });
|
||||
const finalize = await deps.api.postSyncFinalize({
|
||||
vault_id: deps.vaultId,
|
||||
hashes: allHashes,
|
||||
vault_display_name: deps.vaultDisplayName || undefined,
|
||||
});
|
||||
return {
|
||||
scanned: scanned.length,
|
||||
skippedOversize,
|
||||
uploaded: uploadOutcome.uploaded,
|
||||
rejected: uploadOutcome.rejected,
|
||||
deleted: finalize.deleted,
|
||||
restored: finalize.restored,
|
||||
lastSyncedAt: finalize.last_synced_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function scanNote(app: App, cache: ManifestCache, file: TFile): Promise<ScannedNote | null> {
|
||||
const cached = cache.get(file.path);
|
||||
if (cached && cached.mtime === file.stat.mtime) {
|
||||
const content = await app.vault.cachedRead(file);
|
||||
const sizeBytes = byteLength(content);
|
||||
if (sizeBytes > MAX_NOTE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
content,
|
||||
contentHash: cached.contentHash,
|
||||
sizeBytes,
|
||||
mtime: file.stat.mtime,
|
||||
};
|
||||
}
|
||||
const content = await app.vault.cachedRead(file);
|
||||
const sizeBytes = byteLength(content);
|
||||
if (sizeBytes > MAX_NOTE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const contentHash = await computeSha256Hex(content);
|
||||
cache.set(file.path, { contentHash, mtime: file.stat.mtime });
|
||||
return {
|
||||
path: file.path,
|
||||
content,
|
||||
contentHash,
|
||||
sizeBytes,
|
||||
mtime: file.stat.mtime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a predicate matching the user's include-folder list. Empty
|
||||
* list = include everything; otherwise vault-relative paths must be
|
||||
* under one of the listed folder prefixes (case-sensitive, matching
|
||||
* Obsidian's path semantics).
|
||||
*/
|
||||
function buildIncludeFilter(includeFolders: string[]): (path: string) => boolean {
|
||||
const normalized = includeFolders
|
||||
.map((folder) => folder.trim())
|
||||
.filter((folder) => folder.length > 0)
|
||||
.map((folder) => normalizePath(folder));
|
||||
if (normalized.length === 0) {
|
||||
return () => true;
|
||||
}
|
||||
return (path: string) => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
return normalized.some((prefix) => {
|
||||
if (prefix === "/" || prefix === "") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizedPath === prefix ||
|
||||
normalizedPath.startsWith(prefix + "/")
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueHashes(rows: ScannedNote[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const row of rows) {
|
||||
set.add(row.contentHash);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
async function diffAgainstServer(
|
||||
api: UnabyssApiClient,
|
||||
vaultId: string,
|
||||
allHashes: string[],
|
||||
): Promise<Set<string>> {
|
||||
const missing = new Set<string>();
|
||||
for (let i = 0; i < allHashes.length; i += MANIFEST_MAX_HASHES_PER_CHUNK) {
|
||||
const chunk = allHashes.slice(i, i + MANIFEST_MAX_HASHES_PER_CHUNK);
|
||||
const response = await api.postManifestChunk({
|
||||
vault_id: vaultId,
|
||||
hashes: chunk,
|
||||
});
|
||||
for (const hash of response.missing_hashes ?? []) {
|
||||
missing.add(hash);
|
||||
}
|
||||
}
|
||||
if (allHashes.length === 0) {
|
||||
await api.postManifestChunk({ vault_id: vaultId, hashes: [] });
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
interface UploadOutcome {
|
||||
uploaded: number;
|
||||
rejected: NoteUploadRejection[];
|
||||
}
|
||||
|
||||
async function uploadMissingBodies(
|
||||
api: UnabyssApiClient,
|
||||
vaultId: string,
|
||||
scanned: ScannedNote[],
|
||||
missing: Set<string>,
|
||||
progress?: ProgressTracker,
|
||||
): Promise<UploadOutcome> {
|
||||
if (missing.size === 0) {
|
||||
return { uploaded: 0, rejected: [] };
|
||||
}
|
||||
const envelopes: NoteEnvelope[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of scanned) {
|
||||
if (!missing.has(row.contentHash) || seen.has(row.contentHash)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(row.contentHash);
|
||||
envelopes.push({
|
||||
vault_path: row.path,
|
||||
content: row.content,
|
||||
vault_id: vaultId,
|
||||
content_hash: row.contentHash,
|
||||
});
|
||||
}
|
||||
let uploaded = 0;
|
||||
const rejected: NoteUploadRejection[] = [];
|
||||
for (let i = 0; i < envelopes.length; i += NOTE_BODIES_MAX_PER_REQUEST) {
|
||||
const batch = envelopes.slice(i, i + NOTE_BODIES_MAX_PER_REQUEST);
|
||||
const response = await api.postNoteUpload({ notes: batch });
|
||||
uploaded += response.accepted ?? 0;
|
||||
for (const rejection of response.rejected ?? []) {
|
||||
rejected.push(rejection);
|
||||
}
|
||||
progress?.report({ done: uploaded });
|
||||
}
|
||||
return { uploaded, rejected };
|
||||
}
|
||||
|
||||
function byteLength(content: string): number {
|
||||
return new TextEncoder().encode(content).byteLength;
|
||||
}
|
||||
|
||||
async function computeSha256Hex(content: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(content);
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
||||
return bytesToHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
const HEX = "0123456789abcdef";
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
out += HEX[byte >> 4] + HEX[byte & 0x0f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
246
src/types.ts
Normal file
246
src/types.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Shared types for the Unabyss Obsidian plugin.
|
||||
*
|
||||
* The wire shapes here mirror the backend serializers documented in
|
||||
* `dev-docs/backend/obsidian-plugin/01/tech-decisions.md` so the
|
||||
* client and server stay byte-aligned without an OpenAPI codegen step.
|
||||
*
|
||||
* Persistence: every field marked "persisted" lands in
|
||||
* `<vault>/.obsidian/plugins/unabyss/data.json` as plaintext JSON
|
||||
* (see README threat model).
|
||||
*/
|
||||
|
||||
export const DEFAULT_API_BASE_URL = "https://api.unabyss.com";
|
||||
export const OAUTH_CLIENT_ID = "obsidian";
|
||||
export const OAUTH_REDIRECT_URI = "obsidian://unabyss/auth-callback";
|
||||
export const OAUTH_PROTOCOL_ACTION = "unabyss/auth-callback";
|
||||
|
||||
/**
|
||||
* Hard upper bounds enforced by the backend serializers. Kept in sync
|
||||
* with `OBSIDIAN_MANIFEST_MAX_HASHES_PER_CHUNK` and
|
||||
* `OBSIDIAN_NOTE_BODIES_MAX_PER_REQUEST` in `settings/base/integrations.py`.
|
||||
* Mirroring the value locally lets the plugin pre-chunk before hitting
|
||||
* a 400 from the server.
|
||||
*/
|
||||
export const MANIFEST_MAX_HASHES_PER_CHUNK = 1000;
|
||||
export const NOTE_BODIES_MAX_PER_REQUEST = 100;
|
||||
|
||||
/**
|
||||
* Per-note size cap. Mirrors `_MAX_NOTE_BYTES = 1 MiB` in
|
||||
* `ingest/obsidian/services/obsidian_import.py`. Notes above this cap
|
||||
* are skipped client-side so the plugin never wastes a round-trip on
|
||||
* a row the server will reject.
|
||||
*/
|
||||
export const MAX_NOTE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface AuthState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behaviour when an export is soft-deleted on the server side.
|
||||
* Mirrors the requirements §Configuration choices.
|
||||
*/
|
||||
export type ExportDeleteBehaviour = "leave" | "delete" | "move";
|
||||
|
||||
/** Subfolder used when {@link ExportDeleteBehaviour} is "move". */
|
||||
export const DELETED_EXPORTS_SUBFOLDER = "Deleted";
|
||||
|
||||
/**
|
||||
* Debounce window applied to Obsidian file-change events before kicking
|
||||
* off an outbound sync. Aligns with phases.md §"main.ts deltas".
|
||||
*/
|
||||
export const OUTBOUND_DEBOUNCE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Safety-net timer cadence. Runs both directions regardless of file
|
||||
* events so the plugin recovers from missed events / external writes.
|
||||
*/
|
||||
export const SAFETY_NET_INTERVAL_MS = 60 * 60 * 1_000;
|
||||
|
||||
/**
|
||||
* Wire-format trailer appended to the bottom of every Unabyss export
|
||||
* we write into the vault so subsequent syncs can detect that a slug
|
||||
* collision is *the same* export (idempotent overwrite) versus a
|
||||
* collision with an unrelated user-authored note (suffix-on-write).
|
||||
*/
|
||||
export const EXPORT_TRAILER_PREFIX = "<!-- unabyss-export-id:";
|
||||
export const EXPORT_TRAILER_SUFFIX = "-->";
|
||||
|
||||
export interface PluginSettings {
|
||||
apiBaseUrl: string;
|
||||
vaultId: string;
|
||||
includeFolders: string[];
|
||||
/** Vault-relative folder where inbound exports are written. */
|
||||
exportTargetFolder: string;
|
||||
/** Action to take when a synced export is deleted server-side. */
|
||||
exportDeleteBehaviour: ExportDeleteBehaviour;
|
||||
/** Master enable switch for the Obsidian -> Unabyss direction. */
|
||||
outboundEnabled: boolean;
|
||||
/** Master enable switch for the Unabyss -> Obsidian direction. */
|
||||
inboundEnabled: boolean;
|
||||
auth: AuthState | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
apiBaseUrl: DEFAULT_API_BASE_URL,
|
||||
vaultId: "",
|
||||
includeFolders: [],
|
||||
exportTargetFolder: "",
|
||||
exportDeleteBehaviour: "leave",
|
||||
outboundEnabled: true,
|
||||
inboundEnabled: true,
|
||||
auth: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* One row inside the local manifest cache (`<vault_path,
|
||||
* content_hash, mtime>`). `mtime` is the Obsidian-reported
|
||||
* `stat.mtime` in milliseconds since epoch and lets us short-circuit
|
||||
* the hash recomputation on files that did not change.
|
||||
*/
|
||||
export interface ManifestCacheEntry {
|
||||
contentHash: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
/** On-disk shape of the path -> hash cache, keyed by vault-relative path. */
|
||||
export type ManifestPathIndex = Record<string, ManifestCacheEntry>;
|
||||
|
||||
/**
|
||||
* Full on-disk shape of the manifest cache. v1 was a flat
|
||||
* ``Record<path, entry>``; the cache now also persists the per-vault
|
||||
* inbound exports watermark used by ``syncInbound`` so a fresh plugin
|
||||
* boot resumes from the same offset.
|
||||
*/
|
||||
export interface ManifestCacheData {
|
||||
paths: ManifestPathIndex;
|
||||
/** ISO-8601 high-watermark for ``GET /api/exports/changed-since/``. */
|
||||
exportsUpdatedAfter: string;
|
||||
}
|
||||
|
||||
export const EMPTY_MANIFEST_CACHE: ManifestCacheData = {
|
||||
paths: {},
|
||||
exportsUpdatedAfter: "",
|
||||
};
|
||||
|
||||
export interface ManifestChunkRequest {
|
||||
vault_id: string;
|
||||
hashes: string[];
|
||||
}
|
||||
|
||||
export interface ManifestChunkResponse {
|
||||
missing_hashes: string[];
|
||||
}
|
||||
|
||||
export interface NoteEnvelope {
|
||||
vault_path: string;
|
||||
content: string;
|
||||
vault_id: string;
|
||||
content_hash: string;
|
||||
}
|
||||
|
||||
export interface NoteUploadRequest {
|
||||
notes: NoteEnvelope[];
|
||||
}
|
||||
|
||||
export interface NoteUploadRejection {
|
||||
vault_path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface NoteUploadResponse {
|
||||
accepted: number;
|
||||
rejected: NoteUploadRejection[];
|
||||
}
|
||||
|
||||
export interface SyncFinalizeRequest {
|
||||
vault_id: string;
|
||||
hashes: string[];
|
||||
vault_display_name?: string;
|
||||
}
|
||||
|
||||
export interface SyncFinalizeResponse {
|
||||
deleted: number;
|
||||
restored: number;
|
||||
last_synced_at: string;
|
||||
}
|
||||
|
||||
export interface VaultRow {
|
||||
vault_id: string;
|
||||
display_name: string;
|
||||
is_export_target: boolean;
|
||||
connected_at: string;
|
||||
last_synced_at: string | null;
|
||||
last_sync_error: string;
|
||||
notes_count: number;
|
||||
notes_soft_deleted_count: number;
|
||||
}
|
||||
|
||||
export interface VaultListResponse {
|
||||
results: VaultRow[];
|
||||
}
|
||||
|
||||
export interface ExportRow {
|
||||
id: string;
|
||||
title: string;
|
||||
topic_text: string;
|
||||
preset_slug: string;
|
||||
status: string;
|
||||
is_deleted: boolean;
|
||||
markdown: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
export interface OAuthErrorBody {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export interface UserMeResponse {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SyncOutboundReport {
|
||||
scanned: number;
|
||||
skippedOversize: number;
|
||||
uploaded: number;
|
||||
rejected: NoteUploadRejection[];
|
||||
deleted: number;
|
||||
restored: number;
|
||||
lastSyncedAt: string;
|
||||
}
|
||||
|
||||
/** Per-row outcome surfaced from one inbound sync pass. */
|
||||
export interface InboundFileOutcome {
|
||||
exportId: string;
|
||||
title: string;
|
||||
vaultPath: string;
|
||||
action: "written" | "skipped" | "deleted" | "moved";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncInboundReport {
|
||||
polled: number;
|
||||
written: number;
|
||||
deleted: number;
|
||||
moved: number;
|
||||
skipped: number;
|
||||
errors: InboundFileOutcome[];
|
||||
watermarkAdvancedTo: string;
|
||||
}
|
||||
98
tests/__mocks__/obsidian.ts
Normal file
98
tests/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Test-time stub of the ``obsidian`` runtime module.
|
||||
*
|
||||
* Only the symbols actually reached by unit tests are implemented; the
|
||||
* rest are inert placeholders that exist so TypeScript ``import``
|
||||
* statements resolve. Tests that need vault behaviour build their own
|
||||
* tiny fakes per-file and inject them into the unit under test.
|
||||
*/
|
||||
|
||||
export class TFile {
|
||||
path = "";
|
||||
name = "";
|
||||
extension = "md";
|
||||
stat = { mtime: 0, ctime: 0, size: 0 };
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
path = "";
|
||||
name = "";
|
||||
children: Array<TFile | TFolder> = [];
|
||||
}
|
||||
|
||||
export type TAbstractFile = TFile | TFolder;
|
||||
|
||||
export class Plugin {
|
||||
app: unknown = {};
|
||||
addCommand(): void {}
|
||||
addSettingTab(): void {}
|
||||
registerObsidianProtocolHandler(): void {}
|
||||
registerEvent(): void {}
|
||||
registerInterval(): void {}
|
||||
async loadData(): Promise<unknown> {
|
||||
return null;
|
||||
}
|
||||
async saveData(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class PluginSettingTab {
|
||||
containerEl = { empty: () => undefined, createEl: () => ({}) };
|
||||
constructor(_app: unknown, _plugin: unknown) {}
|
||||
display(): void {}
|
||||
}
|
||||
|
||||
export class Setting {
|
||||
constructor(_containerEl: unknown) {}
|
||||
setName(): this {
|
||||
return this;
|
||||
}
|
||||
setDesc(): this {
|
||||
return this;
|
||||
}
|
||||
addText(): this {
|
||||
return this;
|
||||
}
|
||||
addButton(): this {
|
||||
return this;
|
||||
}
|
||||
addToggle(): this {
|
||||
return this;
|
||||
}
|
||||
addDropdown(): this {
|
||||
return this;
|
||||
}
|
||||
descEl = { createSpan: () => ({ setText: () => undefined }) };
|
||||
}
|
||||
|
||||
export class SuggestModal<_T> {
|
||||
constructor(_app: unknown) {}
|
||||
setPlaceholder(): void {}
|
||||
open(): void {}
|
||||
}
|
||||
|
||||
export class AbstractInputSuggest<_T> {
|
||||
constructor(_app: unknown, _inputEl: unknown) {}
|
||||
onSelect(): this {
|
||||
return this;
|
||||
}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
constructor(_message?: string) {}
|
||||
}
|
||||
|
||||
export const App = class {} as unknown;
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
export function requestUrl(): never {
|
||||
throw new Error("requestUrl is not available in unit tests; inject your own client.");
|
||||
}
|
||||
|
||||
export type RequestUrlParam = unknown;
|
||||
export type RequestUrlResponse = unknown;
|
||||
|
||||
export const Vault = class {} as unknown;
|
||||
98
tests/manifestCache.test.ts
Normal file
98
tests/manifestCache.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Manifest cache load / save / clear round-trips.
|
||||
*
|
||||
* The cache is the seam between the outbound scanner's "have I seen
|
||||
* this file before?" cheap path and the on-disk ``data.json``. A
|
||||
* regression here either over-uploads (cache thinks it has no entries
|
||||
* after a reload) or under-uploads (cache claims it knows about a
|
||||
* file it hasn't yet hashed).
|
||||
*/
|
||||
|
||||
import {
|
||||
ManifestCache,
|
||||
normalizeManifestCacheData,
|
||||
} from "../src/manifestCache";
|
||||
import { EMPTY_MANIFEST_CACHE, ManifestCacheData } from "../src/types";
|
||||
|
||||
function makeCache(): { cache: ManifestCache; saved: ManifestCacheData[] } {
|
||||
const saved: ManifestCacheData[] = [];
|
||||
const cache = new ManifestCache({ ...EMPTY_MANIFEST_CACHE }, async (data) => {
|
||||
saved.push(data);
|
||||
});
|
||||
return { cache, saved };
|
||||
}
|
||||
|
||||
describe("ManifestCache", () => {
|
||||
it("round-trips set -> snapshot -> normalize via persistence", async () => {
|
||||
const { cache, saved } = makeCache();
|
||||
cache.set("Notes/Daily.md", { contentHash: "aaaa", mtime: 42 });
|
||||
cache.setExportsWatermark("2026-01-01T00:00:00Z");
|
||||
await cache.flush();
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
const round = normalizeManifestCacheData(saved[0]);
|
||||
expect(round.paths["Notes/Daily.md"]).toEqual({ contentHash: "aaaa", mtime: 42 });
|
||||
expect(round.exportsUpdatedAfter).toEqual("2026-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("clear wipes paths AND the watermark", async () => {
|
||||
const { cache, saved } = makeCache();
|
||||
cache.set("a.md", { contentHash: "abc", mtime: 1 });
|
||||
cache.setExportsWatermark("2026-01-02T00:00:00Z");
|
||||
await cache.flush();
|
||||
|
||||
cache.clear();
|
||||
await cache.flush();
|
||||
|
||||
const restored = normalizeManifestCacheData(saved[saved.length - 1]);
|
||||
expect(restored.paths).toEqual({});
|
||||
expect(restored.exportsUpdatedAfter).toEqual("");
|
||||
});
|
||||
|
||||
it("set is a no-op when the existing entry matches exactly", async () => {
|
||||
const { cache, saved } = makeCache();
|
||||
cache.set("a.md", { contentHash: "abc", mtime: 1 });
|
||||
await cache.flush();
|
||||
const flushesAfterFirst = saved.length;
|
||||
|
||||
cache.set("a.md", { contentHash: "abc", mtime: 1 });
|
||||
await cache.flush();
|
||||
expect(saved.length).toEqual(flushesAfterFirst);
|
||||
});
|
||||
|
||||
it("retainOnly drops paths absent from the live set", async () => {
|
||||
const { cache, saved } = makeCache();
|
||||
cache.set("a.md", { contentHash: "1", mtime: 1 });
|
||||
cache.set("b.md", { contentHash: "2", mtime: 2 });
|
||||
await cache.flush();
|
||||
|
||||
cache.retainOnly(["a.md"]);
|
||||
await cache.flush();
|
||||
|
||||
const restored = normalizeManifestCacheData(saved[saved.length - 1]);
|
||||
expect(Object.keys(restored.paths)).toEqual(["a.md"]);
|
||||
});
|
||||
|
||||
it("normalizes the legacy flat v1 cache shape", () => {
|
||||
const legacy = {
|
||||
"old.md": { contentHash: "deadbeef", mtime: 99 },
|
||||
};
|
||||
const normalized = normalizeManifestCacheData(legacy);
|
||||
expect(normalized.paths["old.md"]).toEqual({ contentHash: "deadbeef", mtime: 99 });
|
||||
expect(normalized.exportsUpdatedAfter).toEqual("");
|
||||
});
|
||||
|
||||
it("setExportsWatermark refuses to rewind to an earlier value", () => {
|
||||
const { cache } = makeCache();
|
||||
cache.setExportsWatermark("2026-02-01T00:00:00Z");
|
||||
cache.setExportsWatermark("2026-01-15T00:00:00Z");
|
||||
expect(cache.getExportsWatermark()).toEqual("2026-02-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("setExportsWatermark to empty string is treated as a reset", () => {
|
||||
const { cache } = makeCache();
|
||||
cache.setExportsWatermark("2026-02-01T00:00:00Z");
|
||||
cache.setExportsWatermark("");
|
||||
expect(cache.getExportsWatermark()).toEqual("");
|
||||
});
|
||||
});
|
||||
55
tests/oauth.test.ts
Normal file
55
tests/oauth.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* PKCE S256 round-trip: the challenge MUST equal
|
||||
* ``base64url(SHA-256(verifier))`` and the verifier MUST satisfy the
|
||||
* RFC-7636 character + length bounds.
|
||||
*
|
||||
* These guarantees are the smallest correctness contract the backend
|
||||
* checks at ``/api/oauth/token/``; a regression in the helper would
|
||||
* silently break every Connect attempt.
|
||||
*/
|
||||
|
||||
import { createHash, webcrypto } from "node:crypto";
|
||||
|
||||
if (typeof globalThis.crypto === "undefined") {
|
||||
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
||||
}
|
||||
|
||||
import {
|
||||
base64UrlEncode,
|
||||
generatePkceVerifier,
|
||||
pkceChallenge,
|
||||
} from "../src/oauth";
|
||||
|
||||
const PKCE_CHARSET = /^[A-Za-z0-9\-_]+$/u;
|
||||
|
||||
function expectedChallenge(verifier: string): string {
|
||||
const digest = createHash("sha256").update(verifier).digest();
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
describe("oauth pkce helpers", () => {
|
||||
it("verifier is RFC-7636 character-safe and at least 43 chars", () => {
|
||||
const verifier = generatePkceVerifier();
|
||||
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
||||
expect(verifier).toMatch(PKCE_CHARSET);
|
||||
});
|
||||
|
||||
it("each call mints a fresh verifier", () => {
|
||||
const a = generatePkceVerifier();
|
||||
const b = generatePkceVerifier();
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it("challenge equals base64url(SHA-256(verifier))", async () => {
|
||||
const verifier = generatePkceVerifier();
|
||||
const actual = await pkceChallenge(verifier);
|
||||
expect(actual).toEqual(expectedChallenge(verifier));
|
||||
});
|
||||
|
||||
it("known-vector RFC-7636 Appendix B round-trips", async () => {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
const actual = await pkceChallenge(verifier);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
68
tests/slugify.test.ts
Normal file
68
tests/slugify.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Slug + collision-suffix policy for inbound exports.
|
||||
*
|
||||
* The slug rules are conservative (lowercase ASCII alphanumerics +
|
||||
* dashes only) so the resulting filename round-trips through
|
||||
* Obsidian's path normaliser regardless of host filesystem. The
|
||||
* collision suffix MUST be deterministic so a second pass against an
|
||||
* unchanged server state writes back to the same on-disk path.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildCollisionPath,
|
||||
collisionSuffixFor,
|
||||
slugifyTitle,
|
||||
trailerLineFor,
|
||||
} from "../src/syncInbound";
|
||||
|
||||
describe("slugifyTitle", () => {
|
||||
it("lowercases and dashifies a typical title", () => {
|
||||
expect(slugifyTitle("Hello World")).toEqual("hello-world");
|
||||
});
|
||||
|
||||
it("collapses sequences of non-alphanumeric chars into a single dash", () => {
|
||||
expect(slugifyTitle("Foo --- Bar / Baz!")).toEqual("foo-bar-baz");
|
||||
});
|
||||
|
||||
it("strips diacritics by collapsing them into dashes (lossy)", () => {
|
||||
const slug = slugifyTitle("\u00e9diteur \u00e0 La Mode");
|
||||
expect(slug).toEqual("diteur-la-mode");
|
||||
});
|
||||
|
||||
it("returns an empty string for all-punctuation titles", () => {
|
||||
expect(slugifyTitle("!!!---???")).toEqual("");
|
||||
});
|
||||
|
||||
it("truncates very long titles to 80 chars", () => {
|
||||
const slug = slugifyTitle("a".repeat(120));
|
||||
expect(slug.length).toEqual(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collision suffix", () => {
|
||||
it("uses the first 6 hex chars of the uuid stripped of dashes", () => {
|
||||
expect(collisionSuffixFor("11111111-2222-3333-4444-555555555555")).toEqual("111111");
|
||||
});
|
||||
|
||||
it("handles uuids that have already been stripped of dashes", () => {
|
||||
expect(collisionSuffixFor("abcdef0123456789")).toEqual("abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCollisionPath", () => {
|
||||
it("inserts the suffix before the .md extension", () => {
|
||||
const path = buildCollisionPath("Folder/hello-world.md", "11111111-2222-3333-4444-555555555555");
|
||||
expect(path).toEqual("Folder/hello-world-111111.md");
|
||||
});
|
||||
|
||||
it("handles paths without a folder prefix", () => {
|
||||
const path = buildCollisionPath("alone.md", "deadbeef-aaaa-bbbb-cccc-dddddddddddd");
|
||||
expect(path).toEqual("alone-deadbe.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trailer line", () => {
|
||||
it("returns a stable comment that identifies the export uuid", () => {
|
||||
expect(trailerLineFor("uuid-123")).toEqual("<!-- unabyss-export-id: uuid-123 -->");
|
||||
});
|
||||
});
|
||||
205
tests/syncInbound.test.ts
Normal file
205
tests/syncInbound.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Watermark advancement on partial inbound failure.
|
||||
*
|
||||
* The contract documented in ``syncInbound.ts``: the
|
||||
* ``exportsUpdatedAfter`` watermark only advances past a row that was
|
||||
* fully applied. If any row inside a page errors, the watermark
|
||||
* remains at whatever the last fully-applied page boundary was so the
|
||||
* next sync replays the failed rows.
|
||||
*/
|
||||
|
||||
import { ManifestCache } from "../src/manifestCache";
|
||||
import { runInboundSync } from "../src/syncInbound";
|
||||
import {
|
||||
EMPTY_MANIFEST_CACHE,
|
||||
ExportRow,
|
||||
PaginatedResponse,
|
||||
} from "../src/types";
|
||||
import { TFile, TFolder } from "./__mocks__/obsidian";
|
||||
|
||||
type ApiStub = {
|
||||
getChangedExports: (
|
||||
updatedAfter: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) => Promise<PaginatedResponse<ExportRow>>;
|
||||
};
|
||||
|
||||
function makeRow(id: string, title: string, updatedAt: string): ExportRow {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
topic_text: "",
|
||||
preset_slug: "",
|
||||
status: "completed",
|
||||
is_deleted: false,
|
||||
markdown: `# ${title}\n\nbody for ${id}`,
|
||||
created_at: updatedAt,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeVault {
|
||||
files = new Map<string, TFile>();
|
||||
folders = new Map<string, TFolder>();
|
||||
createCalls: Array<{ path: string; data: string }> = [];
|
||||
|
||||
constructor(rootFolders: string[]) {
|
||||
for (const folder of rootFolders) {
|
||||
this.addFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
addFolder(path: string): TFolder {
|
||||
const folder = new TFolder();
|
||||
folder.path = path;
|
||||
this.folders.set(path, folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: string): TFile | TFolder | null {
|
||||
return this.files.get(path) ?? this.folders.get(path) ?? null;
|
||||
}
|
||||
|
||||
getRoot(): TFolder {
|
||||
return this.folders.get("/") ?? this.addFolder("/");
|
||||
}
|
||||
|
||||
async createFolder(path: string): Promise<TFolder> {
|
||||
return this.addFolder(path);
|
||||
}
|
||||
|
||||
async cachedRead(file: TFile): Promise<string> {
|
||||
return (file as TFile & { __body?: string }).__body ?? "";
|
||||
}
|
||||
|
||||
async modify(file: TFile, data: string): Promise<void> {
|
||||
(file as TFile & { __body?: string }).__body = data;
|
||||
}
|
||||
|
||||
async create(path: string, data: string): Promise<TFile> {
|
||||
this.createCalls.push({ path, data });
|
||||
if (this.failOnCreate?.test(path)) {
|
||||
throw new Error(`simulated create failure on ${path}`);
|
||||
}
|
||||
const file = new TFile();
|
||||
file.path = path;
|
||||
file.name = path.split("/").pop() ?? path;
|
||||
(file as TFile & { __body?: string }).__body = data;
|
||||
this.files.set(path, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
async trash(_file: TFile, _system: boolean): Promise<void> {}
|
||||
|
||||
failOnCreate: RegExp | null = null;
|
||||
}
|
||||
|
||||
function setupApp(vault: FakeVault) {
|
||||
return {
|
||||
vault,
|
||||
fileManager: { async renameFile() {} },
|
||||
} as unknown as Parameters<typeof runInboundSync>[0]["app"];
|
||||
}
|
||||
|
||||
function setupCache(): { cache: ManifestCache } {
|
||||
const cache = new ManifestCache({ ...EMPTY_MANIFEST_CACHE }, async () => {
|
||||
/* persistence side-effects are uninteresting to these tests */
|
||||
});
|
||||
return { cache };
|
||||
}
|
||||
|
||||
describe("runInboundSync watermark advancement", () => {
|
||||
it("advances the watermark to the highest updated_at of a fully-applied page", async () => {
|
||||
const vault = new FakeVault(["Exports"]);
|
||||
const app = setupApp(vault);
|
||||
const { cache } = setupCache();
|
||||
const rows = [
|
||||
makeRow("11111111-aaaa-bbbb-cccc-dddddddddddd", "Note A", "2026-01-01T00:00:00Z"),
|
||||
makeRow("22222222-aaaa-bbbb-cccc-dddddddddddd", "Note B", "2026-01-02T00:00:00Z"),
|
||||
makeRow("33333333-aaaa-bbbb-cccc-dddddddddddd", "Note C", "2026-01-03T00:00:00Z"),
|
||||
];
|
||||
let calls = 0;
|
||||
const api: ApiStub = {
|
||||
async getChangedExports() {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return { count: rows.length, next: null, previous: null, results: rows };
|
||||
}
|
||||
return { count: 0, next: null, previous: null, results: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const report = await runInboundSync({
|
||||
app,
|
||||
api: api as unknown as Parameters<typeof runInboundSync>[0]["api"],
|
||||
cache,
|
||||
targetFolder: "Exports",
|
||||
deleteBehaviour: "leave",
|
||||
});
|
||||
|
||||
expect(report.errors).toHaveLength(0);
|
||||
expect(report.written).toEqual(3);
|
||||
expect(cache.getExportsWatermark()).toEqual("2026-01-03T00:00:00Z");
|
||||
expect(vault.createCalls.map((row) => row.path)).toEqual([
|
||||
"Exports/note-a.md",
|
||||
"Exports/note-b.md",
|
||||
"Exports/note-c.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT advance the watermark when a row inside the page errors", async () => {
|
||||
const vault = new FakeVault(["Exports"]);
|
||||
vault.failOnCreate = /note-b\.md$/u;
|
||||
const app = setupApp(vault);
|
||||
const { cache } = setupCache();
|
||||
|
||||
const rows = [
|
||||
makeRow("11111111-aaaa-bbbb-cccc-dddddddddddd", "Note A", "2026-01-01T00:00:00Z"),
|
||||
makeRow("22222222-aaaa-bbbb-cccc-dddddddddddd", "Note B", "2026-01-02T00:00:00Z"),
|
||||
makeRow("33333333-aaaa-bbbb-cccc-dddddddddddd", "Note C", "2026-01-03T00:00:00Z"),
|
||||
];
|
||||
const api: ApiStub = {
|
||||
async getChangedExports() {
|
||||
return { count: rows.length, next: null, previous: null, results: rows };
|
||||
},
|
||||
};
|
||||
|
||||
const report = await runInboundSync({
|
||||
app,
|
||||
api: api as unknown as Parameters<typeof runInboundSync>[0]["api"],
|
||||
cache,
|
||||
targetFolder: "Exports",
|
||||
deleteBehaviour: "leave",
|
||||
});
|
||||
|
||||
expect(report.errors).toHaveLength(1);
|
||||
expect(report.errors[0].title).toEqual("Note B");
|
||||
expect(report.written).toEqual(2);
|
||||
expect(cache.getExportsWatermark()).toEqual("");
|
||||
});
|
||||
|
||||
it("appends the stable trailer line to every written export", async () => {
|
||||
const vault = new FakeVault(["Exports"]);
|
||||
const app = setupApp(vault);
|
||||
const { cache } = setupCache();
|
||||
const row = makeRow("uuid-abc", "Daily Log", "2026-01-01T00:00:00Z");
|
||||
const api: ApiStub = {
|
||||
async getChangedExports() {
|
||||
return { count: 1, next: null, previous: null, results: [row] };
|
||||
},
|
||||
};
|
||||
|
||||
await runInboundSync({
|
||||
app,
|
||||
api: api as unknown as Parameters<typeof runInboundSync>[0]["api"],
|
||||
cache,
|
||||
targetFolder: "Exports",
|
||||
deleteBehaviour: "leave",
|
||||
});
|
||||
|
||||
expect(vault.createCalls).toHaveLength(1);
|
||||
expect(vault.createCalls[0].data).toContain("<!-- unabyss-export-id: uuid-abc -->");
|
||||
expect(vault.createCalls[0].data.startsWith("# Daily Log")).toBe(true);
|
||||
});
|
||||
});
|
||||
13
tests/tsconfig.json
Normal file
13
tests/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"types": ["node", "jest"],
|
||||
"isolatedModules": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"../src/**/*.ts",
|
||||
"./**/*.ts"
|
||||
]
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2018",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7",
|
||||
"ES2018",
|
||||
"DOM.Iterable"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
4
versions.json
Normal file
4
versions.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.5.0",
|
||||
"1.0.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue