├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── _config.yml ├── assets │ └── images │ │ ├── 01GX5KHAK22NWQ6CPWEBR1GG11.png │ │ ├── 01GX5KHAK2BSM1CQKT19D6B2AX.png │ │ ├── 01GX5KHAK2G1CQQKKY37RA4KMY.png │ │ ├── 01GX5KHAK2S10XJRZE6CMBSGJB.png │ │ ├── wp-app-pwd-1.png │ │ ├── wp-app-pwd-2.png │ │ ├── wp-miniOrange-1.png │ │ ├── wp-miniOrange-2.png │ │ └── wp-miniOrange-3.png └── index.md ├── esbuild.config.mjs ├── main.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── abstract-modal.ts ├── abstract-wp-client.ts ├── app-state.ts ├── confirm-modal.ts ├── consts.ts ├── i18n.ts ├── i18n │ ├── en.json │ ├── langs.ts │ └── zh-cn.json ├── icons.ts ├── main.ts ├── markdown-it-comment-plugin.ts ├── markdown-it-image-plugin.ts ├── markdown-it-mathjax3-plugin.ts ├── oauth2-client.ts ├── pass-crypto.ts ├── plugin-settings.ts ├── post-published-modal.ts ├── rest-client.ts ├── settings.ts ├── types.ts ├── utils.ts ├── wp-api.ts ├── wp-client.ts ├── wp-clients.ts ├── wp-login-modal.ts ├── wp-profile-chooser-modal.ts ├── wp-profile-manage-modal.ts ├── wp-profile-modal.ts ├── wp-profile.ts ├── wp-publish-modal.ts ├── wp-rest-client.ts ├── wp-xml-rpc-client.ts └── xmlrpc-client.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.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 = space 9 | indent_size = 2 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint", "node" 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 | "node/no-unsupported-features/es-builtins": ["error", { "version": ">=16.0.0", "ignores": [] }], 23 | // "node/no-unsupported-features/es-syntax": ["error", { "version": ">=16.0.0", "ignores": [] }], 24 | "node/no-unsupported-features/node-builtins": ["error", { "version": ">=16.0.0", "ignores": [] }] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-wordpress 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | - name: Upload styles.css 79 | id: upload-css 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./styles.css 86 | asset_name: styles.css 87 | asset_content_type: text/css 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # IDE 107 | .idea/ 108 | 109 | # Compiled files 110 | dist/ 111 | 112 | # They should be uploaded to GitHub releases instead. 113 | main.js 114 | 115 | # obsidian 116 | data.json 117 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | tag-version-prefix="" 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.5.1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [0.18.0](https://github.com/devbean/obsidian-wordpress/compare/0.17.0...0.18.0) (2023-12-20) 6 | 7 | 8 | ### Features 9 | 10 | * Now you can publish private or scheduled posts. ([7a19a5f](https://github.com/devbean/obsidian-wordpress/commit/7a19a5f9abe5bdc5da8a90dfb7128cd8e448f19a)) 11 | 12 | ## [0.17.0](https://github.com/devbean/obsidian-wordpress/compare/0.16.0...0.17.0) (2023-12-18) 13 | 14 | 15 | ### Features 16 | 17 | * Obsidian comments could be ignored or convert to HTML comments. ([296e1e0](https://github.com/devbean/obsidian-wordpress/commit/296e1e0cf422e101ab87d4bf8312187468832552)) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * Image src is not correct if replace media links is disabled. ([60cf917](https://github.com/devbean/obsidian-wordpress/commit/60cf9173220d45ae37f6c80c1c40e32dcd1470fa)) 23 | * No back button for modals on some mobile. ([98258bc](https://github.com/devbean/obsidian-wordpress/commit/98258bc1b707650e121cd6f0b3367d7d268741f8)) 24 | 25 | ## [0.16.0](https://github.com/devbean/obsidian-wordpress/compare/0.15.0...0.16.0) (2023-12-08) 26 | 27 | __For wordpress.com users, in order to fetch types, the new scope `sites` is needed, 28 | thus wordpress.com token should be refreshed before publishing.__ 29 | 30 | ### Features 31 | 32 | * Fetch post types from API. ([f952965](https://github.com/devbean/obsidian-wordpress/commit/f952965d70794a2aa431292e0f3a7a7ad4bf5c9e)) 33 | * Now you could select post type when publishing. ([dfb4b11](https://github.com/devbean/obsidian-wordpress/commit/dfb4b11e506da66e70d50c4bdaa8c2b3289b84ce)) 34 | 35 | ## [0.15.0](https://github.com/devbean/obsidian-wordpress/compare/0.14.0...0.15.0) (2023-10-11) 36 | 37 | 38 | ### Features 39 | 40 | * Images in notes will be uploaded to WordPress. ([32e2a7c](https://github.com/devbean/obsidian-wordpress/commit/32e2a7ce4968a12d8e8c237c9d666cdc62043142)) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * XML-RPC path maybe undefined sometime. ([6699eef](https://github.com/devbean/obsidian-wordpress/commit/6699eefe9134910f324654141a2aa3a256a02800)) 46 | 47 | ## [0.14.0](https://github.com/devbean/obsidian-wordpress/compare/0.13.4...0.14.0) (2023-04-17) 48 | 49 | 50 | ### Features 51 | 52 | * You can parse HTML tags in notes. ([5c23ed4](https://github.com/devbean/obsidian-wordpress/commit/5c23ed47190366ec183a75a6d0d8588bf73765a5)) 53 | 54 | ### [0.13.4](https://github.com/devbean/obsidian-wordpress/compare/0.13.3...0.13.4) (2023-04-12) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * Do not create an empty default profile at startup. ([e59acce](https://github.com/devbean/obsidian-wordpress/commit/e59accef7a45114481d2d009c4b3a20dac534c13)) 60 | * No publish button when newly install. ([409d752](https://github.com/devbean/obsidian-wordpress/commit/409d752bf6a0316cb31f90a1ae9c3da1cd2b394d)) 61 | 62 | ### [0.13.3](https://github.com/devbean/obsidian-wordpress/compare/0.13.2...0.13.3) (2023-04-11) 63 | 64 | 65 | ### Features 66 | 67 | * Confirms if posts' profile is not match picked. ([ed4d423](https://github.com/devbean/obsidian-wordpress/commit/ed4d4231f8db1935c516b8b0e254d6e29b8d5af7)) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * Incorrect response parser of wordpress.com. ([f7c6185](https://github.com/devbean/obsidian-wordpress/commit/f7c61852ea2171e304a29f08e9537b95e3965ee4)) 73 | 74 | ### [0.13.2](https://github.com/devbean/obsidian-wordpress/compare/0.13.1...0.13.2) (2023-04-10) 75 | 76 | 77 | ### Features 78 | 79 | * Ignore chooser modal if there is only one profile. ([bfdfd42](https://github.com/devbean/obsidian-wordpress/commit/bfdfd42925bae5f16f9c1026f1a5cc37c52fd16c)) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * XML-RPC parse bugs. ([15e2d15](https://github.com/devbean/obsidian-wordpress/commit/15e2d15c2f90c43b8bdba7d1cbacd79130d1dcaa)) 85 | 86 | ### [0.13.1](https://github.com/devbean/obsidian-wordpress/compare/0.13.0...0.13.1) (2023-04-10) 87 | 88 | ## [0.13.0](https://github.com/devbean/obsidian-wordpress/compare/0.12.0...0.13.0) (2023-04-06) 89 | 90 | 91 | ### Features 92 | 93 | * Use markdown-it instead of marked to parse markdown notes. ([4df846a](https://github.com/devbean/obsidian-wordpress/commit/4df846ae792582a024d5ec01689468fd7c4cfcf9)) 94 | 95 | ## [0.12.0](https://github.com/devbean/obsidian-wordpress/compare/0.11.0...0.12.0) (2023-04-04) 96 | 97 | 98 | ### Features 99 | 100 | * Do not save password in plaintext. ([7d391b0](https://github.com/devbean/obsidian-wordpress/commit/7d391b0e8df28ddaa72abd4c9fe60069af2598df)) 101 | * You can add multiple profiles of WordPress. ([af74d11](https://github.com/devbean/obsidian-wordpress/commit/af74d11bc06e21c2fb6f2c714813b3fb6acd2fb2)) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * Skip front-matter null values. Close [#34](https://github.com/devbean/obsidian-wordpress/issues/34) ([b55c76d](https://github.com/devbean/obsidian-wordpress/commit/b55c76db4642a701cfc5ab0b3cc1e8f1276e4059)) 107 | 108 | ## [0.11.0](https://github.com/devbean/obsidian-wordpress/compare/0.10.2...0.11.0) (2023-02-22) 109 | 110 | 111 | ### Features 112 | 113 | * Add options about MathJax output format. ([15fb5bc](https://github.com/devbean/obsidian-wordpress/commit/15fb5bcafa5b7a77ff9b43a2a18d52817eff699a)) 114 | * Support MathJax. ([e9d61bf](https://github.com/devbean/obsidian-wordpress/commit/e9d61bfee289eb3bbce9e7aca7d6c17e71becf62)) 115 | 116 | ### [0.10.2](https://github.com/devbean/obsidian-wordpress/compare/0.10.1...0.10.2) (2023-02-09) 117 | 118 | 119 | ### Features 120 | 121 | * Remember last selected categories of this WordPress site. ([caad134](https://github.com/devbean/obsidian-wordpress/commit/caad13403aace506850a28e05d7f4d37e5ee124c)) 122 | 123 | ### [0.10.1](https://github.com/devbean/obsidian-wordpress/compare/0.10.0...0.10.1) (2023-02-09) 124 | 125 | 126 | ### Features 127 | 128 | * Add an option to enable WordPress edit confirm modal. ([716ac1f](https://github.com/devbean/obsidian-wordpress/commit/716ac1f359c87994276639e5c0ad30e498834ad6)) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * Publish to uncategorized with default options. ([3ee1a9c](https://github.com/devbean/obsidian-wordpress/commit/3ee1a9cc93ea4e43b89c07d010f8b55b43c2ca8f)) 134 | 135 | ## [0.10.0](https://github.com/devbean/obsidian-wordpress/compare/0.9.1...0.10.0) (2023-02-09) 136 | 137 | 138 | ### Features 139 | 140 | * A modal will be opened when published successfully in order to let you edit post in browser. ([5400fc9](https://github.com/devbean/obsidian-wordpress/commit/5400fc974a0a1f125abcf0a972f4efce68f27a6c)) 141 | * You can override note title in front matter using `title` field. ([d905f4b](https://github.com/devbean/obsidian-wordpress/commit/d905f4ba5d47f6009ba728367dcbf11a8c05803d)) 142 | 143 | 144 | ### Bug Fixes 145 | 146 | * Post ID is not written to front matter if publishing with default options. ([90bfea8](https://github.com/devbean/obsidian-wordpress/commit/90bfea828946f214461427470e5684e3f6a38aba)) 147 | 148 | ### [0.9.1](https://github.com/devbean/obsidian-wordpress/compare/0.9.0...0.9.1) (2023-02-07) 149 | 150 | 151 | ### Bug Fixes 152 | 153 | * Compile errors. ([b37ee81](https://github.com/devbean/obsidian-wordpress/commit/b37ee81a53a5322adb67eafb51cf737a5628a45c)) 154 | 155 | ## [0.9.0](https://github.com/devbean/obsidian-wordpress/compare/0.8.0...0.9.0) (2023-02-07) 156 | 157 | 158 | ### Features 159 | 160 | * Supports for editing posts. ([2f153df](https://github.com/devbean/obsidian-wordpress/commit/2f153dfc95cd2bfd97245179e0e981aa276f7d20)) 161 | * Supports for tags. ([72a15fc](https://github.com/devbean/obsidian-wordpress/commit/72a15fcb16e7b6246f2da03305c6db52253d228c)) 162 | 163 | ## [0.8.0](https://github.com/devbean/obsidian-wordpress/compare/0.7.0...0.8.0) (2022-12-29) 164 | 165 | 166 | ### Features 167 | 168 | * Error notices will be stay in frame until clicking. ([abae9d7](https://github.com/devbean/obsidian-wordpress/commit/abae9d794370847738a93f720aa3ad220c1a2cd8)) 169 | * Support for wordpress.com. ([12e96eb](https://github.com/devbean/obsidian-wordpress/commit/12e96ebb1d036f2f9f1a5535b517dd552197dc0c)) 170 | 171 | 172 | ### Bug Fixes 173 | 174 | * Update ribbon button may cause plugin failed. ([737f981](https://github.com/devbean/obsidian-wordpress/commit/737f981130a37525d2431d0f847b9afdc73b35c5)) 175 | 176 | ## [0.7.0](https://github.com/devbean/obsidian-wordpress/compare/0.6.0...0.7.0) (2022-12-13) 177 | 178 | 179 | ### Features 180 | 181 | * Support WordPress application passwords authentication. ([fce8ca8](https://github.com/devbean/obsidian-wordpress/commit/fce8ca8c18345c409a05d56c68a16e9504a5d59f)) 182 | * Update license to Apache 2.0 ([abb19c2](https://github.com/devbean/obsidian-wordpress/commit/abb19c2687f12b7639e50727c45643b320d09cf6)) 183 | * Update license to Apache 2.0 ([560712b](https://github.com/devbean/obsidian-wordpress/commit/560712b18103059a599276577a175b6cac09be5d)) 184 | 185 | 186 | ### Bug Fixes 187 | 188 | * Fix a bug about save username and password working. ([90b9281](https://github.com/devbean/obsidian-wordpress/commit/90b9281f53ec62dafee63453a36a86bd55168f90)) 189 | 190 | ## [0.5.0](https://github.com/devbean/obsidian-wordpress/compare/0.4.0...0.5.0) (2022-08-15) 191 | 192 | ## [0.6.0](https://github.com/devbean/obsidian-wordpress/compare/0.4.0...0.6.0) (2022-12-12) 193 | 194 | 195 | ### Features 196 | 197 | * Now you can set comment status when publishing. ([2b69006](https://github.com/devbean/obsidian-wordpress/commit/2b69006033a1543bc6451cb610eb66242dc77afd)) 198 | * Support i18n. ([d8560ea](https://github.com/devbean/obsidian-wordpress/commit/d8560ea602f43de59db0565189710fe8645737a0)) 199 | * You can remember password on login modal. Be careful! ([4dd257d](https://github.com/devbean/obsidian-wordpress/commit/4dd257d2151d12cc93752d4396ed479b880f3de3)) 200 | * You can set XML-RPC path in settings, default is /xmlrpc.php ([b44be7d](https://github.com/devbean/obsidian-wordpress/commit/b44be7db1db3c24286052062a7e05422433a57af)) 201 | 202 | 203 | ### Bug Fixes 204 | 205 | * Cannot login if username and password are not saved. ([f8d2a5b](https://github.com/devbean/obsidian-wordpress/commit/f8d2a5b4f3e9cc9ce5ddce04133a130faf9f4401)) 206 | * Fix date-fns template placeholder error. ([f5b3e32](https://github.com/devbean/obsidian-wordpress/commit/f5b3e32ff56e5ba1904d86703f3973a447c9ca5c)) 207 | * Normalize URL. ([b25659b](https://github.com/devbean/obsidian-wordpress/commit/b25659bf5da586d3aa4eb1fcf31f4544616b4acd)) 208 | * Remove client cache. ([b6584e7](https://github.com/devbean/obsidian-wordpress/commit/b6584e73892ab6a52915ab00b9a00cab2c5752fd)) 209 | * Show notice if no WordPress URL set. ([baf92d7](https://github.com/devbean/obsidian-wordpress/commit/baf92d79e5f2db5f97210db7fa157f9b5ba0d531)) 210 | * Show notice if username or password is invalided. ([577f24f](https://github.com/devbean/obsidian-wordpress/commit/577f24f7c885f6d715fd51c9bc563681a528b370)) 211 | 212 | ## [0.5.0](https://github.com/devbean/obsidian-wordpress/compare/0.4.0...0.5.0) (2022-08-15) 213 | 214 | 215 | ### Features 216 | 217 | * You can remember password on login modal. Be careful! ([4dd257d](https://github.com/devbean/obsidian-wordpress/commit/4dd257d2151d12cc93752d4396ed479b880f3de3)) 218 | 219 | ## [0.4.0](https://github.com/devbean/obsidian-wordpress/compare/0.3.0...0.4.0) (2022-04-26) 220 | 221 | 222 | ### Features 223 | 224 | * Now you can set post category by fetching categories with XML-RPC. ([2393092](https://github.com/devbean/obsidian-wordpress/commit/23930923dd9b626c07cc1b94473da723acbdcb02)) 225 | * Now you can set post one category using REST api, too. ([d7c723e](https://github.com/devbean/obsidian-wordpress/commit/d7c723e61e0a6b7838b97ce5fee094434e341dfe)) 226 | 227 | 228 | ### Bug Fixes 229 | 230 | * Fix 'not well formed' bug if post content is very long using XML-RPC. ([1e8ac85](https://github.com/devbean/obsidian-wordpress/commit/1e8ac854ecfe9f485751d9d10b658ad4002fab95)) 231 | * Fix a bug if XML-RPC returns an array with only one item. ([08f53be](https://github.com/devbean/obsidian-wordpress/commit/08f53beeb553cc370fb1d6736b44171d0fb0fafe)) 232 | 233 | ## [0.3.0](https://github.com/devbean/obsidian-wordpress/compare/0.2.0...0.3.0) (2022-04-05) 234 | 235 | 236 | ### Features 237 | 238 | * Simplify API types. ([99bd146](https://github.com/devbean/obsidian-wordpress/commit/99bd146cef4eef02faf3b592668e3e17e7e7439b)) 239 | * You can set post status now. ([0661893](https://github.com/devbean/obsidian-wordpress/commit/06618936fda714d62240198377a48ea81553f596)) 240 | 241 | ## [0.2.0](https://github.com/devbean/obsidian-wordpress/compare/0.1.1...0.2.0) (2022-03-09) 242 | 243 | 244 | ### Features 245 | 246 | * Add REST support. ([f55139a](https://github.com/devbean/obsidian-wordpress/commit/f55139a13477b83f16be51ea20349acb2a484fe0)) 247 | 248 | ### [0.1.1](https://github.com/devbean/obsidian-wordpress/compare/0.1.0...0.1.1) (2022-02-24) 249 | 250 | 251 | ### Features 252 | 253 | * Ribbon icon could be refreshed if plugin options updates. ([9620ddd](https://github.com/devbean/obsidian-wordpress/commit/9620ddd48cfe3654e6583d6be2039e821e5a6da6)) 254 | 255 | ## [0.1.0](https://github.com/devbean/obsidian-wordpress/compare/0.0.2...0.1.0) (2022-02-24) 256 | 257 | 258 | ### Features 259 | 260 | * Use own XML-RPC implementation in order to support mobile. ([d0cc528](https://github.com/devbean/obsidian-wordpress/commit/d0cc5280d64ee2eded8c124205ef4cf9df9d60dd)) 261 | 262 | ### [0.0.2](https://github.com/devbean/obsidian-wordpress/compare/0.0.1...0.0.2) (2021-12-22) 263 | 264 | 265 | ### Features 266 | 267 | * Use async reading file content instead of sync. ([16036b9](https://github.com/devbean/obsidian-wordpress/commit/16036b9374738c984fc5e6db15e2f8caeec93ce8)) 268 | 269 | ### 0.0.1 (2021-12-09) 270 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obsidian-wordpress 2 | 3 | [BuyMeACoffee](https://www.buymeacoffee.com/devbean) 4 | 5 | This plugin makes you publish Obsidian documents to WordPress. 6 | 7 | There are some introduction videos you can watch: 8 | * [YouTube (Chinese) by 简睿学堂-emisjerry](https://youtu.be/7YECfr_W1WM) 9 | * [Bilibili (Chinese) by 简睿学堂-emisjerry](https://www.bilibili.com/video/BV1FT411A77m/?vd_source=8d3e1ef8cd3aab146af84cfad2f5076f) 10 | 11 | ## Usages 12 | 13 | Set your WordPress URL in settings as well as username if you want. 14 | 15 | Put cursor in a MarkDown editor, then use **Publish to WordPress** in 16 | [Command Palette](https://help.obsidian.md/Plugins/Command+palette) 17 | or you could show a button in side in settings. 18 | The document will be published to the WordPress URL that you set. 19 | 20 | You could add YAML front matter in front of notes. The plugin will read 21 | meta-data from front matter such as override title or tags. 22 | Also, WordPress post ID and categories will be added to this front matter 23 | if the note published successfully in order to support edit. 24 | 25 | For example, you could add as following: 26 | 27 | ```markdown 28 | --- 29 | title: Post title which will override note title, not required 30 | tags: 31 | - any tag you want 32 | - not required 33 | --- 34 | Note content here. 35 | ``` 36 | 37 | ## Limits 38 | 39 | This plugin uses XML-RPC or REST protocol to publish to WordPress. 40 | 41 | XML-RPC is enabled by default but some sites may disable it because of 42 | security problems. While some shared hosts might disable XML-RPC by default 43 | which you have no way to enable it. So this won't work if XML-RPC is disabled. 44 | 45 | REST API is enabled since WordPress 4.7 by default. Some REST API 46 | need extra actions in order to protect writable APIs. 47 | Traditionally, it is done by installing plugins. WordPress 5.6 was introduced 48 | application passwords to do similar things. So if you are OK with WordPress 5.6, 49 | application passwords is preferred as no plugin in needed. 50 | 51 | Read [this page](https://devbean.github.io/obsidian-wordpress) for more information. 52 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /docs/assets/images/01GX5KHAK22NWQ6CPWEBR1GG11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/01GX5KHAK22NWQ6CPWEBR1GG11.png -------------------------------------------------------------------------------- /docs/assets/images/01GX5KHAK2BSM1CQKT19D6B2AX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/01GX5KHAK2BSM1CQKT19D6B2AX.png -------------------------------------------------------------------------------- /docs/assets/images/01GX5KHAK2G1CQQKKY37RA4KMY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/01GX5KHAK2G1CQQKKY37RA4KMY.png -------------------------------------------------------------------------------- /docs/assets/images/01GX5KHAK2S10XJRZE6CMBSGJB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/01GX5KHAK2S10XJRZE6CMBSGJB.png -------------------------------------------------------------------------------- /docs/assets/images/wp-app-pwd-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/wp-app-pwd-1.png -------------------------------------------------------------------------------- /docs/assets/images/wp-app-pwd-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/wp-app-pwd-2.png -------------------------------------------------------------------------------- /docs/assets/images/wp-miniOrange-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/wp-miniOrange-1.png -------------------------------------------------------------------------------- /docs/assets/images/wp-miniOrange-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/wp-miniOrange-2.png -------------------------------------------------------------------------------- /docs/assets/images/wp-miniOrange-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbean/obsidian-wordpress/2f6b6f5d13185eea0c3b782c82d77e481ed61d3c/docs/assets/images/wp-miniOrange-3.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## Obsidian WordPress Plugin 2 | 3 | This an obsidian plugin for publishing documents to WordPress CMS. 4 | 5 | ## How to install 6 | 7 | The plugin could be installed in `Community plugins` 8 | by searching `wordpress` as keyword. 9 | 10 | ![Installing](/obsidian-wordpress/assets/images/01GX5KHAK2BSM1CQKT19D6B2AX.png) 11 | 12 | ## How to use 13 | 14 | Before publishing, necessary WordPress settings should be done 15 | in `WordPress` tab of Settings. 16 | 17 | ![Settings](/obsidian-wordpress/assets/images/01GX5KHAK2S10XJRZE6CMBSGJB.png) 18 | 19 | You can find settings as following: 20 | 21 | Let's say a WordPress server could be access by https://www.mywp.com . 22 | 23 | * **Profiles**: WordPress profiles. You could add multiple WordPress profiles 24 | in order to publish notes to multiple WordPress servers. 25 | * **Show icon in sidebar**: Show WordPress button in sidebar so you could click it 26 | to publish current note without opening command palette. 27 | * **Default Post Status**: Default post status when publishing. 28 | * **Default Post Comment Status**: Default comment status when publishing. 29 | * **Remember last selected categories**: Turn it on if you want to remember 30 | last selected categories when publishing. 31 | * **Show WordPress edit confirmation**: Turn it on if you want to open 32 | WordPress editing page after publishing successfully. 33 | * **MathJax Output Format**: Output format of MathJax. 34 | * SVG: An image format that supports by browser so there is no need configure 35 | for WordPress. But if you try to edit using WordPress editor, SVG images 36 | will be lost for WordPress editor does not support SVG. 37 | * TeX: A WordPress plugin, such as [Simple Mathjax](https://wordpress.org/plugins/simple-mathjax/), 38 | is needed for rendering but you can edit using WordPress editor. 39 | 40 | While WordPress profiles could be managed in another modal. 41 | 42 | ![Profiles](/obsidian-wordpress/assets/images/01GX5KHAK2G1CQQKKY37RA4KMY.png) 43 | 44 | Creates or edits a profile needs such information: 45 | 46 | ![Profile](/obsidian-wordpress/assets/images/01GX5KHAK22NWQ6CPWEBR1GG11.png) 47 | 48 | Some need be explained. 49 | 50 | * **Name**: Name of this profile. 51 | * **WordPress URL**: A full path of WordPress. 52 | It should be https://www.mywp.com. Note that if your site does not support 53 | URL rewrite, you may add `/index.php` appending to your domain. 54 | * **API Type**: At this version we support following API types: 55 | * XML-RPC: Enabled by default but some hosts may disable it for safety problems. 56 | * REST API Authentication by miniOrange: REST API is enabled by default since WordPress 4.7. 57 | An extra plugin named miniOrange is needed to be installed and enabled in order to 58 | protect core writable APIs. 59 | * REST API Authentication by application password: REST API is enabled by default 60 | since WordPress 4.7 while application password was introduced in WordPress 5.6. 61 | If you are OK with WordPress 5.6, this is recommended as no plugin is needed. 62 | * REST API for wordpress.com only: REST API is enabled by default on wordpress.com. 63 | 64 | **Note** The mentioned plugins miniOrange must be installed and enabled in WordPress server 65 | and configured correctly as following steps. 66 | 67 | ## How to config WordPress plugins 68 | 69 | ### WordPress REST API Authentication by miniOrange 70 | 71 | In the plugin settings page, select `BASIC AUTHENTICATION`. 72 | 73 | ![miniOrange](/obsidian-wordpress/assets/images/wp-miniOrange-1.png) 74 | 75 | In the next page, select `Username & Password with Base64 Encoding` then `Next`. 76 | 77 | ![miniOrange](/obsidian-wordpress/assets/images/wp-miniOrange-2.png) 78 | 79 | Finally, click `Finish`. 80 | 81 | ![miniOrange](/obsidian-wordpress/assets/images/wp-miniOrange-3.png) 82 | 83 | ## How to config application passwords 84 | 85 | Application passwords was introduced in WordPress 5.6. 86 | 87 | You need to login WordPress and navigate to 'Profile' page. 88 | 89 | ![applicationPasswords](/obsidian-wordpress/assets/images/wp-app-pwd-1.png) 90 | 91 | You could use any application name you want, then click 'Add New Application Password' button. 92 | 93 | ![applicationPasswords](/obsidian-wordpress/assets/images/wp-app-pwd-2.png) 94 | 95 | Here you need to save generated password as it only shows once. Spaces in the password will be ignored by WordPress automatically. 96 | 97 | Then you could use your login username and the application password in Obsidian WordPress plugin. 98 | 99 | ## How to use with WordPress.com 100 | 101 | WordPress.com supports OAuth 2.0 to protect REST API. This plugin add OAuth 2.0 for wordpress.com. 102 | 103 | When using with WordPress.com, a valid wordpress.com site URL should be input first 104 | (let's say https://yoursitename.wordpress.com). Then select 'REST API for wordpress.com', the browser 105 | should be raised to open wordpress.com authorize page. After clicking 'Approve' button, obsidian.md 106 | should be raised again and the plugin should be authorized. 107 | 108 | **Note**, the plugin fetched wordpress.com token might be expired in two weeks by default. If publishes 109 | failed some day, 'Refresh' button should be clicked in order to get a new token. 110 | -------------------------------------------------------------------------------- /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: ["src/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 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-wordpress", 3 | "name": "WordPress", 4 | "version": "0.18.0", 5 | "minAppVersion": "1.1.1", 6 | "description": "A plugin for publishing Obsidian documents to WordPress.", 7 | "author": "devbean", 8 | "isDesktopOnly": false, 9 | "fundingUrl": "https://www.buymeacoffee.com/devbean" 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-wordpress", 3 | "version": "0.18.0", 4 | "description": "A plugin for publishing Obsidian documents to WordPress.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "release": "standard-version", 10 | "release-test": "standard-version --dry-run", 11 | "release-major": "standard-version --release-as major", 12 | "release-major-test": "standard-version --dry-run --release-as major", 13 | "release-minor": "standard-version --release-as minor", 14 | "release-minor-test": "standard-version --dry-run --release-as minor", 15 | "version": "node version-bump.mjs && git add manifest.json versions.json" 16 | }, 17 | "standard-version": { 18 | "t": "" 19 | }, 20 | "keywords": [ 21 | "wp", 22 | "wordpress", 23 | "obsidian", 24 | "plugin" 25 | ], 26 | "author": "devbean", 27 | "license": "Apache-2.0", 28 | "devDependencies": { 29 | "@types/js-yaml": "4.0.9", 30 | "@types/lodash-es": "4.17.12", 31 | "@types/markdown-it": "14.1.2", 32 | "@types/node": "22.14.1", 33 | "@typescript-eslint/eslint-plugin": "8.30.1", 34 | "@typescript-eslint/parser": "8.30.1", 35 | "builtin-modules": "5.0.0", 36 | "date-fns": "4.1.0", 37 | "esbuild": "0.25.2", 38 | "eslint-plugin-node": "11.1.0", 39 | "file-type-checker": "1.1.4", 40 | "imask": "7.6.1", 41 | "juice": "11.0.1", 42 | "lodash-es": "4.17.21", 43 | "markdown-it": "14.1.0", 44 | "mathjax-full": "3.2.2", 45 | "obsidian": "1.8.7", 46 | "tslib": "2.8.1", 47 | "typescript": "5.8.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/abstract-modal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Platform } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { TranslateKey } from './i18n'; 4 | 5 | export abstract class AbstractModal extends Modal { 6 | 7 | protected constructor( 8 | protected readonly plugin: WordpressPlugin 9 | ) { 10 | super(plugin.app); 11 | } 12 | 13 | protected t(key: TranslateKey, vars?: Record): string { 14 | return this.plugin.i18n.t(key, vars); 15 | } 16 | 17 | protected createHeader(title: string): void { 18 | const { contentEl } = this; 19 | 20 | const headerDiv = contentEl.createDiv(); 21 | headerDiv.addClass('modal-header'); 22 | headerDiv.createEl('h1', { text: title }); 23 | if (Platform.isMobile) { 24 | const backButton = headerDiv.createEl('button', { text: this.t('common_back') }); 25 | backButton.addEventListener('click', () => { 26 | this.close(); 27 | }); 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/abstract-wp-client.ts: -------------------------------------------------------------------------------- 1 | import { Notice, TFile } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { 4 | WordPressAuthParams, 5 | WordPressClient, 6 | WordPressClientResult, 7 | WordPressClientReturnCode, 8 | WordPressMediaUploadResult, 9 | WordPressPostParams, 10 | WordPressPublishResult 11 | } from './wp-client'; 12 | import { WpPublishModal } from './wp-publish-modal'; 13 | import { PostType, PostTypeConst, Term } from './wp-api'; 14 | import { ERROR_NOTICE_TIMEOUT, WP_DEFAULT_PROFILE_NAME } from './consts'; 15 | import { isPromiseFulfilledResult, isValidUrl, openWithBrowser, processFile, SafeAny, showError, } from './utils'; 16 | import { WpProfile } from './wp-profile'; 17 | import { AppState } from './app-state'; 18 | import { ConfirmCode, openConfirmModal } from './confirm-modal'; 19 | import fileTypeChecker from 'file-type-checker'; 20 | import { MatterData, Media } from './types'; 21 | import { openPostPublishedModal } from './post-published-modal'; 22 | import { openLoginModal } from './wp-login-modal'; 23 | import { isFunction } from 'lodash-es'; 24 | 25 | export abstract class AbstractWordPressClient implements WordPressClient { 26 | 27 | /** 28 | * Client name. 29 | */ 30 | name = 'AbstractWordPressClient'; 31 | 32 | protected constructor( 33 | protected readonly plugin: WordpressPlugin, 34 | protected readonly profile: WpProfile 35 | ) { } 36 | 37 | abstract publish( 38 | title: string, 39 | content: string, 40 | postParams: WordPressPostParams, 41 | certificate: WordPressAuthParams 42 | ): Promise>; 43 | 44 | abstract getCategories( 45 | certificate: WordPressAuthParams 46 | ): Promise; 47 | 48 | abstract getPostTypes( 49 | certificate: WordPressAuthParams 50 | ): Promise; 51 | 52 | abstract validateUser( 53 | certificate: WordPressAuthParams 54 | ): Promise>; 55 | 56 | abstract getTag( 57 | name: string, 58 | certificate: WordPressAuthParams 59 | ): Promise; 60 | 61 | abstract uploadMedia( 62 | media: Media, 63 | certificate: WordPressAuthParams 64 | ): Promise>; 65 | 66 | protected needLogin(): boolean { 67 | return true; 68 | } 69 | 70 | private async getAuth(): Promise { 71 | let auth: WordPressAuthParams = { 72 | username: null, 73 | password: null 74 | }; 75 | try { 76 | if (this.needLogin()) { 77 | // Check if there's saved username and password 78 | if (this.profile.username && this.profile.password) { 79 | auth = { 80 | username: this.profile.username, 81 | password: this.profile.password 82 | }; 83 | const authResult = await this.validateUser(auth); 84 | if (authResult.code !== WordPressClientReturnCode.OK) { 85 | throw new Error(this.plugin.i18n.t('error_invalidUser')); 86 | } 87 | } 88 | } 89 | } catch (error) { 90 | showError(error); 91 | const result = await openLoginModal(this.plugin, this.profile, async (auth) => { 92 | const authResult = await this.validateUser(auth); 93 | return authResult.code === WordPressClientReturnCode.OK; 94 | }); 95 | auth = result.auth; 96 | } 97 | return auth; 98 | } 99 | 100 | private async checkExistingProfile(matterData: MatterData) { 101 | const { profileName } = matterData; 102 | const isProfileNameMismatch = profileName && profileName !== this.profile.name; 103 | if (isProfileNameMismatch) { 104 | const confirm = await openConfirmModal({ 105 | message: this.plugin.i18n.t('error_profileNotMatch'), 106 | cancelText: this.plugin.i18n.t('profileNotMatch_useOld', { 107 | profileName: matterData.profileName 108 | }), 109 | confirmText: this.plugin.i18n.t('profileNotMatch_useNew', { 110 | profileName: this.profile.name 111 | }) 112 | }, this.plugin); 113 | if (confirm.code !== ConfirmCode.Cancel) { 114 | delete matterData.postId; 115 | matterData.categories = this.profile.lastSelectedCategories ?? [ 1 ]; 116 | } 117 | } 118 | } 119 | 120 | private async tryToPublish(params: { 121 | postParams: WordPressPostParams, 122 | auth: WordPressAuthParams, 123 | updateMatterData?: (matter: MatterData) => void, 124 | }): Promise> { 125 | const { postParams, auth, updateMatterData } = params; 126 | const tagTerms = await this.getTags(postParams.tags, auth); 127 | postParams.tags = tagTerms.map(term => term.id); 128 | await this.updatePostImages({ 129 | auth, 130 | postParams 131 | }); 132 | const html = AppState.markdownParser.render(postParams.content); 133 | const result = await this.publish( 134 | postParams.title ?? 'A post from Obsidian!', 135 | html, 136 | postParams, 137 | auth); 138 | if (result.code === WordPressClientReturnCode.Error) { 139 | throw new Error(this.plugin.i18n.t('error_publishFailed', { 140 | code: result.error.code as string, 141 | message: result.error.message 142 | })); 143 | } else { 144 | new Notice(this.plugin.i18n.t('message_publishSuccessfully')); 145 | // post id will be returned if creating, true if editing 146 | const postId = result.data.postId; 147 | if (postId) { 148 | // const modified = matter.stringify(postParams.content, matterData, matterOptions); 149 | // this.updateFrontMatter(modified); 150 | const file = this.plugin.app.workspace.getActiveFile(); 151 | if (file) { 152 | await this.plugin.app.fileManager.processFrontMatter(file, fm => { 153 | fm.profileName = this.profile.name; 154 | fm.postId = postId; 155 | fm.postType = postParams.postType; 156 | if (postParams.postType === PostTypeConst.Post) { 157 | fm.categories = postParams.categories; 158 | } 159 | if (isFunction(updateMatterData)) { 160 | updateMatterData(fm); 161 | } 162 | }); 163 | } 164 | 165 | if (this.plugin.settings.rememberLastSelectedCategories) { 166 | this.profile.lastSelectedCategories = (result.data as SafeAny).categories; 167 | await this.plugin.saveSettings(); 168 | } 169 | 170 | if (this.plugin.settings.showWordPressEditConfirm) { 171 | openPostPublishedModal(this.plugin) 172 | .then(() => { 173 | openWithBrowser(`${this.profile.endpoint}/wp-admin/post.php`, { 174 | action: 'edit', 175 | post: postId 176 | }); 177 | }); 178 | } 179 | } 180 | } 181 | return result; 182 | } 183 | 184 | private async updatePostImages(params: { 185 | postParams: WordPressPostParams, 186 | auth: WordPressAuthParams, 187 | }): Promise { 188 | const { postParams, auth } = params; 189 | 190 | const activeFile = this.plugin.app.workspace.getActiveFile(); 191 | if (activeFile === null) { 192 | throw new Error(this.plugin.i18n.t('error_noActiveFile')); 193 | } 194 | const { activeEditor } = this.plugin.app.workspace; 195 | if (activeEditor && activeEditor.editor) { 196 | // process images 197 | const images = getImages(postParams.content); 198 | for (const img of images) { 199 | if (!img.srcIsUrl) { 200 | img.src = decodeURI(img.src); 201 | const fileName = img.src.split("/").pop(); 202 | if (fileName === undefined) { 203 | continue; 204 | } 205 | const imgFile = this.plugin.app.metadataCache.getFirstLinkpathDest(img.src, fileName); 206 | if (imgFile instanceof TFile) { 207 | const content = await this.plugin.app.vault.readBinary(imgFile); 208 | const fileType = fileTypeChecker.detectFile(content); 209 | const result = await this.uploadMedia({ 210 | mimeType: fileType?.mimeType ?? 'application/octet-stream', 211 | fileName: imgFile.name, 212 | content: content 213 | }, auth); 214 | if (result.code === WordPressClientReturnCode.OK) { 215 | if(img.width && img.height){ 216 | postParams.content = postParams.content.replace(img.original, `![[${result.data.url}|${img.width}x${img.height}]]`); 217 | }else if (img.width){ 218 | postParams.content = postParams.content.replace(img.original, `![[${result.data.url}|${img.width}]]`); 219 | }else{ 220 | postParams.content = postParams.content.replace(img.original, `![[${result.data.url}]]`); 221 | } 222 | } else { 223 | if (result.error.code === WordPressClientReturnCode.ServerInternalError) { 224 | new Notice(result.error.message, ERROR_NOTICE_TIMEOUT); 225 | } else { 226 | new Notice(this.plugin.i18n.t('error_mediaUploadFailed', { 227 | name: imgFile.name, 228 | }), ERROR_NOTICE_TIMEOUT); 229 | } 230 | } 231 | } 232 | } else { 233 | // src is a url, skip uploading 234 | } 235 | } 236 | if (this.plugin.settings.replaceMediaLinks) { 237 | activeEditor.editor.setValue(postParams.content); 238 | } 239 | } 240 | } 241 | 242 | async publishPost(defaultPostParams?: WordPressPostParams): Promise> { 243 | try { 244 | if (!this.profile.endpoint || this.profile.endpoint.length === 0) { 245 | throw new Error(this.plugin.i18n.t('error_noEndpoint')); 246 | } 247 | // const { activeEditor } = this.plugin.app.workspace; 248 | const file = this.plugin.app.workspace.getActiveFile() 249 | if (file === null) { 250 | throw new Error(this.plugin.i18n.t('error_noActiveFile')); 251 | } 252 | 253 | // get auth info 254 | const auth = await this.getAuth(); 255 | 256 | // read note title, content and matter data 257 | const title = file.basename; 258 | const { content, matter: matterData } = await processFile(file, this.plugin.app); 259 | 260 | // check if profile selected is matched to the one in note property, 261 | // if not, ask whether to update or not 262 | await this.checkExistingProfile(matterData); 263 | 264 | // now we're preparing the publishing data 265 | let postParams: WordPressPostParams; 266 | let result: WordPressClientResult | undefined; 267 | if (defaultPostParams) { 268 | postParams = this.readFromFrontMatter(title, matterData, defaultPostParams); 269 | postParams.content = content; 270 | result = await this.tryToPublish({ 271 | auth, 272 | postParams 273 | }); 274 | } else { 275 | const categories = await this.getCategories(auth); 276 | const selectedCategories = matterData.categories as number[] 277 | ?? this.profile.lastSelectedCategories 278 | ?? [ 1 ]; 279 | const postTypes = await this.getPostTypes(auth); 280 | if (postTypes.length === 0) { 281 | postTypes.push(PostTypeConst.Post); 282 | } 283 | const selectedPostType = matterData.postType ?? PostTypeConst.Post; 284 | result = await new Promise(resolve => { 285 | const publishModal = new WpPublishModal( 286 | this.plugin, 287 | { items: categories, selected: selectedCategories }, 288 | { items: postTypes, selected: selectedPostType }, 289 | async (postParams: WordPressPostParams, updateMatterData: (matter: MatterData) => void) => { 290 | postParams = this.readFromFrontMatter(title, matterData, postParams); 291 | postParams.content = content; 292 | try { 293 | const r = await this.tryToPublish({ 294 | auth, 295 | postParams, 296 | updateMatterData 297 | }); 298 | if (r.code === WordPressClientReturnCode.OK) { 299 | publishModal.close(); 300 | resolve(r); 301 | } 302 | } catch (error) { 303 | if (error instanceof Error) { 304 | return showError(error); 305 | } else { 306 | throw error; 307 | } 308 | } 309 | }, 310 | matterData); 311 | publishModal.open(); 312 | }); 313 | } 314 | if (result) { 315 | return result; 316 | } else { 317 | throw new Error(this.plugin.i18n.t("message_publishFailed")); 318 | } 319 | } catch (error) { 320 | if (error instanceof Error) { 321 | return showError(error); 322 | } else { 323 | throw error; 324 | } 325 | } 326 | } 327 | 328 | private async getTags(tags: string[], certificate: WordPressAuthParams): Promise { 329 | const results = await Promise.allSettled(tags.map(name => this.getTag(name, certificate))); 330 | const terms: Term[] = []; 331 | results 332 | .forEach(result => { 333 | if (isPromiseFulfilledResult(result)) { 334 | terms.push(result.value); 335 | } 336 | }); 337 | return terms; 338 | } 339 | 340 | private readFromFrontMatter( 341 | noteTitle: string, 342 | matterData: MatterData, 343 | params: WordPressPostParams 344 | ): WordPressPostParams { 345 | const postParams = { ...params }; 346 | postParams.title = noteTitle; 347 | if (matterData.title) { 348 | postParams.title = matterData.title; 349 | } 350 | if (matterData.postId) { 351 | postParams.postId = matterData.postId; 352 | } 353 | postParams.profileName = matterData.profileName ?? WP_DEFAULT_PROFILE_NAME; 354 | if (matterData.postType) { 355 | postParams.postType = matterData.postType; 356 | } else { 357 | // if there is no post type in matter-data, assign it as 'post' 358 | postParams.postType = PostTypeConst.Post; 359 | } 360 | if (postParams.postType === PostTypeConst.Post) { 361 | // only 'post' supports categories and tags 362 | if (matterData.categories) { 363 | postParams.categories = matterData.categories as number[] ?? this.profile.lastSelectedCategories; 364 | } 365 | if (matterData.tags) { 366 | postParams.tags = matterData.tags as string[]; 367 | } 368 | } 369 | return postParams; 370 | } 371 | 372 | } 373 | 374 | interface Image { 375 | original: string; 376 | src: string; 377 | altText?: string; 378 | width?: string; 379 | height?: string; 380 | srcIsUrl: boolean; 381 | startIndex: number; 382 | endIndex: number; 383 | file?: TFile; 384 | content?: ArrayBuffer; 385 | } 386 | 387 | function getImages(content: string): Image[] { 388 | const paths: Image[] = []; 389 | 390 | // for ![Alt Text](image-url) 391 | let regex = /(!\[(.*?)(?:\|(\d+)(?:x(\d+))?)?]\((.*?)\))/g; 392 | let match; 393 | while ((match = regex.exec(content)) !== null) { 394 | paths.push({ 395 | src: match[5], 396 | altText: match[2], 397 | width: match[3], 398 | height: match[4], 399 | original: match[1], 400 | startIndex: match.index, 401 | endIndex: match.index + match.length, 402 | srcIsUrl: isValidUrl(match[5]), 403 | }); 404 | } 405 | 406 | // for ![[image-name]] 407 | regex = /(!\[\[(.*?)(?:\|(\d+)(?:x(\d+))?)?]])/g; 408 | while ((match = regex.exec(content)) !== null) { 409 | paths.push({ 410 | src: match[2], 411 | original: match[1], 412 | width: match[3], 413 | height: match[4], 414 | startIndex: match.index, 415 | endIndex: match.index + match.length, 416 | srcIsUrl: isValidUrl(match[2]), 417 | }); 418 | } 419 | 420 | return paths; 421 | } 422 | -------------------------------------------------------------------------------- /src/app-state.ts: -------------------------------------------------------------------------------- 1 | import { Events } from 'obsidian'; 2 | import MarkdownIt from 'markdown-it'; 3 | import { MarkdownItImagePluginInstance } from './markdown-it-image-plugin'; 4 | import { MarkdownItCommentPluginInstance } from './markdown-it-comment-plugin'; 5 | import { MarkdownItMathJax3PluginInstance } from './markdown-it-mathjax3-plugin'; 6 | 7 | class AppStore { 8 | 9 | markdownParser = new MarkdownIt(); 10 | 11 | events = new Events(); 12 | 13 | codeVerifier: string | undefined; 14 | 15 | } 16 | 17 | export const AppState = new AppStore(); 18 | 19 | AppState.markdownParser 20 | .use(MarkdownItCommentPluginInstance.plugin) 21 | .use(MarkdownItMathJax3PluginInstance.plugin) 22 | .use(MarkdownItImagePluginInstance.plugin); 23 | -------------------------------------------------------------------------------- /src/confirm-modal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { TranslateKey } from './i18n'; 4 | 5 | 6 | export enum ConfirmCode { 7 | Cancel, 8 | Confirm 9 | } 10 | 11 | export interface ConfirmModalMessages { 12 | message: string; 13 | cancelText?: string; 14 | confirmText?: string; 15 | } 16 | 17 | export function openConfirmModal( 18 | messages: ConfirmModalMessages, 19 | plugin: WordpressPlugin 20 | ): Promise<{ code: ConfirmCode }> { 21 | return new Promise((resolve, reject) => { 22 | const modal = new ConfirmModal( 23 | messages, 24 | plugin, 25 | (code, modal) => { 26 | resolve({ 27 | code 28 | }); 29 | modal.close(); 30 | }); 31 | modal.open(); 32 | }); 33 | } 34 | 35 | /** 36 | * Confirm modal. 37 | */ 38 | class ConfirmModal extends Modal { 39 | 40 | constructor( 41 | private readonly messages: ConfirmModalMessages, 42 | private readonly plugin: WordpressPlugin, 43 | private readonly onAction: (code: ConfirmCode, modal: Modal) => void 44 | ) { 45 | super(plugin.app); 46 | } 47 | 48 | onOpen() { 49 | const t = (key: TranslateKey, vars?: Record): string => { 50 | return this.plugin.i18n.t(key, vars); 51 | }; 52 | 53 | const { contentEl } = this; 54 | 55 | contentEl.createEl('h1', { text: t('confirmModal_title') }); 56 | 57 | new Setting(contentEl) 58 | .setName(this.messages.message); 59 | 60 | new Setting(contentEl) 61 | .addButton(button => button 62 | .setButtonText(this.messages.cancelText ?? t('confirmModal_cancel')) 63 | .onClick(() => { 64 | this.onAction(ConfirmCode.Cancel, this); 65 | }) 66 | ) 67 | .addButton(button => button 68 | .setButtonText(this.messages.confirmText ?? t('confirmModal_confirm')) 69 | .setCta() 70 | .onClick(() => { 71 | this.onAction(ConfirmCode.Confirm, this); 72 | }) 73 | ); 74 | } 75 | 76 | onClose() { 77 | const { contentEl } = this; 78 | contentEl.empty(); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_NOTICE_TIMEOUT = 15000; 2 | 3 | export const WP_OAUTH2_CLIENT_ID = '79085'; 4 | export const WP_OAUTH2_CLIENT_SECRET = 'zg4mKy9O1mc1mmynShJTVxs8r1k3X4e3g1sv5URlkpZqlWdUdAA7C2SSBOo02P7X'; 5 | export const WP_OAUTH2_TOKEN_ENDPOINT = 'https://public-api.wordpress.com/oauth2/token'; 6 | export const WP_OAUTH2_AUTHORIZE_ENDPOINT = 'https://public-api.wordpress.com/oauth2/authorize'; 7 | export const WP_OAUTH2_VALIDATE_TOKEN_ENDPOINT = 'https://public-api.wordpress.com/oauth2/token-info'; 8 | export const WP_OAUTH2_URL_ACTION = 'wordpress-plugin-oauth'; 9 | export const WP_OAUTH2_REDIRECT_URI = `obsidian://${WP_OAUTH2_URL_ACTION}`; 10 | 11 | export const WP_DEFAULT_PROFILE_NAME = 'Default'; 12 | 13 | export const enum EventType { 14 | OAUTH2_TOKEN_GOT = 'OAUTH2_TOKEN_GOT', 15 | } 16 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { LANGUAGES } from './i18n/langs'; 2 | import { moment } from 'obsidian'; 3 | import { template } from 'lodash-es'; 4 | 5 | export type Language = keyof typeof LANGUAGES; 6 | export type LanguageWithAuto = Language | 'auto'; 7 | export type TranslateKey = keyof typeof LANGUAGES['en']; 8 | 9 | export class I18n { 10 | 11 | constructor( 12 | private readonly lang: LanguageWithAuto = 'auto' 13 | ) { 14 | this.lang = lang; 15 | } 16 | 17 | t(key: TranslateKey, vars?: Record): string { 18 | const string = this.#get(key); 19 | if (vars) { 20 | const compiled = template(string); 21 | return compiled(vars); 22 | } else { 23 | return string; 24 | } 25 | } 26 | 27 | #get(key: TranslateKey): string { 28 | let lang: Language; 29 | if (this.lang === 'auto' && moment.locale().replace('-', '_') in LANGUAGES) { 30 | lang = moment.locale().replace('-', '_') as Language; 31 | } else { 32 | lang = 'en'; 33 | } 34 | return LANGUAGES[lang][key] || LANGUAGES['en'][key] || key; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "error_noEndpoint": "No WordPress URL set.", 3 | "error_notWpCom": "The URL is not wordpress.com, please check again.", 4 | "error_noUsername": "No username", 5 | "error_noPassword": "No password", 6 | "error_noProfile": "No profile, please add one at least", 7 | "error_noProfileName": "No profile name", 8 | "error_noSuchProfile": "No profile with name <%= profileName %>", 9 | "error_invalidUrl": "Invalid URL", 10 | "error_invalidUser": "Invalid username or password", 11 | "error_publishFailed": "Post published failed!\n<%= code %>: <%= message %>", 12 | "error_wpComAuthFailed": "WordPress authorize failed!\n<%= error %>: <%= desc %>", 13 | "error_invalidWpComToken": "Invalid wordpress.com token", 14 | "error_cannotParseResponse": "Cannot parse WordPress server response.", 15 | "error_noDefaultProfile": "No default profile found.", 16 | "error_profileNotMatch": "WordPress profile not match. Which one do you want to use?", 17 | "error_noActiveFile": "No active document opened.", 18 | "error_mediaUploadFailed": "Media file <%= name %> upload failed.", 19 | "error_noEditorOrFile": "No editor or file found", 20 | "message_publishSuccessfully": "Post published successfully!", 21 | "message_publishFailed": "Post published failed!", 22 | "message_wpComTokenValidated": "Wordpress.com token validated successfully!", 23 | "ribbon_iconTitle": "WordPress Publish", 24 | "command_publish": "Publish current note", 25 | "command_publishWithDefault": "Publish current note with default options", 26 | "common_back": "Back", 27 | "confirmModal_title": "Confirmation", 28 | "confirmModal_cancel": "Cancel", 29 | "confirmModal_confirm": "Confirm", 30 | "settings_title": "WordPress Publish", 31 | "settings_profiles": "Profiles", 32 | "settings_profilesDesc": "Manage WordPress profiles.", 33 | "settings_profilesModal": "Open", 34 | "settings_url": "WordPress URL", 35 | "settings_urlDesc": "Full path of installed WordPress, for example, https://example.com/wordpress", 36 | "settings_urlPlaceholder": "https://example.com/wordpress", 37 | "settings_apiType": "API Type", 38 | "settings_apiTypeDesc": "Select which API you want to use.", 39 | "settings_apiTypeXmlRpc": "XML-RPC", 40 | "settings_apiTypeXmlRpcDesc": "XML-RPC was enabled by default but some sites may disable it because of security problems.", 41 | "settings_apiTypeRestMiniOrange": "REST API Authentication by miniOrange", 42 | "settings_apiTypeRestMiniOrangeDesc": "REST API was enabled by default in WordPress 4.7+. These APIs should be authenticated by extra plugin miniOrange which should be installed and enabled in WordPress server.", 43 | "settings_apiTypeRestApplicationPasswords": "REST API Authentication by application passwords", 44 | "settings_apiTypeRestApplicationPasswordsDesc": "REST API was enabled by default in WordPress 4.7+ while application passwords was introduced in WordPress 5.6+.", 45 | "settings_apiTypeRestWpComOAuth2": "REST API for wordpress.com", 46 | "settings_apiTypeRestWpComOAuth2Desc": "REST API for wordpress.com only.", 47 | "settings_showRibbonIcon": "Show icon in sidebar", 48 | "settings_showRibbonIconDesc": "If enabled, a button which opens publish panel will be added to the Obsidian sidebar.", 49 | "settings_defaultPostStatus": "Default Post Status", 50 | "settings_defaultPostStatusDesc": "Post status which will be published to WordPress.", 51 | "settings_defaultPostStatusDraft": "Draft", 52 | "settings_defaultPostStatusPublish": "Publish", 53 | "settings_defaultPostStatusPrivate": "Private", 54 | "settings_rememberLastSelectedCategories": "Remember last selected categories", 55 | "settings_rememberLastSelectedCategoriesDesc": "Remember last selected post categories of this site.", 56 | "settings_showWordPressEditPageModal": "Show WordPress edit confirmation", 57 | "settings_showWordPressEditPageModalDesc": "Show open WordPress edit page confirmation when publish successfully", 58 | "settings_xmlRpcPath": "XML-RPC Path", 59 | "settings_xmlRpcPathDesc": "XML-RPC Path, default is /xmlrpc.php", 60 | "settings_wpComOAuth2RefreshToken": "Refresh wordpress.com OAuth2 token", 61 | "settings_wpComOAuth2RefreshTokenDesc": "Click right button to validate or refresh wordpress.com OAuth2 token.", 62 | "settings_wpComOAuth2ValidateTokenButtonText": "Validate", 63 | "settings_wpComOAuth2RefreshTokenButtonText": "Refresh", 64 | "settings_defaultPostComment": "Default Comment Status", 65 | "settings_defaultPostCommentDesc": "Comment status which will be published to WordPress.", 66 | "settings_defaultPostCommentOpen": "Open", 67 | "settings_defaultPostCommentClosed": "Closed", 68 | "settings_mathJaxOutputType": "MathJax Output Format", 69 | "settings_mathJaxOutputTypeDesc": "Select MathJax output format.", 70 | "settings_mathJaxOutputTypeTeX": "TeX", 71 | "settings_MathJaxOutputTypeTeXDesc": "Convert MathJax to TeX directly. WordPress needs install MathJax related plugin, such as simple-mathjax.", 72 | "settings_mathJaxOutputTypeSVG": "SVG", 73 | "settings_MathJaxOutputTypeSVGDesc": "Convert MathJax to SVG. Browser render SVG, no plugin needed for WordPress.", 74 | "settings_commentConvertMode": "Comment convert", 75 | "settings_commentConvertModeDesc": "Select how to convert Obsidian notes comments.", 76 | "settings_commentConvertModeIgnore": "Ignore", 77 | "settings_commentConvertModeIgnoreDesc": "Just ignore all comments and convert comments to empty.", 78 | "settings_commentConvertModeHTML": "HTML", 79 | "settings_commentConvertModeHTMLDesc": "Convert Obsidian notes comments to HTML comments.", 80 | "settings_enableHtml": "Enable HTML", 81 | "settings_enableHtmlDesc": "Enable HTML tags in notes. This might cause XSS attack to your WordPress.", 82 | "settings_replaceMediaLinks": "Replace media links", 83 | "settings_replaceMediaLinksDesc": "Replace media links to WordPress URLs after uploading.", 84 | "loginModal_title": "WordPress Login", 85 | "loginModal_username": "Username", 86 | "loginModal_usernameDesc": "Username for <%= url %>", 87 | "loginModal_password": "Password", 88 | "loginModal_passwordDesc": "Password for <%= url %>", 89 | "loginModal_rememberUsername": "Remember Username", 90 | "loginModal_rememberUsernameDesc": "If enabled, the WordPress username you typed will be saved in local data. This might be disclosure in synchronize services.", 91 | "loginModal_rememberPassword": "Remember Password", 92 | "loginModal_rememberPasswordDesc": "If enabled, the WordPress password you typed will be saved in local data. This might be disclosure in synchronize services.", 93 | "loginModal_loginButtonText": "Login", 94 | "publishModal_title": "Publish to WordPress", 95 | "publishModal_postStatus": "Post Status", 96 | "publishModal_postStatusDraft": "Draft", 97 | "publishModal_postStatusPublish": "Publish", 98 | "publishModal_postStatusPrivate": "Private", 99 | "publishModal_postStatusFuture": "Future", 100 | "publishModal_postDateTime": "Post Date", 101 | "publishModal_postDateTimeDesc": "With format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS", 102 | "publishModal_commentStatus": "Comment Status", 103 | "publishModal_commentStatusOpen": "Open", 104 | "publishModal_commentStatusClosed": "Closed", 105 | "publishModal_category": "Category", 106 | "publishModal_postType": "Post Type", 107 | "publishModal_publishButtonText": "Publish", 108 | "publishModal_wrongMatterDataForPage": "There are tags or categories in matter-data which are not allowed for page. Are you sure to delete these data?", 109 | "publishedModal_title": "Post published successfully!", 110 | "publishedModal_confirmEditInWP": "Do you want to open WordPress edit page in browser?", 111 | "publishedModal_cancel": "Cancel", 112 | "publishedModal_open": "Open", 113 | "profilesManageModal_setDefault": "Set Default", 114 | "profilesManageModal_showDetails": "Edit", 115 | "profilesManageModal_deleteTooltip": "Delete", 116 | "profilesManageModal_title": "Profiles", 117 | "profilesManageModal_create": "Create", 118 | "profilesManageModal_createDesc": "Creates a new WordPress profile.", 119 | "profileModal_title": "Profile", 120 | "profileModal_Save": "Save", 121 | "profileModal_name": "Name", 122 | "profileModal_nameDesc": "WordPress name of this profile.", 123 | "profileModal_rememberUsername": "Remember Username", 124 | "profileModal_rememberPassword": "Remember Password", 125 | "profileModal_setDefault": "Set Default", 126 | "profilesChooserModal_title": "Profiles", 127 | "profilesChooserModal_pickOne": "Click to pick one profile to publish.", 128 | "profiles_default": "Default Profile", 129 | "profileNotMatch_useOld": "Use \"<%= profileName %>\" in the note", 130 | "profileNotMatch_useNew": "Create a new post using \"<%= profileName %>\"" 131 | } 132 | -------------------------------------------------------------------------------- /src/i18n/langs.ts: -------------------------------------------------------------------------------- 1 | import * as en from './en.json'; 2 | import * as zh_cn from './zh-cn.json'; 3 | 4 | export const LANGUAGES = { 5 | en, 6 | zh_cn 7 | }; 8 | -------------------------------------------------------------------------------- /src/i18n/zh-cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "error_noEndpoint": "没有设置 WordPress URL", 3 | "error_notWpCom": "不是合法的 wordpress.com 地址,请检查", 4 | "error_noUsername": "没有设置用户名", 5 | "error_noPassword": "没有设置密码", 6 | "error_noProfile": "没有账号,请至少添加一个 WordPress 账号", 7 | "error_noProfileName": "没有设置账号名", 8 | "error_noSuchProfile": "账号 <%= profileName %> 不存在", 9 | "error_invalidUrl": "URL 格式错误", 10 | "error_invalidUser": "用户名或密码错误", 11 | "error_publishFailed": "文章发布失败\n<%= code %>: <%= message %>", 12 | "error_wpComAuthFailed": "WordPress 认证失败!\n<%= error %>: <%= desc %>", 13 | "error_invalidWpComToken": "非法的 wordpress.com 令牌", 14 | "error_cannotParseResponse": "无法识别 WordPress 服务器的响应", 15 | "error_noDefaultProfile": "无法找到默认账号", 16 | "error_profileNotMatch": "WordPress 账号不匹配,使用哪一个账号?", 17 | "error_noActiveFile": "没有打开的文档", 18 | "error_mediaUploadFailed": "媒体文件 <%= name %> 上传失败", 19 | "error_noEditorOrFile": "没有编辑器或文件", 20 | "message_publishSuccessfully": "文章发布成功", 21 | "message_publishFailed": "文章发布失败", 22 | "message_wpComTokenValidated": "Wordpress.com 令牌验证通过", 23 | "ribbon_iconTitle": "发布到 WordPress", 24 | "command_publish": "发布当前笔记", 25 | "command_publishWithDefault": "使用默认参数发布当前笔记", 26 | "common_back": "返回", 27 | "confirmModal_title": "需要确认", 28 | "confirmModal_cancel": "取消", 29 | "confirmModal_confirm": "确认", 30 | "settings_title": "WordPress 发布插件", 31 | "settings_profiles": "WordPress 账户", 32 | "settings_profilesDesc": "管理 WordPress 账户", 33 | "settings_profilesModal": "打开", 34 | "settings_url": "WordPress URL", 35 | "settings_urlDesc": "WordPress 完整路径,例如 https://example.com/wordpress", 36 | "settings_urlPlaceholder": "https://example.com/wordpress", 37 | "settings_apiType": "API 类型", 38 | "settings_apiTypeDesc": "选择需要使用的 API 类型", 39 | "settings_apiTypeXmlRpc": "XML-RPC", 40 | "settings_apiTypeXmlRpcDesc": "XML-RPC 协议默认开启,但某些托管站点可能会出于安全原因禁用", 41 | "settings_apiTypeRestMiniOrange": "REST API(由 miniOrange 提供验证)", 42 | "settings_apiTypeRestMiniOrangeDesc": "REST API 在 WordPress 4.7+ 默认开启。REST API 由第三方插件 miniOrange 提供验证。该插件需要在 WordPress 服务器安装并启用", 43 | "settings_apiTypeRestApplicationPasswords": "REST API(由应用程序密码提供验证)", 44 | "settings_apiTypeRestApplicationPasswordsDesc": "REST API 在 WordPress 4.7+ 默认开启,应用程序密码在 WordPress 5.6+ 引入", 45 | "settings_apiTypeRestWpComOAuth2": "REST API(wordpress.com)", 46 | "settings_apiTypeRestWpComOAuth2Desc": "专供 wordpress.com 使用的 REST API", 47 | "settings_showRibbonIcon": "在边侧栏显示图标", 48 | "settings_showRibbonIconDesc": "如果开启,边侧栏将显示插件图标", 49 | "settings_defaultPostStatus": "默认文章状态", 50 | "settings_defaultPostStatusDesc": "发布到 WordPress 的文章默认状态", 51 | "settings_defaultPostStatusDraft": "草稿", 52 | "settings_defaultPostStatusPublish": "正式发布", 53 | "settings_defaultPostStatusPrivate": "私有", 54 | "settings_rememberLastSelectedCategories": "记住上次选择的分类", 55 | "settings_rememberLastSelectedCategoriesDesc": "记住该站点上次发布时选择的分类", 56 | "settings_showWordPressEditPageModal": "显示 WordPress 编辑确认框", 57 | "settings_showWordPressEditPageModalDesc": "发布成功后弹出是否跳转到 WordPress 编辑页面的确认框", 58 | "settings_xmlRpcPath": "XML-RPC 路径", 59 | "settings_xmlRpcPathDesc": "XML-RPC 路径,默认值为 /xmlrpc.php", 60 | "settings_wpComOAuth2RefreshToken": "刷新 wordpress.com OAuth2 令牌", 61 | "settings_wpComOAuth2RefreshTokenDesc": "点击右侧按钮验证或刷新 wordpress.com OAuth2 令牌", 62 | "settings_wpComOAuth2ValidateTokenButtonText": "验证", 63 | "settings_wpComOAuth2RefreshTokenButtonText": "刷新", 64 | "settings_defaultPostComment": "默认评论状态", 65 | "settings_defaultPostCommentDesc": "发布到 WordPress 的文章评论默认状态", 66 | "settings_defaultPostCommentOpen": "开启", 67 | "settings_defaultPostCommentClosed": "关闭", 68 | "settings_mathJaxOutputType": "MathJax 输出格式", 69 | "settings_mathJaxOutputTypeDesc": "选择 MathJax 的输出格式", 70 | "settings_mathJaxOutputTypeTeX": "TeX", 71 | "settings_MathJaxOutputTypeTeXDesc": "将 MathJax 公式输出为 TeX 格式。WordPress 需要安装 MathJax 相关插件,例如 simple-mathjax", 72 | "settings_mathJaxOutputTypeSVG": "SVG", 73 | "settings_MathJaxOutputTypeSVGDesc": "将 MathJax 公式输出为 SVG 格式。浏览器可以直接显示 SVG 矢量图,WordPress 无需任何处理", 74 | "settings_commentConvertMode": "注释转换", 75 | "settings_commentConvertModeDesc": "选择如何处理笔记中的注释", 76 | "settings_commentConvertModeIgnore": "忽略", 77 | "settings_commentConvertModeIgnoreDesc": "忽略所有注释,将其转换为空白字符串", 78 | "settings_commentConvertModeHTML": "HTML", 79 | "settings_commentConvertModeHTMLDesc": "将笔记中的注释转换为 HTML 注释", 80 | "settings_enableHtml": "启用 HTML", 81 | "settings_enableHtmlDesc": "启用笔记中的 HTML 标签。这可能导致针对 WordPress 的 XSS 攻击", 82 | "settings_replaceMediaLinks": "替换媒体链接", 83 | "settings_replaceMediaLinksDesc": "上传成功之后,将文档中的路径替换为 WordPress 链接", 84 | "loginModal_title": "WordPress 登录", 85 | "loginModal_username": "用户名", 86 | "loginModal_usernameDesc": "<%= url %> 用户名", 87 | "loginModal_password": "密码", 88 | "loginModal_passwordDesc": "<%= url %> 密码", 89 | "loginModal_rememberUsername": "记住用户名", 90 | "loginModal_rememberUsernameDesc": "如果开启,WordPress 用户名会被保存在本地数据。在某些同步服务中可能导致泄露。", 91 | "loginModal_rememberPassword": "记住密码", 92 | "loginModal_rememberPasswordDesc": "如果开启,WordPress 密码会被保存在本地数据。在某些同步服务中可能导致泄露。", 93 | "loginModal_loginButtonText": "登录", 94 | "publishModal_title": "发布到 WordPress", 95 | "publishModal_postStatus": "文章状态", 96 | "publishModal_postStatusDraft": "草稿", 97 | "publishModal_postStatusPublish": "正式发布", 98 | "publishModal_postStatusPrivate": "私有", 99 | "publishModal_postStatusFuture": "定时", 100 | "publishModal_postDateTime": "时间", 101 | "publishModal_postDateTimeDesc": "格式 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS", 102 | "publishModal_commentStatus": "评论状态", 103 | "publishModal_commentStatusOpen": "开启", 104 | "publishModal_commentStatusClosed": "关闭", 105 | "publishModal_category": "分类", 106 | "publishModal_postType": "类型", 107 | "publishModal_publishButtonText": "发布", 108 | "publishModal_wrongMatterDataForPage": "元数据中包含了标签或分类,page 类型不允许该数据。确定要删除这些数据吗?", 109 | "publishedModal_title": "文章发布成功", 110 | "publishedModal_confirmEditInWP": "需要使用浏览器打开 WordPress 编辑页面吗?", 111 | "publishedModal_cancel": "取消", 112 | "publishedModal_open": "打开", 113 | "profilesManageModal_setDefault": "设为默认", 114 | "profilesManageModal_showDetails": "编辑", 115 | "profilesManageModal_deleteTooltip": "删除", 116 | "profilesManageModal_title": "WordPress 帐户", 117 | "profilesManageModal_create": "创建", 118 | "profilesManageModal_createDesc": "创建新的 WordPress 帐户", 119 | "profileModal_title": "WordPress 帐户", 120 | "profileModal_Save": "保存", 121 | "profileModal_name": "名称", 122 | "profileModal_nameDesc": "WordPress 账户名称", 123 | "profileModal_rememberUsername": "记住用户名", 124 | "profileModal_rememberPassword": "记住密码", 125 | "profileModal_setDefault": "设为默认", 126 | "profilesChooserModal_title": "WordPress 账户", 127 | "profilesChooserModal_pickOne": "点击选择一个需要发布到的 WordPress 账户", 128 | "profiles_default": "默认账户", 129 | "profileNotMatch_useOld": "使用笔记中的 \"<%= profileName %>\"", 130 | "profileNotMatch_useNew": "使用 \"<%= profileName %>\" 创建新的文章" 131 | } 132 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { addIcon } from 'obsidian'; 2 | 3 | const icons: Record = { 4 | 'wp-logo': ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ` 14 | }; 15 | 16 | export const addIcons = (): void => { 17 | Object.keys(icons).forEach((key) => { 18 | addIcon(key, icons[key]); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import { WordpressSettingTab } from './settings'; 3 | import { addIcons } from './icons'; 4 | import { WordPressPostParams } from './wp-client'; 5 | import { I18n } from './i18n'; 6 | import { EventType, WP_OAUTH2_REDIRECT_URI, WP_OAUTH2_URL_ACTION } from './consts'; 7 | import { OAuth2Client } from './oauth2-client'; 8 | import { CommentStatus, PostStatus, PostTypeConst } from './wp-api'; 9 | import { openProfileChooserModal } from './wp-profile-chooser-modal'; 10 | import { AppState } from './app-state'; 11 | import { DEFAULT_SETTINGS, SettingsVersion, upgradeSettings, WordpressPluginSettings } from './plugin-settings'; 12 | import { PassCrypto } from './pass-crypto'; 13 | import { doClientPublish, setupMarkdownParser, showError } from './utils'; 14 | import { cloneDeep } from 'lodash-es'; 15 | 16 | export default class WordpressPlugin extends Plugin { 17 | 18 | #settings: WordpressPluginSettings | undefined; 19 | get settings() { 20 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 21 | return this.#settings!; 22 | } 23 | 24 | #i18n: I18n | undefined; 25 | get i18n() { 26 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 27 | return this.#i18n!; 28 | } 29 | 30 | private ribbonWpIcon: HTMLElement | null = null; 31 | 32 | async onload() { 33 | console.log('loading obsidian-wordpress plugin'); 34 | 35 | await this.loadSettings(); 36 | // lang should be load early, but after settings 37 | this.#i18n = new I18n(this.#settings?.lang); 38 | 39 | setupMarkdownParser(this.settings); 40 | 41 | addIcons(); 42 | 43 | this.registerProtocolHandler(); 44 | this.updateRibbonIcon(); 45 | 46 | this.addCommand({ 47 | id: 'defaultPublish', 48 | name: this.#i18n.t('command_publishWithDefault'), 49 | editorCallback: () => { 50 | const defaultProfile = this.#settings?.profiles.find(it => it.isDefault); 51 | if (defaultProfile) { 52 | const params: WordPressPostParams = { 53 | status: this.#settings?.defaultPostStatus ?? PostStatus.Draft, 54 | commentStatus: this.#settings?.defaultCommentStatus ?? CommentStatus.Open, 55 | categories: defaultProfile.lastSelectedCategories ?? [ 1 ], 56 | postType: PostTypeConst.Post, 57 | tags: [], 58 | title: '', 59 | content: '' 60 | }; 61 | doClientPublish(this, defaultProfile, params); 62 | } else { 63 | showError(this.#i18n?.t('error_noDefaultProfile') ?? 'No default profile found.'); 64 | } 65 | } 66 | }); 67 | 68 | this.addCommand({ 69 | id: 'publish', 70 | name: this.#i18n.t('command_publish'), 71 | editorCallback: () => { 72 | this.openProfileChooser(); 73 | } 74 | }); 75 | 76 | this.addSettingTab(new WordpressSettingTab(this)); 77 | } 78 | 79 | onunload() { 80 | } 81 | 82 | async loadSettings() { 83 | this.#settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 84 | const { needUpgrade, settings } = await upgradeSettings(this.#settings, SettingsVersion.V2); 85 | this.#settings = settings; 86 | if (needUpgrade) { 87 | await this.saveSettings(); 88 | } 89 | 90 | const crypto = new PassCrypto(); 91 | const count = this.#settings?.profiles.length ?? 0; 92 | for (let i = 0; i < count; i++) { 93 | const profile = this.#settings?.profiles[i]; 94 | const enPass = profile.encryptedPassword; 95 | if (enPass) { 96 | profile.password = await crypto.decrypt(enPass.encrypted, enPass.key, enPass.vector); 97 | } 98 | } 99 | 100 | AppState.markdownParser.set({ 101 | html: this.#settings?.enableHtml ?? false 102 | }); 103 | } 104 | 105 | async saveSettings() { 106 | const settings = cloneDeep(this.settings); 107 | for (let i = 0; i < settings.profiles.length; i++) { 108 | const profile = settings.profiles[i]; 109 | const password = profile.password; 110 | if (password) { 111 | const crypto = new PassCrypto(); 112 | profile.encryptedPassword = await crypto.encrypt(password); 113 | delete profile.password; 114 | } 115 | } 116 | await this.saveData(settings); 117 | } 118 | 119 | updateRibbonIcon(): void { 120 | const ribbonIconTitle = this.#i18n?.t('ribbon_iconTitle') ?? 'WordPress'; 121 | if (this.#settings?.showRibbonIcon) { 122 | if (!this.ribbonWpIcon) { 123 | this.ribbonWpIcon = this.addRibbonIcon('wp-logo', ribbonIconTitle, () => { 124 | this.openProfileChooser(); 125 | }); 126 | } 127 | } else { 128 | if (this.ribbonWpIcon) { 129 | this.ribbonWpIcon.remove(); 130 | this.ribbonWpIcon = null; 131 | } 132 | } 133 | } 134 | 135 | private async openProfileChooser() { 136 | if (this.settings.profiles.length === 1) { 137 | doClientPublish(this, this.settings.profiles[0]); 138 | } else if (this.settings.profiles.length > 1) { 139 | const profile = await openProfileChooserModal(this); 140 | doClientPublish(this, profile); 141 | } else { 142 | showError(this.i18n.t('error_noProfile')); 143 | } 144 | } 145 | 146 | private registerProtocolHandler(): void { 147 | this.registerObsidianProtocolHandler(WP_OAUTH2_URL_ACTION, async (e) => { 148 | if (e.action === WP_OAUTH2_URL_ACTION) { 149 | if (e.state) { 150 | if (e.error) { 151 | showError(this.i18n.t('error_wpComAuthFailed', { 152 | error: e.error, 153 | desc: e.error_description.replace(/\+/g,' ') 154 | })); 155 | AppState.events.trigger(EventType.OAUTH2_TOKEN_GOT, undefined); 156 | } else if (e.code) { 157 | const token = await OAuth2Client.getWpOAuth2Client(this).getToken({ 158 | code: e.code, 159 | redirectUri: WP_OAUTH2_REDIRECT_URI, 160 | codeVerifier: AppState.codeVerifier 161 | }); 162 | console.log(token); 163 | AppState.events.trigger(EventType.OAUTH2_TOKEN_GOT, token); 164 | } 165 | } 166 | } 167 | }); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /src/markdown-it-comment-plugin.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import { CommentConvertMode } from './plugin-settings'; 3 | 4 | const tokenType = 'ob_comment'; 5 | 6 | interface MarkdownItCommentPluginOptions { 7 | convertMode: CommentConvertMode; 8 | } 9 | 10 | const pluginOptions: MarkdownItCommentPluginOptions = { 11 | convertMode: CommentConvertMode.Ignore, 12 | } 13 | 14 | export const MarkdownItCommentPluginInstance = { 15 | plugin: plugin, 16 | updateConvertMode: (mode: CommentConvertMode) => { 17 | pluginOptions.convertMode = mode; 18 | }, 19 | } 20 | 21 | function plugin(md: MarkdownIt): void { 22 | md.inline.ruler.before('emphasis', tokenType, (state, silent) => { 23 | const start = state.pos; 24 | const max = state.posMax; 25 | const src = state.src; 26 | 27 | // check if start with %% 28 | if (src.charCodeAt(start) !== 0x25 /* % */ || start + 4 >= max) { 29 | return false; 30 | } 31 | if (src.charCodeAt(start + 1) !== 0x25 /* % */) { 32 | return false; 33 | } 34 | 35 | // find ended %% 36 | let end = start + 2; 37 | while (end < max && (src.charCodeAt(end) !== 0x25 /* % */ || src.charCodeAt(end + 1) !== 0x25 /* % */)) { 38 | end++; 39 | } 40 | 41 | if (end >= max) { 42 | return false; 43 | } 44 | 45 | end += 2; // skip ended %% 46 | 47 | if (!silent) { 48 | const token = state.push(tokenType, 'comment', 0); 49 | token.content = src.slice(start + 2, end - 2).trim(); 50 | state.pos = end; 51 | return true; 52 | } 53 | 54 | state.pos = end; 55 | return true; 56 | }); 57 | 58 | md.renderer.rules[tokenType] = (tokens, idx) => { 59 | if (pluginOptions.convertMode === CommentConvertMode.HTML) { 60 | return ``; 61 | } else { 62 | return ''; 63 | } 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/markdown-it-image-plugin.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import Token from 'markdown-it/lib/token'; 3 | import { trim } from 'lodash-es'; 4 | 5 | 6 | const tokenType = 'ob_img'; 7 | 8 | export interface MarkdownItImageActionParams { 9 | src: string; 10 | width?: string; 11 | height?: string; 12 | } 13 | 14 | interface MarkdownItImagePluginOptions { 15 | doWithImage: (img: MarkdownItImageActionParams) => void; 16 | } 17 | 18 | const pluginOptions: MarkdownItImagePluginOptions = { 19 | doWithImage: () => {}, 20 | } 21 | 22 | export const MarkdownItImagePluginInstance = { 23 | plugin: plugin, 24 | doWithImage: (action: (img: MarkdownItImageActionParams) => void) => { 25 | pluginOptions.doWithImage = action; 26 | }, 27 | } 28 | 29 | function plugin(md: MarkdownIt): void { 30 | md.inline.ruler.after('image', tokenType, (state, silent) => { 31 | const regex = /^!\[\[([^|\]\n]+)(\|([^\]\n]+))?\]\]/; 32 | const match = state.src.slice(state.pos).match(regex); 33 | if (match) { 34 | if (silent) { 35 | return true; 36 | } 37 | const token = state.push(tokenType, 'img', 0); 38 | const matched = match[0]; 39 | const src = match[1]; 40 | const size = match[3]; 41 | let width: string | undefined; 42 | let height: string | undefined; 43 | if (size) { 44 | const sepIndex = size.indexOf('x'); // width x height 45 | if (sepIndex > 0) { 46 | width = trim(size.substring(0, sepIndex)); 47 | height = trim(size.substring(sepIndex + 1)); 48 | token.attrs = [ 49 | [ 'src', src ], 50 | [ 'width', width ], 51 | [ 'height', height ], 52 | ]; 53 | } else { 54 | width = trim(size); 55 | token.attrs = [ 56 | [ 'src', src ], 57 | [ 'width', width ], 58 | ]; 59 | } 60 | } else { 61 | token.attrs = [ 62 | [ 'src', src ], 63 | ]; 64 | } 65 | if (pluginOptions.doWithImage) { 66 | pluginOptions.doWithImage({ 67 | src: token.attrs?.[0]?.[1], 68 | width: token.attrs?.[1]?.[1], 69 | height: token.attrs?.[2]?.[1], 70 | }); 71 | } 72 | state.pos += matched.length; 73 | return true; 74 | } else { 75 | return false; 76 | } 77 | }); 78 | md.renderer.rules.ob_img = (tokens: Token[], idx: number) => { 79 | const token = tokens[idx]; 80 | const src = token.attrs?.[0]?.[1]; 81 | const width = token.attrs?.[1]?.[1]; 82 | const height = token.attrs?.[2]?.[1]; 83 | if (width) { 84 | if (height) { 85 | return ``; 86 | } 87 | return ``; 88 | } else { 89 | return ``; 90 | } 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/markdown-it-mathjax3-plugin.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import StateInline from 'markdown-it/lib/rules_inline/state_inline'; 3 | import StateBlock from 'markdown-it/lib/rules_block/state_block'; 4 | import { TeX } from 'mathjax-full/js/input/tex'; 5 | import { AllPackages } from 'mathjax-full/js/input/tex/AllPackages'; 6 | import { SVG } from 'mathjax-full/js/output/svg'; 7 | import Token from 'markdown-it/lib/token'; 8 | import { liteAdaptor } from 'mathjax-full/js/adaptors/liteAdaptor'; 9 | import { RegisterHTMLHandler } from 'mathjax-full/js/handlers/html'; 10 | import { AssistiveMmlHandler } from 'mathjax-full/js/a11y/assistive-mml'; 11 | import { mathjax } from 'mathjax-full/js/mathjax'; 12 | import juice from 'juice'; 13 | import { SafeAny } from './utils'; 14 | import { MathJaxOutputType } from './plugin-settings'; 15 | 16 | const inlineTokenType = 'math_inline'; 17 | const blockTokenType = 'math_block'; 18 | 19 | interface MarkdownItMathJax3PluginOptions { 20 | outputType: MathJaxOutputType; 21 | } 22 | 23 | const pluginOptions: MarkdownItMathJax3PluginOptions = { 24 | outputType: MathJaxOutputType.TeX, 25 | } 26 | 27 | interface ConvertOptions { 28 | display: boolean 29 | } 30 | 31 | export const MarkdownItMathJax3PluginInstance = { 32 | plugin: plugin, 33 | updateOutputType: (type: MathJaxOutputType) => { 34 | pluginOptions.outputType = type; 35 | }, 36 | } 37 | 38 | function plugin(md: MarkdownIt): void { 39 | // set MathJax as the renderer for markdown-it-simplemath 40 | md.inline.ruler.after('escape', inlineTokenType, mathInline); 41 | md.block.ruler.after('blockquote', blockTokenType, mathBlock, { 42 | alt: ['paragraph', 'reference', 'blockquote', 'list'], 43 | }); 44 | md.renderer.rules[inlineTokenType] = (tokens: Token[], idx: number) => { 45 | return renderMath(tokens[idx].content, { 46 | display: false 47 | }); 48 | }; 49 | md.renderer.rules[blockTokenType] = (tokens: Token[], idx: number) => { 50 | return renderMath(tokens[idx].content, { 51 | display: true 52 | }); 53 | }; 54 | } 55 | 56 | function renderMath(content: string, convertOptions: ConvertOptions): string { 57 | if (pluginOptions.outputType === MathJaxOutputType.SVG) { 58 | const documentOptions = { 59 | InputJax: new TeX({ packages: AllPackages }), 60 | OutputJax: new SVG({ fontCache: 'none' }) 61 | }; 62 | const adaptor = liteAdaptor(); 63 | const handler = RegisterHTMLHandler(adaptor); 64 | AssistiveMmlHandler(handler); 65 | const mathDocument = mathjax.document(content, documentOptions); 66 | const html = adaptor.outerHTML(mathDocument.convert(content, convertOptions)); 67 | const stylesheet = adaptor.outerHTML(documentOptions.OutputJax.styleSheet(mathDocument) as SafeAny); 68 | return juice(html + stylesheet); 69 | } else { 70 | if (convertOptions.display) { 71 | return `$$\n${content}$$\n`; 72 | } else { 73 | return `$${content}$`; 74 | } 75 | } 76 | } 77 | 78 | // Test if potential opening or closing delimiter 79 | // Assumes that there is a '$' at state.src[pos] 80 | function isValidDelimiter(state: StateInline, pos: number) { 81 | const max = state.posMax; 82 | let canOpen = true; 83 | let canClose = true; 84 | 85 | const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1; 86 | const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1; 87 | 88 | // Check non-whitespace conditions for opening and closing, and 89 | // check that closing delimiter isn't followed by a number 90 | if (prevChar === 0x20 /* ' ' */ 91 | || prevChar === 0x09 /* \t */ 92 | || (nextChar >= 0x30 /* '0' */ && nextChar <= 0x39) /* '9' */ 93 | ) { 94 | canClose = false; 95 | } 96 | if (nextChar === 0x20 /* ' ' */ || nextChar === 0x09 /* \t */) { 97 | canOpen = false; 98 | } 99 | 100 | return { 101 | canOpen, 102 | canClose 103 | }; 104 | } 105 | 106 | function mathInline(state: StateInline, silent: boolean) { 107 | if (state.src[state.pos] !== '$') { 108 | return false; 109 | } 110 | 111 | let res = isValidDelimiter(state, state.pos); 112 | if (!res.canOpen) { 113 | if (!silent) { 114 | state.pending += '$'; 115 | } 116 | state.pos += 1; 117 | return true; 118 | } 119 | 120 | // First check for and bypass all properly escaped delimiters 121 | // This loop will assume that the first leading backtick can not 122 | // be the first character in state.src, which is known since 123 | // we have found an opening delimiter already. 124 | const start = state.pos + 1; 125 | let match = start; 126 | while ((match = state.src.indexOf('$', match)) !== -1) { 127 | // Found potential $, look for escapes, pos will point to 128 | // first non escape when complete 129 | let pos = match - 1; 130 | while (state.src[pos] === '\\') { 131 | pos -= 1; 132 | } 133 | 134 | // Even number of escapes, potential closing delimiter found 135 | if ((match - pos) % 2 == 1) { 136 | break; 137 | } 138 | match += 1; 139 | } 140 | 141 | // No closing delimter found. Consume $ and continue. 142 | if (match === -1) { 143 | if (!silent) { 144 | state.pending += '$'; 145 | } 146 | state.pos = start; 147 | return true; 148 | } 149 | 150 | // Check if we have empty content, ie: $$. Do not parse. 151 | if (match - start === 0) { 152 | if (!silent) { 153 | state.pending += '$$'; 154 | } 155 | state.pos = start + 1; 156 | return true; 157 | } 158 | 159 | // Check for valid closing delimiter 160 | res = isValidDelimiter(state, match); 161 | if (!res.canClose) { 162 | if (!silent) { 163 | state.pending += '$'; 164 | } 165 | state.pos = start; 166 | return true; 167 | } 168 | 169 | if (!silent) { 170 | const token = state.push(inlineTokenType, 'math', 0); 171 | token.markup = '$'; 172 | token.content = state.src.slice(start, match); 173 | } 174 | 175 | state.pos = match + 1; 176 | return true; 177 | } 178 | 179 | function mathBlock(state: StateBlock, start: number, end: number, silent: boolean) { 180 | let next: number; 181 | let lastPos: number; 182 | let found = false; 183 | let pos = state.bMarks[start] + state.tShift[start]; 184 | let max = state.eMarks[start]; 185 | let lastLine = ''; 186 | 187 | if (pos + 2 > max) { 188 | return false; 189 | } 190 | if (state.src.slice(pos, pos + 2) !== '$$') { 191 | return false; 192 | } 193 | 194 | pos += 2; 195 | let firstLine = state.src.slice(pos, max); 196 | 197 | if (silent) { 198 | return true; 199 | } 200 | if (firstLine.trim().slice(-2) === '$$') { 201 | // Single line expression 202 | firstLine = firstLine.trim().slice(0, -2); 203 | found = true; 204 | } 205 | 206 | for (next = start; !found; ) { 207 | next++; 208 | 209 | if (next >= end) { 210 | break; 211 | } 212 | 213 | pos = state.bMarks[next] + state.tShift[next]; 214 | max = state.eMarks[next]; 215 | 216 | if (pos < max && state.tShift[next] < state.blkIndent) { 217 | // non-empty line with negative indent should stop the list: 218 | break; 219 | } 220 | 221 | if (state.src.slice(pos, max).trim().slice(-2) === '$$') { 222 | lastPos = state.src.slice(0, max).lastIndexOf('$$'); 223 | lastLine = state.src.slice(pos, lastPos); 224 | found = true; 225 | } 226 | } 227 | 228 | state.line = next + 1; 229 | 230 | const token = state.push(blockTokenType, 'math', 0); 231 | token.block = true; 232 | token.content = 233 | (firstLine && firstLine.trim() ? firstLine + '\n' : '') + 234 | state.getLines(start + 1, next, state.tShift[start], true) + 235 | (lastLine && lastLine.trim() ? lastLine : ''); 236 | token.map = [start, state.line]; 237 | token.markup = '$$'; 238 | return true; 239 | } 240 | -------------------------------------------------------------------------------- /src/oauth2-client.ts: -------------------------------------------------------------------------------- 1 | import { generateQueryString, openWithBrowser } from './utils'; 2 | import { requestUrl } from 'obsidian'; 3 | import { WordPressClientResult, WordPressClientReturnCode } from './wp-client'; 4 | import WordpressPlugin from './main'; 5 | import { 6 | WP_OAUTH2_AUTHORIZE_ENDPOINT, 7 | WP_OAUTH2_CLIENT_ID, 8 | WP_OAUTH2_CLIENT_SECRET, 9 | WP_OAUTH2_TOKEN_ENDPOINT, 10 | WP_OAUTH2_VALIDATE_TOKEN_ENDPOINT 11 | } from './consts'; 12 | 13 | export interface OAuth2Token { 14 | accessToken: string; 15 | } 16 | 17 | export interface WordPressOAuth2Token extends OAuth2Token { 18 | tokenType: string; 19 | blogId: string; 20 | blogUrl: string; 21 | scope: string; 22 | } 23 | 24 | export interface GetAuthorizeCodeParams { 25 | redirectUri: string; 26 | scope?: string[]; 27 | blog?: string; 28 | codeVerifier?: string; 29 | } 30 | 31 | export interface GetTokenParams { 32 | code: string; 33 | redirectUri: string; 34 | codeVerifier?: string; 35 | } 36 | 37 | export interface ValidateTokenParams { 38 | token: string; 39 | } 40 | 41 | export interface OAuth2Options { 42 | clientId: string; 43 | clientSecret: string; 44 | tokenEndpoint: string; 45 | authorizeEndpoint: string; 46 | validateTokenEndpoint?: string; 47 | } 48 | 49 | export class OAuth2Client { 50 | 51 | static getWpOAuth2Client(plugin: WordpressPlugin): OAuth2Client { 52 | return new OAuth2Client({ 53 | clientId: WP_OAUTH2_CLIENT_ID, 54 | clientSecret: WP_OAUTH2_CLIENT_SECRET, 55 | tokenEndpoint: WP_OAUTH2_TOKEN_ENDPOINT, 56 | authorizeEndpoint: WP_OAUTH2_AUTHORIZE_ENDPOINT, 57 | validateTokenEndpoint: WP_OAUTH2_VALIDATE_TOKEN_ENDPOINT 58 | }, plugin); 59 | } 60 | 61 | constructor( 62 | private readonly options: OAuth2Options, 63 | private readonly plugin: WordpressPlugin 64 | ) { 65 | console.log(options); 66 | } 67 | 68 | async getAuthorizeCode(params: GetAuthorizeCodeParams): Promise { 69 | const query: { 70 | client_id: string; 71 | response_type: 'code'; 72 | redirect_uri: string; 73 | code_challenge_method?: 'plain' | 'S256'; 74 | code_challenge?: string; 75 | blog?: string; 76 | scope?: string; 77 | } = { 78 | client_id: this.options.clientId, 79 | response_type: 'code', 80 | redirect_uri: params.redirectUri, 81 | blog: params.blog, 82 | scope: undefined 83 | }; 84 | if (params.scope) { 85 | query.scope = params.scope.join(' '); 86 | } 87 | if (params.codeVerifier) { 88 | const codeChallenge = await getCodeChallenge(params.codeVerifier); 89 | query.code_challenge_method = codeChallenge?.[0]; 90 | query.code_challenge = codeChallenge?.[1]; 91 | } 92 | openWithBrowser(this.options.authorizeEndpoint, query); 93 | } 94 | 95 | getToken(params: GetTokenParams): Promise { 96 | const body: { 97 | grant_type: 'authorization_code'; 98 | client_id: string; 99 | code: string; 100 | redirect_uri: string; 101 | } = { 102 | grant_type: 'authorization_code', 103 | client_id: this.options.clientId, 104 | code: params.code, 105 | redirect_uri: params.redirectUri 106 | }; 107 | return requestUrl({ 108 | url: this.options.tokenEndpoint, 109 | method: 'POST', 110 | headers: { 111 | 'Content-Type': 'application/x-www-form-urlencoded', 112 | 'User-Agent': 'obsidian.md' 113 | }, 114 | body: generateQueryString(body) 115 | }) 116 | .then(response => { 117 | console.log('getToken response', response); 118 | const resp = response.json; 119 | return { 120 | accessToken: resp.access_token, 121 | tokenType: resp.token_type, 122 | blogId: resp.blog_id, 123 | blogUrl: resp.blog_url, 124 | scope: resp.scope 125 | }; 126 | }); 127 | } 128 | 129 | async validateToken(params: ValidateTokenParams): Promise> { 130 | if (!this.options.validateTokenEndpoint) { 131 | throw new Error('No validate token endpoint set.'); 132 | } 133 | try { 134 | const response = await requestUrl({ 135 | url: `${this.options.validateTokenEndpoint}?client_id=${this.options.clientId}&token=${params.token}`, 136 | method: 'GET', 137 | headers: { 138 | 'Content-Type': 'application/json', 139 | 'User-Agent': 'obsidian.md' 140 | } 141 | }); 142 | console.log('validateToken response', response); 143 | return { 144 | code: WordPressClientReturnCode.OK, 145 | data: 'done', 146 | response 147 | }; 148 | } catch (error) { 149 | return { 150 | code: WordPressClientReturnCode.Error, 151 | error: { 152 | code: WordPressClientReturnCode.Error, 153 | message: this.plugin.i18n.t('error_invalidWpComToken'), 154 | }, 155 | response: error 156 | }; 157 | } 158 | } 159 | } 160 | 161 | export function generateCodeVerifier(): string { 162 | const arr = new Uint8Array(32); 163 | crypto.getRandomValues(arr); 164 | return base64Url(arr); 165 | } 166 | 167 | async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> { 168 | return ['S256', base64Url(await crypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))]; 169 | } 170 | 171 | function stringToBuffer(input: string): ArrayBuffer { 172 | const buf = new Uint8Array(input.length); 173 | for(let i = 0; i < input.length; i++) { 174 | buf[i] = input.charCodeAt(i) & 0xFF; 175 | } 176 | return buf; 177 | } 178 | 179 | function base64Url(buf: ArrayBuffer): string { 180 | return btoa(String.fromCharCode(...new Uint8Array(buf))) 181 | .replace(/\+/g, '-') 182 | .replace(/\//g, '_') 183 | .replace(/=+$/, ''); 184 | } 185 | -------------------------------------------------------------------------------- /src/pass-crypto.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isNil } from 'lodash-es'; 2 | 3 | const AES_GCM = 'AES-GCM'; 4 | const FORMAT_JWK = 'jwk'; 5 | 6 | export class PassCrypto { 7 | 8 | constructor() { } 9 | 10 | canUse(): boolean { 11 | return !isNil(crypto) 12 | && !isNil(crypto.subtle) 13 | && isFunction(crypto.getRandomValues) 14 | && isFunction(crypto.subtle.generateKey) 15 | && isFunction(crypto.subtle.encrypt) 16 | && isFunction(crypto.subtle.decrypt) 17 | && isFunction(crypto.subtle.importKey) 18 | && isFunction(crypto.subtle.exportKey); 19 | } 20 | 21 | async encrypt(message: string): Promise<{ encrypted: string, key?: string, vector?: string }> { 22 | if (this.canUse()) { 23 | const vector = crypto.getRandomValues(new Uint8Array(12)); 24 | const key = await crypto.subtle.generateKey({ 25 | name: AES_GCM, 26 | length: 256 27 | }, 28 | true, 29 | [ 'encrypt', 'decrypt' ]); 30 | const encrypted = await crypto.subtle.encrypt({ 31 | name: AES_GCM, 32 | iv: vector 33 | }, 34 | key, 35 | new TextEncoder().encode(message)); 36 | const exportedKey = await crypto.subtle.exportKey(FORMAT_JWK, key); 37 | return { 38 | key: JSON.stringify(exportedKey), 39 | vector: this.bufferToBase64(vector), 40 | encrypted: this.bufferToBase64(encrypted) 41 | }; 42 | } else { 43 | return { 44 | encrypted: this.reverseString(this.stringToBase64(this.reverseString(message))) 45 | }; 46 | } 47 | } 48 | 49 | async decrypt(encrypted: string, key?: string, vector?: string): Promise { 50 | if (this.canUse()) { 51 | if (key && vector) { 52 | const keyObject = JSON.parse(key); 53 | const thisKey = await crypto.subtle.importKey(FORMAT_JWK, keyObject, { 54 | name: AES_GCM 55 | }, 56 | false, 57 | [ 'encrypt', 'decrypt' ]); 58 | const decrypted = await crypto.subtle.decrypt({ 59 | name: AES_GCM, 60 | iv: this.base64ToBuffer(vector) 61 | }, 62 | thisKey, 63 | this.base64ToBuffer(encrypted)); 64 | return new TextDecoder().decode(decrypted); 65 | } 66 | return 'xx'; 67 | } else { 68 | return this.reverseString(this.base64ToString(this.reverseString(encrypted))); 69 | } 70 | } 71 | 72 | private bufferToBase64(buffer: ArrayBuffer): string { 73 | let result = ''; 74 | new Uint8Array(buffer).forEach(b => result += String.fromCharCode(b)); 75 | return btoa(result); 76 | } 77 | 78 | private base64ToBuffer(base64: string): ArrayBuffer { 79 | const binaryString = atob(base64); 80 | const len = binaryString.length; 81 | const bytes = new Uint8Array(len); 82 | for (let i = 0; i < len; i++) { 83 | bytes[i] = binaryString.charCodeAt(i); 84 | } 85 | return bytes.buffer; 86 | } 87 | 88 | private reverseString(str: string): string { 89 | return str.split('').reverse().join(''); 90 | } 91 | 92 | private stringToBase64(str: string): string { 93 | return btoa(str); 94 | } 95 | 96 | private base64ToString(base64: string): string { 97 | return atob(base64); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/plugin-settings.ts: -------------------------------------------------------------------------------- 1 | import { LanguageWithAuto } from './i18n'; 2 | import { WpProfile } from './wp-profile'; 3 | import { CommentStatus, PostStatus } from './wp-api'; 4 | import { isNil, isUndefined } from 'lodash-es'; 5 | import { SafeAny } from './utils'; 6 | import { PassCrypto } from './pass-crypto'; 7 | import { WP_DEFAULT_PROFILE_NAME } from './consts'; 8 | 9 | 10 | export const enum SettingsVersion { 11 | V2 = '2' 12 | } 13 | 14 | export const enum ApiType { 15 | XML_RPC = 'xml-rpc', 16 | RestAPI_miniOrange = 'miniOrange', 17 | RestApi_ApplicationPasswords = 'application-passwords', 18 | RestApi_WpComOAuth2 = 'WpComOAuth2' 19 | } 20 | 21 | export const enum MathJaxOutputType { 22 | TeX = 'tex', 23 | SVG = 'svg' 24 | } 25 | 26 | export const enum CommentConvertMode { 27 | Ignore = 'ignore', 28 | HTML = 'html' 29 | } 30 | 31 | export interface WordpressPluginSettings { 32 | 33 | version?: SettingsVersion; 34 | 35 | /** 36 | * Plugin language. 37 | */ 38 | lang: LanguageWithAuto; 39 | 40 | profiles: WpProfile[]; 41 | 42 | /** 43 | * Show plugin icon in side. 44 | */ 45 | showRibbonIcon: boolean; 46 | 47 | /** 48 | * Default post status. 49 | */ 50 | defaultPostStatus: PostStatus; 51 | 52 | /** 53 | * Default comment status. 54 | */ 55 | defaultCommentStatus: CommentStatus; 56 | 57 | /** 58 | * Remember last selected post categories. 59 | */ 60 | rememberLastSelectedCategories: boolean; 61 | 62 | /** 63 | * If WordPress edit confirm modal will be shown when published successfully. 64 | */ 65 | showWordPressEditConfirm: boolean; 66 | 67 | mathJaxOutputType: MathJaxOutputType; 68 | 69 | commentConvertMode: CommentConvertMode; 70 | 71 | enableHtml: boolean; 72 | 73 | /** 74 | * Whether media links should be replaced after uploading to WordPress. 75 | */ 76 | replaceMediaLinks: boolean; 77 | } 78 | 79 | export const DEFAULT_SETTINGS: WordpressPluginSettings = { 80 | lang: 'auto', 81 | profiles: [], 82 | showRibbonIcon: false, 83 | defaultPostStatus: PostStatus.Draft, 84 | defaultCommentStatus: CommentStatus.Open, 85 | rememberLastSelectedCategories: true, 86 | showWordPressEditConfirm: false, 87 | mathJaxOutputType: MathJaxOutputType.SVG, 88 | commentConvertMode: CommentConvertMode.Ignore, 89 | enableHtml: false, 90 | replaceMediaLinks: true, 91 | } 92 | 93 | export async function upgradeSettings( 94 | existingSettings: SafeAny, 95 | to: SettingsVersion 96 | ): Promise<{ needUpgrade: boolean, settings: WordpressPluginSettings }> { 97 | console.log(existingSettings, to); 98 | if (isUndefined(existingSettings.version)) { 99 | // V1 100 | if (to === SettingsVersion.V2) { 101 | const newSettings: WordpressPluginSettings = Object.assign({}, DEFAULT_SETTINGS, { 102 | version: SettingsVersion.V2, 103 | lang: existingSettings.lang, 104 | showRibbonIcon: existingSettings.showRibbonIcon, 105 | defaultPostStatus: existingSettings.defaultPostStatus, 106 | defaultCommentStatus: existingSettings.defaultCommentStatus, 107 | defaultPostType: 'post', 108 | rememberLastSelectedCategories: existingSettings.rememberLastSelectedCategories, 109 | showWordPressEditConfirm: existingSettings.showWordPressEditConfirm, 110 | mathJaxOutputType: existingSettings.mathJaxOutputType, 111 | commentConvertMode: existingSettings.commentConvertMode, 112 | }); 113 | if (existingSettings.endpoint) { 114 | const endpoint = existingSettings.endpoint; 115 | const apiType = existingSettings.apiType; 116 | const xmlRpcPath = existingSettings.xmlRpcPath; 117 | const username = existingSettings.username; 118 | const password = existingSettings.password; 119 | const lastSelectedCategories = existingSettings.lastSelectedCategories; 120 | const crypto = new PassCrypto(); 121 | const encryptedPassword = await crypto.encrypt(password); 122 | const profile = { 123 | name: WP_DEFAULT_PROFILE_NAME, 124 | apiType: apiType, 125 | endpoint: endpoint, 126 | xmlRpcPath: xmlRpcPath, 127 | saveUsername: !isNil(username), 128 | savePassword: !isNil(password), 129 | isDefault: true, 130 | lastSelectedCategories: lastSelectedCategories, 131 | username: username, 132 | encryptedPassword: encryptedPassword 133 | }; 134 | newSettings.profiles = [ 135 | profile 136 | ]; 137 | } else { 138 | newSettings.profiles = []; 139 | } 140 | return { 141 | needUpgrade: true, 142 | settings: newSettings 143 | }; 144 | } 145 | } 146 | return { 147 | needUpgrade: false, 148 | settings: existingSettings 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /src/post-published-modal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { TranslateKey } from './i18n'; 4 | 5 | 6 | export function openPostPublishedModal( 7 | plugin: WordpressPlugin, 8 | ): Promise { 9 | return new Promise((resolve, reject) => { 10 | new PostPublishedModal(plugin, (modal) => { 11 | resolve(); 12 | modal.close(); 13 | }); 14 | }); 15 | } 16 | 17 | /** 18 | * WordPress post published modal. 19 | */ 20 | class PostPublishedModal extends Modal { 21 | 22 | constructor( 23 | private readonly plugin: WordpressPlugin, 24 | private readonly onOpenClicked: (modal: Modal) => void 25 | ) { 26 | super(plugin.app); 27 | } 28 | 29 | onOpen() { 30 | const t = (key: TranslateKey, vars?: Record): string => { 31 | return this.plugin.i18n.t(key, vars); 32 | }; 33 | 34 | const { contentEl } = this; 35 | 36 | contentEl.createEl('h1', { text: t('publishedModal_title') }); 37 | 38 | new Setting(contentEl) 39 | .setName(t('publishedModal_confirmEditInWP')); 40 | new Setting(contentEl) 41 | .addButton(button => button 42 | .setButtonText(t('publishedModal_cancel')) 43 | .onClick(() => { 44 | this.close(); 45 | }) 46 | ) 47 | .addButton(button => button 48 | .setButtonText(t('publishedModal_open')) 49 | .setCta() 50 | .onClick(() => { 51 | this.onOpenClicked(this); 52 | }) 53 | ); 54 | } 55 | 56 | onClose() { 57 | const { contentEl } = this; 58 | contentEl.empty(); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/rest-client.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import { getBoundary, SafeAny } from './utils'; 3 | import { FormItemNameMapper, FormItems } from './types'; 4 | 5 | interface RestOptions { 6 | url: URL; 7 | } 8 | 9 | export class RestClient { 10 | 11 | /** 12 | * Href without '/' at the very end. 13 | * @private 14 | */ 15 | private readonly href: string; 16 | 17 | constructor( 18 | private readonly options: RestOptions 19 | ) { 20 | console.log(options); 21 | 22 | this.href = this.options.url.href; 23 | if (this.href.endsWith('/')) { 24 | this.href = this.href.substring(0, this.href.length - 1); 25 | } 26 | } 27 | 28 | async httpGet( 29 | path: string, 30 | options?: { 31 | headers: Record 32 | } 33 | ): Promise { 34 | let realPath = path; 35 | if (realPath.startsWith('/')) { 36 | realPath = realPath.substring(1); 37 | } 38 | 39 | const endpoint = `${this.href}/${realPath}`; 40 | const opts = { 41 | headers: {}, 42 | ...options 43 | }; 44 | console.log('REST GET', endpoint, opts); 45 | const response = await requestUrl({ 46 | url: endpoint, 47 | method: 'GET', 48 | headers: { 49 | 'content-type': 'application/json', 50 | 'user-agent': 'obsidian.md', 51 | ...opts.headers 52 | } 53 | }); 54 | console.log('GET response', response); 55 | return response.json; 56 | } 57 | 58 | async httpPost( 59 | path: string, 60 | body: SafeAny, 61 | options: { 62 | headers?: Record; 63 | formItemNameMapper?: FormItemNameMapper; 64 | }): Promise { 65 | let realPath = path; 66 | if (realPath.startsWith('/')) { 67 | realPath = realPath.substring(1); 68 | } 69 | 70 | const endpoint = `${this.href}/${realPath}`; 71 | const predefinedHeaders: Record = {}; 72 | let requestBody: SafeAny; 73 | if (body instanceof FormItems) { 74 | const boundary = getBoundary(); 75 | requestBody = await body.toArrayBuffer({ 76 | boundary, 77 | nameMapper: options.formItemNameMapper 78 | }); 79 | predefinedHeaders['content-type'] = `multipart/form-data; boundary=${boundary}`; 80 | } else if (body instanceof ArrayBuffer) { 81 | requestBody = body; 82 | } else { 83 | requestBody = JSON.stringify(body); 84 | predefinedHeaders['content-type'] = 'application/json'; 85 | } 86 | const response = await requestUrl({ 87 | url: endpoint, 88 | method: 'POST', 89 | headers: { 90 | 'user-agent': 'obsidian.md', 91 | ...predefinedHeaders, 92 | ...options.headers 93 | }, 94 | body: requestBody 95 | }); 96 | console.log('POST response', response); 97 | return response.json; 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { CommentStatus, PostStatus } from './wp-api'; 4 | import { TranslateKey } from './i18n'; 5 | import { WpProfileManageModal } from './wp-profile-manage-modal'; 6 | import { CommentConvertMode, MathJaxOutputType } from './plugin-settings'; 7 | import { WpProfile } from './wp-profile'; 8 | import { setupMarkdownParser } from './utils'; 9 | import { AppState } from './app-state'; 10 | 11 | 12 | export class WordpressSettingTab extends PluginSettingTab { 13 | 14 | constructor( 15 | private readonly plugin: WordpressPlugin 16 | ) { 17 | super(plugin.app, plugin); 18 | } 19 | 20 | display(): void { 21 | const t = (key: TranslateKey, vars?: Record): string => { 22 | return this.plugin.i18n.t(key, vars); 23 | }; 24 | 25 | const getMathJaxOutputTypeDesc = (type: MathJaxOutputType): string => { 26 | switch (type) { 27 | case MathJaxOutputType.TeX: 28 | return t('settings_MathJaxOutputTypeTeXDesc'); 29 | case MathJaxOutputType.SVG: 30 | return t('settings_MathJaxOutputTypeSVGDesc'); 31 | default: 32 | return ''; 33 | } 34 | } 35 | 36 | const getCommentConvertModeDesc = (type: CommentConvertMode): string => { 37 | switch (type) { 38 | case CommentConvertMode.Ignore: 39 | return t('settings_commentConvertModeIgnoreDesc'); 40 | case CommentConvertMode.HTML: 41 | return t('settings_commentConvertModeHTMLDesc'); 42 | default: 43 | return ''; 44 | } 45 | } 46 | 47 | const { containerEl } = this; 48 | 49 | containerEl.empty(); 50 | 51 | containerEl.createEl('h1', { text: t('settings_title') }); 52 | 53 | let mathJaxOutputTypeDesc = getMathJaxOutputTypeDesc(this.plugin.settings.mathJaxOutputType); 54 | let commentConvertModeDesc = getCommentConvertModeDesc(this.plugin.settings.commentConvertMode); 55 | 56 | new Setting(containerEl) 57 | .setName(t('settings_profiles')) 58 | .setDesc(t('settings_profilesDesc')) 59 | .addButton(button => button 60 | .setButtonText(t('settings_profilesModal')) 61 | .onClick(() => { 62 | new WpProfileManageModal(this.plugin).open(); 63 | })); 64 | 65 | new Setting(containerEl) 66 | .setName(t('settings_showRibbonIcon')) 67 | .setDesc(t('settings_showRibbonIconDesc')) 68 | .addToggle((toggle) => 69 | toggle 70 | .setValue(this.plugin.settings.showRibbonIcon) 71 | .onChange(async (value) => { 72 | this.plugin.settings.showRibbonIcon = value; 73 | await this.plugin.saveSettings(); 74 | 75 | this.plugin.updateRibbonIcon(); 76 | }), 77 | ); 78 | 79 | new Setting(containerEl) 80 | .setName(t('settings_defaultPostStatus')) 81 | .setDesc(t('settings_defaultPostStatusDesc')) 82 | .addDropdown((dropdown) => { 83 | dropdown 84 | .addOption(PostStatus.Draft, t('settings_defaultPostStatusDraft')) 85 | .addOption(PostStatus.Publish, t('settings_defaultPostStatusPublish')) 86 | .addOption(PostStatus.Private, t('settings_defaultPostStatusPrivate')) 87 | .setValue(this.plugin.settings.defaultPostStatus) 88 | .onChange(async (value) => { 89 | this.plugin.settings.defaultPostStatus = value as PostStatus; 90 | await this.plugin.saveSettings(); 91 | }); 92 | }); 93 | 94 | new Setting(containerEl) 95 | .setName(t('settings_defaultPostComment')) 96 | .setDesc(t('settings_defaultPostCommentDesc')) 97 | .addDropdown((dropdown) => { 98 | dropdown 99 | .addOption(CommentStatus.Open, t('settings_defaultPostCommentOpen')) 100 | .addOption(CommentStatus.Closed, t('settings_defaultPostCommentClosed')) 101 | // .addOption(PostStatus.Future, 'future') 102 | .setValue(this.plugin.settings.defaultCommentStatus) 103 | .onChange(async (value) => { 104 | this.plugin.settings.defaultCommentStatus = value as CommentStatus; 105 | await this.plugin.saveSettings(); 106 | }); 107 | }); 108 | 109 | new Setting(containerEl) 110 | .setName(t('settings_rememberLastSelectedCategories')) 111 | .setDesc(t('settings_rememberLastSelectedCategoriesDesc')) 112 | .addToggle((toggle) => 113 | toggle 114 | .setValue(this.plugin.settings.rememberLastSelectedCategories) 115 | .onChange(async (value) => { 116 | this.plugin.settings.rememberLastSelectedCategories = value; 117 | if (!value) { 118 | this.plugin.settings.profiles.forEach((profile: WpProfile) => { 119 | if (!profile.lastSelectedCategories || profile.lastSelectedCategories.length === 0) { 120 | profile.lastSelectedCategories = [ 1 ]; 121 | } 122 | }); 123 | } 124 | await this.plugin.saveSettings(); 125 | }), 126 | ); 127 | 128 | new Setting(containerEl) 129 | .setName(t('settings_showWordPressEditPageModal')) 130 | .setDesc(t('settings_showWordPressEditPageModalDesc')) 131 | .addToggle((toggle) => 132 | toggle 133 | .setValue(this.plugin.settings.showWordPressEditConfirm) 134 | .onChange(async (value) => { 135 | this.plugin.settings.showWordPressEditConfirm = value; 136 | await this.plugin.saveSettings(); 137 | }), 138 | ); 139 | 140 | new Setting(containerEl) 141 | .setName(t('settings_mathJaxOutputType')) 142 | .setDesc(t('settings_mathJaxOutputTypeDesc')) 143 | .addDropdown((dropdown) => { 144 | dropdown 145 | .addOption(MathJaxOutputType.TeX, t('settings_mathJaxOutputTypeTeX')) 146 | .addOption(MathJaxOutputType.SVG, t('settings_mathJaxOutputTypeSVG')) 147 | .setValue(this.plugin.settings.mathJaxOutputType) 148 | .onChange(async (value) => { 149 | this.plugin.settings.mathJaxOutputType = value as MathJaxOutputType; 150 | mathJaxOutputTypeDesc = getMathJaxOutputTypeDesc(this.plugin.settings.mathJaxOutputType); 151 | await this.plugin.saveSettings(); 152 | this.display(); 153 | 154 | setupMarkdownParser(this.plugin.settings); 155 | }); 156 | }); 157 | containerEl.createEl('p', { 158 | text: mathJaxOutputTypeDesc, 159 | cls: 'setting-item-description' 160 | }); 161 | 162 | new Setting(containerEl) 163 | .setName(t('settings_commentConvertMode')) 164 | .setDesc(t('settings_commentConvertModeDesc')) 165 | .addDropdown((dropdown) => { 166 | dropdown 167 | .addOption(CommentConvertMode.Ignore, t('settings_commentConvertModeIgnore')) 168 | .addOption(CommentConvertMode.HTML, t('settings_commentConvertModeHTML')) 169 | .setValue(this.plugin.settings.commentConvertMode) 170 | .onChange(async (value) => { 171 | this.plugin.settings.commentConvertMode = value as CommentConvertMode; 172 | commentConvertModeDesc = getCommentConvertModeDesc(this.plugin.settings.commentConvertMode); 173 | await this.plugin.saveSettings(); 174 | this.display(); 175 | 176 | setupMarkdownParser(this.plugin.settings); 177 | }); 178 | }); 179 | containerEl.createEl('p', { 180 | text: commentConvertModeDesc, 181 | cls: 'setting-item-description' 182 | }); 183 | 184 | new Setting(containerEl) 185 | .setName(t('settings_enableHtml')) 186 | .setDesc(t('settings_enableHtmlDesc')) 187 | .addToggle((toggle) => 188 | toggle 189 | .setValue(this.plugin.settings.enableHtml) 190 | .onChange(async (value) => { 191 | this.plugin.settings.enableHtml = value; 192 | await this.plugin.saveSettings(); 193 | 194 | AppState.markdownParser.set({ 195 | html: this.plugin.settings.enableHtml 196 | }); 197 | }), 198 | ); 199 | 200 | new Setting(containerEl) 201 | .setName(t('settings_replaceMediaLinks')) 202 | .setDesc(t('settings_replaceMediaLinksDesc')) 203 | .addToggle((toggle) => 204 | toggle 205 | .setValue(this.plugin.settings.replaceMediaLinks) 206 | .onChange(async (value) => { 207 | this.plugin.settings.replaceMediaLinks = value; 208 | await this.plugin.saveSettings(); 209 | }), 210 | ); 211 | } 212 | 213 | } 214 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SafeAny } from './utils'; 2 | import { isArray, isString } from 'lodash-es'; 3 | 4 | export interface MarkdownItPlugin { 5 | updateOptions: (opts: SafeAny) => void; 6 | } 7 | 8 | export type MatterData = { [p: string]: SafeAny }; 9 | 10 | export interface Media { 11 | mimeType: string; 12 | fileName: string; 13 | content: ArrayBuffer; 14 | } 15 | 16 | export function isMedia(obj: SafeAny): obj is Media { 17 | return ( 18 | typeof obj === 'object' 19 | && obj !== null 20 | && 'mimeType' in obj && typeof obj.mimeType === 'string' 21 | && 'fileName' in obj && typeof obj.fileName === 'string' 22 | && 'content' in obj && obj.content instanceof ArrayBuffer 23 | ); 24 | } 25 | 26 | /** 27 | * Convert original item name to custom one. 28 | * 29 | * @param name original item name. If `isArray` is `true`, which means is in an array, the `name` will be appended by `[]` 30 | * @param isArray whether this item is in an array 31 | */ 32 | export type FormItemNameMapper = (name: string, isArray: boolean) => string; 33 | 34 | export class FormItems { 35 | #formData: Record = {}; 36 | 37 | append(name: string, data: string): FormItems; 38 | append(name: string, data: Media): FormItems; 39 | append(name: string, data: string | Media): FormItems { 40 | const existing = this.#formData[name]; 41 | if (existing) { 42 | this.#formData[name] = [ existing ]; 43 | this.#formData[name].push(data); 44 | } else { 45 | this.#formData[name] = data; 46 | } 47 | return this; 48 | } 49 | 50 | toArrayBuffer(option: { 51 | boundary: string; 52 | nameMapper?: FormItemNameMapper; 53 | }): Promise { 54 | const CRLF = '\r\n'; 55 | const itemPart = (name: string, data: string | Media, isArray: boolean) => { 56 | let itemName = name; 57 | if (option.nameMapper) { 58 | itemName = option.nameMapper(name, isArray); 59 | } 60 | 61 | body.push(encodedItemStart); 62 | if (isString(data)) { 63 | body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"${CRLF}${CRLF}`)); 64 | body.push(encoder.encode(data)); 65 | } else { 66 | const media = data; 67 | body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"; filename="${media.fileName}"${CRLF}Content-Type: ${media.mimeType}${CRLF}${CRLF}`)); 68 | body.push(media.content); 69 | } 70 | body.push(encoder.encode(CRLF)); 71 | }; 72 | 73 | const encoder = new TextEncoder(); 74 | const encodedItemStart = encoder.encode(`--${option.boundary}${CRLF}`); 75 | const body: ArrayBuffer[] = []; 76 | Object.entries(this.#formData).forEach(([ name, data ]) => { 77 | if (isArray(data)) { 78 | data.forEach(item => { 79 | itemPart(`${name}[]`, item, true); 80 | }); 81 | } else { 82 | itemPart(name, data, false); 83 | } 84 | }); 85 | body.push(encoder.encode(`--${option.boundary}--`)); 86 | return new Blob(body).arrayBuffer(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Setting, TFile } from 'obsidian'; 2 | import { WpProfile } from './wp-profile'; 3 | import { WordpressPluginSettings } from './plugin-settings'; 4 | import { MarkdownItMathJax3PluginInstance } from './markdown-it-mathjax3-plugin'; 5 | import { WordPressClientResult, WordPressClientReturnCode, WordPressPostParams } from './wp-client'; 6 | import { getWordPressClient } from './wp-clients'; 7 | import WordpressPlugin from './main'; 8 | import { isString } from 'lodash-es'; 9 | import { ERROR_NOTICE_TIMEOUT } from './consts'; 10 | import { format } from 'date-fns'; 11 | import { MatterData } from './types'; 12 | import { MarkdownItCommentPluginInstance } from './markdown-it-comment-plugin'; 13 | 14 | export type SafeAny = any; // eslint-disable-line @typescript-eslint/no-explicit-any 15 | 16 | export function openWithBrowser(url: string, queryParams: Record = {}): void { 17 | window.open(`${url}?${generateQueryString(queryParams)}`); 18 | } 19 | 20 | export function generateQueryString(params: Record): string { 21 | return new URLSearchParams( 22 | Object.fromEntries( 23 | Object.entries(params).filter( ([k, v]) => v!==undefined) 24 | ) as Record 25 | ).toString(); 26 | } 27 | 28 | export function isPromiseFulfilledResult(obj: SafeAny): obj is PromiseFulfilledResult { 29 | return !!obj && obj.status === 'fulfilled' && obj.value; 30 | } 31 | 32 | export function setupMarkdownParser(settings: WordpressPluginSettings): void { 33 | MarkdownItMathJax3PluginInstance.updateOutputType(settings.mathJaxOutputType); 34 | MarkdownItCommentPluginInstance.updateConvertMode(settings.commentConvertMode); 35 | } 36 | 37 | 38 | export function rendererProfile(profile: WpProfile, container: HTMLElement): Setting { 39 | let name = profile.name; 40 | if (profile.isDefault) { 41 | name += ' ✔️'; 42 | } 43 | let desc = profile.endpoint; 44 | if (profile.wpComOAuth2Token) { 45 | desc += ` / 🆔 / 🔒`; 46 | } else { 47 | if (profile.saveUsername) { 48 | desc += ` / 🆔 ${profile.username}`; 49 | } 50 | if (profile.savePassword) { 51 | desc += ' / 🔒 ******'; 52 | } 53 | } 54 | return new Setting(container) 55 | .setName(name) 56 | .setDesc(desc); 57 | } 58 | 59 | export function isValidUrl(url: string): boolean { 60 | try { 61 | return Boolean(new URL(url)); 62 | } catch(e) { 63 | return false; 64 | } 65 | } 66 | 67 | export function doClientPublish(plugin: WordpressPlugin, profile: WpProfile, defaultPostParams?: WordPressPostParams): void; 68 | export function doClientPublish(plugin: WordpressPlugin, profileName: string, defaultPostParams?: WordPressPostParams): void; 69 | export function doClientPublish( 70 | plugin: WordpressPlugin, 71 | profileOrName: WpProfile | string, 72 | defaultPostParams?: WordPressPostParams 73 | ): void { 74 | let profile: WpProfile | undefined; 75 | if (isString(profileOrName)) { 76 | profile = plugin.settings.profiles.find(it => it.name === profileOrName); 77 | } else { 78 | profile = profileOrName; 79 | } 80 | if (profile) { 81 | const client = getWordPressClient(plugin, profile); 82 | if (client) { 83 | client.publishPost(defaultPostParams).then(); 84 | } 85 | } else { 86 | const noSuchProfileMessage = plugin.i18n.t('error_noSuchProfile', { 87 | profileName: String(profileOrName) 88 | }); 89 | showError(noSuchProfileMessage); 90 | throw new Error(noSuchProfileMessage); 91 | } 92 | } 93 | 94 | export function getBoundary(): string { 95 | return `----obsidianBoundary${format(new Date(), 'yyyyMMddHHmmss')}`; 96 | } 97 | 98 | export function showError(error: unknown): WordPressClientResult { 99 | let errorMessage: string; 100 | if (isString(error)) { 101 | errorMessage = error; 102 | } else if (error instanceof Error) { 103 | errorMessage = error.message; 104 | } else { 105 | errorMessage = (error as SafeAny).toString(); 106 | } 107 | new Notice(`❌ ${ errorMessage }`, ERROR_NOTICE_TIMEOUT); 108 | return { 109 | code: WordPressClientReturnCode.Error as const, 110 | error: { 111 | code: WordPressClientReturnCode.Error, 112 | message: errorMessage, 113 | } 114 | }; 115 | } 116 | 117 | export async function processFile(file: TFile, app: App): Promise<{ content: string, matter: MatterData }> { 118 | let fm = app.metadataCache.getFileCache(file)?.frontmatter; 119 | if (!fm) { 120 | await app.fileManager.processFrontMatter(file, matter => { 121 | fm = matter 122 | }); 123 | } 124 | const raw = await app.vault.read(file); 125 | return { 126 | content: raw.replace(/^---[\s\S]+?---/, '').trim(), 127 | matter: fm ?? {} 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/wp-api.ts: -------------------------------------------------------------------------------- 1 | export const enum PostStatus { 2 | Draft = 'draft', 3 | Publish = 'publish', 4 | Private = 'private', 5 | Future = 'future' 6 | } 7 | 8 | export const enum CommentStatus { 9 | Open = 'open', 10 | Closed = 'closed' 11 | } 12 | 13 | export const enum PostTypeConst { 14 | Post = 'post', 15 | Page = 'page', 16 | } 17 | export type PostType = string; 18 | 19 | export interface Term { 20 | id: string; 21 | name: string; 22 | slug: string; 23 | taxonomy: string; 24 | description: string; 25 | parent?: string; 26 | count: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/wp-client.ts: -------------------------------------------------------------------------------- 1 | import { CommentStatus, PostStatus, PostType } from './wp-api'; 2 | import { SafeAny } from './utils'; 3 | 4 | export enum WordPressClientReturnCode { 5 | OK, 6 | Error, 7 | ServerInternalError, 8 | } 9 | 10 | interface _wpClientResult { 11 | /** 12 | * Response from WordPress server. 13 | */ 14 | response?: SafeAny; 15 | 16 | code: WordPressClientReturnCode; 17 | } 18 | 19 | interface WpClientOkResult extends _wpClientResult { 20 | code: WordPressClientReturnCode.OK; 21 | data: T; 22 | } 23 | 24 | interface WpClientErrorResult extends _wpClientResult { 25 | code: WordPressClientReturnCode.Error; 26 | error: { 27 | /** 28 | * This code could be returned from remote server 29 | */ 30 | code: WordPressClientReturnCode | string; 31 | message: string; 32 | } 33 | } 34 | 35 | export type WordPressClientResult = 36 | | WpClientOkResult 37 | | WpClientErrorResult; 38 | 39 | export interface WordPressAuthParams { 40 | username: string | null; 41 | password: string | null; 42 | } 43 | 44 | export interface WordPressPostParams { 45 | status: PostStatus; 46 | commentStatus: CommentStatus; 47 | categories: number[]; 48 | postType: PostType; 49 | tags: string[]; 50 | 51 | /** 52 | * Post title. 53 | */ 54 | title: string; 55 | 56 | /** 57 | * Post content. 58 | */ 59 | content: string; 60 | 61 | /** 62 | * WordPress post ID. 63 | * 64 | * If this is assigned, the post will be updated, otherwise created. 65 | */ 66 | postId?: string; 67 | 68 | /** 69 | * WordPress profile name. 70 | */ 71 | profileName?: string; 72 | 73 | datetime?: Date; 74 | } 75 | 76 | export interface WordPressPublishParams extends WordPressAuthParams { 77 | postParams: WordPressPostParams; 78 | matterData: { [p: string]: SafeAny }; 79 | } 80 | 81 | export interface WordPressPublishResult { 82 | postId: string; 83 | categories: number[]; 84 | } 85 | 86 | export interface WordPressMediaUploadResult { 87 | url: string; 88 | } 89 | 90 | export interface WordPressClient { 91 | 92 | /** 93 | * Publish a post to WordPress. 94 | * 95 | * If there is a `postId` in front-matter, the post will be updated, 96 | * otherwise, create a new one. 97 | * 98 | * @param defaultPostParams Use this parameter instead of popup publish modal if this is not undefined. 99 | */ 100 | publishPost(defaultPostParams?: WordPressPostParams): Promise>; 101 | 102 | /** 103 | * Checks if the login certificate is OK. 104 | * @param certificate 105 | */ 106 | validateUser(certificate: WordPressAuthParams): Promise>; 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/wp-clients.ts: -------------------------------------------------------------------------------- 1 | import WordpressPlugin from './main'; 2 | import { WpXmlRpcClient } from './wp-xml-rpc-client'; 3 | import { 4 | WpRestClient, 5 | WpRestClientAppPasswordContext, 6 | WpRestClientMiniOrangeContext, 7 | WpRestClientWpComOAuth2Context 8 | } from './wp-rest-client'; 9 | import { WordPressClient } from './wp-client'; 10 | import { WpProfile } from './wp-profile'; 11 | import { ApiType } from './plugin-settings'; 12 | import { showError } from './utils'; 13 | 14 | export function getWordPressClient( 15 | plugin: WordpressPlugin, 16 | profile: WpProfile 17 | ): WordPressClient | null { 18 | if (!profile.endpoint || profile.endpoint.length === 0) { 19 | showError(plugin.i18n.t('error_noEndpoint')); 20 | return null; 21 | } 22 | let client: WordPressClient | null = null; 23 | switch (profile.apiType) { 24 | case ApiType.XML_RPC: 25 | client = new WpXmlRpcClient(plugin, profile); 26 | break; 27 | case ApiType.RestAPI_miniOrange: 28 | client = new WpRestClient(plugin, profile, new WpRestClientMiniOrangeContext()); 29 | break; 30 | case ApiType.RestApi_ApplicationPasswords: 31 | client = new WpRestClient(plugin, profile, new WpRestClientAppPasswordContext()); 32 | break; 33 | case ApiType.RestApi_WpComOAuth2: 34 | if (profile.wpComOAuth2Token) { 35 | client = new WpRestClient(plugin, profile, new WpRestClientWpComOAuth2Context( 36 | profile.wpComOAuth2Token.blogId, 37 | profile.wpComOAuth2Token.accessToken 38 | )); 39 | } else { 40 | showError(plugin.i18n.t('error_invalidWpComToken')); 41 | } 42 | break; 43 | default: 44 | client = null; 45 | break; 46 | } 47 | return client; 48 | } 49 | -------------------------------------------------------------------------------- /src/wp-login-modal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { WpProfile } from './wp-profile'; 4 | import { WordPressAuthParams } from './wp-client'; 5 | import { showError } from './utils'; 6 | import { AbstractModal } from './abstract-modal'; 7 | 8 | export function openLoginModal( 9 | plugin: WordpressPlugin, 10 | profile: WpProfile, 11 | validateUser: (auth: WordPressAuthParams) => Promise, 12 | ): Promise<{ auth: WordPressAuthParams, loginModal: Modal }> { 13 | return new Promise((resolve, reject) => { 14 | const modal = new WpLoginModal(plugin, profile, async (auth, loginModal) => { 15 | const validate = await validateUser(auth); 16 | if (validate) { 17 | resolve({ 18 | auth, 19 | loginModal 20 | }); 21 | modal.close(); 22 | } else { 23 | showError(plugin.i18n.t('error_invalidUser')); 24 | } 25 | }); 26 | modal.open(); 27 | }); 28 | } 29 | 30 | /** 31 | * WordPress login modal with username and password inputs. 32 | */ 33 | export class WpLoginModal extends AbstractModal { 34 | 35 | constructor( 36 | readonly plugin: WordpressPlugin, 37 | private readonly profile: WpProfile, 38 | private readonly onSubmit: (auth: WordPressAuthParams, modal: Modal) => void 39 | ) { 40 | super(plugin); 41 | } 42 | 43 | onOpen() { 44 | const { contentEl } = this; 45 | 46 | this.createHeader(this.t('loginModal_title')); 47 | 48 | let username = this.profile.username; 49 | let password = this.profile.password; 50 | new Setting(contentEl) 51 | .setName(this.t('loginModal_username')) 52 | .setDesc(this.t('loginModal_usernameDesc', { url: this.profile.endpoint })) 53 | .addText(text => { 54 | text 55 | .setValue(this.profile.username ?? '') 56 | .onChange(async (value) => { 57 | username = value; 58 | if (this.profile.saveUsername) { 59 | this.profile.username = value; 60 | await this.plugin.saveSettings(); 61 | } 62 | }); 63 | if (!this.profile.saveUsername) { 64 | setTimeout(() => { 65 | text.inputEl.focus(); 66 | }); 67 | } 68 | }); 69 | new Setting(contentEl) 70 | .setName(this.t('loginModal_password')) 71 | .setDesc(this.t('loginModal_passwordDesc', { url: this.profile.endpoint })) 72 | .addText(text => { 73 | text 74 | .setValue(this.profile.password ?? '') 75 | .onChange(async (value) => { 76 | password = value; 77 | if (this.profile.savePassword) { 78 | this.profile.password = value; 79 | await this.plugin.saveSettings(); 80 | } 81 | }); 82 | if (this.profile.saveUsername) { 83 | setTimeout(() => { 84 | text.inputEl.focus(); 85 | }); 86 | } 87 | }); 88 | // new Setting(contentEl) 89 | // .setName(this.t('loginModal_rememberUsername')) 90 | // .setDesc(this.t('loginModal_rememberUsernameDesc')) 91 | // .addToggle((toggle) => 92 | // toggle 93 | // .setValue(this.profile.saveUsername) 94 | // .onChange(async (value) => { 95 | // this.profile.saveUsername = value; 96 | // if (!this.profile.saveUsername) { 97 | // delete this.profile.username; 98 | // } else { 99 | // this.profile.username = username; 100 | // } 101 | // await this.plugin.saveSettings(); 102 | // }), 103 | // ); 104 | // new Setting(contentEl) 105 | // .setName(this.t('loginModal_rememberPassword')) 106 | // .setDesc(this.t('loginModal_rememberPasswordDesc')) 107 | // .addToggle((toggle) => 108 | // toggle 109 | // .setValue(this.profile.savePassword) 110 | // .onChange(async (value) => { 111 | // this.profile.savePassword = value; 112 | // if (!this.profile.savePassword) { 113 | // delete this.profile.password; 114 | // } else { 115 | // this.profile.password = password; 116 | // } 117 | // await this.plugin.saveSettings(); 118 | // }), 119 | // ); 120 | new Setting(contentEl) 121 | .addButton(button => button 122 | .setButtonText(this.t('loginModal_loginButtonText')) 123 | .setCta() 124 | .onClick(() => { 125 | if (!username) { 126 | showError(this.t('error_noUsername')); 127 | } else if (!password) { 128 | showError(this.t('error_noPassword')); 129 | } 130 | if (username && password) { 131 | this.onSubmit({ username, password }, this); 132 | } 133 | }) 134 | ); 135 | } 136 | 137 | onClose() { 138 | const { contentEl } = this; 139 | contentEl.empty(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/wp-profile-chooser-modal.ts: -------------------------------------------------------------------------------- 1 | import WordpressPlugin from './main'; 2 | import { WpProfile } from './wp-profile'; 3 | import { rendererProfile } from './utils'; 4 | import { AbstractModal } from './abstract-modal'; 5 | 6 | 7 | export function openProfileChooserModal( 8 | plugin: WordpressPlugin 9 | ): Promise { 10 | return new Promise((resolve, reject) => { 11 | const modal = new WpProfileChooserModal(plugin, (profile) => { 12 | resolve(profile); 13 | }); 14 | modal.open(); 15 | }); 16 | } 17 | 18 | /** 19 | * WordPress profiles chooser modal. 20 | */ 21 | class WpProfileChooserModal extends AbstractModal { 22 | 23 | private readonly profiles: WpProfile[]; 24 | 25 | constructor( 26 | readonly plugin: WordpressPlugin, 27 | private readonly onChoose: (profile: WpProfile) => void 28 | ) { 29 | super(plugin); 30 | 31 | this.profiles = plugin.settings.profiles; 32 | } 33 | 34 | onOpen() { 35 | const chooseProfile = (profile: WpProfile): void => { 36 | this.onChoose(profile); 37 | this.close(); 38 | } 39 | 40 | const renderProfiles = (): void => { 41 | content.empty(); 42 | this.profiles.forEach((profile) => { 43 | const setting = rendererProfile(profile, content); 44 | setting.settingEl.addEventListener('click', () => { 45 | chooseProfile(profile); 46 | }); 47 | }); 48 | } 49 | 50 | this.createHeader(this.t('profilesChooserModal_title')); 51 | 52 | const { contentEl } = this; 53 | const content = contentEl.createEl('div'); 54 | renderProfiles(); 55 | } 56 | 57 | onClose() { 58 | const { contentEl } = this; 59 | contentEl.empty(); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/wp-profile-manage-modal.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { WpProfile } from './wp-profile'; 4 | import { openProfileModal } from './wp-profile-modal'; 5 | import { isNil } from 'lodash-es'; 6 | import { rendererProfile } from './utils'; 7 | import { AbstractModal } from './abstract-modal'; 8 | 9 | 10 | /** 11 | * WordPress profiles manage modal. 12 | */ 13 | export class WpProfileManageModal extends AbstractModal { 14 | 15 | private readonly profiles: WpProfile[]; 16 | 17 | constructor( 18 | readonly plugin: WordpressPlugin 19 | ) { 20 | super(plugin); 21 | 22 | this.profiles = plugin.settings.profiles; 23 | } 24 | 25 | onOpen() { 26 | const renderProfiles = (): void => { 27 | content.empty(); 28 | this.profiles.forEach((profile, index) => { 29 | const setting = rendererProfile(profile, content); 30 | if (!profile.isDefault) { 31 | setting 32 | .addButton(button => button 33 | .setButtonText(this.t('profilesManageModal_setDefault')) 34 | .onClick(() => { 35 | this.profiles.forEach(it => it.isDefault = false); 36 | profile.isDefault = true; 37 | renderProfiles(); 38 | this.plugin.saveSettings().then(); 39 | })); 40 | } 41 | setting.addButton(button => button 42 | .setButtonText(this.t('profilesManageModal_showDetails')) 43 | .onClick(async () => { 44 | const { profile: newProfile, atIndex } = await openProfileModal( 45 | this.plugin, 46 | profile, 47 | index 48 | ); 49 | console.log('updateProfile', newProfile, atIndex); 50 | if (!isNil(atIndex) && atIndex > -1) { 51 | if (newProfile.isDefault) { 52 | this.profiles.forEach(it => it.isDefault = false); 53 | } 54 | this.profiles[atIndex] = newProfile; 55 | renderProfiles(); 56 | this.plugin.saveSettings().then(); 57 | } 58 | })); 59 | setting.addExtraButton(button => button 60 | .setIcon('lucide-trash') 61 | .setTooltip(this.t('profilesManageModal_deleteTooltip')) 62 | .onClick(() => { 63 | this.profiles.splice(index, 1); 64 | if (profile.isDefault) { 65 | if (this.profiles.length > 0) { 66 | this.profiles[0].isDefault = true; 67 | } 68 | } 69 | renderProfiles(); 70 | this.plugin.saveSettings().then(); 71 | })); 72 | }); 73 | } 74 | 75 | this.createHeader(this.t('profilesManageModal_title')); 76 | 77 | const { contentEl } = this; 78 | new Setting(contentEl) 79 | .setName(this.t('profilesManageModal_create')) 80 | .setDesc(this.t('profilesManageModal_createDesc')) 81 | .addButton(button => button 82 | .setButtonText(this.t('profilesManageModal_create')) 83 | .setCta() 84 | .onClick(async () => { 85 | const { profile } = await openProfileModal( 86 | this.plugin 87 | ); 88 | console.log('appendProfile', profile); 89 | // if no profile, make the first one default 90 | if (this.profiles.length === 0) { 91 | profile.isDefault = true; 92 | } 93 | if (profile.isDefault) { 94 | this.profiles.forEach(it => it.isDefault = false); 95 | } 96 | this.profiles.push(profile); 97 | renderProfiles(); 98 | await this.plugin.saveSettings(); 99 | })); 100 | 101 | const content = contentEl.createEl('div'); 102 | renderProfiles(); 103 | } 104 | 105 | onClose() { 106 | const { contentEl } = this; 107 | contentEl.empty(); 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/wp-profile-modal.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { WpProfile } from './wp-profile'; 4 | import { EventType, WP_OAUTH2_REDIRECT_URI } from './consts'; 5 | import { WordPressClientReturnCode } from './wp-client'; 6 | import { generateCodeVerifier, OAuth2Client } from './oauth2-client'; 7 | import { AppState } from './app-state'; 8 | import { isValidUrl, showError } from './utils'; 9 | import { ApiType } from './plugin-settings'; 10 | import { AbstractModal } from './abstract-modal'; 11 | 12 | 13 | export function openProfileModal( 14 | plugin: WordpressPlugin, 15 | profile: WpProfile = { 16 | name: '', 17 | apiType: ApiType.XML_RPC, 18 | endpoint: '', 19 | xmlRpcPath: '/xmlrpc.php', 20 | saveUsername: false, 21 | savePassword: false, 22 | isDefault: false, 23 | lastSelectedCategories: [ 1 ], 24 | }, 25 | atIndex = -1 26 | ): Promise<{ profile: WpProfile, atIndex?: number }> { 27 | return new Promise((resolve, reject) => { 28 | const modal = new WpProfileModal(plugin, (profile, atIndex) => { 29 | resolve({ 30 | profile, 31 | atIndex 32 | }); 33 | }, profile, atIndex); 34 | modal.open(); 35 | }); 36 | } 37 | 38 | /** 39 | * WordPress profile modal. 40 | */ 41 | class WpProfileModal extends AbstractModal { 42 | 43 | private readonly profileData: WpProfile; 44 | 45 | private readonly tokenGotRef; 46 | 47 | constructor( 48 | readonly plugin: WordpressPlugin, 49 | private readonly onSubmit: (profile: WpProfile, atIndex?: number) => void, 50 | private readonly profile: WpProfile = { 51 | name: '', 52 | apiType: ApiType.XML_RPC, 53 | endpoint: '', 54 | xmlRpcPath: '/xmlrpc.php', 55 | saveUsername: false, 56 | savePassword: false, 57 | isDefault: false, 58 | lastSelectedCategories: [ 1 ], 59 | }, 60 | private readonly atIndex: number = -1 61 | ) { 62 | super(plugin); 63 | 64 | this.profileData = Object.assign({}, profile); 65 | this.tokenGotRef = AppState.events.on(EventType.OAUTH2_TOKEN_GOT, async token => { 66 | this.profileData.wpComOAuth2Token = token; 67 | if (atIndex >= 0) { 68 | // if token is undefined, just remove it 69 | this.plugin.settings.profiles[atIndex].wpComOAuth2Token = token; 70 | await this.plugin.saveSettings(); 71 | } 72 | }); 73 | } 74 | 75 | onOpen() { 76 | const getApiTypeDesc = (apiType: ApiType): string => { 77 | switch (apiType) { 78 | case ApiType.XML_RPC: 79 | return this.t('settings_apiTypeXmlRpcDesc'); 80 | case ApiType.RestAPI_miniOrange: 81 | return this.t('settings_apiTypeRestMiniOrangeDesc'); 82 | case ApiType.RestApi_ApplicationPasswords: 83 | return this.t('settings_apiTypeRestApplicationPasswordsDesc'); 84 | case ApiType.RestApi_WpComOAuth2: 85 | return this.t('settings_apiTypeRestWpComOAuth2Desc'); 86 | default: 87 | return ''; 88 | } 89 | }; 90 | let apiDesc = getApiTypeDesc(this.profileData.apiType); 91 | 92 | const renderProfile = () => { 93 | content.empty(); 94 | 95 | new Setting(content) 96 | .setName(this.t('profileModal_name')) 97 | .setDesc(this.t('profileModal_nameDesc')) 98 | .addText(text => text 99 | .setPlaceholder('Profile name') 100 | .setValue(this.profileData.name ?? '') 101 | .onChange((value) => { 102 | this.profileData.name = value; 103 | }) 104 | ); 105 | new Setting(content) 106 | .setName(this.t('settings_url')) 107 | .setDesc(this.t('settings_urlDesc')) 108 | .addText(text => text 109 | .setPlaceholder(this.t('settings_urlPlaceholder')) 110 | .setValue(this.profileData.endpoint) 111 | .onChange((value) => { 112 | if (this.profileData.endpoint !== value) { 113 | this.profileData.endpoint = value; 114 | } 115 | })); 116 | new Setting(content) 117 | .setName(this.t('settings_apiType')) 118 | .setDesc(this.t('settings_apiTypeDesc')) 119 | .addDropdown((dropdown) => { 120 | dropdown 121 | .addOption(ApiType.XML_RPC, this.t('settings_apiTypeXmlRpc')) 122 | .addOption(ApiType.RestAPI_miniOrange, this.t('settings_apiTypeRestMiniOrange')) 123 | .addOption(ApiType.RestApi_ApplicationPasswords, this.t('settings_apiTypeRestApplicationPasswords')) 124 | .addOption(ApiType.RestApi_WpComOAuth2, this.t('settings_apiTypeRestWpComOAuth2')) 125 | .setValue(this.profileData.apiType) 126 | .onChange(async (value) => { 127 | let hasError = false; 128 | let newApiType = value; 129 | if (value === ApiType.RestApi_WpComOAuth2) { 130 | if (!this.profileData.endpoint.includes('wordpress.com')) { 131 | showError(this.t('error_notWpCom')); 132 | hasError = true; 133 | newApiType = this.profileData.apiType; 134 | } 135 | } 136 | this.profileData.apiType = newApiType as ApiType; 137 | apiDesc = getApiTypeDesc(this.profileData.apiType); 138 | renderProfile(); 139 | if (!hasError) { 140 | if (value === ApiType.RestApi_WpComOAuth2) { 141 | if (this.profileData.wpComOAuth2Token) { 142 | const endpointUrl = new URL(this.profileData.endpoint); 143 | const blogUrl = new URL(this.profileData.wpComOAuth2Token.blogUrl); 144 | if (endpointUrl.host !== blogUrl.host) { 145 | await this.refreshWpComToken(); 146 | } 147 | } else { 148 | await this.refreshWpComToken(); 149 | } 150 | } 151 | } 152 | }); 153 | }); 154 | content.createEl('p', { 155 | text: apiDesc, 156 | cls: 'setting-item-description' 157 | }); 158 | if (this.profileData.apiType === ApiType.XML_RPC) { 159 | new Setting(content) 160 | .setName(this.t('settings_xmlRpcPath')) 161 | .setDesc(this.t('settings_xmlRpcPathDesc')) 162 | .addText(text => text 163 | .setPlaceholder('/xmlrpc.php') 164 | .setValue(this.profileData.xmlRpcPath ?? '') 165 | .onChange((value) => { 166 | this.profileData.xmlRpcPath = value; 167 | })); 168 | } else if (this.profileData.apiType === ApiType.RestApi_WpComOAuth2) { 169 | new Setting(content) 170 | .setName(this.t('settings_wpComOAuth2RefreshToken')) 171 | .setDesc(this.t('settings_wpComOAuth2RefreshTokenDesc')) 172 | .addButton(button => button 173 | .setButtonText(this.t('settings_wpComOAuth2ValidateTokenButtonText')) 174 | .onClick(() => { 175 | if (this.profileData.wpComOAuth2Token) { 176 | OAuth2Client.getWpOAuth2Client(this.plugin).validateToken({ 177 | token: this.profileData.wpComOAuth2Token.accessToken 178 | }) 179 | .then(result => { 180 | if (result.code === WordPressClientReturnCode.Error) { 181 | showError(result.error?.message + ''); 182 | } else { 183 | new Notice(this.t('message_wpComTokenValidated')); 184 | } 185 | }); 186 | } 187 | })) 188 | .addButton(button => button 189 | .setButtonText(this.t('settings_wpComOAuth2RefreshTokenButtonText')) 190 | .onClick(async () => { 191 | await this.refreshWpComToken(); 192 | })); 193 | } 194 | 195 | if (this.profileData.apiType !== ApiType.RestApi_WpComOAuth2) { 196 | const usernameSetting = new Setting(content) 197 | .setName(this.t('profileModal_rememberUsername')); 198 | if (this.profileData.saveUsername) { 199 | usernameSetting 200 | .addText(text => text 201 | .setValue(this.profileData.username ?? '') 202 | .onChange((value) => { 203 | this.profileData.username = value; 204 | }) 205 | ); 206 | } 207 | usernameSetting.addToggle(toggle => toggle 208 | .setValue(this.profileData.saveUsername) 209 | .onChange(save => { 210 | this.profileData.saveUsername = save; 211 | renderProfile(); 212 | }) 213 | ); 214 | const passwordSetting = new Setting(content) 215 | .setName(this.t('profileModal_rememberPassword')); 216 | if (this.profileData.savePassword) { 217 | passwordSetting 218 | .addText(text => text 219 | .setValue(this.profileData.password ?? '') 220 | .onChange((value) => { 221 | this.profileData.password = value; 222 | }) 223 | ); 224 | } 225 | passwordSetting.addToggle(toggle => toggle 226 | .setValue(this.profileData.savePassword) 227 | .onChange(save => { 228 | this.profileData.savePassword = save; 229 | renderProfile(); 230 | }) 231 | ); 232 | } 233 | new Setting(content) 234 | .setName(this.t('profileModal_setDefault')) 235 | .addToggle(toggle => toggle 236 | .setValue(this.profileData.isDefault) 237 | .onChange((value) => { 238 | this.profileData.isDefault = value; 239 | }) 240 | ); 241 | 242 | new Setting(content) 243 | .addButton(button => button 244 | .setButtonText(this.t('profileModal_Save')) 245 | .setCta() 246 | .onClick(() => { 247 | if (!isValidUrl(this.profileData.endpoint)) { 248 | showError(this.t('error_invalidUrl')); 249 | } else if (this.profileData.name.length === 0) { 250 | showError(this.t('error_noProfileName')); 251 | } else if (this.profileData.saveUsername && !this.profileData.username) { 252 | showError(this.t('error_noUsername')); 253 | } else if (this.profileData.savePassword && !this.profileData.password) { 254 | showError(this.t('error_noPassword')); 255 | } else { 256 | this.onSubmit(this.profileData, this.atIndex); 257 | this.close(); 258 | } 259 | }) 260 | ); 261 | } 262 | 263 | this.createHeader(this.t('profileModal_title')); 264 | 265 | const { contentEl } = this; 266 | 267 | const content = contentEl.createEl('div'); 268 | renderProfile(); 269 | } 270 | 271 | onClose() { 272 | if (this.tokenGotRef) { 273 | AppState.events.offref(this.tokenGotRef); 274 | } 275 | const { contentEl } = this; 276 | contentEl.empty(); 277 | } 278 | 279 | private async refreshWpComToken(): Promise { 280 | AppState.codeVerifier = generateCodeVerifier(); 281 | await OAuth2Client.getWpOAuth2Client(this.plugin).getAuthorizeCode({ 282 | redirectUri: WP_OAUTH2_REDIRECT_URI, 283 | scope: [ 'posts', 'taxonomy', 'media', 'sites' ], 284 | blog: this.profileData.endpoint, 285 | codeVerifier: AppState.codeVerifier 286 | }); 287 | } 288 | 289 | } 290 | -------------------------------------------------------------------------------- /src/wp-profile.ts: -------------------------------------------------------------------------------- 1 | import { WordPressOAuth2Token } from './oauth2-client'; 2 | import { ApiType } from './plugin-settings'; 3 | import { PostType } from './wp-api'; 4 | 5 | export interface WpProfile { 6 | 7 | /** 8 | * Profile name. 9 | */ 10 | name: string; 11 | 12 | /** 13 | * API type. 14 | */ 15 | apiType: ApiType; 16 | 17 | /** 18 | * Endpoint. 19 | */ 20 | endpoint: string; 21 | 22 | /** 23 | * XML-RPC path. 24 | */ 25 | xmlRpcPath?: string; 26 | 27 | /** 28 | * WordPress username. 29 | */ 30 | username?: string; 31 | 32 | /** 33 | * WordPress password. 34 | */ 35 | password?: string; 36 | 37 | /** 38 | * Encrypted password which will be saved locally. 39 | */ 40 | encryptedPassword?: { 41 | encrypted: string; 42 | key?: string; 43 | vector?: string; 44 | }; 45 | 46 | /** 47 | * OAuth2 token for wordpress.com 48 | */ 49 | wpComOAuth2Token?: WordPressOAuth2Token; 50 | 51 | /** 52 | * Save username to local data. 53 | */ 54 | saveUsername: boolean; 55 | 56 | /** 57 | * Save user password to local data. 58 | */ 59 | savePassword: boolean; 60 | 61 | /** 62 | * Is default profile. 63 | */ 64 | isDefault: boolean; 65 | 66 | /** 67 | * Last selected post categories. 68 | */ 69 | lastSelectedCategories: number[]; 70 | } 71 | -------------------------------------------------------------------------------- /src/wp-publish-modal.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import WordpressPlugin from './main'; 3 | import { WordPressPostParams } from './wp-client'; 4 | import { CommentStatus, PostStatus, PostType, PostTypeConst, Term } from './wp-api'; 5 | import { toNumber } from 'lodash-es'; 6 | import { MatterData } from './types'; 7 | import { ConfirmCode, openConfirmModal } from './confirm-modal'; 8 | import { AbstractModal } from './abstract-modal'; 9 | import IMask, { DynamicMaskType, InputMask } from 'imask'; 10 | import { SafeAny } from './utils'; 11 | import { format, parse } from 'date-fns'; 12 | 13 | 14 | /** 15 | * WordPress publish modal. 16 | */ 17 | export class WpPublishModal extends AbstractModal { 18 | 19 | private dateInputMask: InputMask | null = null; 20 | 21 | constructor( 22 | readonly plugin: WordpressPlugin, 23 | private readonly categories: { 24 | items: Term[], 25 | selected: number[] 26 | }, 27 | private readonly postTypes: { 28 | items: PostType[], 29 | selected: PostType 30 | }, 31 | private readonly onSubmit: (params: WordPressPostParams, updateMatterData: (matter: MatterData) => void) => void, 32 | private readonly matterData: MatterData, 33 | ) { 34 | super(plugin); 35 | } 36 | 37 | onOpen() { 38 | const params: WordPressPostParams = { 39 | status: this.plugin.settings.defaultPostStatus, 40 | commentStatus: this.plugin.settings.defaultCommentStatus, 41 | postType: this.postTypes.selected, 42 | categories: this.categories.selected, 43 | tags: [], 44 | title: '', 45 | content: '' 46 | }; 47 | 48 | this.display(params); 49 | } 50 | 51 | onClose() { 52 | const { contentEl } = this; 53 | contentEl.empty(); 54 | if (this.dateInputMask) { 55 | this.dateInputMask.destroy(); 56 | } 57 | } 58 | 59 | private display(params: WordPressPostParams): void { 60 | const { contentEl } = this; 61 | 62 | contentEl.empty(); 63 | 64 | this.createHeader(this.t('publishModal_title')); 65 | 66 | new Setting(contentEl) 67 | .setName(this.t('publishModal_postStatus')) 68 | .addDropdown((dropdown) => { 69 | dropdown 70 | .addOption(PostStatus.Draft, this.t('publishModal_postStatusDraft')) 71 | .addOption(PostStatus.Publish, this.t('publishModal_postStatusPublish')) 72 | .addOption(PostStatus.Private, this.t('publishModal_postStatusPrivate')) 73 | .addOption(PostStatus.Future, this.t('publishModal_postStatusFuture')) 74 | .setValue(params.status) 75 | .onChange((value) => { 76 | params.status = value as PostStatus; 77 | this.display(params); 78 | }); 79 | }); 80 | 81 | if (params.status === PostStatus.Future) { 82 | new Setting(contentEl) 83 | .setName(this.t('publishModal_postDateTime')) 84 | .setDesc(this.t('publishModal_postDateTimeDesc')) 85 | .addText(text => { 86 | const dateFormat = 'yyyy-MM-dd'; 87 | const dateTimeFormat = 'yyyy-MM-dd HH:mm:ss'; 88 | const dateBlocks = { 89 | yyyy: { 90 | mask: IMask.MaskedRange, 91 | from: 1970, 92 | to: 9999, 93 | }, 94 | MM: { 95 | mask: IMask.MaskedRange, 96 | from: 1, 97 | to: 12, 98 | }, 99 | dd: { 100 | mask: IMask.MaskedRange, 101 | from: 1, 102 | to: 31, 103 | }, 104 | }; 105 | const dateMask = { 106 | mask: Date, 107 | lazy: false, 108 | overwrite: true, 109 | }; 110 | if (this.dateInputMask) { 111 | this.dateInputMask.destroy(); 112 | } 113 | this.dateInputMask = IMask(text.inputEl, [ 114 | { 115 | ...dateMask, 116 | pattern: dateFormat, 117 | blocks: dateBlocks, 118 | format: (date: SafeAny) => format(date, dateFormat), 119 | parse: (str: string) => parse(str, dateFormat, new Date()) 120 | }, 121 | { 122 | ...dateMask, 123 | pattern: dateTimeFormat, 124 | blocks: { 125 | ...dateBlocks, 126 | HH: { 127 | mask: IMask.MaskedRange, 128 | from: 0, 129 | to: 23, 130 | }, 131 | mm: { 132 | mask: IMask.MaskedRange, 133 | from: 0, 134 | to: 59, 135 | }, 136 | ss: { 137 | mask: IMask.MaskedRange, 138 | from: 0, 139 | to: 59, 140 | }, 141 | }, 142 | format: (date: SafeAny) => format(date, dateTimeFormat), 143 | parse: (str: string) => parse(str, dateTimeFormat, new Date()) 144 | } 145 | ]); 146 | 147 | this.dateInputMask.on('accept', () => { 148 | if (this.dateInputMask) { 149 | if (this.dateInputMask.masked.isComplete) { 150 | text.inputEl.style.borderColor = ''; 151 | params.datetime = this.dateInputMask.typedValue; 152 | } else { 153 | text.inputEl.style.borderColor = 'red'; 154 | } 155 | } 156 | }); 157 | }); 158 | } else { 159 | delete params.datetime; 160 | } 161 | 162 | new Setting(contentEl) 163 | .setName(this.t('publishModal_commentStatus')) 164 | .addDropdown((dropdown) => { 165 | dropdown 166 | .addOption(CommentStatus.Open, this.t('publishModal_commentStatusOpen')) 167 | .addOption(CommentStatus.Closed, this.t('publishModal_commentStatusClosed')) 168 | .setValue(params.commentStatus) 169 | .onChange((value) => { 170 | params.commentStatus = value as CommentStatus; 171 | }); 172 | }); 173 | 174 | if (!this.matterData?.postId) { 175 | new Setting(contentEl) 176 | .setName(this.t('publishModal_postType')) 177 | .addDropdown((dropdown) => { 178 | this.postTypes.items.forEach(it => { 179 | dropdown.addOption(it, it); 180 | }); 181 | dropdown 182 | .setValue(params.postType) 183 | .onChange((value) => { 184 | params.postType = value as PostType; 185 | this.display(params); 186 | }); 187 | }); 188 | } 189 | 190 | if (params.postType === PostTypeConst.Post) { 191 | if (this.categories.items.length > 0) { 192 | new Setting(contentEl) 193 | .setName(this.t('publishModal_category')) 194 | .addDropdown((dropdown) => { 195 | this.categories.items.forEach(it => { 196 | dropdown.addOption(it.id, it.name); 197 | }); 198 | dropdown 199 | .setValue(String(params.categories[0])) 200 | .onChange((value) => { 201 | params.categories = [ toNumber(value) ]; 202 | }); 203 | }); 204 | } 205 | } 206 | new Setting(contentEl) 207 | .addButton(button => button 208 | .setButtonText(this.t('publishModal_publishButtonText')) 209 | .setCta() 210 | .onClick(() => { 211 | if (this.matterData.postType 212 | && this.matterData.postType !== PostTypeConst.Post 213 | && (this.matterData.tags || this.matterData.categories) 214 | ) { 215 | openConfirmModal({ 216 | message: this.t('publishModal_wrongMatterDataForPage') 217 | }, this.plugin) 218 | .then(result => { 219 | if (result.code === ConfirmCode.Confirm) { 220 | this.onSubmit(params, fm => { 221 | delete fm.categories; 222 | delete fm.tags; 223 | }); 224 | } 225 | }); 226 | } else { 227 | this.onSubmit(params, fm => {}); 228 | } 229 | }) 230 | ); 231 | } 232 | 233 | } 234 | -------------------------------------------------------------------------------- /src/wp-rest-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WordPressAuthParams, 3 | WordPressClientResult, 4 | WordPressClientReturnCode, 5 | WordPressMediaUploadResult, 6 | WordPressPostParams, 7 | WordPressPublishResult 8 | } from './wp-client'; 9 | import { AbstractWordPressClient } from './abstract-wp-client'; 10 | import WordpressPlugin from './main'; 11 | import { PostStatus, PostType, Term } from './wp-api'; 12 | import { RestClient } from './rest-client'; 13 | import { isArray, isFunction, isNumber, isObject, isString, template } from 'lodash-es'; 14 | import { SafeAny } from './utils'; 15 | import { WpProfile } from './wp-profile'; 16 | import { FormItemNameMapper, FormItems, Media } from './types'; 17 | import { formatISO } from 'date-fns'; 18 | 19 | 20 | interface WpRestEndpoint { 21 | base: string | UrlGetter; 22 | newPost: string | UrlGetter; 23 | editPost: string | UrlGetter; 24 | getCategories: string | UrlGetter; 25 | newTag: string | UrlGetter; 26 | getTag: string | UrlGetter; 27 | validateUser: string | UrlGetter; 28 | uploadFile: string | UrlGetter; 29 | getPostTypes: string | UrlGetter; 30 | } 31 | 32 | export class WpRestClient extends AbstractWordPressClient { 33 | 34 | private readonly client: RestClient; 35 | 36 | constructor( 37 | readonly plugin: WordpressPlugin, 38 | readonly profile: WpProfile, 39 | private readonly context: WpRestClientContext 40 | ) { 41 | super(plugin, profile); 42 | this.name = 'WpRestClient'; 43 | this.client = new RestClient({ 44 | url: new URL(getUrl(this.context.endpoints?.base, profile.endpoint)) 45 | }); 46 | } 47 | 48 | protected needLogin(): boolean { 49 | if (this.context.needLoginModal !== undefined) { 50 | return this.context.needLoginModal; 51 | } 52 | return super.needLogin(); 53 | } 54 | 55 | async publish( 56 | title: string, 57 | content: string, 58 | postParams: WordPressPostParams, 59 | certificate: WordPressAuthParams 60 | ): Promise> { 61 | let url: string; 62 | if (postParams.postId) { 63 | url = getUrl(this.context.endpoints?.editPost, 'wp-json/wp/v2/posts/<%= postId %>', { 64 | postId: postParams.postId 65 | }); 66 | } else { 67 | url = getUrl(this.context.endpoints?.newPost, 'wp-json/wp/v2/posts'); 68 | } 69 | const extra: Record = {}; 70 | if (postParams.status === PostStatus.Future) { 71 | extra.date = formatISO(postParams.datetime ?? new Date()); 72 | } 73 | const resp: SafeAny = await this.client.httpPost( 74 | url, 75 | { 76 | title, 77 | content, 78 | status: postParams.status, 79 | comment_status: postParams.commentStatus, 80 | categories: postParams.categories, 81 | tags: postParams.tags ?? [], 82 | ...extra 83 | }, 84 | { 85 | headers: this.context.getHeaders(certificate) 86 | }); 87 | console.log('WpRestClient response', resp); 88 | try { 89 | const result = this.context.responseParser.toWordPressPublishResult(postParams, resp); 90 | return { 91 | code: WordPressClientReturnCode.OK, 92 | data: result, 93 | response: resp 94 | }; 95 | } catch (e) { 96 | return { 97 | code: WordPressClientReturnCode.Error, 98 | error: { 99 | code: WordPressClientReturnCode.ServerInternalError, 100 | message: this.plugin.i18n.t('error_cannotParseResponse') 101 | }, 102 | response: resp 103 | }; 104 | } 105 | } 106 | 107 | async getCategories(certificate: WordPressAuthParams): Promise { 108 | const data = await this.client.httpGet( 109 | getUrl(this.context.endpoints?.getCategories, 'wp-json/wp/v2/categories?per_page=100'), 110 | { 111 | headers: this.context.getHeaders(certificate) 112 | }); 113 | return this.context.responseParser.toTerms(data); 114 | } 115 | 116 | async getPostTypes(certificate: WordPressAuthParams): Promise { 117 | const data: SafeAny = await this.client.httpGet( 118 | getUrl(this.context.endpoints?.getPostTypes, 'wp-json/wp/v2/types'), 119 | { 120 | headers: this.context.getHeaders(certificate) 121 | }); 122 | return this.context.responseParser.toPostTypes(data); 123 | } 124 | 125 | async validateUser(certificate: WordPressAuthParams): Promise> { 126 | try { 127 | const data = await this.client.httpGet( 128 | getUrl(this.context.endpoints?.validateUser, `wp-json/wp/v2/users/me`), 129 | { 130 | headers: this.context.getHeaders(certificate) 131 | }); 132 | return { 133 | code: WordPressClientReturnCode.OK, 134 | data: !!data, 135 | response: data 136 | }; 137 | } catch(error) { 138 | return { 139 | code: WordPressClientReturnCode.Error, 140 | error: { 141 | code: WordPressClientReturnCode.Error, 142 | message: this.plugin.i18n.t('error_invalidUser'), 143 | }, 144 | response: error 145 | }; 146 | } 147 | } 148 | 149 | async getTag(name: string, certificate: WordPressAuthParams): Promise { 150 | const termResp: SafeAny = await this.client.httpGet( 151 | getUrl(this.context.endpoints?.getTag, 'wp-json/wp/v2/tags?number=1&search=<%= name %>', { 152 | name 153 | }), 154 | ); 155 | const exists = this.context.responseParser.toTerms(termResp); 156 | if (exists.length === 0) { 157 | const resp = await this.client.httpPost( 158 | getUrl(this.context.endpoints?.newTag, 'wp-json/wp/v2/tags'), 159 | { 160 | name 161 | }, 162 | { 163 | headers: this.context.getHeaders(certificate) 164 | }); 165 | console.log('WpRestClient newTag response', resp); 166 | return this.context.responseParser.toTerm(resp); 167 | } else { 168 | return exists[0]; 169 | } 170 | } 171 | 172 | async uploadMedia(media: Media, certificate: WordPressAuthParams): Promise> { 173 | try { 174 | const formItems = new FormItems(); 175 | formItems.append('file', media); 176 | 177 | const response: SafeAny = await this.client.httpPost( 178 | getUrl(this.context.endpoints?.uploadFile, 'wp-json/wp/v2/media'), 179 | formItems, 180 | { 181 | headers: { 182 | ...this.context.getHeaders(certificate) 183 | }, 184 | formItemNameMapper: this.context.formItemNameMapper 185 | }); 186 | const result = this.context.responseParser.toWordPressMediaUploadResult(response); 187 | return { 188 | code: WordPressClientReturnCode.OK, 189 | data: result, 190 | response 191 | }; 192 | } catch (e: SafeAny) { 193 | console.error('uploadMedia', e); 194 | return { 195 | code: WordPressClientReturnCode.Error, 196 | error: { 197 | code: WordPressClientReturnCode.ServerInternalError, 198 | message: e.toString() 199 | }, 200 | response: undefined 201 | }; 202 | } 203 | } 204 | 205 | } 206 | 207 | type UrlGetter = () => string; 208 | 209 | function getUrl( 210 | url: string | UrlGetter | undefined, 211 | defaultValue: string, 212 | params?: { [p: string]: string | number } 213 | ): string { 214 | let resultUrl: string; 215 | if (isString(url)) { 216 | resultUrl = url; 217 | } else if (isFunction(url)) { 218 | resultUrl = url(); 219 | } else { 220 | resultUrl = defaultValue; 221 | } 222 | if (params) { 223 | const compiled = template(resultUrl); 224 | return compiled(params); 225 | } else { 226 | return resultUrl; 227 | } 228 | } 229 | 230 | interface WpRestClientContext { 231 | name: string; 232 | 233 | responseParser: { 234 | toWordPressPublishResult: (postParams: WordPressPostParams, response: SafeAny) => WordPressPublishResult; 235 | /** 236 | * Convert response to `WordPressMediaUploadResult`. 237 | * 238 | * If there is any error, throw new error directly. 239 | * @param response response from remote server 240 | */ 241 | toWordPressMediaUploadResult: (response: SafeAny) => WordPressMediaUploadResult; 242 | toTerms: (response: SafeAny) => Term[]; 243 | toTerm: (response: SafeAny) => Term; 244 | toPostTypes: (response: SafeAny) => PostType[]; 245 | }; 246 | 247 | endpoints?: Partial; 248 | 249 | needLoginModal?: boolean; 250 | 251 | formItemNameMapper?: FormItemNameMapper; 252 | 253 | getHeaders(wp: WordPressAuthParams): Record; 254 | 255 | } 256 | 257 | class WpRestClientCommonContext implements WpRestClientContext { 258 | name = 'WpRestClientCommonContext'; 259 | 260 | getHeaders(wp: WordPressAuthParams): Record { 261 | return { 262 | 'authorization': `Basic ${btoa(`${wp.username}:${wp.password}`)}` 263 | }; 264 | } 265 | 266 | responseParser = { 267 | toWordPressPublishResult: (postParams: WordPressPostParams, response: SafeAny): WordPressPublishResult => { 268 | if (response.id) { 269 | return { 270 | postId: postParams.postId ?? response.id, 271 | categories: postParams.categories ?? response.categories 272 | } 273 | } 274 | throw new Error('xx'); 275 | }, 276 | toWordPressMediaUploadResult: (response: SafeAny): WordPressMediaUploadResult => { 277 | return { 278 | url: response.source_url 279 | }; 280 | }, 281 | toTerms: (response: SafeAny): Term[] => { 282 | if (isArray(response)) { 283 | return response as Term[]; 284 | } 285 | return []; 286 | }, 287 | toTerm: (response: SafeAny): Term => ({ 288 | ...response, 289 | id: response.id 290 | }), 291 | toPostTypes: (response: SafeAny): PostType[] => { 292 | if (isObject(response)) { 293 | return Object.keys(response); 294 | } 295 | return []; 296 | } 297 | }; 298 | } 299 | 300 | export class WpRestClientMiniOrangeContext extends WpRestClientCommonContext { 301 | name = 'WpRestClientMiniOrangeContext'; 302 | 303 | constructor() { 304 | super(); 305 | console.log(`${this.name} loaded`); 306 | } 307 | } 308 | 309 | export class WpRestClientAppPasswordContext extends WpRestClientCommonContext { 310 | name = 'WpRestClientAppPasswordContext'; 311 | 312 | constructor() { 313 | super(); 314 | console.log(`${this.name} loaded`); 315 | } 316 | } 317 | 318 | export class WpRestClientWpComOAuth2Context implements WpRestClientContext { 319 | name = 'WpRestClientWpComOAuth2Context'; 320 | 321 | needLoginModal = false; 322 | 323 | endpoints: WpRestEndpoint = { 324 | base: 'https://public-api.wordpress.com', 325 | newPost: () => `/rest/v1.1/sites/${this.site}/posts/new`, 326 | editPost: () => `/rest/v1.1/sites/${this.site}/posts/<%= postId %>`, 327 | getCategories: () => `/rest/v1.1/sites/${this.site}/categories`, 328 | newTag: () => `/rest/v1.1/sites/${this.site}/tags/new`, 329 | getTag: () => `/rest/v1.1/sites/${this.site}/tags?number=1&search=<%= name %>`, 330 | validateUser: () => `/rest/v1.1/sites/${this.site}/posts?number=1`, 331 | uploadFile: () => `/rest/v1.1/sites/${this.site}/media/new`, 332 | getPostTypes: () => `/rest/v1.1/sites/${this.site}/post-types`, 333 | }; 334 | 335 | constructor( 336 | private readonly site: string, 337 | private readonly accessToken: string 338 | ) { 339 | console.log(`${this.name} loaded`); 340 | } 341 | 342 | formItemNameMapper(name: string, isArray: boolean): string { 343 | if (name === 'file' && !isArray) { 344 | return 'media[]'; 345 | } 346 | return name; 347 | } 348 | 349 | getHeaders(wp: WordPressAuthParams): Record { 350 | return { 351 | 'authorization': `BEARER ${this.accessToken}` 352 | }; 353 | } 354 | 355 | responseParser = { 356 | toWordPressPublishResult: (postParams: WordPressPostParams, response: SafeAny): WordPressPublishResult => { 357 | if (response.ID) { 358 | return { 359 | postId: postParams.postId ?? response.ID, 360 | categories: postParams.categories ?? Object.values(response.categories).map((cat: SafeAny) => cat.ID) 361 | }; 362 | } 363 | throw new Error('xx'); 364 | }, 365 | toWordPressMediaUploadResult: (response: SafeAny): WordPressMediaUploadResult => { 366 | if (response.media.length > 0) { 367 | const media = response.media[0]; 368 | return { 369 | url: media.link 370 | }; 371 | } else if (response.errors) { 372 | throw new Error(response.errors.error.message); 373 | } 374 | throw new Error('Upload failed'); 375 | }, 376 | toTerms: (response: SafeAny): Term[] => { 377 | if (isNumber(response.found)) { 378 | return response 379 | .categories 380 | .map((it: Term & { ID: number; }) => ({ 381 | ...it, 382 | id: String(it.ID) 383 | })); 384 | } 385 | return []; 386 | }, 387 | toTerm: (response: SafeAny): Term => ({ 388 | ...response, 389 | id: response.ID 390 | }), 391 | toPostTypes: (response: SafeAny): PostType[] => { 392 | if (isNumber(response.found)) { 393 | return response 394 | .post_types 395 | .map((it: { name: string }) => (it.name)); 396 | } 397 | return []; 398 | } 399 | }; 400 | } 401 | -------------------------------------------------------------------------------- /src/wp-xml-rpc-client.ts: -------------------------------------------------------------------------------- 1 | import WordpressPlugin from './main'; 2 | import { 3 | WordPressAuthParams, 4 | WordPressClientResult, 5 | WordPressClientReturnCode, 6 | WordPressMediaUploadResult, 7 | WordPressPostParams, 8 | WordPressPublishResult 9 | } from './wp-client'; 10 | import { XmlRpcClient } from './xmlrpc-client'; 11 | import { AbstractWordPressClient } from './abstract-wp-client'; 12 | import { PostStatus, PostType, PostTypeConst, Term } from './wp-api'; 13 | import { SafeAny, showError } from './utils'; 14 | import { WpProfile } from './wp-profile'; 15 | import { Media } from './types'; 16 | 17 | interface FaultResponse { 18 | faultCode: string; 19 | faultString: string; 20 | } 21 | 22 | function isFaultResponse(response: unknown): response is FaultResponse { 23 | return (response as FaultResponse).faultCode !== undefined; 24 | } 25 | 26 | export class WpXmlRpcClient extends AbstractWordPressClient { 27 | 28 | private readonly client: XmlRpcClient; 29 | 30 | constructor( 31 | readonly plugin: WordpressPlugin, 32 | readonly profile: WpProfile 33 | ) { 34 | super(plugin, profile); 35 | this.name = 'WpXmlRpcClient'; 36 | this.client = new XmlRpcClient({ 37 | url: new URL(profile.endpoint), 38 | xmlRpcPath: profile.xmlRpcPath ?? '' 39 | }); 40 | } 41 | 42 | async publish( 43 | title: string, 44 | content: string, 45 | postParams: WordPressPostParams, 46 | certificate: WordPressAuthParams 47 | ): Promise> { 48 | let publishContent; 49 | if (postParams.postType === PostTypeConst.Page) { 50 | publishContent = { 51 | post_type: postParams.postType, 52 | post_status: postParams.status, 53 | comment_status: postParams.commentStatus, 54 | post_title: title, 55 | post_content: content, 56 | }; 57 | } else { 58 | publishContent = { 59 | post_type: postParams.postType, 60 | post_status: postParams.status, 61 | comment_status: postParams.commentStatus, 62 | post_title: title, 63 | post_content: content, 64 | terms: { 65 | 'category': postParams.categories 66 | }, 67 | terms_names: { 68 | 'post_tag': postParams.tags 69 | } 70 | }; 71 | } 72 | if (postParams.status === PostStatus.Future) { 73 | publishContent = { 74 | ...publishContent, 75 | post_date: postParams.datetime ?? new Date() 76 | }; 77 | } 78 | let publishPromise; 79 | if (postParams.postId) { 80 | publishPromise = this.client.methodCall('wp.editPost', [ 81 | 0, 82 | certificate.username, 83 | certificate.password, 84 | postParams.postId, 85 | publishContent 86 | ]); 87 | } else { 88 | publishPromise = this.client.methodCall('wp.newPost', [ 89 | 0, 90 | certificate.username, 91 | certificate.password, 92 | publishContent 93 | ]); 94 | } 95 | const response = await publishPromise; 96 | if (isFaultResponse(response)) { 97 | return { 98 | code: WordPressClientReturnCode.Error, 99 | error: { 100 | code: response.faultCode, 101 | message: response.faultString 102 | }, 103 | response 104 | }; 105 | } 106 | return { 107 | code: WordPressClientReturnCode.OK, 108 | data: { 109 | postId: postParams.postId ?? (response as string), 110 | categories: postParams.categories 111 | }, 112 | response 113 | }; 114 | } 115 | 116 | async getCategories(certificate: WordPressAuthParams): Promise { 117 | const response = await this.client.methodCall('wp.getTerms', [ 118 | 0, 119 | certificate.username, 120 | certificate.password, 121 | 'category' 122 | ]); 123 | if (isFaultResponse(response)) { 124 | const fault = `${response.faultCode}: ${response.faultString}`; 125 | showError(fault); 126 | throw new Error(fault); 127 | } 128 | return (response as SafeAny).map((it: SafeAny) => ({ 129 | ...it, 130 | id: it.term_id 131 | })) ?? []; 132 | } 133 | 134 | async getPostTypes(certificate: WordPressAuthParams): Promise { 135 | const response = await this.client.methodCall('wp.getPostTypes', [ 136 | 0, 137 | certificate.username, 138 | certificate.password, 139 | ]); 140 | if (isFaultResponse(response)) { 141 | const fault = `${response.faultCode}: ${response.faultString}`; 142 | showError(fault); 143 | throw new Error(fault); 144 | } 145 | return Object.keys(response as SafeAny) ?? []; 146 | } 147 | 148 | async validateUser(certificate: WordPressAuthParams): Promise> { 149 | const response = await this.client.methodCall('wp.getProfile', [ 150 | 0, 151 | certificate.username, 152 | certificate.password 153 | ]); 154 | if (isFaultResponse(response)) { 155 | return { 156 | code: WordPressClientReturnCode.Error, 157 | error: { 158 | code: response.faultCode, 159 | message: `${response.faultCode}: ${response.faultString}` 160 | }, 161 | response 162 | }; 163 | } else { 164 | return { 165 | code: WordPressClientReturnCode.OK, 166 | data: !!response, 167 | response 168 | }; 169 | } 170 | } 171 | 172 | getTag(name: string, certificate: WordPressAuthParams): Promise { 173 | return Promise.resolve({ 174 | id: name, 175 | name, 176 | slug: name, 177 | taxonomy: 'post_tag', 178 | description: name, 179 | count: 0 180 | }); 181 | } 182 | 183 | async uploadMedia(media: Media, certificate: WordPressAuthParams): Promise> { 184 | const wpMedia = { 185 | name: media.fileName, 186 | type: media.mimeType, 187 | bits: media.content, 188 | }; 189 | const response = await this.client.methodCall('wp.uploadFile', [ 190 | 0, 191 | certificate.username, 192 | certificate.password, 193 | wpMedia, 194 | ]); 195 | if (isFaultResponse(response)) { 196 | return { 197 | code: WordPressClientReturnCode.Error, 198 | error: { 199 | code: response.faultCode, 200 | message: `${response.faultCode}: ${response.faultString}` 201 | }, 202 | response 203 | }; 204 | } else { 205 | return { 206 | code: WordPressClientReturnCode.OK, 207 | data: { 208 | url: (response as SafeAny).url 209 | }, 210 | response 211 | }; 212 | } 213 | } 214 | 215 | } 216 | -------------------------------------------------------------------------------- /src/xmlrpc-client.ts: -------------------------------------------------------------------------------- 1 | import { arrayBufferToBase64, request } from 'obsidian'; 2 | import { isArray, isArrayBuffer, isBoolean, isDate, isNumber, isObject, isSafeInteger } from 'lodash-es'; 3 | import { format, parse } from 'date-fns'; 4 | import { SafeAny } from './utils'; 5 | 6 | interface XmlRpcOptions { 7 | url: URL; 8 | xmlRpcPath: string; 9 | } 10 | 11 | export class XmlRpcClient { 12 | 13 | /** 14 | * Href without '/' at the very end. 15 | * @private 16 | */ 17 | private readonly href: string; 18 | 19 | /** 20 | * XML-RPC path without '/' at the beginning or end. 21 | * @private 22 | */ 23 | private readonly xmlRpcPath: string; 24 | 25 | private readonly endpoint: string; 26 | 27 | constructor( 28 | private readonly options: XmlRpcOptions 29 | ) { 30 | console.log(options); 31 | 32 | this.href = this.options.url.href; 33 | if (this.href.endsWith('/')) { 34 | this.href = this.href.substring(0, this.href.length - 1); 35 | } 36 | 37 | this.xmlRpcPath = this.options.xmlRpcPath ?? ''; 38 | if (this.xmlRpcPath.startsWith('/')) { 39 | this.xmlRpcPath = this.xmlRpcPath.substring(1); 40 | } 41 | if (this.xmlRpcPath.endsWith('/')) { 42 | this.xmlRpcPath = this.xmlRpcPath.substring(0, this.xmlRpcPath.length - 1); 43 | } 44 | 45 | this.endpoint = `${this.href}/${this.xmlRpcPath}`; 46 | } 47 | 48 | methodCall( 49 | method: string, 50 | params: unknown 51 | ): Promise { 52 | const xml = this.objectToXml(method, params); 53 | console.log(`Endpoint: ${this.endpoint}, ${method}, request: ${xml}`, params); 54 | return request({ 55 | url: this.endpoint, 56 | method: 'POST', 57 | headers: { 58 | 'Content-Type': 'text/xml', 59 | 'User-Agent': 'obsidian.md' 60 | }, 61 | body: xml 62 | }) 63 | .then(res => this.responseXmlToObject(res)); 64 | } 65 | 66 | private objectToXml(method: string, ...obj: unknown[]): string { 67 | const doc = document.implementation.createDocument('', '', null); 68 | const methodCall = doc.createElement('methodCall'); 69 | 70 | doc.appendChild(methodCall); 71 | const pi = doc.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"'); 72 | doc.insertBefore(pi, doc.firstChild); 73 | 74 | const methodName = doc.createElement('methodName'); 75 | methodName.appendText(method); 76 | const params = doc.createElement('params'); 77 | methodCall.appendChild(methodName); 78 | methodCall.appendChild(params); 79 | obj.forEach(it => this.createParam(it, params, doc)); 80 | return new XMLSerializer().serializeToString(doc); 81 | } 82 | 83 | private createParam(obj: unknown, params: HTMLElement, doc: XMLDocument): void { 84 | const param = doc.createElement('param'); 85 | params.appendChild(param); 86 | this.createValue(obj, param, doc); 87 | } 88 | 89 | private createValue(data: unknown, parent: HTMLElement, doc: XMLDocument): void { 90 | const value = doc.createElement('value'); 91 | parent.appendChild(value); 92 | if (isSafeInteger(data)) { 93 | const i4 = doc.createElement('i4'); 94 | i4.appendText((data as SafeAny).toString()); 95 | value.appendChild(i4); 96 | } else if (isNumber(data)) { 97 | const double = doc.createElement('double'); 98 | double.appendText((data as SafeAny).toString()); 99 | value.appendChild(double); 100 | } else if (isBoolean(data)) { 101 | const boolean = doc.createElement('boolean'); 102 | boolean.appendText(data ? '1' : '0'); 103 | value.appendChild(boolean); 104 | } else if (isDate(data)) { 105 | const date = doc.createElement('dateTime.iso8601'); 106 | date.appendText(format(data as Date, "yyyyMMdd'T'HH:mm:ss")); 107 | value.appendChild(date); 108 | } else if (isArray(data)) { 109 | const array = doc.createElement('array'); 110 | const arrayData = doc.createElement('data'); 111 | array.appendChild(arrayData); 112 | (data as unknown[]).forEach(it => this.createValue(it, arrayData, doc)); 113 | value.appendChild(array); 114 | } else if (isArrayBuffer(data)) { 115 | const base64 = doc.createElement('base64'); 116 | base64.setText(arrayBufferToBase64(data)); 117 | value.appendChild(base64); 118 | } else if (isObject(data)) { 119 | const struct = doc.createElement('struct'); 120 | for (const [ propName, propValue] of Object.entries(data)) { 121 | const member = doc.createElement('member'); 122 | struct.appendChild(member); 123 | const memberName = doc.createElement('name'); 124 | memberName.setText(propName); 125 | member.appendChild(memberName); 126 | this.createValue(propValue, member, doc); 127 | } 128 | value.appendChild(struct); 129 | } else { 130 | const string = doc.createElement('string'); 131 | const cdata = doc.createCDATASection((data as SafeAny).toString()); 132 | string.appendChild(cdata); 133 | value.appendChild(string); 134 | } 135 | } 136 | 137 | private responseXmlToObject(xml: string): unknown { 138 | const parser = new DOMParser(); 139 | const doc = parser.parseFromString(xml, 'application/xml'); 140 | const methodResponse = doc.getElementsByTagName('methodResponse')[0]; 141 | const faults = methodResponse.getElementsByTagName('fault'); 142 | let response: unknown; 143 | if (faults.length > 0) { 144 | const faultValue = faults[0] 145 | .children[0] // 146 | .children[0]; 147 | response = this.fromElement(faultValue); 148 | } else { 149 | const responseValue = methodResponse 150 | .children[0] // 151 | .children[0] // 152 | .children[0] // 153 | .children[0]; 154 | response = this.fromElement(responseValue); 155 | } 156 | console.log(`response: ${xml}`, response); 157 | return response; 158 | } 159 | 160 | private fromElement(element: Element): unknown { 161 | const tagName = element.tagName; 162 | if (tagName === 'string') { 163 | return element.getText(); 164 | } else if (tagName === 'i4' || tagName === 'int') { 165 | return element.getText(); 166 | } else if (tagName === 'double') { 167 | return element.getText(); 168 | } else if (tagName === 'boolean') { 169 | return element.getText() === '1'; 170 | } else if (tagName === 'dateTime.iso8601') { 171 | const datetime = element.getText(); 172 | if (datetime) { 173 | return parse(datetime, "yyyyMMdd'T'HH:mm:ss", new Date()); 174 | } else { 175 | return new Date(); 176 | } 177 | } else if (tagName === 'array') { 178 | const array = []; 179 | const arrayValues = element 180 | .children[0] // 181 | .children; // s 182 | for (let i = 0; i < arrayValues.length; i++) { 183 | array.push(this.fromElement(arrayValues[i].children[0])); 184 | } 185 | return array; 186 | } else if (tagName === 'struct') { 187 | const struct: SafeAny = {}; 188 | const members = element.children; // s 189 | for (let i = 0; i < members.length; i++) { 190 | const member = members[i]; 191 | let name; 192 | let value; 193 | for (let memberIndex = 0; memberIndex < member.children.length; memberIndex++) { 194 | const prop = member.children[memberIndex]; 195 | if (prop.tagName === 'name') { 196 | name = prop; 197 | } else if (prop.tagName === 'value') { 198 | value = prop.children[0]; 199 | } 200 | } 201 | // const name = member.getElementsByTagName('name')[0]; 202 | // const value = member.getElementsByTagName('value')[0].children[0]; 203 | if (name && value) { 204 | struct[name.getText()] = this.fromElement(value); 205 | } 206 | } 207 | return struct; 208 | } 209 | } 210 | 211 | } 212 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .modal-header { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | margin: 16px 0; 6 | } 7 | .modal-header h1 { 8 | margin: 0; 9 | } 10 | -------------------------------------------------------------------------------- /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 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | "strict": true, 17 | "lib": [ 18 | "DOM", 19 | "ES5", 20 | "ES6", 21 | "ES7" 22 | ] 23 | }, 24 | "include": [ 25 | "**/*.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.6.0": "0.12.0", 3 | "0.16.0": "1.1.1" 4 | } 5 | --------------------------------------------------------------------------------