├── .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 | [
](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 | 
11 |
12 | ## How to use
13 |
14 | Before publishing, necessary WordPress settings should be done
15 | in `WordPress` tab of Settings.
16 |
17 | 
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 | 
43 |
44 | Creates or edits a profile needs such information:
45 |
46 | 
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 | 
74 |
75 | In the next page, select `Username & Password with Base64 Encoding` then `Next`.
76 |
77 | 
78 |
79 | Finally, click `Finish`.
80 |
81 | 
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 | 
90 |
91 | You could use any application name you want, then click 'Add New Application Password' button.
92 |
93 | 
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 
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 | `
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 |
--------------------------------------------------------------------------------