├── src ├── sync.ts ├── ui │ ├── scrybbleContext.ts │ ├── Components │ │ ├── RmDir.ts │ │ ├── ErrorComponent.ts │ │ ├── RmFileTree.ts │ │ ├── SearchFilter.ts │ │ ├── FileSyncFeedbackModal.ts │ │ ├── SyncNotice.ts │ │ └── RmFile.ts │ ├── loadComponents.ts │ └── Pages │ │ ├── ScrybbleFileTree.ts │ │ ├── SupportPage.ts │ │ ├── ScrybbleUI.ts │ │ ├── OnboardingPage.ts │ │ └── AccountPage.ts ├── FileNavigator.ts ├── errorHandling │ ├── logging.ts │ └── Errors.ts ├── SettingsImpl.ts ├── support.ts ├── ScrybbleView.ts ├── settings.ts ├── SyncJob.ts ├── SyncQueue.ts └── Authentication.ts ├── .npmrc ├── .eslintignore ├── img ├── highlights.png ├── rM_filetree.png └── handwritten notes.png ├── __mocks__ └── obsidian.ts ├── .editorconfig ├── manifest.json ├── cucumber.json ├── features ├── support │ ├── MockObsidian.ts │ ├── MockFileNavigator.ts │ ├── ObsidianWorld.ts │ └── MockScrybbleApi.ts ├── step_definitions │ ├── before_test.ts │ ├── Obsidian_steps.ts │ ├── server_steps.ts │ ├── auth_steps.ts │ └── steps.ts ├── navigation.feature ├── Authentication.feature └── FileLinkVisibility.feature ├── .gitignore ├── flake.nix ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── devreadme.md ├── .github └── workflows │ └── build-and-release.yml ├── versions.json ├── package.json ├── flake.lock ├── esbuild.config.ts ├── README.md ├── Plugin_readme.md ├── @types └── scrybble.ts └── main.ts /src/sync.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /img/highlights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scrybbling-together/scrybble/HEAD/img/highlights.png -------------------------------------------------------------------------------- /img/rM_filetree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scrybbling-together/scrybble/HEAD/img/rM_filetree.png -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export const getIcon = (name: string): string => { 2 | return `[${name}]`; 3 | }; 4 | -------------------------------------------------------------------------------- /img/handwritten notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scrybbling-together/scrybble/HEAD/img/handwritten notes.png -------------------------------------------------------------------------------- /src/ui/scrybbleContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from "@lit/context"; 2 | import {ScrybbleCommon} from "../../@types/scrybble"; 3 | 4 | export const scrybbleContext = createContext('scrybble-common'); 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "scrybble.ink", 3 | "name": "Scrybble", 4 | "version": "3.17.1", 5 | "minAppVersion": "1.3.7", 6 | "description": "Synchronize highlights from your ReMarkable to Obsidian!", 7 | "author": "Streamsoft", 8 | "authorUrl": "https://scrybble.ink", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /cucumber.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": { 3 | "require": [ 4 | "features/step_definitions/load_jsdom.ts", 5 | "features/step_definitions/**/*.ts" 6 | ], 7 | "requireModule": [ 8 | "ts-node/register" 9 | ], 10 | "format": [ 11 | "progress-bar", 12 | "json:cucumber-report.json" 13 | ], 14 | "formatOptions": { 15 | "snippetInterface": "async-await" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /features/support/MockObsidian.ts: -------------------------------------------------------------------------------- 1 | const mockObsidian = { 2 | getIcon: (name: string) => `[${name}]` 3 | }; 4 | 5 | // Mock the module 6 | const Module = require('module'); 7 | const originalRequire = Module.prototype.require; 8 | Module.prototype.require = function (...args: any) { 9 | if (args[0] === 'obsidian') { 10 | return mockObsidian; 11 | } 12 | return originalRequire.apply(this, args); 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /features/step_definitions/before_test.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | import {After, setWorldConstructor} from "@cucumber/cucumber"; 3 | import {ObsidianWorld} from "../support/ObsidianWorld"; 4 | import sinonChai from "sinon-chai"; 5 | import chai from "chai"; 6 | 7 | // set-up happy-dom 8 | GlobalRegistrator.register(); 9 | 10 | // configure the world for cucumber shared state 11 | setWorldConstructor(ObsidianWorld); 12 | After(function (this: ObsidianWorld) { 13 | this.cleanup(); 14 | }); 15 | 16 | chai.use(sinonChai); 17 | -------------------------------------------------------------------------------- /features/step_definitions/Obsidian_steps.ts: -------------------------------------------------------------------------------- 1 | import {ObsidianWorld} from "../support/ObsidianWorld"; 2 | import path from "path"; 3 | 4 | const {Given} = require("@cucumber/cucumber"); 5 | Given("The Scrybble folder is configured to be {string}", function (this: ObsidianWorld, config_value: string) { 6 | this.scrybble.settings.sync_folder = config_value; 7 | }); 8 | Given("There is a file called {string} in the {string} folder", function (this: ObsidianWorld, filename: string, folder: string) { 9 | const filename1 = path.join(folder, filename); 10 | this.addObsidianFile(filename1, "a file"); 11 | }); 12 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Development environment with npm"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = nixpkgs.legacyPackages.${system}; 13 | in 14 | { 15 | devShells.default = pkgs.mkShell { 16 | buildInputs = with pkgs; [ 17 | bun 18 | nodejs_22 19 | ]; 20 | 21 | shellHook = '' 22 | ''; 23 | }; 24 | } 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "es6"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "module": "commonjs", 16 | "declaration": true, 17 | "outDir": "./lib", 18 | "removeComments": true, 19 | "experimentalDecorators": true, 20 | "strictNullChecks": true 21 | }, 22 | "include": [ 23 | "features/**/*", 24 | "src/**/*", 25 | "main.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /features/navigation.feature: -------------------------------------------------------------------------------- 1 | Feature: UI Navigation 2 | Scenario: You can navigate to the support page when the server is unreachable 3 | Given The plugin initializes 4 | When The user opens the Scrybble interface 5 | 6 | But The server is unreachable 7 | When The user clicks on the "Support" button 8 | Then The interface should say "Scrybble Support" 9 | 10 | Scenario: Logging in 11 | Given The plugin initializes 12 | When The user opens the Scrybble interface 13 | Then The interface should say "Connect to Scrybble" 14 | 15 | Scenario: Opening Scrybble 16 | Given The user has logged in locally 17 | * The user's access token is valid on the server 18 | 19 | When The plugin initializes 20 | * The user opens the Scrybble interface 21 | Then The interface should say "reMarkable file tree" 22 | And The interface should say "Current directory is /" 23 | 24 | -------------------------------------------------------------------------------- /devreadme.md: -------------------------------------------------------------------------------- 1 | # Scrybble 2 | 3 | ## Development on a new machine 4 | 5 | ```shell 6 | npm i 7 | # go to your obsidian folder 8 | # cd ~/Documents/Lauranomicon/.obsidian 9 | mkdir -p plugins 10 | cd plugins 11 | # symlink this project to the plugins folder 12 | ln -s ~/PhpstormProjects/scrybble/ scrybble.beta 13 | ``` 14 | 15 | ## Development 16 | 17 | `npm run dev` 18 | 19 | You need to restart Obsidian whenever you make a change. 20 | Use the "reload app without saving changes" command in obsidian to restart quickly. 21 | 22 | ## Release 23 | 24 | Working on doing this on push using github actions! Boop 25 | 26 | 1. [ ] Run `npm version {patch|minor|major}` after updating (if necessary) `minAppVersion` in `manifest.json` 27 | 2. [ ] Push to Github 28 | 3. [ ] run `npm run build` 29 | 4. [ ] On Github, create new release with tag `{YOUR VERSION}` and title `v{YOUR VERSION}` 30 | - Attach `main.js`, `styles.css`, and `manifest.json` 31 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Bun 17 | uses: oven-sh/setup-bun@v2 18 | 19 | - name: Build plugin 20 | run: | 21 | bun install 22 | bun run build 23 | 24 | - name: Create release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: | 28 | tag="${GITHUB_REF#refs/tags/}" 29 | 30 | gh release create "$tag" \ 31 | --title="$tag" \ 32 | --draft \ 33 | main.js manifest.json styles.css 34 | -------------------------------------------------------------------------------- /src/ui/Components/RmDir.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, TemplateResult } from "lit-element"; 2 | import { property } from "lit-element/decorators.js"; 3 | import { getIcon } from "obsidian"; 4 | import {RMTreeItem} from "../../../@types/scrybble"; 5 | 6 | export class RmDir extends LitElement { 7 | @property({type: Object}) 8 | directory!: RMTreeItem 9 | 10 | 11 | render(): TemplateResult { 12 | return html`
13 |
14 | ${getIcon('folder')} ${this.directory.name} 15 |
16 |
17 | `; 18 | } 19 | 20 | protected createRenderRoot(): HTMLElement | DocumentFragment { 21 | return this; 22 | } 23 | 24 | private _handleClick(): void { 25 | this.dispatchEvent(new CustomEvent('rm-click', { 26 | detail: { name: this.directory.name, path: this.directory.path, type: 'd' }, 27 | bubbles: true, 28 | composed: true 29 | })); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/Components/ErrorComponent.ts: -------------------------------------------------------------------------------- 1 | import {ErrorMessage} from "../../errorHandling/Errors"; 2 | import {LitElement} from "lit-element"; 3 | import {property} from "lit-element/decorators.js"; 4 | import {html, nothing, TemplateResult} from "lit-html"; 5 | 6 | export class ErrorComponent extends LitElement { 7 | @property({type: Object}) 8 | private error!: ErrorMessage 9 | 10 | @property({type: Array}) 11 | public actions: TemplateResult[] = []; 12 | 13 | public render() { 14 | if (this.error) { 15 | const errorActions = this.actions.length ? html`
${this.actions}
` : nothing 16 | return html` 17 |
18 |
19 |

${this.error.title}

20 |

${this.error.message}

21 |

${this.error.helpAction}

22 |
23 | ${errorActions} 24 |
25 | ` 26 | } 27 | } 28 | 29 | protected createRenderRoot(): HTMLElement | DocumentFragment { 30 | return this 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/Components/RmFileTree.ts: -------------------------------------------------------------------------------- 1 | import {LitElement} from "lit-element"; 2 | import {html} from "lit-html"; 3 | import {property} from "lit-element/decorators.js"; 4 | import {RMFileTree, ScrybbleCommon} from "../../../@types/scrybble"; 5 | import {consume} from "@lit/context"; 6 | import {scrybbleContext} from "../scrybbleContext"; 7 | 8 | export class RmFileTree extends LitElement { 9 | @consume({context: scrybbleContext}) 10 | @property({type: Object, attribute: false}) 11 | scrybble!: ScrybbleCommon; 12 | 13 | @property({type: Object}) 14 | tree!: RMFileTree; 15 | 16 | @property({type: String}) 17 | cwd!: string; 18 | 19 | render() { 20 | return html` 21 |
22 |
23 | ${this.tree.items.map((fileOrDirectory) => { 24 | if (fileOrDirectory.type === "d") { 25 | return html``; 26 | } else if (fileOrDirectory.type === "f") { 27 | return html` `; 28 | } 29 | })} 30 |
31 |
` 32 | } 33 | 34 | protected createRenderRoot(): HTMLElement | DocumentFragment { 35 | return this 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.7", 3 | "1.0.1": "0.12.0", 4 | "2.0.0": "0.12.0", 5 | "2.1.0": "0.12.0", 6 | "2.2.0": "0.12.0", 7 | "2.3.0": "0.12.0", 8 | "2.4.0": "0.12.0", 9 | "2.5.0": "0.12.0", 10 | "2.6.0": "0.12.0", 11 | "2.6.1": "0.12.0", 12 | "2.7.0": "0.12.0", 13 | "2.8.0": "0.12.0", 14 | "2.9.0": "1.3.7", 15 | "2.10.0": "1.3.7", 16 | "2.10.1": "1.3.7", 17 | "2.10.2": "1.3.7", 18 | "2.10.3": "1.3.7", 19 | "2.10.4": "1.3.7", 20 | "2.10.5": "1.3.7", 21 | "2.10.6": "1.3.7", 22 | "2.10.7": "1.3.7", 23 | "2.10.8": "1.3.7", 24 | "2.10.9": "1.3.7", 25 | "2.10.10": "1.3.7", 26 | "2.10.11": "1.3.7", 27 | "2.10.12": "1.3.7", 28 | "2.10.13": "1.3.7", 29 | "2.11.0": "1.3.7", 30 | "2.11.1": "1.3.7", 31 | "3.0.0": "1.3.7", 32 | "3.1.0": "1.3.7", 33 | "3.2.0": "1.3.7", 34 | "3.3.0": "1.3.7", 35 | "3.3.1": "1.3.7", 36 | "3.3.2": "1.3.7", 37 | "3.4.0": "1.3.7", 38 | "3.5.0": "1.3.7", 39 | "3.6.0": "1.3.7", 40 | "3.7.0": "1.3.7", 41 | "3.8.0": "1.3.7", 42 | "3.8.1": "1.3.7", 43 | "3.8.2": "1.3.7", 44 | "3.9.0": "1.3.7", 45 | "3.9.1": "1.3.7", 46 | "3.9.2": "1.3.7", 47 | "3.10.0": "1.3.7", 48 | "3.10.1": "1.3.7", 49 | "3.11.0": "1.3.7", 50 | "3.12.0": "1.3.7", 51 | "3.13.0": "1.3.7", 52 | "3.14.0": "1.3.7", 53 | "3.15.0": "1.3.7", 54 | "3.16.0": "1.3.7", 55 | "3.17.0": "1.3.7", 56 | "3.17.1": "1.3.7" 57 | } -------------------------------------------------------------------------------- /features/step_definitions/server_steps.ts: -------------------------------------------------------------------------------- 1 | import {Given, Then, When} from "@cucumber/cucumber"; 2 | import {ObsidianWorld} from "../support/ObsidianWorld"; 3 | import {expect} from "chai"; 4 | import {HTMLSpanElement} from "happy-dom"; 5 | 6 | When("The server is unreachable", function (this: ObsidianWorld) { 7 | this.api.serverIsUnreachable(); 8 | }); 9 | When("The server is reachable", function (this: ObsidianWorld) { 10 | this.api.serverIsReachable(); 11 | }); 12 | 13 | When("The server responds to the {string} request with a {int} status code", function (this: ObsidianWorld, text, statusCode) { 14 | this.api.requestWillFailWithStatusCode(text, statusCode); 15 | }); 16 | When("The server responds to the {string} as usual", function (this: ObsidianWorld, text) { 17 | this.api.requestGoesAsNormal(text); 18 | }); 19 | 20 | Given("The reMarkable has a file called {string} in the {string} folder", function (this: ObsidianWorld, name: string, path: string) { 21 | this.api.addFile({ 22 | type: "f", 23 | path, 24 | name 25 | }) 26 | }); 27 | 28 | let id = 0; 29 | Given("The file {string} in the folder {string} has been downloaded {string}", function (this: ObsidianWorld, name, path, when) { 30 | const file = `${path}${name}`; 31 | this.scrybble.settings.sync_state[file] = id; 32 | this.api.add_synced_file(file, when); 33 | id += 1 34 | }); 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrybble", 3 | "version": "3.17.1", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "bun esbuild.config.ts", 8 | "build": "tsc -noEmit -skipLibCheck && bun esbuild.config.ts production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "test": "cucumber-js" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "GPLv3", 15 | "devDependencies": { 16 | "@cucumber/cucumber": "^11.3.0", 17 | "@happy-dom/global-registrator": "^17.6.3", 18 | "@types/node": "^16.18.126", 19 | "@typescript-eslint/eslint-plugin": "^5.62.0", 20 | "@typescript-eslint/parser": "^5.62.0", 21 | "builtin-modules": "^3.3.0", 22 | "esbuild": "^0.25.9", 23 | "happy-dom": "^17.6.3", 24 | "jsdom": "^26.1.0", 25 | "obsidian": "latest", 26 | "ts-node": "^10.9.2", 27 | "tslib": "^2.8.1", 28 | "typescript": "^5.9.2" 29 | }, 30 | "dependencies": { 31 | "@lit/context": "^1.1.6", 32 | "@types/chai": "^5.2.2", 33 | "@types/jsdom": "^21.1.7", 34 | "@types/sinon": "^17.0.4", 35 | "@types/sinon-chai": "^4.0.0", 36 | "chai": "^5.3.3", 37 | "fflate": "^0.8.2", 38 | "lit-element": "^4.2.1", 39 | "lit-html": "^3.3.1", 40 | "path-browserify": "^1.0.1", 41 | "pino": "^9.9.0", 42 | "sinon": "^20.0.0", 43 | "sinon-chai": "^4.0.1", 44 | "stream-browserify": "^3.0.0", 45 | "typescript-fsm": "^1.6.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/FileNavigator.ts: -------------------------------------------------------------------------------- 1 | import {ContextMenuItem, FileNavigator} from "../@types/scrybble"; 2 | import {App, Menu} from "obsidian"; 3 | 4 | export class ObsidianFileNavigator implements FileNavigator { 5 | constructor(private app: App) { 6 | } 7 | 8 | async openInNewTab(filePath: string): Promise { 9 | const file = this.app.vault.getFileByPath(filePath); 10 | if (file) await this.app.workspace.getLeaf(true).openFile(file); 11 | } 12 | 13 | async openInVerticalSplit(filePath: string): Promise { 14 | const file = this.app.vault.getFileByPath(filePath); 15 | if (file) { 16 | await this.app.workspace.getLeaf("split", "vertical").openFile(file); 17 | } 18 | } 19 | 20 | async openInHorizontalSplit(filePath: string): Promise { 21 | const file = this.app.vault.getFileByPath(filePath); 22 | if (file) { 23 | await this.app.workspace.getLeaf("split", "horizontal").openFile(file); 24 | } 25 | } 26 | 27 | getFileByPath(path: string): any | null { 28 | return this.app.vault.getFileByPath(path); 29 | } 30 | 31 | showContextMenu(event: MouseEvent, items: ContextMenuItem[]): void { 32 | const menu = new Menu(); 33 | 34 | items.forEach(item => { 35 | if (item.isSeparator) { 36 | menu.addSeparator(); 37 | } else { 38 | menu.addItem(menuItem => { 39 | menuItem 40 | .setTitle(item.title) 41 | .setIcon(item.icon) 42 | .setDisabled(item.disabled || false); 43 | 44 | if (item.onClick) { 45 | menuItem.onClick(item.onClick); 46 | } 47 | }); 48 | } 49 | }); 50 | 51 | menu.showAtMouseEvent(event); 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /features/support/MockFileNavigator.ts: -------------------------------------------------------------------------------- 1 | import {ContextMenuItem, FileNavigator} from "../../@types/scrybble"; 2 | import path from "path"; 3 | 4 | export class MockFileNavigator implements FileNavigator { 5 | public openedFiles: Array<{ path: string, method: string }> = []; 6 | public lastContextMenu: ContextMenuItem[] | null = null; 7 | private files: Map = new Map(); 8 | 9 | setMockFile(path: string, file: any): void { 10 | if (path.startsWith("/")) { 11 | path = path.replace(/^\//, ""); 12 | } 13 | 14 | this.files.set(path, file); 15 | } 16 | 17 | async openInNewTab(filePath: string): Promise { 18 | this.openedFiles.push({path: filePath, method: 'newTab'}); 19 | } 20 | 21 | async openInVerticalSplit(filePath: string): Promise { 22 | this.openedFiles.push({path: filePath, method: 'verticalSplit'}); 23 | } 24 | 25 | async openInHorizontalSplit(filePath: string): Promise { 26 | this.openedFiles.push({path: filePath, method: 'horizontalSplit'}); 27 | } 28 | 29 | getFileByPath(requestedPath: string): any | null { 30 | const dirname = path.dirname(requestedPath); 31 | // Obsidian always returns null for absolute paths 32 | if (path.isAbsolute(dirname)) { 33 | return null; 34 | } 35 | for (let existingPath of this.files.keys()) { 36 | if (path.relative(requestedPath, existingPath) === "") { 37 | return this.files.get(requestedPath); 38 | } 39 | } 40 | } 41 | 42 | showContextMenu(event: MouseEvent, items: ContextMenuItem[]): void { 43 | this.lastContextMenu = items; 44 | // In tests, you could trigger the onClick handlers manually 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/errorHandling/logging.ts: -------------------------------------------------------------------------------- 1 | import * as p from "pino"; 2 | 3 | const storageKey = 'scrybble-logs'; 4 | const maxEntries = 500; 5 | 6 | export function retrieveScrybbleLogs() { 7 | return JSON.parse(localStorage.getItem(storageKey) ?? "[]"); 8 | } 9 | 10 | function writeToLocalstorage(obj: Record) { 11 | delete obj["hostname"]; 12 | delete obj["pid"]; 13 | delete obj["time"]; 14 | 15 | try { 16 | // Get existing logs 17 | const existingLogs: Record[] = JSON.parse(localStorage.getItem(storageKey) || '[]'); 18 | 19 | // Add new log entry with timestamp 20 | const logEntry = { 21 | ...obj, 22 | timestamp: new Date().toISOString(), 23 | }; 24 | 25 | existingLogs.push(logEntry); 26 | 27 | // Implement rotation: keep only the most recent maxEntries 28 | if (existingLogs.length > maxEntries) { 29 | existingLogs.splice(0, existingLogs.length - maxEntries); 30 | } 31 | 32 | // Save back to localStorage 33 | localStorage.setItem(storageKey, JSON.stringify(existingLogs)); 34 | } catch (error) { 35 | // If localStorage fails, try to at least log to console 36 | console.error('Failed to write to localStorage transport:', error); 37 | console.log('Original log:', obj); 38 | } 39 | } 40 | export const pino = p.default({ 41 | browser: { 42 | write: writeToLocalstorage, 43 | serialize: true, 44 | }, 45 | }) 46 | // window.addEventListener("error", function (e) { 47 | // pino.error("Uncaught error :", e) 48 | // }) 49 | // window.addEventListener('unhandledrejection', function (e) { 50 | // pino.error("Uncaught error :", e) 51 | // }) 52 | // window.onerror = pino.error.bind(pino) 53 | -------------------------------------------------------------------------------- /src/SettingsImpl.ts: -------------------------------------------------------------------------------- 1 | import {Host, ScrybbleSettings} from "../@types/scrybble"; 2 | 3 | export class SettingsImpl implements ScrybbleSettings { 4 | public readonly sync_folder: string = "scrybble"; 5 | public readonly self_hosted: boolean = false; 6 | public readonly custom_host: Host = { 7 | endpoint: "", 8 | client_secret: "", 9 | client_id: "" 10 | } 11 | public readonly sync_state: Record = {} 12 | 13 | public readonly refresh_token?: string; 14 | public readonly access_token?: string; 15 | public readonly save: () => Promise; 16 | 17 | public constructor(s: Omit | null, saveSettings: () => Promise) { 18 | this.sync_folder = s?.sync_folder ?? "scrybble/"; 19 | this.sync_state = s?.sync_state ?? {}; 20 | this.self_hosted = s?.self_hosted ?? false; 21 | if (s?.custom_host) { 22 | this.custom_host = s.custom_host; 23 | } 24 | if (s?.refresh_token) { 25 | this.refresh_token = s.refresh_token; 26 | } 27 | if (s?.access_token) { 28 | this.access_token = s.access_token; 29 | } 30 | 31 | this.save = saveSettings; 32 | } 33 | 34 | get endpoint(): string { 35 | if (this.self_hosted) { 36 | return this.custom_host.endpoint; 37 | } else { 38 | return "https://scrybble.ink" 39 | } 40 | } 41 | 42 | get client_id(): string { 43 | if (this.self_hosted) { 44 | return this.custom_host.client_id 45 | } 46 | return "01974ab1-1afe-700a-a69b-22fe0e3334c1"; 47 | } 48 | 49 | get client_secret(): string { 50 | if (this.self_hosted){ 51 | return this.custom_host.client_secret; 52 | } 53 | return "7OVMeOZbXJaMH2I1mKr67H6VPrW2S7PlwAneuSFQ"; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1738961098, 24 | "narHash": "sha256-yWNBf6VDW38tl179FEuJ0qukthVfB02kv+mRsfUsWC0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "a3eaf5e8eca7cab680b964138fb79073704aca75", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /esbuild.config.ts: -------------------------------------------------------------------------------- 1 | import esbuild, {BuildOptions} from "esbuild"; 2 | import process from "process"; 3 | 4 | const banner = 5 | `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = (process.argv[2] === 'production'); 12 | 13 | const options: BuildOptions = { 14 | banner: { 15 | js: banner, 16 | }, 17 | platform: "browser", 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | 21 | external: [ 22 | 'obsidian', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view'], 44 | alias: { 45 | 'path': 'path-browserify', 46 | 'stream': 'stream-browserify' 47 | }, 48 | mainFields: ['browser', 'module', 'main'], 49 | format: 'cjs', 50 | target: "ES2021", 51 | logLevel: "info", 52 | sourcemap: prod ? false : 'inline', 53 | treeShaking: true, 54 | outfile: 'main.js', 55 | }; 56 | let ctx = await esbuild.context(options).catch(() => process.exit(1)); 57 | 58 | if (prod) { 59 | console.log("Release mode: Building for release") 60 | await esbuild.build(options) 61 | await ctx.dispose() 62 | } else { 63 | console.log("Development mode: Watching for changes") 64 | await ctx.watch({ 65 | 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /features/step_definitions/auth_steps.ts: -------------------------------------------------------------------------------- 1 | import {Given, Then, When} from "@cucumber/cucumber"; 2 | import {ObsidianWorld} from "../support/ObsidianWorld"; 3 | import {expect} from "chai"; 4 | 5 | Given("The user has logged in locally", function (this: ObsidianWorld) { 6 | this.hasLoggedInLocally(); 7 | }); 8 | 9 | function loggedIn(this: ObsidianWorld) { 10 | this.api.isLoggedIn(); 11 | } 12 | 13 | Given("The user's access token is valid on the server", loggedIn); 14 | Given("The server creates access tokens for the user", loggedIn); 15 | 16 | Then("The OAuth flow should be initiated", function (this: ObsidianWorld) { 17 | expect(this.spies.initiateDeviceFlow).to.have.been.calledOnce; 18 | }); 19 | 20 | Then("The browser should open with the authorization URL", function (this: ObsidianWorld) { 21 | expect(this.spies.windowOpen).to.have.been.calledOnce; 22 | }); 23 | 24 | Then("The plugin receives a callback from the browser", function (this: ObsidianWorld) { 25 | this.scrybble.settings.access_token = "test_access_token"; 26 | this.scrybble.settings.refresh_token = "test_refresh_token"; 27 | this.isLoggedIn(); 28 | }); 29 | 30 | Then("The user should be logged in", async function (this: ObsidianWorld) { 31 | await new Promise(resolve => setTimeout(resolve, 2500)); 32 | expect(this.scrybble.settings.access_token).to.equal("test_access_token"); 33 | }); 34 | Given("The user's access token has expired on the server", function (this: ObsidianWorld) { 35 | this.api.accessTokenIsExpired(); 36 | }); 37 | Then("The client requests a new access token using the refresh token", async function (this: ObsidianWorld) { 38 | expect(this.spies.refreshAccessToken).to.have.been.calledOnce; 39 | }); 40 | Then("The plugin should be polling the website for successful authentication", async function (this: ObsidianWorld) { 41 | await new Promise(resolve => setTimeout(resolve, 2500)); 42 | expect(this.spies.pollForDeviceToken).to.have.been.calledOnce; 43 | }); 44 | When("The user authorizes the login on the server", async function (this: ObsidianWorld) { 45 | this.api.authorizeDeviceToken(); 46 | }); 47 | -------------------------------------------------------------------------------- /src/support.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Replaces a random percentage of characters in a string with asterisks 3 | * @param {string} str - The string to obfuscate 4 | * @param {number} percentage - Percentage of characters to replace (0-100) 5 | * @returns {string} The obfuscated string 6 | */ 7 | export function obfuscateString(str: string, percentage: number) { 8 | // Validate inputs 9 | if (typeof str !== 'string') return ''; 10 | if (typeof percentage !== 'number' || percentage < 0 || percentage > 100) { 11 | throw new Error('Percentage must be a number between 0 and 100'); 12 | } 13 | 14 | // Handle edge cases 15 | if (str.length === 0 || percentage === 0) return str; 16 | if (percentage === 100) return '_'.repeat(str.length); 17 | 18 | // Convert string to array for manipulation 19 | const chars = str.split(''); 20 | 21 | // Calculate how many characters to replace 22 | const charsToReplace = Math.round((percentage / 100) * str.length); 23 | 24 | // Create array of indices and shuffle it 25 | const indices = Array.from({ length: str.length }, (_, i) => i); 26 | shuffleArray(indices); 27 | 28 | // Replace characters at the first charsToReplace indices 29 | for (let i = 0; i < charsToReplace; i++) { 30 | chars[indices[i]] = '_'; 31 | } 32 | 33 | // Join back to string and return 34 | return chars.join(''); 35 | } 36 | 37 | // Fisher-Yates shuffle algorithm for randomizing indices 38 | function shuffleArray(array: any[]) { 39 | for (let i = array.length - 1; i > 0; i--) { 40 | const j = Math.floor(Math.random() * (i + 1)); 41 | [array[i], array[j]] = [array[j], array[i]]; 42 | } 43 | } 44 | 45 | /** 46 | * Dir paths always end with / 47 | * @param filePath 48 | */ 49 | export function dirPath(filePath: string): string { 50 | const atoms = filePath.split("/") 51 | const dirs = atoms.slice(0, atoms.length - 1) 52 | return dirs.filter((a) => Boolean(a)).join("/") + "/" 53 | } 54 | 55 | export function basename(filePath: string): string { 56 | const atoms = filePath.split("/") 57 | return atoms[atoms.length - 1] 58 | } 59 | 60 | export function sanitizeFilename(filePath: string, ignoreSlashes=false): string { 61 | const tokens = ['*', '"', "\\", "<", ">", ":", "|", "?"]; 62 | if (!ignoreSlashes) { 63 | tokens.push("/"); 64 | } 65 | 66 | tokens.forEach((token) => { 67 | const regex = new RegExp('\\' + token, 'g'); 68 | filePath = filePath.replace(regex, "_"); 69 | }); 70 | 71 | return filePath; 72 | } 73 | -------------------------------------------------------------------------------- /features/Authentication.feature: -------------------------------------------------------------------------------- 1 | Feature: OAuth Authentication 2 | 3 | Scenario: Logging in using device flow 4 | When The plugin initializes 5 | * The user opens the Scrybble interface 6 | Then The interface should say "Connect to Scrybble" 7 | 8 | When The user clicks on the "Files" button 9 | 10 | And The user clicks on the "Sign in with Scrybble" button 11 | Then The OAuth flow should be initiated 12 | * The interface should say "Complete Authorization" 13 | * The plugin should be polling the website for successful authentication 14 | 15 | When The user clicks on the "Open Authorization Page" button 16 | Then The browser should open with the authorization URL 17 | 18 | When The user authorizes the login on the server 19 | And The server creates access tokens for the user 20 | * The user should be logged in 21 | 22 | Then The interface should say "Welcome back Test user" 23 | * The interface should say "You're connected to Scrybble" 24 | 25 | Scenario: Accessing file tree and sync history is only possible when logged in 26 | When The plugin initializes 27 | * The user opens the Scrybble interface 28 | Then The interface should say "Connect to Scrybble" 29 | 30 | # Navigation to the files page is disabled 31 | When The user clicks on the "Files" button 32 | Then The interface should say "Connect to Scrybble" 33 | 34 | When The user clicks on the "Sign in with Scrybble" button 35 | * The user authorizes the login on the server 36 | * The server creates access tokens for the user 37 | * The user should be logged in 38 | 39 | # After logging in, navigation should be accessible 40 | When The user clicks on the "Files" button 41 | Then The interface should say "reMarkable file tree" 42 | 43 | Scenario: Canceling OAuth authentication flow 44 | When The plugin initializes 45 | * The user opens the Scrybble interface 46 | Then The interface should say "Connect to Scrybble" 47 | 48 | And The user clicks on the "Sign in with Scrybble" button 49 | Then The interface should say "Complete Authorization" 50 | 51 | When The user clicks on the "Cancel" button 52 | Then The interface should say "Connect to Scrybble" 53 | 54 | Scenario: The access token has expired, and will be refreshed 55 | Given The user has logged in locally 56 | * The user's access token has expired on the server 57 | 58 | When The plugin initializes 59 | * The user opens the Scrybble interface 60 | 61 | Then The client requests a new access token using the refresh token 62 | 63 | And The user clicks on the "Account" button 64 | * The interface should say "Welcome back Test user" 65 | * The interface should say "You're connected to Scrybble" 66 | -------------------------------------------------------------------------------- /features/step_definitions/steps.ts: -------------------------------------------------------------------------------- 1 | import "../support/MockObsidian" 2 | import {Then, When} from "@cucumber/cucumber"; 3 | import {html, render} from "lit-html"; 4 | import {expect} from "chai"; 5 | import loadLitComponents from "../../src/ui/loadComponents"; 6 | import {ObsidianWorld} from "../support/ObsidianWorld"; 7 | import {HTMLSpanElement} from "happy-dom"; 8 | 9 | loadLitComponents(); 10 | 11 | When("The user opens the Scrybble interface", async function (this: ObsidianWorld) { 12 | this.container = document.createElement('div'); 13 | document.body.appendChild(this.container as Node); 14 | 15 | render(html``, this.container) 20 | 21 | await new Promise(resolve => setTimeout(resolve, 250)); 22 | }); 23 | 24 | When("The user clicks on the {string} button", async function (this: ObsidianWorld, text) { 25 | const elements = Array.from((this.container as HTMLDivElement).querySelectorAll(`button`)); 26 | const element = elements.find(el => el.innerText.includes(text)); 27 | 28 | expect(element).to.not.be.null; 29 | 30 | if (element) { 31 | (element as HTMLButtonElement).click(); 32 | } 33 | 34 | await new Promise(resolve => setTimeout(resolve, 250)); 35 | }); 36 | 37 | Then("The interface should say {string}", async function (text) { 38 | await new Promise(resolve => setTimeout(resolve, 100)); 39 | expect(this.container.innerText).to.include(text); 40 | }); 41 | When("The plugin initializes", async function (this: ObsidianWorld) { 42 | console.log("Initializing plugin"); 43 | await this.authentication.initializeAuth(); 44 | }); 45 | 46 | function findFileByNameInReMarkableFiletree(world: ObsidianWorld, name: string) { 47 | const files = Array.from(world.container!.querySelectorAll("sc-rm-file")); 48 | const ts = files.filter(node => ((node.querySelector(".filename") as unknown) as HTMLElement).innerText === name); 49 | return ts[0]; 50 | } 51 | 52 | Then("The {string} link for {string} is {string}", function (this: ObsidianWorld, pdfOrMD: string, filename: string, className: string) { 53 | if (!["pdf", "md"].includes(pdfOrMD)) { 54 | throw new Error("The first parameter to this Then should be `pdf` or `md` and nothing else"); 55 | } 56 | if (!["available", "unavailable"].includes(className)) { 57 | throw new Error("The third parameter to this Then should be `available` or `unavailable` and nothing else"); 58 | } 59 | const requestedFile = findFileByNameInReMarkableFiletree(this, filename); 60 | 61 | const button: HTMLSpanElement = (requestedFile.querySelector(`.${pdfOrMD}`) as unknown) as HTMLSpanElement; 62 | expect(Array.from(button.classList)).to.contain(className); 63 | }); 64 | -------------------------------------------------------------------------------- /src/ScrybbleView.ts: -------------------------------------------------------------------------------- 1 | import {apiVersion, ItemView, Platform, WorkspaceLeaf} from "obsidian"; 2 | import Scrybble from "../main"; 3 | import {html, render} from "lit-html"; 4 | import {ScrybbleViewType} from "./ui/Pages/ScrybbleUI"; 5 | import {ObsidianFileNavigator} from "./FileNavigator"; 6 | import {ScrybbleCommon} from "../@types/scrybble"; 7 | import {FileSyncFeedbackModal} from "./ui/Components/FileSyncFeedbackModal"; 8 | 9 | export const SCRYBBLE_VIEW = "SCRYBBLE_VIEW"; 10 | 11 | export class ScrybbleView extends ItemView { 12 | navigation = true; 13 | private readonly plugin: Scrybble; 14 | 15 | constructor(leaf: WorkspaceLeaf, plugin: Scrybble) { 16 | super(leaf); 17 | this.plugin = plugin; 18 | } 19 | 20 | async onload(): Promise { 21 | this.setupContainerStyles(); 22 | await this.renderView(); 23 | } 24 | 25 | getDisplayText(): string { 26 | return "Scrybble"; 27 | } 28 | 29 | getViewType(): string { 30 | return SCRYBBLE_VIEW; 31 | } 32 | 33 | getIcon(): string { 34 | return "pencil-line"; 35 | } 36 | 37 | private setupContainerStyles(): void { 38 | this.contentEl.style.display = "flex"; 39 | this.contentEl.style.flexDirection = "column"; 40 | } 41 | 42 | private async handleViewSwitch(view: ScrybbleViewType): Promise { 43 | await this.renderView(); 44 | } 45 | 46 | private async handleErrorRefresh(): Promise { 47 | await this.renderView(); 48 | } 49 | 50 | private async renderView(): Promise { 51 | const self = this; 52 | const scrybble: ScrybbleCommon = { 53 | api: this.plugin, 54 | get sync() {return self.plugin.syncQueue}, 55 | get settings() {return self.plugin.settings}, 56 | fileNavigator: new ObsidianFileNavigator(this.plugin.app), 57 | get authentication() {return self.plugin.authentication}, 58 | meta: { 59 | scrybbleVersion: this.plugin.manifest.version, 60 | obsidianVersion: apiVersion, 61 | platformInfo: this.getPlatformInfo() 62 | }, 63 | openFeedbackDialog: (syncFile, onSubmit) => { 64 | const dialog = new FileSyncFeedbackModal(self.plugin.app, syncFile, onSubmit) 65 | dialog.open(); 66 | } 67 | } 68 | 69 | render(html` 70 | `, this.contentEl); 74 | } 75 | 76 | private getPlatformInfo(): string { 77 | const p = Platform; 78 | 79 | // App type 80 | const appType = p.isDesktopApp ? 'Desktop' : 'Mobile'; 81 | 82 | // Operating system 83 | let os = 'Unknown'; 84 | if (p.isMacOS) os = 'macOS'; 85 | else if (p.isWin) os = 'Windows'; 86 | else if (p.isLinux) os = 'Linux'; 87 | else if (p.isIosApp) os = 'iOS'; 88 | else if (p.isAndroidApp) os = 'Android'; 89 | 90 | // Form factor (only relevant for mobile) 91 | const formFactor = p.isMobile ? (p.isPhone ? ' (Phone)' : p.isTablet ? ' (Tablet)' : '') : ''; 92 | 93 | return `${appType} - ${os}${formFactor}`; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/ui/loadComponents.ts: -------------------------------------------------------------------------------- 1 | import {RmDir} from "./Components/RmDir"; 2 | import {RmFile} from "./Components/RmFile"; 3 | import {RmFileTree} from "./Components/RmFileTree"; 4 | import {SearchFilter} from "./Components/SearchFilter"; 5 | import {ScrybbleFileTreeComponent} from "./Pages/ScrybbleFileTree"; 6 | import {ErrorComponent} from "./Components/ErrorComponent"; 7 | import {SyncProgressIndicator} from "./Components/SyncNotice"; 8 | import {ScrybbleUI} from "./Pages/ScrybbleUI"; 9 | import {SupportPage} from "./Pages/SupportPage"; 10 | import {AccountPage} from "./Pages/AccountPage"; 11 | import {ScrybbleOnboarding} from "./Pages/OnboardingPage"; 12 | import {addIcon, getIcon} from "obsidian"; 13 | import {Errors} from "../errorHandling/Errors"; 14 | 15 | export default function loadLitComponents() { 16 | try { 17 | if (!window.customElements.get("sc-rm-tree")) { 18 | window.customElements.define("sc-rm-tree", RmFileTree) 19 | } 20 | if (!window.customElements.get("sc-rm-file")) { 21 | window.customElements.define('sc-rm-file', RmFile) 22 | } 23 | if (!window.customElements.get("sc-rm-dir")) { 24 | window.customElements.define('sc-rm-dir', RmDir) 25 | } 26 | if (!window.customElements.get("sc-search-filter")) { 27 | window.customElements.define('sc-search-filter', SearchFilter) 28 | } 29 | if (!window.customElements.get("sc-file-tree")) { 30 | window.customElements.define('sc-file-tree', ScrybbleFileTreeComponent) 31 | } 32 | if (!window.customElements.get("sc-error-view")) { 33 | window.customElements.define('sc-error-view', ErrorComponent) 34 | } 35 | if (!window.customElements.get("sc-sync-progress-indicator")) { 36 | window.customElements.define('sc-sync-progress-indicator', SyncProgressIndicator) 37 | } 38 | 39 | // pages 40 | if (!window.customElements.get("sc-ui")) { 41 | window.customElements.define("sc-ui", ScrybbleUI) 42 | } 43 | if (!window.customElements.get("sc-support")) { 44 | window.customElements.define('sc-support', SupportPage) 45 | } 46 | if (!window.customElements.get("sc-account")) { 47 | window.customElements.define('sc-account', AccountPage) 48 | } 49 | if (!window.customElements.get("sc-onboarding")) { 50 | window.customElements.define('sc-onboarding', ScrybbleOnboarding) 51 | } 52 | } catch (e) { 53 | Errors.handle("COMPONENT_REGISTRATION_ERROR", e as Error) 54 | } 55 | 56 | try { 57 | // unsure why this icon is unavailable, it should be available given Obsidian's lucide version 58 | if (getIcon('file-x-2') == null) { 59 | addIcon("file-x-2", ``); 60 | } 61 | } catch (e) { 62 | Errors.handle("ICON_REGISTRATION_ERROR", e as Error) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /features/support/ObsidianWorld.ts: -------------------------------------------------------------------------------- 1 | import {IWorldOptions, World} from "@cucumber/cucumber"; 2 | import {ScrybbleCommon} from "../../@types/scrybble"; 3 | import {MockScrybbleApi} from "./MockScrybbleApi"; 4 | import sinon, {SinonSpy} from "sinon"; 5 | import {MockFileNavigator} from "./MockFileNavigator"; 6 | import {Authentication} from "../../src/Authentication"; 7 | import {SettingsImpl} from "../../src/SettingsImpl"; 8 | import {SyncJob, SyncJobStates} from "../../src/SyncJob"; 9 | 10 | export class ObsidianWorld extends World { 11 | public container: HTMLDivElement | null; 12 | public api: MockScrybbleApi; 13 | public authentication: Authentication; 14 | private readonly fileNavigator: MockFileNavigator; 15 | 16 | public spies: { 17 | initiateDeviceFlow: SinonSpy; 18 | refreshAccessToken: SinonSpy; 19 | fetchGetUser: SinonSpy; 20 | pollForDeviceToken: SinonSpy; 21 | windowOpen: SinonSpy; 22 | }; 23 | 24 | public readonly scrybble: ScrybbleCommon; 25 | 26 | constructor(options: IWorldOptions) { 27 | super(options); 28 | this.container = null; 29 | 30 | const settings = new SettingsImpl({ 31 | sync_folder: "scrybble", 32 | sync_state: {}, 33 | custom_host: { 34 | client_secret: "", 35 | endpoint: "", 36 | client_id: "" 37 | }, 38 | 39 | self_hosted: false 40 | }, () => { 41 | return Promise.resolve(); 42 | }); 43 | 44 | this.api = new MockScrybbleApi(settings); 45 | 46 | this.authentication = new Authentication(settings, this.api); 47 | this.fileNavigator = new MockFileNavigator(); 48 | this.scrybble = { 49 | api: this.api, 50 | sync: { 51 | requestSync(filename: string) { 52 | }, 53 | unsubscribeToSyncStateChangesForFile(path: string) { 54 | }, 55 | subscribeToSyncStateChangesForFile(path: string, callback: (newState: SyncJobStates, job: SyncJob) => void) { 56 | } 57 | }, 58 | settings, 59 | authentication: this.authentication, 60 | fileNavigator: this.fileNavigator, 61 | meta: { 62 | scrybbleVersion: "dev", 63 | obsidianVersion: "unknown", 64 | platformInfo: "development" 65 | }, 66 | openFeedbackDialog: (syncFile, onSubmit) => {}, 67 | }; 68 | 69 | this.spies = { 70 | initiateDeviceFlow: sinon.spy(this.authentication, 'initiateDeviceFlow'), 71 | pollForDeviceToken: sinon.spy(this.api, 'fetchPollForDeviceToken'), 72 | fetchGetUser: sinon.spy(this.api, 'fetchGetUser'), 73 | refreshAccessToken: sinon.spy(this.api, 'fetchRefreshOAuthAccessToken'), 74 | windowOpen: sinon.spy(window, 'open') 75 | }; 76 | } 77 | 78 | cleanup() { 79 | // Restore all spies after tests 80 | Object.values(this.spies).forEach(spy => { 81 | if (spy && spy.restore) { 82 | spy.restore(); 83 | } 84 | }); 85 | } 86 | 87 | hasLoggedInLocally() { 88 | this.scrybble.settings.access_token = "access-abcdefg"; 89 | this.scrybble.settings.refresh_token = "refresh-abcdefg"; 90 | } 91 | 92 | isLoggedIn() { 93 | this.api.isLoggedIn(); 94 | } 95 | 96 | isNotLoggedIn() { 97 | this.scrybble.settings.access_token = undefined; 98 | this.scrybble.settings.refresh_token = undefined; 99 | this.api.isNotLoggedIn(); 100 | } 101 | 102 | addObsidianFile(filename: string, folder: string) { 103 | this.fileNavigator.setMockFile(filename, folder); 104 | } 105 | 106 | [key: string]: any; 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrybble sync - Think analog, organize digital 2 | 3 | Scrybble sync lets you access your handwritten notes from a [reMarkable tablet](https://remarkable.com) - a digital paper tablet for distraction-free writing and reading. 4 | 5 | ## Searchable highlights and written text 6 | 7 | All typed text on your device, along with all the passages you highlight while reading and researching are conveniently gathered in a single Markdown file per Document. 8 | 9 | All of these are neatly organized per page. 10 | 11 | Perfect if you have a [Type Folio](https://remarkable.com/products/remarkable-paper/pro/type-folio) 12 | 13 | ![A markdown page for the eBook "Docs for developers", showing quotes about the importance of documentation](img/highlights.png) 14 | 15 | ## Your handwritten notes available for reference 16 | 17 | Whether personal or professional, you write about things that are important for you. Make sure they're stored safely in your vault, for reference. 18 | 19 | ![An intention-setting page from a bullet journal, talking about friends, curiosity, creativity and nature](img/handwritten%20notes.png) 20 | 21 | ## Quick access to all of your ebooks, PDFs, Quick Notes and worksheets in your vault 22 | 23 | The reMarkable is an amazing device, but it can be frustrating or worrisome that all your notes, documents, papers and eBooks are in only one place. 24 | 25 | You can safely store them in your vault. 26 | 27 | ## Staying organized with tags 28 | 29 | The tags you add on the reMarkable will show up in the generated Markdown documents, so it's easy to stay organized. 30 | 31 | - Any tags added to the document itself will appear in the frontmatter 32 | - Tags you add to specific pages will show up along the page headings 33 | 34 | ## Cost 35 | 36 | Sync requires a subscription, costing € 22,- yearly for students and academics, or € 40,- a year for professionals. 37 | You can also choose a monthly or two-yearly subscription. 38 | 39 | [Get a subscription here](https://streamsoft.gumroad.com/l/remarkable-to-obsidian) 40 | 41 | ## Using Scrybble Sync 42 | 43 | You can open the Scrybble pane in two ways, there's a small "scrybble" button in the status bar in the bottom right of Obsidian. 44 | Or you can open it via the command pane, when you search for "Scrybble" or "reMarkable". 45 | 46 | ### Set-up 47 | 48 | The plugin will guide you through setting up the connection with your reMarkable tablet. Once you've set-up your connection, you can authenticate with your Scrybble account and start browsing your reMarkable files right from Obsidian. 49 | 50 | ### Syncing files 51 | 52 | Simply press a file and a sync will be requested. Once it's ready, it will appear in your vault. 53 | Within the reMarkable file tree, you can easily access the associated PDF or Markdown file for your synced reMarkable notebook by clicking the respective MD or PDF button. 54 | 55 | ![The reMarkable filetree with several folders and files shown, also showing a context menu where you can open the synced PDF or Markdown file](img/rM_filetree.png) 56 | 57 | ### Settings 58 | 59 | By default, notes will be synced to the "scrybble" folder, you can change this if you wish. 60 | 61 | ## Open-source 62 | 63 | All of Scrybble is entirely open source, you can find the source-code for the various components on the [**scrybbling together** Github](https://github.com/Scrybbling-together/). 64 | 65 | ## Links 66 | 67 | - [scrybble.ink](https://scrybble.ink) 68 | - Support: 69 | - mail@scrybble.ink 70 | - [Scrybbling together - The community Discord](https://discord.gg/zPrAUzNuSN) 71 | -------------------------------------------------------------------------------- /src/ui/Components/SearchFilter.ts: -------------------------------------------------------------------------------- 1 | import {LitElement, html, nothing, TemplateResult} from "lit-element"; 2 | import {property, state} from "lit-element/decorators.js"; 3 | import {SearchFilters} from "../../../@types/scrybble"; 4 | import {getIcon} from "obsidian"; 5 | 6 | export class SearchFilter extends LitElement { 7 | @property({type: Object}) 8 | filters: SearchFilters = {}; 9 | 10 | @state() 11 | private query: string = ""; 12 | 13 | @state() 14 | private tag: string = ""; 15 | 16 | @state() 17 | private starred: boolean = false; 18 | 19 | @property({type: Boolean}) 20 | isSearchMode: boolean = false; 21 | 22 | connectedCallback() { 23 | super.connectedCallback(); 24 | this.applyFilters(this.filters); 25 | } 26 | 27 | updated(changedProperties: Map) { 28 | if (changedProperties.has('filters')) { 29 | this.applyFilters(this.filters); 30 | } 31 | } 32 | 33 | private applyFilters(filters: SearchFilters) { 34 | this.query = filters.query ?? ""; 35 | this.tag = filters.tags?.join(", ") ?? ""; 36 | this.starred = filters.starred ?? false; 37 | } 38 | 39 | render(): TemplateResult { 40 | return html` 41 |
42 |
43 |
44 | 45 | 52 |
53 |
54 | 55 | 62 |
63 |
64 | 72 |
73 |
74 |
75 | 83 | ${this.isSearchMode ? html` 84 | 91 | ` : nothing} 92 |
93 |
94 | `; 95 | } 96 | 97 | protected createRenderRoot(): HTMLElement | DocumentFragment { 98 | return this; 99 | } 100 | 101 | private handleQueryChange(e: Event) { 102 | this.query = (e.target as HTMLInputElement).value; 103 | } 104 | 105 | private handleTagChange(e: Event) { 106 | this.tag = (e.target as HTMLInputElement).value; 107 | } 108 | 109 | private handleStarredChange(e: Event) { 110 | this.starred = (e.target as HTMLInputElement).checked; 111 | } 112 | 113 | private hasFilters(): boolean { 114 | return this.query.trim() !== "" || this.tag.trim() !== "" || this.starred; 115 | } 116 | 117 | private buildFilters(): SearchFilters { 118 | const filters: SearchFilters = {}; 119 | 120 | if (this.query.trim()) { 121 | filters.query = this.query.trim(); 122 | } 123 | 124 | if (this.tag.trim()) { 125 | filters.tags = this.tag.split(",").map(t => t.trim()).filter(t => t); 126 | } 127 | 128 | if (this.starred) { 129 | filters.starred = true; 130 | } 131 | 132 | return filters; 133 | } 134 | 135 | private handleSearch() { 136 | if (!this.hasFilters()) return; 137 | 138 | this.dispatchEvent(new CustomEvent('search', { 139 | detail: this.buildFilters(), 140 | bubbles: true, 141 | composed: true 142 | })); 143 | } 144 | 145 | private handleClear() { 146 | this.query = ""; 147 | this.tag = ""; 148 | this.starred = false; 149 | 150 | this.dispatchEvent(new CustomEvent('clear-search', { 151 | bubbles: true, 152 | composed: true 153 | })); 154 | } 155 | } -------------------------------------------------------------------------------- /Plugin_readme.md: -------------------------------------------------------------------------------- 1 | # Obsidian Sample Plugin 2 | 3 | This is a sample plugin for Obsidian (https://obsidian.md). 4 | 5 | This project uses Typescript to provide type checking and documentation. 6 | The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does. 7 | 8 | **Note:** The Obsidian API is still in early alpha and is subject to change at any time! 9 | 10 | This sample plugin demonstrates some of the basic functionality the plugin API can do. 11 | - Changes the default font color to red using `styles.css`. 12 | - Adds a ribbon icon, which shows a Notice when clicked. 13 | - Adds a command "Open Sample Modal" which opens a Modal. 14 | - Adds a plugin setting tab to the settings page. 15 | - Registers a global click event and output 'click' to the console. 16 | - Registers a global interval which logs 'setInterval' to the console. 17 | 18 | ## First time developing plugins? 19 | 20 | Quick starting guide for new plugin devs: 21 | 22 | - Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. 23 | - Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). 24 | - Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. 25 | - Install NodeJS, then run `npm i` in the command line under your repo folder. 26 | - Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. 27 | - Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. 28 | - Reload Obsidian to load the new version of your plugin. 29 | - Enable plugin in settings window. 30 | - For updates to the Obsidian API run `npm update` in the command line under your repo folder. 31 | 32 | ## Releasing new releases 33 | 34 | - Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. 35 | - Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. 36 | - Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases 37 | - Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. 38 | - Publish the release. 39 | 40 | > You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. 41 | > The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` 42 | 43 | ## Adding your plugin to the community plugin list 44 | 45 | - Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md 46 | - Publish an initial version. 47 | - Make sure you have a `README.md` file in the root of your repo. 48 | - Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. 49 | 50 | ## How to use 51 | 52 | - Clone this repo. 53 | - `npm i` or `yarn` to install dependencies 54 | - `npm run dev` to start compilation in watch mode. 55 | 56 | ## Manually installing the plugin 57 | 58 | - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. 59 | 60 | ## Improve code quality with eslint (optional) 61 | - [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. 62 | - To use eslint with this project, make sure to install eslint from terminal: 63 | - `npm install -g eslint` 64 | - To use eslint to analyze this project use this command: 65 | - `eslint main.ts` 66 | - eslint will then create a report with suggestions for code improvement by file and line number. 67 | - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: 68 | - `eslint .\src\` 69 | 70 | 71 | ## API Documentation 72 | 73 | See https://github.com/obsidianmd/obsidian-api 74 | -------------------------------------------------------------------------------- /src/ui/Pages/ScrybbleFileTree.ts: -------------------------------------------------------------------------------- 1 | // ScrybbleFileTreeComponent.ts 2 | import {html, LitElement, nothing} from 'lit-element'; 3 | import {property, state} from 'lit-element/decorators.js'; 4 | import {RMFileTree, RMTreeItem, ScrybbleCommon, SearchFilters} from "../../../@types/scrybble"; 5 | import {ErrorMessage, Errors} from "../../errorHandling/Errors"; 6 | import {scrybbleContext} from "../scrybbleContext"; 7 | import {consume} from "@lit/context"; 8 | import {getIcon} from "obsidian"; 9 | 10 | export class ScrybbleFileTreeComponent extends LitElement { 11 | @consume({context: scrybbleContext}) 12 | @property({type: Object, attribute: false}) 13 | scrybble!: ScrybbleCommon; 14 | 15 | @state() 16 | private items: ReadonlyArray = []; 17 | 18 | @state() 19 | private mode: 'browse' | 'search' = 'browse'; 20 | 21 | @state() 22 | private cwd = "/"; 23 | 24 | @state() 25 | private searchFilters: SearchFilters = {}; 26 | 27 | @state() 28 | private loading: boolean = true; 29 | 30 | @state() 31 | private error: ErrorMessage | null = null; 32 | 33 | async connectedCallback() { 34 | super.connectedCallback(); 35 | await this.loadTree(); 36 | } 37 | 38 | async refresh() { 39 | if (this.mode === 'browse') { 40 | await this.loadTree(); 41 | } else { 42 | await this.executeSearch(this.searchFilters); 43 | } 44 | this.requestUpdate(); 45 | } 46 | 47 | async handleClickFileOrFolder({detail: {path, type}}: any) { 48 | if (type === "f") { 49 | try { 50 | this.scrybble.sync.requestSync(path) 51 | } catch (e) { 52 | Errors.handle("REQUEST_FILE_SYNC_ERROR", e as Error) 53 | } 54 | } else if (type === "d") { 55 | this.cwd = path; 56 | this.mode = 'browse'; 57 | await this.loadTree(); 58 | } 59 | } 60 | 61 | async handleSearch({detail: filters}: CustomEvent) { 62 | this.searchFilters = filters; 63 | await this.executeSearch(filters); 64 | } 65 | 66 | async handleClearSearch() { 67 | this.mode = 'browse'; 68 | this.searchFilters = {}; 69 | await this.loadTree(); 70 | } 71 | 72 | private async executeSearch(filters: SearchFilters) { 73 | try { 74 | this.loading = true; 75 | this.requestUpdate(); 76 | const result = await this.scrybble.api.fetchSearchFiles(filters); 77 | this.items = result.items; 78 | this.mode = 'search'; 79 | this.error = null; 80 | } catch (e) { 81 | this.error = Errors.handle("SEARCH_ERROR", e as Error); 82 | } finally { 83 | this.loading = false; 84 | this.requestUpdate(); 85 | } 86 | } 87 | 88 | async loadTree() { 89 | try { 90 | this.loading = true; 91 | this.requestUpdate(); 92 | const tree = await this.scrybble.api.fetchFileTree(this.cwd); 93 | this.items = tree.items; 94 | this.error = null; 95 | } catch (e) { 96 | this.error = Errors.handle("TREE_LOADING_ERROR", e as Error); 97 | } finally { 98 | this.loading = false; 99 | this.requestUpdate(); 100 | } 101 | } 102 | 103 | public setSearchFilters(filters: SearchFilters) { 104 | this.searchFilters = filters; 105 | this.requestUpdate(); 106 | } 107 | 108 | render() { 109 | const error = this.error ? html` 110 |
111 |

${this.error.title}

112 |

${this.error.message}

113 |

${this.error.helpAction}

114 |
` : nothing; 115 | 116 | const heading = html` 117 |
118 |

reMarkable file tree

119 | 127 |
`; 128 | 129 | const searchFilter = html` 130 | `; 136 | 137 | const locationIndicator = this.mode === 'browse' 138 | ? html`
Current directory is ${this.cwd}
` 139 | : nothing; 140 | 141 | const tree = !this.error && this.items.length > 0 ? html` 142 | ` : nothing; 143 | 144 | const emptyState = !this.error && !this.loading && this.items.length === 0 ? html` 145 |
146 | ${this.mode === 'search' ? 'No files match your search criteria.' : 'This folder is empty.'} 147 |
` : nothing; 148 | 149 | return html` 150 |
151 | ${heading} 152 | ${searchFilter} 153 | ${error} 154 | ${locationIndicator} 155 | ${tree} 156 | ${emptyState} 157 |
158 | `; 159 | } 160 | 161 | protected createRenderRoot(): HTMLElement | DocumentFragment { 162 | return this 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/ui/Pages/SupportPage.ts: -------------------------------------------------------------------------------- 1 | import {LitElement} from "lit-element"; 2 | import {html} from "lit-html"; 3 | import {getIcon} from "obsidian"; 4 | import {consume} from "@lit/context"; 5 | import {scrybbleContext} from "../scrybbleContext"; 6 | import {property} from "lit-element/decorators.js"; 7 | import {ScrybbleCommon} from "../../../@types/scrybble"; 8 | import {retrieveScrybbleLogs} from "../../errorHandling/logging"; 9 | 10 | export class SupportPage extends LitElement { 11 | @consume({context: scrybbleContext}) 12 | @property({type: Object, attribute: false}) 13 | scrybble!: ScrybbleCommon; 14 | 15 | render() { 16 | return html` 17 |
18 |
19 |

Scrybble Support

20 |
21 | 22 | 23 |
24 |

Get Help

25 |

${getIcon("mail")} Email mail@scrybble.ink

27 |

${getIcon("message-circle")} Discord Join our community - other users can help too!

29 |
30 | 31 |
32 |

Bug Report Template

33 |

Copy this template when sending a bug report:

34 | 39 | 45 |
46 | 47 |
48 |

View Error Logs

49 |

Before reporting issues, check what's happening:

50 |
51 | 54 | 57 |
58 |
59 |
60 |
61 | `; 62 | } 63 | 64 | protected createRenderRoot(): HTMLElement | DocumentFragment { 65 | return this; 66 | } 67 | 68 | private showLogs() { 69 | const container = this.querySelector('#logs-container'); 70 | if (!container) return; 71 | 72 | container.innerHTML = ` 73 |
${JSON.stringify(retrieveScrybbleLogs(), null, 2)}
74 | `; 75 | } 76 | 77 | private copyTemplate() { 78 | const template = this.getBugReportTemplate(); 79 | navigator.clipboard.writeText(template).then(() => this.showTemporaryMessage("Copied!", ".copy-button")); 80 | } 81 | 82 | private downloadLogs() { 83 | try { 84 | const logs = retrieveScrybbleLogs(); 85 | if (!logs) { 86 | // Show user feedback 87 | this.showTemporaryMessage('No logs available to download', ".download-logs"); 88 | return; 89 | } 90 | 91 | // Create formatted log content 92 | const logContent = JSON.stringify(logs, null, 2); 93 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 94 | const filename = `scrybble-logs-${timestamp}.json`; 95 | 96 | // Create and trigger download 97 | const blob = new Blob([logContent], { type: 'application/json' }); 98 | const url = URL.createObjectURL(blob); 99 | const a = document.createElement('a'); 100 | a.href = url; 101 | a.download = filename; 102 | a.style.display = 'none'; 103 | document.body.appendChild(a); 104 | a.click(); 105 | document.body.removeChild(a); 106 | URL.revokeObjectURL(url); 107 | 108 | // Show success feedback 109 | this.showTemporaryMessage('Logs downloaded successfully', ".download-logs"); 110 | } catch (error) { 111 | console.error('Error downloading logs:', error); 112 | this.showTemporaryMessage('Error downloading logs', ".download-logs"); 113 | } 114 | } 115 | 116 | private showTemporaryMessage(message: string, btnClass: string) { 117 | const button = this.querySelector(btnClass) as HTMLButtonElement; 118 | if (button) { 119 | const originalText = button.innerHTML; 120 | button.innerHTML = message; 121 | button.disabled = true; 122 | setTimeout(() => { 123 | button.innerHTML = originalText; 124 | button.disabled = false; 125 | }, 2000); 126 | } 127 | } 128 | 129 | private getEnvironment(): string { 130 | return `- Obsidian version: ${this.scrybble.meta.obsidianVersion} 131 | - Scrybble version: ${this.scrybble.meta.scrybbleVersion} 132 | - Platform: ${this.scrybble.meta.platformInfo}; 133 | - Email: ${this.scrybble.authentication.user?.user.email ?? "Please type your Scrybble e-mail here"}`; 134 | } 135 | 136 | private getBugReportTemplate(): string { 137 | return `**What happened:** 138 | [Describe the issue] 139 | 140 | **Steps to reproduce:** 141 | 1. 142 | 2. 143 | 3. 144 | 145 | **Expected behavior:** 146 | [What should have happened] 147 | 148 | **Environment:** 149 | ${this.getEnvironment()} 150 | 151 | **Error logs:** 152 | [Attach the downloaded error logs to the e-mail if possible] 153 | 154 | **Additional context:** 155 | [Any other relevant information]`; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/ui/Pages/ScrybbleUI.ts: -------------------------------------------------------------------------------- 1 | import {html, nothing, TemplateResult} from "lit-html"; 2 | import {ErrorMessage} from "../../errorHandling/Errors"; 3 | import {getIcon} from "obsidian"; 4 | import {ScrybbleCommon} from "../../../@types/scrybble"; 5 | import {scrybbleContext} from "../scrybbleContext"; 6 | import {provide} from "@lit/context"; 7 | import {LitElement} from "lit-element"; 8 | import {property, state} from "lit-element/decorators.js"; 9 | import {AuthStates} from "../../Authentication"; 10 | 11 | export enum ScrybbleViewType { 12 | FILE_TREE = "file_tree", 13 | SUPPORT = "support", 14 | ACCOUNT = "login", 15 | ONBOARDING = "onboarding" 16 | } 17 | 18 | export class ScrybbleUI extends LitElement { 19 | @state() 20 | private currentView: ScrybbleViewType = ScrybbleViewType.FILE_TREE; 21 | 22 | @state() 23 | private error: ErrorMessage | null = null; 24 | 25 | @property({type: Object, attribute: false}) 26 | @provide({context: scrybbleContext}) 27 | private scrybble!: ScrybbleCommon; 28 | 29 | @property({type: Function, attribute: false}) 30 | onViewSwitch!: (view: ScrybbleViewType) => void; 31 | 32 | @property({type: Function, attribute: false}) 33 | onErrorRefresh!: () => Promise; 34 | 35 | private async initialize() { 36 | this.scrybble.authentication.addStateChangeListener((state) => { 37 | if (state === AuthStates.AUTHENTICATED) { 38 | if (this.scrybble.authentication.user?.onboarding_state !== "ready") { 39 | this.currentView = ScrybbleViewType.ONBOARDING; 40 | } 41 | this.requestUpdate(); 42 | } else if (state === AuthStates.UNAUTHENTICATED) { 43 | this.requestUpdate(); 44 | } 45 | }); 46 | 47 | if (!this.scrybble.settings.access_token) { 48 | this.currentView = ScrybbleViewType.ACCOUNT 49 | return; 50 | } 51 | } 52 | 53 | private shouldDisableNavButton(): boolean { 54 | if (this.currentView === ScrybbleViewType.ONBOARDING) { 55 | return this.scrybble.authentication.user!.onboarding_state !== "ready"; 56 | } 57 | return !this.scrybble.authentication.user; 58 | } 59 | 60 | async connectedCallback() { 61 | super.connectedCallback(); 62 | await this.initialize(); 63 | } 64 | 65 | async switchView(view: ScrybbleViewType): Promise { 66 | if (this.currentView !== view) { 67 | this.currentView = view; 68 | this.onViewSwitch(view); 69 | } 70 | } 71 | 72 | async handleErrorRefresh(): Promise { 73 | this.error = null; 74 | await this.initialize(); 75 | await this.onErrorRefresh(); 76 | } 77 | 78 | render(): TemplateResult { 79 | const { error } = this; 80 | 81 | const errorTemplate = error ? html` 82 | Refresh`]}"/>` : nothing; 84 | 85 | return html` 86 | ${this.renderNavigation()} 87 |
88 | ${this.currentView === ScrybbleViewType.SUPPORT ? nothing : errorTemplate} 89 | ${(error && this.currentView !== ScrybbleViewType.SUPPORT) ? nothing : this.renderCurrentView()} 90 |
91 | `; 92 | } 93 | 94 | private renderNavigation(): TemplateResult { 95 | const { currentView } = this; 96 | 97 | return html` 98 | 124 | `; 125 | } 126 | private renderCurrentView(): TemplateResult | typeof nothing { 127 | const { currentView } = this; 128 | 129 | switch (currentView) { 130 | case ScrybbleViewType.FILE_TREE: 131 | return html``; 132 | case ScrybbleViewType.SUPPORT: 133 | return html``; 134 | case ScrybbleViewType.ACCOUNT: 135 | return html``; 136 | case ScrybbleViewType.ONBOARDING: 137 | return html``; 140 | default: 141 | return nothing; 142 | } 143 | } 144 | 145 | protected createRenderRoot(): HTMLElement | DocumentFragment { 146 | return this 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import {App, PluginSettingTab, Setting} from "obsidian"; 2 | import Scrybble from "../main"; 3 | 4 | export class Settings extends PluginSettingTab { 5 | // @ts-expect-error TS2564 6 | private endpointSetting: Setting; 7 | // @ts-expect-error TS2564 8 | private clientSecretSetting: Setting; 9 | // @ts-expect-error TS2564 10 | private connectedMessage: HTMLElement; 11 | // @ts-expect-error TS2564 12 | private clientIdSetting: Setting; 13 | 14 | constructor(app: App, private readonly plugin: Scrybble) { 15 | super(app, plugin); 16 | this.plugin = plugin; 17 | } 18 | 19 | display(): void { 20 | const {containerEl} = this; 21 | 22 | containerEl.empty() 23 | containerEl.createEl("h2", {text: "Output"}) 24 | 25 | new Setting(containerEl) 26 | .setName('Output folder') 27 | .setDesc(`Where your reMarkable files will be stored. 28 | Default is "scrybble/"`) 29 | .addText((text) => text 30 | .setValue(this.plugin.settings.sync_folder) 31 | .setPlaceholder("scrybble/") 32 | .onChange(async (value) => { 33 | this.plugin.settings.sync_folder = value; 34 | await this.plugin.settings.save(); 35 | })); 36 | 37 | containerEl.createEl("h2", {text: "Scrybble server"}) 38 | 39 | new Setting(containerEl) 40 | .setName("Self hosted") 41 | .setDesc("Enable if you host your own Scrybble server") 42 | .addToggle((toggle) => { 43 | toggle.setValue(this.plugin.settings.self_hosted) 44 | .onChange(async (value) => { 45 | this.plugin.settings.self_hosted = value; 46 | await this.plugin.settings.save(); 47 | this.updateVisibility(); 48 | }) 49 | }) 50 | 51 | 52 | this.endpointSetting = new Setting(containerEl) 53 | .setName("Endpoint") 54 | .setDesc("Link to a Scrybble server, leave unchanged for the official scrybble.ink server") 55 | .addText((text) => text 56 | .setPlaceholder("http://localhost") 57 | .setValue(this.plugin.settings.custom_host.endpoint) 58 | .onChange(async (value) => { 59 | this.plugin.settings.custom_host.endpoint = value; 60 | await this.plugin.settings.save(); 61 | this.updateClientFieldsVisibility(); 62 | })); 63 | 64 | this.clientIdSetting = new Setting(containerEl) 65 | .setName("Server client ID") 66 | .addText((text) => { 67 | text.inputEl.setAttribute('type', 'password') 68 | return text 69 | .setValue(this.plugin.settings.custom_host.client_id) 70 | .onChange(async (value) => { 71 | this.plugin.settings.custom_host.client_id = value; 72 | await this.plugin.settings.save(); 73 | }); 74 | }) 75 | 76 | this.clientSecretSetting = new Setting(containerEl) 77 | .setName("Server client secret") 78 | .addText((text) => { 79 | text.inputEl.setAttribute('type', 'password') 80 | return text 81 | .setValue(this.plugin.settings.custom_host.client_secret) 82 | .onChange(async (value) => { 83 | this.plugin.settings.custom_host.client_secret = value; 84 | await this.plugin.settings.save(); 85 | }); 86 | }); 87 | 88 | this.connectedMessage = containerEl.createEl("p", { 89 | text: "Connected to the official scrybble server, no additional configuration required." 90 | }); 91 | 92 | this.updateVisibility(); 93 | } 94 | 95 | private updateClientFieldsDescription(): void { 96 | const endpoint = this.plugin.settings.custom_host.endpoint; 97 | 98 | if (endpoint && endpoint.trim()) { 99 | const setupUrl = `${endpoint.replace(/\/$/, '')}/self-host-setup`; 100 | 101 | this.clientIdSetting.descEl.innerHTML = `Visit ${setupUrl} to get your credentials`; 102 | this.clientSecretSetting.descEl.innerHTML = `Visit ${setupUrl} to get your credentials`; 103 | } else { 104 | this.clientIdSetting.descEl.textContent = "Enter the endpoint URL first"; 105 | this.clientSecretSetting.descEl.textContent = "Enter the endpoint URL first"; 106 | } 107 | } 108 | 109 | private updateClientFieldsVisibility(): void { 110 | const endpoint = this.plugin.settings.custom_host.endpoint; 111 | const hasValidEndpoint = endpoint && endpoint.trim() && endpoint.startsWith('http'); 112 | 113 | this.clientIdSetting.components.forEach(component => { 114 | if ('inputEl' in component) { 115 | (component as any).inputEl.disabled = !hasValidEndpoint; 116 | } 117 | }); 118 | 119 | this.clientSecretSetting.components.forEach(component => { 120 | if ('inputEl' in component) { 121 | (component as any).inputEl.disabled = !hasValidEndpoint; 122 | } 123 | }); 124 | 125 | this.updateClientFieldsDescription(); 126 | 127 | if (hasValidEndpoint) { 128 | this.clientIdSetting.settingEl.style.opacity = ""; 129 | this.clientSecretSetting.settingEl.style.opacity = ""; 130 | } else { 131 | this.clientIdSetting.settingEl.style.opacity = "0.5"; 132 | this.clientSecretSetting.settingEl.style.opacity = "0.5"; 133 | } 134 | } 135 | 136 | private updateVisibility(): void { 137 | if (this.plugin.settings.self_hosted) { 138 | this.endpointSetting.settingEl.style.display = ""; 139 | this.clientSecretSetting.settingEl.style.display = ""; 140 | this.clientIdSetting.settingEl.style.display = ""; 141 | this.connectedMessage.style.display = "none"; 142 | this.updateClientFieldsVisibility(); 143 | } else { 144 | this.endpointSetting.settingEl.style.display = "none"; 145 | this.clientSecretSetting.settingEl.style.display = "none"; 146 | this.clientIdSetting.settingEl.style.display = "none"; 147 | this.connectedMessage.style.display = ""; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/ui/Components/FileSyncFeedbackModal.ts: -------------------------------------------------------------------------------- 1 | import {FeedbackFormDetails, SyncFile} from "../../../@types/scrybble"; 2 | import {App, ButtonComponent, Modal, Notice, Setting, ToggleComponent} from "obsidian"; 3 | 4 | export class FileSyncFeedbackModal extends Modal { 5 | private devAccess: boolean = false; 6 | private openAccess: boolean = false; 7 | private comment: string = ''; 8 | 9 | // toggles 10 | // @ts-expect-error TS2564 11 | private openAccessSetting: ToggleComponent; 12 | // @ts-expect-error TS2564 13 | private commentSetting: Setting; 14 | 15 | // @ts-expect-error TS2564 16 | private whatWillBeShared: Setting; 17 | // @ts-expect-error TS2564 18 | private whoWillBeSharedWith: Setting; 19 | // @ts-expect-error TS2564 20 | private shareButton: ButtonComponent; 21 | 22 | constructor( 23 | app: App, 24 | private syncFile: SyncFile, 25 | private readonly onSubmit: (details: FeedbackFormDetails) => Promise 26 | ) { 27 | super(app); 28 | } 29 | 30 | onOpen() { 31 | const { contentEl } = this; 32 | contentEl.empty(); 33 | 34 | if (this.syncFile.sync?.error) { 35 | this.setTitle("Your reMarkable document isn't syncing well. Need help?"); 36 | } else { 37 | this.setTitle("Got feedback about this document? Let us know!") 38 | } 39 | 40 | new Setting(contentEl) 41 | .setHeading() 42 | .setName('Your privacy matters') 43 | .setDesc('The Scrybble developers cannot just access your documents.'); 44 | 45 | new Setting(contentEl) 46 | .setName('Allow the developer to access this reMarkable file') 47 | .addToggle(toggle => toggle 48 | .setValue(this.devAccess) 49 | .onChange(value => { 50 | this.devAccess = value; 51 | if (!value) { 52 | this.openAccess = false; 53 | this.openAccessSetting.setValue(false); 54 | } 55 | this.updateVisibility(); 56 | this.updateShareInfo(); 57 | this.updateButtonState(); 58 | })); 59 | 60 | new Setting(contentEl) 61 | .setName('Share with the wider reMarkable development community') 62 | .setDesc('Sharing your document with everyone may feel strange, but Scrybble and the reMarkable development community is built on open principles. When you share with the wider community, any developer can use it to make sure their application works well for you. Want a thriving community of tools for the reMarkable? You can contribute!') 63 | .addToggle(toggle => { 64 | this.openAccessSetting = toggle; 65 | return toggle 66 | .setValue(this.openAccess) 67 | .onChange(value => { 68 | this.openAccess = value; 69 | this.updateShareInfo(); 70 | }); 71 | }); 72 | 73 | this.commentSetting = new Setting(contentEl) 74 | .setName("What's up with this document?") 75 | .addTextArea(text => text 76 | .setPlaceholder('I expected [...] but [...] happened instead') 77 | .onChange(value => { 78 | this.comment = value; 79 | this.updateButtonState(); 80 | })); 81 | 82 | this.whatWillBeShared = new Setting(contentEl) 83 | .setName('What will be shared?'); 84 | 85 | this.whoWillBeSharedWith = new Setting(contentEl) 86 | .setName('Who will it be shared with?'); 87 | 88 | new Setting(contentEl) 89 | .addButton(btn => { 90 | this.shareButton = btn; 91 | return btn 92 | .setButtonText('Share this document') 93 | .setCta() 94 | .setDisabled(true) 95 | .onClick(this.handleShare.bind(this)); 96 | }) 97 | .addButton(btn => btn 98 | .setButtonText("Don't share") 99 | .onClick(this.close.bind(this))); 100 | 101 | this.updateVisibility(); 102 | this.updateShareInfo(); 103 | } 104 | 105 | private updateVisibility() { 106 | this.openAccessSetting.toggleEl.style.display = this.devAccess ? '' : 'none'; 107 | this.commentSetting.settingEl.style.display = this.devAccess ? '' : 'none'; 108 | } 109 | 110 | private updateShareInfo() { 111 | let whatDesc: string; 112 | if (this.devAccess) { 113 | const items = []; 114 | if (this.syncFile.sync?.error) { 115 | items.push('The errors associated with this reMarkable document, PDF or quick sheets'); 116 | } 117 | items.push(`Your reMarkable document: ${this.syncFile.name}`); 118 | whatDesc = items.join('\n• '); 119 | whatDesc = '• ' + whatDesc; 120 | } else { 121 | whatDesc = "Nothing. You haven't given permission to share."; 122 | } 123 | this.whatWillBeShared.setDesc(whatDesc); 124 | 125 | let whoDesc: string; 126 | if (this.devAccess && this.openAccess) { 127 | whoDesc = 'This document will be shared with anyone.'; 128 | } else if (this.devAccess && !this.openAccess) { 129 | whoDesc = 'This document will be shared with the developer(s) of Scrybble.'; 130 | } else { 131 | whoDesc = "Nobody. You haven't given permission to share."; 132 | } 133 | this.whoWillBeSharedWith.setDesc(whoDesc); 134 | } 135 | 136 | private updateButtonState() { 137 | this.shareButton.setDisabled(!this.devAccess || this.comment.length === 0) 138 | } 139 | 140 | private async handleShare() { 141 | const details: FeedbackFormDetails = { 142 | developer_access_consent_granted: this.devAccess, 143 | open_access_consent_granted: this.openAccess, 144 | sync_id: this.syncFile.sync!.id 145 | }; 146 | 147 | if (this.comment.trim()) { 148 | details.feedback = this.comment.trim(); 149 | } 150 | 151 | try { 152 | await this.onSubmit(details); 153 | new Notice('Shared reMarkable document.'); 154 | this.close(); 155 | } catch (error) { 156 | new Notice('Was unable to share the reMarkable document. Contact developer.'); 157 | console.error('Share error:', error); 158 | } 159 | } 160 | 161 | onClose() { 162 | const { contentEl } = this; 163 | contentEl.empty(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /features/FileLinkVisibility.feature: -------------------------------------------------------------------------------- 1 | Feature: File Detection with Different Output Folder Configurations 2 | As a user of the Scrybble plugin 3 | I want the plugin to correctly detect PDF and Markdown files 4 | Regardless of where I configure the output path 5 | 6 | Background: 7 | Given The user has logged in locally 8 | * The user's access token is valid on the server 9 | * The reMarkable has a file called "Diary" in the "/" folder 10 | * The file "Diary" in the folder "/" has been downloaded "2 weeks ago" 11 | 12 | Scenario Outline: File detection with configured output folder "" - no files present 13 | Given The Scrybble folder is configured to be "" 14 | 15 | When The user opens the Scrybble interface 16 | Then The interface should say "reMarkable file tree" 17 | * The interface should say "Diary" 18 | And The "pdf" link for "Diary" is "unavailable" 19 | * The "md" link for "Diary" is "unavailable" 20 | 21 | Examples: 22 | | output_folder | description | 23 | | / | Root folder configuration | 24 | | /scrybble | Absolute path with subfolder | 25 | | /scrybble/ | Absolute path with trailing slash | 26 | | scrybble | Relative path without slashes | 27 | | scrybble/ | Relative path with trailing slash | 28 | | /external/reMarkable | Deep absolute path | 29 | | external/reMarkable | Deep relative path | 30 | | external/reMarkable/ | Deep relative path with trailing slash | 31 | 32 | Scenario Outline: File detection with configured output folder "" - PDF file present 33 | Given The Scrybble folder is configured to be "" 34 | * There is a file called "Diary.pdf" in the "" folder 35 | 36 | When The user opens the Scrybble interface 37 | Then The interface should say "reMarkable file tree" 38 | * The interface should say "Diary" 39 | And The "pdf" link for "Diary" is "available" 40 | * The "md" link for "Diary" is "unavailable" 41 | 42 | Examples: 43 | | output_folder | description | 44 | | / | Root folder configuration | 45 | | /scrybble | Absolute path with subfolder | 46 | | scrybble | Relative path without slashes | 47 | | /external/reMarkable | Deep absolute path | 48 | | /external/reMarkable/ | Deep absolute path trailing slash | 49 | | external/reMarkable/ | Deep absolute no start slash | 50 | | external/reMarkable/ | Deep absolute no supporting slashes altogether | 51 | | /02 Knowledgebase/00 remarkable | Real-world regression | 52 | 53 | Scenario Outline: File detection with configured output folder "" - MD file present 54 | Given The Scrybble folder is configured to be "" 55 | * There is a file called "Diary.md" in the "" folder 56 | 57 | When The user opens the Scrybble interface 58 | Then The interface should say "reMarkable file tree" 59 | * The interface should say "Diary" 60 | And The "pdf" link for "Diary" is "unavailable" 61 | * The "md" link for "Diary" is "available" 62 | 63 | Examples: 64 | | output_folder | description | 65 | | / | Root folder configuration | 66 | | /scrybble | Absolute path with subfolder | 67 | | scrybble | Relative path without slashes | 68 | | /external/reMarkable | Deep absolute path | 69 | | /external/reMarkable/ | Deep absolute path trailing slash | 70 | | external/reMarkable/ | Deep absolute no start slash | 71 | | external/reMarkable/ | Deep absolute no supporting slashes altogether | 72 | | /02 Knowledgebase/00 remarkable | Real-world regression | 73 | 74 | Scenario Outline: File detection with configured output folder "" - both files present 75 | Given The Scrybble folder is configured to be "" 76 | * There is a file called "Diary.pdf" in the "" folder 77 | * There is a file called "Diary.md" in the "" folder 78 | 79 | When The user opens the Scrybble interface 80 | Then The interface should say "reMarkable file tree" 81 | * The interface should say "Diary" 82 | And The "pdf" link for "Diary" is "available" 83 | * The "md" link for "Diary" is "available" 84 | 85 | Examples: 86 | | output_folder | description | 87 | | / | Root folder configuration | 88 | | /scrybble | Absolute path with subfolder | 89 | | scrybble | Relative path without slashes | 90 | | /external/reMarkable | Deep absolute path | 91 | | /external/reMarkable/ | Deep absolute path trailing slash | 92 | | external/reMarkable/ | Deep absolute no start slash | 93 | | external/reMarkable/ | Deep absolute no supporting slashes altogether | 94 | | /02 Knowledgebase/00 remarkable | Real-world regression | 95 | -------------------------------------------------------------------------------- /src/ui/Components/SyncNotice.ts: -------------------------------------------------------------------------------- 1 | import {html, LitElement} from 'lit-element'; 2 | import {property} from 'lit-element/decorators.js'; 3 | import {SyncJobStates} from "../../SyncJob"; 4 | import {Notice} from "obsidian"; 5 | import {render} from "lit-html"; 6 | 7 | export class SyncProgressIndicator extends LitElement { 8 | @property({type: String}) 9 | state = SyncJobStates.init; 10 | 11 | @property({type: String}) 12 | filename = ''; 13 | 14 | render() { 15 | return html`
16 |
Syncing ${this.filename}
17 |
18 |
19 | 20 |
21 |
${this.getStageIcon('sync')}
22 |
${this.getStageLabel('sync')}
23 |
24 | 25 |
26 |
${this.getStageIcon('process')}
27 |
${this.getStageLabel('process')}
28 |
29 | 30 |
31 |
${this.getStageIcon('download')}
32 |
${this.getStageLabel('download')}
33 |
34 |
35 |
`; 36 | } 37 | 38 | getStageLabel(stage: 'sync' | 'process' | 'download'): string { 39 | switch (stage) { 40 | case 'sync': 41 | if (this.state === SyncJobStates.init) return 'Request Sync'; 42 | if (this.state === SyncJobStates.sync_requested) return 'Requesting Sync'; 43 | if (this.getStageClass('sync') === 'stage-completed') return 'Requested Sync'; 44 | return 'Request Sync'; 45 | 46 | case 'process': 47 | if (this.state === SyncJobStates.init || this.state === SyncJobStates.sync_requested) 48 | return 'Process File'; 49 | if (this.state === SyncJobStates.processing || this.state === SyncJobStates.awaiting_processing) 50 | return 'Processing File'; 51 | if (this.state === SyncJobStates.failed_to_process) 52 | return 'Failed to Process File'; 53 | if (this.getStageClass('process') === 'stage-completed') 54 | return 'Processed File'; 55 | return 'Process File'; 56 | 57 | case 'download': 58 | if (this.state === SyncJobStates.init || 59 | this.state === SyncJobStates.sync_requested || 60 | this.state === SyncJobStates.processing || 61 | this.state === SyncJobStates.awaiting_processing) 62 | return 'Download'; 63 | if (this.state === SyncJobStates.ready_to_download) 64 | return 'Ready to Download'; 65 | if (this.state === SyncJobStates.downloading) 66 | return 'Downloading'; 67 | if (this.state === SyncJobStates.downloaded) 68 | return 'Downloaded'; 69 | if (this.state === SyncJobStates.failed_to_download) 70 | return 'Failed to download'; 71 | return 'Download'; 72 | } 73 | } 74 | 75 | getStageClass(stage: 'sync' | 'process' | 'download'): string { 76 | switch (stage) { 77 | case 'sync': 78 | if (this.state === SyncJobStates.sync_requested) return 'stage-waiting'; 79 | if (this.state === SyncJobStates.processing || 80 | this.state === SyncJobStates.awaiting_processing || 81 | this.state === SyncJobStates.ready_to_download || 82 | this.state === SyncJobStates.downloading || 83 | this.state === SyncJobStates.downloaded || 84 | this.state === SyncJobStates.failed_to_process) return 'stage-completed'; 85 | return ''; 86 | 87 | case 'process': 88 | if (this.state === SyncJobStates.processing || 89 | this.state === SyncJobStates.awaiting_processing) return 'stage-waiting'; 90 | if (this.state === SyncJobStates.failed_to_process) return 'stage-error'; 91 | if (this.state === SyncJobStates.ready_to_download || 92 | this.state === SyncJobStates.downloading || 93 | this.state === SyncJobStates.downloaded) return 'stage-completed'; 94 | return ''; 95 | 96 | case 'download': 97 | if (this.state === SyncJobStates.downloading) return 'stage-waiting'; 98 | if (this.state === SyncJobStates.failed_to_download) return 'stage-error'; 99 | if (this.state === SyncJobStates.downloaded) return 'stage-completed'; 100 | return ''; 101 | } 102 | } 103 | 104 | getStageIcon(stage: 'sync' | 'process' | 'download'): string { 105 | const stageClass = this.getStageClass(stage); 106 | 107 | if (stageClass === 'stage-completed') return '✓'; 108 | if (stageClass === 'stage-error') return '!'; 109 | return ''; 110 | } 111 | 112 | protected createRenderRoot(): HTMLElement | DocumentFragment { 113 | return this 114 | } 115 | } 116 | export class SyncProgressNotice { 117 | private notice: Notice; 118 | private readonly indicator: SyncProgressIndicator; 119 | 120 | constructor(filename: string) { 121 | this.notice = new Notice("", 0); 122 | 123 | this.indicator = document.createElement('sc-sync-progress-indicator') as SyncProgressIndicator; 124 | this.indicator.filename = filename; 125 | this.indicator.state = SyncJobStates.init; 126 | 127 | this.notice.containerEl.style.whiteSpace = "normal"; 128 | this.notice.containerEl.style.maxWidth = "calc(var(--size-4-18) * 5 + 2 * var(--size-4-3))"; 129 | 130 | render(this.indicator, this.notice.containerEl) 131 | } 132 | 133 | updateState(newState: SyncJobStates): void { 134 | this.indicator.state = newState; 135 | 136 | if (newState === SyncJobStates.downloaded) { 137 | setTimeout(() => { 138 | this.notice.hide(); 139 | }, 2000); 140 | } 141 | 142 | if (newState === SyncJobStates.failed_to_process || newState === SyncJobStates.failed_to_download) { 143 | setTimeout(() => { 144 | this.notice.hide(); 145 | }, 5000); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/SyncJob.ts: -------------------------------------------------------------------------------- 1 | import {StateMachine, t} from "typescript-fsm"; 2 | import {SyncProgressNotice} from "./ui/Components/SyncNotice"; 3 | 4 | export enum SyncJobStates { 5 | // initial state 6 | init = "INIT", 7 | 8 | sync_requested = "SYNC_REQUESTED", 9 | 10 | // we know the file has been sent, and it is processing in the cloud 11 | processing = "PROCESSING", 12 | // A request has been sent to check the sync status 13 | awaiting_processing = "AWAITING_PROCESSING", 14 | // the server confirmed the file failed to be processed 15 | failed_to_process = "FAILED_TO_PROCESS", 16 | 17 | // the server confirmed the file is ready to download 18 | ready_to_download = "READY_TO_DOWNLOAD", 19 | 20 | // a download request is in-flight 21 | downloading = "DOWNLOADING", 22 | // the file has been downloaded 23 | downloaded = "DOWNLOADED", 24 | // tried to download and put in the vault, but failed 25 | failed_to_download = "FAILED_TO_DOWNLOAD" 26 | } 27 | 28 | export enum SyncJobEvents { 29 | syncRequestConfirmed = "SYNC_REQUEST_CONFIRMED", 30 | syncRequestSent = "SYNC_REQUEST_SENT", 31 | sentProcessingCheckRequest = "CHECKING_PROCESSING_STATE", 32 | stillProcessing = "STILL_PROCESSING", 33 | ready = "READY", 34 | downloadRequestSent = "DOWNLOAD_REQUEST_SENT", 35 | downloadFailed = "DOWNLOAD_FAILED", 36 | failedToProcess = "FAILED_TO_PROCESS", 37 | downloaded = "DOWNLOADED", 38 | errorReceived = "ERROR_RECEIVED", 39 | } 40 | 41 | export class SyncJob extends StateMachine { 42 | public download_url?: string; 43 | public sync_id?: number; 44 | 45 | constructor( 46 | key: number = 0, 47 | init: SyncJobStates.init = SyncJobStates.init, 48 | private onStateChange: (filename: string, newState: SyncJobStates, job: SyncJob) => void, 49 | public filename: string 50 | ) { 51 | super(init, [], console); 52 | 53 | const notice = new SyncProgressNotice(filename); 54 | this.onStateChange(this.filename, SyncJobStates.init, this); 55 | notice.updateState(init); 56 | 57 | const transitions = [ 58 | t(SyncJobStates.init, SyncJobEvents.syncRequestSent, SyncJobStates.sync_requested, () => { 59 | this.onStateChange(this.filename, SyncJobStates.sync_requested, this); 60 | notice.updateState(SyncJobStates.sync_requested); 61 | }), 62 | t(SyncJobStates.init, SyncJobEvents.ready, SyncJobStates.ready_to_download, () => { 63 | this.onStateChange(this.filename, SyncJobStates.ready_to_download, this); 64 | notice.updateState(SyncJobStates.ready_to_download); 65 | }), 66 | 67 | t(SyncJobStates.sync_requested, SyncJobEvents.syncRequestConfirmed, SyncJobStates.processing, () => { 68 | this.onStateChange(this.filename, SyncJobStates.processing, this); 69 | notice.updateState(SyncJobStates.processing); 70 | }), 71 | 72 | t(SyncJobStates.processing, SyncJobEvents.sentProcessingCheckRequest, SyncJobStates.awaiting_processing, () => { 73 | this.onStateChange(this.filename, SyncJobStates.awaiting_processing, this); 74 | notice.updateState(SyncJobStates.awaiting_processing); 75 | }), 76 | t(SyncJobStates.awaiting_processing, SyncJobEvents.ready, SyncJobStates.ready_to_download, () => { 77 | this.onStateChange(this.filename, SyncJobStates.ready_to_download, this); 78 | notice.updateState(SyncJobStates.ready_to_download); 79 | }), 80 | t(SyncJobStates.awaiting_processing, SyncJobEvents.stillProcessing, SyncJobStates.processing, () => { 81 | this.onStateChange(this.filename, SyncJobStates.processing, this); 82 | notice.updateState(SyncJobStates.processing); 83 | }), 84 | t(SyncJobStates.awaiting_processing, SyncJobEvents.failedToProcess, SyncJobStates.failed_to_process, () => { 85 | this.onStateChange(this.filename, SyncJobStates.failed_to_process, this); 86 | notice.updateState(SyncJobStates.failed_to_process); 87 | }), 88 | 89 | t(SyncJobStates.processing, SyncJobEvents.ready, SyncJobStates.ready_to_download, () => { 90 | this.onStateChange(this.filename, SyncJobStates.ready_to_download, this); 91 | notice.updateState(SyncJobStates.ready_to_download); 92 | }), 93 | t(SyncJobStates.processing, SyncJobEvents.failedToProcess, SyncJobStates.failed_to_process, () => { 94 | this.onStateChange(this.filename, SyncJobStates.failed_to_process, this); 95 | notice.updateState(SyncJobStates.failed_to_process); 96 | }), 97 | 98 | t(SyncJobStates.ready_to_download, SyncJobEvents.downloadRequestSent, SyncJobStates.downloading, () => { 99 | this.onStateChange(this.filename, SyncJobStates.downloading, this); 100 | notice.updateState(SyncJobStates.downloading); 101 | }), 102 | t(SyncJobStates.downloading, SyncJobEvents.downloadFailed, SyncJobStates.downloaded, () => { 103 | this.onStateChange(this.filename, SyncJobStates.failed_to_download, this); 104 | notice.updateState(SyncJobStates.failed_to_download); 105 | }), 106 | t(SyncJobStates.downloading, SyncJobEvents.downloaded, SyncJobStates.downloaded, () => { 107 | this.onStateChange(this.filename, SyncJobStates.downloaded, this); 108 | notice.updateState(SyncJobStates.downloaded); 109 | }), 110 | ]; 111 | this.addTransitions(transitions) 112 | } 113 | 114 | async readyToDownload(download_url: string, sync_id: number) { 115 | this.download_url = download_url; 116 | this.sync_id = sync_id; 117 | await this.dispatch(SyncJobEvents.ready) 118 | } 119 | 120 | async downloaded() { 121 | await this.dispatch(SyncJobEvents.downloaded) 122 | } 123 | 124 | async syncRequestConfirmed(sync_id: number) { 125 | await this.dispatch(SyncJobEvents.syncRequestConfirmed); 126 | this.sync_id = sync_id; 127 | } 128 | 129 | async sentProcessingRequest() { 130 | await this.dispatch(SyncJobEvents.sentProcessingCheckRequest); 131 | } 132 | 133 | async fileStillProcessing() { 134 | await this.dispatch(SyncJobEvents.stillProcessing); 135 | } 136 | 137 | async syncRequestSent() { 138 | await this.dispatch(SyncJobEvents.syncRequestSent); 139 | } 140 | 141 | async startDownload() { 142 | await this.dispatch(SyncJobEvents.downloadRequestSent) 143 | } 144 | 145 | async processingFailed() { 146 | await this.dispatch(SyncJobEvents.failedToProcess); 147 | } 148 | 149 | async downloadingFailed() { 150 | await this.dispatch(SyncJobEvents.downloadFailed) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /@types/scrybble.ts: -------------------------------------------------------------------------------- 1 | import {ISyncQueue} from "../src/SyncQueue"; 2 | import {Authentication} from "../src/Authentication"; 3 | 4 | export interface Host { 5 | endpoint: string; 6 | client_secret: string; 7 | client_id: string; 8 | } 9 | 10 | export interface ScrybbleSettings { 11 | sync_folder: string; 12 | 13 | self_hosted: boolean; 14 | 15 | custom_host: Host; 16 | sync_state: Record; 17 | 18 | access_token?: string; 19 | refresh_token?: string; 20 | 21 | get client_id(): string; 22 | get client_secret(): string; 23 | get endpoint(): string; 24 | 25 | save(): Promise; 26 | } 27 | 28 | export type SyncDelta = { id: number, download_url: string, filename: string }; 29 | 30 | export interface RMTreeItem { 31 | type: 'f' | 'd' 32 | name: string 33 | path: string 34 | } 35 | 36 | export interface File extends RMTreeItem { 37 | type: 'f' 38 | } 39 | 40 | export interface Directory extends RMTreeItem { 41 | type: 'd' 42 | } 43 | 44 | export type RMFileTree = { items: ReadonlyArray, cwd: string }; 45 | 46 | export interface SearchFilters { 47 | query?: string; 48 | starred?: boolean; 49 | tags?: string[]; 50 | } 51 | 52 | export type SearchResult = { items: ReadonlyArray }; 53 | 54 | export interface SyncInfo { 55 | id: number; 56 | completed: boolean; 57 | error: boolean; 58 | created_at: string; 59 | } 60 | 61 | export interface SyncFile { 62 | name: string; 63 | path: string; 64 | sync: null | SyncInfo; 65 | type: "f" | "d"; 66 | } 67 | 68 | export interface PaginatedResponse { 69 | data: T[]; 70 | current_page: number; 71 | last_page: number; 72 | per_page: number; 73 | total: number; 74 | } 75 | 76 | export type OnboardingState = "unauthenticated" 77 | | "setup-gumroad" 78 | | "setup-one-time-code" 79 | | "setup-one-time-code-again" 80 | | "ready"; 81 | 82 | export type AuthenticateWithGumroadLicenseResponse = 83 | | { newState: OnboardingState } 84 | | { error: string }; 85 | 86 | export type OneTimeCodeResponse = 87 | | { newState: OnboardingState } 88 | | { error: string }; 89 | 90 | export type ResetConnectionResponse = { 91 | success: boolean; 92 | newState: OnboardingState; 93 | }; 94 | 95 | 96 | export interface FeedbackFormDetails { 97 | developer_access_consent_granted: boolean; 98 | open_access_consent_granted: boolean; 99 | sync_id: number; 100 | feedback?: string; 101 | } 102 | 103 | type SyncStateSuccess = { 104 | error: false; 105 | completed: true; 106 | download_url: string; 107 | } 108 | 109 | type SyncStateError = { 110 | error: true; 111 | completed: false; 112 | } 113 | 114 | export type SyncStateResponse = { 115 | id: number; 116 | filename: string; 117 | } & (SyncStateSuccess | SyncStateError); 118 | 119 | export interface ScrybbleApi { 120 | fetchSyncDelta(): Promise>; 121 | 122 | fetchFileTree(path: string): Promise; 123 | 124 | fetchSearchFiles(filters: SearchFilters): Promise; 125 | 126 | fetchSyncState(sync_id: number): Promise; 127 | 128 | fetchRequestFileToBeSynced(filePath: string): Promise<{ sync_id: number; filename: string; }>; 129 | 130 | fetchOnboardingState(): Promise; 131 | 132 | fetchGetUser(): Promise; 133 | 134 | fetchRefreshOAuthAccessToken(): Promise<{ access_token: string, refresh_token: string }>; 135 | 136 | fetchPollForDeviceToken(deviceCode: string): Promise; 137 | 138 | fetchDeviceCode(): Promise; 139 | 140 | sendGumroadLicense(license: string): Promise; 141 | sendOneTimeCode(code: string): Promise; 142 | 143 | fetchGiveFeedback(details: FeedbackFormDetails): Promise; 144 | 145 | deleteRemarkableConnection(): Promise; 146 | } 147 | 148 | export interface ScrybblePersistentStorage { 149 | access_token: string | null; 150 | } 151 | 152 | export type ContextMenuItem = 153 | | { 154 | title: string; 155 | icon: string; 156 | disabled?: boolean; 157 | onClick?: () => void | Promise; 158 | isSeparator?: false; 159 | } 160 | | { 161 | isSeparator: true; 162 | }; 163 | 164 | export interface FileNavigator { 165 | openInNewTab(file: string): Promise; 166 | 167 | openInVerticalSplit(file: string): Promise; 168 | 169 | openInHorizontalSplit(file: string): Promise; 170 | 171 | getFileByPath(path: string): File | null; 172 | 173 | showContextMenu(event: MouseEvent, items: ContextMenuItem[]): void; 174 | } 175 | 176 | export type ScrybbleCommon = { 177 | api: ScrybbleApi; 178 | sync: ISyncQueue; 179 | settings: ScrybbleSettings; 180 | fileNavigator: FileNavigator; 181 | authentication: Authentication; 182 | meta: { 183 | scrybbleVersion: string; 184 | obsidianVersion: string; 185 | platformInfo: string; 186 | }; 187 | openFeedbackDialog: (syncFile: SyncFile, onSubmit: (details: FeedbackFormDetails) => Promise) => void; 188 | } 189 | 190 | export interface LicenseInformation { 191 | uses: number; 192 | order_number: string; 193 | sale_id: string; 194 | subscription_id: string; 195 | active: boolean; 196 | } 197 | 198 | export interface GumroadLicenseResponse { 199 | license: string; 200 | exists: boolean; 201 | lifetime: boolean; 202 | 203 | // only present when 'exists' is true 204 | licenseInformation?: LicenseInformation; 205 | } 206 | 207 | 208 | export type ScrybbleUser = { 209 | user: { 210 | created_at: string; 211 | email: string; 212 | name: string; 213 | id: number; 214 | }, 215 | onboarding_state: OnboardingState; 216 | subscription_status: GumroadLicenseResponse | null; 217 | total_syncs: number; 218 | } 219 | 220 | export interface DeviceCodeResponse { 221 | device_code: string; 222 | user_code: string; 223 | verification_uri: string; 224 | expires_in: number; 225 | interval: number; 226 | } 227 | 228 | export type DeviceFlowError = 229 | | 'authorization_pending' 230 | | 'slow_down' 231 | | 'access_denied' 232 | | 'expired_token'; 233 | 234 | export interface DeviceTokenErrorResponse { 235 | error: DeviceFlowError; 236 | error_description: string; 237 | } 238 | 239 | export type DeviceTokenSuccessResponse = { 240 | access_token: string; 241 | refresh_token: string; 242 | token_type: string; 243 | expires_in: number; 244 | }; 245 | export type DeviceTokenResponse = DeviceTokenSuccessResponse | DeviceTokenErrorResponse; 246 | -------------------------------------------------------------------------------- /src/errorHandling/Errors.ts: -------------------------------------------------------------------------------- 1 | import {html, TemplateResult} from "lit-html"; 2 | import {pino} from "./logging"; 3 | 4 | export interface ResponseError extends Error { 5 | status: number; 6 | message: string; 7 | } 8 | 9 | /** 10 | * @property title Say what happened 11 | * @property message Provide reassurance, say why it happened and give a suggestion to fix it. 12 | * @property helpAction Give a concrete way to reach step_definitions or report the error; give them a way out 13 | * @property details Internal property for logging purposes, not relevant for end-users 14 | */ 15 | export type ErrorMessage = { 16 | title: TemplateResult; 17 | message: TemplateResult; 18 | helpAction: TemplateResult; 19 | details?: Error; 20 | } 21 | 22 | type ErrorHandler = (e: Error | ResponseError | undefined) => ErrorMessage 23 | 24 | function formatError(e?: Error | ResponseError): TemplateResult { 25 | if (e && 'status' in e && e.status) { 26 | return html`: response status - ${e.status}`; 27 | } 28 | 29 | return html``; 30 | } 31 | const SUPPORT_EMAIL = "mail@scrybble.ink"; 32 | const PERSISTENT_PROBLEM_MESSAGE = `If the problem persists, contact ${SUPPORT_EMAIL} for support`; 33 | 34 | const errors = { 35 | "FILE_DOWNLOAD_ERROR": (e?: Error | ResponseError) => ({ 36 | title: html`Unable to download the file`, 37 | message: html`..`, 38 | helpAction: html`...` 39 | }), 40 | 41 | "COMPONENT_REGISTRATION_ERROR": (e?: Error) => ({ 42 | title: html`Was unable to initialize the Scrybble plugin`, 43 | message: html`...`, 44 | helpAction: html`...` 45 | }), 46 | 47 | "ICON_REGISTRATION_ERROR": (e?: Error) => ({ 48 | title: html`Was unable to define custom icons for the Scrybble plugin`, 49 | message: html`...`, 50 | helpAction: html`...` 51 | }), 52 | 53 | "ZIP_EXTRACT_ERROR": (e?: Error | ResponseError) => ({ 54 | title: html`Unable to extract the downloaded files`, 55 | message: html`Your file is likely corrupted.`, 56 | helpAction: html`Contact support for further guidance, ${SUPPORT_EMAIL}`, 57 | details: e 58 | }), 59 | 60 | "UNABLE_TO_CREATE_FOLDER": (e?: Error | ResponseError) => ({ 61 | title: html`Unable to create folder`, 62 | message: html`Does your folder contain unsupported characters?`, 63 | helpAction: html`Contact support for further guidance, ${SUPPORT_EMAIL}`, 64 | }), 65 | 66 | "TREE_LOADING_ERROR": (e?: Error | ResponseError) => ({ 67 | title: html`File loading error`, 68 | message: html`There's a problem loading your files${formatError(e)}`, 69 | helpAction: html`Please try refreshing in a minute or so. ${PERSISTENT_PROBLEM_MESSAGE}`, 70 | details: e 71 | }), 72 | 73 | "SEARCH_ERROR": (e?: Error | ResponseError) => ({ 74 | title: html`Search error`, 75 | message: html`There was a problem searching your files${formatError(e)}`, 76 | helpAction: html`Please try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 77 | details: e 78 | }), 79 | 80 | "SYNC_HISTORY_ERROR": (e?: Error | ResponseError) => ({ 81 | title: html`Sync history error`, 82 | message: html`There was a problem loading your sync history${formatError(e)}`, 83 | helpAction: html`Please try refreshing in a minute or so. ${PERSISTENT_PROBLEM_MESSAGE}`, 84 | details: e 85 | }), 86 | 87 | "AUTHENTICATION_CHECK_ERROR": (e?: Error | ResponseError) => ({ 88 | title: html`Authentication check failed`, 89 | message: html`We couldn't verify your login status${formatError(e)}`, 90 | helpAction: html`Please check your internet connection and try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 91 | details: e 92 | }), 93 | 94 | "OAUTH_INITIATION_ERROR": (e?: Error | ResponseError) => ({ 95 | title: html`Login failed to start`, 96 | message: html`We couldn't start the login process${formatError(e)}`, 97 | helpAction: html`Please check your internet connection and try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 98 | details: e 99 | }), 100 | 101 | "OAUTH_COMPLETION_ERROR": (e?: Error | ResponseError) => ({ 102 | title: html`Login completion failed`, 103 | message: html`Login was successful but we couldn't fetch your account information${formatError(e)}`, 104 | helpAction: html`Please try refreshing the page. ${PERSISTENT_PROBLEM_MESSAGE}`, 105 | details: e 106 | }), 107 | 108 | "USER_INFO_FETCH_ERROR": (e?: Error | ResponseError) => ({ 109 | title: html`Account information unavailable`, 110 | message: html`We couldn't load your account information${formatError(e)}`, 111 | helpAction: html`Please try refreshing the page or logging out and back in. ${PERSISTENT_PROBLEM_MESSAGE}`, 112 | details: e 113 | }), 114 | 115 | "DEVICE_AUTH_INITIATION_ERROR": (e?: Error | ResponseError) => ({ 116 | title: html`Device authorization failed`, 117 | message: html`We couldn't start the device authorization process${formatError(e)}`, 118 | helpAction: html`Please cancel and try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 119 | details: e 120 | }), 121 | 122 | "REQUEST_FILE_SYNC_ERROR": (e?: Error | ResponseError) => ({ 123 | title: html`Failed to request sync for a file`, 124 | message: html`There is likely a problem with the server.`, 125 | helpAction: html`Please try again in a minute or so. ${PERSISTENT_PROBLEM_MESSAGE}`, 126 | details: e 127 | }), 128 | 129 | "GENERAL_ERROR": (e?: Error | ResponseError) => ({ 130 | title: html`Something went wrong`, 131 | message: html`An unexpected error occurred${formatError(e)}`, 132 | helpAction: html`Please try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 133 | details: e 134 | }), 135 | 136 | "SERVER_CONNECTION_ERROR": (e?: Error | ResponseError) => ({ 137 | title: html`Connection error`, 138 | message: html`Could not connect to the Scrybble servers${formatError(e)}`, 139 | helpAction: html`Please try again later. ${PERSISTENT_PROBLEM_MESSAGE}`, 140 | details: e 141 | }), 142 | 143 | "RESET_CONNECTION_ERROR": (e?: Error | ResponseError) => ({ 144 | title: html`Failed to reset connection`, 145 | message: html`We couldn't reset your reMarkable connection${formatError(e)}`, 146 | helpAction: html`Please try again. ${PERSISTENT_PROBLEM_MESSAGE}`, 147 | details: e 148 | }) 149 | } satisfies Record; 150 | 151 | export class Errors { 152 | public static handle(error_name: keyof typeof errors, e: Error | ResponseError | undefined | null) { 153 | if (e) { 154 | pino.error({err: e, errorMessage: e?.message, errorStack: e?.stack, errorName: e?.constructor.name}, `Scrybble ${error_name} occurred.`) 155 | } else { 156 | e = new Error("No error specified by caller"); 157 | pino.error(`Scrybble ${error_name} occurred.`); 158 | } 159 | if (e && "message" in e && e.message.includes("ERR_CONNECTION_REFUSED")) { 160 | return errors["SERVER_CONNECTION_ERROR"](e) 161 | } 162 | return errors[error_name](e) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/ui/Components/RmFile.ts: -------------------------------------------------------------------------------- 1 | import {LitElement, nothing} from "lit-element"; 2 | import {html} from "lit-html"; 3 | import {getIcon, Notice} from "obsidian"; 4 | import {property, state} from "lit-element/decorators.js"; 5 | import {sanitizeFilename} from "../../support"; 6 | import {consume} from "@lit/context"; 7 | import {scrybbleContext} from "../scrybbleContext"; 8 | import {ScrybbleCommon, SyncFile} from "../../../@types/scrybble"; 9 | import {SyncJobStates} from "../../SyncJob"; 10 | import path from "path"; 11 | 12 | export class RmFile extends LitElement { 13 | @consume({context: scrybbleContext}) 14 | @property({type: Object, attribute: false}) 15 | scrybble!: ScrybbleCommon; 16 | 17 | @property({type: Object}) 18 | file!: SyncFile; 19 | 20 | @state() 21 | private currentlySyncing: boolean = false; 22 | 23 | @state() 24 | private syncOverride: SyncFile['sync'] | undefined = undefined; 25 | 26 | constructor() { 27 | super(); 28 | } 29 | 30 | get sync(): SyncFile['sync'] { 31 | if (this.syncOverride != null) { 32 | return this.syncOverride; 33 | } 34 | return this.file.sync; 35 | } 36 | 37 | connectedCallback() { 38 | super.connectedCallback(); 39 | this.scrybble.sync.subscribeToSyncStateChangesForFile(this.file.path, (newState: SyncJobStates, job) => { 40 | this.currentlySyncing = !(newState === SyncJobStates.downloaded || newState === SyncJobStates.failed_to_process); 41 | 42 | if (newState === SyncJobStates.downloaded) { 43 | this.syncOverride = { 44 | error: false, 45 | completed: true, 46 | created_at: "Just now", 47 | id: job.sync_id! 48 | }; 49 | } else if (newState === SyncJobStates.failed_to_process) { 50 | this.syncOverride = { 51 | error: true, 52 | completed: false, 53 | created_at: "Just now", 54 | id: job.sync_id! 55 | }; 56 | } 57 | 58 | this.requestUpdate(); 59 | }); 60 | } 61 | 62 | disconnectedCallback() { 63 | super.disconnectedCallback(); 64 | this.scrybble.sync.unsubscribeToSyncStateChangesForFile(this.file.path); 65 | } 66 | 67 | render() { 68 | let syncState: "file-check-2" | "file-clock" | "file" | "file-x-2"; 69 | if (this.currentlySyncing) { 70 | syncState = "file-clock"; 71 | } else if (this.sync?.error) { 72 | syncState = "file-x-2"; 73 | } else if (this.sync?.completed) { 74 | syncState = "file-check-2"; 75 | } else if (this.sync != null && !this.sync?.error && !this.sync?.completed) { 76 | syncState = "file-clock"; 77 | } else { 78 | syncState = "file"; 79 | } 80 | 81 | const {pdf, md} = this.findFile(); 82 | 83 | return html` 84 |
87 |
88 | ${this.currentlySyncing ? this.renderSpinner() : getIcon(syncState)} 89 | ${this.file.name} 90 |
91 |
92 |
93 |
94 | ${this.sync ? this.sync.created_at : "Not synced yet"} 95 | 96 | ${this.sync ? html` 97 | 98 | 99 | PDF 101 | MD 103 | ` : nothing} 104 | 105 |
106 |
107 | `; 108 | } 109 | 110 | protected createRenderRoot(): HTMLElement | DocumentFragment { 111 | return this 112 | } 113 | 114 | private openFeedbackDialog() { 115 | if (this.currentlySyncing) { 116 | new Notice("Not available during syncing, please wait until syncing is done."); 117 | return; 118 | } 119 | this.scrybble.openFeedbackDialog(this.file, 120 | async (details) => { 121 | await this.scrybble.api.fetchGiveFeedback(details); 122 | }); 123 | } 124 | 125 | private clickMd(e: MouseEvent) { 126 | const {md, mdPath} = this.findFile() 127 | 128 | if (md) { 129 | const fileNavigator = this.scrybble.fileNavigator 130 | 131 | fileNavigator.showContextMenu(e, [ 132 | { 133 | title: "Open Markdown file in new tab", 134 | icon: "file-plus", 135 | onClick: () => fileNavigator.openInNewTab(mdPath) 136 | }, 137 | { 138 | title: "Open Markdown file in vertical split view", 139 | icon: "separator-vertical", 140 | onClick: () => fileNavigator.openInVerticalSplit(mdPath) 141 | }, 142 | { 143 | title: "Open Markdown file in horizontal split view", 144 | icon: "separator-horizontal", 145 | onClick: () => fileNavigator.openInHorizontalSplit(mdPath) 146 | }]); 147 | } 148 | } 149 | 150 | private clickPdf(e: MouseEvent) { 151 | const {pdf, pdfPath} = this.findFile() 152 | 153 | if (pdf) { 154 | const fileNavigator = this.scrybble.fileNavigator 155 | 156 | fileNavigator.showContextMenu(e, [ 157 | { 158 | title: "Open PDF file in new tab", 159 | icon: "file-plus", 160 | onClick: () => fileNavigator.openInNewTab(pdfPath) 161 | }, 162 | { 163 | title: "Open PDF file in vertical split view", 164 | icon: "separator-vertical", 165 | onClick: () => fileNavigator.openInVerticalSplit(pdfPath) 166 | }, 167 | { 168 | title: "Open PDF file in horizontal split view", 169 | icon: "separator-horizontal", 170 | onClick: () => fileNavigator.openInHorizontalSplit(pdfPath) 171 | }]); 172 | } 173 | } 174 | 175 | private _handleClick() { 176 | this.dispatchEvent(new CustomEvent('rm-click', { 177 | detail: {name: this.file.name, path: this.file.path, type: 'f'}, 178 | bubbles: true, 179 | composed: true 180 | })); 181 | } 182 | 183 | private findFile() { 184 | // The path to the file is constructed from: 185 | // 1. The configured root folder from settings 186 | // 2. The sanitized filename 187 | // 3. Adding .pdf/.md extension 188 | const sanitizedName = sanitizeFilename(this.file.path.substring(1, this.file.path.length), true); 189 | 190 | let rootFolder = this.scrybble.settings.sync_folder; 191 | if (path.isAbsolute(rootFolder)) { 192 | rootFolder = rootFolder.replace(/^\//, ""); 193 | } 194 | 195 | const pdfPath = path.join(rootFolder, `${sanitizedName}.pdf`); 196 | const mdPath = path.join(rootFolder, `${sanitizedName}.md`); 197 | 198 | const pdf = this.scrybble.fileNavigator.getFileByPath(pdfPath); 199 | const md = this.scrybble.fileNavigator.getFileByPath(mdPath); 200 | 201 | return { 202 | pdfPath, 203 | mdPath, 204 | pdf, 205 | md 206 | }; 207 | } 208 | 209 | private renderSpinner() { 210 | return html`${getIcon('loader-circle')}`; 211 | } 212 | } 213 | 214 | -------------------------------------------------------------------------------- /features/support/MockScrybbleApi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthenticateWithGumroadLicenseResponse, 3 | DeviceCodeResponse, 4 | DeviceFlowError, 5 | DeviceTokenResponse, 6 | FeedbackFormDetails, 7 | OneTimeCodeResponse, 8 | ResetConnectionResponse, 9 | RMFileTree, 10 | RMTreeItem, 11 | ScrybbleApi, 12 | ScrybbleSettings, 13 | ScrybbleUser, 14 | SearchFilters, 15 | SearchResult, 16 | SyncDelta, 17 | SyncFile 18 | } from "../../@types/scrybble"; 19 | 20 | export class MockScrybbleApi implements ScrybbleApi { 21 | private loggedIn: boolean = false; 22 | private accessTokenExpired: boolean = false; 23 | 24 | private errors: Record = {}; 25 | private serverReachable: boolean = true; 26 | 27 | private pollingState: DeviceFlowError | "authenticated" = "authorization_pending"; 28 | 29 | private currentDirectory = "/"; 30 | private directories: string[][] = [["/"]]; 31 | private files: RMTreeItem[] = []; 32 | 33 | private syncHistory: SyncFile[] = []; 34 | private _id = 0; 35 | 36 | constructor(private settings: ScrybbleSettings) { 37 | } 38 | 39 | public accessTokenIsExpired() { 40 | this.accessTokenExpired = true; 41 | } 42 | 43 | public accessTokenIsValid() { 44 | this.accessTokenExpired = false; 45 | } 46 | 47 | public isNotLoggedIn() { 48 | this.loggedIn = false; 49 | } 50 | 51 | public isLoggedIn() { 52 | this.loggedIn = true; 53 | } 54 | 55 | public requestWillFailWithStatusCode(requestName: string, statusCode: number) { 56 | this.errors[requestName] = statusCode; 57 | } 58 | 59 | public serverIsUnreachable() { 60 | this.serverReachable = false; 61 | } 62 | 63 | serverIsReachable() { 64 | this.serverReachable = true; 65 | } 66 | 67 | public requestGoesAsNormal(requestName: string) { 68 | delete this.errors[requestName]; 69 | } 70 | 71 | async fetchFileTree(path: string): Promise { 72 | this.throwIfErrorIsConfigured("fetchFileTree"); 73 | const directories: RMTreeItem[] = this.directories.map((breadcrumbs) => ({ 74 | name: breadcrumbs.join("/"), 75 | path: breadcrumbs.join("/"), 76 | type: "d" 77 | })) 78 | const files = this.files.filter((file) => file.path === this.currentDirectory).map((file) => { 79 | const f = this.syncHistory.find((s) => s.name.endsWith(file.name)) ?? file; 80 | const parts = f.name.split("/"); 81 | return { 82 | ...f, 83 | name: parts[parts.length - 1], 84 | } 85 | }); 86 | const tree: RMFileTree = { 87 | // TODO: directories need special logic 88 | // TODO: files need to be filtered based on current directory 89 | items: files, 90 | cwd: this.currentDirectory 91 | } 92 | return Promise.resolve(tree); 93 | } 94 | 95 | addFile(file: RMTreeItem) { 96 | this.files.push(file); 97 | } 98 | 99 | fetchOnboardingState(): Promise<"unauthenticated" | "setup-gumroad" | "setup-one-time-code" | "setup-one-time-code-again" | "ready"> { 100 | this.throwIfErrorIsConfigured("fetchOnboardingState"); 101 | return Promise.resolve("ready"); 102 | } 103 | 104 | fetchRequestFileToBeSynced(filePath: string): Promise<{ sync_id: number; filename: string }> { 105 | this.throwIfErrorIsConfigured("fetchRequestFileToBeSynced"); 106 | return Promise.resolve({filename: "", sync_id: 0}); 107 | } 108 | 109 | fetchSyncDelta(): Promise> { 110 | this.throwIfErrorIsConfigured("fetchSyncDelta"); 111 | return Promise.resolve([]); 112 | } 113 | 114 | fetchSyncState(sync_id: number): Promise { 115 | this.throwIfErrorIsConfigured("fetchSyncState"); 116 | return Promise.resolve(undefined); 117 | } 118 | 119 | fetchGetUser(): Promise { 120 | this.throwIfErrorIsConfigured("fetchGetUser"); 121 | if (this.accessTokenExpired) { 122 | const err = new Error("Token expired"); 123 | // @ts-ignore 124 | err.status = 401; 125 | return Promise.reject(err); 126 | } 127 | if (!this.loggedIn) { 128 | const err = new Error("Not authenticated"); 129 | // @ts-ignore 130 | err.status = 401; 131 | return Promise.reject(err); 132 | } 133 | return Promise.resolve( 134 | { 135 | user: { 136 | name: "Test user", 137 | email: "test@scrybble.local", 138 | id: 1, 139 | created_at: "2025-05-05" 140 | }, 141 | onboarding_state: "ready", 142 | subscription_status: { 143 | exists: true, 144 | licenseInformation: { 145 | subscription_id: "8yFSEPV-yKKLQwC2jJQ68w==", 146 | active: true, 147 | order_number: "abc", 148 | sale_id: "def", 149 | uses: 0 150 | }, 151 | lifetime: true, 152 | license: "liceeeense" 153 | }, 154 | total_syncs: 32 155 | } 156 | ); 157 | } 158 | 159 | fetchRefreshOAuthAccessToken(): Promise<{ access_token: string; refresh_token: string }> { 160 | this.throwIfErrorIsConfigured("fetchRefreshOAuthAccessToken"); 161 | this.settings.access_token = "new_test_access_token"; 162 | this.settings.refresh_token = "new_test_refresh_token"; 163 | this.accessTokenExpired = false; 164 | this.loggedIn = true; 165 | return Promise.resolve({access_token: "new_test_access_token", refresh_token: "new_test_refresh_token"}); 166 | } 167 | 168 | async fetchDeviceCode(): Promise { 169 | this.throwIfErrorIsConfigured("fetchDeviceCode"); 170 | return { 171 | device_code: "test_device_code", 172 | user_code: "test_user_code", 173 | expires_in: 60 * 10, 174 | interval: 1, 175 | verification_uri: "test_verification_uri" 176 | } 177 | } 178 | 179 | async fetchPollForDeviceToken(deviceCode: string): Promise { 180 | this.throwIfErrorIsConfigured("fetchPollForDeviceToken"); 181 | if (this.pollingState !== "authenticated") { 182 | return { 183 | error: this.pollingState, 184 | error_description: this.pollingState 185 | }; 186 | } else { 187 | return { 188 | access_token: "test_access_token", 189 | refresh_token: "test_refresh_token", 190 | expires_in: 1000, 191 | token_type: "bearer" 192 | }; 193 | } 194 | } 195 | 196 | authorizeDeviceToken() { 197 | this.pollingState = "authenticated"; 198 | } 199 | 200 | async sendGumroadLicense(license: string): Promise { 201 | return { 202 | newState: "ready" 203 | }; 204 | } 205 | 206 | async sendOneTimeCode(code: string): Promise { 207 | return { 208 | newState: "ready" 209 | }; 210 | } 211 | 212 | fetchGiveFeedback(details: FeedbackFormDetails): Promise { 213 | return Promise.resolve(); 214 | } 215 | 216 | async deleteRemarkableConnection(): Promise { 217 | this.throwIfErrorIsConfigured("deleteRemarkableConnection"); 218 | return { 219 | success: true, 220 | newState: "setup-one-time-code" 221 | }; 222 | } 223 | 224 | async fetchSearchFiles(filters: SearchFilters): Promise { 225 | this.throwIfErrorIsConfigured("fetchSearchFiles"); 226 | return { items: [] }; 227 | } 228 | 229 | add_synced_file(filename: string, created_at: string) { 230 | this.syncHistory.push({ 231 | name: filename, 232 | path: filename, 233 | type: "f", 234 | sync: { 235 | created_at, 236 | id: this._id, 237 | completed: true, 238 | error: false 239 | } 240 | }) 241 | this._id += 1; 242 | } 243 | 244 | private throwIfErrorIsConfigured(requestName: string) { 245 | if (!this.serverReachable) { 246 | throw new Error("ERR_CONNECTION_REFUSED"); 247 | } 248 | if (requestName in this.errors) { 249 | throw new Error(`Request failed, status ${this.errors[requestName]}`); 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/SyncQueue.ts: -------------------------------------------------------------------------------- 1 | import {SyncJob, SyncJobStates} from "./SyncJob"; 2 | import {Errors, ResponseError} from "./errorHandling/Errors"; 3 | import {basename, dirPath, sanitizeFilename} from "./support"; 4 | import {App, requestUrl, TFile, Vault} from "obsidian"; 5 | import { unzip } from "fflate"; 6 | import {ScrybbleApi, ScrybbleSettings} from "../@types/scrybble"; 7 | import path from "path"; 8 | import {pino} from "./errorHandling/logging"; 9 | 10 | export interface ISyncQueue { 11 | requestSync(filename: string): void; 12 | 13 | subscribeToSyncStateChangesForFile(path: string, callback: (newState: SyncJobStates, job: SyncJob) => void): void; 14 | 15 | unsubscribeToSyncStateChangesForFile(path: string): void; 16 | } 17 | 18 | export class SyncQueue implements ISyncQueue { 19 | private syncJobs: SyncJob[] = []; 20 | 21 | private readonly busyStates = [SyncJobStates.downloading, SyncJobStates.awaiting_processing]; 22 | private syncJobStateChangeListeners: Map void)[]> = new Map(); 23 | 24 | constructor( 25 | private settings: ScrybbleSettings, 26 | private vault: Vault, 27 | private api: ScrybbleApi, 28 | private onStartDownloadFile: (job: SyncJob) => void, 29 | private onFinishedDownloadFile: (job: SyncJob, success: boolean, error?: Error | ResponseError) => void, 30 | ) { 31 | setInterval(async () => { 32 | const maxActiveJobs = 3 33 | let busy = this.countBusyJobs(); 34 | for (let job of this.syncJobs) { 35 | if (busy < maxActiveJobs) { 36 | if (job.getState() === SyncJobStates.init) { 37 | await this.requestFileToBeSynced(job) 38 | busy += 1 39 | } else if (job.getState() === SyncJobStates.ready_to_download) { 40 | const file = await this.download(job) 41 | if (file) { 42 | await this.writeDownloadedZip(job, file) 43 | } 44 | busy += 1 45 | } else if (job.getState() === SyncJobStates.processing) { 46 | await this.checkProcessingState(job) 47 | busy += 1 48 | } 49 | } 50 | } 51 | }, 2000) 52 | } 53 | 54 | syncjobStateChangeListener(path: string, newState: SyncJobStates, job: SyncJob) { 55 | if (this.syncJobStateChangeListeners.has(path)) { 56 | const listeners = this.syncJobStateChangeListeners.get(path) ?? []; 57 | for (let listener of listeners) { 58 | listener(newState, job); 59 | } 60 | } 61 | } 62 | 63 | subscribeToSyncStateChangesForFile(path: string, callback: (newState: SyncJobStates, job: SyncJob) => void): void { 64 | if (this.syncJobStateChangeListeners.has(path)) { 65 | this.syncJobStateChangeListeners.get(path)!.push(callback); 66 | } else { 67 | this.syncJobStateChangeListeners.set(path, [callback]); 68 | } 69 | } 70 | 71 | unsubscribeToSyncStateChangesForFile(path: string) { 72 | this.syncJobStateChangeListeners.delete(path); 73 | } 74 | 75 | async downloadProcessedFile(filename: string, download_url: string, sync_id: number) { 76 | pino.info(`Creating sync job for file '${filename}' from the sync delta`) 77 | const syncJob = new SyncJob(0, SyncJobStates.init, this.syncjobStateChangeListener.bind(this), filename); 78 | pino.info(`Sync job for file '${filename}' from sync delta is successfully created, will now be queued`) 79 | await syncJob.readyToDownload(download_url, sync_id) 80 | this.syncJobs.push(syncJob) 81 | } 82 | 83 | requestSync(filename: string) { 84 | pino.info(`Creating sync job for file '${filename}' requested by the user`) 85 | const job = new SyncJob(0, SyncJobStates.init, this.syncjobStateChangeListener.bind(this), filename) 86 | pino.info(`Sync job for file '${filename}' requested by the user is successfully created, will now be queued`) 87 | this.syncJobs.push(job) 88 | } 89 | 90 | private countBusyJobs(): number { 91 | return this.syncJobs.filter((job: SyncJob) => this.busyStates.contains(job.getState())).length 92 | } 93 | 94 | public async download(job: SyncJob): Promise { 95 | try { 96 | this.onStartDownloadFile(job) 97 | await job.startDownload() 98 | return await requestUrl({ 99 | method: "GET", 100 | url: job.download_url! 101 | }).arrayBuffer 102 | } catch (e) { 103 | this.onFinishedDownloadFile(job, false, e as Error) 104 | Errors.handle("FILE_DOWNLOAD_ERROR", e as Error) 105 | await job.downloadingFailed(); 106 | } 107 | } 108 | 109 | private async writeDownloadedZip(job: SyncJob, file: ArrayBuffer) { 110 | let relativePath = dirPath(job.filename) 111 | let nameOfFile = sanitizeFilename(basename(job.filename)) 112 | const folderPath = await this.ensureFolderExists(this.vault, relativePath, this.settings.sync_folder) 113 | const out_path = 114 | path.join(folderPath, nameOfFile); 115 | 116 | try { 117 | const zipData = new Uint8Array(file); 118 | 119 | // Unzip the file using fflate 120 | const unzippedFiles = await new Promise>((resolve, reject) => { 121 | unzip(zipData, (err, unzipped) => { 122 | if (err) reject(err); 123 | else resolve(unzipped); 124 | }); 125 | }); 126 | 127 | await this.extractFileFromZip(this.vault, unzippedFiles, /_remarks(-only)?.pdf/, `${out_path}.pdf`) 128 | await this.extractFileFromZip(this.vault, unzippedFiles, /_obsidian.md/, `${out_path}.md`, false) 129 | await job.downloaded() 130 | this.onFinishedDownloadFile(job, true) 131 | } catch(e) { 132 | this.onFinishedDownloadFile(job, false, e as Error) 133 | Errors.handle("ZIP_EXTRACT_ERROR", e as Error) 134 | await job.downloadingFailed(); 135 | return; 136 | } 137 | } 138 | 139 | private async writeToFile(vault: App["vault"], filePath: string, data: ArrayBuffer) { 140 | const file = vault.getAbstractFileByPath(filePath) 141 | if (file === null) { 142 | try { 143 | await vault.createBinary(filePath, data) 144 | } catch { 145 | throw new Error(`Scrybble: Was unable to write file ${filePath}, reference = 104`) 146 | } 147 | } else if (file instanceof TFile) { 148 | try { 149 | await vault.modifyBinary(file, data) 150 | } catch { 151 | throw new Error(`Scrybble: Was unable to modify file ${filePath}, reference = 105`) 152 | } 153 | } else { 154 | throw new Error("Scrybble: Unknown error reference = 103") 155 | } 156 | } 157 | 158 | private async ensureFolderExists(vault: App["vault"], relativePath: string, sync_folder: string) { 159 | let folderPath = relativePath.startsWith("/") ? `${sync_folder}${relativePath}` : `${sync_folder}/${relativePath}` 160 | folderPath = folderPath.split("/").map((folderName) => sanitizeFilename(folderName, true)).join("/") 161 | try { 162 | await vault.createFolder(folderPath) 163 | } catch (e) { 164 | if (e instanceof Error && !e.message.includes("already exists")) { 165 | Errors.handle("UNABLE_TO_CREATE_FOLDER", e); 166 | } 167 | } 168 | 169 | return folderPath 170 | } 171 | 172 | private async extractFileFromZip(vault: App["vault"], unzippedFiles: Record, nameMatch: RegExp, vaultFileName: string, required = true) { 173 | // Find matching file in the unzipped files 174 | const matchingFile = Object.keys(unzippedFiles).find(filename => nameMatch.test(filename)); 175 | 176 | if (!matchingFile) { 177 | if (required) { 178 | throw new Error("Scrybble: Missing file in downloaded sync zip, reference = 106") 179 | } 180 | return 181 | } 182 | 183 | const data = unzippedFiles[matchingFile].buffer as ArrayBuffer; 184 | try { 185 | await this.writeToFile(vault, vaultFileName, data) 186 | } catch (e) { 187 | throw new Error(`Scrybble: Failed to place file "${vaultFileName}" in the right location, reference = 107`); 188 | } 189 | } 190 | 191 | private async requestFileToBeSynced(job: SyncJob) { 192 | try { 193 | await job.syncRequestSent() 194 | const response = await this.api.fetchRequestFileToBeSynced(job.filename) 195 | await job.syncRequestConfirmed(response.sync_id) 196 | } catch (e) { 197 | // if it's a 400, assume the sync job is not posted. 198 | // if it's a timeout or connection error, we don't know what happened. 199 | } 200 | } 201 | 202 | private async checkProcessingState(job: SyncJob) { 203 | await job.sentProcessingRequest() 204 | const state = await this.api.fetchSyncState(job.sync_id!) 205 | if (state.completed) { 206 | await job.readyToDownload(state.download_url, state.id) 207 | } else if (state.error) { 208 | await job.processingFailed() 209 | } else { 210 | await job.fileStillProcessing() 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/ui/Pages/OnboardingPage.ts: -------------------------------------------------------------------------------- 1 | import {html, nothing, TemplateResult} from "lit-html"; 2 | import {LitElement} from "lit-element"; 3 | import {property, state} from "lit-element/decorators.js"; 4 | import {OnboardingState, ScrybbleCommon} from "../../../@types/scrybble"; 5 | import {consume} from "@lit/context"; 6 | import {scrybbleContext} from "../scrybbleContext"; 7 | import {ErrorMessage, Errors} from "../../errorHandling/Errors"; 8 | 9 | export class ScrybbleOnboarding extends LitElement { 10 | @consume({context: scrybbleContext}) 11 | @property({type: Object, attribute: false}) 12 | scrybble!: ScrybbleCommon; 13 | 14 | @property({type: String}) 15 | onboardingReady!: () => {}; 16 | 17 | @state() 18 | private error: ErrorMessage | null = null; 19 | 20 | @state() 21 | private isLoading: boolean = false; 22 | 23 | @state() 24 | private license: string = ''; 25 | 26 | @state() 27 | private oneTimeCode: string = ''; 28 | 29 | @state() 30 | private feedback: string = ''; 31 | 32 | get onboardingState() { 33 | return this.scrybble.authentication.user!.onboarding_state; 34 | } 35 | 36 | set onboardingState(state: OnboardingState) { 37 | this.scrybble.authentication.user!.onboarding_state = state; 38 | } 39 | 40 | resetFeedbackAndErrors() { 41 | if (this.error) { 42 | this.error = null; 43 | } 44 | if (this.feedback) { 45 | this.feedback = ''; 46 | } 47 | } 48 | 49 | render(): TemplateResult | typeof nothing { 50 | if (!this.onboardingState) { 51 | return html` 52 |
Loading onboarding...
`; 53 | } 54 | 55 | let view: TemplateResult; 56 | 57 | switch (this.onboardingState) { 58 | case "setup-gumroad": 59 | view = this.renderGumroadLicense(); 60 | break; 61 | 62 | case "setup-one-time-code": 63 | view = this.renderOneTimeCode(true); 64 | break; 65 | 66 | case "setup-one-time-code-again": 67 | view = this.renderOneTimeCode(false); 68 | break; 69 | 70 | case "ready": 71 | view = this.renderReadyState(); 72 | break; 73 | 74 | default: 75 | view = html` 76 |
Unknown onboarding state: ${this.onboardingState}
`; 77 | } 78 | 79 | return html` 80 | ` 91 | } 92 | 93 | protected createRenderRoot(): HTMLElement | DocumentFragment { 94 | return this; 95 | } 96 | 97 | private async handleStateChange(): Promise { 98 | try { 99 | this.onboardingState = await this.scrybble.api.fetchOnboardingState() 100 | if (this.onboardingState === "ready") { 101 | this.onboardingReady(); 102 | } 103 | } catch (e) { 104 | this.error = Errors.handle("GENERAL_ERROR", e as Error); 105 | } 106 | } 107 | 108 | private async handleLicenseSubmit(e: Event): Promise { 109 | e.preventDefault(); 110 | 111 | if (!this.license.trim()) { 112 | this.feedback = "Please enter your license key"; 113 | return; 114 | } 115 | 116 | this.isLoading = true; 117 | this.error = null; 118 | 119 | try { 120 | const response = await this.scrybble.api.sendGumroadLicense(this.license.trim()); 121 | if ("error" in response) { 122 | if (response.error.includes("not found")) { 123 | this.feedback = response.error; 124 | } else { 125 | this.error = Errors.handle("GENERAL_ERROR", new Error(response.error)); 126 | } 127 | } else { 128 | // Success - response contains newState 129 | this.onboardingState = response.newState; 130 | await this.handleStateChange(); 131 | } 132 | } catch (e) { 133 | this.error = Errors.handle("GENERAL_ERROR", new Error("Failed to submit license")); 134 | } finally { 135 | this.isLoading = false; 136 | } 137 | } 138 | 139 | private async handleOneTimeCodeSubmit(e: Event): Promise { 140 | e.preventDefault(); 141 | 142 | if (!this.oneTimeCode.trim()) { 143 | this.feedback = "Please enter your one-time code"; 144 | return; 145 | } 146 | 147 | if (this.oneTimeCode.length !== 8) { 148 | this.feedback = "Code must be exactly 8 characters"; 149 | return; 150 | } 151 | 152 | if (!/^[a-z]{8}$/.test(this.oneTimeCode)) { 153 | this.feedback = "Code must contain only lowercase letters"; 154 | return; 155 | } 156 | 157 | this.isLoading = true; 158 | this.resetFeedbackAndErrors(); 159 | 160 | try { 161 | const response = await this.scrybble.api.sendOneTimeCode(this.oneTimeCode.trim()); 162 | 163 | if ('error' in response) { 164 | this.error = Errors.handle("GENERAL_ERROR", new Error(response.error)); 165 | } else { 166 | this.onboardingState = response.newState; 167 | await this.handleStateChange(); 168 | } 169 | } catch (e) { 170 | this.error = Errors.handle("GENERAL_ERROR", new Error(e instanceof Error ? e.message : "Failed to submit code")); 171 | } finally { 172 | this.isLoading = false; 173 | } 174 | } 175 | 176 | private handleLicenseInputChange(e: Event): void { 177 | const target = e.target as HTMLInputElement; 178 | this.license = target.value; 179 | this.resetFeedbackAndErrors() 180 | } 181 | 182 | private handleCodeInputChange(e: Event): void { 183 | const target = e.target as HTMLInputElement; 184 | this.oneTimeCode = target.value.toLowerCase(); 185 | this.resetFeedbackAndErrors() 186 | } 187 | 188 | private renderGumroadLicense(): TemplateResult { 189 | return html` 190 | 232 | `; 233 | } 234 | 235 | private renderOneTimeCode(firstTime: boolean): TemplateResult { 236 | return html` 237 | 299 | `; 300 | } 301 | 302 | private renderReadyState(): TemplateResult { 303 | return html` 304 | 324 | `; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import {App, Modal, Plugin, requestUrl, Setting, WorkspaceLeaf} from 'obsidian'; 2 | 3 | class InputModal extends Modal { 4 | private result: string = ""; 5 | private readonly title: string; 6 | private readonly placeholder: string; 7 | private readonly onSubmit: (result: string | null) => void; 8 | 9 | constructor(app: App, title: string, placeholder: string, onSubmit: (result: string | null) => void) { 10 | super(app); 11 | this.title = title; 12 | this.placeholder = placeholder; 13 | this.onSubmit = onSubmit; 14 | } 15 | 16 | onOpen() { 17 | const {contentEl} = this; 18 | 19 | contentEl.createEl("h3", {text: this.title}); 20 | 21 | new Setting(contentEl) 22 | .setName("Value") 23 | .addText((text) => 24 | text 25 | .setPlaceholder(this.placeholder) 26 | .onChange((value) => { 27 | this.result = value; 28 | }) 29 | ); 30 | 31 | new Setting(contentEl) 32 | .addButton((btn) => 33 | btn 34 | .setButtonText("Search") 35 | .setCta() 36 | .onClick(() => { 37 | this.close(); 38 | this.onSubmit(this.result || null); 39 | }) 40 | ) 41 | .addButton((btn) => 42 | btn 43 | .setButtonText("Cancel") 44 | .onClick(() => { 45 | this.close(); 46 | this.onSubmit(null); 47 | }) 48 | ); 49 | } 50 | 51 | onClose() { 52 | const {contentEl} = this; 53 | contentEl.empty(); 54 | } 55 | } 56 | import { 57 | AuthenticateWithGumroadLicenseResponse, 58 | DeviceCodeResponse, 59 | DeviceTokenResponse, 60 | FeedbackFormDetails, 61 | OneTimeCodeResponse, 62 | ResetConnectionResponse, 63 | RMFileTree, 64 | ScrybbleApi, 65 | ScrybblePersistentStorage, 66 | ScrybbleSettings, 67 | ScrybbleUser, 68 | SearchFilters, 69 | SearchResult, 70 | SyncDelta, 71 | } from "./@types/scrybble"; 72 | import {Settings} from "./src/settings"; 73 | import {SCRYBBLE_VIEW, ScrybbleView} from "./src/ScrybbleView"; 74 | import loadLitComponents from "./src/ui/loadComponents"; 75 | import {SyncQueue} from "./src/SyncQueue"; 76 | import {Authentication} from "./src/Authentication"; 77 | import {SettingsImpl} from "./src/SettingsImpl"; 78 | import {pino} from "./src/errorHandling/logging"; 79 | 80 | export default class Scrybble extends Plugin implements ScrybbleApi, ScrybblePersistentStorage { 81 | // @ts-expect-error TS2564 -- onload acts as a constructor. 82 | public settings: ScrybbleSettings; 83 | // @ts-expect-error TS2564 -- onload acts as a constructor. 84 | public syncQueue: SyncQueue; 85 | // @ts-expect-error TS2564 -- onload acts as a constructor. 86 | public authentication: Authentication; 87 | 88 | get access_token(): string | null { 89 | return this.settings.access_token ?? null; 90 | } 91 | 92 | get refresh_token(): string | null { 93 | return this.settings.refresh_token ?? null; 94 | } 95 | 96 | async onload() { 97 | pino.info("Loading Scrybble plugin") 98 | 99 | // only needs to happen once, ever. 100 | pino.info("Loading lit components") 101 | loadLitComponents() 102 | 103 | this.settings = new SettingsImpl(await this.loadData(), async () => { 104 | await this.saveData(this.settings); 105 | }); 106 | this.authentication = new Authentication(this.settings, this); 107 | 108 | this.syncQueue = new SyncQueue( 109 | this.settings, 110 | this.app.vault, 111 | this, 112 | function onStartDownloadFile(job) { 113 | }, 114 | (job) => { 115 | this.settings.sync_state[job.filename] = job.sync_id! 116 | this.settings.save() 117 | } 118 | ); 119 | 120 | this.addSettingTab(new Settings(this.app, this)); 121 | this.registerView(SCRYBBLE_VIEW, 122 | (leaf) => { 123 | 124 | return new ScrybbleView(leaf, this); 125 | }) 126 | 127 | const syncHistory = this.addStatusBarItem(); 128 | syncHistory.addClass("mod-clickable"); 129 | syncHistory.setText("Scrybble"); 130 | syncHistory.onClickEvent(this.showScrybbleFiletree.bind(this)); 131 | 132 | this.addCommand({ 133 | id: "open-scrybble-pane", 134 | name: "Browse your reMarkable files", 135 | callback: this.showScrybbleFiletree.bind(this) 136 | }) 137 | 138 | this.addCommand({ 139 | id: "search-by-name", 140 | name: "Search files by name", 141 | callback: async () => { 142 | const query = await this.promptForInput("Enter name pattern (regex)", "e.g. .*meeting.*"); 143 | if (query) { 144 | await this.openWithSearchFilters({query}); 145 | } 146 | } 147 | }) 148 | 149 | this.addCommand({ 150 | id: "search-by-tag", 151 | name: "Search files by tag", 152 | callback: async () => { 153 | const tag = await this.promptForInput("Enter tag name", "e.g. Work"); 154 | if (tag) { 155 | await this.openWithSearchFilters({tags: [tag]}); 156 | } 157 | } 158 | }) 159 | 160 | this.addCommand({ 161 | id: "show-starred-files", 162 | name: "Show starred files", 163 | callback: async () => { 164 | await this.openWithSearchFilters({starred: true}); 165 | } 166 | }) 167 | 168 | this.app.workspace.onLayoutReady(this.checkAccountStatus.bind(this)); 169 | } 170 | 171 | async showScrybbleFiletree(): Promise { 172 | const {workspace} = this.app; 173 | 174 | let leaf: WorkspaceLeaf | null = null; 175 | const leaves = workspace.getLeavesOfType(SCRYBBLE_VIEW); 176 | 177 | if (leaves.length > 0) { 178 | // A leaf with our view already exists, use that 179 | leaf = leaves[0]; 180 | } else { 181 | // Our view could not be found in the workspace, create a new leaf 182 | // in the right sidebar for it 183 | leaf = workspace.getRightLeaf(false); 184 | await leaf?.setViewState({type: SCRYBBLE_VIEW, active: true}); 185 | } 186 | 187 | // "Reveal" the leaf in case it is in a collapsed sidebar 188 | if (leaf instanceof WorkspaceLeaf) { 189 | await workspace.revealLeaf(leaf); 190 | } 191 | 192 | return leaf; 193 | } 194 | 195 | private async promptForInput(title: string, placeholder: string): Promise { 196 | return new Promise((resolve) => { 197 | const modal = new InputModal(this.app, title, placeholder, resolve); 198 | modal.open(); 199 | }); 200 | } 201 | 202 | private async openWithSearchFilters(filters: SearchFilters): Promise { 203 | const leaf = await this.showScrybbleFiletree(); 204 | if (!leaf) return; 205 | 206 | // Small delay to ensure the view is rendered 207 | setTimeout(() => { 208 | const fileTreeComponent = leaf.view.containerEl.querySelector('sc-file-tree') as any; 209 | if (fileTreeComponent && typeof fileTreeComponent.setSearchFilters === 'function') { 210 | fileTreeComponent.setSearchFilters(filters); 211 | } 212 | }, 100); 213 | } 214 | 215 | async authenticatedRequest(url: string, options: any = {}) { 216 | return requestUrl({ 217 | ...options, 218 | url, 219 | headers: { 220 | ...options.headers, 221 | "Authorization": `Bearer ${this.access_token}` 222 | } 223 | }); 224 | } 225 | 226 | async sync() { 227 | const latestSyncState = await this.fetchSyncDelta() 228 | const settings = this.settings 229 | 230 | for (const {filename, id, download_url} of latestSyncState) { 231 | // there is an update to a file iff 232 | // 1. it is not in the sync state OR 233 | // 2. the id remote is higher than the id locally 234 | const file_not_synced_locally = !(filename in settings.sync_state); 235 | const file_has_update = settings.sync_state[filename] < id; 236 | if (file_not_synced_locally || file_has_update) { 237 | await this.syncQueue.downloadProcessedFile(filename, download_url, id) 238 | } 239 | } 240 | } 241 | 242 | async fetchSyncDelta(): Promise> { 243 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/delta`, { 244 | method: "GET", 245 | headers: { 246 | "Accept": "application/json", 247 | } 248 | }) 249 | return response.json 250 | } 251 | 252 | async fetchFileTree(path: string = "/"): Promise { 253 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/RMFileTree`, { 254 | method: "POST", 255 | headers: { 256 | "Accept": "application/json", 257 | "Content-Type": "application/json" 258 | }, 259 | body: JSON.stringify({path}) 260 | }) 261 | return response.json 262 | } 263 | 264 | async fetchSearchFiles(filters: SearchFilters): Promise { 265 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/search`, { 266 | method: "POST", 267 | headers: { 268 | "Accept": "application/json", 269 | "Content-Type": "application/json" 270 | }, 271 | body: JSON.stringify(filters) 272 | }) 273 | return response.json 274 | } 275 | 276 | async fetchSyncState(sync_id: number) { 277 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/status`, { 278 | method: "POST", 279 | headers: { 280 | "Content-Type": "application/json", 281 | "accept": "application/json", 282 | }, 283 | body: JSON.stringify({sync_id}) 284 | }); 285 | 286 | return response.json 287 | } 288 | 289 | async fetchRequestFileToBeSynced(filePath: string): Promise<{ sync_id: number; filename: string; }> { 290 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/file`, { 291 | method: "POST", 292 | headers: { 293 | "Content-Type": "application/json", 294 | "accept": "application/json", 295 | }, 296 | body: JSON.stringify({ 297 | file: filePath 298 | }) 299 | }); 300 | 301 | return response.json 302 | } 303 | 304 | async fetchOnboardingState(): Promise<"unauthenticated" | "setup-gumroad" | "setup-one-time-code" | "setup-one-time-code-again" | "ready"> { 305 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/onboardingState`, { 306 | method: "GET", 307 | headers: { 308 | "accept": "application/json", 309 | "Authorization": `Bearer ${this.access_token}` 310 | } 311 | }); 312 | 313 | return response.json 314 | } 315 | 316 | async fetchGetUser(): Promise { 317 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/user`, { 318 | method: "GET", 319 | headers: { 320 | "accept": "application/json", 321 | } 322 | }); 323 | 324 | return {...response.json}; 325 | } 326 | 327 | async fetchDeviceCode(): Promise { 328 | const response = await requestUrl({ 329 | url: `${this.settings.endpoint}/oauth/device/code`, 330 | method: 'POST', 331 | headers: { 332 | 'Content-Type': 'application/x-www-form-urlencoded', 333 | 'Accept': 'application/json', 334 | }, 335 | body: new URLSearchParams({ 336 | client_id: this.settings.client_id, 337 | scope: '', 338 | }).toString(), 339 | }); 340 | 341 | const data = response.json; 342 | 343 | // Validate response structure 344 | if (!data.device_code || !data.user_code || !data.verification_uri) { 345 | throw new Error('Invalid device code response format'); 346 | } 347 | 348 | return data; 349 | } 350 | 351 | async fetchPollForDeviceToken(deviceCode: string): Promise { 352 | const response = await requestUrl({ 353 | url: `${this.settings.endpoint}/oauth/token`, 354 | method: 'POST', 355 | headers: { 356 | 'Content-Type': 'application/x-www-form-urlencoded', 357 | 'Accept': 'application/json', 358 | }, 359 | body: new URLSearchParams({ 360 | grant_type: 'urn:ietf:params:oauth:grant-type:device_code', 361 | client_id: this.settings.client_id, 362 | device_code: deviceCode, 363 | client_secret: this.settings.client_secret 364 | }).toString(), 365 | throw: false 366 | }); 367 | 368 | return response.json; 369 | } 370 | 371 | public async fetchRefreshOAuthAccessToken(): Promise<{ access_token: string, refresh_token: string }> { 372 | pino.info(`Sending request for a refresh token with ${this.refresh_token}`); 373 | const formData = new URLSearchParams({ 374 | grant_type: 'refresh_token', 375 | client_id: this.settings.client_id, 376 | refresh_token: this.refresh_token!, 377 | scope: '' 378 | }); 379 | const response = await requestUrl({ 380 | url: `${this.settings.endpoint}/oauth/token`, 381 | method: "POST", 382 | headers: { 383 | 'Content-Type': 'application/x-www-form-urlencoded', 384 | 'Accept': 'application/json' 385 | }, 386 | body: formData.toString() 387 | }) 388 | 389 | return response.json; 390 | } 391 | 392 | async sendGumroadLicense(license: string): Promise { 393 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/gumroadLicense`, { 394 | method: "POST", 395 | headers: { 396 | 'Content-Type': 'application/json', 397 | }, 398 | body: JSON.stringify({license}), 399 | throw: false 400 | }); 401 | 402 | return response.json; 403 | } 404 | 405 | async sendOneTimeCode(code: string): Promise { 406 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/onetimecode`, { 407 | method: "POST", 408 | headers: { 409 | 'Content-Type': 'application/json', 410 | }, 411 | body: JSON.stringify({code}) 412 | }); 413 | 414 | return response.json; 415 | } 416 | 417 | async fetchGiveFeedback(details: FeedbackFormDetails): Promise { 418 | this.authenticatedRequest(`${this.settings.endpoint}/api/sync/remarkable-document-share`, { 419 | method: "POST", 420 | headers: { 421 | 'Content-Type': 'application/json', 422 | }, 423 | body: JSON.stringify(details) 424 | }); 425 | } 426 | 427 | async deleteRemarkableConnection(): Promise { 428 | const response = await this.authenticatedRequest(`${this.settings.endpoint}/api/sync/remarkable-connection`, { 429 | method: "DELETE", 430 | headers: { 431 | 'Accept': 'application/json', 432 | } 433 | }); 434 | 435 | return response.json; 436 | } 437 | 438 | private async checkAccountStatus() { 439 | await this.authentication.initializeAuth(); 440 | if (this.authentication.isAuthenticated()) { 441 | await this.sync(); 442 | } 443 | } 444 | } 445 | 446 | -------------------------------------------------------------------------------- /src/Authentication.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeviceFlowError, 3 | DeviceTokenErrorResponse, 4 | DeviceTokenResponse, 5 | DeviceTokenSuccessResponse, 6 | ScrybbleApi, 7 | ScrybbleSettings, 8 | ScrybbleUser 9 | } from "../@types/scrybble"; 10 | import {pino} from "./errorHandling/logging"; 11 | import {ResponseError} from "./errorHandling/Errors"; 12 | import {StateMachine, t} from "typescript-fsm"; 13 | 14 | 15 | export function isDeviceFlowError(response: any): response is DeviceTokenErrorResponse { 16 | return response && typeof response === 'object' && "error" in response; 17 | } 18 | 19 | 20 | export function getDeviceTokenErrorResponseType(response: DeviceTokenResponse): DeviceFlowError | null { 21 | if (isDeviceFlowError(response)) { 22 | return response.error; 23 | } 24 | return null; 25 | } 26 | 27 | export enum AuthStates { 28 | INIT = "INIT", 29 | 30 | FETCHING_USER = "FETCHING_USER", 31 | 32 | REQUESTING_DEVICE_CODE = "REQUESTING_DEVICE_CODE", 33 | WAITING_FOR_USER_AUTHORIZATION = "WAITING_FOR_USER_AUTHORIZATION", 34 | POLLING_FOR_TOKEN = "POLLING_FOR_TOKEN", 35 | REFRESHING_TOKEN = "REFRESHING_TOKEN", 36 | 37 | AUTHENTICATED = "AUTHENTICATED", 38 | UNAUTHENTICATED = "UNAUTHENTICATED" 39 | } 40 | 41 | export enum AuthEvents { 42 | ACCESS_TOKEN_RECEIVED = "ACCESS_TOKEN_RECEIVED", 43 | 44 | TOKEN_FOUND_ON_STARTUP = "TOKEN_FOUND_ON_STARTUP", 45 | NO_TOKEN_FOUND_ON_STARTUP = "NO_TOKEN_FOUND_ON_STARTUP", 46 | 47 | ACCESS_TOKEN_EXPIRED = "ACCESS_TOKEN_EXPIRED", 48 | 49 | REFRESH_SUCCESS = "REFRESH_SUCCESS", 50 | REFRESH_FAILURE = "REFRESH_FAILURE", 51 | 52 | USER_FETCHED = "USER_FETCHED", 53 | USER_FETCH_FAILED = "USER_FETCH_FAILED", 54 | 55 | LOGIN_REQUESTED = "LOGIN_REQUESTED", 56 | LOGOUT_REQUESTED = "LOGOUT_REQUESTED", 57 | 58 | DEVICE_CODE_RECEIVED = "DEVICE_CODE_RECEIVED", 59 | DEVICE_CODE_REQUEST_FAILED = "DEVICE_CODE_REQUEST_FAILED", 60 | POLLING_STARTED = "POLLING_STARTED", 61 | AUTHORIZATION_EXPIRED = "AUTHORIZATION_EXPIRED", 62 | AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED", 63 | DEVICE_FLOW_CANCELED = "DEVICE_FLOW_CANCELED", 64 | } 65 | 66 | export interface DeviceAuthorizationData { 67 | device_code: string; 68 | user_code: string; 69 | verification_uri: string; 70 | expires_in: number; 71 | interval: number; 72 | } 73 | 74 | function isSuccessResponse(deviceTokenResponse: DeviceTokenResponse): deviceTokenResponse is DeviceTokenSuccessResponse { 75 | return "access_token" in deviceTokenResponse; 76 | } 77 | 78 | export class Authentication extends StateMachine { 79 | public user: ScrybbleUser | null = null; 80 | public deviceAuth: DeviceAuthorizationData | null = null; 81 | 82 | private listeners: ((new_state: AuthStates) => void)[] = []; 83 | private pollingTimer: number | null = null; 84 | 85 | constructor(private settings: ScrybbleSettings, private api: ScrybbleApi) { 86 | super(AuthStates.INIT, []); 87 | 88 | /** 89 | * ```mermaid 90 | * stateDiagram-v2 91 | * [*] --> INIT 92 | * INIT --> REQUESTING_DEVICE_CODE: LOGIN_REQUEST 93 | * INIT --> FETCHING_USER: TOKEN_FOUND_ON_START 94 | * INIT --> UNAUTHENTICATED: NO_TOKEN_FOUND_ON_START 95 | * REQUESTING_DEVICE_CODE --> WAITING_FOR_USER_AUTHORIZATION: DEVICE_CODE_RECEIVED 96 | * REQUESTING_DEVICE_CODE --> UNAUTHENTICATED: DEVICE_CODE_REQUEST_FAILED 97 | * WAITING_FOR_USER_AUTHORIZATION --> POLLING_FOR_TOKEN: POLLING_START 98 | * WAITING_FOR_USER_AUTHORIZATION --> UNAUTHENTICATED: DEVICE_FLOW_CANCEL 99 | * POLLING_FOR_TOKEN --> FETCHING_USER: ACCESS_TOKEN_RECEIVED 100 | * POLLING_FOR_TOKEN --> UNAUTHENTICATED: AUTHORIZATION_EXPIRED 101 | * POLLING_FOR_TOKEN --> UNAUTHENTICATED: AUTHORIZATION_DENIED 102 | * POLLING_FOR_TOKEN --> UNAUTHENTICATED: DEVICE_FLOW_CANCEL 103 | * FETCHING_USER --> AUTHENTICATED: USER_FETCH 104 | * FETCHING_USER --> UNAUTHENTICATED: USER_FETCH_FAILED 105 | * AUTHENTICATED --> UNAUTHENTICATED: LOGOUT_REQUEST 106 | * REFRESHING_TOKEN --> FETCHING_USER: REFRESH_SUCCESS 107 | * REFRESHING_TOKEN --> UNAUTHENTICATED: REFRESH_FAILURE 108 | * UNAUTHENTICATED --> REQUESTING_DEVICE_CODE: LOGIN_REQUEST 109 | * UNAUTHENTICATED --> REFRESHING_TOKEN: ACCESS_TOKEN_EXPIRED 110 | * ``` 111 | */ 112 | const transitions = [ 113 | t(AuthStates.INIT, AuthEvents.LOGIN_REQUESTED, AuthStates.REQUESTING_DEVICE_CODE, this.broadcastStateChange.bind(this)), 114 | t(AuthStates.INIT, AuthEvents.TOKEN_FOUND_ON_STARTUP, AuthStates.FETCHING_USER, this.broadcastStateChange.bind(this)), 115 | t(AuthStates.INIT, AuthEvents.NO_TOKEN_FOUND_ON_STARTUP, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 116 | 117 | t(AuthStates.REQUESTING_DEVICE_CODE, AuthEvents.DEVICE_CODE_RECEIVED, AuthStates.WAITING_FOR_USER_AUTHORIZATION, this.broadcastStateChange.bind(this)), 118 | t(AuthStates.REQUESTING_DEVICE_CODE, AuthEvents.DEVICE_CODE_REQUEST_FAILED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 119 | 120 | t(AuthStates.WAITING_FOR_USER_AUTHORIZATION, AuthEvents.POLLING_STARTED, AuthStates.POLLING_FOR_TOKEN, this.broadcastStateChange.bind(this)), 121 | t(AuthStates.WAITING_FOR_USER_AUTHORIZATION, AuthEvents.DEVICE_FLOW_CANCELED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 122 | 123 | t(AuthStates.POLLING_FOR_TOKEN, AuthEvents.ACCESS_TOKEN_RECEIVED, AuthStates.FETCHING_USER, this.broadcastStateChange.bind(this)), 124 | t(AuthStates.POLLING_FOR_TOKEN, AuthEvents.AUTHORIZATION_EXPIRED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 125 | t(AuthStates.POLLING_FOR_TOKEN, AuthEvents.AUTHORIZATION_DENIED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 126 | t(AuthStates.POLLING_FOR_TOKEN, AuthEvents.DEVICE_FLOW_CANCELED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 127 | 128 | t(AuthStates.FETCHING_USER, AuthEvents.USER_FETCHED, AuthStates.AUTHENTICATED, this.broadcastStateChange.bind(this)), 129 | t(AuthStates.FETCHING_USER, AuthEvents.USER_FETCH_FAILED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 130 | 131 | t(AuthStates.AUTHENTICATED, AuthEvents.LOGOUT_REQUESTED, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 132 | 133 | t(AuthStates.REFRESHING_TOKEN, AuthEvents.REFRESH_SUCCESS, AuthStates.FETCHING_USER, this.broadcastStateChange.bind(this)), 134 | t(AuthStates.REFRESHING_TOKEN, AuthEvents.REFRESH_FAILURE, AuthStates.UNAUTHENTICATED, this.broadcastStateChange.bind(this)), 135 | 136 | t(AuthStates.UNAUTHENTICATED, AuthEvents.LOGIN_REQUESTED, AuthStates.REQUESTING_DEVICE_CODE, this.broadcastStateChange.bind(this)), 137 | t(AuthStates.UNAUTHENTICATED, AuthEvents.ACCESS_TOKEN_EXPIRED, AuthStates.REFRESHING_TOKEN, this.broadcastStateChange.bind(this)) 138 | ]; 139 | 140 | this.addTransitions(transitions); 141 | } 142 | 143 | broadcastStateChange() { 144 | for (const listener of this.listeners) { 145 | listener(this.getState()); 146 | } 147 | } 148 | 149 | addStateChangeListener(listener: (new_state: AuthStates) => void) { 150 | this.listeners.push(listener); 151 | } 152 | 153 | public async initializeAuth(): Promise { 154 | if (this.settings.access_token) { 155 | await this.dispatch(AuthEvents.TOKEN_FOUND_ON_STARTUP); 156 | await this.fetchAndSetUser(); 157 | } else { 158 | await this.dispatch(AuthEvents.NO_TOKEN_FOUND_ON_STARTUP); 159 | } 160 | } 161 | 162 | public async initiateDeviceFlow(): Promise { 163 | await this.dispatch(AuthEvents.LOGIN_REQUESTED); 164 | 165 | try { 166 | this.deviceAuth = await this.api.fetchDeviceCode(); 167 | await this.dispatch(AuthEvents.DEVICE_CODE_RECEIVED); 168 | 169 | // Start polling after a short delay to let UI update 170 | setTimeout(() => this.startPolling(), 1000); 171 | 172 | } catch (error) { 173 | pino.error(error, "Failed to request device code"); 174 | await this.dispatch(AuthEvents.DEVICE_CODE_REQUEST_FAILED); 175 | throw error; 176 | } 177 | } 178 | 179 | public async cancelDeviceFlow(): Promise { 180 | this.stopPolling(); 181 | this.deviceAuth = null; 182 | await this.dispatch(AuthEvents.DEVICE_FLOW_CANCELED); 183 | } 184 | 185 | public async copyUserCodeToClipboard(): Promise { 186 | if (!this.deviceAuth?.user_code) { 187 | return false; 188 | } 189 | 190 | try { 191 | await navigator.clipboard.writeText(this.deviceAuth.user_code); 192 | return true; 193 | } catch (error) { 194 | pino.warn(error as Error); 195 | return false; 196 | } 197 | } 198 | 199 | public openVerificationUrl(): void { 200 | if (!this.deviceAuth?.verification_uri) { 201 | pino.warn("No verification URI available"); 202 | return; 203 | } 204 | 205 | window.open(this.deviceAuth.verification_uri, '_blank'); 206 | } 207 | 208 | async refreshAccessToken(): Promise<{ access_token: string, refresh_token: string }> { 209 | await this.dispatch(AuthEvents.ACCESS_TOKEN_EXPIRED); 210 | if (!this.settings.refresh_token) { 211 | await this.dispatch(AuthEvents.REFRESH_FAILURE); 212 | throw new Error("No refresh token available"); 213 | } 214 | 215 | try { 216 | const response = await this.api.fetchRefreshOAuthAccessToken(); 217 | 218 | this.settings.access_token = response.access_token; 219 | this.settings.refresh_token = response.refresh_token; 220 | await this.settings.save(); 221 | await this.dispatch(AuthEvents.REFRESH_SUCCESS); 222 | pino.info("Successfully refreshed OAuth token"); 223 | 224 | return {access_token: response.access_token, refresh_token: response.refresh_token}; 225 | } catch (e) { 226 | await this.dispatch(AuthEvents.REFRESH_FAILURE); 227 | this.settings.access_token = undefined; 228 | this.settings.refresh_token = undefined; 229 | await this.settings.save(); 230 | throw e; 231 | } 232 | } 233 | 234 | async refreshToken(error: ResponseError | Error) { 235 | // If we get a 401, try to refresh the token 236 | if ("status" in error && error.status === 401 && this.settings.refresh_token) { 237 | pino.warn("Got a 401, refreshing"); 238 | try { 239 | await this.refreshAccessToken(); 240 | await this.fetchAndSetUser(false); 241 | } catch (refreshError) { 242 | pino.error(error, "You were unexpectedly logged out, please try to log back in again."); 243 | this.settings.refresh_token = undefined; 244 | this.settings.access_token = undefined; 245 | await this.settings.save(); 246 | this.user = null; 247 | await this.dispatch(AuthEvents.LOGOUT_REQUESTED); 248 | throw error; 249 | } 250 | } else { 251 | pino.error("Unexpected server error"); 252 | throw error; 253 | } 254 | } 255 | 256 | public async logout(): Promise { 257 | this.stopPolling(); 258 | this.deviceAuth = null; 259 | this.settings.access_token = undefined; 260 | this.settings.refresh_token = undefined; 261 | this.user = null; 262 | await this.settings.save(); 263 | await this.dispatch(AuthEvents.LOGOUT_REQUESTED); 264 | } 265 | 266 | isAuthenticated() { 267 | return this.getState() === AuthStates.AUTHENTICATED; 268 | } 269 | 270 | public async refreshUserInfo(): Promise { 271 | this.user = await this.api.fetchGetUser(); 272 | this.broadcastStateChange(); 273 | } 274 | 275 | private async fetchAndSetUser(attemptRefreshOnFailure = true): Promise { 276 | try { 277 | this.user = await this.api.fetchGetUser(); 278 | await this.dispatch(AuthEvents.USER_FETCHED); 279 | } catch (error) { 280 | pino.error(error, "Failed to fetch user data"); 281 | await this.dispatch(AuthEvents.USER_FETCH_FAILED); 282 | if (attemptRefreshOnFailure) { 283 | await this.refreshToken(error as Error); 284 | } 285 | } 286 | } 287 | 288 | private async startPolling(): Promise { 289 | if (!this.deviceAuth) { 290 | pino.error("Cannot start polling: no device auth data"); 291 | return; 292 | } 293 | 294 | await this.dispatch(AuthEvents.POLLING_STARTED); 295 | 296 | const startTime = Date.now(); 297 | const expirationTime = startTime + (this.deviceAuth.expires_in * 1000); 298 | let currentInterval = this.deviceAuth.interval; 299 | 300 | const poll = async (): Promise => { 301 | if (Date.now() >= expirationTime) { 302 | pino.warn("Device authorization expired"); 303 | this.stopPolling(); 304 | await this.dispatch(AuthEvents.AUTHORIZATION_EXPIRED); 305 | return; 306 | } 307 | 308 | if (this.getState() !== AuthStates.POLLING_FOR_TOKEN) { 309 | this.stopPolling(); 310 | return; 311 | } 312 | 313 | try { 314 | const deviceTokenResponse = await this.api.fetchPollForDeviceToken(this.deviceAuth!.device_code); 315 | 316 | if (isSuccessResponse(deviceTokenResponse)) { 317 | this.stopPolling(); 318 | this.deviceAuth = null; 319 | 320 | this.settings.access_token = deviceTokenResponse.access_token; 321 | this.settings.refresh_token = deviceTokenResponse.refresh_token; 322 | await this.settings.save(); 323 | 324 | await this.dispatch(AuthEvents.ACCESS_TOKEN_RECEIVED); 325 | await this.fetchAndSetUser(); 326 | return; 327 | } 328 | 329 | switch (getDeviceTokenErrorResponseType(deviceTokenResponse)) { 330 | case 'authorization_pending': 331 | // User hasn't authorized yet, continue polling 332 | this.scheduleNextPoll(currentInterval, poll); 333 | break; 334 | 335 | case 'slow_down': 336 | // Server requests slower polling 337 | currentInterval += 5; 338 | this.scheduleNextPoll(currentInterval, poll); 339 | break; 340 | 341 | case 'access_denied': 342 | // User denied the authorization 343 | pino.info("User denied device authorization"); 344 | this.stopPolling(); 345 | this.deviceAuth = null; 346 | await this.dispatch(AuthEvents.AUTHORIZATION_DENIED); 347 | break; 348 | 349 | case 'expired_token': 350 | // Device code expired 351 | pino.warn("Device code expired"); 352 | this.stopPolling(); 353 | this.deviceAuth = null; 354 | await this.dispatch(AuthEvents.AUTHORIZATION_EXPIRED); 355 | break; 356 | 357 | default: 358 | // Other error 359 | pino.error(deviceTokenResponse, "Unexpected error during device polling"); 360 | this.stopPolling(); 361 | this.deviceAuth = null; 362 | await this.dispatch(AuthEvents.AUTHORIZATION_DENIED); 363 | break; 364 | } 365 | } catch (error) { 366 | pino.error(error, "Failed to poll for device token"); 367 | } 368 | }; 369 | 370 | this.scheduleNextPoll(currentInterval, poll); 371 | } 372 | 373 | private scheduleNextPoll(interval: number, pollFunction: () => Promise): void { 374 | this.pollingTimer = window.setTimeout(pollFunction, interval * 1000); 375 | } 376 | 377 | private stopPolling(): void { 378 | if (this.pollingTimer) { 379 | clearTimeout(this.pollingTimer); 380 | this.pollingTimer = null; 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/ui/Pages/AccountPage.ts: -------------------------------------------------------------------------------- 1 | import {html, TemplateResult} from "lit-html"; 2 | import {LitElement, nothing} from "lit-element"; 3 | import {property, state} from "lit-element/decorators.js"; 4 | import {consume} from "@lit/context"; 5 | import {scrybbleContext} from "../scrybbleContext"; 6 | import {ScrybbleCommon} from "../../../@types/scrybble"; 7 | import {getIcon} from "obsidian"; 8 | import {ErrorMessage, Errors} from "../../errorHandling/Errors"; 9 | import {pino} from "../../errorHandling/logging"; 10 | import {AuthStates} from "../../Authentication"; 11 | 12 | export class AccountPage extends LitElement { 13 | @consume({context: scrybbleContext}) 14 | @property({attribute: false}) 15 | scrybble!: ScrybbleCommon; 16 | 17 | @state() 18 | private authState: AuthStates = AuthStates.INIT; 19 | 20 | @state() 21 | private error: ErrorMessage | null = null; 22 | 23 | @state() 24 | private copySuccess = false; 25 | 26 | @state() 27 | private isResettingConnection = false; 28 | 29 | @state() 30 | private showResetConfirmation = false; 31 | 32 | async connectedCallback() { 33 | super.connectedCallback(); 34 | 35 | // Get initial state 36 | this.authState = this.scrybble.authentication.getState(); 37 | 38 | // Listen for state changes 39 | this.scrybble.authentication.addStateChangeListener(this.stateChangeHandler); 40 | } 41 | 42 | disconnectedCallback() { 43 | super.disconnectedCallback(); 44 | } 45 | 46 | render(): TemplateResult { 47 | const errorTemplate = this.error ? html` 48 | Retry` 51 | ]}"> 52 | ` : nothing; 53 | 54 | return html` 55 | 59 | `; 60 | } 61 | 62 | protected createRenderRoot(): HTMLElement | DocumentFragment { 63 | return this; 64 | } 65 | 66 | private stateChangeHandler = (newState: AuthStates) => { 67 | this.authState = newState; 68 | this.requestUpdate(); 69 | }; 70 | 71 | private renderStateBasedContent(): TemplateResult { 72 | switch (this.authState) { 73 | case AuthStates.INIT: 74 | return this.renderLoadingView("Initializing..."); 75 | 76 | case AuthStates.REQUESTING_DEVICE_CODE: 77 | return this.renderLoadingView("Preparing authentication..."); 78 | 79 | case AuthStates.WAITING_FOR_USER_AUTHORIZATION: 80 | case AuthStates.POLLING_FOR_TOKEN: 81 | return this.renderDeviceAuthorizationView(); 82 | 83 | case AuthStates.FETCHING_USER: 84 | return this.renderLoadingView("Fetching user information..."); 85 | 86 | case AuthStates.REFRESHING_TOKEN: 87 | return this.renderLoadingView("Refreshing authentication..."); 88 | 89 | case AuthStates.AUTHENTICATED: 90 | return this.renderAuthenticatedView(); 91 | 92 | case AuthStates.UNAUTHENTICATED: 93 | return this.renderLoginView(); 94 | 95 | default: 96 | return html` 97 |
Unknown state: ${this.authState}
`; 98 | } 99 | } 100 | 101 | private async startDeviceFlow(): Promise { 102 | pino.info("Starting Device Authorization flow"); 103 | 104 | try { 105 | this.error = null; 106 | await this.scrybble.authentication.initiateDeviceFlow(); 107 | } catch (error) { 108 | this.error = Errors.handle("DEVICE_AUTH_INITIATION_ERROR", error as Error); 109 | } 110 | } 111 | 112 | private async handleLogout(): Promise { 113 | await this.scrybble.authentication.logout(); 114 | } 115 | 116 | private showResetConnectionConfirmation(): void { 117 | this.showResetConfirmation = true; 118 | } 119 | 120 | private cancelResetConnection(): void { 121 | this.showResetConfirmation = false; 122 | } 123 | 124 | private async handleResetConnection(): Promise { 125 | pino.info("Resetting reMarkable connection"); 126 | this.isResettingConnection = true; 127 | this.showResetConfirmation = false; 128 | 129 | try { 130 | await this.scrybble.api.deleteRemarkableConnection(); 131 | // Refresh user data to update onboarding state 132 | await this.scrybble.authentication.refreshUserInfo(); 133 | } catch (error) { 134 | this.error = Errors.handle("RESET_CONNECTION_ERROR", error as Error); 135 | } finally { 136 | this.isResettingConnection = false; 137 | } 138 | } 139 | 140 | private async handleErrorRetry(): Promise { 141 | pino.info("Retrying after error"); 142 | this.error = null; 143 | } 144 | 145 | private async copyUserCode(): Promise { 146 | const success = await this.scrybble.authentication.copyUserCodeToClipboard(); 147 | if (success) { 148 | this.copySuccess = true; 149 | setTimeout(() => { 150 | this.copySuccess = false; 151 | this.requestUpdate(); 152 | }, 2000); 153 | this.requestUpdate(); 154 | } 155 | } 156 | 157 | private openVerificationUrl(): void { 158 | this.scrybble.authentication.openVerificationUrl(); 159 | } 160 | 161 | private async cancelDeviceFlow(): Promise { 162 | await this.scrybble.authentication.cancelDeviceFlow(); 163 | } 164 | 165 | private formatDate(dateString: string): string { 166 | try { 167 | return new Date(dateString).toLocaleDateString(); 168 | } catch (error) { 169 | pino.warn({dateString, error}); 170 | return 'Unknown'; 171 | } 172 | } 173 | 174 | private renderLoadingView(message: string = "Loading..."): TemplateResult { 175 | return html` 176 | 184 | `; 185 | } 186 | 187 | private renderDeviceAuthorizationView(): TemplateResult { 188 | const deviceAuth = this.scrybble.authentication.deviceAuth; 189 | if (!deviceAuth) { 190 | return this.renderLoadingView("Loading authorization..."); 191 | } 192 | 193 | const isPolling = this.authState === AuthStates.POLLING_FOR_TOKEN; 194 | 195 | return html` 196 | 293 | `; 294 | } 295 | 296 | private renderLoginView(): TemplateResult { 297 | return html` 298 | 313 | `; 314 | } 315 | 316 | private formatGumroadSubscriptionManageUrl(): string { 317 | const user = this.scrybble.authentication.user; 318 | if (!user) return ""; 319 | if (!user.subscription_status?.licenseInformation?.subscription_id) { 320 | pino.warn("Missing subscription ID for Gumroad URL"); 321 | return "#"; 322 | } 323 | return `https://gumroad.com/subscriptions/${user.subscription_status.licenseInformation.subscription_id}/manage`; 324 | } 325 | 326 | private renderAuthenticatedView(): TemplateResult { 327 | const userInfo = this.scrybble.authentication.user; 328 | if (!userInfo) { 329 | return this.renderLoadingView("Loading user information..."); 330 | } 331 | 332 | return html` 333 | 457 | `; 458 | } 459 | } 460 | --------------------------------------------------------------------------------