├── .gitignore ├── README.md ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js ├── settings.ts ├── src ├── api.ts └── utils.ts ├── store.ts ├── styles.css ├── tsconfig.json ├── versions.json └── view.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DynaSync 2 | 3 | **WIP: this is currently very experimental** 4 | 5 | A plugin for [Obsidian](https://obsidian.md) to sync selected files with [Dynalist](https://dynalist.io). 6 | 7 | ## Notes 8 | 9 | This plugin aims to provide a small bridge between Obsidian and Dynalist, to 10 | synchronize a few selected files between them - just as a crutch until a better 11 | mobile app for Obsidian comes along. 12 | 13 | My use case consists of tracking my Inbox, Reminders and To Do list in Dynalist 14 | where I can easily access them in the Dynalist mobile app, and syncing them to 15 | my full knowledge base in Obsidian. 16 | 17 | This plugin will never sync a whole vault, nor strife to be a 100% perfect 18 | lossless sync solution. 19 | 20 | ## Roadmap 21 | 22 | - [x] manually download a Dynalist file 23 | - [ ] manually upload a Dynalist file 24 | - [ ] auto-sync for selected files 25 | 26 | ## Out of scope 27 | 28 | - syncing whole vault 29 | - handling larger files than simple checklists 30 | - conflict resolution, line diffing 31 | - link rewriting 32 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Notice, 3 | Plugin, 4 | PluginSettingTab, 5 | Setting, 6 | WorkspaceLeaf, 7 | MarkdownView 8 | } from 'obsidian'; 9 | 10 | import DynasyncSettings from './settings'; 11 | import Store from './store'; 12 | import DynasyncView from './view'; 13 | import { exportDocument } from './src/utils'; 14 | 15 | function extractFrontmatter(source: string): string { 16 | if (!source.startsWith('---')) { 17 | return ''; 18 | } 19 | 20 | const end = source.indexOf("\n---\n", 4); 21 | if (end < 0) { 22 | return ''; 23 | } 24 | 25 | return source.substring(0, end + 5) + "\n"; 26 | } 27 | 28 | export default class DynasyncPlugin extends Plugin { 29 | store: Store; 30 | view: DynasyncView; 31 | 32 | async onload() { 33 | console.log('loading DynaSync'); 34 | 35 | const settings = (await this.loadData()) || new DynasyncSettings(); 36 | this.store = new Store(settings); 37 | 38 | this.addRibbonIcon('dice', 'Dynasync Plugin', () => { 39 | new Notice('This is a notice!'); 40 | }); 41 | 42 | // this.addStatusBarItem().setText('Status Bar Text'); 43 | 44 | this.addCommand({ 45 | id: 'refresh-dynasync', 46 | name: 'Refresh DynaSync', 47 | callback: () => { 48 | this.store.trigger('sync'); 49 | }, 50 | }); 51 | 52 | this.addSettingTab(new DynasyncSettingTab(this.app, this)); 53 | 54 | this.registerView('dynasync', (leaf: WorkspaceLeaf) => 55 | (this.view = new DynasyncView(leaf, this.store)) 56 | ); 57 | 58 | this.initLeaf(); 59 | 60 | this.store.on('sync', () => { 61 | console.log('syncing'); 62 | this.sync(); 63 | }); 64 | } 65 | 66 | initLeaf(): void { 67 | if (!this.app.workspace.getLeavesOfType('dynasync').length) { 68 | this.app.workspace.getRightLeaf(false).setViewState({ 69 | type: 'dynasync', 70 | }); 71 | } 72 | } 73 | 74 | onunload() { 75 | console.log('unloading plugin'); 76 | this.app.workspace 77 | .getLeavesOfType('dynasync') 78 | .forEach((leaf) => leaf.detach()); 79 | } 80 | 81 | async sync(): Promise { 82 | this.store.setStatus('syncing ...'); 83 | 84 | const view = this.app.workspace.activeLeaf.view; 85 | if (!(view instanceof MarkdownView)) { 86 | this.store.setStatus('failed'); 87 | return; 88 | } 89 | const content = view.sourceMode.get(); 90 | const frontmatter = extractFrontmatter(content); 91 | 92 | const file = this.app.workspace.getActiveFile(); 93 | const metadata = this.app.metadataCache.getFileCache(file); 94 | const documentId = metadata.frontmatter && metadata.frontmatter.dynasync; 95 | if (!documentId) { 96 | this.store.setStatus('disabled'); 97 | return; 98 | } 99 | 100 | const body = await this.store.client.readDocument(documentId); 101 | const doc = exportDocument(body); 102 | 103 | const newContent = frontmatter + doc; 104 | view.sourceMode.set(newContent, true); 105 | 106 | this.store.setStatus('synced'); 107 | } 108 | } 109 | 110 | class DynasyncSettingTab extends PluginSettingTab { 111 | display(): void { 112 | let {containerEl} = this; 113 | const plugin: DynasyncPlugin = (this as any).plugin; 114 | 115 | containerEl.empty(); 116 | 117 | containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'}); 118 | 119 | new Setting(containerEl) 120 | .setName('API token') 121 | .setDesc('Dynalist API token') 122 | .addText(text => text.setPlaceholder('Enter your secret') 123 | .setValue(plugin.store.settings.apiToken) 124 | .onChange(async (value) => { 125 | plugin.store.settings.apiToken = value; 126 | await plugin.saveData(plugin.store.settings); 127 | console.log('saved', plugin.store.settings); 128 | })); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dynasync", 3 | "name": "DynaSync", 4 | "version": "0.1", 5 | "minAppVersion": "0.9.12", 6 | "description": "Sync Dynalist with Obsidian", 7 | "author": "cschomburg", 8 | "authorUrl": "https://github.com/cschomburg/obsidian-dynasync", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "0.9.7", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^15.1.0", 15 | "@rollup/plugin-node-resolve": "^9.0.0", 16 | "@rollup/plugin-typescript": "^6.0.0", 17 | "@types/node": "^14.14.2", 18 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 19 | "rollup": "^2.32.1", 20 | "tslib": "^2.0.3", 21 | "typescript": "^4.0.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'main.ts', 7 | output: { 8 | dir: '.', 9 | sourcemap: 'inline', 10 | format: 'cjs', 11 | exports: 'default' 12 | }, 13 | external: ['obsidian'], 14 | plugins: [ 15 | typescript(), 16 | nodeResolve({browser: true}), 17 | commonjs(), 18 | ] 19 | }; -------------------------------------------------------------------------------- /settings.ts: -------------------------------------------------------------------------------- 1 | export default class DynasyncSettings { 2 | apiToken = ''; 3 | } 4 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | export type Node = { 2 | id: string; 3 | content: string; 4 | note: string; 5 | checked: boolean; 6 | checkbox: boolean; 7 | color: number; 8 | heading: number; 9 | created: number; 10 | modified: number; 11 | collapsed: boolean; 12 | children: string[]; 13 | } 14 | 15 | export type Document = { 16 | file_id: string; 17 | title: string; 18 | version: number; 19 | nodes: Node[]; 20 | } 21 | 22 | export default class Client { 23 | baseUrl = 'https://dynalist.io/api/v1/'; 24 | token: string; 25 | 26 | constructor(token: string) { 27 | this.token = token; 28 | } 29 | 30 | async listFiles(): Promise { 31 | const body = this.request('file/list', {}); 32 | return body; 33 | } 34 | 35 | async readDocument(id: string): Promise { 36 | const body = this.request('doc/read', { 37 | file_id: id, 38 | }); 39 | return body; 40 | } 41 | 42 | async request(endpoint: string, data: Record): Promise { 43 | data.token = this.token; 44 | const request = new Request(this.baseUrl + endpoint, { 45 | method: 'POST', 46 | headers: { 47 | 'content-type': 'application/json', 48 | }, 49 | body: JSON.stringify(data), 50 | }); 51 | 52 | const resp = await fetch(request); 53 | const body = await resp.json(); 54 | return body; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Document, Node } from './api'; 2 | 3 | export type Visitor = (node: Node, parents: Node[]) => void; 4 | 5 | export class NodeTree { 6 | nodes: Record; 7 | 8 | constructor(nodes: Node[]) { 9 | this.nodes = {}; 10 | nodes.forEach((node) => { 11 | this.nodes[node.id] = node; 12 | }); 13 | } 14 | 15 | get(id: string): Node { 16 | return this.nodes[id]; 17 | } 18 | 19 | walk(visit: Visitor): void { 20 | this.doWalk('root', [], visit); 21 | } 22 | 23 | doWalk(id: string, parents: Node[], visit: Visitor): void { 24 | const node = this.get(id); 25 | visit(node, parents); 26 | 27 | const nodes = [...parents, node]; 28 | if (node.children) { 29 | node.children.forEach((child) => { 30 | this.doWalk(child, nodes, visit); 31 | }); 32 | } 33 | } 34 | } 35 | 36 | export function exportDocument(doc: Document): string { 37 | const tree = new NodeTree(doc.nodes); 38 | 39 | const lines = []; 40 | lines.push('# ' + doc.title); 41 | lines.push(''); 42 | 43 | tree.walk((node, parents) => { 44 | if (node.id !== 'root') { 45 | lines.push(exportNode(node, parents)); 46 | } 47 | }); 48 | 49 | return lines.join("\n"); 50 | } 51 | 52 | export function exportNode(node: Node, parents: Node[]): string { 53 | let out = node.content; 54 | 55 | if (node.heading) { 56 | out = "\n" + '#'.repeat(node.heading) + ' '; 57 | } else { 58 | const indent = getIndent(parents); 59 | out = ' '.repeat(indent) + '- '; 60 | } 61 | 62 | if (node.checkbox) { 63 | if (node.checked) { 64 | out += '[x] '; 65 | } else { 66 | out += '[ ] '; 67 | } 68 | } 69 | 70 | out += node.content; 71 | 72 | if (node.heading) { 73 | out += "\n"; 74 | } 75 | 76 | return out; 77 | } 78 | 79 | export function getIndent(parents: Node[]): number { 80 | const level = parents.filter((n) => !n.heading).length; 81 | return (level - 1) * 2; 82 | } 83 | -------------------------------------------------------------------------------- /store.ts: -------------------------------------------------------------------------------- 1 | import { Events } from 'obsidian'; 2 | import DynasyncSettings from './settings'; 3 | import Client from './src/api'; 4 | 5 | export default class Store extends Events { 6 | settings: DynasyncSettings; 7 | client: Client; 8 | status = 'ready'; 9 | 10 | constructor(settings: DynasyncSettings) { 11 | super(); 12 | this.settings = settings; 13 | this.client = new Client(settings.apiToken); 14 | } 15 | 16 | setStatus(status: string): void { 17 | this.status = status; 18 | this.trigger('status.changed', { status }); 19 | console.log('status changed', status); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cschomburg/obsidian-dynasync/d2c6940a17d476f0f2139c126809431a94c2150b/styles.css -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1": "0.9.12" 3 | } 4 | -------------------------------------------------------------------------------- /view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import Store from './store'; 3 | 4 | export default class DynasyncView extends ItemView { 5 | status: HTMLElement; 6 | store: Store; 7 | 8 | constructor(leaf: WorkspaceLeaf, store: Store) { 9 | super(leaf); 10 | this.store = store; 11 | } 12 | 13 | getViewType(): string { 14 | return 'dynasync'; 15 | } 16 | 17 | getDisplayText(): string { 18 | return "DynaSync"; 19 | } 20 | 21 | async onOpen(): Promise { 22 | const dom = (this as any).contentEl as HTMLElement; 23 | 24 | const button = dom.createEl('button', { 25 | text: 'Sync', 26 | }); 27 | button.onClickEvent(() => { 28 | this.store.trigger('sync'); 29 | }); 30 | 31 | this.status = dom.createEl('div', { 32 | text: '-', 33 | }); 34 | 35 | this.store.on('status.changed', () => { 36 | this.redraw(); 37 | }); 38 | 39 | this.redraw(); 40 | } 41 | 42 | async redraw(): Promise { 43 | console.log('redraw'); 44 | this.status.setText(this.store.status); 45 | } 46 | } 47 | --------------------------------------------------------------------------------