├── .npmrc ├── .eslintignore ├── versions.json ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── styles.css ├── .github └── workflows │ └── release.yml ├── LICENSE ├── esbuild.config.mjs ├── lib ├── Utils.ts ├── types.ts ├── Arena.ts ├── Modals.ts ├── Settings.ts ├── Commands.ts └── FileHandler.ts ├── main.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "arena-manager", 3 | "name": "Are.na Manager", 4 | "version": "1.0.5", 5 | "minAppVersion": "1.6.4", 6 | "description": "Publish content from your vault to Arena and the other way around.", 7 | "author": "Javier Arce", 8 | "authorUrl": "https://javier.computer", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arena-manager", 3 | "version": "1.0.0", 4 | "description": "Publish content from your vault to Arena and the other way around.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .arena-manager-modal .suggestion-item { 2 | display: flex; 3 | gap: 0.4em; 4 | align-items: center; 5 | } 6 | .arena-manager-modal span.count { 7 | opacity: 0.5; 8 | } 9 | .arena-manager-modal .icon-lock { 10 | box-sizing: border-box; 11 | position: relative; 12 | display: block; 13 | transform: scale(0.8); 14 | width: 10px; 15 | height: 8px; 16 | border: 2px solid; 17 | border-top-right-radius: 50%; 18 | border-top-left-radius: 50%; 19 | border-bottom: transparent; 20 | margin-top: -12px; 21 | opacity: 0.5; 22 | } 23 | .arena-manager-modal .icon-lock::after { 24 | content: ""; 25 | display: block; 26 | box-sizing: border-box; 27 | position: absolute; 28 | width: 12px; 29 | height: 8px; 30 | border-radius: 1px; 31 | border: 2px solid transparent; 32 | box-shadow: 0 0 0 2px; 33 | left: -3px; 34 | top: 8px; 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Javier Arce 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. 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 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /lib/Utils.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "./types"; 2 | import { Settings } from "./Settings"; 3 | 4 | class Utils { 5 | createPermalinkFromTitle(title: string) { 6 | return title.replace(/-\d+$/, ""); 7 | } 8 | 9 | getFrontmatterFromBlock(block: Block, channelTitle?: string) { 10 | const frontmatter: Record = {}; 11 | 12 | frontmatter["blockid"] = block.id; 13 | 14 | if (block.class) { 15 | frontmatter["class"] = block.class; 16 | } 17 | 18 | if (block.title) { 19 | frontmatter["title"] = block.title; 20 | } 21 | 22 | if (block.description) { 23 | frontmatter["description"] = block.description; 24 | } 25 | 26 | if (block.user?.slug) { 27 | frontmatter["user"] = block.user.slug; 28 | } 29 | 30 | if (block.source?.title) { 31 | frontmatter["source title"] = block.source.title; 32 | } 33 | 34 | if (block.source?.url) { 35 | frontmatter["source url"] = block.source.url; 36 | } 37 | 38 | if (channelTitle) { 39 | frontmatter["channel"] = channelTitle; 40 | } 41 | 42 | return frontmatter; 43 | } 44 | 45 | hasRequiredSettings(settings: Settings) { 46 | const { username, folder, accessToken } = settings; 47 | 48 | if (!username || !folder || !accessToken) { 49 | return false; 50 | } 51 | 52 | return true; 53 | } 54 | } 55 | 56 | export default new Utils(); 57 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export const ARENA_BLOCK_URL = "https://www.are.na/block/"; 2 | export const ARENA_APP_URL = "https://dev.are.na/oauth/applications"; 3 | 4 | export interface Channel { 5 | id: number; 6 | slug: string; 7 | title: string; 8 | body: string; 9 | length: number; 10 | status: string; 11 | class: string; 12 | base_class: string; 13 | contents: Block[]; 14 | } 15 | 16 | export interface Provider { 17 | name: string; 18 | url: string; 19 | } 20 | 21 | export interface Source { 22 | provider: Provider; 23 | title: string; 24 | url: string; 25 | } 26 | 27 | export interface Attachment { 28 | extension: string; 29 | file_name: string; 30 | file_size: number; 31 | file_size_display: string; 32 | url: string; 33 | } 34 | 35 | export interface User { 36 | avatar: string; 37 | avatar_image: string; 38 | badge: string; 39 | base_class: string; 40 | can_index: boolean; 41 | channel_count: number; 42 | class: string; 43 | created_at: string; 44 | first_name: string; 45 | follower_count: number; 46 | following_count: number; 47 | full_name: string; 48 | id: number; 49 | initials: string; 50 | is_confirmed: boolean; 51 | is_exceeding_connections_limit: boolean; 52 | is_lifetime_premium: boolean; 53 | is_pending_confirmation: boolean; 54 | is_pending_reconfirmation: boolean; 55 | is_premium: boolean; 56 | is_supporter: boolean; 57 | last_name: string; 58 | metadata: object; 59 | profile_id: number; 60 | slug: string; 61 | username: string; 62 | } 63 | 64 | export interface URL { 65 | url: string; 66 | } 67 | export interface Image { 68 | content_type: string; 69 | display: URL; 70 | filename: string; 71 | large: URL; 72 | original: URL; 73 | square: URL; 74 | thumb: URL; 75 | updated_at: string; 76 | } 77 | 78 | export interface Block { 79 | user: User; 80 | id: number; 81 | attachment: Attachment; 82 | base_class: string; 83 | image: Image; 84 | class: string; 85 | comment_count: number; 86 | connected_at: string; 87 | connected_by_user_id: number; 88 | connected_by_user_slug: string; 89 | connected_by_username: string; 90 | connection_id: number; 91 | content: string; 92 | content_html: string; 93 | created_at: string; 94 | description: string; 95 | description_html: string; 96 | embed: string; 97 | generated_title: string; 98 | position: number; 99 | slug: string; 100 | source: Source; 101 | title: string; 102 | } 103 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Events, Plugin } from "obsidian"; 2 | 3 | import { 4 | Settings, 5 | DEFAULT_SETTINGS, 6 | TemplaterSettingTab, 7 | } from "./lib/Settings"; 8 | 9 | import Arena from "./lib/Arena"; 10 | import Commands from "./lib/Commands"; 11 | import FileHandler from "./lib/FileHandler"; 12 | import Utils from "./lib/Utils"; 13 | 14 | export default class ArenaManagerPlugin extends Plugin { 15 | settings: Settings; 16 | events: Events; 17 | arena: Arena; 18 | fileHandler: FileHandler; 19 | commands: Commands; 20 | 21 | async onload() { 22 | await this.loadSettings(); 23 | this.commands = new Commands(this.app, this.settings); 24 | 25 | this.events = new Events(); 26 | this.fileHandler = new FileHandler(this.app, this.settings); 27 | this.arena = new Arena(this.settings); 28 | 29 | this.addSettingTab(new TemplaterSettingTab(this)); 30 | 31 | this.addCommand({ 32 | id: "get-blocks-from-channel", 33 | name: "Get blocks from channel", 34 | checkCallback: (checking: boolean) => { 35 | if (Utils.hasRequiredSettings(this.settings)) { 36 | if (!checking) { 37 | this.commands.getBlocksFromChannel(); 38 | } 39 | return true; 40 | } 41 | 42 | return false; 43 | }, 44 | }); 45 | 46 | this.addCommand({ 47 | id: "pull-block", 48 | name: "Pull block from Are.na", 49 | checkCallback: (checking: boolean) => { 50 | const currentFile = this.app.workspace.getActiveFile(); 51 | if (currentFile && Utils.hasRequiredSettings(this.settings)) { 52 | if (!checking) { 53 | this.commands.pullBlock(); 54 | } 55 | return true; 56 | } 57 | 58 | return false; 59 | }, 60 | }); 61 | 62 | this.addCommand({ 63 | id: "push-note", 64 | name: "Push note to Are.na", 65 | checkCallback: (checking: boolean) => { 66 | const currentFile = this.app.workspace.getActiveFile(); 67 | 68 | if (currentFile && Utils.hasRequiredSettings(this.settings)) { 69 | if (!checking) { 70 | this.commands.pushBlock(); 71 | } 72 | return true; 73 | } 74 | 75 | return false; 76 | }, 77 | }); 78 | 79 | this.addCommand({ 80 | id: "get-block-from-arena", 81 | name: "Get a block from Are.na", 82 | checkCallback: (checking: boolean) => { 83 | if (Utils.hasRequiredSettings(this.settings)) { 84 | if (!checking) { 85 | this.commands.getBlockFromArena(); 86 | } 87 | return true; 88 | } 89 | 90 | return false; 91 | }, 92 | }); 93 | 94 | this.addCommand({ 95 | id: "go-to-block", 96 | name: "Go to block in Are.na", 97 | checkCallback: (checking: boolean) => { 98 | const currentFile = this.app.workspace.getActiveFile(); 99 | 100 | if (currentFile) { 101 | if (!checking) { 102 | this.commands.goToBlock(); 103 | } 104 | return true; 105 | } 106 | 107 | return false; 108 | }, 109 | }); 110 | 111 | this.addCommand({ 112 | id: "get-block-by-id", 113 | name: "Get a block by its ID or URL", 114 | checkCallback: (checking: boolean) => { 115 | if (Utils.hasRequiredSettings(this.settings)) { 116 | if (!checking) { 117 | this.commands.getBlockByID(); 118 | } 119 | return true; 120 | } 121 | 122 | return false; 123 | }, 124 | }); 125 | } 126 | 127 | onunload() {} 128 | 129 | async loadSettings() { 130 | this.settings = Object.assign( 131 | {}, 132 | DEFAULT_SETTINGS, 133 | await this.loadData(), 134 | ); 135 | } 136 | 137 | async saveSettings() { 138 | await this.saveData(this.settings); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/Arena.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | import { Channel, Block } from "./types"; 3 | import { Settings } from "./Settings"; 4 | 5 | const BLOCKS_LIMIT = 1000; 6 | 7 | export default class Arena { 8 | settings: Settings; 9 | 10 | constructor(settings: Settings) { 11 | this.settings = settings; 12 | } 13 | 14 | async getChannelsFromUser(): Promise { 15 | const baseUrl = `https://api.are.na/v2/users/${this.settings.username}/channels`; 16 | const headers = { 17 | Authorization: `Bearer ${this.settings.accessToken}`, 18 | }; 19 | 20 | let allChannels: Channel[] = []; 21 | let currentPage = 1; 22 | let totalPages = 1; 23 | 24 | while (currentPage <= totalPages) { 25 | const url = `${baseUrl}?page=${currentPage}&v=${Date.now()}`; 26 | 27 | try { 28 | const response = await requestUrl({ 29 | url, 30 | headers, 31 | }); 32 | 33 | if (response.status !== 200) { 34 | throw new Error(`HTTP error! Status: ${response.status}`); 35 | } 36 | 37 | const data = response.json; 38 | 39 | allChannels = allChannels.concat(data.channels); 40 | totalPages = data.total_pages; 41 | currentPage++; 42 | } catch (error) { 43 | console.error("Error fetching channels:", error); 44 | break; 45 | } 46 | } 47 | 48 | return allChannels; 49 | } 50 | 51 | async updateBlockWithContentAndBlockID( 52 | id: number, 53 | title: string, 54 | content: string, 55 | frontmatter: Record = {}, 56 | ): Promise { 57 | try { 58 | const newTitle = 59 | typeof frontmatter.title === "string" 60 | ? frontmatter.title 61 | : title; 62 | const description = frontmatter.description; 63 | content = content.replace(/---[\s\S]*?---\n/g, ""); 64 | 65 | const url = `https://api.are.na/v2/blocks/${id}`; 66 | const response = await requestUrl({ 67 | url, 68 | method: "PUT", 69 | headers: { 70 | Authorization: `Bearer ${this.settings.accessToken}`, 71 | "Content-Type": "application/json", 72 | }, 73 | body: JSON.stringify({ 74 | title: newTitle, 75 | content, 76 | description, 77 | }), 78 | }); 79 | 80 | return response; 81 | } catch (error) { 82 | console.error("Failed to update block:", error); 83 | throw error; 84 | } 85 | } 86 | 87 | async getBlockWithID(id: number): Promise { 88 | const url = `https://api.are.na/v2/blocks/${id}?v=${Date.now()}`; 89 | return requestUrl({ 90 | url, 91 | headers: { 92 | Authorization: `Bearer ${this.settings.accessToken}`, 93 | }, 94 | }) 95 | .then((response) => response.json) 96 | .then((data) => { 97 | return data; 98 | }); 99 | } 100 | 101 | async getBlocksFromChannel(channelSlug: string): Promise { 102 | const url = `https://api.are.na/v2/channels/${channelSlug}/contents?per=${BLOCKS_LIMIT}&v=${Date.now()}`; 103 | return requestUrl({ 104 | url, 105 | headers: { 106 | Authorization: `Bearer ${this.settings.accessToken}`, 107 | }, 108 | }) 109 | .then((response) => response.json) 110 | .then((data) => { 111 | return data.contents.sort((a: Block, b: Block) => { 112 | return b.position - a.position; 113 | }); 114 | }); 115 | } 116 | 117 | async createBlockWithContentAndTitle( 118 | content: string, 119 | generated_title: string, 120 | channelSlug: string, 121 | frontmatter: Record = {}, 122 | ): Promise { 123 | const title = frontmatter.title || generated_title; 124 | const description = frontmatter.description; 125 | content = content.replace(/---[\s\S]*?---\n/g, ""); 126 | 127 | const url = `https://api.are.na/v2/channels/${channelSlug}/blocks`; 128 | return requestUrl({ 129 | url, 130 | method: "POST", 131 | headers: { 132 | Authorization: `Bearer ${this.settings.accessToken}`, 133 | "Content-Type": "application/json", 134 | }, 135 | body: JSON.stringify({ 136 | title, 137 | content, 138 | description, 139 | }), 140 | }).then((response) => response.json); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Are.na Manager 2 | 3 | Publish content from [Obsidian](https://obsidian.md) to [Are.na](https://www.are.na) and the other way around. 4 | 5 | ### Commands 6 | 7 | Currently this plugin offers 5 commands: 8 | 9 | | Command | Description | 10 | | :--------------------------- | :-------------------------------------------------------------------------------------------------- | 11 | | `Get blocks from channel` | Get all the blocks from a channel and create a new note in Obsidian with the content of each block. | 12 | | `Pull block` | Updates the current open note with the content of a block from Are.na. | 13 | | `Push note` | Pushes the content of the current open note to a block in Are.na. | 14 | | `Get block from Are.na` | Creates a new note with the content of a block from your Are.na account. | 15 | | `Get block by its ID or URL` | Creates a new note with the content of a block from Are.na by its ID or URL. | 16 | | `Go to block in Are.na` | Opens the block in the Are.na website. | 17 | 18 | ### Installation 19 | 20 | 1. [Install the plugin](https://obsidian.md/plugins?id=arena-manager) and enable it. 21 | 2. Create a new Are.na application at [https://dev.are.na/oauth/applications](https://dev.are.na/oauth/applications). 22 | You can use any valid URL in the `Redirect URI` field. 23 | 3. Submit the form and copy the `Personal Access Token`. 24 | 4. Open the plugin settings page and set the following options: 25 | - **Personal Access Token**: the `Personal Access Token` you copied earlier. 26 | - **Username**: Your Are.na slug (e.g., `username` in `https://www.are.na/username`). 27 | - **Folder**: The folder where you want to store the notes (the folder is called `arena` by default). 28 | 5. You are done! Use any of the commands above to interact with your Are.na blocks and channels. 29 | 30 | ### Attachments download 31 | 32 | The plugin doesn’t download attachments by default. If you want to download them, you can enable the `Download attachments` option in the settings. You can choose from the following download locations: 33 | 34 | - **Download inside the channel folder**: Attachments will be stored in the same folder as the note. For example: `arena/fantastic-channel/{folder name}`. If you leave the field empty, your attachments will be stored in the channel folder. 35 | - **Download to a custom folder**: Attachments will be stored in a custom folder. For example: `attachments/web/files-i-saved-in-arena` 36 | 37 | ### Frontmatter structure 38 | 39 | When you get a block from Are.na, the plugin will add some frontmatter automatically to allow syncronizing your note and the block. 40 | 41 | | Property | Description | 42 | | :----------- | :--------------------------------------------------------- | 43 | | blockid | the id of the block in Are.na | 44 | | class | the class of the block in Are.na (e.g. Link or Attachment) | 45 | | title | the title of the block in Are.na | 46 | | user | the user who created the block in Are.na | 47 | | channel | the channel where the block was pulled from | 48 | | source title | the title of the block's source | 49 | | source url | the url of the block's source | 50 | 51 | ### Roadmap 52 | 53 | - [x] Fetch all the user's channels. 54 | - [x] Attachment offline support. 55 | - [x] Getting blocks by their ID or URL. 56 | - [ ] Avoid overiding the frontmatter when pulling a block. 57 | - [ ] Template system (from [this issue](https://github.com/javierarce/arena-manager/issues/1)) 58 | - [ ] Getting blocks to folders outside of the Are.na directory designated in the settings. 59 | - [ ] Getting blocks from other users’ channels. 60 | - [ ] Downloading blocks by URL. 61 | - [ ] Creating new channels from the content of a note or directory 62 | 63 | ### Contributing 64 | 65 | If you have ideas, suggestions, or bug reports feel free to [open an issue](https://github.com/javierarce/arena-manager/issues). 66 | -------------------------------------------------------------------------------- /lib/Modals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Setting, 3 | Events, 4 | App, 5 | Modal, 6 | FuzzySuggestModal, 7 | FuzzyMatch, 8 | } from "obsidian"; 9 | import { Settings } from "./Settings"; 10 | import { Channel, Block } from "./types"; 11 | 12 | const INSTRUCTIONS = [ 13 | { command: "↑↓", purpose: "to navigate" }, 14 | { command: "Tab ↹", purpose: "to autocomplete" }, 15 | { command: "↵", purpose: "to choose item" }, 16 | { command: "esc", purpose: "to dismiss" }, 17 | ]; 18 | 19 | export class ChannelsModal extends FuzzySuggestModal { 20 | channels: Channel[]; 21 | settings: Settings; 22 | showEmptyChannels: boolean; 23 | events: Events; 24 | callback: (channel: Channel) => void; 25 | 26 | onOpen(): void { 27 | const $prompt = this.containerEl.querySelector(".prompt-results"); 28 | if ($prompt) { 29 | $prompt.addClass("is-loading"); 30 | } 31 | this.events.on("channels-load", (channels: Channel[]) => { 32 | this.channels = channels; 33 | if ($prompt) { 34 | $prompt.removeClass("is-loading"); 35 | } 36 | this.setInstructions(INSTRUCTIONS); 37 | super.onOpen(); 38 | }); 39 | } 40 | 41 | constructor( 42 | app: App, 43 | settings: Settings, 44 | showEmptyChannels: boolean, 45 | events: Events, 46 | callback: (channel: Channel) => void, 47 | ) { 48 | super(app); 49 | this.settings = settings; 50 | this.events = events; 51 | this.showEmptyChannels = showEmptyChannels; 52 | this.callback = callback; 53 | 54 | this.emptyStateText = "No channels found. Press esc to dismiss."; 55 | this.setPlaceholder(`Search @${this.settings.username}'s channels`); 56 | 57 | this.containerEl.addClass("arena-manager-modal"); 58 | } 59 | 60 | renderSuggestion(match: FuzzyMatch, el: HTMLElement): void { 61 | if (match.item.status === "private") { 62 | el.createEl("span").addClass("icon-lock"); 63 | } 64 | el.createEl("span", { text: match.item.title }); 65 | el.createEl("span", { text: match.item.length.toString() }).addClass( 66 | "count", 67 | ); 68 | } 69 | 70 | getItems(): Channel[] { 71 | if (this.showEmptyChannels) { 72 | return this.channels; 73 | } else if (this.channels) { 74 | return this.channels.filter((channel) => channel.length > 0); 75 | } else { 76 | return []; 77 | } 78 | } 79 | 80 | getItemText(channel: Channel): string { 81 | return `${channel.title} (${channel.length})`; 82 | } 83 | 84 | onChooseItem(channel: Channel, _evt: MouseEvent | KeyboardEvent): void { 85 | this.callback(channel); 86 | } 87 | } 88 | 89 | export class BlocksModal extends FuzzySuggestModal { 90 | blocks: Block[]; 91 | channel: Channel; 92 | events: Events; 93 | callback: (block: Block, channel: Channel) => void; 94 | 95 | onOpen(): void { 96 | const $prompt = this.containerEl.querySelector(".prompt-results"); 97 | if ($prompt) { 98 | $prompt.addClass("is-loading"); 99 | } 100 | this.events.on("blocks-load", (blocks: Block[]) => { 101 | this.blocks = blocks; 102 | if ($prompt) { 103 | $prompt.removeClass("is-loading"); 104 | } 105 | this.setInstructions(INSTRUCTIONS); 106 | super.onOpen(); 107 | }); 108 | } 109 | 110 | constructor( 111 | app: App, 112 | channel: Channel, 113 | events: Events, 114 | callback: (block: Block, channel: Channel) => void, 115 | ) { 116 | super(app); 117 | this.blocks = []; 118 | this.channel = channel; 119 | this.events = events; 120 | this.callback = callback; 121 | 122 | this.emptyStateText = "No blocks found. Press esc to dismiss."; 123 | this.setPlaceholder(`Search blocks from ${channel.title}`); 124 | this.emptyStateText = "Loading..."; 125 | } 126 | 127 | getItems(): Block[] { 128 | if (this.blocks) { 129 | return this.blocks.filter( 130 | (block) => block.class !== "Channel" && block.class !== "Media", 131 | ); 132 | } 133 | return this.blocks; 134 | } 135 | 136 | getItemText(item: Block): string { 137 | return item.generated_title; 138 | } 139 | 140 | onChooseItem(block: Block, _evt: MouseEvent | KeyboardEvent): void { 141 | this.callback(block, this.channel); 142 | } 143 | } 144 | 145 | export class URLModal extends Modal { 146 | url: string; 147 | callback: (url: string) => void; 148 | onSubmit: (result: string) => void; 149 | 150 | constructor(app: App, callback: (url: string) => void) { 151 | super(app); 152 | this.callback = callback; 153 | } 154 | 155 | onOpen() { 156 | const { contentEl } = this; 157 | this.titleEl.setText("Enter Are.na block id or URL"); 158 | 159 | contentEl.createEl("form", {}, (form) => { 160 | form.style.display = "flex"; 161 | form.style.gap = "12px"; 162 | 163 | const input = form.createEl("input", { 164 | placeholder: "https://are.na/block/5", 165 | type: "text", 166 | }) as HTMLInputElement; 167 | 168 | input.style.width = "100%"; 169 | 170 | input.onchange = (_event) => { 171 | this.url = input.value; 172 | }; 173 | 174 | form.onsubmit = (e) => { 175 | e.preventDefault(); 176 | this.callback(this.url); 177 | this.close(); 178 | }; 179 | 180 | form.createEl("button", { text: "Submit" }); 181 | }); 182 | } 183 | 184 | onClose() { 185 | let { contentEl } = this; 186 | contentEl.empty(); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /lib/Settings.ts: -------------------------------------------------------------------------------- 1 | import { Setting, PluginSettingTab } from "obsidian"; 2 | import { ARENA_APP_URL } from "./types"; 3 | import ArenaManagerPlugin from "main"; 4 | 5 | export interface Settings { 6 | accessToken: string; 7 | username: string; 8 | folder: string; 9 | download_attachments_type: string; 10 | attachments_folder?: string; 11 | } 12 | 13 | export const DOWNLOAD_ATTACHMENTS_TYPES = { 14 | none: "1", 15 | channel: "2", 16 | custom: "3", 17 | }; 18 | 19 | export const CUSTOM_ATTACHMENTS_FOLDER = "arena/attachments"; 20 | 21 | export const DEFAULT_SETTINGS: Settings = { 22 | accessToken: "", 23 | username: "", 24 | folder: "arena", 25 | attachments_folder: CUSTOM_ATTACHMENTS_FOLDER, 26 | download_attachments_type: DOWNLOAD_ATTACHMENTS_TYPES.none, 27 | }; 28 | 29 | export class TemplaterSettingTab extends PluginSettingTab { 30 | constructor(private plugin: ArenaManagerPlugin) { 31 | super(app, plugin); 32 | this.plugin = plugin; 33 | } 34 | 35 | display(): void { 36 | this.containerEl.empty(); 37 | 38 | this.addFolder(); 39 | this.addUsername(); 40 | this.addAccessToken(); 41 | this.addSelect(); 42 | 43 | if ( 44 | this.plugin.settings.download_attachments_type !== 45 | DOWNLOAD_ATTACHMENTS_TYPES.none 46 | ) { 47 | this.addAttachmentsFolder(); 48 | } 49 | } 50 | 51 | addFolder() { 52 | new Setting(this.containerEl) 53 | .setName("Folder") 54 | .setDesc("The folder where the blocks will be saved.") 55 | .addText((text) => 56 | text 57 | .setPlaceholder("Enter a name") 58 | .setValue(this.plugin.settings.folder) 59 | .onChange(async (value) => { 60 | this.plugin.settings.folder = value; 61 | await this.plugin.saveSettings(); 62 | }), 63 | ); 64 | } 65 | 66 | addUsername() { 67 | new Setting(this.containerEl) 68 | .setName("Username") 69 | .setDesc("Your are.na slug (e.g. 'username' in are.na/username).") 70 | .addText((text) => 71 | text 72 | .setPlaceholder("Enter your username") 73 | .setValue(this.plugin.settings.username) 74 | .onChange(async (value) => { 75 | this.plugin.settings.username = value; 76 | await this.plugin.saveSettings(); 77 | }), 78 | ); 79 | } 80 | 81 | addAccessToken() { 82 | new Setting(this.containerEl) 83 | .setName("Access token") 84 | .setDesc( 85 | createFragment((fragment) => { 86 | fragment.append( 87 | "Ceate an app and get the 'Personal Access Token' from ", 88 | fragment.createEl("a", { 89 | text: ARENA_APP_URL, 90 | href: ARENA_APP_URL, 91 | }), 92 | ); 93 | }), 94 | ) 95 | .addText((text) => 96 | text 97 | .setPlaceholder("Enter your token") 98 | .setValue(this.plugin.settings.accessToken) 99 | .onChange(async (value) => { 100 | this.plugin.settings.accessToken = value; 101 | await this.plugin.saveSettings(); 102 | }), 103 | ); 104 | } 105 | 106 | addSelect() { 107 | new Setting(this.containerEl) 108 | .setName("Download attachments") 109 | .setDesc("Choose where to download the attachments.") 110 | .addDropdown((dropdown) => { 111 | dropdown.addOption( 112 | DOWNLOAD_ATTACHMENTS_TYPES.none, 113 | "Don't download attachments", 114 | ); 115 | dropdown.addOption( 116 | DOWNLOAD_ATTACHMENTS_TYPES.channel, 117 | "Download inside the channel folder", 118 | ); 119 | dropdown.addOption( 120 | DOWNLOAD_ATTACHMENTS_TYPES.custom, 121 | "Download to a custom folder", 122 | ); 123 | dropdown.setValue( 124 | this.plugin.settings.download_attachments_type, 125 | ); 126 | dropdown.onChange(async (value) => { 127 | this.plugin.settings.download_attachments_type = value; 128 | if (value === DOWNLOAD_ATTACHMENTS_TYPES.none) { 129 | this.plugin.settings.attachments_folder = undefined; 130 | } else if (value === DOWNLOAD_ATTACHMENTS_TYPES.channel) { 131 | this.plugin.settings.attachments_folder = undefined; 132 | } else if (value === DOWNLOAD_ATTACHMENTS_TYPES.custom) { 133 | this.plugin.settings.attachments_folder = 134 | CUSTOM_ATTACHMENTS_FOLDER; 135 | } 136 | 137 | await this.plugin.saveSettings(); 138 | this.display(); 139 | }); 140 | }); 141 | } 142 | 143 | addAttachmentsFolder() { 144 | let name = "Channel attachments folder"; 145 | 146 | let tooltip = createFragment((fragment) => { 147 | fragment.append( 148 | "Save inside the channel folder:", 149 | fragment.createEl("code", { 150 | text: `${this.plugin.settings.folder}/channel/{folder name}.`, 151 | }), 152 | ); 153 | fragment.append( 154 | "Leave empty to save directly in the channel folder", 155 | ); 156 | }); 157 | 158 | if ( 159 | this.plugin.settings.download_attachments_type === 160 | DOWNLOAD_ATTACHMENTS_TYPES.custom 161 | ) { 162 | name = "Custom attachments folder"; 163 | tooltip = tooltip = createFragment((fragment) => { 164 | fragment.append( 165 | "Save inside a custom folder:", 166 | fragment.createEl("code", { 167 | text: `${this.plugin.settings.folder}/attachments/{folder name}.`, 168 | }), 169 | ); 170 | }); 171 | } 172 | 173 | new Setting(this.containerEl) 174 | .setName(name) 175 | .setDesc(tooltip) 176 | .addText((text) => 177 | text 178 | .setPlaceholder("Folder name") 179 | .setValue(this.plugin.settings.attachments_folder || "") 180 | .onChange(async (value) => { 181 | this.plugin.settings.attachments_folder = value; 182 | await this.plugin.saveSettings(); 183 | }), 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /lib/Commands.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Events } from "obsidian"; 2 | 3 | import Arena from "./Arena"; 4 | import FileHandler from "./FileHandler"; 5 | import Utils from "./Utils"; 6 | import { ARENA_BLOCK_URL, Channel, Block } from "./types"; 7 | import { ChannelsModal, BlocksModal, URLModal } from "./Modals"; 8 | import { Settings } from "./Settings"; 9 | 10 | export default class Commands { 11 | app: App; 12 | settings: Settings; 13 | events: Events; 14 | arena: Arena; 15 | fileHandler: FileHandler; 16 | 17 | constructor(app: App, settings: Settings) { 18 | this.app = app; 19 | this.settings = settings; 20 | this.arena = new Arena(settings); 21 | this.events = new Events(); 22 | this.fileHandler = new FileHandler(app, settings); 23 | } 24 | 25 | async pushBlock() { 26 | const currentFile = this.app.workspace.getActiveFile(); 27 | 28 | if (!currentFile) { 29 | new Notice("No active file open"); // TODO: Improve error message 30 | return; 31 | } 32 | 33 | const currentFileContent = await this.app.vault.read(currentFile); 34 | 35 | this.app.fileManager.processFrontMatter( 36 | currentFile, 37 | async (frontmatter) => { 38 | const blockId = frontmatter.blockid; 39 | if (blockId) { 40 | const title = currentFile.basename; 41 | 42 | if (frontmatter.user !== this.settings.username) { 43 | return new Notice( 44 | `You don't have permission to update ${frontmatter.user}'s block`, 45 | ); 46 | } 47 | 48 | await this.arena 49 | .updateBlockWithContentAndBlockID( 50 | blockId, 51 | title, 52 | currentFileContent, 53 | frontmatter, 54 | ) 55 | .then((response: any) => { 56 | if (response.status === 422) { 57 | new Notice(`Block not updated`); 58 | return; 59 | } 60 | new Notice("Block updated"); 61 | }) 62 | .catch((error) => { 63 | console.error(error); 64 | new Notice("Block not updated"); 65 | }); 66 | } else { 67 | const onSelectChannel = async (channel: Channel) => { 68 | await this.arena 69 | .createBlockWithContentAndTitle( 70 | currentFileContent, 71 | currentFile.basename, 72 | channel.slug, 73 | frontmatter, 74 | ) 75 | .then((response: any) => { 76 | if (response.code === 422) { 77 | new Notice(`Error: ${response.message}`); 78 | return; 79 | } 80 | 81 | this.app.fileManager.processFrontMatter( 82 | currentFile, 83 | async (frontmatter) => { 84 | frontmatter["blockid"] = response.id; 85 | frontmatter["channel"] = 86 | Utils.createPermalinkFromTitle( 87 | channel.title, 88 | ); 89 | }, 90 | ); 91 | 92 | new Notice("Block updated"); 93 | }) 94 | .catch((error) => { 95 | console.error(error); 96 | new Notice("Block not updated"); 97 | }); 98 | }; 99 | 100 | new ChannelsModal( 101 | this.app, 102 | this.settings, 103 | true, 104 | this.events, 105 | onSelectChannel, 106 | ).open(); 107 | 108 | this.arena.getChannelsFromUser().then((channels) => { 109 | this.events.trigger("channels-load", channels); 110 | }); 111 | } 112 | }, 113 | ); 114 | } 115 | 116 | async getBlocksFromChannel() { 117 | const callback = async (channel: Channel) => { 118 | let notesCreated = 0; 119 | new Notice(`Getting blocks from ${channel.title}…`); 120 | 121 | this.arena 122 | .getBlocksFromChannel(channel.slug) 123 | .then(async (blocks) => { 124 | for (const block of blocks) { 125 | if ( 126 | block.class === "Channel" || 127 | block.class === "Media" 128 | ) { 129 | continue; 130 | } 131 | const fileName = block.generated_title 132 | ? block.generated_title 133 | : block.title; 134 | 135 | const frontData = Utils.getFrontmatterFromBlock( 136 | block, 137 | channel.title, 138 | ); 139 | 140 | const slug = Utils.createPermalinkFromTitle( 141 | channel.title, 142 | ); 143 | 144 | let content = block.content; 145 | 146 | if (block.class === "Image" || block.class === "Link") { 147 | const imageUrl = block.image?.display.url; 148 | content = `![](${imageUrl})`; 149 | } 150 | 151 | try { 152 | await this.fileHandler.writeFile( 153 | `${this.settings.folder}/${slug}`, 154 | fileName, 155 | content, 156 | frontData, 157 | block.class === "Attachment" 158 | ? block.attachment 159 | : undefined, 160 | ); 161 | notesCreated++; 162 | } catch (error) { 163 | console.error(error); 164 | new Notice("Error creating file"); 165 | } 166 | } 167 | 168 | new Notice( 169 | `${notesCreated} note${notesCreated > 1 ? "s" : ""} created`, 170 | ); 171 | }); 172 | }; 173 | 174 | const modal = new ChannelsModal( 175 | this.app, 176 | this.settings, 177 | false, 178 | this.events, 179 | callback, 180 | ); 181 | 182 | modal.open(); 183 | 184 | this.arena.getChannelsFromUser().then((channels) => { 185 | this.events.trigger("channels-load", channels); 186 | }); 187 | } 188 | 189 | async pullBlock() { 190 | const currentFile = this.app.workspace.getActiveFile(); 191 | 192 | if (!currentFile) { 193 | new Notice("No active file open"); // TODO: Improve error message 194 | return; 195 | } 196 | 197 | const frontMatter = 198 | await this.fileHandler.getFrontmatterFromFile(currentFile); 199 | 200 | const blockId = frontMatter?.blockid as number; 201 | 202 | if (blockId) { 203 | this.arena.getBlockWithID(blockId).then(async (block) => { 204 | const title = block.generated_title; 205 | let content = block.content; 206 | const channelTitle = frontMatter?.channel as string; 207 | 208 | const frontData = Utils.getFrontmatterFromBlock( 209 | block, 210 | channelTitle, 211 | ); 212 | 213 | if (block.class === "Image" || block.class === "Link") { 214 | const imageUrl = block.image?.display.url; 215 | content = `![](${imageUrl})`; 216 | } 217 | 218 | this.fileHandler.renameFile( 219 | currentFile, 220 | title, 221 | content, 222 | frontData, 223 | block.class === "Attachment" ? block.attachment : undefined, 224 | ); 225 | }); 226 | } else { 227 | new Notice("No block id found in frontmatter"); 228 | } 229 | } 230 | 231 | async goToBlock() { 232 | const currentFile = this.app.workspace.getActiveFile(); 233 | 234 | if (!currentFile) { 235 | new Notice("No active file open"); // TODO: Improve error message 236 | return; 237 | } 238 | 239 | this.app.fileManager.processFrontMatter(currentFile, (frontmatter) => { 240 | const blockId = frontmatter.blockid; 241 | if (blockId) { 242 | const url = `${ARENA_BLOCK_URL}${blockId}`; 243 | window.open(url, "_blank"); 244 | } else { 245 | new Notice("No block id found in frontmatter"); 246 | } 247 | }); 248 | } 249 | 250 | async getBlockFromArena() { 251 | const onSelectChannel = async (channel: Channel) => { 252 | const onSelectBlock = async (block: Block, channel: Channel) => { 253 | const fileName = `${block.generated_title}`; 254 | const frontData = Utils.getFrontmatterFromBlock( 255 | block, 256 | channel.title, 257 | ); 258 | const slug = Utils.createPermalinkFromTitle(channel.title); 259 | 260 | let content = block.content; 261 | 262 | if (block.class === "Image" || block.class === "Link") { 263 | const imageUrl = block.image?.display.url; 264 | content = `![](${imageUrl})`; 265 | } 266 | 267 | await this.fileHandler.writeFile( 268 | `${this.settings.folder}/${slug}`, 269 | fileName, 270 | content, 271 | frontData, 272 | block.class === "Attachment" ? block.attachment : undefined, 273 | ); 274 | 275 | new Notice(`Note created`); 276 | await this.app.workspace.openLinkText(fileName, "", true); 277 | }; 278 | 279 | new BlocksModal( 280 | this.app, 281 | channel, 282 | this.events, 283 | onSelectBlock, 284 | ).open(); 285 | 286 | this.arena.getBlocksFromChannel(channel.slug).then((channels) => { 287 | this.events.trigger("blocks-load", channels); 288 | }); 289 | }; 290 | 291 | new ChannelsModal( 292 | this.app, 293 | this.settings, 294 | false, 295 | this.events, 296 | onSelectChannel, 297 | ).open(); 298 | 299 | this.arena.getChannelsFromUser().then((channels) => { 300 | this.events.trigger("channels-load", channels); 301 | }); 302 | } 303 | 304 | async getBlockByID() { 305 | new URLModal(this.app, (url: string) => { 306 | function getIDFromURL(url: string) { 307 | const blockMatch = url.match(/(?:block\/)(\d+)/); 308 | 309 | if (blockMatch) { 310 | return blockMatch[1]; 311 | } 312 | 313 | if (/^\d+$/.test(url)) { 314 | return url; 315 | } 316 | 317 | return -1; 318 | } 319 | 320 | const blockId = getIDFromURL(url) as number; 321 | 322 | if (blockId > 0) { 323 | this.arena 324 | .getBlockWithID(blockId) 325 | .then(async (block) => { 326 | const fileName = `${block.generated_title}`; 327 | const frontData = Utils.getFrontmatterFromBlock(block); 328 | 329 | let content = block.content; 330 | 331 | if (block.class === "Image" || block.class === "Link") { 332 | const imageUrl = block.image?.display.url; 333 | content = `![](${imageUrl})`; 334 | } 335 | 336 | await this.fileHandler.writeFile( 337 | `${this.settings.folder}`, 338 | fileName, 339 | content, 340 | frontData, 341 | block.class === "Attachment" 342 | ? block.attachment 343 | : undefined, 344 | ); 345 | 346 | new Notice(`Note created`); 347 | await this.app.workspace.openLinkText( 348 | fileName, 349 | "", 350 | true, 351 | ); 352 | }) 353 | .catch((error) => { 354 | console.error(error); 355 | new Notice("Error getting block"); 356 | }); 357 | } 358 | }).open(); 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /lib/FileHandler.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile, TAbstractFile, TFolder, requestUrl } from "obsidian"; 2 | 3 | import { Attachment } from "./types"; 4 | import { Settings, DOWNLOAD_ATTACHMENTS_TYPES } from "./Settings"; 5 | 6 | export default class Filemanager { 7 | app: App; 8 | settings: Settings; 9 | 10 | constructor(app: App, settings: Settings) { 11 | this.app = app; 12 | this.settings = settings; 13 | } 14 | 15 | getSafeFilename(fileName: string) { 16 | return fileName.replace(/[\\/:]/g, " "); 17 | } 18 | 19 | async doesAttachmentExist(fileName: string): Promise { 20 | const filePath = `${this.settings.attachments_folder}/${fileName}`; 21 | return this.doesFileExist(filePath); 22 | } 23 | 24 | async doesFileExist(filePath: string): Promise { 25 | const file = this.app.vault.getAbstractFileByPath(filePath); 26 | return file !== null; 27 | } 28 | 29 | async renameFile( 30 | filePath: TFile, 31 | title: string, 32 | content: string, 33 | frontData: Record = {}, 34 | attachment?: Attachment, 35 | ) { 36 | const fileName = this.getSafeFilename(title); 37 | let folderPath = this.settings.folder; 38 | 39 | if (frontData.channel) { 40 | folderPath = `${folderPath}/${frontData.channel}`; 41 | } 42 | 43 | if ( 44 | this.settings.download_attachments_type !== 45 | DOWNLOAD_ATTACHMENTS_TYPES.none && 46 | attachment 47 | ) { 48 | const attachmentFileName = await this.downloadAttachment( 49 | attachment, 50 | folderPath, 51 | fileName, 52 | ); 53 | content = `![[${attachmentFileName}]]`; 54 | } 55 | 56 | const newName = `${folderPath}/${fileName}.md`; 57 | await this.app.vault.modify(filePath, content); 58 | await this.writeFrontmatter(filePath, frontData); 59 | await this.app.vault.rename(filePath, newName); 60 | } 61 | 62 | getFileByFolderPathAndFileName( 63 | folderPath: string, 64 | fileName: string, 65 | ): TFile | null { 66 | const normalizedFolderPath = folderPath.replace(/\\/g, "/"); 67 | const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`; 68 | return this.app.vault.getFileByPath(filePath); 69 | } 70 | 71 | async createFolder(folderPath: string) { 72 | const normalizedFolderPath = folderPath.replace(/\\/g, "/"); 73 | 74 | const folder = 75 | this.app.vault.getAbstractFileByPath(normalizedFolderPath); 76 | 77 | if (!folder) { 78 | await this.app.vault.createFolder(normalizedFolderPath); 79 | } 80 | } 81 | 82 | async updateFileWithFrontmatter( 83 | folderPath: string, 84 | fileName: string, 85 | content: string, 86 | frontData: Record = {}, 87 | ) { 88 | const normalizedFolderPath = folderPath.replace(/\\/g, "/"); 89 | const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`; 90 | 91 | const file = this.app.vault.getFileByPath(filePath); 92 | if (file) { 93 | await this.app.vault.modify(file, content); 94 | await this.writeFrontmatter(file, frontData); 95 | } 96 | } 97 | 98 | async createFileWithFrontmatter( 99 | folderPath: string, 100 | fileName: string, 101 | content: string, 102 | frontData: Record = {}, 103 | ) { 104 | const normalizedFolderPath = folderPath.replace(/\\/g, "/"); 105 | const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`; 106 | const file = await this.app.vault.create(filePath, content); 107 | await this.writeFrontmatter(file, frontData); 108 | } 109 | 110 | getAttachmentFilenameFromTitle(attachment: Attachment, title?: string) { 111 | let name = title || attachment.file_name; 112 | if (name.endsWith(`.${attachment.extension}`)) { 113 | name = name.slice(0, -attachment.extension.length - 1); 114 | } 115 | return this.getSafeFilename(`${name}.${attachment.extension}`); 116 | } 117 | 118 | async downloadAttachment( 119 | attachment: Attachment, 120 | folderPath: string, 121 | filename: string, 122 | ) { 123 | const request = await requestUrl(attachment.url); 124 | 125 | if ( 126 | this.settings.download_attachments_type != 127 | DOWNLOAD_ATTACHMENTS_TYPES.none && 128 | this.settings.attachments_folder 129 | ) { 130 | if ( 131 | this.settings.download_attachments_type == 132 | DOWNLOAD_ATTACHMENTS_TYPES.channel 133 | ) { 134 | await this.createFolder( 135 | `${folderPath}/${this.settings.attachments_folder}`, 136 | ); 137 | } else if ( 138 | this.settings.download_attachments_type == 139 | DOWNLOAD_ATTACHMENTS_TYPES.custom 140 | ) { 141 | await this.createFolder(this.settings.attachments_folder); 142 | } 143 | } 144 | 145 | try { 146 | const attachmentFileName = this.getAttachmentFilenameFromTitle( 147 | attachment, 148 | filename, 149 | ); 150 | 151 | let attachmentFolderPath = this.settings.attachments_folder; 152 | 153 | if ( 154 | this.settings.download_attachments_type == 155 | DOWNLOAD_ATTACHMENTS_TYPES.channel 156 | ) { 157 | if (this.settings.attachments_folder) { 158 | attachmentFolderPath = `${folderPath}/${this.settings.attachments_folder}`; 159 | } else { 160 | attachmentFolderPath = `${folderPath}`; 161 | } 162 | } 163 | 164 | this.app.vault.adapter.writeBinary( 165 | `${attachmentFolderPath}/${attachmentFileName}`, 166 | request.arrayBuffer, 167 | ); 168 | 169 | if ( 170 | this.settings.download_attachments_type == 171 | DOWNLOAD_ATTACHMENTS_TYPES.channel 172 | ) { 173 | if (this.settings.attachments_folder) { 174 | return `${folderPath}/${this.settings.attachments_folder}/${attachmentFileName}`; 175 | } else { 176 | return `${folderPath}/${attachmentFileName}`; 177 | } 178 | } else { 179 | return `${attachmentFolderPath}/${attachmentFileName}`; 180 | } 181 | } catch (error) { 182 | console.error("Error downloading attachment", error); 183 | return null; 184 | } 185 | } 186 | 187 | async findNextAvailableFileName( 188 | folderPath: string, 189 | baseFileName: string, 190 | blockId: any, 191 | ): Promise { 192 | let counter = 0; 193 | let filePath = `${folderPath}/${baseFileName}.md`; 194 | 195 | while (await this.doesFileExist(filePath)) { 196 | const file = this.app.vault.getAbstractFileByPath( 197 | filePath, 198 | ) as TFile; 199 | 200 | const frontmatter = await this.getFrontmatterFromFile(file); 201 | 202 | if (frontmatter.blockid === blockId) { 203 | // If we find a file with the same blockId, we'll use this file 204 | if (counter === 0) { 205 | return baseFileName; 206 | } else { 207 | return `${baseFileName}-${counter}`; 208 | } 209 | } 210 | 211 | // If blockId is different, increment counter and try next filename 212 | counter++; 213 | filePath = `${folderPath}/${baseFileName}-${counter}.md`; 214 | } 215 | 216 | return `${baseFileName}-${counter}`; 217 | } 218 | 219 | async updateFile( 220 | file: TFile, 221 | folderPath: string, 222 | fileName: string, 223 | content: string, 224 | frontData: Record = {}, 225 | attachment?: Attachment, 226 | ) { 227 | const blockId = await this.getBlockIdFromFile(file); 228 | 229 | if (blockId === frontData?.blockid) { 230 | if ( 231 | this.settings.download_attachments_type !== 232 | DOWNLOAD_ATTACHMENTS_TYPES.none && 233 | attachment 234 | ) { 235 | const attachmentFileName = await this.downloadAttachment( 236 | attachment, 237 | folderPath, 238 | fileName, 239 | ); 240 | content = `![[${attachmentFileName}]]`; 241 | } 242 | 243 | // If the blockid is the same, update the file 244 | await this.updateFileWithFrontmatter( 245 | folderPath, 246 | fileName, 247 | content, 248 | frontData, 249 | ); 250 | } else { 251 | // If the blockid is different, create a new file 252 | const baseFileName = `${fileName.split(".")[0]}`; 253 | const newFilename = await this.findNextAvailableFileName( 254 | folderPath, 255 | baseFileName, 256 | frontData.blockid, 257 | ); 258 | 259 | if ( 260 | this.settings.download_attachments_type !== 261 | DOWNLOAD_ATTACHMENTS_TYPES.none && 262 | attachment 263 | ) { 264 | const attachmentFileName = await this.downloadAttachment( 265 | attachment, 266 | folderPath, 267 | newFilename, 268 | ); 269 | content = `![[${attachmentFileName}]]`; 270 | } 271 | 272 | const newFile = this.getFileByFolderPathAndFileName( 273 | folderPath, 274 | newFilename, 275 | ); 276 | 277 | if (!newFile) { 278 | await this.createFileWithFrontmatter( 279 | folderPath, 280 | newFilename, 281 | content, 282 | frontData, 283 | ); 284 | } else { 285 | await this.updateFileWithFrontmatter( 286 | folderPath, 287 | newFilename, 288 | content, 289 | frontData, 290 | ); 291 | } 292 | } 293 | } 294 | 295 | async writeFile( 296 | folderPath: string, 297 | fileName: string, 298 | content: string, 299 | frontData: Record = {}, 300 | attachment?: Attachment, 301 | ) { 302 | await this.createFolder(folderPath); 303 | const file = this.getFileByFolderPathAndFileName(folderPath, fileName); 304 | 305 | if (!content) { 306 | content = ""; 307 | console.warn("Empty content"); 308 | } 309 | 310 | if (!file) { 311 | if ( 312 | this.settings.download_attachments_type !== 313 | DOWNLOAD_ATTACHMENTS_TYPES.none && 314 | attachment 315 | ) { 316 | const attachmentFileName = await this.downloadAttachment( 317 | attachment, 318 | folderPath, 319 | fileName, 320 | ); 321 | content = `![[${attachmentFileName}]]`; 322 | } 323 | 324 | await this.createFileWithFrontmatter( 325 | folderPath, 326 | fileName, 327 | content, 328 | frontData, 329 | ); 330 | } else { 331 | await this.updateFile( 332 | file, 333 | folderPath, 334 | fileName, 335 | content, 336 | frontData, 337 | attachment, 338 | ); 339 | } 340 | } 341 | 342 | async getFrontmatterFromFile( 343 | file: TFile, 344 | ): Promise> { 345 | let frontmatterData: Record = {}; 346 | 347 | await this.app.fileManager.processFrontMatter( 348 | file, 349 | (frontmatter: Record) => { 350 | frontmatterData = frontmatter || null; 351 | }, 352 | ); 353 | 354 | return frontmatterData; 355 | } 356 | 357 | async getBlockIdFromFile(file: TFile): Promise { 358 | let blockId: number | null = null; 359 | 360 | await this.app.fileManager.processFrontMatter( 361 | file, 362 | (frontmatter: Record) => { 363 | blockId = frontmatter.blockid || null; 364 | }, 365 | ); 366 | 367 | return blockId; 368 | } 369 | 370 | async writeFrontmatter( 371 | file: TFile, 372 | frontData: Record, 373 | ) { 374 | if (!frontData) { 375 | return; 376 | } 377 | await this.app.fileManager.processFrontMatter( 378 | file, 379 | async (frontmatter: Record) => { 380 | Object.entries(frontData).forEach(([key, value]) => { 381 | frontmatter[key] = value; 382 | }); 383 | }, 384 | ); 385 | } 386 | isMarkdownFile(file: TAbstractFile): file is TFile { 387 | return file instanceof TFile && file.extension === "md"; 388 | } 389 | 390 | async getFilesWithBlockId( 391 | folderPath: string, 392 | ): Promise<{ name: string; blockid: number }[]> { 393 | const result: { name: string; blockid: number }[] = []; 394 | 395 | const folder = this.app.vault.getAbstractFileByPath(folderPath); 396 | if (!folder || !(folder instanceof TFolder)) { 397 | throw new Error("Directory not found or not a folder"); 398 | } 399 | 400 | const files = folder.children.filter(this.isMarkdownFile); 401 | 402 | for (const file of files) { 403 | await this.app.fileManager.processFrontMatter( 404 | file, 405 | (frontmatter: Record) => { 406 | if (frontmatter.blockid) { 407 | const blockid = Number(frontmatter.blockid); 408 | if (!isNaN(blockid)) { 409 | result.push({ 410 | name: file.name, 411 | blockid: blockid, 412 | }); 413 | } else { 414 | console.warn( 415 | `Invalid blockid for file ${file.name}: ${frontmatter.blockid}`, 416 | ); 417 | } 418 | } 419 | }, 420 | ); 421 | } 422 | 423 | return result; 424 | } 425 | } 426 | --------------------------------------------------------------------------------