├── .eslintignore ├── src ├── time.ts ├── env.ts ├── sync-engine.d.ts ├── settings │ └── suggesters │ │ ├── FolderSuggester.ts │ │ └── suggest.ts ├── api.ts ├── util │ └── markdown.ts ├── helpers │ ├── legacy-api.ts │ └── legacy-sync.ts ├── contract.ts ├── sync-engine.ts ├── obsidian-platform-adapter.ts └── main.ts ├── styles.css ├── .editorconfig ├── manifest.json ├── .gitignore ├── versions.json ├── tsconfig.json ├── .eslintrc ├── esbuild.config.mjs ├── package.json ├── LICENSE.md ├── .github └── workflows │ └── releases.yml └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /src/time.ts: -------------------------------------------------------------------------------- 1 | export function minutes(n: number) { 2 | return n * 60 * 1000; 3 | } 4 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .body { 2 | color: inherit; 3 | } 4 | 5 | .tressel-invalid-token-error { 6 | color: red; 7 | } -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | // export const API_BASE_URL = "https://api.tressel.xyz"; 2 | export const API_BASE_URL = "http://localhost:8000"; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-tressel", 3 | "name": "Tressel Sync for Obsidian", 4 | "version": "0.2.8", 5 | "minAppVersion": "0.0.1", 6 | "description": "Official Tressel plugin to sync/export various content from the Internet to Obsidian", 7 | "author": "Tressel", 8 | "authorUrl": "https://tressel.xyz", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | build 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | -------------------------------------------------------------------------------- /src/sync-engine.d.ts: -------------------------------------------------------------------------------- 1 | export type SaveFileOpts = { 2 | filepath: string; 3 | contents: string; 4 | onConflict: "skip" | "replace"; 5 | }; 6 | 7 | type Callback = () => void; 8 | 9 | export interface PlatformAdapter { 10 | setInterval: (cb: Callback, interval: number) => number; 11 | clearInterval: (id: number) => void; 12 | saveFile: (opts: SaveFileOpts) => void; 13 | onAppBecameActive: (cb: Callback) => void; 14 | } 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.0.1", 3 | "0.0.2": "0.0.1", 4 | "0.1.0": "0.0.1", 5 | "0.1.1": "0.0.1", 6 | "0.1.2": "0.0.1", 7 | "0.1.3": "0.0.1", 8 | "0.1.4": "0.0.1", 9 | "0.1.5": "0.0.1", 10 | "0.1.6": "0.0.1", 11 | "0.1.7": "0.0.1", 12 | "0.1.8": "0.0.1", 13 | "0.1.9": "0.0.1", 14 | "0.2.0": "0.0.1", 15 | "0.2.1": "0.0.1", 16 | "0.2.2": "0.0.1", 17 | "0.2.3": "0.0.1", 18 | "0.2.4": "0.0.1", 19 | "0.2.5": "0.0.1", 20 | "0.2.6": "0.0.1", 21 | "0.2.7": "0.0.1", 22 | "0.2.8": "0.0.1" 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7"], 15 | "allowSyntheticDefaultImports": true, 16 | "paths": { 17 | "@util/*": ["util/*"] 18 | } 19 | }, 20 | "include": ["**/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 18 | "@typescript-eslint/ban-ts-comment": "off", 19 | "no-prototype-builtins": "off", 20 | "@typescript-eslint/no-empty-function": "off" 21 | } 22 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = `/* 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 | esbuild 14 | .build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | outdir: "./", 20 | bundle: true, 21 | external: ["obsidian", "electron", ...builtins], 22 | format: "cjs", 23 | watch: !prod, 24 | target: "es2016", 25 | logLevel: "info", 26 | sourcemap: prod ? false : "inline", 27 | treeShaking: true, 28 | }) 29 | .catch(() => process.exit(1)); 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-tressel", 3 | "version": "0.2.8", 4 | "description": "Official Tressel plugin to sync/export various content from the Internet to Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@types/node": "^16.11.6", 15 | "@types/turndown": "5.0.1", 16 | "@ts-rest/core": "^3.26.3", 17 | "@typescript-eslint/eslint-plugin": "^5.2.0", 18 | "@typescript-eslint/parser": "^5.2.0", 19 | "builtin-modules": "^3.2.0", 20 | "esbuild": "0.13.12", 21 | "obsidian": "latest", 22 | "tslib": "2.3.1", 23 | "typescript": "4.4.4", 24 | "zod": "^3.21.4" 25 | }, 26 | "dependencies": { 27 | "@popperjs/core": "^2.11.5", 28 | "axios": "^0.24.0", 29 | "electron": "^19.0.5", 30 | "sanitize-filename": "^1.6.3", 31 | "turndown": "7.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/settings/suggesters/FolderSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { TAbstractFile, TFolder } from "obsidian"; 4 | import { TextInputSuggest } from "./suggest"; 5 | 6 | export class FolderSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): TFolder[] { 8 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 9 | const folders: TFolder[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | abstractFiles.forEach((folder: TAbstractFile) => { 13 | if ( 14 | folder instanceof TFolder && 15 | folder.path.toLowerCase().contains(lowerCaseInputStr) 16 | ) { 17 | folders.push(folder); 18 | } 19 | }); 20 | 21 | return folders; 22 | } 23 | 24 | renderSuggestion(file: TFolder, el: HTMLElement): void { 25 | el.setText(file.path); 26 | } 27 | 28 | selectSuggestion(file: TFolder): void { 29 | this.inputEl.value = file.path; 30 | this.inputEl.trigger("input"); 31 | this.close(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { initClient } from "@ts-rest/core"; 2 | import axios, { AxiosError, AxiosResponse, Method } from "axios"; 3 | import { apiContract } from "contract"; 4 | import { API_BASE_URL } from "env"; 5 | 6 | export function createApi(getAccessToken: () => string) { 7 | const client = initClient(apiContract, { 8 | baseUrl: API_BASE_URL, 9 | baseHeaders: { 10 | "Content-Type": "application/json", 11 | }, 12 | api: async ({ path, method, headers, body }) => { 13 | try { 14 | const response = await axios.request({ 15 | method: method as Method, 16 | url: path, 17 | headers: { 18 | ...headers, 19 | Authorization: `Access ${getAccessToken()}`, 20 | } as Record, 21 | data: body, 22 | }); 23 | return { 24 | status: response.status, 25 | body: response.data, 26 | headers: new Headers(response.headers), 27 | }; 28 | } catch (e: Error | AxiosError | any) { 29 | if (axios.isAxiosError(e)) { 30 | const error = e as AxiosError; 31 | const response = error.response as AxiosResponse; 32 | return { 33 | status: response.status, 34 | body: response.data, 35 | headers: new Headers(response.headers), 36 | }; 37 | } 38 | throw e; 39 | } 40 | }, 41 | }); 42 | 43 | return client; 44 | } 45 | 46 | export type ApiClient = ReturnType; 47 | -------------------------------------------------------------------------------- /src/util/markdown.ts: -------------------------------------------------------------------------------- 1 | export type Markdown = { 2 | metadata: Metadata; 3 | body: string; 4 | }; 5 | 6 | export type Metadata = { 7 | [key: string]: string | string[]; 8 | }; 9 | 10 | const BASE_PATTERN = /^---[\s]+([\s\S]*?)[\s]+---([\s\S]*?)$/; 11 | const FM_PATTERN = /(.*?)\s*:\s*(?:(?:\[\s*(.*?)(?=\s*\]))|(.*))/g; 12 | const ARRAY_PATTERN = /\s?,\s?/g; 13 | 14 | export function parseMarkdown(data: string): Markdown { 15 | const results: RegExpExecArray | [any, any, string] = BASE_PATTERN.exec( 16 | data 17 | ) || [null, null, data]; 18 | 19 | const markdown: Markdown = { 20 | metadata: {}, 21 | body: results[2].trim(), 22 | }; 23 | 24 | let tecurp: RegExpExecArray | null; 25 | while ((tecurp = FM_PATTERN.exec(results[1] ?? "")) !== null) { 26 | markdown.metadata[tecurp[1]] = tecurp[2] 27 | ? tecurp[2].split(ARRAY_PATTERN) 28 | : tecurp[3]; 29 | } 30 | 31 | return markdown; 32 | } 33 | 34 | export function renderMarkdown(md: Markdown): string { 35 | const attributes = Object.entries(md.metadata) 36 | .map(([key, value]) => { 37 | if (Array.isArray(value)) { 38 | return `${key}: [${value.join(", ")}]`; 39 | } 40 | return `${key}: ${value}`; 41 | }) 42 | .join("\n"); 43 | 44 | return `---\n${attributes}\n---\n\n${md.body.trim()}`; 45 | } 46 | 47 | export function addMetadata(md: Markdown, metadata: Metadata): Markdown { 48 | return { ...md, metadata: { ...md.metadata, ...metadata } }; 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers/legacy-api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosRequestHeaders } from "axios"; 2 | import { API_BASE_URL } from "env"; 3 | 4 | export class LegacyApi { 5 | tresselAccessToken: string | null; 6 | client: AxiosInstance; 7 | apiBaseUrl: string; 8 | 9 | constructor(tresselAccessToken: string) { 10 | this.apiBaseUrl = API_BASE_URL; 11 | this.tresselAccessToken = tresselAccessToken; 12 | 13 | const headers: AxiosRequestHeaders = { 14 | Accept: "application/json", 15 | }; 16 | 17 | if (this.tresselAccessToken) { 18 | headers.Authorization = `Access ${this.tresselAccessToken}`; 19 | } 20 | 21 | this.client = axios.create({ 22 | baseURL: this.apiBaseUrl, 23 | timeout: 60000, 24 | headers: headers, 25 | }); 26 | } 27 | 28 | updateClient = (newToken: string) => { 29 | this.tresselAccessToken = newToken; 30 | 31 | const headers: AxiosRequestHeaders = { 32 | Accept: "application/json", 33 | }; 34 | 35 | if (this.tresselAccessToken) { 36 | headers.Authorization = `Access ${this.tresselAccessToken}`; 37 | } 38 | 39 | this.client = axios.create({ 40 | baseURL: this.apiBaseUrl, 41 | timeout: 60000, 42 | headers: headers, 43 | }); 44 | 45 | return this; 46 | }; 47 | 48 | // Endpoints 49 | verifyAccessToken = () => { 50 | return this.client.get("/token/verify"); 51 | }; 52 | 53 | syncObsidianUserData = () => { 54 | return this.client.get("/obsidian/data"); 55 | }; 56 | 57 | clearObsidianSyncMemory = () => { 58 | return this.client.get("/obsidian/clear-sync-memory"); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/contract.ts: -------------------------------------------------------------------------------- 1 | import { initContract } from "@ts-rest/core"; 2 | import { z } from "zod"; 3 | 4 | const c = initContract(); 5 | 6 | export type Provider = "twitter" | "webpage"; 7 | 8 | const Clipping = z.object({ 9 | id: z.string(), 10 | user_id: z.string(), 11 | type: z.union([z.literal("twitter"), z.literal("webpage")]), 12 | content: z.string(), 13 | created_at: z.string(), 14 | saved_at: z.string(), 15 | markdown: z.any(), 16 | }); 17 | 18 | export type ClippingType = z.infer; 19 | 20 | const snapshotInput = z.strictObject({ 21 | type: z.enum(["twitter", "webpage"]), 22 | url: z.string(), 23 | content: z.string(), 24 | }); 25 | 26 | export type Shapshot = z.infer; 27 | 28 | export const apiContract = c.router({ 29 | ping: { 30 | method: "GET", 31 | path: "/ping", 32 | responses: { 33 | 200: z.string(), 34 | }, 35 | }, 36 | getAccessToken: { 37 | method: "GET", 38 | path: "/auth/access-token", 39 | responses: { 40 | 200: z.string(), 41 | }, 42 | }, 43 | verifyToken: { 44 | method: "GET", 45 | path: "/auth/verify-token", 46 | responses: { 47 | 200: z.boolean(), 48 | }, 49 | }, 50 | capture: { 51 | method: "POST", 52 | path: "/capture", 53 | body: snapshotInput, 54 | responses: { 55 | 200: z.boolean(), 56 | }, 57 | }, 58 | sync: { 59 | method: "GET", 60 | path: `/sync`, 61 | query: z.object({ 62 | after: z.string().optional(), 63 | }), 64 | responses: { 65 | 200: z.array(Clipping), 66 | }, 67 | }, 68 | }); 69 | 70 | export type Tweet = { 71 | id: string; 72 | text: string; 73 | createdAt: string; 74 | author: Author; 75 | quotedTweet?: QuotedTweet; 76 | attachment?: Attachment; 77 | }; 78 | 79 | export type Attachment = ImageAttachment | LinkAttachment; 80 | 81 | export type ImageAttachment = { 82 | type: "image"; 83 | src: string; 84 | }; 85 | 86 | export type LinkAttachment = { 87 | type: "link"; 88 | src: string; 89 | }; 90 | 91 | export type Author = { 92 | name: string; 93 | username: string; 94 | avatar?: string; 95 | }; 96 | 97 | export type QuotedTweet = { 98 | id?: string; 99 | source?: string; 100 | text: string; 101 | author: Author; 102 | }; 103 | 104 | export type Capture = { 105 | url: string; 106 | type: "twitter" | "webpage"; 107 | title: string; 108 | content: string; 109 | }; 110 | -------------------------------------------------------------------------------- /src/sync-engine.ts: -------------------------------------------------------------------------------- 1 | import { Metadata, parseMarkdown } from "@util/markdown"; 2 | import { ApiClient } from "api"; 3 | import { ClippingType, Provider } from "contract"; 4 | import path from "path"; 5 | import sanitize from "sanitize-filename"; 6 | import { minutes } from "time"; 7 | import { PlatformAdapter } from "./sync-engine.d"; 8 | 9 | type SyncEngineOps = { 10 | basePath: string; 11 | api: ApiClient; 12 | } & PlatformAdapter; 13 | 14 | export class SyncEngine { 15 | private intervalId: number; 16 | 17 | private syncInProgress: boolean; 18 | 19 | constructor(private opts: SyncEngineOps) { 20 | console.log("Initialized Tressel Sync"); 21 | 22 | this.intervalId = opts.setInterval(() => { 23 | this.sync(); 24 | }, minutes(2)); 25 | 26 | opts.onAppBecameActive(() => { 27 | console.log("App became active. Running sync."); 28 | this.sync(); 29 | }); 30 | } 31 | 32 | dispose = () => { 33 | this.opts.clearInterval(this.intervalId); 34 | }; 35 | 36 | sync = async () => { 37 | if (this.syncInProgress) { 38 | console.warn("Sync is already in progress"); 39 | return; 40 | } 41 | this.syncInProgress = true; 42 | try { 43 | const syncData = await this.opts.api.sync(); 44 | 45 | const clippings = syncData.body as ClippingType[]; 46 | for (const clipping of clippings) { 47 | const { metadata } = parseMarkdown(clipping.markdown); 48 | const subpath = getFilePath(clipping.type, metadata); 49 | if (!subpath) { 50 | console.error("Invalid clipping", clipping); 51 | continue; 52 | } 53 | 54 | const filepath = path.join(this.opts.basePath, subpath); 55 | 56 | this.opts.saveFile({ 57 | filepath, 58 | contents: clipping.markdown, 59 | onConflict: "skip", 60 | }); 61 | } 62 | } catch (e) { 63 | throw e; 64 | } finally { 65 | this.syncInProgress = false; 66 | } 67 | }; 68 | } 69 | 70 | function getFilePath(type: Provider, metadata: Metadata): string | undefined { 71 | switch (type) { 72 | case "twitter": 73 | const username = metadata.username as string; 74 | const id = metadata.id as string; 75 | if (!username || !id) return; 76 | 77 | return path.join("Twitter", username, sanitize(id) + `.md`); 78 | case "webpage": 79 | const title = metadata.title as string; 80 | const source = metadata.source as string; 81 | return path.join( 82 | "Web Pages", 83 | new URL(source).hostname, 84 | sanitize(title) + `.md` 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/obsidian-platform-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Platform, Plugin, Vault, normalizePath } from "obsidian"; 2 | import path from "path"; 3 | import { SaveFileOpts } from "./sync-engine.d"; 4 | 5 | export async function saveFile( 6 | vault: Vault, 7 | { filepath, contents, onConflict }: SaveFileOpts 8 | ) { 9 | await createDirectory(vault, path.dirname(filepath)); 10 | const fileExists = await vault.adapter.exists(filepath); 11 | 12 | if (fileExists) { 13 | // there's a conflict 14 | if (onConflict == "skip") { 15 | return; 16 | } else if (onConflict == "replace") { 17 | await vault.adapter.remove(filepath); 18 | } 19 | } 20 | 21 | await vault.create(filepath, contents); 22 | } 23 | 24 | async function createDirectory(vault: Vault, dir: string): Promise { 25 | const { adapter } = vault; 26 | const root = vault.getRoot().path; 27 | const directoryExists = await adapter.exists(dir); 28 | 29 | // =============================================================== 30 | // -> Desktop App 31 | // =============================================================== 32 | if (!Platform.isIosApp && !directoryExists) { 33 | return adapter.mkdir(normalizePath(dir)); 34 | } 35 | // =============================================================== 36 | // -> Mobile App (IOS) 37 | // =============================================================== 38 | // This is a workaround for a bug in the mobile app: 39 | // To get the file explorer view to update correctly, we have to create 40 | // each directory in the path one at time. 41 | 42 | // Split the path into an array of sub paths 43 | // Note: `normalizePath` converts path separators to '/' on all platforms 44 | // @example '/one/two/three/' ==> ['one', 'one/two', 'one/two/three'] 45 | // @example 'one\two\three' ==> ['one', 'one/two', 'one/two/three'] 46 | const subPaths: string[] = normalizePath(dir) 47 | .split("/") 48 | .filter((part) => part.trim() !== "") 49 | .map((_, index, arr) => arr.slice(0, index + 1).join("/")); 50 | 51 | // Create each directory if it does not exist 52 | for (const subPath of subPaths) { 53 | const directoryExists = await adapter.exists(path.join(root, subPath)); 54 | if (!directoryExists) { 55 | await adapter.mkdir(path.join(root, subPath)); 56 | } 57 | } 58 | } 59 | 60 | const ACTIVITY_EVENT_NAMES = [ 61 | "layout-change", 62 | "editor-change", 63 | "resize", 64 | "active-leaf-change", 65 | "codemirror", 66 | "file-open", 67 | "file-menu", 68 | ]; 69 | 70 | export function onAppBecameActive( 71 | plugin: Plugin, 72 | afterMs: number, 73 | cb: () => void 74 | ) { 75 | let lastActive = new Date(); 76 | 77 | const updateLastActive = (...args: any[]) => { 78 | const currentTime = new Date(); 79 | const msInactivity = currentTime.getTime() - lastActive.getTime(); 80 | 81 | if (msInactivity > afterMs) { 82 | console.log(`App became active again after ${msInactivity}ms`); 83 | cb(); 84 | } 85 | 86 | lastActive = currentTime; 87 | }; 88 | 89 | for (const eventName of ACTIVITY_EVENT_NAMES) { 90 | const eventRef = plugin.app.workspace.on(eventName as any, () => { 91 | return updateLastActive(); 92 | }); 93 | plugin.registerEvent(eventRef); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obsidian-tressel 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "20.x" 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | - name: Upload zip file 44 | id: upload-zip 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 51 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 52 | asset_content_type: application/zip 53 | - name: Upload main.js 54 | id: upload-main 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | - name: Upload manifest.json 64 | id: upload-manifest 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./manifest.json 71 | asset_name: manifest.json 72 | asset_content_type: application/json 73 | - name: Upload styles.css 74 | id: upload-css 75 | uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: ./styles.css 81 | asset_name: styles.css 82 | asset_content_type: text/css 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tressel Sync for Obsidian 2 | 3 | ![](https://tressel.xyz/open-graph-image.png) 4 | 5 | Official Tressel plugin to save & export content/highlights from **Twitter, Reddit, Kindle, Pocket, Instapaper, Raindrop, Hypothes.is, Hacker News and more** in [Tressel](https://tressel.xyz) into Obsidian 6 | 7 | ## Instructions 8 | 9 | 1. Install the plugin 10 | 2. Copy your personal token from the Access Token/Obsidian settings section in the [Tressel app](https://app.tressel.xyz) into the Tressel plugin settings in Obsidian 11 | 3. **You're done!** Your saved content will automatically be synced every time you open Obsidian 12 | 4. *Optional - you can click the Sync Tressel button in the side ribbon to manually sync from Tressel* 13 | 5. *Optional - you can click Clear Sync Memory in the [Tressel app](https://app.tressel.xyz) settings to resync all your content from scratch* 14 | 15 | ## Notes 16 | 17 | For feature requests, to report bugs or request help using the plugin, please email hello@tressel.xyz, create an issue or use the Help & Support form in the [Tressel app](https://app.tressel.xyz) 18 | 19 | ## Changelog 20 | - 0.2.8 21 | - Updated some broken links 22 | - 0.2.7 23 | - Support for Raindrops (i.e. links/bookmarks) 24 | - 0.2.6 25 | - Added Subfolder organization preference 26 | - Added Remove main title heading from highlights preference 27 | - 0.2.5 28 | - Support for Hypothes.is annotations/highlights 29 | - 0.2.4 30 | - Support for Raindrop highlights 31 | - 0.2.3 32 | - Support for Instapaper highlights 33 | - 0.2.2 34 | - Support for Hacker News highlights 35 | - 0.2.1 36 | - Only create subfolders if the content exists for those folders 37 | - 0.2.0 38 | - Fix issues syncing large numbers of highlights to Obsidian 39 | - Fix Tressel folders not being created before sync 40 | - Add Help & Support links and resync button to plugin settings 41 | - Internal improvements 42 | - Modularize code (by function) 43 | - 0.1.9 44 | - Support for Pocket highlights 45 | - 0.1.8 46 | - Support for generic highlights 47 | - Change folder name from Settings 48 | - Clear Sync Memory from Settings 49 | - Add folders to organize highlights (e.g. tweets go into /Twitter/Tweets subdirectory) 50 | - 0.1.7 51 | - Fix random "Invalid token provided" errors 52 | - 0.1.6 53 | - Support for syncing Kindle highlights from Tressel 54 | - 0.1.5 55 | - Support for syncing Reddit posts and comments from Tressel 56 | - 0.1.4 57 | - Update API URL 58 | - 0.1.3 59 | - Clear Sync Memory now in Tressel dashboard settings (vs previously in plugin settings) 60 | - Performance improvements in fetching new tweets/tweet collections from Tressel 61 | - Internal improvements: 62 | - Additional error message logging (for help with support enquiries) 63 | - Fetch data from Node.js server with token authentication (previously Firebase serverless) 64 | - 0.1.2 65 | - Export conversations to Obsidian (new feature) 66 | - 0.1.1 67 | - Fix Markdown template spacing, metadata and title issues 68 | - 0.1.0 69 | - Adds images to synced tweets and threads from Tressel 70 | - Internal improvements: 71 | - Use Obsidian Vault API instead of Adapter API 72 | - Use Obsidian request API instead of axios (for greater mobile compatibility and smaller bundle size) 73 | - Use async/await instead of .then (for better code readability) 74 | - 0.0.2 75 | - Restricts the plugin for desktop use only (due to lack of testing on mobile) 76 | - 0.0.1 77 | - Initial release 78 | - Sync your tweets and threads from Tressel to Obsidian (text-only) 79 | -------------------------------------------------------------------------------- /src/settings/suggesters/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { App, ISuggestOwner, Scope } from "obsidian"; 4 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor( 18 | owner: ISuggestOwner, 19 | containerEl: HTMLElement, 20 | scope: Scope 21 | ) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | 25 | containerEl.on( 26 | "click", 27 | ".suggestion-item", 28 | this.onSuggestionClick.bind(this) 29 | ); 30 | containerEl.on( 31 | "mousemove", 32 | ".suggestion-item", 33 | this.onSuggestionMouseover.bind(this) 34 | ); 35 | 36 | scope.register([], "ArrowUp", (event) => { 37 | if (!event.isComposing) { 38 | this.setSelectedItem(this.selectedItem - 1, true); 39 | return false; 40 | } 41 | }); 42 | 43 | scope.register([], "ArrowDown", (event) => { 44 | if (!event.isComposing) { 45 | this.setSelectedItem(this.selectedItem + 1, true); 46 | return false; 47 | } 48 | }); 49 | 50 | scope.register([], "Enter", (event) => { 51 | if (!event.isComposing) { 52 | this.useSelectedItem(event); 53 | return false; 54 | } 55 | }); 56 | } 57 | 58 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 59 | event.preventDefault(); 60 | 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | this.useSelectedItem(event); 64 | } 65 | 66 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 67 | const item = this.suggestions.indexOf(el); 68 | this.setSelectedItem(item, false); 69 | } 70 | 71 | setSuggestions(values: T[]) { 72 | this.containerEl.empty(); 73 | const suggestionEls: HTMLDivElement[] = []; 74 | 75 | values.forEach((value) => { 76 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 77 | this.owner.renderSuggestion(value, suggestionEl); 78 | suggestionEls.push(suggestionEl); 79 | }); 80 | 81 | this.values = values; 82 | this.suggestions = suggestionEls; 83 | this.setSelectedItem(0, false); 84 | } 85 | 86 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 87 | const currentValue = this.values[this.selectedItem]; 88 | if (currentValue) { 89 | this.owner.selectSuggestion(currentValue, event); 90 | } 91 | } 92 | 93 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 94 | const normalizedIndex = wrapAround( 95 | selectedIndex, 96 | this.suggestions.length 97 | ); 98 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 99 | const selectedSuggestion = this.suggestions[normalizedIndex]; 100 | 101 | prevSelectedSuggestion?.removeClass("is-selected"); 102 | selectedSuggestion?.addClass("is-selected"); 103 | 104 | this.selectedItem = normalizedIndex; 105 | 106 | if (scrollIntoView) { 107 | selectedSuggestion.scrollIntoView(false); 108 | } 109 | } 110 | } 111 | 112 | export abstract class TextInputSuggest implements ISuggestOwner { 113 | protected app: App; 114 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 115 | 116 | private popper: PopperInstance; 117 | private scope: Scope; 118 | private suggestEl: HTMLElement; 119 | private suggest: Suggest; 120 | 121 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 122 | this.app = app; 123 | this.inputEl = inputEl; 124 | this.scope = new Scope(); 125 | 126 | this.suggestEl = createDiv("suggestion-container"); 127 | const suggestion = this.suggestEl.createDiv("suggestion"); 128 | this.suggest = new Suggest(this, suggestion, this.scope); 129 | 130 | this.scope.register([], "Escape", this.close.bind(this)); 131 | 132 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 133 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 134 | this.inputEl.addEventListener("blur", this.close.bind(this)); 135 | this.suggestEl.on( 136 | "mousedown", 137 | ".suggestion-container", 138 | (event: MouseEvent) => { 139 | event.preventDefault(); 140 | } 141 | ); 142 | } 143 | 144 | onInputChanged(): void { 145 | const inputStr = this.inputEl.value; 146 | const suggestions = this.getSuggestions(inputStr); 147 | 148 | if (!suggestions) { 149 | this.close(); 150 | return; 151 | } 152 | 153 | if (suggestions.length > 0) { 154 | this.suggest.setSuggestions(suggestions); 155 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 156 | this.open((this.app).dom.appContainerEl, this.inputEl); 157 | } else { 158 | this.close(); 159 | } 160 | } 161 | 162 | open(container: HTMLElement, inputEl: HTMLElement): void { 163 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 164 | (this.app).keymap.pushScope(this.scope); 165 | 166 | container.appendChild(this.suggestEl); 167 | this.popper = createPopper(inputEl, this.suggestEl, { 168 | placement: "bottom-start", 169 | modifiers: [ 170 | { 171 | name: "sameWidth", 172 | enabled: true, 173 | fn: ({ state, instance }) => { 174 | // Note: positioning needs to be calculated twice - 175 | // first pass - positioning it according to the width of the popper 176 | // second pass - position it with the width bound to the reference element 177 | // we need to early exit to avoid an infinite loop 178 | const targetWidth = `${state.rects.reference.width}px`; 179 | if (state.styles.popper.width === targetWidth) { 180 | return; 181 | } 182 | state.styles.popper.width = targetWidth; 183 | instance.update(); 184 | }, 185 | phase: "beforeWrite", 186 | requires: ["computeStyles"], 187 | }, 188 | ], 189 | }); 190 | } 191 | 192 | close(): void { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (this.app).keymap.popScope(this.scope); 195 | 196 | this.suggest.setSuggestions([]); 197 | if (this.popper) this.popper.destroy(); 198 | this.suggestEl.detach(); 199 | } 200 | 201 | abstract getSuggestions(inputStr: string): T[]; 202 | abstract renderSuggestion(item: T, el: HTMLElement): void; 203 | abstract selectSuggestion(item: T): void; 204 | } 205 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient, createApi } from "api"; 2 | import { shell } from "electron"; 3 | import { LegacyApi } from "helpers/legacy-api"; 4 | import { 5 | createTresselSyncFolder, 6 | syncTresselUserData, 7 | } from "helpers/legacy-sync"; 8 | import { 9 | App, 10 | Notice, 11 | Plugin, 12 | PluginSettingTab, 13 | Setting, 14 | debounce, 15 | } from "obsidian"; 16 | import { onAppBecameActive, saveFile } from "obsidian-platform-adapter"; 17 | 18 | import { FolderSuggest } from "settings/suggesters/FolderSuggester"; 19 | import { SyncEngine } from "sync-engine"; 20 | import { minutes } from "time"; 21 | 22 | export interface TresselPluginSettings { 23 | tresselAccessToken: string; 24 | syncFolder: string; 25 | subFolders: boolean; 26 | removeMainHeading: boolean; 27 | } 28 | 29 | const DEFAULT_SETTINGS: TresselPluginSettings = { 30 | tresselAccessToken: "", 31 | syncFolder: "🗃️ Tressel", 32 | subFolders: true, 33 | removeMainHeading: false, 34 | }; 35 | 36 | export default class TresselPlugin extends Plugin { 37 | settings: TresselPluginSettings; 38 | tokenValid: boolean; 39 | userSubscribed: boolean; 40 | 41 | legacyApi: LegacyApi; 42 | api: ApiClient; 43 | syncEngine: SyncEngine; 44 | 45 | async onload() { 46 | console.info("Loading Tressel Sync for Obsidian plugin"); 47 | await this.loadSettings(); 48 | 49 | // Create a Tressel sync button in the left ribbon. 50 | this.addRibbonIcon("sync", "Sync Tressel", async (evt: MouseEvent) => { 51 | // Called when the user clicks the button. 52 | await this.syncTressel(); 53 | await this.saveSettings(); 54 | }); 55 | 56 | // This adds a settings tab so the user can configure various aspects of the plugin 57 | const settingsTab = new TresselSettingTab(this.app, this); 58 | this.addSettingTab(settingsTab); 59 | 60 | this.legacyApi = new LegacyApi(this.settings.tresselAccessToken); 61 | this.api = createApi(() => this.settings.tresselAccessToken); 62 | 63 | this.syncEngine = new SyncEngine({ 64 | api: this.api, 65 | basePath: this.settings.syncFolder, 66 | setInterval: (cb, interval) => { 67 | const id = window.setInterval(cb, interval); 68 | this.registerInterval(id); 69 | return id; 70 | }, 71 | clearInterval: (id) => clearInterval(id), 72 | saveFile: (opts) => saveFile(this.app.vault, opts), 73 | onAppBecameActive: (cb) => 74 | onAppBecameActive(this, minutes(0.1), cb), 75 | }); 76 | 77 | await this.saveSettings(); 78 | 79 | this.app.workspace.onLayoutReady(() => 80 | this.initializeTressel(settingsTab) 81 | ); 82 | 83 | this.addCommand({ 84 | id: "run-tressel-sync", 85 | name: "Sync Tressel", 86 | callback: () => { 87 | this.syncTressel(true); 88 | }, 89 | }); 90 | } 91 | 92 | async initializeTressel(settingsTab: TresselSettingTab) { 93 | try { 94 | // Verify token 95 | await this.verifyToken(settingsTab); 96 | if (this.tokenValid) { 97 | await this.syncTressel(false); 98 | } else { 99 | new Notice( 100 | "Unable to sync from Tressel - Invalid Token provided" 101 | ); 102 | } 103 | } catch {} 104 | } 105 | 106 | async syncTressel(notify: boolean = false) { 107 | if (notify) { 108 | new Notice("Starting Tressel Sync"); 109 | } 110 | 111 | if (this.settings.tresselAccessToken === "") { 112 | new Notice( 113 | "Unable to sync from Tressel - please fill out your Tressel user token before syncing" 114 | ); 115 | return; 116 | } 117 | 118 | await this.syncEngine.sync(); 119 | 120 | try { 121 | this.legacyApi.updateClient(this.settings.tresselAccessToken); 122 | let userData = {} as any; 123 | 124 | do { 125 | userData = await ( 126 | await this.legacyApi.syncObsidianUserData() 127 | ).data; 128 | 129 | if ( 130 | userData.hasOwnProperty("message") && 131 | (userData.message as string).includes("Error") 132 | ) { 133 | new Notice( 134 | "Unable to sync from Tressel - Invalid Token provided" 135 | ); 136 | return; 137 | } 138 | 139 | await createTresselSyncFolder(this.app, this.settings); 140 | await syncTresselUserData(userData, this.app, this.settings); 141 | } while (Object.keys(userData).length > 0); 142 | } catch (error) { 143 | console.error("Error while syncing from Tressel -", error); 144 | new Notice( 145 | "Error while syncing from Tressel - check the console for logs" 146 | ); 147 | return; 148 | } 149 | 150 | if (!notify) { 151 | new Notice("Finished Tressel Sync"); 152 | } 153 | } 154 | 155 | onunload() {} 156 | 157 | async loadSettings() { 158 | this.settings = Object.assign( 159 | {}, 160 | DEFAULT_SETTINGS, 161 | await this.loadData() 162 | ); 163 | } 164 | 165 | async saveSettings() { 166 | await this.saveData(this.settings); 167 | } 168 | 169 | verifyToken = async (settingsTab: TresselSettingTab): Promise => { 170 | try { 171 | this.legacyApi = this.legacyApi.updateClient( 172 | this.settings.tresselAccessToken 173 | ); 174 | const verifiedResult = await this.legacyApi.verifyAccessToken(); 175 | this.tokenValid = verifiedResult.data.valid; 176 | try { 177 | this.userSubscribed = verifiedResult.data.subscribed; 178 | } catch (error) { 179 | this.userSubscribed = false; 180 | } 181 | } catch (error) { 182 | this.tokenValid = false; 183 | this.userSubscribed = false; 184 | } 185 | 186 | await this.saveSettings(); 187 | if (settingsTab.containerEl.querySelector("#settingsContainer")) { 188 | settingsTab.displaySettings(); 189 | } 190 | }; 191 | 192 | debouncedVerifyToken = debounce(this.verifyToken, 500); 193 | } 194 | 195 | class TresselSettingTab extends PluginSettingTab { 196 | plugin: TresselPlugin; 197 | 198 | constructor(app: App, plugin: TresselPlugin) { 199 | super(app, plugin); 200 | this.plugin = plugin; 201 | } 202 | 203 | display(): void { 204 | const { containerEl } = this; 205 | 206 | containerEl.empty(); 207 | 208 | containerEl.createEl("h2", { text: "Tressel Settings" }); 209 | 210 | new Setting(containerEl) 211 | .setName("Tressel Access Token") 212 | .setDesc( 213 | "Get your unique access token from the Obsidian/Access Token page in Tressel's integration settings" 214 | ) 215 | .addText((text) => 216 | text 217 | .setPlaceholder("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX") 218 | .setValue(this.plugin.settings.tresselAccessToken) 219 | .onChange(async (value) => { 220 | this.plugin.settings.tresselAccessToken = value; 221 | this.plugin.debouncedVerifyToken(this); 222 | await this.plugin.saveSettings(); 223 | }) 224 | ); 225 | 226 | containerEl.createDiv({ 227 | attr: { 228 | id: "settingsContainer", 229 | }, 230 | }); 231 | 232 | this.displaySettings(); 233 | } 234 | 235 | async displaySettings(): Promise { 236 | const { containerEl } = this; 237 | const settingsContainer = containerEl.querySelector( 238 | "#settingsContainer" 239 | ) as HTMLElement; 240 | settingsContainer.empty(); 241 | 242 | if (this.plugin.tokenValid) { 243 | settingsContainer.createEl("h3", { text: "Preferences" }); 244 | settingsContainer.createDiv({ 245 | attr: { 246 | id: "preferencesContainer", 247 | }, 248 | }); 249 | 250 | await this.loadPreferences(); 251 | 252 | settingsContainer.createEl("h3", { text: "Help & Support" }); 253 | settingsContainer.createDiv({ 254 | attr: { 255 | id: "supportContainer", 256 | }, 257 | }); 258 | 259 | await this.loadSupport(); 260 | } else { 261 | settingsContainer.createEl("p", { 262 | text: "Invalid token - please enter the right token to sync your highlights from Tressel. You can find your Tressel access token in the Tressel integration settings page", 263 | cls: "tressel-invalid-token-error", 264 | }); 265 | 266 | settingsContainer.createEl( 267 | "button", 268 | { text: "⚙️ Go to integration settings" }, 269 | (button) => { 270 | button.onClickEvent((e) => { 271 | shell.openExternal( 272 | "https://app.tressel.xyz/connect/obsidian" 273 | ); 274 | }); 275 | } 276 | ); 277 | } 278 | } 279 | 280 | async loadPreferences(): Promise { 281 | const { containerEl } = this; 282 | 283 | const preferencesContainer = containerEl.querySelector( 284 | "#preferencesContainer" 285 | ) as HTMLElement; 286 | preferencesContainer.empty(); 287 | 288 | new Setting(preferencesContainer) 289 | .setName("Clear Sync Memory") 290 | .setDesc( 291 | "Press this if you want to re-sync all your highlights from scratch (including ones already synced)" 292 | ) 293 | .addButton((button) => { 294 | button.setButtonText("☁️ Clear Sync Memory").onClick(() => { 295 | new Notice("Clearing Obsidian sync memory..."); 296 | this.plugin.legacyApi.updateClient( 297 | this.plugin.settings.tresselAccessToken 298 | ); 299 | this.plugin.legacyApi 300 | .clearObsidianSyncMemory() 301 | .then(() => { 302 | new Notice( 303 | "Successfully cleared Obsidian sync memory" 304 | ); 305 | }) 306 | .catch((error) => { 307 | console.error( 308 | "Error clearing Obsidian sync memory - ", 309 | error 310 | ); 311 | new Notice( 312 | "Error clearing Obsidian sync memory - check console for errors" 313 | ); 314 | }); 315 | }); 316 | }); 317 | 318 | new Setting(preferencesContainer) 319 | .setName("Folder Name") 320 | .setDesc( 321 | "Choose the folder you'd like your Tressel highlights to be stored in. If it doesn't exist, Tressel will automatically create it" 322 | ) 323 | .addSearch((search) => { 324 | new FolderSuggest(this.app, search.inputEl); 325 | search 326 | .setPlaceholder("Example: folder1/folder2") 327 | .setValue(this.plugin.settings.syncFolder) 328 | .onChange((newFolder) => { 329 | this.plugin.settings.syncFolder = newFolder; 330 | this.plugin.saveSettings(); 331 | // this.checkIfFolderNameValid(); 332 | }); 333 | }); 334 | 335 | new Setting(preferencesContainer) 336 | .setName("Subfolder Organization") 337 | .setDesc( 338 | "Sync into separate folders based on type. For example, Twitter threads will be saved to /Twitter/Tweet Collections. Leave this off if you want a flat file structure." 339 | ) 340 | .addToggle((toggle) => { 341 | toggle 342 | .setValue(this.plugin.settings.subFolders) 343 | .onChange((newToggle) => { 344 | this.plugin.settings.subFolders = newToggle; 345 | this.plugin.saveSettings(); 346 | }); 347 | }); 348 | 349 | new Setting(preferencesContainer) 350 | .setName("Remove Main Title Heading") 351 | .setDesc( 352 | "Remove the main title heading from synced highlight pages. This is so you don't view two titles when viewing in Obsidian." 353 | ) 354 | .addToggle((toggle) => { 355 | toggle 356 | .setValue(this.plugin.settings.removeMainHeading) 357 | .onChange((newToggle) => { 358 | this.plugin.settings.removeMainHeading = newToggle; 359 | this.plugin.saveSettings(); 360 | }); 361 | }); 362 | 363 | preferencesContainer.createEl( 364 | "button", 365 | { text: "🔃 Resync" }, 366 | (button) => { 367 | button.onClickEvent((e) => { 368 | this.plugin.syncTressel(true); 369 | }); 370 | } 371 | ); 372 | } 373 | 374 | async loadSupport(): Promise { 375 | const { containerEl } = this; 376 | 377 | const supportContainer = containerEl.querySelector( 378 | "#supportContainer" 379 | ) as HTMLElement; 380 | supportContainer.empty(); 381 | 382 | supportContainer.createEl("p", { 383 | text: "Need help? Just send an email to hello@tressel.xyz. Expect a response in 24-48hrs", 384 | }); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/helpers/legacy-sync.ts: -------------------------------------------------------------------------------- 1 | import { TresselPluginSettings } from "main"; 2 | import { App, TFile, TFolder } from "obsidian"; 3 | import sanitize from "sanitize-filename"; 4 | import TurndownService from "turndown"; 5 | 6 | export const createTresselSyncFolder = async ( 7 | app: App, 8 | settings: TresselPluginSettings 9 | ) => { 10 | const tresselFolder = app.vault.getAbstractFileByPath(settings.syncFolder); 11 | const tresselFolderExists = tresselFolder instanceof TFolder; 12 | 13 | if (!tresselFolderExists) { 14 | try { 15 | await app.vault.createFolder(settings.syncFolder); 16 | } catch (error) { 17 | console.info("Error creating Tressel sync folder - ", error); 18 | } 19 | } 20 | }; 21 | 22 | export const createSyncSubfolder = async ( 23 | subfolderPath: string, 24 | app: App, 25 | settings: TresselPluginSettings 26 | ) => { 27 | try { 28 | await app.vault.createFolder(settings.syncFolder + subfolderPath); 29 | } catch (error) { 30 | console.info( 31 | `Error while creating Tressel ${subfolderPath} folder -`, 32 | error 33 | ); 34 | } 35 | }; 36 | 37 | export const syncTresselUserData = async ( 38 | userData: any, 39 | app: App, 40 | settings: TresselPluginSettings 41 | ) => { 42 | if (userData.hasOwnProperty("tweets") && userData.tweets.length > 0) { 43 | if (settings.subFolders) { 44 | await createSyncSubfolder("/Twitter", app, settings); 45 | await createSyncSubfolder("/Twitter/Tweets", app, settings); 46 | } 47 | 48 | for (let tweet of userData.tweets) { 49 | await syncTweetToObsidian(tweet, app, settings); 50 | } 51 | } 52 | 53 | if ( 54 | userData.hasOwnProperty("tweetCollections") && 55 | userData.tweetCollections.length > 0 56 | ) { 57 | if (settings.subFolders) { 58 | await createSyncSubfolder("/Twitter", app, settings); 59 | await createSyncSubfolder( 60 | "/Twitter/Tweet Collections", 61 | app, 62 | settings 63 | ); 64 | } 65 | 66 | for (let tweetCollection of userData.tweetCollections) { 67 | await syncTweetCollectionToObsidian(tweetCollection, app, settings); 68 | } 69 | } 70 | 71 | if ( 72 | userData.hasOwnProperty("redditComments") && 73 | userData.redditComments.length > 0 74 | ) { 75 | if (settings.subFolders) { 76 | await createSyncSubfolder("/Reddit", app, settings); 77 | await createSyncSubfolder("/Reddit/Comments", app, settings); 78 | } 79 | 80 | for (let redditComment of userData.redditComments) { 81 | await syncRedditCommentToObsidian(redditComment, app, settings); 82 | } 83 | } 84 | 85 | if ( 86 | userData.hasOwnProperty("redditPosts") && 87 | userData.redditPosts.length > 0 88 | ) { 89 | if (settings.subFolders) { 90 | await createSyncSubfolder("/Reddit", app, settings); 91 | await createSyncSubfolder("/Reddit/Posts", app, settings); 92 | } 93 | 94 | for (let redditPost of userData.redditPosts) { 95 | await syncRedditPostToObsidian(redditPost, app, settings); 96 | } 97 | } 98 | 99 | if ( 100 | userData.hasOwnProperty("kindleHighlights") && 101 | userData.kindleHighlights.length > 0 102 | ) { 103 | if (settings.subFolders) { 104 | await createSyncSubfolder("/Kindle Highlights", app, settings); 105 | } 106 | for (let kindleHighlight of userData.kindleHighlights) { 107 | await syncKindleHighlightToObsidian(kindleHighlight, app, settings); 108 | } 109 | } 110 | 111 | if ( 112 | userData.hasOwnProperty("genericHighlights") && 113 | userData.genericHighlights.length > 0 114 | ) { 115 | if (settings.subFolders) { 116 | await createSyncSubfolder("/Highlights", app, settings); 117 | } 118 | for (let genericHighlight of userData.genericHighlights) { 119 | await syncGenericHighlightToObsidian( 120 | genericHighlight, 121 | app, 122 | settings 123 | ); 124 | } 125 | } 126 | 127 | if ( 128 | userData.hasOwnProperty("pocketHighlights") && 129 | userData.pocketHighlights.length > 0 130 | ) { 131 | if (settings.subFolders) { 132 | await createSyncSubfolder("/Pocket", app, settings); 133 | } 134 | for (let pocketHighlight of userData.pocketHighlights) { 135 | await syncPocketHighlightToObsidian(pocketHighlight, app, settings); 136 | } 137 | } 138 | 139 | if ( 140 | userData.hasOwnProperty("hackerNewsHighlights") && 141 | userData.hackerNewsHighlights.length > 0 142 | ) { 143 | if (settings.subFolders) { 144 | await createSyncSubfolder("/Hacker News", app, settings); 145 | } 146 | for (let hackerNewsHighlight of userData.hackerNewsHighlights) { 147 | await syncHackerNewsHighlightToObsidian( 148 | hackerNewsHighlight, 149 | app, 150 | settings 151 | ); 152 | } 153 | } 154 | 155 | if ( 156 | userData.hasOwnProperty("instapaperHighlights") && 157 | userData.instapaperHighlights.length > 0 158 | ) { 159 | if (settings.subFolders) { 160 | await createSyncSubfolder("/Instapaper", app, settings); 161 | } 162 | for (let instapaperHighlight of userData.instapaperHighlights) { 163 | await syncInstapaperHighlightToObsidian( 164 | instapaperHighlight, 165 | app, 166 | settings 167 | ); 168 | } 169 | } 170 | 171 | if ( 172 | userData.hasOwnProperty("raindropHighlights") && 173 | userData.raindropHighlights.length > 0 174 | ) { 175 | if (settings.subFolders) { 176 | await createSyncSubfolder("/Raindrop", app, settings); 177 | } 178 | for (let raindropHighlight of userData.raindropHighlights) { 179 | await syncRaindropHighlightToObsidian( 180 | raindropHighlight, 181 | app, 182 | settings 183 | ); 184 | } 185 | } 186 | 187 | if (userData.hasOwnProperty("raindrops") && userData.raindrops.length > 0) { 188 | if (settings.subFolders) { 189 | await createSyncSubfolder("/Raindrop", app, settings); 190 | } 191 | for (let raindrop of userData.raindrops) { 192 | await syncRaindropToObsidian(raindrop, app, settings); 193 | } 194 | } 195 | 196 | if ( 197 | userData.hasOwnProperty("hypothesisAnnotations") && 198 | userData.hypothesisAnnotations.length > 0 199 | ) { 200 | if (settings.subFolders) { 201 | await createSyncSubfolder("/Hypothesis", app, settings); 202 | } 203 | for (let hypothesisAnnotation of userData.hypothesisAnnotations) { 204 | await syncHypothesisAnnotationToObsidian( 205 | hypothesisAnnotation, 206 | app, 207 | settings 208 | ); 209 | } 210 | } 211 | }; 212 | 213 | const syncTweetToObsidian = async ( 214 | tweet: any, 215 | app: App, 216 | settings: TresselPluginSettings 217 | ) => { 218 | try { 219 | // Create new page for tweet in Tressel directory 220 | const mainHeading = `# ${tweet.text 221 | .replace(/(\r\n|\n|\r)/gm, " ") 222 | .slice(0, 50)}...`; 223 | 224 | let templateArray = []; 225 | let metadataArray = [ 226 | `## Metadata`, 227 | `- Author: [${tweet.author.name}](https://twitter.com/${tweet.author.username})`, 228 | `- Type: 🐤 Tweet #tweet`, 229 | `- URL: ${tweet.url}\n`, 230 | `## Tweet`, 231 | `${tweet.text}\n`, 232 | ]; 233 | 234 | if (!settings.removeMainHeading) { 235 | templateArray.push(mainHeading); 236 | templateArray = templateArray.concat(metadataArray); 237 | } else { 238 | templateArray = metadataArray; 239 | } 240 | 241 | if (tweet.media) { 242 | for (let mediaEntity of tweet.media) { 243 | templateArray.push(`![](${mediaEntity.url})\n`); 244 | } 245 | } 246 | 247 | let template = templateArray.join("\n"); 248 | 249 | let folderString = "/"; 250 | if (settings.subFolders) { 251 | folderString = "/Twitter/Tweets/"; 252 | } 253 | 254 | await app.vault.create( 255 | settings.syncFolder + 256 | folderString + 257 | sanitize( 258 | tweet.text 259 | .replace(/(\r\n|\n|\r)/gm, " ") 260 | .replace("\n\n", " ") 261 | .replace("\n\n\n", " ") 262 | .slice(0, 50) 263 | ) + 264 | ".md", 265 | template 266 | ); 267 | } catch (error) { 268 | console.error(`Error syncing tweet ${tweet.url} -`, error); 269 | } 270 | }; 271 | 272 | const syncTweetCollectionToObsidian = async ( 273 | tweetCollection: any, 274 | app: App, 275 | settings: TresselPluginSettings 276 | ) => { 277 | if (tweetCollection.type === 1) { 278 | try { 279 | // It's a thread 280 | // Create new page for thread in Tressel directory 281 | const mainHeading = `# ${tweetCollection.tweets[0].text 282 | .replace(/(\r\n|\n|\r)/gm, " ") 283 | .slice(0, 50)}...`; 284 | 285 | let templateArray = []; 286 | let metadataArray = [ 287 | `## Metadata`, 288 | `- Author: [${tweetCollection.author.name}](https://twitter.com/${tweetCollection.author.username})`, 289 | `- Type: 🧵 Thread #thread`, 290 | `- URL: ${tweetCollection.url}\n`, 291 | `## Thread`, 292 | ]; 293 | 294 | if (!settings.removeMainHeading) { 295 | templateArray.push(mainHeading); 296 | templateArray = templateArray.concat(metadataArray); 297 | } else { 298 | templateArray = metadataArray; 299 | } 300 | 301 | for (let tweet of tweetCollection.tweets) { 302 | templateArray.push(`${tweet.text}\n`); 303 | if (tweet.media) { 304 | for (let mediaEntity of tweet.media) { 305 | templateArray.push(`![](${mediaEntity.url})\n`); 306 | } 307 | } 308 | } 309 | 310 | let template = templateArray.join("\n"); 311 | 312 | let folderString = "/"; 313 | if (settings.subFolders) { 314 | folderString = "/Twitter/Tweet Collections/"; 315 | } 316 | 317 | await app.vault.create( 318 | settings.syncFolder + 319 | folderString + 320 | sanitize( 321 | tweetCollection.tweets[0].text 322 | .replace(/(\r\n|\n|\r)/gm, " ") 323 | .replace("\n\n", " ") 324 | .replace("\n\n\n", " ") 325 | .slice(0, 50) 326 | ) + 327 | ".md", 328 | template 329 | ); 330 | } catch (error) { 331 | console.error( 332 | `Error syncing thread ${tweetCollection.url} -`, 333 | error 334 | ); 335 | } 336 | } else if (tweetCollection.type === 2) { 337 | try { 338 | // It's a conversation 339 | // Create new page for conversation in Tressel directory 340 | const mainHeading = `# ${tweetCollection.tweets[0].text 341 | .replace(/(\r\n|\n|\r)/gm, " ") 342 | .slice(0, 50)}...`; 343 | 344 | let templateArray = []; 345 | let metadataArray = [ 346 | `## Metadata`, 347 | `- Author: [${tweetCollection.author.name}](https://twitter.com/${tweetCollection.author.username})`, 348 | `- Type: 💬 Conversation #conversation`, 349 | `- URL: ${tweetCollection.url}\n`, 350 | `## Conversation`, 351 | ]; 352 | 353 | if (!settings.removeMainHeading) { 354 | templateArray.push(mainHeading); 355 | templateArray = templateArray.concat(metadataArray); 356 | } else { 357 | templateArray = metadataArray; 358 | } 359 | 360 | for (let tweet of tweetCollection.tweets) { 361 | templateArray.push( 362 | `**[${tweet.author.name} (@${tweet.author.username})](${tweet.author.url})**\n` 363 | ); 364 | templateArray.push(`${tweet.text}\n`); 365 | if (tweet.media) { 366 | for (let mediaEntity of tweet.media) { 367 | templateArray.push(`![](${mediaEntity.url})\n`); 368 | } 369 | } 370 | templateArray.push(`---\n`); 371 | } 372 | 373 | let template = templateArray.join("\n"); 374 | 375 | let folderString = "/"; 376 | if (settings.subFolders) { 377 | folderString = "/Twitter/Tweet Collections/"; 378 | } 379 | 380 | await app.vault.create( 381 | settings.syncFolder + 382 | folderString + 383 | sanitize( 384 | tweetCollection.tweets[0].text 385 | .replace(/(\r\n|\n|\r)/gm, " ") 386 | .replace("\n\n", " ") 387 | .replace("\n\n\n", " ") 388 | .slice(0, 50) 389 | ) + 390 | ".md", 391 | template 392 | ); 393 | } catch (error) { 394 | console.error( 395 | `Error syncing conversation ${tweetCollection.url} -`, 396 | error 397 | ); 398 | } 399 | } 400 | }; 401 | 402 | const syncRedditCommentToObsidian = async ( 403 | redditComment: any, 404 | app: App, 405 | settings: TresselPluginSettings 406 | ) => { 407 | try { 408 | // Create new page for redditComment in Tressel directory 409 | const mainHeading = `# ${redditComment.text 410 | .replace(/(\r\n|\n|\r)/gm, " ") 411 | .slice(0, 50)}...`; 412 | 413 | let templateArray = []; 414 | let metadataArray = [ 415 | `## Metadata`, 416 | `- Subreddit: [r/${redditComment.subreddit}](https://reddit.com/r/${redditComment.subreddit})`, 417 | `- Author: [u/${redditComment.author.username}](https://reddit.com/user/${redditComment.author.username})`, 418 | `- Type: 👾 Reddit Comment #reddit-comment`, 419 | `- URL: ${redditComment.url}\n`, 420 | `## Comment`, 421 | `${redditComment.text}\n`, 422 | ]; 423 | 424 | if (!settings.removeMainHeading) { 425 | templateArray.push(mainHeading); 426 | templateArray = templateArray.concat(metadataArray); 427 | } else { 428 | templateArray = metadataArray; 429 | } 430 | 431 | let template = templateArray.join("\n"); 432 | 433 | let folderString = "/"; 434 | if (settings.subFolders) { 435 | folderString = "/Reddit/Comments/"; 436 | } 437 | 438 | await app.vault.create( 439 | settings.syncFolder + 440 | folderString + 441 | sanitize( 442 | redditComment.text 443 | .replace(/(\r\n|\n|\r)/gm, " ") 444 | .replace("\n\n", " ") 445 | .replace("\n\n\n", " ") 446 | .slice(0, 50) 447 | ) + 448 | ".md", 449 | template 450 | ); 451 | } catch (error) { 452 | console.error( 453 | `Error syncing redditComment ${redditComment.url} -`, 454 | error 455 | ); 456 | } 457 | }; 458 | 459 | const syncRedditPostToObsidian = async ( 460 | redditPost: any, 461 | app: App, 462 | settings: TresselPluginSettings 463 | ) => { 464 | try { 465 | // Create new page for redditPost in Tressel directory 466 | const mainHeading = `# ${redditPost.title.replace( 467 | /(\r\n|\n|\r)/gm, 468 | " " 469 | )}`; 470 | 471 | let templateArray = []; 472 | let metadataArray = [ 473 | , 474 | `## Metadata`, 475 | `- Subreddit: [r/${redditPost.subreddit}](https://reddit.com/r/${redditPost.subreddit})`, 476 | `- Author: [u/${redditPost.author.username}](https://reddit.com/user/${redditPost.author.username})`, 477 | `- Type: 👾 Reddit Post #reddit-post`, 478 | `- URL: ${redditPost.url}\n`, 479 | `## Post`, 480 | `${redditPost.text ? redditPost.text + "\n" : ""}`, 481 | ]; 482 | 483 | if (!settings.removeMainHeading) { 484 | templateArray.push(mainHeading); 485 | templateArray = templateArray.concat(metadataArray); 486 | } else { 487 | templateArray = metadataArray; 488 | } 489 | 490 | if (redditPost.media) { 491 | for (let mediaEntity of redditPost.media) { 492 | if (mediaEntity.type === 1) { 493 | // It's an image 494 | templateArray.push(`![](${mediaEntity.url})\n`); 495 | } else if (mediaEntity.type === 2) { 496 | // It's a video 497 | templateArray.push(`[Video](${mediaEntity.url})\n`); 498 | } 499 | } 500 | } 501 | 502 | let template = templateArray.join("\n"); 503 | 504 | let folderString = "/"; 505 | if (settings.subFolders) { 506 | folderString = "/Reddit/Posts/"; 507 | } 508 | 509 | await app.vault.create( 510 | settings.syncFolder + 511 | folderString + 512 | sanitize( 513 | redditPost.title 514 | .replace(/(\r\n|\n|\r)/gm, " ") 515 | .replace("\n\n", " ") 516 | .replace("\n\n\n", " ") 517 | .slice(0, 50) 518 | ) + 519 | ".md", 520 | template 521 | ); 522 | } catch (error) { 523 | console.error(`Error syncing redditPost ${redditPost.url} -`, error); 524 | } 525 | }; 526 | 527 | const syncKindleHighlightToObsidian = async ( 528 | kindleHighlight: any, 529 | app: App, 530 | settings: TresselPluginSettings 531 | ) => { 532 | try { 533 | // Find if there's an existing page for the kindle highlight already in Tressel 534 | let folderString = "/"; 535 | if (settings.subFolders) { 536 | folderString = "/Kindle Highlights/"; 537 | } 538 | 539 | const bookPage = await app.vault.getAbstractFileByPath( 540 | settings.syncFolder + 541 | folderString + 542 | sanitize( 543 | kindleHighlight.book.title 544 | .replace(/(\r\n|\n|\r)/gm, " ") 545 | .replace("\n\n", " ") 546 | .replace("\n\n\n", " ") 547 | .slice(0, 50) 548 | ) + 549 | ".md" 550 | ); 551 | 552 | let updatedBookPage: TFile; 553 | if (bookPage instanceof TFile) { 554 | updatedBookPage = bookPage; 555 | } else { 556 | // Create new page for Book in Tressel directory 557 | const mainHeading = `# ${kindleHighlight.book.title.replace( 558 | /(\r\n|\n|\r)/gm, 559 | " " 560 | )}`; 561 | 562 | let templateArray = []; 563 | let metadataArray = [ 564 | `## Metadata`, 565 | `- Author: ${kindleHighlight.book.author}`, 566 | `- Type: 📕 Kindle Highlight #kindle-highlight`, 567 | `- URL: ${kindleHighlight.book.url}\n`, 568 | `## Highlights`, 569 | ]; 570 | 571 | if (!settings.removeMainHeading) { 572 | templateArray.push(mainHeading); 573 | templateArray = templateArray.concat(metadataArray); 574 | } else { 575 | templateArray = metadataArray; 576 | } 577 | 578 | let template = templateArray.join("\n"); 579 | 580 | try { 581 | updatedBookPage = await app.vault.create( 582 | settings.syncFolder + 583 | folderString + 584 | sanitize( 585 | kindleHighlight.book.title 586 | .replace(/(\r\n|\n|\r)/gm, " ") 587 | .replace("\n\n", " ") 588 | .replace("\n\n\n", " ") 589 | .slice(0, 50) 590 | ) + 591 | ".md", 592 | template 593 | ); 594 | } catch (error) { 595 | console.error( 596 | `Error syncing kindleHighlight ${kindleHighlight.url} -`, 597 | error 598 | ); 599 | } 600 | } 601 | 602 | if (updatedBookPage) { 603 | let updatedBookContents = await app.vault.read(updatedBookPage); 604 | 605 | updatedBookContents += `\n${kindleHighlight.text} - *Location: ${kindleHighlight.location}*\n`; 606 | await app.vault.modify(updatedBookPage, updatedBookContents); 607 | } 608 | } catch (error) { 609 | console.error( 610 | `Error syncing kindleHighlight ${kindleHighlight.url} -`, 611 | error 612 | ); 613 | } 614 | }; 615 | 616 | const syncGenericHighlightToObsidian = async ( 617 | genericHighlight: any, 618 | app: App, 619 | settings: TresselPluginSettings 620 | ) => { 621 | try { 622 | // Create new page for redditPost in Tressel directory 623 | const mainHeading = `# ${genericHighlight.title.replace( 624 | /(\r\n|\n|\r)/gm, 625 | " " 626 | )}`; 627 | 628 | let templateArray = []; 629 | let metadataArray = [ 630 | `## Metadata`, 631 | `- Type: 💬 Highlight #highlight`, 632 | `- URL: ${genericHighlight.url}\n`, 633 | `## Highlight`, 634 | `${genericHighlight.text ? genericHighlight.text + "\n" : ""}`, 635 | ]; 636 | 637 | if (!settings.removeMainHeading) { 638 | templateArray.push(mainHeading); 639 | templateArray = templateArray.concat(metadataArray); 640 | } else { 641 | templateArray = metadataArray; 642 | } 643 | 644 | let template = templateArray.join("\n"); 645 | 646 | let folderString = "/"; 647 | if (settings.subFolders) { 648 | folderString = "/Highlights/"; 649 | } 650 | 651 | const highlightPath = 652 | sanitize( 653 | genericHighlight.title 654 | .replace(/(\r\n|\n|\r)/gm, " ") 655 | .replace("\n\n", " ") 656 | .replace("\n\n\n", " ") 657 | .slice(0, 50) 658 | ) + ".md"; 659 | 660 | await app.vault.create( 661 | settings.syncFolder + folderString + highlightPath, 662 | template 663 | ); 664 | } catch (error) { 665 | console.error( 666 | `Error syncing genericHighlight ${genericHighlight.url} -`, 667 | error 668 | ); 669 | } 670 | }; 671 | 672 | const syncPocketHighlightToObsidian = async ( 673 | pocketHighlight: any, 674 | app: App, 675 | settings: TresselPluginSettings 676 | ) => { 677 | try { 678 | // Find if there's an existing page for the pocket article already in Tressel 679 | let folderString = "/"; 680 | if (settings.subFolders) { 681 | folderString = "/Pocket/"; 682 | } 683 | 684 | const articlePage = await app.vault.getAbstractFileByPath( 685 | settings.syncFolder + 686 | folderString + 687 | sanitize( 688 | pocketHighlight.pocketArticle.title 689 | .replace(/(\r\n|\n|\r)/gm, " ") 690 | .replace("\n\n", " ") 691 | .replace("\n\n\n", " ") 692 | .slice(0, 50) 693 | ) + 694 | ".md" 695 | ); 696 | 697 | let updatedArticlePage: TFile; 698 | if (articlePage instanceof TFile) { 699 | updatedArticlePage = articlePage; 700 | } else { 701 | // Create new page for article in Tressel directory 702 | const mainHeading = `# ${pocketHighlight.pocketArticle.title.replace( 703 | /(\r\n|\n|\r)/gm, 704 | " " 705 | )}`; 706 | 707 | let templateArray = []; 708 | let metadataArray = [ 709 | `## Metadata`, 710 | `- Author: ${pocketHighlight.pocketArticle.author}`, 711 | `- Type: 📑 Pocket Highlights #pocket-highlights`, 712 | `- URL: ${pocketHighlight.pocketArticle.url}\n`, 713 | `## Highlights`, 714 | ]; 715 | 716 | if (!settings.removeMainHeading) { 717 | templateArray.push(mainHeading); 718 | templateArray = templateArray.concat(metadataArray); 719 | } else { 720 | templateArray = metadataArray; 721 | } 722 | 723 | let template = templateArray.join("\n"); 724 | 725 | try { 726 | updatedArticlePage = await app.vault.create( 727 | settings.syncFolder + 728 | folderString + 729 | sanitize( 730 | pocketHighlight.pocketArticle.title 731 | .replace(/(\r\n|\n|\r)/gm, " ") 732 | .replace("\n\n", " ") 733 | .replace("\n\n\n", " ") 734 | .slice(0, 50) 735 | ) + 736 | ".md", 737 | template 738 | ); 739 | } catch (error) { 740 | console.error( 741 | `Error syncing pocketHighlight ${pocketHighlight.pocketArticle.url} -`, 742 | error 743 | ); 744 | } 745 | } 746 | 747 | if (updatedArticlePage) { 748 | let updatedArticleContents = await app.vault.read( 749 | updatedArticlePage 750 | ); 751 | 752 | updatedArticleContents += `\n${pocketHighlight.text}*\n`; 753 | await app.vault.modify(updatedArticlePage, updatedArticleContents); 754 | } 755 | } catch (error) { 756 | console.error( 757 | `Error syncing pocketHighlight ${pocketHighlight.pocketArticle.url} -`, 758 | error 759 | ); 760 | } 761 | }; 762 | 763 | const syncInstapaperHighlightToObsidian = async ( 764 | instapaperHighlight: any, 765 | app: App, 766 | settings: TresselPluginSettings 767 | ) => { 768 | try { 769 | // Find if there's an existing page for the instapaper bookmark already in Tressel 770 | 771 | let folderString = "/"; 772 | if (settings.subFolders) { 773 | folderString = "/Instapaper/"; 774 | } 775 | 776 | const bookmarkPage = await app.vault.getAbstractFileByPath( 777 | settings.syncFolder + 778 | folderString + 779 | sanitize( 780 | instapaperHighlight.instapaperBookmark.title 781 | .replace(/(\r\n|\n|\r)/gm, " ") 782 | .replace("\n\n", " ") 783 | .replace("\n\n\n", " ") 784 | .slice(0, 50) 785 | ) + 786 | ".md" 787 | ); 788 | 789 | let updatedArticlePage: TFile; 790 | if (bookmarkPage instanceof TFile) { 791 | updatedArticlePage = bookmarkPage; 792 | } else { 793 | // Create new page for bookmark in Tressel directory 794 | const mainHeading = `# ${instapaperHighlight.instapaperBookmark.title.replace( 795 | /(\r\n|\n|\r)/gm, 796 | " " 797 | )}`; 798 | 799 | let templateArray = []; 800 | let metadataArray = [ 801 | `## Metadata`, 802 | `- Type: 📑 Instapaper Highlights #instapaper-highlights`, 803 | `- URL: ${instapaperHighlight.instapaperBookmark.url}\n`, 804 | `## Highlights/Notes`, 805 | ]; 806 | 807 | if (!settings.removeMainHeading) { 808 | templateArray.push(mainHeading); 809 | templateArray = templateArray.concat(metadataArray); 810 | } else { 811 | templateArray = metadataArray; 812 | } 813 | 814 | let template = templateArray.join("\n"); 815 | 816 | try { 817 | updatedArticlePage = await app.vault.create( 818 | settings.syncFolder + 819 | folderString + 820 | sanitize( 821 | instapaperHighlight.instapaperBookmark.title 822 | .replace(/(\r\n|\n|\r)/gm, " ") 823 | .replace("\n\n", " ") 824 | .replace("\n\n\n", " ") 825 | .slice(0, 50) 826 | ) + 827 | ".md", 828 | template 829 | ); 830 | } catch (error) { 831 | console.error( 832 | `Error syncing instapaperHighlight ${instapaperHighlight.instapaperBookmark.url} -`, 833 | error 834 | ); 835 | } 836 | } 837 | 838 | if (updatedArticlePage) { 839 | let updatedArticleContents = await app.vault.read( 840 | updatedArticlePage 841 | ); 842 | 843 | if (instapaperHighlight.note) { 844 | updatedArticleContents += `\n***${instapaperHighlight.note}***\n`; 845 | } 846 | updatedArticleContents += `\n${instapaperHighlight.text}*\n`; 847 | await app.vault.modify(updatedArticlePage, updatedArticleContents); 848 | } 849 | } catch (error) { 850 | console.error( 851 | `Error syncing instapaperHighlight ${instapaperHighlight.instapaperBookmark.url} -`, 852 | error 853 | ); 854 | } 855 | }; 856 | 857 | const syncRaindropHighlightToObsidian = async ( 858 | raindropHighlight: any, 859 | app: App, 860 | settings: TresselPluginSettings 861 | ) => { 862 | try { 863 | // Find if there's an existing page for the raindrop bookmark already in Tressel 864 | let folderString = "/"; 865 | if (settings.subFolders) { 866 | folderString = "/Raindrop/"; 867 | } 868 | 869 | const raindropPage = await app.vault.getAbstractFileByPath( 870 | settings.syncFolder + 871 | folderString + 872 | sanitize( 873 | raindropHighlight.raindrop.title 874 | .replace(/(\r\n|\n|\r)/gm, " ") 875 | .replace("\n\n", " ") 876 | .replace("\n\n\n", " ") 877 | .slice(0, 50) 878 | ) + 879 | ".md" 880 | ); 881 | 882 | let updatedArticlePage: TFile; 883 | if (raindropPage instanceof TFile) { 884 | updatedArticlePage = raindropPage; 885 | } else { 886 | const mainHeading = `# ${raindropHighlight.raindrop.title.replace( 887 | /(\r\n|\n|\r)/gm, 888 | " " 889 | )}`; 890 | 891 | let templateArray = []; 892 | let metadataArray = [ 893 | `## Metadata`, 894 | `- Type: 💧 Raindrop Highlights #raindrop-highlights`, 895 | `- URL: ${raindropHighlight.raindrop.url}\n`, 896 | `## Highlights/Notes`, 897 | ]; 898 | 899 | if (!settings.removeMainHeading) { 900 | templateArray.push(mainHeading); 901 | templateArray = templateArray.concat(metadataArray); 902 | } else { 903 | templateArray = metadataArray; 904 | } 905 | 906 | // Create new page for bookmark in Tressel directory 907 | let template = templateArray.join("\n"); 908 | 909 | try { 910 | updatedArticlePage = await app.vault.create( 911 | settings.syncFolder + 912 | folderString + 913 | sanitize( 914 | raindropHighlight.raindrop.title 915 | .replace(/(\r\n|\n|\r)/gm, " ") 916 | .replace("\n\n", " ") 917 | .replace("\n\n\n", " ") 918 | .slice(0, 50) 919 | ) + 920 | ".md", 921 | template 922 | ); 923 | } catch (error) { 924 | console.error( 925 | `Error syncing raindropHighlight ${raindropHighlight.raindrop.url} -`, 926 | error 927 | ); 928 | } 929 | } 930 | 931 | if (updatedArticlePage) { 932 | let updatedArticleContents = await app.vault.read( 933 | updatedArticlePage 934 | ); 935 | 936 | if (raindropHighlight.note) { 937 | updatedArticleContents += `\n***${raindropHighlight.note}***\n`; 938 | } 939 | updatedArticleContents += `\n${raindropHighlight.text}*\n`; 940 | await app.vault.modify(updatedArticlePage, updatedArticleContents); 941 | } 942 | } catch (error) { 943 | console.error( 944 | `Error syncing raindropHighlight ${raindropHighlight.raindrop.url} -`, 945 | error 946 | ); 947 | } 948 | }; 949 | 950 | const syncRaindropToObsidian = async ( 951 | raindrop: any, 952 | app: App, 953 | settings: TresselPluginSettings 954 | ) => { 955 | try { 956 | // Find if there's an existing page for the raindrop bookmark already in Tressel 957 | let folderString = "/"; 958 | if (settings.subFolders) { 959 | folderString = "/Raindrop/"; 960 | } 961 | 962 | const raindropPage = await app.vault.getAbstractFileByPath( 963 | settings.syncFolder + 964 | folderString + 965 | sanitize( 966 | raindrop.title 967 | .replace(/(\r\n|\n|\r)/gm, " ") 968 | .replace("\n\n", " ") 969 | .replace("\n\n\n", " ") 970 | .slice(0, 50) 971 | ) + 972 | ".md" 973 | ); 974 | 975 | let updatedArticlePage: TFile; 976 | if (raindropPage instanceof TFile) { 977 | updatedArticlePage = raindropPage; 978 | } else { 979 | const mainHeading = `# ${raindrop.title.replace( 980 | /(\r\n|\n|\r)/gm, 981 | " " 982 | )}`; 983 | 984 | let templateArray = []; 985 | let metadataArray = [ 986 | `## Metadata`, 987 | `- Type: 💧 Raindrop Highlights #raindrop-highlights`, 988 | `- URL: ${raindrop.url}\n`, 989 | `## Highlights/Notes`, 990 | ]; 991 | 992 | if (!settings.removeMainHeading) { 993 | templateArray.push(mainHeading); 994 | templateArray = templateArray.concat(metadataArray); 995 | } else { 996 | templateArray = metadataArray; 997 | } 998 | 999 | // Create new page for bookmark in Tressel directory 1000 | let template = templateArray.join("\n"); 1001 | 1002 | try { 1003 | updatedArticlePage = await app.vault.create( 1004 | settings.syncFolder + 1005 | folderString + 1006 | sanitize( 1007 | raindrop.title 1008 | .replace(/(\r\n|\n|\r)/gm, " ") 1009 | .replace("\n\n", " ") 1010 | .replace("\n\n\n", " ") 1011 | .slice(0, 50) 1012 | ) + 1013 | ".md", 1014 | template 1015 | ); 1016 | } catch (error) { 1017 | console.error( 1018 | `Error syncing raindrop ${raindrop.url} -`, 1019 | error 1020 | ); 1021 | } 1022 | } 1023 | } catch (error) { 1024 | console.error(`Error syncing raindrop ${raindrop.url} -`, error); 1025 | } 1026 | }; 1027 | 1028 | const syncHypothesisAnnotationToObsidian = async ( 1029 | hypothesisAnnotation: any, 1030 | app: App, 1031 | settings: TresselPluginSettings 1032 | ) => { 1033 | try { 1034 | // Find if there's an existing page for the hypothesis document already in Tressel 1035 | let folderString = "/"; 1036 | if (settings.subFolders) { 1037 | folderString = "/Hypothesis/"; 1038 | } 1039 | 1040 | const documentPage = await app.vault.getAbstractFileByPath( 1041 | settings.syncFolder + 1042 | folderString + 1043 | sanitize( 1044 | hypothesisAnnotation.hypothesisDocument.title 1045 | .replace(/(\r\n|\n|\r)/gm, " ") 1046 | .replace("\n\n", " ") 1047 | .replace("\n\n\n", " ") 1048 | .slice(0, 50) 1049 | ) + 1050 | ".md" 1051 | ); 1052 | 1053 | let updatedArticlePage: TFile; 1054 | if (documentPage instanceof TFile) { 1055 | updatedArticlePage = documentPage; 1056 | } else { 1057 | const mainHeading = `# ${hypothesisAnnotation.hypothesisDocument.title.replace( 1058 | /(\r\n|\n|\r)/gm, 1059 | " " 1060 | )}`; 1061 | 1062 | let templateArray = []; 1063 | let metadataArray = [ 1064 | `## Metadata`, 1065 | `- Type: 💧 Hypothes.is Annotations #hypothesis-annotations`, 1066 | `- URL: ${hypothesisAnnotation.hypothesisDocument.url}\n`, 1067 | `## Annotations/Highlights`, 1068 | ]; 1069 | 1070 | if (!settings.removeMainHeading) { 1071 | templateArray.push(mainHeading); 1072 | templateArray = templateArray.concat(metadataArray); 1073 | } else { 1074 | templateArray = metadataArray; 1075 | } 1076 | 1077 | // Create new page for bookmark in Tressel directory 1078 | let template = templateArray.join("\n"); 1079 | 1080 | try { 1081 | updatedArticlePage = await app.vault.create( 1082 | settings.syncFolder + 1083 | folderString + 1084 | sanitize( 1085 | hypothesisAnnotation.hypothesisDocument.title 1086 | .replace(/(\r\n|\n|\r)/gm, " ") 1087 | .replace("\n\n", " ") 1088 | .replace("\n\n\n", " ") 1089 | .slice(0, 50) 1090 | ) + 1091 | ".md", 1092 | template 1093 | ); 1094 | } catch (error) { 1095 | console.error( 1096 | `Error syncing hypothesisAnnotation ${hypothesisAnnotation.hypothesisDocument.url} -`, 1097 | error 1098 | ); 1099 | } 1100 | } 1101 | 1102 | if (updatedArticlePage) { 1103 | let updatedArticleContents = await app.vault.read( 1104 | updatedArticlePage 1105 | ); 1106 | 1107 | if (hypothesisAnnotation.note) { 1108 | updatedArticleContents += `\n***${hypothesisAnnotation.note}***\n`; 1109 | } 1110 | updatedArticleContents += `\n${hypothesisAnnotation.text}*\n`; 1111 | await app.vault.modify(updatedArticlePage, updatedArticleContents); 1112 | } 1113 | } catch (error) { 1114 | console.error( 1115 | `Error syncing hypothesisAnnotation ${hypothesisAnnotation.raindrop.url} -`, 1116 | error 1117 | ); 1118 | } 1119 | }; 1120 | 1121 | const syncHackerNewsHighlightToObsidian = async ( 1122 | hackerNewsHighlight: any, 1123 | app: App, 1124 | settings: TresselPluginSettings 1125 | ) => { 1126 | try { 1127 | const turndownService = new TurndownService(); 1128 | 1129 | // Create new page for hackerNewsHighlight in Tressel directory 1130 | const mainHeading = `# ${ 1131 | hackerNewsHighlight.title 1132 | ? hackerNewsHighlight.title.replace(/(\r\n|\n|\r)/gm, " ") 1133 | : hackerNewsHighlight.text 1134 | .substring(0, 80) 1135 | .replace(/(\r\n|\n|\r)/gm, " ") + "..." 1136 | }`; 1137 | 1138 | let templateArray = []; 1139 | let metadataArray = [ 1140 | `## Metadata`, 1141 | `- Author: [${hackerNewsHighlight.author}](https://news.ycombinator.com/user?id=${hackerNewsHighlight.author})`, 1142 | `- Type: 👾 Hacker News Highlight #hacker-news-highlight`, 1143 | `- URL: https://news.ycombinator.com/item?id=${hackerNewsHighlight.hackerNewsHighlightId}\n`, 1144 | `## Highlight`, 1145 | `${ 1146 | hackerNewsHighlight.text 1147 | ? turndownService.turndown(hackerNewsHighlight.text) + "\n" 1148 | : "" 1149 | }`, 1150 | ]; 1151 | 1152 | if (!settings.removeMainHeading) { 1153 | templateArray.push(mainHeading); 1154 | templateArray = templateArray.concat(metadataArray); 1155 | } else { 1156 | templateArray = metadataArray; 1157 | } 1158 | 1159 | if (hackerNewsHighlight.url) { 1160 | templateArray.push(`[Link](${hackerNewsHighlight.url})\n`); 1161 | } 1162 | 1163 | let template = templateArray.join("\n"); 1164 | 1165 | let folderString = "/"; 1166 | if (settings.subFolders) { 1167 | folderString = "/Hacker News/"; 1168 | } 1169 | 1170 | await app.vault.create( 1171 | settings.syncFolder + 1172 | folderString + 1173 | sanitize( 1174 | hackerNewsHighlight.title 1175 | ? hackerNewsHighlight.title 1176 | .replace(/(\r\n|\n|\r)/gm, " ") 1177 | .replace("\n\n", " ") 1178 | .replace("\n\n\n", " ") 1179 | .slice(0, 50) 1180 | : hackerNewsHighlight.text 1181 | .replace(/(\r\n|\n|\r)/gm, " ") 1182 | .replace("\n\n", " ") 1183 | .replace("\n\n\n", " ") 1184 | .slice(0, 50) 1185 | ) + 1186 | ".md", 1187 | template 1188 | ); 1189 | } catch (error) { 1190 | console.error( 1191 | `Error syncing hackerNewsHighlight ${hackerNewsHighlight.url} -`, 1192 | error 1193 | ); 1194 | } 1195 | }; 1196 | --------------------------------------------------------------------------------