diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..1dbbfc4 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,21 @@ +# 🐝 Developer Section + +What comes below is meant for developers and maintainers. + +## 🚢 Releasing new releases + +Releases are entirely managed in github actions and only require a pull-request +against `master` to be merged. Semantic versioning in connection with +conventional commits will take care of versioning + +## 🏠 How to build + +- Clone this repo. +- Make sure your NodeJS is at least v16 (`node --version`). +- `npm i` or `yarn` to install dependencies. +- `npm run dev` to start compilation in watch mode. +- (optionally) `npm run lint` to check coding style + +## 🎯 Manually installing the plugin + +Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/relay-md/`. diff --git a/README.md b/README.md index a924b3d..a569d9c 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,72 @@ +

+ +

+

+ GitHub Release + GitHub Actions Workflow Status + GitHub Downloads (all assets, all releases) + GitHub contributors + GitHub last commit + GitHub language count + GitHub License + Mozilla HTTP Observatory Grade + GitHub watchers + GitHub Repo stars +

+

+ "Buy Me A Coffee" +

+ # Relay.md Obsidian plugin This repo contains an [Obsidian](https://obsidian.md) plugin for [relay.md](https://relay.md). -The purpose of relay.md is to make sharing markdown files fun again. In -particular, we want to establish "Markdown workflows for teams". +🎉 The purpose of relay.md is to make **sharing markdown files fun again**. -Relay.md makes it easy to send documents to groups or people or individuals and -allows to subscribe to entire teams and their documents. This will allow -individual team members to share their knowledge with the entire team from -within Obsidian. No more copy&pasting and editing into some strange wiki-syntax. +Relay.md enables **quick** and **easy** sharing of documents with a team and +allows to **subscribe** to individual subjects. Individual team members to share +their knowledge with the entire team from +within Obsidian. + +* ❌ No more **copy&pasting** to share a document with your team. +* ❌ No more changing syntax because your mail client does not support Markdown +* ❌ No more moving your hands away from keyboard to your mouse +* ❌ No more looking up email addresses +* ✅ share your document with the right people in seconds -Further, those that deal with different projects, teams or clients, can keep -their information aggregated within Obsidian and send out their documents to -corresponding people from within Obsidian. Got your specs for -*new-start-up-feature-A* ready, send them out to the tech team of the startup. Finished writing a consultancy contract for *business B*, have them notified -from within Obsidian by sendind the docs via relay.md. +Further, those that deal with different projects, teams or clients, can keep +their information aggregated within **as single vault** and send out their documents to +corresponding people from within Obsidian. + +🤩 Send specification to the right people in your team using **topics**. +from within Obsidian by sending the docs via relay.md. + +🎯 Most importantly, you get to keep your knowledge together! + +# 🏠 Installation + +1. [open relay.md plugin in Obsidian](obsidian://show-plugin?id=relay-md) +2. click **install** +3. click **enable** +4. click **configure** -Most importantly, you get to keep your stuff together! +# 🪢 Link your account with relay.md +1. Open plugin configuration +2. Click **obtain access to relay.md** +3. 🎉 Ready to rumble -# Howto: +# 📶 Use the Plugin Using relay.md couldn't be easier with Obsidian. All you need to do is specify -the recipient(s) in the frontmatter using: +the recipient(s) in the **frontmatter** using: ``` relay-to: - - label@team + - subject@team-name - @funnyfriend49 ``` -Upon updating the document, the file will be sent to relay.md using your -personal access token. As soon as your friends open up their Obsidian, the -relay.md plugin will automatically retrieve all documents that have been created -or updated. - -# Developer Section - -What comes below is meant for developers and maintainers. - -## Releasing new releases -Releases are entirely managed in github actions and only require a pull-request -against `master` to be merged. Semantic versioning in connection with -conventional commits will take care of versioning - -## How to build - -- Clone this repo. -- Make sure your NodeJS is at least v16 (`node --version`). -- `npm i` or `yarn` to install dependencies. -- `npm run dev` to start compilation in watch mode. -- (optionally) `npm run lint` to check coding style - -## Manually installing the plugin - -Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/relay-md/`. +1. Upon updating the document, the file will be sent to relay.md +2. Your access credentials are stored in your vault during configuration +3. As soon as your team mates open up their Obsidian, the relay.md plugin will automatically retrieve all documents that have been created or updated for them. diff --git a/src/main.ts b/src/main.ts index bfb383f..f28e4af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,20 +13,53 @@ import { normalizePath } from 'obsidian'; +interface Embed { + checksum_sha256: string; + filename: string; + filesize: number; + id: string; +} + +/* + * Doesn't work because we use "-" in keys in the json api +interface Document { + checksum_sha256: string; + embeds: Array; + filesize: int; + relay_document: string; + relay_filename: string; + relay_title: string; + relay_to: string; +} +*/ + interface RelayMDSettings { + // Webseite + auth_url: string; + // API Site base_uri: string; + // API Access key api_key: string; + // username that corresponds to the api_key + api_username: string; + // vault folder to store new files in vault_base_folder: string + // Interval to check for new documents + fetch_recent_documents_interval: number } const DEFAULT_SETTINGS: RelayMDSettings = { + auth_url: 'https://relay.md', base_uri: 'https://api.relay.md', api_key: '', - vault_base_folder: "relay.md" + api_username: '', + vault_base_folder: "relay.md", + fetch_recent_documents_interval: 5 * 60, } export default class RelayMdPLugin extends Plugin { settings: RelayMDSettings; + fetch_recent_document_timer: number; async onload() { await this.loadSettings(); @@ -38,7 +71,8 @@ export default class RelayMdPLugin extends Plugin { return; } this.settings.api_key = params.token; - this.settings.base_uri = DEFAULT_SETTINGS.base_uri; // also potentially reset the base uri + this.settings.api_username = params.username; + this.settings.base_uri = params.api_url; this.saveSettings(); new Notice("Access credentials for relay.md have been succesfully installed!"); }); @@ -77,10 +111,7 @@ export default class RelayMdPLugin extends Plugin { } })); - // Additionally, we register a timer to fetch documents for us - this.registerInterval(window.setInterval(() => { - this.get_recent_documents(); - }, 5 * 60 * 1000)); // 5 minutes + this.register_timer(); this.addSettingTab(new RelayMDSettingTab(this.app, this)); } @@ -97,35 +128,68 @@ export default class RelayMdPLugin extends Plugin { await this.saveData(this.settings); } - async upsert_document(folder: string, filename: string, body: string) { - // Does the folder exist? If not, create it "recusrively" - if (!(this.app.vault.getAbstractFileByPath(folder) instanceof TFolder)) { - folder.split('/').reduce( - (directories, directory) => { - directories += `${directory}/`; - try { - this.app.vault.createFolder(directories); - } catch (e) { - // do nothing - } - return directories; - }, - '', - ); - } - const full_path_to_file = normalizePath(folder + "/" + filename); - const fileRef = this.app.vault.getAbstractFileByPath(full_path_to_file); + make_directory_recursively(path: string) { + // path contains the filename as well which could contain a folder. That's why we slice away the last part + path.split('/').slice(0, -1).reduce( + (directories = "", directory) => { + directories += `${directory}/`; + if (!(this.app.vault.getAbstractFileByPath(directories) instanceof TFolder)) { + this.app.vault.createFolder(directories); + } + return directories + }, + '', + ); + } + + async upsert_document(path: string, body: string) { + const fileRef = this.app.vault.getAbstractFileByPath(path); if (fileRef === undefined || fileRef === null) { - await this.app.vault.create(full_path_to_file, body); - new Notice('File ' + full_path_to_file + ' has been created!'); + await this.app.vault.create(path, body); + new Notice('File ' + path + ' has been created!'); } else if (fileRef instanceof TFile) { - // TODO: consider storing multiple versions of a file here! await this.app.vault.modify(fileRef, body); - new Notice('File ' + full_path_to_file + ' has been modified!'); + new Notice('File ' + path + ' has been modified!'); } } async load_document(id: string) { + // We first obtain the metdata of the document using application/json + const options: RequestUrlParam = { + url: this.settings.base_uri + '/v1/doc/' + id, + method: 'GET', + headers: { + 'X-API-KEY': this.settings.api_key, + 'Content-Type': 'application/json' + }, + } + const response: RequestUrlResponse = await requestUrl(options); + if (response.json.error) { + console.error("API server returned an error"); + new Notice("API returned an error: " + response.json.error.message); + return; + } + + const result = response.json.result; + + // Load embeds + const embeds = result.embeds; + if (embeds) { + embeds.map((embed: Embed) => { + this.load_embeds(embed, result); + }); + } + + // Load the document body, we are going to use text/markdown here + this.load_document_body(result); + } + + async load_document_body(result: any) { + const id: string = result["relay-document"]; + const filename: string = result["relay-filename"]; + const relay_to: Array = result["relay-to"]; + const remote_checksum = result["checksum_sha256"]; + const options: RequestUrlParam = { url: this.settings.base_uri + '/v1/doc/' + id, method: 'GET', @@ -137,26 +201,91 @@ export default class RelayMdPLugin extends Plugin { const response: RequestUrlResponse = await requestUrl(options); // we do not look for error in json response as we do not get a json // response but markdown in the body - try { - const filename: string = response.headers["x-relay-filename"]; - const relay_to = JSON.parse(response.headers["x-relay-to"]); - const body: string = response.text; - + const body: string = response.text; + + // Let's first see if we maybe have the document already somewhere in the fault + const located_documents = await this.locate_document(id); + if (located_documents?.length) { + located_documents.forEach(async (located_document: TFile) => { + // Compare checksum + const local_content = await this.app.vault.readBinary(located_document); + const checksum = await this.calculateSHA256Checksum(local_content); + if (checksum != remote_checksum) { + this.upsert_document(located_document.path, body); + } else { + console.log("No change detected on " + located_document.path); + } + }) + } else { // Loop through team/topics for (const to of relay_to) { const tos: Array = to.split("@", 2); const team: string = tos[1]; const topic: string = tos[0]; - let full_path_to_file: string = this.settings.vault_base_folder + "/"; + let folder: string = this.settings.vault_base_folder + "/"; // We ignore the "_" team which is a "global" team if (team != "_") - full_path_to_file += team + "/"; - full_path_to_file += topic; - this.upsert_document(full_path_to_file, filename, body); + folder += team + "/"; + folder += topic; + const path = normalizePath(folder + "/" + filename); + // Does the folder exist? If not, create it "recursively" + this.make_directory_recursively(path); + this.upsert_document(path, body); } - } catch (e) { - console.log(JSON.stringify(e)); - throw e; + } + } + + async load_embeds(embed: Embed, document: any) { // document is a dictionary provided by the API with incompatible key names like ("relay-to") + // TODO: think about this for a bit, it allows to update other peoples file by just using the same filename + // On the other hand, if we were to put the team name into the path, we end up (potentially) having tos + // duplicate the file into multiple team's attachment folder. Hmm + document["relay-to"].forEach(async (team_topic: string) => { + const parts = team_topic.split("@", 2); + const team = parts[1]; + if (!team) return; + const folder = normalizePath(this.settings.vault_base_folder + "/" + team + "/_attachments"); + const path = normalizePath(folder + "/" + embed.filename); + this.make_directory_recursively(path); + const file = this.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const local_content = await this.app.vault.readBinary(file); + const checksum = await this.calculateSHA256Checksum(local_content); + if (checksum != embed.checksum_sha256) { + const content = await this.get_embed_binady(embed); + this.app.vault.modifyBinary(file, content); + console.log("Binary file " + path + " has been updated!"); + } + } else { + const content = await this.get_embed_binady(embed); + this.app.vault.createBinary(path, content); + console.log("Binary file " + path + " has been created!"); + } + }) + } + + async get_embed_binady(embed: Embed) { + const options: RequestUrlParam = { + url: this.settings.base_uri + '/v1/assets/' + embed.id, + method: 'GET', + headers: { + 'X-API-KEY': this.settings.api_key, + 'Content-Type': 'application/octet-stream' + }, + } + const response: RequestUrlResponse = await requestUrl(options); + return response.arrayBuffer; + } + + async calculateSHA256Checksum(buffer: ArrayBuffer): Promise { + const data = new Uint8Array(buffer); + + try { + const hashBuffer = await window.crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const checksum = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); + return checksum; + } catch (error) { + throw new Error(`Error calculating SHA-256 checksum: ${error}`); } } @@ -172,7 +301,7 @@ export default class RelayMdPLugin extends Plugin { const response: RequestUrlResponse = await requestUrl(options); if (response.json.error) { console.error("API server returned an error"); - new Notice("Relay.md returned an error: " + response.json.error.message); + new Notice("API returned an error: " + response.json.error.message); return; } try { @@ -208,32 +337,29 @@ export default class RelayMdPLugin extends Plugin { } // We only share if relay-to is provided, even if its empty - if (!("relay-to" in metadata.frontmatter)) { + if (!("relay-to" in metadata.frontmatter) + || !(metadata.frontmatter["relay-to"])) { return } - // File is in the shared folder, no re-sharing - if (activeFile.path.startsWith(this.settings.vault_base_folder + "/")) { - console.warn( - "Files from the relay.md base folder cannot be sent." - ); - return; - } - // Do we already have an id maybe? - const id = metadata.frontmatter["relay-document"] + let id = metadata.frontmatter["relay-document"] // Get the content of the file const body = await this.app.vault.cachedRead(activeFile); // Either we POST a new document or we PUT an existing document let method = "POST"; - let url = this.settings.base_uri + '/v1/doc?filename=' + encodeURIComponent(activeFile.name); + let url = this.settings.base_uri + '/v1/doc?filename=' + encodeURIComponent(activeFile.path); if (id) { method = "PUT" url = this.settings.base_uri + '/v1/doc/' + id; + + // In this case, we also need to check if we maybe have to update the assets we originally had in the document as well + // TODO: this also means that we may have to delete embeds that have been removed from the note since it has been last + // sent to the API } - console.log("Sending API request to api.relay.md (" + method + ")"); + console.log("Sending API request to " + this.settings.base_uri + " (" + method + ")"); const options: RequestUrlParam = { url: url, method: method, @@ -246,25 +372,94 @@ export default class RelayMdPLugin extends Plugin { const response: RequestUrlResponse = await requestUrl(options); if (response.json.error) { console.error("API server returned an error"); - new Notice("Relay.md returned an error: " + response.json.error.message); + new Notice("API returned an error: " + response.json.error.message); return; } + console.log("Successfully sent to " + this.settings.base_uri); + try { // WARNING: This overwrites an existing relay-document id potentially, // depending on how the server responds. It's a feature, and not a bug and // allows the backend to decide if a new document should be // created, or the old one should be updated, depending on // permissions. - const doc_id = response.json.result["relay-document"]; + id = response.json.result["relay-document"]; // update document to contain new document id - app.fileManager.processFrontMatter(activeFile, (frontmatter) => { - frontmatter["relay-document"] = doc_id; + this.app.fileManager.processFrontMatter(activeFile, (frontmatter) => { + frontmatter["relay-document"] = id; }); } catch (e) { console.log(e); } + + // Now try upload the embeds + if (!metadata.embeds) { + return; + } + metadata.embeds.map(async (item: any) => { + let file = this.app.vault.getAbstractFileByPath(item.link); + if (!(file instanceof TFile)) { + file = this.app.metadataCache.getFirstLinkpathDest(item.link, ""); + } + if (!file || !(file instanceof TFile)) { + console.log(`Embed ${item.link} was not found!`) + } else { + this.upload_asset(id, item.link, file); + } + }); } + + async upload_asset(id: string, link: string, file: TFile) { + const content = await this.app.vault.readBinary(file); + const options: RequestUrlParam = { + url: this.settings.base_uri + '/v1/assets/' + id, + method: "POST", + headers: { + 'X-API-KEY': this.settings.api_key, + 'Content-Type': 'application/octet-stream', + 'x-relay-filename': link + }, + body: content, + } + const response: RequestUrlResponse = await requestUrl(options); + if (response.json.error) { + console.error("API server returned an error"); + new Notice("API returned an error: " + response.json.error.message); + return; + } + console.log("Successfully uploaded " + file.path + " as " + response.json.result.id); + } + + async locate_document(document_id: string) { + const files = this.app.vault.getMarkdownFiles(); + let located_files: Array = []; + for (let i = 0; i < files.length; i++) { + const activeFile = files[i]; + const metadata = this.app.metadataCache.getCache(activeFile.path); + if (!metadata || !metadata.frontmatter) { + return; + } + if (metadata.frontmatter["relay-document"] == document_id) + located_files.push(activeFile); + } + return located_files; + } + + register_timer() { + // Additionally, we register a timer to fetch documents for us + if (this.fetch_recent_document_timer) { + window.clearInterval( + this.fetch_recent_document_timer + ); + } + this.fetch_recent_document_timer = window.setInterval(() => { + this.get_recent_documents(); + }, this.settings.fetch_recent_documents_interval * 1000); + + this.registerInterval(this.fetch_recent_document_timer); + } + } class RelayMDSettingTab extends PluginSettingTab { @@ -281,32 +476,31 @@ class RelayMDSettingTab extends PluginSettingTab { containerEl.empty(); new Setting(containerEl) - .setName('Base API URI') - .setDesc('Base URL for API access') + .setName('Authenticate against') + .setDesc('Main Website to manage accounts') .addText(text => text - .setPlaceholder('Enter your API url') - .setValue(this.plugin.settings.base_uri) + .setPlaceholder('Enter your URL') + .setValue(this.plugin.settings.auth_url) .onChange(async (value) => { - this.plugin.settings.base_uri = value; + this.plugin.settings.auth_url = value; await this.plugin.saveSettings(); })); - if (this.plugin.settings.api_key === DEFAULT_SETTINGS.api_key) { + if (!(this.plugin.settings.api_key) || this.plugin.settings.api_key === DEFAULT_SETTINGS.api_key) { new Setting(containerEl) .setName('API Access') - .setDesc('Authenticate against the relay.md API') + .setDesc('Link with your account') .addButton((button) => button.setButtonText("Obtain access to relay.md").onClick(async () => { - window.open("https://relay.md/configure/obsidian"); - //window.open("http://localhost:5000/configure/obsidian"); + window.open(this.plugin.settings.auth_url + "/configure/obsidian"); }) ); } else { new Setting(containerEl) .setName('API Access') - .setDesc('Authenticate against the relay.md API') + .setDesc(`Logged in as @${this.plugin.settings.api_username}`) .addButton((button) => - button.setButtonText("reset").onClick(async () => { + button.setButtonText(`Logout!`).onClick(async () => { this.plugin.settings.api_key = DEFAULT_SETTINGS.api_key; await this.plugin.saveSettings(); // refresh settings page @@ -325,5 +519,22 @@ class RelayMDSettingTab extends PluginSettingTab { this.plugin.settings.vault_base_folder = value; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName('Fetch recent documents interval') + .setDesc('How often to look for document updates in seconds') + .addText(text => text + .setPlaceholder('300') + .setValue(this.plugin.settings.fetch_recent_documents_interval.toString()) + .onChange(async (value) => { + const as_float = parseFloat(value); + if (isNaN(as_float) || !isFinite(+as_float)) { + new Notice("Interval must be an number!") + } else { + this.plugin.settings.fetch_recent_documents_interval = as_float; + await this.plugin.saveSettings(); + this.plugin.register_timer(); + } + })); } }