├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── manifest.json ├── package.json ├── src ├── api │ └── api.ts ├── assets │ ├── defaultTemplate.njk │ └── hypothesisIcon.svg ├── custom.d.ts ├── fileManager.ts ├── main.ts ├── modals │ ├── apiTokenModal.svelte │ ├── apiTokenModal.ts │ ├── manageGroupsModal.svelte │ ├── manageGroupsModal.ts │ ├── resyncDelFileModal.svelte │ └── resyncDelFileModal.ts ├── models.ts ├── parser │ ├── parseGroupResponse.ts │ └── parseSyncResponse.ts ├── renderer.ts ├── settingsTab │ ├── datetimeInstructions.html │ ├── index.ts │ └── templateInstructions.html ├── store │ ├── index.ts │ ├── initialise.ts │ ├── settings.ts │ ├── syncSession.ts │ └── tokenManager.ts ├── sync │ ├── syncGroup.ts │ ├── syncHypothesis.ts │ └── syncState.ts └── utils │ ├── frontmatter.ts │ └── sanitizeTitle.ts ├── tsconfig.json ├── versions.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended'] 6 | }; 7 | -------------------------------------------------------------------------------- /.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 12 | 13 | # obsidian plugin files 14 | main.js 15 | main.js.map 16 | data.json 17 | 18 | coverage 19 | dist 20 | tsconfig.tsbuildinfo 21 | .DS_Store 22 | main.js.LICENSE.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jsonmartin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian Hypothesis Plugin (Community Plugin) 2 | 3 | Obsidian Hypothesis (Community Plugin) is an unofficial plugin to synchronize [Hypothesis](https://hypothes.is/) **web** article highlights/annotations into your Obsidian Vault. 4 | 5 | 6 | 7 | ### Features 8 | 9 | - Sync web article highlights/annotations on Obsidian startup or manual trigger 10 | - Update existing articles with new highlights and annotations 11 | - Customization highlights through [Nunjucks](https://mozilla.github.io/nunjucks) template 12 | 13 | ## Usage 14 | 15 | After installing the plugin, configure the the settings of the plugin then initiate the first sync manually. Thereafter, the plugin can be configured to sync automatically or manually 16 | 17 | Use Hypothesis icon on the side icon ribbon or command to trigger manual sync. 18 | 19 | ### Settings 20 | 21 | - `Connect`: Enter [API Token](https://hypothes.is/account/developer) in order to pull the highlights from Hypohesis 22 | - `Disconnect`: Remove API Token from Obsidian 23 | - `Auto Sync Interval`: Set the interval in minutes to sync Hypothesis highlights automatically 24 | - `Highlights folder`: Specify the folder location for your Hypothesis articles 25 | - `Use domain folders`: Group generated files into folders based on the domain of the annotated URL 26 | - `Sync on startup`: Automatically sync highlights when open Obsidian 27 | - `Highlights template`: Nunjuck template for rendering your highlights 28 | - `Groups`: Add/remove group to be synced 29 | - `Reset sync`: Wipe your sync history. Does not delete any previously synced highlights from your vault 30 | 31 | ### To sync all new highlights since previous update 32 | 33 | - Click: Hypothesis ribbon icon 34 | - Command: Sync new highlights 35 | - Command: Resync deleted file 36 | > (Note: Files synced before v0.1.5 will need to reset sync history and delete all synced files to have this feature work properly) 37 | 38 | ## Limitations & caveats 39 | 40 | - Limit to 1000 highlights on initial sync for performance. Subsequent sync for deltas are capped at 200 as pagination of result sets does not work in conjunction with API search_after parameter. 41 | - Only tested with Obsidian Mac OSX and Windows 10. 42 | - Does not suport annotations on PDFs. 43 | 44 | ## Acknowledgement 45 | 46 | This project is inspired by Hady Ozman's [Obsidian Kindle Plugin](https://github.com/hadynz/obsidian-kindle-plugin). 47 | 48 | ## Do you find this plugin useful? 49 | 50 | 51 | 52 | Thank you for your support 🙏 53 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-hypothesis-plugin", 3 | "name": "Hypothes.is", 4 | "version": "0.1.19", 5 | "minAppVersion": "0.11.0", 6 | "description": "Sync your Hypothesis highlights", 7 | "author": "weichenw", 8 | "authorUrl": "https://github.com/weichenw", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-hypothesis-plugin", 3 | "version": "0.0.1", 4 | "description": "Sync your Hypothesis highlights", 5 | "main": "src/main.ts", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/weichenw/obsidian-hypothesis-highlights.git" 9 | }, 10 | "scripts": { 11 | "clean": "rimraf dist main.js", 12 | "build": "svelte-check && npm run lint && webpack", 13 | "dev": "NODE_ENV=development webpack && cp ./dist/main.js* .", 14 | "lint": "eslint . --ext .ts" 15 | }, 16 | "keywords": [], 17 | "author": "weichenw", 18 | "license": "MIT", 19 | "dependencies": { 20 | "axios": "^0.26.0", 21 | "gray-matter": "^4.0.3", 22 | "lodash.pickby": "^4.6.0", 23 | "nunjucks": "^3.2.3" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.15.8", 27 | "@tsconfig/svelte": "^1.0.10", 28 | "@types/lodash.pickby": "^4.6.6", 29 | "@types/node": "^14.14.37", 30 | "@types/nunjucks": "^3.2.0", 31 | "@typescript-eslint/eslint-plugin": "^5.0.0", 32 | "@typescript-eslint/parser": "^5.0.0", 33 | "babel-loader": "^8.2.2", 34 | "copy-webpack-plugin": "^8.1.1", 35 | "crypto-js": "^4.1.1", 36 | "electron": ">=13.6.6", 37 | "eslint": "^8.0.0", 38 | "obsidian": "^0.12.0", 39 | "rimraf": "^3.0.2", 40 | "sanitize-filename": "^1.6.3", 41 | "svelte": "^3.37.0", 42 | "svelte-check": "^1.4.0", 43 | "svelte-loader": "^3.1.0", 44 | "svelte-preprocess": "^4.7.0", 45 | "svelte-select": "^4.4.3", 46 | "ts-loader": "^8.1.0", 47 | "tslib": "^2.2.0", 48 | "typescript": "^4.2.4", 49 | "webpack": "^5.30.0", 50 | "webpack-cli": "^4.6.0", 51 | "webpack-node-externals": "^2.5.2" 52 | } 53 | } -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Notice, moment } from 'obsidian'; 3 | import axios from "axios"; 4 | 5 | export default class ApiManager { 6 | readonly baseUrl: string = 'https://hypothes.is/api'; 7 | private token: string; 8 | private userid: string; 9 | 10 | constructor(token: string, userid: string = undefined) { 11 | this.token = token; 12 | this.userid = userid; 13 | } 14 | 15 | private getHeaders() { 16 | return { 17 | 'AUTHORIZATION': `Bearer ${this.token}`, 18 | 'Accept': 'application/json', 19 | }; 20 | } 21 | 22 | async getProfile() { 23 | try { 24 | const response = await axios.get(`${this.baseUrl}/profile`, { headers: this.getHeaders() }) 25 | return response.data.userid 26 | } 27 | catch (e) { 28 | new Notice('Failed to authorize Hypothes.is user. Please check your API token and try again.') 29 | console.error(e); 30 | return; 31 | } 32 | } 33 | 34 | async getHighlights(lastSyncDate?: Date, limit = 2000) { 35 | let annotations = []; 36 | 37 | try { 38 | // Paginate API calls via search_after param 39 | // search_after=null starts at with the earliest annotations 40 | let newestTimestamp = lastSyncDate && moment.utc(lastSyncDate).format() 41 | while (annotations.length < limit) { 42 | const response = await axios.get( 43 | `${this.baseUrl}/search`, 44 | { 45 | params: { 46 | limit: 200, // Max pagination size 47 | sort: "updated", 48 | order: "asc", // Get all annotations since search_after 49 | search_after: newestTimestamp, 50 | user: this.userid, 51 | }, 52 | headers: this.getHeaders() 53 | } 54 | ) 55 | const newAnnotations = response.data.rows; 56 | if (!newAnnotations.length) { 57 | // No more annotations 58 | break; 59 | } 60 | 61 | annotations = [ ...annotations, ...newAnnotations ]; 62 | newestTimestamp = newAnnotations[newAnnotations.length - 1].updated; 63 | } 64 | 65 | } catch (e) { 66 | new Notice('Failed to fetch Hypothes.is annotations. Please check your API token and try again.') 67 | console.error(e); 68 | } 69 | 70 | return annotations; 71 | } 72 | 73 | async getHighlightWithUri(uri: string, limit = 200) { 74 | try { 75 | const response = await axios.get(`${this.baseUrl}/search`, { 76 | params: { 77 | limit, 78 | uri, 79 | user: this.userid, 80 | sort: "updated", 81 | order: "asc" 82 | }, 83 | headers: this.getHeaders() 84 | }) 85 | 86 | return response.data.rows; 87 | } catch (e) { 88 | new Notice('Failed to fetch Hypothes.is annotations. Please check your API token and try again.') 89 | console.error(e); 90 | } 91 | } 92 | 93 | async getGroups() { 94 | try { 95 | const response = await axios.get(`${this.baseUrl}/groups`, { headers: this.getHeaders() }) 96 | return response.data 97 | } catch (e) { 98 | new Notice('Failed to fetch Hypothes.is annotation groups. Please check your API token and try again.') 99 | console.error(e); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/assets/defaultTemplate.njk: -------------------------------------------------------------------------------- 1 | {% if is_new_article %} 2 | # {{title}} 3 | 4 | ## Metadata 5 | {% if author %}- Author: [{{author}}]({{authorUrl}}){% endif %} 6 | - Title: {{title}} 7 | {% if url %}- Reference: {{url}}{% endif %} 8 | - Category: #article 9 | {% endif %} 10 | 11 | {%- if is_new_article %} 12 | ## Page Notes 13 | {% for highlight in page_notes -%} 14 | {{highlight.annotation}} 15 | {%- if highlight.tags | length %} 16 | Tags: {% for tag in highlight.tags -%} #{{tag | replace(" ", "-")+" "}}{%- endfor %} 17 | {% endif %} 18 | {% endfor %} 19 | {%- endif -%} 20 | 21 | {%- if is_new_article -%} 22 | ## Highlights 23 | {% for highlight in highlights -%} 24 | - {{highlight.text}} — [Updated on {{highlight.updated}}]({{highlight.incontext}}) 25 | {%- if 'Private' != highlight.group %} — Group: #{{highlight.group | replace(" ", "-")}}{% endif %} 26 | {% if highlight.tags | length %} - Tags: {% for tag in highlight.tags %} #{{tag | replace(" ", "-")+" "}}{% endfor %} 27 | {% endif -%} 28 | {% if highlight.annotation %} - Annotation: {{highlight.annotation}}{% endif %} 29 | {% endfor %} 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /src/assets/hypothesisIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | declare module '*.njk' { 4 | const content: any; 5 | export default content; 6 | } 7 | 8 | declare module '*.html' { 9 | const content: any; 10 | export default content; 11 | } 12 | 13 | declare module '*.svg' { 14 | const content: any; 15 | export default content; 16 | } 17 | -------------------------------------------------------------------------------- /src/fileManager.ts: -------------------------------------------------------------------------------- 1 | import type { Vault, MetadataCache, TFile } from 'obsidian'; 2 | import { get } from 'svelte/store'; 3 | import { Renderer } from '~/renderer'; 4 | import { settingsStore } from '~/store'; 5 | import { sanitizeTitle } from '~/utils/sanitizeTitle'; 6 | import type { Article } from '~/models'; 7 | import { frontMatterDocType, addFrontMatter } from "~/utils/frontmatter" 8 | 9 | type AnnotationFile = { 10 | articleUrl?: string; 11 | file: TFile; 12 | }; 13 | 14 | const articleFolderPath = (article: Article): string => { 15 | const settings = get(settingsStore); 16 | if (settings.useDomainFolders) { 17 | // "metadata.author" is equal to the article domain at the moment 18 | return `${settings.highlightsFolder}/${article.metadata.author}`; 19 | } 20 | 21 | return settings.highlightsFolder; 22 | }; 23 | 24 | export default class FileManager { 25 | private vault: Vault; 26 | private metadataCache: MetadataCache; 27 | private renderer: Renderer; 28 | 29 | constructor(vault: Vault, metadataCache: MetadataCache) { 30 | this.vault = vault; 31 | this.metadataCache = metadataCache; 32 | this.renderer = new Renderer(); 33 | } 34 | 35 | // Save an article as markdown file, replacing its existing file if present 36 | public async saveArticle(article: Article): Promise { 37 | const existingFile = await this.getArticleFile(article); 38 | 39 | if (existingFile) { 40 | console.debug(`Updating ${existingFile.path}`); 41 | 42 | const newMarkdownContent = this.renderer.render(article, false); 43 | const existingFileContent = await this.vault.cachedRead(existingFile); 44 | const fileContent = existingFileContent + newMarkdownContent; 45 | 46 | await this.vault.modify(existingFile, fileContent); 47 | return false; 48 | } else { 49 | const newFilePath = await this.getNewArticleFilePath(article); 50 | console.debug(`Creating ${newFilePath}`); 51 | 52 | const markdownContent = this.renderer.render(article, true); 53 | const fileContent = addFrontMatter(markdownContent, article); 54 | 55 | await this.vault.create(newFilePath, fileContent); 56 | return true; 57 | } 58 | } 59 | 60 | public async createFolder(folderPath: string): Promise { 61 | await this.vault.createFolder(folderPath); 62 | } 63 | 64 | public async isArticleSaved(article: Article): Promise { 65 | const file = await this.getArticleFile(article); 66 | return !!file 67 | } 68 | 69 | private async getArticleFile(article: Article): Promise { 70 | const files = await this.getAnnotationFiles() 71 | return files.find((file) => file.articleUrl === article.metadata.url)?.file || null; 72 | } 73 | 74 | // TODO cache this method for performance? 75 | private async getAnnotationFiles(): Promise { 76 | const files = this.vault.getMarkdownFiles(); 77 | 78 | return files 79 | .map((file) => { 80 | const cache = this.metadataCache.getFileCache(file); 81 | return { file, frontmatter: cache?.frontmatter }; 82 | }) 83 | .filter(({ frontmatter }) => frontmatter?.["doc_type"] === frontMatterDocType) 84 | .map(({ file, frontmatter }): AnnotationFile => ({ file, articleUrl: frontmatter["url"] })) 85 | } 86 | 87 | public async getNewArticleFilePath(article: Article): Promise { 88 | const folderPath = articleFolderPath(article); 89 | 90 | if (!(await this.vault.adapter.exists(folderPath))) { 91 | console.info(`Folder ${folderPath} not found. Will be created`); 92 | await this.createFolder(folderPath); 93 | } 94 | 95 | let fileName = `${sanitizeTitle(article.metadata.title)}.md`; 96 | let filePath = `${folderPath}/${fileName}` 97 | 98 | let suffix = 1; 99 | const tfiles = this.vault.getMarkdownFiles(); 100 | while (tfiles.find((tfile) => tfile.path === filePath)) { 101 | console.debug(`${filePath} alreay exists`) 102 | fileName = `${sanitizeTitle(article.metadata.title)} (${suffix++}).md`; 103 | filePath = `${folderPath}/${fileName}`; 104 | } 105 | 106 | return filePath; 107 | } 108 | 109 | 110 | } 111 | 112 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, addIcon } from 'obsidian'; 2 | import { SettingsTab } from '~/settingsTab'; 3 | import { initialise, settingsStore } from '~/store'; 4 | import { get } from 'svelte/store'; 5 | import SyncHypothesis from '~/sync/syncHypothesis'; 6 | import hypothesisIcon from '~/assets/hypothesisIcon.svg' 7 | import FileManager from '~/fileManager'; 8 | import ResyncDelFileModal from '~/modals/resyncDelFileModal'; 9 | 10 | addIcon('hypothesisIcon', hypothesisIcon); 11 | 12 | export default class HypothesisPlugin extends Plugin { 13 | private syncHypothesis!: SyncHypothesis; 14 | private timeoutIDAutoSync: number; 15 | 16 | async onload(): Promise { 17 | console.log('loading plugin', new Date().toLocaleString()); 18 | 19 | await initialise(this); 20 | 21 | const fileManager = new FileManager(this.app.vault, this.app.metadataCache); 22 | 23 | this.syncHypothesis = new SyncHypothesis(fileManager); 24 | 25 | this.addRibbonIcon('hypothesisIcon', 'Sync your hypothesis highlights', () => { 26 | if (!get(settingsStore).isConnected) { 27 | new Notice('Please configure Hypothesis API token in the plugin setting'); 28 | } else { 29 | this.startSync(); 30 | } 31 | }); 32 | 33 | // this.addStatusBarItem().setText('Status Bar Text'); 34 | 35 | this.addCommand({ 36 | id: 'hypothesis-sync', 37 | name: 'Sync highlights', 38 | callback: () => { 39 | if (!get(settingsStore).isConnected) { 40 | new Notice('Please configure Hypothesis API token in the plugin setting'); 41 | } else { 42 | this.startSync(); 43 | } 44 | }, 45 | }); 46 | 47 | this.addCommand({ 48 | id: 'hypothesis-resync-deleted', 49 | name: 'Resync deleted file(s)', 50 | callback: () => { 51 | if (!get(settingsStore).isConnected) { 52 | new Notice('Please configure Hypothesis API token in the plugin setting'); 53 | } else { 54 | this.showResyncModal(); 55 | } 56 | }, 57 | }); 58 | 59 | this.addSettingTab(new SettingsTab(this.app, this)); 60 | 61 | if (get(settingsStore).syncOnBoot) { 62 | if (get(settingsStore).isConnected) { 63 | await this.startSync(); 64 | } else{ 65 | console.info('Sync disabled. API Token not configured'); 66 | } 67 | } 68 | 69 | if (get(settingsStore).autoSyncInterval) { 70 | this.startAutoSync(); 71 | } 72 | } 73 | 74 | async showResyncModal(): Promise { 75 | const resyncDelFileModal = new ResyncDelFileModal(this.app); 76 | await resyncDelFileModal.waitForClose; 77 | } 78 | 79 | async onunload() : Promise { 80 | console.log('unloading plugin', new Date().toLocaleString()); 81 | this.clearAutoSync(); 82 | } 83 | 84 | async startSync(): Promise { 85 | console.log('Start syncing...') 86 | await this.syncHypothesis.startSync(); 87 | } 88 | 89 | async clearAutoSync(): Promise { 90 | if (this.timeoutIDAutoSync) { 91 | window.clearTimeout(this.timeoutIDAutoSync); 92 | this.timeoutIDAutoSync = undefined; 93 | } 94 | console.log('Clearing auto sync...'); 95 | } 96 | 97 | async startAutoSync(minutes?: number): Promise { 98 | const minutesToSync = minutes ?? Number(get(settingsStore).autoSyncInterval); 99 | if (minutesToSync > 0) { 100 | this.timeoutIDAutoSync = window.setTimeout( 101 | () => { 102 | this.startSync(); 103 | this.startAutoSync(); 104 | }, 105 | minutesToSync * 60000 106 | ); 107 | } 108 | console.log(`StartAutoSync: this.timeoutIDAutoSync ${this.timeoutIDAutoSync} with ${minutesToSync} minutes`); 109 | } 110 | } -------------------------------------------------------------------------------- /src/modals/apiTokenModal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
Hypothesis API Token
9 |
10 | Log into your 11 | Hypothes.is Developer settings to retrieve API 12 | token 13 |
14 |
15 | 16 |
17 |
18 | 19 | 20 |
-------------------------------------------------------------------------------- /src/modals/apiTokenModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | import type { TokenManager } from "~/store/tokenManager"; 3 | import ApiTokenModalContent from "./apiTokenModal.svelte"; 4 | 5 | export default class ApiTokenModal extends Modal { 6 | public waitForClose: Promise; 7 | private resolvePromise: () => void; 8 | private modalContent: ApiTokenModalContent; 9 | private tokenManager: TokenManager; 10 | 11 | constructor(app: App, tokenManager: TokenManager) { 12 | super(app); 13 | 14 | this.tokenManager = tokenManager; 15 | this.waitForClose = new Promise( 16 | (resolve) => (this.resolvePromise = resolve) 17 | ); 18 | 19 | this.titleEl.innerText = "Enter Hypothesis API token"; 20 | 21 | this.modalContent = new ApiTokenModalContent({ 22 | target: this.contentEl, 23 | props: { 24 | onSubmit: async (value: string) => { 25 | await this.tokenManager.setToken(value); 26 | this.close(); 27 | }, 28 | }, 29 | }); 30 | 31 | this.open(); 32 | } 33 | 34 | onClose() { 35 | super.onClose(); 36 | this.modalContent.$destroy(); 37 | this.resolvePromise(); 38 | } 39 | } -------------------------------------------------------------------------------- /src/modals/manageGroupsModal.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 |
21 |
22 |
23 |

24 | 27 | 28 |

29 |
30 |
31 |
32 |
33 | 34 |
35 |
36 |
37 | {description} 38 |
39 |
40 |
41 | 43 |
44 |
45 | 46 | -------------------------------------------------------------------------------- /src/modals/resyncDelFileModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Vault } from 'obsidian'; 2 | import ResyncDelFileModalContent from './resyncDelFileModal.svelte'; 3 | import { settingsStore } from '~/store'; 4 | import { get } from 'svelte/store'; 5 | import SyncHypothesis from '~/sync/syncHypothesis'; 6 | import FileManager from '~/fileManager'; 7 | import type { Article, SyncedFile } from '~/models'; 8 | import ApiManager from '~/api/api'; 9 | import parseSyncResponse from '~/parser/parseSyncResponse'; 10 | 11 | export default class ResyncDelFileModal extends Modal { 12 | private syncHypothesis!: SyncHypothesis; 13 | public waitForClose: Promise; 14 | private resolvePromise: () => void; 15 | private modalContent: ResyncDelFileModalContent; 16 | private vault: Vault; 17 | private fileManager: FileManager 18 | 19 | constructor(app: App) { 20 | super(app); 21 | this.vault = app.vault; 22 | this.fileManager = new FileManager(this.vault, this.app.metadataCache); 23 | 24 | this.waitForClose = new Promise( 25 | (resolve) => (this.resolvePromise = resolve) 26 | ); 27 | 28 | this.open(); 29 | 30 | } 31 | 32 | async onOpen() { 33 | super.onOpen() 34 | this.syncHypothesis = new SyncHypothesis(this.fileManager); 35 | const deletedFiles = await this.retrieveDeletedFiles(); 36 | 37 | this.titleEl.innerText = "Hypothes.is: Resync deleted file(s)"; 38 | 39 | this.modalContent = new ResyncDelFileModalContent({ 40 | target: this.contentEl, 41 | props: { 42 | deletedFiles: deletedFiles, 43 | onSubmit: async (value: { selected }) => { 44 | if((!!value.selected) && value.selected.length > 0 ){ 45 | this.startResync(value.selected) 46 | } else{ 47 | console.log('No files selected') 48 | } 49 | this.close(); 50 | }, 51 | }, 52 | }); 53 | 54 | } 55 | 56 | onClose() { 57 | super.onClose(); 58 | this.modalContent.$destroy(); 59 | this.resolvePromise(); 60 | } 61 | 62 | async retrieveDeletedFiles(): Promise { 63 | const token = get(settingsStore).token; 64 | const userid = get(settingsStore).user; 65 | const apiManager = new ApiManager(token, userid); 66 | 67 | // Fetch all annotated articles that *should* be present 68 | const allAnnotations = await apiManager.getHighlights() 69 | const allArticles: [] = Object.values(await parseSyncResponse(allAnnotations)); 70 | 71 | // Check which files are actually present 72 | const deletedArticles = await Promise.all(allArticles.filter(async article => !(await this.fileManager.isArticleSaved(article)))); 73 | return deletedArticles.map((article: Article) => 74 | ({ uri: article.metadata.url, filename: this.fileManager.getNewArticleFilePath(article)}) 75 | ); 76 | } 77 | 78 | async startResync(selectedFiles: SyncedFile[]): Promise { 79 | selectedFiles.forEach(async selected => { 80 | console.log(`Start resync deleted file - ${selected.filename}`) 81 | await this.syncHypothesis.startSync(selected.uri); 82 | }) 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | export type Article = { 2 | id: string; 3 | metadata: Metadata; 4 | highlights: Highlights[]; 5 | page_notes: Highlights[]; 6 | }; 7 | 8 | export type Metadata = { 9 | author: string; 10 | title: string; 11 | url: string; 12 | lastAccessedDate?: string; 13 | }; 14 | 15 | export type Highlights = { 16 | id?: string; 17 | created: string; 18 | updated: string; 19 | text: string; 20 | incontext: string; 21 | user: string; 22 | annotation: string; 23 | tags: string[]; 24 | group: string; 25 | isReply: boolean; 26 | }; 27 | 28 | export type RenderTemplate = { 29 | is_new_article: boolean; 30 | title: string; 31 | author: string; 32 | url: string; 33 | highlights: Highlights[]; 34 | page_notes: Highlights[]; 35 | }; 36 | 37 | export type Group = { 38 | id: string; 39 | name: string; 40 | type: string; 41 | public: boolean; 42 | selected: boolean; 43 | }; 44 | 45 | export type SyncedFile = { 46 | filename: string, 47 | uri: string 48 | } 49 | -------------------------------------------------------------------------------- /src/parser/parseGroupResponse.ts: -------------------------------------------------------------------------------- 1 | const parseGroupsResponse = async (data) => { 2 | 3 | return data.map(group => { 4 | group.selected = true; 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | const { organization, scoped, groupid, links, ...rest } = group; 7 | return rest; 8 | }); 9 | 10 | 11 | } 12 | 13 | export default parseGroupsResponse; -------------------------------------------------------------------------------- /src/parser/parseSyncResponse.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'crypto-js/md5'; 2 | import { moment } from 'obsidian'; 3 | import { settingsStore } from '~/store'; 4 | import { get } from 'svelte/store'; 5 | import type { Article, Highlights } from '../models' 6 | 7 | const parseAuthorUrl = (url: string) => { 8 | const domain = (new URL(url)); 9 | const author = domain.hostname.replace('www.', ''); 10 | return author; 11 | } 12 | 13 | // Strip excessive whitespace and newlines from the TextQuoteSelector highlight text 14 | // This mirrors how Hypothesis displays annotations, to remove artifacts from the HTML annotation anchoring 15 | export const cleanTextSelectorHighlight = (text: string): string => { 16 | text = text.replaceAll('\n', ' ') // e.g. http://www.paulgraham.com/venturecapital.html 17 | text = text.replace('\t', ' ') // e.g. https://sive.rs/about 18 | 19 | // Remove space-indented lines, e.g. https://calpaterson.com/bank-python.html 20 | while (text.contains(' ')) { 21 | text = text.replaceAll(' ', ' ') 22 | } 23 | 24 | return text 25 | }; 26 | 27 | const parseTitleFromUrl = (url: string) => { 28 | const domain = (new URL(url)); 29 | let pathname = domain.pathname 30 | // Remove leading and optional trailing slash 31 | pathname = pathname.slice(1) 32 | if (pathname.endsWith("/")) { 33 | pathname = pathname.slice(0, pathname.length - 1) 34 | } 35 | 36 | return pathname.replaceAll('/', '-'); 37 | } 38 | 39 | const parseHighlight = (annotationData, groupName: string, momentFormat: string): Highlights => { 40 | try { 41 | // Get highlighted text or reply 42 | let isReply, highlightText = null; 43 | const selector = annotationData['target'][0]['selector'] 44 | if (selector) { 45 | highlightText = selector 46 | .find(item => item.type === "TextQuoteSelector") 47 | ?.exact 48 | } else { 49 | // Could be page note or reply 50 | if (annotationData['references']) { 51 | isReply = true 52 | } 53 | } 54 | 55 | return { 56 | id: annotationData['id'], 57 | created: moment(annotationData['created']).format(momentFormat), 58 | updated: moment(annotationData['updated']).format(momentFormat), 59 | text: highlightText && cleanTextSelectorHighlight(highlightText), 60 | incontext: annotationData['links']['incontext'], 61 | user: annotationData['user'], 62 | annotation: annotationData['text'], 63 | tags: annotationData['tags'], 64 | group: groupName, 65 | isReply, 66 | } 67 | } catch (error) { 68 | 69 | console.log(`Error parsing annotation format: ${error}`, annotationData); 70 | return null 71 | } 72 | } 73 | 74 | 75 | const parseSyncResponse = (data): Article[] => { 76 | const momentFormat = get(settingsStore).dateTimeFormat; 77 | const groups = get(settingsStore).groups; 78 | 79 | // Group annotations per article 80 | const articlesMap = data.reduce((result, annotationData) => { 81 | const url = annotationData['uri']; 82 | const md5Hash = md5(url); 83 | 84 | // Skip pdf source 85 | if ((url).startsWith('urn:x-pdf')) { 86 | return result; 87 | } 88 | 89 | // Check if group is selected 90 | const group = groups.find(k => k.id == annotationData['group']); 91 | if (!group.selected) { 92 | return result; 93 | } 94 | 95 | 96 | const title = annotationData['document']['title']?.[0] || parseTitleFromUrl(url); 97 | const author = parseAuthorUrl(url); 98 | // Set article metadata, if not already set by previous annotation 99 | if (!result[md5Hash]) { 100 | result[md5Hash] = { id: md5Hash, metadata: { title, url, author }, highlights: [], page_notes: [] }; 101 | } 102 | 103 | const annotation = parseHighlight(annotationData, group.name, momentFormat) 104 | if (!annotation.text && !annotation.isReply) { 105 | result[md5Hash].page_notes.push(annotation); 106 | } else { 107 | result[md5Hash].highlights.push(annotation); 108 | } 109 | 110 | return result; 111 | }, {}); 112 | 113 | return Object.values(articlesMap) 114 | } 115 | 116 | export default parseSyncResponse; -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import nunjucks from 'nunjucks'; 2 | import { get } from 'svelte/store'; 3 | import { settingsStore } from '~/store'; 4 | import type { Article, RenderTemplate } from './models'; 5 | 6 | export class Renderer { 7 | constructor() { 8 | nunjucks.configure({ autoescape: false }); 9 | } 10 | 11 | validate(template: string): boolean { 12 | try { 13 | nunjucks.renderString(template, {}); 14 | return true; 15 | } catch (error) { 16 | return false; 17 | } 18 | } 19 | 20 | render(entry: Article, isNew = true): string { 21 | const { metadata , highlights, page_notes } = entry; 22 | 23 | const context: RenderTemplate = { 24 | is_new_article: isNew, 25 | ...metadata, 26 | highlights, 27 | page_notes, 28 | }; 29 | 30 | const template = get(settingsStore).template; 31 | const content = nunjucks.renderString(template, context); 32 | return content; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/settingsTab/datetimeInstructions.html: -------------------------------------------------------------------------------- 1 | Configure how {{created}} and {{updated}} timestamps in the Nunjucks template will be formatted. 2 |
3 | For more syntax, refer to the (format reference). -------------------------------------------------------------------------------- /src/settingsTab/index.ts: -------------------------------------------------------------------------------- 1 | import templateInstructions from './templateInstructions.html'; 2 | import datetimeInstructions from './datetimeInstructions.html'; 3 | import type HypothesisPlugin from '~/main'; 4 | import pickBy from 'lodash.pickby'; 5 | import { App, PluginSettingTab, Setting } from 'obsidian'; 6 | import { get } from 'svelte/store'; 7 | import { Renderer } from '~/renderer'; 8 | import { settingsStore } from '~/store'; 9 | import { TokenManager } from '~/store/tokenManager'; 10 | import ApiTokenModal from '~/modals/apiTokenModal'; 11 | import ResyncDelFileModal from '~/modals/resyncDelFileModal'; 12 | import SyncGroup from '~/sync/syncGroup'; 13 | import ManageGroupsModal from '~/modals/manageGroupsModal'; 14 | 15 | const { moment } = window; 16 | 17 | export class SettingsTab extends PluginSettingTab { 18 | public app: App; 19 | private plugin: HypothesisPlugin; 20 | private renderer: Renderer; 21 | private tokenManager: TokenManager; 22 | private syncGroup: SyncGroup; 23 | 24 | constructor(app: App, plugin: HypothesisPlugin) { 25 | super(app, plugin); 26 | this.app = app; 27 | this.plugin = plugin; 28 | this.renderer = new Renderer(); 29 | this.tokenManager = new TokenManager(); 30 | this.syncGroup = new SyncGroup(); 31 | } 32 | 33 | public async display(): Promise { 34 | const { containerEl } = this; 35 | 36 | containerEl.empty(); 37 | 38 | if (get(settingsStore).isConnected) { 39 | this.disconnect(); 40 | } else { 41 | this.connect(); 42 | } 43 | this.autoSyncInterval(); 44 | this.highlightsFolder(); 45 | this.folderPath(); 46 | this.syncOnBoot(); 47 | this.dateFormat(); 48 | this.template(); 49 | this.manageGroups(); 50 | this.resetSyncHistory(); 51 | } 52 | 53 | private disconnect(): void { 54 | const syncMessage = get(settingsStore).lastSyncDate 55 | ? `Last sync ${moment(get(settingsStore).lastSyncDate).fromNow()}` 56 | : 'Sync has never run'; 57 | 58 | const descFragment = document.createRange().createContextualFragment(` 59 | ${get(settingsStore).history.totalArticles} article(s) & ${get(settingsStore).history.totalHighlights} highlight(s) synced
60 | ${syncMessage} 61 | `); 62 | 63 | new Setting(this.containerEl) 64 | .setName(`Connected to Hypothes.is as ${(get(settingsStore).user).match(/([^:]+)@/)[1]}`) 65 | .setDesc(descFragment) 66 | .addButton((button) => { 67 | return button 68 | .setButtonText('Disconnect') 69 | .setCta() 70 | .onClick(async () => { 71 | button 72 | .removeCta() 73 | .setButtonText('Removing API token...') 74 | .setDisabled(true); 75 | 76 | await settingsStore.actions.disconnect(); 77 | 78 | this.display(); // rerender 79 | }); 80 | }); 81 | } 82 | 83 | private connect(): void { 84 | new Setting(this.containerEl) 85 | .setName('Connect to Hypothes.is') 86 | .addButton((button) => { 87 | return button 88 | .setButtonText('Connect') 89 | .setCta() 90 | .onClick(async () => { 91 | button 92 | .removeCta() 93 | .setButtonText('Removing API token...') 94 | .setDisabled(true); 95 | 96 | const tokenModal = new ApiTokenModal(this.app, this.tokenManager); 97 | await tokenModal.waitForClose; 98 | 99 | this.display(); // rerender 100 | }); 101 | }); 102 | } 103 | 104 | private autoSyncInterval(): void { 105 | new Setting(this.containerEl) 106 | .setName('Auto sync in interval (minutes)') 107 | .setDesc('Sync every X minutes. To disable auto sync, specify negative value or zero (default)') 108 | .addText((text) => { 109 | text 110 | .setPlaceholder(String(0)) 111 | .setValue(String(get(settingsStore).autoSyncInterval)) 112 | .onChange(async (value) => { 113 | if (!isNaN(Number(value))) { 114 | const minutes = Number(value); 115 | await settingsStore.actions.setAutoSyncInterval(minutes); 116 | const autoSyncInterval = get(settingsStore).autoSyncInterval; 117 | console.log(autoSyncInterval); 118 | if (autoSyncInterval > 0) { 119 | this.plugin.clearAutoSync(); 120 | this.plugin.startAutoSync(minutes); 121 | console.log( 122 | `Auto sync enabled! Every ${minutes} minutes.` 123 | ); 124 | } else if (autoSyncInterval <= 0) { 125 | this.plugin.clearAutoSync() && console.log("Auto sync disabled!"); 126 | } 127 | } 128 | }); 129 | }); 130 | } 131 | 132 | private highlightsFolder(): void { 133 | new Setting(this.containerEl) 134 | .setName('Highlights folder location') 135 | .setDesc('Vault folder to use for writing hypothesis highlights') 136 | .addDropdown((dropdown) => { 137 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 138 | const files = (this.app.vault.adapter as any).files; 139 | const folders = pickBy(files, (val) => { 140 | return val.type === 'folder'; 141 | }); 142 | 143 | Object.keys(folders).forEach((val) => { 144 | dropdown.addOption(val, val); 145 | }); 146 | return dropdown 147 | .setValue(get(settingsStore).highlightsFolder) 148 | .onChange(async (value) => { 149 | await settingsStore.actions.setHighlightsFolder(value); 150 | }); 151 | }); 152 | } 153 | 154 | private template(): void { 155 | 156 | const descFragment = document 157 | .createRange() 158 | .createContextualFragment(templateInstructions); 159 | 160 | new Setting(this.containerEl) 161 | .setName('Highlights template') 162 | .setDesc(descFragment) 163 | .addTextArea((text) => { 164 | text.inputEl.style.width = '100%'; 165 | text.inputEl.style.height = '450px'; 166 | text.inputEl.style.fontSize = '0.8em'; 167 | text 168 | .setValue(get(settingsStore).template) 169 | .onChange(async (value) => { 170 | const isValid = this.renderer.validate(value); 171 | 172 | if (isValid) { 173 | await settingsStore.actions.setTemplate(value); 174 | } 175 | 176 | text.inputEl.style.border = isValid ? '' : '1px solid red'; 177 | }); 178 | return text; 179 | }); 180 | } 181 | 182 | private folderPath(): void { 183 | new Setting(this.containerEl) 184 | .setName('Use domain folders') 185 | .setDesc('Group generated files into folders based on the domain of the annotated URL') 186 | .addToggle((toggle) => 187 | toggle 188 | .setValue(get(settingsStore).useDomainFolders) 189 | .onChange(async (value) => { 190 | await settingsStore.actions.setUseDomainFolder(value); 191 | }) 192 | ); 193 | } 194 | 195 | private syncOnBoot(): void { 196 | new Setting(this.containerEl) 197 | .setName('Sync on Startup') 198 | .setDesc( 199 | 'Automatically sync new highlights when Obsidian starts' 200 | ) 201 | .addToggle((toggle) => 202 | toggle 203 | .setValue(get(settingsStore).syncOnBoot) 204 | .onChange(async (value) => { 205 | await settingsStore.actions.setSyncOnBoot(value); 206 | }) 207 | ); 208 | } 209 | 210 | private resetSyncHistory(): void { 211 | new Setting(this.containerEl) 212 | .setName('Reset sync') 213 | .setDesc('Wipe sync history to allow for resync') 214 | .addButton((button) => { 215 | return button 216 | .setButtonText('Reset') 217 | .setDisabled(!get(settingsStore).isConnected) 218 | .setWarning() 219 | .onClick(async () => { 220 | await settingsStore.actions.resetSyncHistory(); 221 | this.display(); // rerender 222 | }); 223 | }); 224 | } 225 | 226 | private dateFormat(): void { 227 | const descFragment = document 228 | .createRange() 229 | .createContextualFragment(datetimeInstructions); 230 | 231 | new Setting(this.containerEl) 232 | .setName('Date & time format') 233 | .setDesc(descFragment) 234 | .addText((text) => { 235 | text 236 | .setPlaceholder('YYYY-MM-DD HH:mm:ss') 237 | .setValue(get(settingsStore).dateTimeFormat) 238 | .onChange(async (value) => { 239 | await settingsStore.actions.setDateTimeFormat(value); 240 | }); 241 | }); 242 | } 243 | 244 | private async resyncDeletedFile(): Promise { 245 | new Setting(this.containerEl) 246 | .setName('Sync deleted file(s)') 247 | .setDesc('Manually sync deleted file(s)') 248 | .addButton((button) => { 249 | return button 250 | .setButtonText('Show deleted file(s)') 251 | .setCta() 252 | .onClick(async () => { 253 | button 254 | .removeCta() 255 | .setButtonText('Resync deleted file..') 256 | .setDisabled(true); 257 | 258 | const resyncDelFileModal = new ResyncDelFileModal(this.app); 259 | await resyncDelFileModal.waitForClose; 260 | 261 | this.display(); // rerender 262 | }); 263 | }); 264 | } 265 | 266 | private async manageGroups(): Promise { 267 | const descFragment = document.createRange().createContextualFragment(`Add/remove group(s) to be synced.
268 | ${(get(settingsStore).groups).length} group(s) synced from Hypothesis
`); 269 | 270 | new Setting(this.containerEl) 271 | .setName('Groups') 272 | .setDesc(descFragment) 273 | .addExtraButton((button) => { 274 | return button 275 | .setIcon('switch') 276 | .setTooltip('Reset group selections') 277 | .setDisabled(!get(settingsStore).isConnected) 278 | .onClick(async () => { 279 | await settingsStore.actions.resetGroups(); 280 | await this.syncGroup.startSync(); 281 | this.display(); // rerender 282 | }); 283 | }) 284 | .addButton((button) => { 285 | return button 286 | .setButtonText('Manage') 287 | .setCta() 288 | .setDisabled(!get(settingsStore).isConnected) 289 | .onClick(async () => { 290 | const manageGroupsModal = new ManageGroupsModal(this.app); 291 | await manageGroupsModal.waitForClose; 292 | this.display(); // rerender 293 | }); 294 | }); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/settingsTab/templateInstructions.html: -------------------------------------------------------------------------------- 1 | Template (Nunjucks) for 2 | rendering every synced Hypothesis highlights & annotations. 3 | 4 |

5 | Available variables to use 6 |

7 | 8 | Article Metadata 9 |
    10 |
  • {{is_new_article}} - New file indicator
  • 11 |
  • {{title}} - Title
  • 12 |
  • {{author}} - Author
  • 13 |
  • {{url}} - Link to source
  • 14 |
  • {{highlights}} - List of your Highlights
  • 15 |
16 | 17 | Highlight 18 |
    19 |
  • {{id}} - Unique Id
  • 20 |
  • {{text}} - Text
  • 21 |
  • {{color}} - Highlight color
  • 22 |
  • {{incontext}} - Link to Highlight in context
  • 23 |
  • {{created}} - Created on
  • 24 |
  • {{updated}} - Updated on
  • 25 |
  • {{user}} - Username
  • 26 |
  • {{group}} - Group name
  • 27 |
28 | 29 | Annotation 30 |
    31 |
  • {{annotation}} - Annotation
  • 32 |
  • {{tags}} - List of tags
  • 33 |
-------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export { TokenManager } from './tokenManager'; 2 | export { settingsStore } from './settings'; 3 | export { syncSessionStore } from './syncSession'; 4 | export { initialise } from './initialise'; -------------------------------------------------------------------------------- /src/store/initialise.ts: -------------------------------------------------------------------------------- 1 | import type HypothesisPlugin from '../main'; 2 | import { settingsStore } from './settings'; 3 | 4 | export async function initialise(plugin: HypothesisPlugin): Promise { 5 | await settingsStore.initialise(plugin); 6 | } 7 | -------------------------------------------------------------------------------- /src/store/settings.ts: -------------------------------------------------------------------------------- 1 | import type { Group } from '~/models'; 2 | import { writable } from 'svelte/store'; 3 | import defaultTemplate from '~/assets/defaultTemplate.njk'; 4 | import type HypothesisPlugin from '~/main'; 5 | 6 | type SyncHistory = { 7 | totalArticles: number; 8 | totalHighlights: number; 9 | }; 10 | 11 | type Settings = { 12 | token: string 13 | user: string 14 | highlightsFolder: string; 15 | lastSyncDate?: Date; 16 | isConnected: boolean; 17 | template: string; 18 | syncOnBoot: boolean; 19 | history: SyncHistory; 20 | dateTimeFormat: string; 21 | autoSyncInterval: number; 22 | groups: Group[]; 23 | useDomainFolders: boolean; 24 | }; 25 | 26 | const DEFAULT_SETTINGS: Settings = { 27 | token: '', 28 | user: '', 29 | highlightsFolder: '/', 30 | isConnected: false, 31 | template: defaultTemplate, 32 | syncOnBoot: false, 33 | autoSyncInterval: 0, 34 | dateTimeFormat: 'YYYY-MM-DD HH:mm:ss', 35 | history: { 36 | totalArticles: 0, 37 | totalHighlights: 0, 38 | }, 39 | groups: [], 40 | useDomainFolders: false 41 | }; 42 | 43 | const createSettingsStore = () => { 44 | const store = writable(DEFAULT_SETTINGS as Settings); 45 | 46 | let _plugin!: HypothesisPlugin; 47 | 48 | // Load settings data from disk into store 49 | const initialise = async (plugin: HypothesisPlugin): Promise => { 50 | const data = Object.assign({}, DEFAULT_SETTINGS, await plugin.loadData()); 51 | 52 | const settings: Settings = { 53 | ...data, 54 | lastSyncDate: data.lastSyncDate ? new Date(data.lastSyncDate) : undefined, 55 | }; 56 | 57 | store.set(settings); 58 | 59 | _plugin = plugin; 60 | }; 61 | 62 | // Listen to any change to store, and write to disk 63 | store.subscribe(async (settings) => { 64 | if (_plugin) { 65 | // Transform settings fields for serialization 66 | const data = { 67 | ...settings, 68 | lastSyncDate: settings.lastSyncDate 69 | ? settings.lastSyncDate.toJSON() 70 | : undefined, 71 | }; 72 | 73 | await _plugin.saveData(data); 74 | } 75 | }); 76 | 77 | const connect = async (token: string, userid: string) => { 78 | store.update((state) => { 79 | state.isConnected = true; 80 | state.token = token; 81 | state.user = userid; 82 | return state; 83 | }); 84 | }; 85 | 86 | const disconnect = () => { 87 | store.update((state) => { 88 | state.isConnected = false; 89 | state.user = undefined; 90 | state.token = undefined; 91 | state.groups = []; 92 | return state; 93 | }); 94 | }; 95 | 96 | const setHighlightsFolder = (value: string) => { 97 | store.update((state) => { 98 | state.highlightsFolder = value; 99 | return state; 100 | }); 101 | }; 102 | 103 | const resetSyncHistory = () => { 104 | store.update((state) => { 105 | state.history.totalArticles = 0; 106 | state.history.totalHighlights = 0; 107 | state.lastSyncDate = undefined; 108 | return state; 109 | }); 110 | }; 111 | 112 | const setSyncDateToNow = () => { 113 | store.update((state) => { 114 | state.lastSyncDate = new Date(); 115 | return state; 116 | }); 117 | }; 118 | 119 | const setTemplate = (value: string) => { 120 | store.update((state) => { 121 | state.template = value; 122 | return state; 123 | }); 124 | }; 125 | 126 | const setSyncOnBoot = (value: boolean) => { 127 | store.update((state) => { 128 | state.syncOnBoot = value; 129 | return state; 130 | }); 131 | }; 132 | 133 | const incrementHistory = (delta: SyncHistory) => { 134 | store.update((state) => { 135 | state.history.totalArticles += delta.totalArticles; 136 | state.history.totalHighlights += delta.totalHighlights; 137 | return state; 138 | }); 139 | }; 140 | 141 | const setDateTimeFormat = (value: string) => { 142 | store.update((state) => { 143 | state.dateTimeFormat = value; 144 | return state; 145 | }); 146 | }; 147 | 148 | const setAutoSyncInterval = (value: number) => { 149 | store.update((state) => { 150 | state.autoSyncInterval = value; 151 | return state; 152 | }); 153 | }; 154 | 155 | const setGroups = async (value: Group[]) => { 156 | store.update((state) => { 157 | state.groups = value; 158 | return state; 159 | }); 160 | }; 161 | 162 | const resetGroups = async () => { 163 | store.update((state) => { 164 | state.groups = []; 165 | return state; 166 | }); 167 | }; 168 | 169 | const setUseDomainFolder = (value: boolean) => { 170 | store.update((state) => { 171 | state.useDomainFolders = value; 172 | return state; 173 | }); 174 | }; 175 | 176 | return { 177 | subscribe: store.subscribe, 178 | initialise, 179 | actions: { 180 | setHighlightsFolder, 181 | resetSyncHistory, 182 | setSyncDateToNow, 183 | connect, 184 | disconnect, 185 | setAutoSyncInterval, 186 | setTemplate, 187 | setSyncOnBoot, 188 | incrementHistory, 189 | setDateTimeFormat, 190 | setGroups, 191 | resetGroups, 192 | setUseDomainFolder, 193 | }, 194 | }; 195 | }; 196 | 197 | export const settingsStore = createSettingsStore(); 198 | -------------------------------------------------------------------------------- /src/store/syncSession.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import { settingsStore } from '~/store'; 3 | import type { Article } from '~/models'; 4 | 5 | type SyncJob = { 6 | status: 'idle' | 'in-progress' | 'done' | 'error'; 7 | articleId: string; 8 | }; 9 | 10 | type SyncResult = { 11 | newArticlesCount: number; 12 | newHighlightsCount: number; 13 | updatedArticlesCount: number; 14 | updatedHighlightsCount: number; 15 | }; 16 | 17 | type SyncSession = { 18 | status: 'idle' | 'login' | 'loading' | 'error'; 19 | errorMessage?: string; 20 | jobs: SyncJob[]; 21 | }; 22 | 23 | // const getArticles = (state: SyncSession): Article[] => { 24 | // return state.jobs.map((j) => j.article); 25 | // }; 26 | 27 | const createSyncSessionStore = () => { 28 | const initialState: SyncSession = { 29 | status: 'idle', 30 | jobs: [], 31 | }; 32 | 33 | const store = writable(initialState); 34 | 35 | const startSync = () => { 36 | store.update((state) => { 37 | state.status = 'loading'; 38 | state.errorMessage = undefined; 39 | state.jobs = []; 40 | return state; 41 | }); 42 | }; 43 | 44 | const reset = () => { 45 | store.update((state) => { 46 | state.status = 'idle'; 47 | state.errorMessage = undefined; 48 | state.jobs = []; 49 | return state; 50 | }); 51 | }; 52 | 53 | const errorSync = (errorMessage: string) => { 54 | store.update((state) => { 55 | state.status = 'error'; 56 | state.errorMessage = errorMessage; 57 | return state; 58 | }); 59 | }; 60 | 61 | const completeSync = (result: SyncResult) => { 62 | store.update((state) => { 63 | settingsStore.actions.setSyncDateToNow(); 64 | settingsStore.actions.incrementHistory({ 65 | totalArticles: result.newArticlesCount, 66 | totalHighlights: result.newHighlightsCount, 67 | }); 68 | reset(); 69 | return state; 70 | }); 71 | }; 72 | 73 | const setJobs = (articles: Article[]) => { 74 | store.update((state) => { 75 | for (const article of articles) { 76 | state.jobs.push({ status: 'idle', articleId: article.id }); 77 | } 78 | return state; 79 | }); 80 | }; 81 | 82 | const updateJob = (article: Article, status: SyncJob['status']) => { 83 | store.update((state) => { 84 | const job = state.jobs.filter((job) => job.articleId === article.id)[0]; 85 | job.status = status; 86 | 87 | if (status === 'done') { 88 | settingsStore.actions.setSyncDateToNow(); 89 | } 90 | 91 | return state; 92 | }); 93 | }; 94 | 95 | return { 96 | subscribe: store.subscribe, 97 | actions: { 98 | startSync, 99 | errorSync, 100 | completeSync, 101 | setJobs, 102 | startJob: (article: Article) => updateJob(article, 'in-progress'), 103 | completeJob: (article: Article) => updateJob(article, 'done'), 104 | errorJob: (article: Article) => updateJob(article, 'error'), 105 | reset, 106 | }, 107 | }; 108 | }; 109 | 110 | export const syncSessionStore = createSyncSessionStore(); 111 | -------------------------------------------------------------------------------- /src/store/tokenManager.ts: -------------------------------------------------------------------------------- 1 | 2 | import { settingsStore } from '~/store'; 3 | import ApiManager from '~/api/api'; 4 | import { Notice } from 'obsidian'; 5 | 6 | export class TokenManager { 7 | 8 | async setToken(token: string){ 9 | if (token === null || token.length == 0) { 10 | await settingsStore.actions.disconnect; 11 | new Notice('Please enter API token to connect') 12 | } else{ 13 | const apiManager = new ApiManager(token); 14 | const userid = await apiManager.getProfile(); 15 | if (userid && userid !== undefined) { 16 | await settingsStore.actions.connect(token, userid); 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/sync/syncGroup.ts: -------------------------------------------------------------------------------- 1 | import { settingsStore } from '~/store'; 2 | import { get } from 'svelte/store'; 3 | import ApiManager from '~/api/api'; 4 | import parseGroupsResponse from '~/parser/parseGroupResponse'; 5 | 6 | export default class SyncGroup { 7 | 8 | async startSync() { 9 | 10 | const token = await get(settingsStore).token; 11 | const userid = await get(settingsStore).user; 12 | 13 | const apiManager = new ApiManager(token, userid); 14 | 15 | const responseBody: [] = await apiManager.getGroups(); 16 | 17 | const fetchedGroups = await parseGroupsResponse(responseBody); 18 | 19 | const currentGroups = await get(settingsStore).groups; 20 | 21 | const mergedGroups = [...currentGroups, ...fetchedGroups]; 22 | const set = new Set(); 23 | 24 | const unionGroups = mergedGroups.filter(item => { 25 | if (!set.has(item.id)) { 26 | set.add(item.id); 27 | return true; 28 | } 29 | return false; 30 | }, set); 31 | 32 | await settingsStore.actions.setGroups(unionGroups); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/sync/syncHypothesis.ts: -------------------------------------------------------------------------------- 1 | import { settingsStore, syncSessionStore } from '~/store'; 2 | import type { SyncState } from './syncState'; 3 | import { get } from 'svelte/store'; 4 | import ApiManager from '~/api/api'; 5 | import parseSyncResponse from '~/parser/parseSyncResponse'; 6 | import SyncGroup from './syncGroup'; 7 | import type FileManager from '~/fileManager'; 8 | import type { Article } from '~/models'; 9 | 10 | export default class SyncHypothesis { 11 | 12 | private syncState: SyncState = { newArticlesSynced: 0, newHighlightsSynced: 0 }; 13 | private syncGroup: SyncGroup; 14 | private fileManager: FileManager; 15 | 16 | constructor(fileManager: FileManager) { 17 | this.fileManager = fileManager; 18 | this.syncGroup = new SyncGroup; 19 | } 20 | 21 | async startSync(uri?: string) { 22 | this.syncState = { newArticlesSynced: 0, newHighlightsSynced: 0 }; 23 | 24 | const token = await get(settingsStore).token; 25 | const userid = await get(settingsStore).user; 26 | 27 | const apiManager = new ApiManager(token, userid); 28 | 29 | syncSessionStore.actions.startSync(); 30 | 31 | //fetch groups 32 | await this.syncGroup.startSync(); 33 | 34 | //fetch highlights 35 | const responseBody: [] = (!uri) ? await apiManager.getHighlights(get(settingsStore).lastSyncDate) : await apiManager.getHighlightWithUri(uri); 36 | const articles = await parseSyncResponse(responseBody); 37 | 38 | syncSessionStore.actions.setJobs(articles); 39 | 40 | if (articles.length > 0) { 41 | await this.syncArticles(articles); 42 | } 43 | 44 | syncSessionStore.actions.completeSync({ 45 | newArticlesCount: this.syncState.newArticlesSynced, 46 | newHighlightsCount: this.syncState.newHighlightsSynced, 47 | updatedArticlesCount: 0, 48 | updatedHighlightsCount: 0, 49 | }); 50 | } 51 | 52 | private async syncArticles(articles: Article[]): Promise { 53 | 54 | for (const article of articles) { 55 | try { 56 | syncSessionStore.actions.startJob(article); 57 | 58 | await this.syncArticle(article); 59 | 60 | syncSessionStore.actions.completeJob(article); 61 | 62 | } catch (e) { 63 | console.error(`Error syncing ${article.metadata.title}`, e); 64 | syncSessionStore.actions.errorJob(article); 65 | } 66 | } 67 | } 68 | 69 | private async syncArticle(article: Article): Promise { 70 | 71 | const createdNewArticle = await this.fileManager.saveArticle(article); 72 | 73 | if (createdNewArticle) { 74 | this.syncState.newArticlesSynced += 1; 75 | } 76 | this.syncState.newHighlightsSynced += article.highlights.length; 77 | 78 | } 79 | } -------------------------------------------------------------------------------- /src/sync/syncState.ts: -------------------------------------------------------------------------------- 1 | export type SyncState = { 2 | newArticlesSynced: number; 3 | newHighlightsSynced: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/frontmatter.ts: -------------------------------------------------------------------------------- 1 | import matter from "gray-matter" 2 | import type { Article } from '~/models'; 3 | 4 | type FrontMatterContent = { 5 | doc_type?: string; 6 | url?: string; 7 | } 8 | 9 | export const frontMatterDocType = "hypothesis-highlights" 10 | 11 | export const addFrontMatter = (markdownContent: string, article: Article) => { 12 | const frontMatter: FrontMatterContent = { 13 | doc_type: frontMatterDocType, 14 | url: article.metadata.url, 15 | }; 16 | return matter.stringify(markdownContent, frontMatter); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/sanitizeTitle.ts: -------------------------------------------------------------------------------- 1 | import sanitize from 'sanitize-filename'; 2 | 3 | export const sanitizeTitle = (title: string): string => { 4 | const santizedTitle = title.replace(/[':#|]/g, '').trim(); 5 | return sanitize(santizedTitle); 6 | }; 7 | 8 | export const sanitizeTitleExcess = (title: string): string => { 9 | const santizedTitle = title 10 | .replace(/ *\([^)]*\) */g, '') // remove parenthesis and contents from title 11 | .replace(/:.*/g, '') // remove description test after `:` in title 12 | .trim(); 13 | 14 | return sanitize(santizedTitle); 15 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "include": ["src/**/*", "tests/**/*"], 4 | "exclude": ["node_modules/*"], 5 | "compilerOptions": { 6 | "types": ["node", "svelte"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "src": ["src/*", "tests/*"], 10 | "~/*": ["src/*"] 11 | }, 12 | "lib": [ 13 | "es2021" 14 | ] 15 | }, 16 | "esModuleInterop": true 17 | } 18 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.19": "0.11.0", 3 | "0.1.18": "0.11.0", 4 | "0.1.17": "0.11.0", 5 | "0.1.16": "0.11.0", 6 | "0.1.15": "0.11.0", 7 | "0.1.14": "0.11.0", 8 | "0.1.13": "0.11.0", 9 | "0.1.12": "0.11.0", 10 | "0.1.11": "0.11.0", 11 | "0.1.10": "0.11.0", 12 | "0.1.9": "0.11.0", 13 | "0.1.8": "0.11.0", 14 | "0.1.7": "0.11.0", 15 | "0.1.6": "0.11.0", 16 | "0.1.5": "0.11.0", 17 | "0.1.4": "0.11.0", 18 | "0.1.3": "0.11.0", 19 | "0.1.2": "0.11.0", 20 | "0.1.1": "0.11.0", 21 | "0.1.0": "0.11.0" 22 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | const sveltePreprocess = require('svelte-preprocess'); 4 | 5 | const isDevMode = process.env.NODE_ENV === 'development'; 6 | 7 | module.exports = { 8 | entry: './src/main.ts', 9 | output: { 10 | path: path.resolve(__dirname, './dist'), 11 | filename: 'main.js', 12 | libraryTarget: 'commonjs' 13 | }, 14 | target: 'node', 15 | mode: isDevMode ? 'development' : 'production', 16 | ...(isDevMode ? { devtool: 'eval' } : {}), 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | options: { 23 | transpileOnly: true 24 | } 25 | }, 26 | { 27 | test: /\.(svelte)$/, 28 | use: [ 29 | { loader: 'babel-loader' }, 30 | { 31 | loader: 'svelte-loader', 32 | options: { 33 | preprocess: sveltePreprocess({}) 34 | } 35 | } 36 | ] 37 | }, 38 | { 39 | test: /\.(svg|njk|html)$/, 40 | type: 'asset/source' 41 | } 42 | ] 43 | }, 44 | plugins: [ 45 | new CopyPlugin({ 46 | patterns: [{ from: './manifest.json', to: '.' }] 47 | }) 48 | ], 49 | resolve: { 50 | alias: { 51 | svelte: path.resolve('node_modules', 'svelte'), 52 | '~': path.resolve(__dirname, 'src') 53 | }, 54 | extensions: ['.ts', '.tsx', '.js', '.svelte'], 55 | mainFields: ['svelte', 'browser', 'module', 'main'] 56 | }, 57 | externals: { 58 | electron: 'commonjs2 electron', 59 | obsidian: 'commonjs2 obsidian' 60 | } 61 | }; 62 | --------------------------------------------------------------------------------