├── .editorconfig ├── rs ├── .gitignore ├── src │ ├── infra │ │ ├── mod.rs │ │ ├── panic_hook.rs │ │ ├── option.rs │ │ ├── log.rs │ │ ├── result.rs │ │ └── http.rs │ ├── cnb │ │ ├── mod.rs │ │ ├── user │ │ │ └── mod.rs │ │ ├── post_tag │ │ │ └── mod.rs │ │ ├── post_cat │ │ │ ├── mod.rs │ │ │ ├── del.rs │ │ │ ├── get_cnb_category_list.rs │ │ │ ├── get_all.rs │ │ │ ├── get_one.rs │ │ │ ├── create.rs │ │ │ └── update.rs │ │ ├── post │ │ │ ├── mod.rs │ │ │ ├── get_template.rs │ │ │ ├── del_one.rs │ │ │ ├── get_one.rs │ │ │ ├── del_some.rs │ │ │ ├── update.rs │ │ │ ├── get_count.rs │ │ │ ├── get_list.rs │ │ │ └── search.rs │ │ ├── img │ │ │ ├── mod.rs │ │ │ ├── from_data_url.rs │ │ │ ├── download.rs │ │ │ └── upload.rs │ │ ├── oauth │ │ │ ├── mod.rs │ │ │ └── revoke_token.rs │ │ ├── ing │ │ │ ├── get_comment.rs │ │ │ ├── mod.rs │ │ │ ├── get_list.rs │ │ │ ├── publish.rs │ │ │ └── comment.rs │ │ └── api_base.rs │ ├── lib.rs │ ├── rand.rs │ ├── text.rs │ ├── http │ │ ├── get.rs │ │ ├── del.rs │ │ ├── put.rs │ │ ├── post.rs │ │ ├── mime_infer.rs │ │ └── mod.rs │ └── base64.rs └── Cargo.toml ├── src ├── infra │ ├── convert │ │ ├── string-literal.ts │ │ ├── map-to-json.ts │ │ └── readableToBuffer.ts │ ├── cmd.ts │ ├── http │ │ ├── infra │ │ │ ├── url-para.ts │ │ │ ├── auth-type.ts │ │ │ └── header.ts │ │ ├── req.ts │ │ └── authed-req.ts │ ├── filter │ │ └── rm-yfm.ts │ ├── fp │ │ ├── pipe.ts │ │ └── ord.ts │ ├── tree-view.ts │ ├── fmt-img-link.ts │ ├── fs │ │ └── fsUtil.ts │ ├── save-file-pending-changes.ts │ ├── uri-handler.ts │ ├── http-client.ts │ └── alert.ts ├── assets │ ├── logo.png │ ├── favicon.png │ ├── icons.woff2 │ ├── scripts │ │ └── clipboard │ │ │ ├── linux.sh │ │ │ ├── wsl.sh │ │ │ ├── windows.ps1 │ │ │ ├── mac.applescript │ │ │ └── windows10.ps1 │ ├── icon-page-previous.svg │ ├── icon-home.svg │ ├── icon-logout.svg │ ├── icon-avatar.svg │ ├── icon-page-next.svg │ ├── icon-refresh.svg │ ├── icon-account-settings.svg │ ├── icon-blog.svg │ ├── icon-image-upload.svg │ ├── icon-image-upload-dark.svg │ ├── icon-blog-management.svg │ └── favicon.svg ├── model │ ├── site-category.ts │ ├── zzk-search-result.ts │ ├── post-list-state.ts │ ├── post-edit-dto.ts │ ├── user-info.ts │ ├── img-upload-status.ts │ ├── clipboard-img.ts │ ├── post-updated-response.ts │ ├── my-config.ts │ ├── blog-export │ │ └── export-post.ts │ ├── page.ts │ ├── post-list-resp-item.ts │ ├── blog-export.ts │ ├── post-cat.ts │ ├── webview-msg.ts │ ├── blog-post.ts │ ├── blog-setting.ts │ └── webview-cmd.ts ├── cmd │ ├── open │ │ ├── os-open-active-file.ts │ │ ├── os-open-local-post-file.ts │ │ └── open-post-in-blog-admin.ts │ ├── blog-export │ │ ├── refresh.ts │ │ ├── create.ts │ │ ├── edit.ts │ │ └── view-post.ts │ ├── ing │ │ ├── pub-ing-with-select.ts │ │ ├── comment-ing.ts │ │ └── pub-ing.ts │ ├── upload-img │ │ ├── upload-img-from-path.ts │ │ ├── upload-img.ts │ │ ├── upload-fs-img.ts │ │ ├── upload-clipboard-img.ts │ │ └── upload-img-util.ts │ ├── extract-img.ts │ ├── view-post-online.ts │ ├── post-list │ │ ├── open-post-in-vscode.ts │ │ ├── open-post-file.ts │ │ ├── del-post-to-local-file-map.ts │ │ └── copy-link.ts │ ├── post-cat │ │ ├── new-post-cat.ts │ │ ├── update-post-cat-treeview.ts │ │ └── input-post-cat.ts │ ├── browser.ts │ └── workspace.ts ├── tree-view │ ├── model │ │ ├── base-tree-item-source.ts │ │ ├── base-entry-tree-item.ts │ │ ├── category-list-tree-item.ts │ │ ├── post-category-tree-item.ts │ │ ├── blog-export │ │ │ ├── index.ts │ │ │ ├── post.ts │ │ │ ├── parser.ts │ │ │ └── record-metadata.ts │ │ ├── post-tree-item.ts │ │ └── post-search-result-entry.ts │ └── navi-view.ts ├── ctx │ ├── cfg │ │ ├── post-list.ts │ │ ├── icon-theme.ts │ │ ├── ui.ts │ │ ├── chromium.ts │ │ ├── platform.ts │ │ ├── workspace.ts │ │ └── markdown.ts │ ├── ext-const.ts │ ├── local-state.ts │ └── global-ctx.ts ├── setup │ ├── setup-state.ts │ ├── setup-ui.ts │ └── setup-watch.ts ├── service │ ├── code-challenge.ts │ ├── parse-webview-html.ts │ ├── local-post.ts │ ├── blog-setting.ts │ ├── is-target-workspace.ts │ ├── blog-export │ │ ├── blog-export-records.store.ts │ │ └── blog-export.ts │ ├── extract-img │ │ └── apply-replace-list.ts │ ├── post │ │ ├── create.ts │ │ ├── post-list-view.ts │ │ └── search-post-by-title.ts │ ├── upload-img │ │ └── image.service.ts │ ├── user.service.ts │ └── ing │ │ └── ing.ts ├── auth │ ├── is-auth-session-expired.ts │ └── oauth.ts ├── markdown │ └── extend-markdownIt.ts └── extension.ts ├── test ├── tsconfig.json └── jest.config.mjs ├── .vscode ├── api-key.example.txt ├── extensions.json ├── launch.json └── settings.json ├── .prettierrc.js ├── .gitignore ├── .prettierignore ├── fmt-lint.sh ├── ui ├── tailwind.config.js ├── ing │ ├── index.tsx │ ├── index.less │ ├── index.html │ └── IngList.tsx ├── post-cfg │ ├── index.tsx │ ├── index.less │ ├── index.html │ └── components │ │ ├── input │ │ ├── PwdInput.tsx │ │ └── TitleInput.tsx │ │ ├── OptionCheckBox.tsx │ │ └── select │ │ ├── PermissionSelect.tsx │ │ └── CatSelect.tsx ├── share │ ├── vscode-api.ts │ └── active-theme-provider.ts ├── global.d.ts └── tsconfig.json ├── .npmrc ├── tsconfig.integration.json ├── .vscodeignore ├── tsconfig.spec.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature-request.yaml ├── tsconfig.json ├── shell.nix ├── .gitattributes ├── LICENSE ├── .eslintrc.json └── download-iconfont.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf -------------------------------------------------------------------------------- /rs/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | -------------------------------------------------------------------------------- /src/infra/convert/string-literal.ts: -------------------------------------------------------------------------------- 1 | export const r = String.raw 2 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.spec.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnblogs/vscode-cnb/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /.vscode/api-key.example.txt: -------------------------------------------------------------------------------- 1 | CLIENTID:client_id_example 2 | CLIENTSECRET:client_secret_example 3 | -------------------------------------------------------------------------------- /src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnblogs/vscode-cnb/HEAD/src/assets/favicon.png -------------------------------------------------------------------------------- /src/assets/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnblogs/vscode-cnb/HEAD/src/assets/icons.woff2 -------------------------------------------------------------------------------- /rs/src/infra/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod http; 2 | pub mod log; 3 | pub mod option; 4 | pub mod panic_hook; 5 | pub mod result; 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@cnblogs/prettier-config'), 3 | tabWidth: 4, 4 | semi: false, 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test 5 | *.vsix 6 | .DS_Store 7 | .idea 8 | .vscode/api-key.txt 9 | with-key-build.sh 10 | -------------------------------------------------------------------------------- /src/model/site-category.ts: -------------------------------------------------------------------------------- 1 | export class SiteCat { 2 | id = -1 3 | title = '' 4 | parentId = -1 5 | children: SiteCat[] = [] 6 | } 7 | -------------------------------------------------------------------------------- /rs/src/infra/panic_hook.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! panic_hook { 3 | () => { 4 | console_error_panic_hook::set_once(); 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | rs 2 | .github 3 | 4 | pkg 5 | out 6 | dist 7 | src/wasm 8 | .vscode-test 9 | node_modules 10 | 11 | *.md 12 | *.html 13 | package-lock.json -------------------------------------------------------------------------------- /src/infra/cmd.ts: -------------------------------------------------------------------------------- 1 | import { commands } from 'vscode' 2 | 3 | export const regCmd = commands.registerCommand 4 | 5 | export const execCmd = commands.executeCommand 6 | -------------------------------------------------------------------------------- /src/model/zzk-search-result.ts: -------------------------------------------------------------------------------- 1 | export type ZzkSearchResult = { 2 | documents: Record 3 | postIds: number[] 4 | } 5 | -------------------------------------------------------------------------------- /fmt-lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm run format 4 | npm run lint 5 | 6 | declare para='--manifest-path rs/Cargo.toml' 7 | cargo fmt $para 8 | cargo clippy $para 9 | -------------------------------------------------------------------------------- /rs/src/cnb/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api_base; 2 | pub mod img; 3 | pub mod ing; 4 | pub mod oauth; 5 | pub mod post; 6 | pub mod post_cat; 7 | pub mod post_tag; 8 | pub mod user; 9 | -------------------------------------------------------------------------------- /ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./ui/**/*.{html,ts,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | } 8 | -------------------------------------------------------------------------------- /src/infra/http/infra/url-para.ts: -------------------------------------------------------------------------------- 1 | export function consUrlPara(...kvs: [string, string][]) { 2 | const para = new URLSearchParams(kvs) 3 | return para.toString() 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //gitlab.cnblogs.com/api/v4/projects/371/packages/npm/:_authToken=${GITLAB_NPM_TOKEN} 2 | @cnblogs-gitlab:registry=https://gitlab.cnblogs.com/api/v4/projects/371/packages/npm/ -------------------------------------------------------------------------------- /src/cmd/open/os-open-active-file.ts: -------------------------------------------------------------------------------- 1 | import { execCmd } from '@/infra/cmd' 2 | 3 | export const osOpenActiveFile = () => execCmd('workbench.files.action.showActiveFileInExplorer') 4 | -------------------------------------------------------------------------------- /ui/ing/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less' 2 | import { App } from 'ing/App' 3 | import ReactDOM from 'react-dom' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /ui/post-cfg/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less' 2 | import { App } from './App' 3 | import ReactDOM from 'react-dom' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /ui/share/vscode-api.ts: -------------------------------------------------------------------------------- 1 | let instance: VsCodeApi | null = null 2 | 3 | export function getVsCodeApiSingleton() { 4 | instance ??= acquireVsCodeApi() 5 | return instance 6 | } 7 | -------------------------------------------------------------------------------- /src/model/post-list-state.ts: -------------------------------------------------------------------------------- 1 | export type PostListState = { 2 | pageIndex: number 3 | pageSize: number 4 | pageCount: number 5 | hasPrev: boolean 6 | hasNext: boolean 7 | } 8 | -------------------------------------------------------------------------------- /ui/post-cfg/index.less: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | body { 4 | height: 100vh; 5 | overflow-y: auto; 6 | } 7 | 8 | input, 9 | input:focus { 10 | outline: initial; 11 | } 12 | -------------------------------------------------------------------------------- /src/infra/filter/rm-yfm.ts: -------------------------------------------------------------------------------- 1 | // remove YAML front matter in markdown 2 | export function rmYfm(mkd: string) { 3 | const reg = /^---\n(\n|.)*?\n---\n*/g 4 | return mkd.replace(reg, '') 5 | } 6 | -------------------------------------------------------------------------------- /src/infra/fp/pipe.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/naming-convention 2 | export function pipe(f1: (a: A) => B, f2: (b: B) => C) { 3 | return (a: A) => f2(f1(a)) 4 | } 5 | -------------------------------------------------------------------------------- /src/model/post-edit-dto.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@/model/post' 2 | import { MyConfig } from '@/model/my-config' 3 | 4 | export type PostEditDto = { 5 | post: Post 6 | config: MyConfig 7 | } 8 | -------------------------------------------------------------------------------- /src/tree-view/model/base-tree-item-source.ts: -------------------------------------------------------------------------------- 1 | import { TreeItem } from 'vscode' 2 | 3 | export abstract class BaseTreeItemSource { 4 | abstract toTreeItem(): TreeItem | Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/model/user-info.ts: -------------------------------------------------------------------------------- 1 | export interface UserInfo { 2 | userId: string 3 | accountId: number 4 | displayName: string 5 | blogApp: string 6 | avatar: string 7 | isVip: boolean 8 | } 9 | -------------------------------------------------------------------------------- /src/tree-view/model/base-entry-tree-item.ts: -------------------------------------------------------------------------------- 1 | export type BaseEntryTreeItem = { 2 | readonly getChildren: () => TChildren[] 3 | readonly getChildrenAsync: () => Promise 4 | } 5 | -------------------------------------------------------------------------------- /src/infra/tree-view.ts: -------------------------------------------------------------------------------- 1 | import vscode, { TreeViewOptions } from 'vscode' 2 | 3 | export function regTreeView(id: string, opt: TreeViewOptions) { 4 | return vscode.window.createTreeView(id, opt) 5 | } 6 | -------------------------------------------------------------------------------- /src/infra/http/infra/auth-type.ts: -------------------------------------------------------------------------------- 1 | export function bearer(token: string) { 2 | return `Bearer ${token}` 3 | } 4 | 5 | export function basic(credentials: string) { 6 | return `Basic ${credentials}` 7 | } 8 | -------------------------------------------------------------------------------- /rs/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![no_std] 2 | #![feature(try_blocks)] 3 | extern crate alloc; 4 | 5 | pub mod base64; 6 | pub mod cnb; 7 | pub mod http; 8 | pub mod infra; 9 | pub mod rand; 10 | pub mod regex; 11 | pub mod text; 12 | -------------------------------------------------------------------------------- /rs/src/infra/option.rs: -------------------------------------------------------------------------------- 1 | pub trait IntoOption 2 | where 3 | Self: Sized, 4 | { 5 | #[inline] 6 | fn into_some(self) -> Option { 7 | Some(self) 8 | } 9 | } 10 | 11 | impl IntoOption for T {} 12 | -------------------------------------------------------------------------------- /src/infra/convert/map-to-json.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/naming-convention 2 | export function mapToJson(map: Map) { 3 | const obj = Object.fromEntries(map) 4 | return JSON.stringify(obj) 5 | } 6 | -------------------------------------------------------------------------------- /src/ctx/cfg/post-list.ts: -------------------------------------------------------------------------------- 1 | import { LocalState } from '@/ctx/local-state' 2 | 3 | export namespace PostListCfg { 4 | export function getListPageSize() { 5 | return LocalState.getExtCfg().get('pageSize.postList') ?? 30 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/ctx/cfg/icon-theme.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode' 2 | 3 | export namespace IconThemeCfg { 4 | export function getIconTheme(): string { 5 | return workspace.getConfiguration('workbench').get('iconTheme') ?? 'unknown' 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/model/img-upload-status.ts: -------------------------------------------------------------------------------- 1 | export enum ImgUploadStatusId { 2 | uploading, 3 | uploaded, 4 | failed, 5 | } 6 | 7 | export type ImgUploadStatus = { 8 | id: ImgUploadStatusId 9 | imageUrl?: string 10 | errors?: string[] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher", "rioj7.command-variable"] 5 | } 6 | -------------------------------------------------------------------------------- /src/model/clipboard-img.ts: -------------------------------------------------------------------------------- 1 | export type ClipboardImg = { 2 | imgPath: string 3 | /** 4 | * if the path is generated by the extension -> false 5 | * if the path is a real file path in system -> true 6 | */ 7 | shouldKeepAfterUploading: boolean 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.integration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.spec.json", 3 | "include": ["src/test"], 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "outDir": "out" 7 | }, 8 | "exclude": ["node_modules", ".vscode-test", "build.js"] 9 | } 10 | -------------------------------------------------------------------------------- /ui/global.d.ts: -------------------------------------------------------------------------------- 1 | type WebviewCommonCmd = import('@/model/webview-cmd').WebviewCommonCmd 2 | 3 | declare type VsCodeApi = { 4 | postMessage = WebviewCommonCmd<{}>>(message: Object | T): any 5 | } 6 | 7 | declare function acquireVsCodeApi(): VsCodeApi 8 | -------------------------------------------------------------------------------- /ui/share/active-theme-provider.ts: -------------------------------------------------------------------------------- 1 | import { darkTheme, lightTheme } from 'share/theme' 2 | 3 | export namespace ActiveThemeProvider { 4 | export function activeTheme() { 5 | if (document.body.classList.contains('vscode-dark')) return darkTheme 6 | else return lightTheme 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/model/post-updated-response.ts: -------------------------------------------------------------------------------- 1 | import { PostType } from './post' 2 | 3 | export type PostUpdatedResp = { 4 | blogUrl: string 5 | dateAdded: string 6 | entryName: string 7 | id: number 8 | postType: PostType 9 | tags: string[] 10 | title: string 11 | url: string 12 | } 13 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .github 3 | .vscode 4 | .gitignore 5 | .prettierignore 6 | 7 | **/tsconfig.json 8 | **/tsconfig.*.json 9 | **/.eslintrc.json 10 | 11 | **/*.map 12 | **/*.ts 13 | 14 | *.mjs 15 | *.js 16 | *.sh 17 | 18 | out 19 | pkg 20 | test 21 | 22 | rs 23 | ui 24 | src 25 | node_modules 26 | 27 | shell.nix 28 | -------------------------------------------------------------------------------- /src/infra/fmt-img-link.ts: -------------------------------------------------------------------------------- 1 | export function fmtImgLink(link: string, format: 'html' | 'markdown' | 'raw') { 2 | if (link === '') return '' 3 | 4 | if (format === 'html') return `image` 5 | if (format === 'markdown') return `![img](${link})` 6 | 7 | // raw case 8 | return link 9 | } 10 | -------------------------------------------------------------------------------- /src/model/my-config.ts: -------------------------------------------------------------------------------- 1 | export type MyConfig = { 2 | canInSiteCandidate: boolean 3 | noSiteCandidateMsg: string 4 | canInSiteHome: boolean 5 | noSiteHomeMsg: string 6 | myTeamCollection: [] 7 | editor: { 8 | id: number 9 | host: string 10 | cdnRefreshId: number 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/fs/fsUtil.ts: -------------------------------------------------------------------------------- 1 | import { Uri, workspace } from 'vscode' 2 | 3 | export namespace fsUtil { 4 | export async function exists(fsPath: string) { 5 | try { 6 | await workspace.fs.stat(Uri.file(fsPath)) 7 | return true 8 | } catch (e) { 9 | return false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui/ing/index.less: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | #root { 4 | padding: 8px 0; 5 | } 6 | 7 | .ing-comment__content { 8 | a { 9 | white-space: nowrap; 10 | } 11 | } 12 | 13 | .ing-content__icons { 14 | img { 15 | max-width: 28px; 16 | margin-right: 5px; 17 | } 18 | 19 | img.ing-icon { 20 | width: 22px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "rootDir": "", 7 | "resolveJsonModule": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowJs": true, 11 | "outDir": "out", 12 | "module": "CommonJS" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/cmd/blog-export/refresh.ts: -------------------------------------------------------------------------------- 1 | import { setCtx } from '@/ctx/global-ctx' 2 | import { BlogExportProvider } from '@/tree-view/provider/blog-export-provider' 3 | 4 | export async function refreshExportRecord() { 5 | await setCtx('backup.records.isLoading', true) 6 | 7 | await BlogExportProvider.instance.refreshRecords() 8 | 9 | await setCtx('backup.records.isLoading', false) 10 | } 11 | -------------------------------------------------------------------------------- /src/setup/setup-state.ts: -------------------------------------------------------------------------------- 1 | import { setCtx } from '@/ctx/global-ctx' 2 | 3 | export async function setupState() { 4 | await setCtx('post-list.isLoading', undefined) 5 | await setCtx('post-cat-list.isLoading', undefined) 6 | await setCtx('ing-list.isLoading', undefined) 7 | await setCtx('backup.isDownloading', undefined) 8 | await setCtx('backup.records.isLoading', undefined) 9 | } 10 | -------------------------------------------------------------------------------- /src/infra/save-file-pending-changes.ts: -------------------------------------------------------------------------------- 1 | import { window, Uri } from 'vscode' 2 | 3 | export const saveFilePendingChanges = async (filePath: Uri | string | undefined) => { 4 | const localPath = typeof filePath === 'string' ? filePath : filePath?.fsPath 5 | const activeEditor = window.visibleTextEditors.find(x => x.document.uri.fsPath === localPath) 6 | if (activeEditor !== undefined) await activeEditor.document.save() 7 | } 8 | -------------------------------------------------------------------------------- /rs/src/infra/log.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::wasm_bindgen; 2 | 3 | #[wasm_bindgen] 4 | extern "C" { 5 | #[wasm_bindgen(js_namespace = console)] 6 | pub fn log(text: &str); 7 | } 8 | 9 | #[macro_export] 10 | macro_rules! console_log { 11 | ($expr:expr) => { 12 | use alloc::format; 13 | use $crate::infra::log::log; 14 | let text = format!("{:#?}", $expr); 15 | log(&text); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/infra/fp/ord.ts: -------------------------------------------------------------------------------- 1 | export const gt = 2 | (a: T) => 3 | (b: T) => 4 | a > b 5 | 6 | export const ge = 7 | (a: T) => 8 | (b: T) => 9 | a >= b 10 | 11 | export const eq = 12 | (a: T) => 13 | (b: T) => 14 | a === b 15 | 16 | export const le = 17 | (a: T) => 18 | (b: T) => 19 | a <= b 20 | 21 | export const lt = 22 | (a: T) => 23 | (b: T) => 24 | a < b 25 | -------------------------------------------------------------------------------- /src/service/code-challenge.ts: -------------------------------------------------------------------------------- 1 | import base64url from 'base64url' 2 | import crypto from 'crypto' 3 | import { RsRand } from '@/wasm' 4 | 5 | export const genVerifyChallengePair = () => { 6 | const verifyCode = RsRand.string(128) 7 | const base64Digest = crypto.createHash('sha256').update(verifyCode).digest('base64') 8 | const challengeCode = base64url.fromBase64(base64Digest) 9 | 10 | return [verifyCode, challengeCode] 11 | } 12 | -------------------------------------------------------------------------------- /rs/src/cnb/user/mod.rs: -------------------------------------------------------------------------------- 1 | mod get_info; 2 | 3 | use crate::cnb::oauth::Token; 4 | use crate::panic_hook; 5 | use wasm_bindgen::prelude::*; 6 | 7 | #[wasm_bindgen(js_name = UserReq)] 8 | pub struct UserReq { 9 | token: Token, 10 | } 11 | 12 | #[wasm_bindgen(js_class = UserReq)] 13 | impl UserReq { 14 | #[wasm_bindgen(constructor)] 15 | pub fn new(token: Token) -> UserReq { 16 | panic_hook!(); 17 | UserReq { token } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/cmd/ing/pub-ing-with-select.ts: -------------------------------------------------------------------------------- 1 | import { window } from 'vscode' 2 | import { Alert } from '@/infra/alert' 3 | import { pubIngWithInput } from '@/cmd/ing/pub-ing-with-input' 4 | 5 | export function pubIngWithSelect() { 6 | const text = window.activeTextEditor?.document.getText(window.activeTextEditor?.selection) 7 | if (text === undefined) { 8 | void Alert.warn(`当前没有选中任何内容`) 9 | return 10 | } 11 | 12 | pubIngWithInput(text) 13 | } 14 | -------------------------------------------------------------------------------- /src/tree-view/model/category-list-tree-item.ts: -------------------------------------------------------------------------------- 1 | import { PostCat } from '@/model/post-cat' 2 | import { PostCatTreeItem } from './post-category-tree-item' 3 | import { PostEntryMetadata, PostMetadata, PostTagMetadata } from './post-metadata' 4 | import { PostTreeItem } from './post-tree-item' 5 | 6 | export type PostCatListTreeItem = 7 | | PostCat 8 | | PostCatTreeItem 9 | | PostTreeItem 10 | | PostMetadata 11 | | PostEntryMetadata 12 | -------------------------------------------------------------------------------- /rs/src/cnb/post_tag/mod.rs: -------------------------------------------------------------------------------- 1 | mod get_all; 2 | 3 | use crate::cnb::oauth::Token; 4 | use crate::panic_hook; 5 | use wasm_bindgen::prelude::*; 6 | 7 | #[wasm_bindgen(js_name = PostTagReq)] 8 | pub struct PostTagReq { 9 | token: Token, 10 | } 11 | 12 | #[wasm_bindgen(js_class = PostTagReq)] 13 | impl PostTagReq { 14 | #[wasm_bindgen(constructor)] 15 | pub fn new(token: Token) -> PostTagReq { 16 | panic_hook!(); 17 | PostTagReq { token } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ctx/cfg/ui.ts: -------------------------------------------------------------------------------- 1 | import { LocalState } from '@/ctx/local-state' 2 | import getExtCfg = LocalState.getExtCfg 3 | 4 | export namespace UiCfg { 5 | const cfgGet = (key: string) => getExtCfg().get(`ui.${key}`) 6 | 7 | export function isEnableTextIngStar() { 8 | return cfgGet('textIngStar') ?? false 9 | } 10 | 11 | export function isDisableIngUserAvatar() { 12 | return cfgGet('disableIngUserAvatar') ?? false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/model/blog-export/export-post.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize' 2 | import { Post } from '@/model/post' 3 | 4 | export type ExportPostType = 'BlogPost' | 'Message' 5 | 6 | export type ExportPost = Pick< 7 | Post, 8 | 'id' | 'title' | 'datePublished' | 'blogId' | 'isMarkdown' | 'accessPermission' | 'entryName' | 'dateUpdated' 9 | > & { 10 | body?: string | null 11 | postType: ExportPostType 12 | } 13 | 14 | export class ExportPostModel extends Model {} 15 | -------------------------------------------------------------------------------- /src/cmd/upload-img/upload-img-from-path.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/infra/alert' 2 | import { imageService } from '@/service/upload-img/image.service' 3 | import fs from 'fs' 4 | 5 | export function uploadImgFromPath(path: string) { 6 | try { 7 | const readStream = fs.createReadStream(path) 8 | return imageService.upload(readStream) 9 | } catch (e) { 10 | console.log(`上传图片失败: ${e}`) 11 | void Alert.err(`上传图片失败: ${e}`) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/service/parse-webview-html.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode' 2 | import { globalCtx } from '@/ctx/global-ctx' 3 | 4 | export type WebviewEntryName = 'ing' | 'post-cfg' 5 | 6 | export async function parseWebviewHtml(entry: WebviewEntryName, webview: vscode.Webview) { 7 | const path = vscode.Uri.joinPath(globalCtx.assetsUri, 'ui', entry, 'index.html') 8 | const file = await vscode.workspace.fs.readFile(path) 9 | return file.toString().replace(/@PWD/g, webview.asWebviewUri(globalCtx.assetsUri).toString()) 10 | } 11 | -------------------------------------------------------------------------------- /ui/ing/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 13 | 14 | 15 | 16 |
17 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/auth/is-auth-session-expired.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationSession as AuthSession } from 'vscode' 2 | 3 | export function isAuthSessionExpired(authSession: AuthSession) { 4 | const accessToken = authSession.accessToken 5 | 6 | // TODO: need better solution 7 | if (accessToken.length === 64) return false 8 | 9 | const accessTokenPart2 = accessToken.split('.')[1] 10 | const buf = Buffer.from(accessTokenPart2, 'base64') 11 | const exp = JSON.parse(buf.toString()).exp 12 | return exp * 1000 < Date.now() 13 | } 14 | -------------------------------------------------------------------------------- /src/ctx/cfg/chromium.ts: -------------------------------------------------------------------------------- 1 | import { PlatformCfg } from '@/ctx/cfg/platform' 2 | import getPlatformCfg = PlatformCfg.getPlatformCfg 3 | import { ConfigurationTarget } from 'vscode' 4 | 5 | export namespace ChromiumCfg { 6 | export function getChromiumPath(): string { 7 | return getPlatformCfg().get('chromiumPath') ?? '' 8 | } 9 | 10 | export function setChromiumPath(path: string) { 11 | const cfgTarget = ConfigurationTarget.Global 12 | return getPlatformCfg().update('chromiumPath', path, cfgTarget) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **问题描述** 10 | 11 | 简单的描述一下你遇到的问题 12 | 13 | **复现问题** 14 | 15 | 复现问题的步骤: 16 | 17 | 1. 在某个地方 ... 18 | 2. 进行了什么操作 ... 19 | 3. ... 20 | 21 | **期望的结果** 22 | 23 | 你期望的结果 24 | 25 | **问题截图** 26 | 27 | 如果可能的话, 提供下遇到问题时的截图以便排查 28 | 29 | **环境信息:** 30 | 31 | - 操作系统: [e.g. macOS] 32 | - VSCode版本 [e.g. 1.00.0] 33 | - 博客园扩展版本 [e.g. 1.0.0] 34 | 35 | **任何其他有助于排查问题的信息** 36 | 37 | 在这里填写任何其他有助于排查问题的信息 38 | -------------------------------------------------------------------------------- /ui/post-cfg/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 13 | 14 | 15 |
16 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/tree-view/model/post-category-tree-item.ts: -------------------------------------------------------------------------------- 1 | import { PostCat } from '@/model/post-cat' 2 | import { toTreeItem } from '@/tree-view/convert' 3 | import { BaseTreeItemSource } from './base-tree-item-source' 4 | import { PostTreeItem } from './post-tree-item' 5 | 6 | export class PostCatTreeItem extends BaseTreeItemSource { 7 | constructor( 8 | public readonly category: PostCat, 9 | public children: (PostCatTreeItem | PostTreeItem)[] = [] 10 | ) { 11 | super() 12 | } 13 | 14 | toTreeItem = () => toTreeItem(this.category) 15 | } 16 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod del; 3 | mod get_all; 4 | mod get_cnb_category_list; 5 | mod get_one; 6 | mod update; 7 | 8 | use crate::cnb::oauth::Token; 9 | use crate::panic_hook; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_name = PostCatReq)] 13 | pub struct PostCatReq { 14 | token: Token, 15 | } 16 | 17 | #[wasm_bindgen(js_class = PostCatReq)] 18 | impl PostCatReq { 19 | #[wasm_bindgen(constructor)] 20 | pub fn new(token: Token) -> PostCatReq { 21 | panic_hook!(); 22 | PostCatReq { token } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rs/src/cnb/post/mod.rs: -------------------------------------------------------------------------------- 1 | mod del_one; 2 | mod del_some; 3 | mod get_count; 4 | mod get_list; 5 | mod get_one; 6 | mod get_template; 7 | mod search; 8 | mod update; 9 | use crate::cnb::oauth::Token; 10 | use crate::panic_hook; 11 | use wasm_bindgen::prelude::*; 12 | 13 | #[wasm_bindgen(js_name = PostReq)] 14 | pub struct PostReq { 15 | token: Token, 16 | } 17 | 18 | #[wasm_bindgen(js_class = PostReq)] 19 | impl PostReq { 20 | #[wasm_bindgen(constructor)] 21 | pub fn new(token: Token) -> PostReq { 22 | panic_hook!(); 23 | PostReq { token } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/infra/http/infra/header.ts: -------------------------------------------------------------------------------- 1 | export namespace ReqHeaderKey { 2 | export const CONTENT_TYPE = 'Content-Type' 3 | export const AUTHORIZATION = 'Authorization' 4 | export const AUTHORIZATION_TYPE = 'Authorization-Type' 5 | 6 | export enum ContentType { 7 | appJson = 'application/json', 8 | appX3wfu = 'application/x-www-form-urlencoded', 9 | } 10 | } 11 | 12 | export function consHeader(...kvs: [string, string][]) { 13 | const header = new Map() 14 | kvs.forEach(([k, v]) => header.set(k, v)) 15 | return header 16 | } 17 | -------------------------------------------------------------------------------- /src/ctx/cfg/platform.ts: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import { workspace } from 'vscode' 3 | 4 | function getPlatformPrefix() { 5 | const osName = os.platform() 6 | 7 | if (osName === 'darwin') return 'macos' 8 | if (osName === 'win32') return 'windows' 9 | if (osName === 'linux') return 'linux' 10 | 11 | // fallback to win 12 | return 'windows' 13 | } 14 | 15 | export namespace PlatformCfg { 16 | export function getPlatformCfg() { 17 | const entry = `cnblogsClient.${getPlatformPrefix()}` 18 | return workspace.getConfiguration(entry) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/service/local-post.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Uri, workspace } from 'vscode' 3 | 4 | export class LocalPost { 5 | constructor(public filePath: string) {} 6 | 7 | get fileNameWithoutExt(): string { 8 | return path.basename(this.filePath, this.fileExt) 9 | } 10 | 11 | get fileExt() { 12 | return path.extname(this.filePath) 13 | } 14 | 15 | async readAllText() { 16 | const arr = await workspace.fs.readFile(Uri.file(this.filePath)) 17 | const buf = Buffer.from(arr) 18 | return buf.toString() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "target": "ES2020", 5 | "moduleResolution": "node", 6 | "lib": ["ES2020"], 7 | "outDir": "out", 8 | "sourceMap": true, 9 | "rootDir": "src", 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | }, 16 | "strict": true 17 | }, 18 | "exclude": ["node_modules", "build.mjs", ".vscode-test", "ui", "src/test", "test", "__mocks__"] 19 | } 20 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | let 4 | packages = with pkgs; [ 5 | openssl_3 6 | pkg-config 7 | ]; 8 | 9 | corepack = stdenv.mkDerivation { 10 | name = "corepack"; 11 | buildInputs = [ pkgs.nodejs_18 ]; 12 | phases = [ "installPhase" ]; 13 | installPhase = '' 14 | mkdir -p $out/bin 15 | corepack enable --install-directory=$out/bin 16 | ''; 17 | }; 18 | in pkgs.mkShell { 19 | buildInputs = packages ++ [ corepack ]; 20 | 21 | shellHook = '' 22 | export LD_LIBRARY_PATH=${ 23 | pkgs.lib.makeLibraryPath packages 24 | }:$LD_LIBRARY_PATH 25 | ''; 26 | } 27 | -------------------------------------------------------------------------------- /src/infra/convert/readableToBuffer.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream' 2 | 3 | export function readableToBytes(readable: Readable): Promise { 4 | return new Promise(resolve => { 5 | const bufs: Buffer[] = [] 6 | readable.on('readable', () => { 7 | const chunk = readable.read() 8 | 9 | if (chunk !== null) { 10 | bufs.push(chunk) 11 | } else { 12 | const buf = Buffer.concat(bufs) 13 | const bytes = new Uint8Array(buf) 14 | resolve(bytes) 15 | } 16 | }) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/model/page.ts: -------------------------------------------------------------------------------- 1 | export type Page = { 2 | index: number 3 | size: number 4 | count: number 5 | items: T[] 6 | } 7 | 8 | export type PageList = { 9 | pages: Page[] 10 | } 11 | 12 | export namespace PageList { 13 | export function hasPrev(pageIndex: number) { 14 | return pageIndex > 1 15 | } 16 | 17 | export function hasNext(pageIndex: number, pageCount: number) { 18 | return pageIndex < pageCount 19 | } 20 | 21 | export function calcPageCount(pageCap: number, pageListItemCount: number) { 22 | return Math.floor(pageListItemCount / pageCap) + (pageListItemCount % pageCap > 0 ? 1 : 0) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/model/post-list-resp-item.ts: -------------------------------------------------------------------------------- 1 | import { AccessPermission, PostType } from '@/model/post' 2 | 3 | export type PostListRespItem = { 4 | accessPermission: AccessPermission 5 | aggCount: number 6 | 7 | datePublished: string 8 | dateUpdated: string 9 | 10 | entryName: string 11 | feedBackCount: number 12 | 13 | id: number 14 | 15 | isDraft: boolean 16 | isInSiteCandidate: boolean 17 | isInSiteHome: boolean 18 | isMarkdown: boolean 19 | isPinned: boolean 20 | isPublished: boolean 21 | 22 | postConfig: number 23 | postType: PostType 24 | 25 | title: string 26 | url: string 27 | viewCount: number 28 | webCount: number 29 | } 30 | -------------------------------------------------------------------------------- /rs/src/rand.rs: -------------------------------------------------------------------------------- 1 | use crate::panic_hook; 2 | use alloc::string::String; 3 | use rand::distributions::Alphanumeric; 4 | use rand::Rng; 5 | use wasm_bindgen::prelude::wasm_bindgen; 6 | 7 | #[wasm_bindgen(js_name = RsRand)] 8 | struct RsRand; 9 | 10 | #[wasm_bindgen(js_class = RsRand)] 11 | impl RsRand { 12 | #[wasm_bindgen(js_name = string)] 13 | pub fn export_string(len: usize) -> String { 14 | panic_hook!(); 15 | rand::thread_rng() 16 | .sample_iter(&Alphanumeric) 17 | .take(len) 18 | .map(char::from) 19 | .collect() 20 | } 21 | } 22 | 23 | #[test] 24 | fn test_export_string() { 25 | let r = RsRand::export_string(10); 26 | assert_eq!(r.len(), 10); 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd/open/os-open-local-post-file.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode' 2 | import { execCmd } from '@/infra/cmd' 3 | import { Post } from '@/model/post' 4 | import { PostFileMapManager } from '@/service/post/post-file-map' 5 | import { PostTreeItem } from '@/tree-view/model/post-tree-item' 6 | 7 | export function osOpenLocalPostFile(post?: Post | PostTreeItem) { 8 | if (post === undefined) { 9 | console.error('post is undefined in osOpenLocalPostFile') 10 | return 11 | } 12 | 13 | post = post instanceof PostTreeItem ? post.post : post 14 | const postFilePath = PostFileMapManager.getFilePath(post.id) 15 | if (postFilePath === undefined) return 16 | 17 | return execCmd('revealFileInOS', Uri.file(postFilePath)) 18 | } 19 | -------------------------------------------------------------------------------- /src/assets/scripts/clipboard/linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # require xclip(see http://stackoverflow.com/questions/592620/check-if-a-program-exists-from-a-bash-script/677212#677212) 3 | command -v xclip >/dev/null 2>&1 || { echo >&1 "no xclip"; exit 1; } 4 | 5 | # write image in clipboard to file (see http://unix.stackexchange.com/questions/145131/copy-image-from-clipboard-to-file) 6 | filePath=`xclip -selection clipboard -o 2>/dev/null | grep ^file:// | cut -c8-` 7 | if [ ! -n "$filePath" ] ;then 8 | if 9 | xclip -selection clipboard -target image/png -o >/dev/null 2>&1 10 | then 11 | xclip -selection clipboard -target image/png -o >$1 2>/dev/null 12 | echo $1 13 | else 14 | echo "no image" 15 | fi 16 | else 17 | echo $filePath 18 | fi 19 | -------------------------------------------------------------------------------- /src/assets/scripts/clipboard/wsl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # grab the paths 3 | scriptPath=$(echo $0 | awk '{ print substr( $0, 1, length($0)-6 ) }')"windows10.ps1" 4 | imagePath=$(echo $1 | awk '{ print substr( $0, 1, length($0)-18 ) }') 5 | imageName=$(echo $1 | awk '{ print substr( $0, length($0)-17, length($0) ) }') 6 | 7 | # run the powershell script 8 | res=$(powershell.exe -noprofile -noninteractive -nologo -sta -executionpolicy unrestricted -file $(wslpath -w $scriptPath) $(wslpath -w $imagePath)"\\"$imageName) 9 | 10 | # note that there is a return symbol in powershell result 11 | noImage=$(echo "no image\r") 12 | 13 | # check whether image exists 14 | if [ "$res" = "$noImage" ] ;then 15 | echo "no image" 16 | else 17 | echo $(wslpath -u -a "${res}") 18 | fi 19 | -------------------------------------------------------------------------------- /rs/src/text.rs: -------------------------------------------------------------------------------- 1 | use crate::panic_hook; 2 | use alloc::string::String; 3 | use alloc::vec::Vec; 4 | use wasm_bindgen::prelude::wasm_bindgen; 5 | 6 | #[wasm_bindgen(js_name = RsText)] 7 | struct RsText; 8 | 9 | #[wasm_bindgen(js_class = RsText)] 10 | impl RsText { 11 | #[wasm_bindgen(js_name = replaceWithByteOffset)] 12 | pub fn export_replace_with_byte_offset( 13 | raw: String, 14 | start: usize, 15 | end: usize, 16 | replace_with: String, 17 | ) -> String { 18 | panic_hook!(); 19 | let mut vec: Vec = raw.as_bytes().to_vec(); 20 | let replace_with = replace_with.as_bytes().iter().copied(); 21 | vec.splice(start..end, replace_with); 22 | String::from_utf8(vec).unwrap() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/cmd/blog-export/create.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/infra/alert' 2 | import { BlogExportApi } from '@/service/blog-export/blog-export' 3 | import { BlogExportProvider } from '@/tree-view/provider/blog-export-provider' 4 | 5 | export async function createBlogExport() { 6 | const answer = await Alert.info( 7 | '确定要创建备份吗?', 8 | { modal: true, detail: '一天可以创建一次备份' }, 9 | { 10 | title: '确定', 11 | isCloseAffordance: false, 12 | } 13 | ) 14 | if (answer === undefined) return 15 | 16 | try { 17 | await BlogExportApi.create() 18 | await BlogExportProvider.optionalInstance?.refreshRecords() 19 | } catch (e) { 20 | void Alert.err(`创建备份失败: ${e}`) 21 | return false 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "target": "ES2020", 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "outDir": "./dist", 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "react-jsx", 11 | "moduleResolution": "node", 12 | "strictNullChecks": true, 13 | "strictPropertyInitialization": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/*": ["../src/*"] 17 | }, 18 | "lib": ["DOM", "ES2020"], 19 | "strictBindCallApply": true 20 | }, 21 | "include": ["./**/*.ts", "./**/*.tsx", "./**/*.jsx", "./**/*.js", "*.mjs", "../src/model/*.ts"], 22 | "exclude": ["./dist", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/scripts/clipboard/windows.ps1: -------------------------------------------------------------------------------- 1 | 2 | param($imagePath) 3 | 4 | # Adapted from https://github.com/octan3/img-clipboard-dump/blob/master/dump-clipboard-png.ps1 5 | 6 | Add-Type -Assembly PresentationCore 7 | $img = [Windows.Clipboard]::GetImage() 8 | 9 | if ($img -eq $null) { 10 | "no image" 11 | Exit 1 12 | } 13 | 14 | if (-not $imagePath) { 15 | "no image" 16 | Exit 1 17 | } 18 | 19 | $fcb = new-object Windows.Media.Imaging.FormatConvertedBitmap($img, [Windows.Media.PixelFormats]::Rgb24, $null, 0) 20 | $stream = [IO.File]::Open($imagePath, "OpenOrCreate") 21 | $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder 22 | $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($fcb)) | out-null 23 | $encoder.Save($stream) | out-null 24 | $stream.Dispose() | out-null 25 | 26 | $imagePath 27 | -------------------------------------------------------------------------------- /src/tree-view/model/blog-export/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DownloadedExportChildTreeItem, 3 | DownloadedExportsEntryTreeItem, 4 | DownloadedExportTreeItem, 5 | } from '@/tree-view/model/blog-export/downloaded' 6 | import { BlogExportRecordMetadata } from '@/tree-view/model/blog-export/record-metadata' 7 | import { BlogExportRecordTreeItem } from '@/tree-view/model/blog-export/record' 8 | 9 | export * from './record-metadata' 10 | export * from './record' 11 | export * from './downloaded' 12 | 13 | export type BlogExportTreeItem = 14 | | BlogExportRecordMetadata 15 | | BlogExportRecordTreeItem 16 | | DownloadedExportTreeItem 17 | | DownloadedExportChildTreeItem 18 | | DownloadedExportsEntryTreeItem 19 | export { parseBlogExportRecords, parseStatusIcon } from './parser' 20 | export * from '../post-tree-item' 21 | -------------------------------------------------------------------------------- /ui/post-cfg/components/input/PwdInput.tsx: -------------------------------------------------------------------------------- 1 | import { Label, Stack, TextField } from '@fluentui/react' 2 | import React, { Component } from 'react' 3 | 4 | type Props = { 5 | password: string 6 | onChange: (password: string) => void 7 | } 8 | 9 | export class PwdInput extends Component { 10 | constructor(props: Props) { 11 | super(props) 12 | } 13 | 14 | render() { 15 | return ( 16 | 17 | 18 | void this.props.onChange(v ?? '')} 21 | value={this.props.password} 22 | canRevealPassword 23 | > 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/markdown/extend-markdownIt.ts: -------------------------------------------------------------------------------- 1 | import { HighlightCodeLinesPlugin, MultilineBlockquotePlugin, ImageSizePlugin } from '@cnblogs/markdown-it-presets' 2 | import type { MarkdownIt } from '@cnblogs/markdown-it-presets' 3 | import { MarkdownCfg } from '@/ctx/cfg/markdown' 4 | 5 | export const extendMarkdownIt = (md: MarkdownIt) => 6 | md 7 | .use(MultilineBlockquotePlugin, { 8 | enable: () => MarkdownCfg.isEnableMarkdownEnhancement() && MarkdownCfg.isEnableMarkdownFenceBlockquote(), 9 | }) 10 | .use(ImageSizePlugin, { 11 | enable: () => MarkdownCfg.isEnableMarkdownEnhancement() && MarkdownCfg.isEnableMarkdownImageSizing(), 12 | }) 13 | .use(HighlightCodeLinesPlugin, { 14 | enable: () => MarkdownCfg.isEnableMarkdownEnhancement() && MarkdownCfg.isEnableMarkdownHighlightCodeLines(), 15 | }) 16 | -------------------------------------------------------------------------------- /rs/src/http/get.rs: -------------------------------------------------------------------------------- 1 | use crate::http::{body_or_err, header_json_to_header_map, RsHttp}; 2 | use crate::infra::result::{HomoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::string::String; 5 | use anyhow::Result; 6 | use wasm_bindgen::prelude::wasm_bindgen; 7 | 8 | #[wasm_bindgen(js_class = RsHttp)] 9 | impl RsHttp { 10 | #[wasm_bindgen(js_name = get)] 11 | pub async fn export_get(url: &str, header_json: &str) -> HomoResult { 12 | panic_hook!(); 13 | let body = get(url, header_json).await; 14 | body.homo_string() 15 | } 16 | } 17 | 18 | async fn get(url: &str, header_json: &str) -> Result { 19 | let header_map = header_json_to_header_map(header_json)?; 20 | 21 | let client = reqwest::Client::new(); 22 | 23 | let req = client.get(url).headers(header_map); 24 | let resp = req.send().await?; 25 | 26 | body_or_err(resp).await 27 | } 28 | -------------------------------------------------------------------------------- /rs/src/http/del.rs: -------------------------------------------------------------------------------- 1 | use crate::http::{body_or_err, header_json_to_header_map, RsHttp}; 2 | use crate::infra::result::{HomoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::string::String; 5 | use anyhow::Result; 6 | use wasm_bindgen::prelude::wasm_bindgen; 7 | 8 | #[wasm_bindgen(js_class = RsHttp)] 9 | impl RsHttp { 10 | #[wasm_bindgen(js_name = del)] 11 | pub async fn export_del(url: &str, header_json: &str) -> HomoResult { 12 | panic_hook!(); 13 | let body = del(url, header_json).await; 14 | body.homo_string() 15 | } 16 | } 17 | 18 | async fn del(url: &str, header_json: &str) -> Result { 19 | let header_map = header_json_to_header_map(header_json)?; 20 | 21 | let client = reqwest::Client::new(); 22 | 23 | let req = client.delete(url).headers(header_map); 24 | let resp = req.send().await?; 25 | 26 | body_or_err(resp).await 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd/extract-img.ts: -------------------------------------------------------------------------------- 1 | import { Uri, window, workspace } from 'vscode' 2 | import { dirname } from 'path' 3 | import { extractImg } from '@/service/extract-img/extract-img' 4 | import { Workspace } from '@/cmd/workspace' 5 | 6 | export async function extractImgCmd(uri?: Uri) { 7 | if (uri === undefined) return 8 | if (uri.scheme !== 'file') return 9 | 10 | const editor = window.visibleTextEditors.find(x => x.document.fileName === uri.fsPath) 11 | const textDocument = editor?.document ?? workspace.textDocuments.find(x => x.fileName === uri.fsPath) 12 | if (textDocument === undefined) return 13 | 14 | const fileDir = dirname(textDocument.uri.fsPath) 15 | const extracted = await extractImg(textDocument.getText(), fileDir) 16 | if (extracted === undefined) return 17 | 18 | const we = Workspace.resetTextDoc(textDocument, extracted) 19 | 20 | await workspace.applyEdit(we) 21 | } 22 | -------------------------------------------------------------------------------- /src/infra/http/req.ts: -------------------------------------------------------------------------------- 1 | import { RsHttp } from '@/wasm' 2 | 3 | import { mapToJson } from '@/infra/convert/map-to-json' 4 | 5 | type Header = Map 6 | 7 | export namespace Req { 8 | export function put(url: string, header: Header, body: string) { 9 | const headerJson = mapToJson(header) 10 | return RsHttp.put(url, headerJson, body) 11 | } 12 | 13 | export function del(url: string, header: Header) { 14 | const headerJson = mapToJson(header) 15 | return RsHttp.del(url, headerJson) 16 | } 17 | 18 | export function post(url: string, header: Header, body: string) { 19 | const headerJson = mapToJson(header) 20 | return RsHttp.post(url, headerJson, body) 21 | } 22 | 23 | export function get(url: string, header: Header) { 24 | const headerJson = mapToJson(header) 25 | return RsHttp.get(url, headerJson) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd/upload-img/upload-img.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/infra/alert' 2 | import { uploadClipboardImg } from './upload-clipboard-img' 3 | import { insertImgLinkToActiveEditor } from './upload-img-util' 4 | import { uploadFsImage } from './upload-fs-img' 5 | 6 | export async function uploadImg() { 7 | const options = ['本地图片', '剪贴板图片'] 8 | const selected = await Alert.info( 9 | '上传图片到博客园', 10 | { 11 | modal: true, 12 | detail: '选择图片来源', 13 | }, 14 | ...options 15 | ) 16 | if (selected === undefined) return 17 | 18 | let imageUrl: string | undefined 19 | 20 | if (selected === options[0]) imageUrl = await uploadFsImage() 21 | else if (selected === options[1]) imageUrl = await uploadClipboardImg() 22 | 23 | if (imageUrl === undefined) return 24 | 25 | await insertImgLinkToActiveEditor(imageUrl) 26 | 27 | return imageUrl 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/icon-page-previous.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: "新功能建议" 2 | description: 给博客园扩展提一个新功能建议 3 | labels: 4 | - "feature" 5 | 6 | body: 7 | - type: dropdown 8 | id: affected-ui-area 9 | attributes: 10 | label: UI 上的哪个部分与此新功能有关? 11 | options: 12 | - 侧边栏控制台 13 | - 侧边栏控制台随笔列表 14 | - 侧边栏控制台随笔分类 15 | - 侧边栏控制台工作空间 16 | - 侧边栏控制台账号中心 17 | - 侧边栏控制台网站导航 18 | - 侧边栏控制台随笔列表右键上下文菜单 19 | - 侧边栏控制台随笔分类右键上下文菜单 20 | - 设置面板 21 | - Markdown 编辑器右键上下文菜单 22 | - Markdown 编辑器工具栏 23 | - 侧边栏文件浏览右键上下文菜单 24 | - 其他 25 | multiple: true 26 | 27 | - type: textarea 28 | id: description 29 | attributes: 30 | label: 新功能描述 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: proposed-solution 36 | attributes: 37 | label: "[可选]建议的解决方案" 38 | validations: 39 | required: false 40 | -------------------------------------------------------------------------------- /src/ctx/ext-const.ts: -------------------------------------------------------------------------------- 1 | declare const CNBLOGS_CLIENTID: string 2 | declare const CNBLOGS_CLIENTSECRET: string 3 | 4 | export const isDevEnv = () => process.env.NODE_ENV === 'Development' 5 | 6 | export namespace ExtConst { 7 | export const EXT_NAME = 'vscode-cnb' 8 | export const EXT_PUBLISHER = 'cnblogs' 9 | 10 | export const EXT_SESSION_STORAGE_KEY = 'cnblogs.sessions' 11 | 12 | export const CLIENT_ID = CNBLOGS_CLIENTID 13 | export const CLIENT_SEC = CNBLOGS_CLIENTSECRET 14 | 15 | export namespace ApiBase { 16 | export const BLOG_BACKEND = 'https://write.cnblogs.com/api' 17 | export const OPENAPI = 'https://api.cnblogs.com/api' 18 | export const OAUTH = 'https://oauth.cnblogs.com' 19 | } 20 | 21 | export const OAUTH_SCOPES = ['openid', 'profile', 'CnBlogsApi', 'CnblogsAdminApi'] 22 | } 23 | 24 | export function extName(tail: any) { 25 | return `${ExtConst.EXT_NAME}${tail}` 26 | } 27 | -------------------------------------------------------------------------------- /src/cmd/view-post-online.ts: -------------------------------------------------------------------------------- 1 | import { Uri, window } from 'vscode' 2 | import { execCmd } from '@/infra/cmd' 3 | import { Post } from '@/model/post' 4 | import { PostService } from '@/service/post/post' 5 | import { PostFileMapManager } from '@/service/post/post-file-map' 6 | import { PostTreeItem } from '@/tree-view/model/post-tree-item' 7 | 8 | export async function viewPostOnline(input?: Post | PostTreeItem | Uri) { 9 | let post: Post | undefined = input instanceof Post ? input : input instanceof PostTreeItem ? input.post : undefined 10 | if (input === undefined) input = window.activeTextEditor?.document.uri 11 | 12 | if (input instanceof Uri) { 13 | const postId = PostFileMapManager.getPostId(input.path) 14 | if (postId !== undefined) post = (await PostService.getPostEditDto(postId))?.post 15 | } 16 | 17 | if (post === undefined) return 18 | 19 | await execCmd('vscode.open', Uri.parse(post.url)) 20 | } 21 | -------------------------------------------------------------------------------- /rs/src/http/put.rs: -------------------------------------------------------------------------------- 1 | use crate::http::{body_or_err, header_json_to_header_map, RsHttp}; 2 | use crate::infra::result::{HomoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::string::String; 5 | use anyhow::Result; 6 | use wasm_bindgen::prelude::wasm_bindgen; 7 | 8 | #[wasm_bindgen(js_class = RsHttp)] 9 | impl RsHttp { 10 | #[wasm_bindgen(js_name = put)] 11 | pub async fn export_put(url: &str, header_json: &str, body: String) -> HomoResult { 12 | panic_hook!(); 13 | let body = put(url, header_json, body).await; 14 | body.homo_string() 15 | } 16 | } 17 | 18 | async fn put(url: &str, header_json: &str, body: String) -> Result { 19 | let header_map = header_json_to_header_map(header_json)?; 20 | 21 | let client = reqwest::Client::new(); 22 | 23 | let req = client.put(url).headers(header_map).body(body); 24 | let resp = req.send().await?; 25 | 26 | body_or_err(resp).await 27 | } 28 | -------------------------------------------------------------------------------- /rs/src/http/post.rs: -------------------------------------------------------------------------------- 1 | use crate::http::{body_or_err, header_json_to_header_map, RsHttp}; 2 | use crate::infra::result::{HomoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::string::String; 5 | use anyhow::Result; 6 | use wasm_bindgen::prelude::wasm_bindgen; 7 | 8 | #[wasm_bindgen(js_class = RsHttp)] 9 | impl RsHttp { 10 | #[wasm_bindgen(js_name = post)] 11 | pub async fn export_post(url: &str, header_json: &str, body: String) -> HomoResult { 12 | panic_hook!(); 13 | let body = post(url, header_json, body).await; 14 | body.homo_string() 15 | } 16 | } 17 | 18 | async fn post(url: &str, header_json: &str, body: String) -> Result { 19 | let header_map = header_json_to_header_map(header_json)?; 20 | 21 | let client = reqwest::Client::new(); 22 | 23 | let req = client.post(url).headers(header_map).body(body); 24 | let resp = req.send().await?; 25 | 26 | body_or_err(resp).await 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/oauth.ts: -------------------------------------------------------------------------------- 1 | import { globalCtx } from '@/ctx/global-ctx' 2 | import { Alert } from '@/infra/alert' 3 | import { OauthReq } from '@/wasm' 4 | import { ExtConst } from '@/ctx/ext-const' 5 | 6 | function getAuthedOauthReq() { 7 | return new OauthReq(ExtConst.CLIENT_ID, ExtConst.CLIENT_SEC) 8 | } 9 | 10 | export namespace Oauth { 11 | export function getToken(verifyCode: string, authCode: string) { 12 | const req = getAuthedOauthReq() 13 | try { 14 | return req.getToken(authCode, verifyCode, globalCtx.extUrl) 15 | } catch (e) { 16 | void Alert.err(`获取 Token 失败: ${e}`) 17 | throw e 18 | } 19 | } 20 | 21 | export function revokeToken(token: string) { 22 | try { 23 | const req = getAuthedOauthReq() 24 | return req.revokeToken(token) 25 | } catch (e) { 26 | void Alert.err(`撤销 Token 失败: ${e}`) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cmd/open/open-post-in-blog-admin.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode' 2 | import { Post } from '@/model/post' 3 | import { PostFileMapManager } from '@/service/post/post-file-map' 4 | import { PostTreeItem } from '@/tree-view/model/post-tree-item' 5 | import { Browser } from '@/cmd/browser' 6 | 7 | export const openPostInBlogAdmin = (arg?: PostTreeItem | Post | Uri) => { 8 | if (arg instanceof Post) { 9 | const postId = arg.id 10 | return Browser.Open.open(`https://write.cnblogs.com/posts/edit;postId=${postId}`) 11 | } 12 | if (arg instanceof PostTreeItem) { 13 | const postId = arg.post.id 14 | return Browser.Open.open(`https://write.cnblogs.com/posts/edit;postId=${postId}`) 15 | } 16 | if (arg instanceof Uri) { 17 | const postId = PostFileMapManager.getPostId(arg.path) 18 | if (postId === undefined) return 19 | return Browser.Open.open(`https://write.cnblogs.com/posts/edit;postId=${postId}`) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/cmd/upload-img/upload-fs-img.ts: -------------------------------------------------------------------------------- 1 | import { ProgressLocation, window } from 'vscode' 2 | import { uploadImgFromPath } from '@/cmd/upload-img/upload-img-from-path' 3 | 4 | export async function uploadFsImage() { 5 | const uriList = await window.showOpenDialog({ 6 | title: '选择要上传的图片(图片不能超过20M)', 7 | canSelectMany: false, 8 | canSelectFolders: false, 9 | filters: { 10 | images: ['png', 'jpg', 'jpeg', 'bmp', 'webp', 'svg', 'gif'], 11 | }, 12 | }) 13 | if (uriList === undefined) return 14 | 15 | const path = uriList[0].fsPath 16 | 17 | return window.withProgress( 18 | { 19 | title: '正在上传图片', 20 | location: ProgressLocation.Notification, 21 | }, 22 | async p => { 23 | p.report({ increment: 30 }) 24 | const url = await uploadImgFromPath(path) 25 | p.report({ increment: 100 }) 26 | return url 27 | } 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /rs/src/cnb/img/mod.rs: -------------------------------------------------------------------------------- 1 | mod download; 2 | mod from_data_url; 3 | mod upload; 4 | 5 | use crate::cnb::oauth::Token; 6 | use crate::panic_hook; 7 | use alloc::boxed::Box; 8 | use alloc::string::String; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(js_name = ImgReq)] 12 | pub struct ImgReq { 13 | token: Token, 14 | } 15 | 16 | #[wasm_bindgen(js_class = ImgReq)] 17 | impl ImgReq { 18 | #[wasm_bindgen(constructor)] 19 | pub fn new(token: Token) -> ImgReq { 20 | panic_hook!(); 21 | ImgReq { token } 22 | } 23 | } 24 | 25 | #[wasm_bindgen(js_name = ImgBytes, getter_with_clone)] 26 | #[derive(Debug, PartialEq)] 27 | pub struct ImgBytes { 28 | pub bytes: Box<[u8]>, 29 | pub mime: String, 30 | } 31 | 32 | #[wasm_bindgen(js_class = ImgBytes)] 33 | impl ImgBytes { 34 | #[wasm_bindgen(constructor)] 35 | pub fn new(bytes: Box<[u8]>, mime: String) -> ImgBytes { 36 | panic_hook!(); 37 | ImgBytes { bytes, mime } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rs" 3 | version = "0.0.1" 4 | license = "MIT" 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | 12 | anyhow = "1.0.72" 13 | lazy_static = "1.4.0" 14 | futures-util = "0.3.28" 15 | 16 | base64 = "0.21.2" 17 | base64url = "0.1.0" 18 | data-url = "0.3.0" 19 | getrandom = { version = "0.2.10", features = ["js"] } 20 | mime = "0.3.17" 21 | mime_guess = "2.0.4" 22 | rand = { version = "0.8.5" } 23 | regex = "1.8.1" 24 | reqwest = { version = "0.11.16", features = ["json", "multipart"] } 25 | 26 | js-sys = "0.3.64" 27 | web-sys = "0.3.64" 28 | wasm-bindgen = "0.2.87" 29 | wasm-bindgen-futures = "0.4.37" 30 | wasm-streams = "0.3.0" 31 | console_error_panic_hook = "0.1.7" 32 | 33 | serde = { version = "1.0.177", features = ["derive"] } 34 | serde_qs = "0.12.0" 35 | serde_json = { version = "1.0", default-features = false, features = ["alloc"] } 36 | serde-wasm-bindgen = "0.5.0" 37 | serde_with = "3.1.0" 38 | serde_repr = "0.1.16" 39 | -------------------------------------------------------------------------------- /src/infra/uri-handler.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, ProviderResult, Uri, UriHandler } from 'vscode' 2 | import { openPostInVscode } from '@/cmd/post-list/open-post-in-vscode' 3 | 4 | class ExtUriHandler implements UriHandler { 5 | private _evEmitter = new EventEmitter() 6 | 7 | get onUri() { 8 | return this._evEmitter.event 9 | } 10 | 11 | reset() { 12 | const evEmitter = new EventEmitter() 13 | evEmitter.event(uri => { 14 | const { path } = uri 15 | const splits = path.split('/') 16 | if (splits.length >= 3 && splits[1] === 'post.edit') { 17 | const postId = parseInt(splits[2]) 18 | if (postId > 0) void openPostInVscode(postId) 19 | } 20 | }) 21 | this._evEmitter = evEmitter 22 | } 23 | 24 | handleUri(uri: Uri): ProviderResult { 25 | this._evEmitter.fire(uri) 26 | } 27 | } 28 | 29 | export const extUriHandler = new ExtUriHandler() 30 | -------------------------------------------------------------------------------- /rs/src/cnb/img/from_data_url.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::img::ImgBytes; 2 | use crate::infra::result::{IntoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::string::{String, ToString}; 5 | use anyhow::anyhow; 6 | use anyhow::Result; 7 | use data_url::DataUrl; 8 | use wasm_bindgen::prelude::*; 9 | 10 | #[wasm_bindgen(js_class = ImgBytes)] 11 | impl ImgBytes { 12 | #[wasm_bindgen(js_name = fromDataUrl)] 13 | pub fn export_from_data_url(data_url: &str) -> Result { 14 | panic_hook!(); 15 | let ib = from_data_url(data_url); 16 | ib.err_to_string() 17 | } 18 | } 19 | 20 | fn from_data_url(data_url: &str) -> Result { 21 | let data_url = DataUrl::process(data_url).map_err(|e| anyhow!("{:?}", e))?; 22 | let (body, _) = data_url.decode_to_vec().map_err(|e| anyhow!("{:?}", e))?; 23 | 24 | let bytes = body.into_boxed_slice(); 25 | let mime = data_url.mime_type().to_string(); 26 | 27 | ImgBytes::new(bytes, mime).into_ok() 28 | } 29 | -------------------------------------------------------------------------------- /src/ctx/local-state.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode' 2 | import { globalCtx } from '@/ctx/global-ctx' 3 | 4 | export namespace LocalState { 5 | export function getExtCfg() { 6 | return workspace.getConfiguration('cnblogsClient') 7 | } 8 | 9 | export function getState(key: string) { 10 | return globalCtx.extCtx.globalState.get(key) 11 | } 12 | 13 | export function setState(key: string, val: any) { 14 | return globalCtx.extCtx.globalState.update(key, val) 15 | } 16 | 17 | export function delState(key: string) { 18 | return setState(key, undefined) 19 | } 20 | 21 | export function getSecret(key: string) { 22 | return globalCtx.extCtx.secrets.get(key) 23 | } 24 | 25 | export function setSecret(key: string, val: string) { 26 | return globalCtx.extCtx.secrets.store(key, val) 27 | } 28 | 29 | export function delSecret(key: string) { 30 | return globalCtx.extCtx.secrets.delete(key) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/assets/icon-home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rs/src/cnb/oauth/mod.rs: -------------------------------------------------------------------------------- 1 | mod get_token; 2 | mod revoke_token; 3 | 4 | use crate::panic_hook; 5 | use alloc::string::String; 6 | use wasm_bindgen::prelude::*; 7 | 8 | #[wasm_bindgen(js_name = OauthReq)] 9 | pub struct OauthReq { 10 | client_id: String, 11 | client_sec: String, 12 | } 13 | 14 | #[wasm_bindgen(js_class = OauthReq)] 15 | impl OauthReq { 16 | #[wasm_bindgen(constructor)] 17 | pub fn new(client_id: String, client_sec: String) -> OauthReq { 18 | panic_hook!(); 19 | OauthReq { 20 | client_id, 21 | client_sec, 22 | } 23 | } 24 | } 25 | 26 | #[wasm_bindgen(getter_with_clone)] 27 | pub struct Token { 28 | pub token: String, 29 | pub is_pat: bool, 30 | } 31 | 32 | #[wasm_bindgen(js_class = Token)] 33 | impl Token { 34 | #[wasm_bindgen(constructor)] 35 | pub fn new(token: String, is_pat_token: bool) -> Token { 36 | panic_hook!(); 37 | Token { 38 | token, 39 | is_pat: is_pat_token, 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/ing/IngList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Ing, IngComment } from '@/model/ing' 3 | import { IngItem } from 'ing/IngItem' 4 | import { Stack } from '@fluentui/react' 5 | 6 | type Props = { 7 | ingList: Ing[] 8 | comments: Record 9 | } 10 | 11 | export class IngList extends Component { 12 | constructor(props: Props) { 13 | super(props) 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | 20 | {this.renderItems()} 21 | 22 |
23 | ) 24 | } 25 | 26 | private renderItems() { 27 | const comments = this.props.comments 28 | return this.props.ingList.map(ing => ( 29 | 30 | 31 | 32 | )) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/assets/scripts/sqlite3/lib/binding filter=lfs diff=lfs merge=lfs -text 2 | src/assets/scripts/sqlite3/lib/binding/napi-v6-linux-musl-x64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 3 | src/assets/scripts/sqlite3/lib/binding/napi-v6-win32-unknown-ia32/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 4 | src/assets/scripts/sqlite3/lib/binding/napi-v6-win32-unknown-x64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 5 | src/assets/scripts/sqlite3/lib/binding/napi-v6-darwin-unknown-arm64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 6 | src/assets/scripts/sqlite3/lib/binding/napi-v6-darwin-unknown-x64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 7 | src/assets/scripts/sqlite3/lib/binding/napi-v6-linux-glibc-arm64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 8 | src/assets/scripts/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 9 | src/assets/scripts/sqlite3/lib/binding/napi-v6-linux-musl-arm64/node_sqlite3.node filter=lfs diff=lfs merge=lfs -text 10 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/del.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::post_cat::PostCatReq; 2 | use crate::http::unit_or_err; 3 | use crate::infra::http::setup_auth; 4 | use crate::infra::result::ResultExt; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::format; 7 | use alloc::string::String; 8 | use anyhow::Result; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(js_class = PostCatReq)] 12 | impl PostCatReq { 13 | #[wasm_bindgen(js_name = del)] 14 | pub async fn export_del(&self, category_id: usize) -> Result<(), String> { 15 | panic_hook!(); 16 | 17 | let url = blog_backend!("/category/blog/{}", category_id); 18 | 19 | let client = reqwest::Client::new(); 20 | 21 | let req = { 22 | let req = client.delete(url); 23 | setup_auth(req, &self.token.token, self.token.is_pat) 24 | }; 25 | 26 | let result: Result<()> = try { 27 | let resp = req.send().await?; 28 | unit_or_err(resp).await? 29 | }; 30 | 31 | result.err_to_string() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ctx/global-ctx.ts: -------------------------------------------------------------------------------- 1 | import { env, ExtensionContext, Uri } from 'vscode' 2 | import path from 'path' 3 | import { execCmd } from '@/infra/cmd' 4 | import { ExtConst } from '@/ctx/ext-const' 5 | 6 | export class GlobalCtx { 7 | private _extensionContext: ExtensionContext | null = null 8 | 9 | get extCtx(): ExtensionContext { 10 | if (this._extensionContext == null) throw Error('ext ctx not exist') 11 | return this._extensionContext 12 | } 13 | 14 | set extCtx(v: ExtensionContext) { 15 | this._extensionContext = v 16 | } 17 | 18 | get assetsUri() { 19 | const joined = path.join(globalCtx.extCtx.extensionPath, 'dist', 'assets') 20 | return Uri.file(joined) 21 | } 22 | 23 | get extUrl() { 24 | return `${env.uriScheme}://${ExtConst.EXT_PUBLISHER}.${ExtConst.EXT_NAME}` 25 | } 26 | } 27 | 28 | export async function setCtx(key: string, val: any) { 29 | await execCmd('setContext', `${ExtConst.EXT_NAME}.${key}`, val) 30 | } 31 | 32 | export const globalCtx = new GlobalCtx() 33 | -------------------------------------------------------------------------------- /src/tree-view/model/post-tree-item.ts: -------------------------------------------------------------------------------- 1 | import { TreeItem, TreeItemCollapsibleState } from 'vscode' 2 | import { Post } from '@/model/post' 3 | import { toTreeItem } from '@/tree-view/convert' 4 | import { BaseTreeItemSource } from './base-tree-item-source' 5 | 6 | export class PostTreeItem extends BaseTreeItemSource { 7 | parent?: TParent 8 | 9 | constructor( 10 | public readonly post: Post, 11 | public readonly showMetadata = true 12 | ) { 13 | super() 14 | } 15 | 16 | toTreeItem(): TreeItem | Promise { 17 | const value = toTreeItem(this.post) 18 | return value instanceof Promise ? value.then(this.assign) : this.assign(value) 19 | } 20 | 21 | private readonly assign = (treeItem: TreeItem) => 22 | Object.assign( 23 | treeItem, 24 | this.showMetadata 25 | ? { collapsibleState: TreeItemCollapsibleState.Collapsed } 26 | : { collapsibleState: TreeItemCollapsibleState.None } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/service/blog-setting.ts: -------------------------------------------------------------------------------- 1 | import { BlogSetting, BlogSiteDto, BlogSiteExtendDto } from '@/model/blog-setting' 2 | import { AuthedReq } from '@/infra/http/authed-req' 3 | import { consHeader } from '@/infra/http/infra/header' 4 | import { Alert } from '@/infra/alert' 5 | import { ExtConst } from '@/ctx/ext-const' 6 | 7 | let cache: BlogSetting | null = null 8 | 9 | export namespace BlogSettingService { 10 | export async function getBlogSetting(refresh = false) { 11 | if (cache != null && !refresh) return cache 12 | 13 | const url = `${ExtConst.ApiBase.BLOG_BACKEND}/settings` 14 | 15 | try { 16 | const resp = await AuthedReq.get(url, consHeader()) 17 | const data = JSON.parse(resp) as { blogSite: BlogSiteDto; extend: BlogSiteExtendDto } 18 | const setting = new BlogSetting(data.blogSite, data.extend) 19 | cache ??= setting 20 | return setting 21 | } catch (e) { 22 | void Alert.err(`获取博客设置失败: ${e}`) 23 | return cache 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /rs/src/cnb/post/get_template.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostReq)] 13 | impl PostReq { 14 | #[wasm_bindgen(js_name = getTemplate)] 15 | pub async fn export_get_template(&self) -> HomoResult { 16 | panic_hook!(); 17 | let result = get_template(&self.token).await; 18 | result.homo_string() 19 | } 20 | } 21 | 22 | async fn get_template(token: &Token) -> Result { 23 | let url = blog_backend!("/posts/-1"); 24 | 25 | let client = reqwest::Client::new(); 26 | 27 | let req = { 28 | let req = client.get(url); 29 | setup_auth(req, &token.token, token.is_pat) 30 | }; 31 | 32 | let resp = req.send().await?; 33 | body_or_err(resp).await 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/get_cnb_category_list.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::post_cat::PostCatReq; 2 | use crate::http::body_or_err; 3 | use crate::infra::http::setup_auth; 4 | use crate::infra::result::{HomoResult, ResultExt}; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::format; 7 | use alloc::string::String; 8 | use anyhow::Result; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(js_class = PostCatReq)] 12 | impl PostCatReq { 13 | #[wasm_bindgen(js_name = getSitePresetList)] 14 | pub async fn export_get_site_preset_list(&self) -> HomoResult { 15 | panic_hook!(); 16 | let url = blog_backend!("/category/site"); 17 | 18 | let client = reqwest::Client::new(); 19 | 20 | let req = { 21 | let req = client.get(url); 22 | setup_auth(req, &self.token.token, self.token.is_pat) 23 | }; 24 | 25 | let result: Result = try { 26 | let resp = req.send().await?; 27 | body_or_err(resp).await? 28 | }; 29 | 30 | result.err_to_string() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/service/is-target-workspace.ts: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import { workspace } from 'vscode' 3 | import { WorkspaceCfg } from '@/ctx/cfg/workspace' 4 | import { setCtx } from '@/ctx/global-ctx' 5 | 6 | const diskSymbolRegex = /^(\S{1,5}:)(.*)/ 7 | 8 | export const isTargetWorkspace = (): boolean => { 9 | const folders = workspace.workspaceFolders 10 | let currentFolder = folders?.length === 1 ? folders[0].uri.path : undefined 11 | let targetFolder = WorkspaceCfg.getWorkspaceUri().path 12 | const platform = os.platform() 13 | if (platform === 'win32' && targetFolder !== '' && currentFolder !== undefined) { 14 | const replacer = (sub: string, m0?: string, m2?: string) => 15 | m0 !== undefined && m2 !== undefined ? m0.toLowerCase() + m2 : sub 16 | currentFolder = currentFolder.replace(diskSymbolRegex, replacer) 17 | targetFolder = targetFolder.replace(diskSymbolRegex, replacer) 18 | } 19 | const isTarget = currentFolder === targetFolder 20 | void setCtx('isTargetWorkspace', isTarget) 21 | return isTarget 22 | } 23 | -------------------------------------------------------------------------------- /src/assets/icon-logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rs/src/cnb/post/del_one.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::http::unit_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::ResultExt; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostReq)] 13 | impl PostReq { 14 | #[wasm_bindgen(js_name = delOne)] 15 | pub async fn export_del_one(&self, post_id: usize) -> Result<(), String> { 16 | panic_hook!(); 17 | let result = del_one(&self.token, post_id).await; 18 | result.err_to_string() 19 | } 20 | } 21 | 22 | async fn del_one(token: &Token, post_id: usize) -> Result<()> { 23 | let url = blog_backend!("/posts/{}", post_id); 24 | 25 | let client = reqwest::Client::new(); 26 | 27 | let req = { 28 | let req = client.delete(url); 29 | setup_auth(req, &token.token, token.is_pat) 30 | }; 31 | 32 | let resp = req.send().await?; 33 | unit_or_err(resp).await 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/get_all.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post_cat::PostCatReq; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostCatReq)] 13 | impl PostCatReq { 14 | #[wasm_bindgen(js_name = getAll)] 15 | pub async fn export_get_all(&self) -> HomoResult { 16 | panic_hook!(); 17 | let result = get_all(&self.token).await; 18 | result.homo_string() 19 | } 20 | } 21 | 22 | async fn get_all(token: &Token) -> Result { 23 | let url = blog_backend!("/v2/blog-category-types/1/categories"); 24 | 25 | let client = reqwest::Client::new(); 26 | 27 | let req = { 28 | let req = client.get(url); 29 | setup_auth(req, &token.token, token.is_pat) 30 | }; 31 | 32 | let resp = req.send().await?; 33 | body_or_err(resp).await 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/cnb/post/get_one.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostReq)] 13 | impl PostReq { 14 | #[wasm_bindgen(js_name = getOne)] 15 | pub async fn export_get_one(&self, post_id: usize) -> HomoResult { 16 | panic_hook!(); 17 | let result = get_one(&self.token, post_id).await; 18 | result.homo_string() 19 | } 20 | } 21 | 22 | async fn get_one(token: &Token, post_id: usize) -> Result { 23 | let url = blog_backend!("/posts/{}", post_id); 24 | 25 | let client = reqwest::Client::new(); 26 | 27 | let req = { 28 | let req = client.get(url); 29 | setup_auth(req, &token.token, token.is_pat) 30 | }; 31 | 32 | let resp = req.send().await?; 33 | body_or_err(resp).await 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/cnb/ing/get_comment.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::ing::IngReq; 2 | use crate::cnb::oauth::Token; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{openapi, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = IngReq)] 13 | impl IngReq { 14 | #[wasm_bindgen(js_name = getComment)] 15 | pub async fn export_get_comment(&self, ing_id: usize) -> HomoResult { 16 | panic_hook!(); 17 | let result = get_comment(&self.token, ing_id).await; 18 | result.homo_string() 19 | } 20 | } 21 | 22 | async fn get_comment(token: &Token, ing_id: usize) -> Result { 23 | let url = openapi!("/statuses/{}/comments", ing_id); 24 | 25 | let client = reqwest::Client::new(); 26 | 27 | let req = { 28 | let req = client.get(url); 29 | setup_auth(req, &token.token, token.is_pat) 30 | }; 31 | 32 | let resp = req.send().await?; 33 | body_or_err(resp).await 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/http/mime_infer.rs: -------------------------------------------------------------------------------- 1 | use crate::http::RsHttp; 2 | use crate::infra::option::IntoOption; 3 | use crate::panic_hook; 4 | use alloc::string::{String, ToString}; 5 | use mime::Mime; 6 | use wasm_bindgen::prelude::*; 7 | 8 | #[wasm_bindgen(js_class = RsHttp)] 9 | impl RsHttp { 10 | #[wasm_bindgen(js_name = mimeInfer)] 11 | pub fn export_mime_infer(path: &str) -> Option { 12 | panic_hook!(); 13 | let guess = mime_guess::from_path(path); 14 | guess.first().map(|mime| mime.to_string()) 15 | } 16 | 17 | #[wasm_bindgen(js_name = mimeToImgExt)] 18 | pub fn export_mime_to_img_ext(mime: &str) -> Option { 19 | panic_hook!(); 20 | let Ok(mime) = mime.parse::() else { return None; }; 21 | 22 | match mime.essence_str() { 23 | "image/jpeg" => "jpg", 24 | "image/gif" => "gif", 25 | "image/png" => "png", 26 | "image/bmp" => "bmp", 27 | "image/svg+xml" => "svg", 28 | _ => return None, 29 | } 30 | .to_string() 31 | .into_some() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/icon-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icon-page-next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cmd/upload-img/upload-clipboard-img.ts: -------------------------------------------------------------------------------- 1 | import { ProgressLocation, Uri, window, workspace } from 'vscode' 2 | import { Alert } from '@/infra/alert' 3 | import { getClipboardImg } from '@/infra/get-clipboard-img' 4 | import { uploadImgFromPath } from '@/cmd/upload-img/upload-img-from-path' 5 | 6 | const noImagePath = 'no image' 7 | 8 | export async function uploadClipboardImg() { 9 | const img = await getClipboardImg() 10 | 11 | if (img.imgPath === noImagePath) { 12 | void Alert.warn('剪贴板中没有找到图片') 13 | return 14 | } 15 | const path = img.imgPath 16 | const opt = { 17 | title: '正在上传图片', 18 | location: ProgressLocation.Notification, 19 | } 20 | 21 | return window.withProgress(opt, async p => { 22 | p.report({ increment: 30 }) 23 | const url = await uploadImgFromPath(path) 24 | 25 | p.report({ increment: 80 }) 26 | if (!img.shouldKeepAfterUploading) { 27 | const url = Uri.file(path) 28 | await workspace.fs.delete(url) 29 | } 30 | 31 | p.report({ increment: 100 }) 32 | return url 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/get_one.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::post_cat::PostCatReq; 2 | use crate::http::body_or_err; 3 | use crate::infra::http::setup_auth; 4 | use crate::infra::result::{HomoResult, ResultExt}; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::format; 7 | use alloc::string::String; 8 | use anyhow::Result; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(js_class = PostCatReq)] 12 | impl PostCatReq { 13 | #[wasm_bindgen(js_name = getOne)] 14 | pub async fn export_get_one(&self, category_id: usize) -> HomoResult { 15 | panic_hook!(); 16 | let query = format!("parent={}", category_id); 17 | let url = blog_backend!("/v2/blog-category-types/1/categories?{}", query); 18 | 19 | let client = reqwest::Client::new(); 20 | 21 | let req = { 22 | let req = client.get(url); 23 | setup_auth(req, &self.token.token, self.token.is_pat) 24 | }; 25 | 26 | let result: Result = try { 27 | let resp = req.send().await?; 28 | body_or_err(resp).await? 29 | }; 30 | 31 | result.homo_string() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/model/blog-export.ts: -------------------------------------------------------------------------------- 1 | export enum BlogExportStatus { 2 | created = 0, 3 | exporting = 1, 4 | compressing = 2, 5 | done = 3, 6 | failed = 4, 7 | } 8 | 9 | export type BlogExportRecordList = { 10 | items: BlogExportRecord[] 11 | pageIndex: number 12 | pageSize: number 13 | totalCount: number 14 | } 15 | 16 | export type BlogExportRecord = { 17 | id: number 18 | blogId: number 19 | fileName: string 20 | archiveName?: string | null 21 | fileBytes: number 22 | archiveBytes: number 23 | sha256Checksum?: string | null 24 | exportedPostCount: number 25 | postCount: number 26 | status: BlogExportStatus 27 | dateExported?: string | null 28 | dateAdded: string 29 | } 30 | 31 | export type DownloadedBlogExport = { 32 | filePath: string 33 | id?: number | null 34 | } 35 | 36 | export const blogExportStatusNameMap: Record = { 37 | [BlogExportStatus.compressing]: '压缩中', 38 | [BlogExportStatus.exporting]: '导出中', 39 | [BlogExportStatus.created]: '排队中', 40 | [BlogExportStatus.done]: '已完成', 41 | [BlogExportStatus.failed]: '失败', 42 | } 43 | -------------------------------------------------------------------------------- /src/infra/http-client.ts: -------------------------------------------------------------------------------- 1 | import { AuthManager } from '@/auth/auth-manager' 2 | import got, { BeforeRequestHook } from 'got' 3 | import { isString } from 'lodash-es' 4 | import { ReqHeaderKey } from '@/infra/http/infra/header' 5 | import { bearer } from '@/infra/http/infra/auth-type' 6 | 7 | const bearerTokenHook: BeforeRequestHook = async opt => { 8 | const { headers } = opt 9 | const headerKeys = Object.keys(headers) 10 | 11 | const keyIndex = headerKeys.findIndex(x => x.toLowerCase() === ReqHeaderKey.AUTHORIZATION.toLowerCase()) 12 | 13 | if (keyIndex < 0) { 14 | const token = await AuthManager.acquireToken() 15 | 16 | if (isString(token)) headers[ReqHeaderKey.AUTHORIZATION] = bearer(token) 17 | 18 | // TODO: need better solution 19 | if (token.length === 64) headers[ReqHeaderKey.AUTHORIZATION_TYPE] = 'pat' 20 | } 21 | } 22 | 23 | const httpClient = got.extend({ 24 | hooks: { 25 | beforeRequest: [bearerTokenHook], 26 | }, 27 | throwHttpErrors: true, 28 | https: { rejectUnauthorized: false }, 29 | }) 30 | 31 | export { got } 32 | export * from 'got' 33 | export default httpClient 34 | -------------------------------------------------------------------------------- /src/model/post-cat.ts: -------------------------------------------------------------------------------- 1 | export class PostCat { 2 | parentId?: number | null 3 | categoryId = -1 4 | title = '' 5 | visible = true 6 | description = '' 7 | itemCount = 0 8 | order?: number 9 | childCount = 0 10 | visibleChildCount = 0 11 | parent?: PostCat | null 12 | children?: PostCat[] | null 13 | 14 | flattenParents(includeSelf: boolean): PostCat[] { 15 | // eslint-disable-next-line @typescript-eslint/no-this-alias 16 | let i: PostCat | null | undefined = this 17 | const result: PostCat[] = [] 18 | while (i != null) { 19 | if (i !== this || includeSelf) result.unshift(i) 20 | if (i.parent !== null && i.parent !== undefined && !(i.parent instanceof PostCat)) 21 | i.parent = Object.assign(new PostCat(), i.parent) 22 | i = i.parent 23 | } 24 | 25 | return result 26 | } 27 | } 28 | 29 | export type PostCatAddDto = { 30 | title: string 31 | visible: boolean 32 | description: string 33 | } 34 | export type PostCatUpdateDto = Pick 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Cnblogs and Contributors 4 | 5 | All rights reserved. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /src/assets/scripts/clipboard/mac.applescript: -------------------------------------------------------------------------------- 1 | -- From https://github.com/mushanshitiancai/vscode-paste-image 2 | property fileTypes : {{«class PNGf», ".png"}} 3 | 4 | on run argv 5 | if argv is {} then 6 | return "" 7 | end if 8 | 9 | if ((clipboard info) as string) contains "«class furl»" then 10 | return POSIX path of (the clipboard as «class furl») 11 | else 12 | set imagePath to (item 1 of argv) 13 | set theType to getType() 14 | 15 | if theType is not missing value then 16 | try 17 | set myFile to (open for access imagePath with write permission) 18 | set eof myFile to 0 19 | write (the clipboard as (first item of theType)) to myFile 20 | close access myFile 21 | return (POSIX path of imagePath) 22 | on error 23 | try 24 | close access myFile 25 | end try 26 | return "no image" 27 | end try 28 | else 29 | return "no image" 30 | end if 31 | end if 32 | end run 33 | 34 | on getType() 35 | repeat with aType in fileTypes 36 | repeat with theInfo in (clipboard info) 37 | if (first item of theInfo) is equal to (first item of aType) then return aType 38 | end repeat 39 | end repeat 40 | return missing value 41 | end getType 42 | -------------------------------------------------------------------------------- /src/cmd/post-list/open-post-in-vscode.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode' 2 | import { Alert } from '@/infra/alert' 3 | import { PostFileMapManager } from '@/service/post/post-file-map' 4 | import { openPostFile } from './open-post-file' 5 | import { fsUtil } from '@/infra/fs/fsUtil' 6 | import { postPull } from './post-pull' 7 | 8 | export async function openPostInVscode(postId: number): Promise { 9 | const mappedPostFilePath = await getMappedPostFilePath(postId) 10 | 11 | // pull 时取消覆盖本地文件也会执行此处代码 12 | if (mappedPostFilePath == null) return 13 | 14 | if (!(await fsUtil.exists(mappedPostFilePath))) { 15 | void Alert.err(`博文关联的本地文件不存在,postId: ${postId},path: ${mappedPostFilePath}`) 16 | return 17 | } 18 | 19 | await openPostFile(mappedPostFilePath) 20 | return Uri.file(mappedPostFilePath) 21 | } 22 | 23 | async function getMappedPostFilePath(postId: number) { 24 | const mappedPostFilePath = PostFileMapManager.getFilePath(postId) 25 | if (mappedPostFilePath != null && (await fsUtil.exists(mappedPostFilePath))) return mappedPostFilePath 26 | if (await postPull(postId, true, true)) return PostFileMapManager.getFilePath(postId) 27 | } 28 | -------------------------------------------------------------------------------- /rs/src/cnb/api_base.rs: -------------------------------------------------------------------------------- 1 | pub const BLOG_BACKEND: &str = "https://write.cnblogs.com/api"; 2 | 3 | #[macro_export] 4 | macro_rules! blog_backend { 5 | ($($arg:tt)*) => {{ 6 | use $crate::cnb::api_base::BLOG_BACKEND; 7 | use alloc::format; 8 | format!("{}{}", BLOG_BACKEND, format_args!($($arg)*)) 9 | }}; 10 | } 11 | 12 | pub const OPENAPI: &str = "https://api.cnblogs.com/api"; 13 | #[macro_export] 14 | macro_rules! openapi { 15 | ($($arg:tt)*) => {{ 16 | use $crate::cnb::api_base::OPENAPI; 17 | use alloc::format; 18 | format!("{}{}", OPENAPI, format_args!($($arg)*)) 19 | }}; 20 | } 21 | 22 | pub const OAUTH: &str = "https://oauth.cnblogs.com"; 23 | #[macro_export] 24 | macro_rules! oauth { 25 | ($($arg:tt)*) => {{ 26 | use $crate::cnb::api_base::OAUTH; 27 | use alloc::format; 28 | format!("{}{}", OAUTH, format_args!($($arg)*)) 29 | }}; 30 | } 31 | 32 | pub const BACKUP: &str = "https://export.cnblogs.com"; 33 | #[macro_export] 34 | macro_rules! backup { 35 | ($($arg:tt)*) => {{ 36 | use $crate::cnb::api_base::BACKUP; 37 | use alloc::format; 38 | format!("{}{}", OAUTH, format_args!($($arg)*)) 39 | }}; 40 | } 41 | -------------------------------------------------------------------------------- /src/service/blog-export/blog-export-records.store.ts: -------------------------------------------------------------------------------- 1 | import { BlogExportRecordList } from '@/model/blog-export' 2 | import { BlogExportApi } from '@/service/blog-export/blog-export' 3 | 4 | export namespace BlogExportRecordsStore { 5 | let cacheList: Promise | null = null 6 | let cache: BlogExportRecordList | null = null 7 | 8 | export function getCached() { 9 | return cache 10 | } 11 | 12 | export async function refresh(options?: { pageIndex?: number; pageSize?: number; shouldRefresh?: boolean }) { 13 | await clearCache() 14 | return list(options) 15 | } 16 | 17 | export async function clearCache(): Promise { 18 | if (cacheList !== null) await cacheList 19 | 20 | cacheList = null 21 | cache = null 22 | } 23 | 24 | export async function list({ 25 | pageIndex = 1, 26 | pageSize = 500, 27 | }: { 28 | pageIndex?: number 29 | pageSize?: number 30 | shouldRefresh?: boolean 31 | } = {}): Promise { 32 | cacheList ??= BlogExportApi.list({ pageIndex, pageSize }) 33 | cache = await cacheList 34 | 35 | return cache 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/assets/icon-refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rs/src/infra/result.rs: -------------------------------------------------------------------------------- 1 | use alloc::string::{String, ToString}; 2 | use anyhow::Result; 3 | 4 | pub trait IntoResult 5 | where 6 | Self: Sized, 7 | { 8 | #[inline] 9 | fn into_ok(self) -> Result { 10 | Ok(self) 11 | } 12 | #[inline] 13 | fn into_err(self) -> Result { 14 | Err(self) 15 | } 16 | } 17 | 18 | impl IntoResult for T {} 19 | 20 | pub type HomoResult = Result; 21 | 22 | pub trait ResultExt { 23 | fn err_to_string(self) -> Result 24 | where 25 | E: ToString; 26 | 27 | fn homo_string(self) -> HomoResult 28 | where 29 | O: ToString, 30 | E: ToString; 31 | } 32 | 33 | impl ResultExt for Result { 34 | #[inline] 35 | fn err_to_string(self) -> Result 36 | where 37 | E: ToString, 38 | { 39 | self.map_err(|e| e.to_string()) 40 | } 41 | 42 | #[inline] 43 | fn homo_string(self) -> HomoResult 44 | where 45 | O: ToString, 46 | E: ToString, 47 | { 48 | match self { 49 | Ok(o) => Ok(o.to_string()), 50 | Err(e) => Err(e.to_string()), 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /rs/src/infra/http.rs: -------------------------------------------------------------------------------- 1 | use alloc::format; 2 | use alloc::string::{String, ToString}; 3 | use alloc::vec::Vec; 4 | use reqwest::RequestBuilder; 5 | 6 | pub const AUTHORIZATION_TYPE: &str = "Authorization-Type"; 7 | pub const PAT: &str = "pat"; 8 | 9 | #[macro_export] 10 | macro_rules! bearer { 11 | ($token:expr) => {{ 12 | use alloc::format; 13 | format!("Bearer {}", $token) 14 | }}; 15 | } 16 | 17 | #[macro_export] 18 | macro_rules! basic { 19 | ($token:expr) => {{ 20 | use alloc::format; 21 | format!("Basic {}", $token) 22 | }}; 23 | } 24 | 25 | pub fn setup_auth(builder: RequestBuilder, token: &str, is_pat_token: bool) -> RequestBuilder { 26 | let builder = builder.bearer_auth(token); 27 | 28 | if is_pat_token { 29 | builder.header(AUTHORIZATION_TYPE, PAT) 30 | } else { 31 | builder 32 | } 33 | } 34 | 35 | pub fn cons_query_string(queries: Vec<(impl ToString, impl ToString)>) -> String { 36 | queries 37 | .into_iter() 38 | .map(|(k, v)| { 39 | let s_k = k.to_string(); 40 | let s_v = v.to_string(); 41 | format!("{}={}", s_k, s_v) 42 | }) 43 | .fold("".to_string(), |acc, q| format!("{acc}&{q}")) 44 | } 45 | -------------------------------------------------------------------------------- /src/cmd/upload-img/upload-img-util.ts: -------------------------------------------------------------------------------- 1 | import { env, SnippetString, window } from 'vscode' 2 | import { fmtImgLink } from '@/infra/fmt-img-link' 3 | import { Alert } from '@/infra/alert' 4 | 5 | /** 6 | * 显示上传成功对话框, 支持复制不同格式的图片链接 7 | * 8 | * @param {string} imgLink 9 | */ 10 | export async function showUploadSuccessModel(imgLink: string) { 11 | const options = ['复制链接', '复制链接(markdown)', '复制链接(html)'] 12 | const selected = await Alert.info( 13 | '上传图片成功', 14 | { 15 | modal: true, 16 | detail: `图片链接: ${imgLink}`, 17 | }, 18 | ...options 19 | ) 20 | 21 | let text = null 22 | 23 | if (selected === options[0]) text = imgLink 24 | else if (selected === options[1]) text = fmtImgLink(imgLink, 'markdown') 25 | else if (selected === options[2]) text = fmtImgLink(imgLink, 'html') 26 | 27 | if (text !== null) await env.clipboard.writeText(text) 28 | } 29 | 30 | export async function insertImgLinkToActiveEditor(imgLink: string): Promise { 31 | const activeEditor = window.activeTextEditor 32 | if (activeEditor === undefined) return false 33 | 34 | await activeEditor.insertSnippet(new SnippetString(fmtImgLink(imgLink, 'markdown'))) 35 | return true 36 | } 37 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/create.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::post_cat::PostCatReq; 2 | use crate::http::unit_or_err; 3 | use crate::infra::http::setup_auth; 4 | use crate::infra::result::ResultExt; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::format; 7 | use alloc::string::{String, ToString}; 8 | use anyhow::Result; 9 | use mime::APPLICATION_JSON; 10 | use reqwest::header::CONTENT_TYPE; 11 | use wasm_bindgen::prelude::*; 12 | 13 | #[wasm_bindgen(js_class = PostCatReq)] 14 | impl PostCatReq { 15 | #[wasm_bindgen(js_name = create)] 16 | pub async fn export_create(&self, category_dto_json: String) -> Result<(), String> { 17 | panic_hook!(); 18 | 19 | let url = blog_backend!("/category/blog/1"); 20 | 21 | let client = reqwest::Client::new(); 22 | 23 | let req = { 24 | let req = client.post(url); 25 | let req = req 26 | .header(CONTENT_TYPE, APPLICATION_JSON.to_string()) 27 | .body(category_dto_json); 28 | setup_auth(req, &self.token.token, self.token.is_pat) 29 | }; 30 | 31 | let result: Result<()> = try { 32 | let resp = req.send().await?; 33 | unit_or_err(resp).await? 34 | }; 35 | 36 | result.err_to_string() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/service/extract-img/apply-replace-list.ts: -------------------------------------------------------------------------------- 1 | import { ImgInfo } from '@/service/extract-img/get-replace-list' 2 | import { RsText } from '@/wasm' 3 | import { FILTER_BYTE_OFFSET } from './find-img-link' 4 | import { escapeRegExp } from 'lodash' 5 | 6 | export function applyReplaceList( 7 | text: string, 8 | replaceList: [src: ImgInfo, newLink: string][], 9 | beforeEach: (newLink: string) => void 10 | ) { 11 | const rsCandidate = replaceList.filter(x => x[0].byteOffset !== FILTER_BYTE_OFFSET) 12 | 13 | // replace from end 14 | const sorted = rsCandidate.sort((a, b) => b[0].byteOffset - a[0].byteOffset) 15 | for (const [src, newLink] of sorted) { 16 | beforeEach(newLink) 17 | const start = src.byteOffset 18 | const end = src.byteOffset + Buffer.from(src.data).length 19 | text = RsText.replaceWithByteOffset(text, start, end, newLink) 20 | } 21 | 22 | const tsCandidate = replaceList.filter(x => x[0].byteOffset === FILTER_BYTE_OFFSET) 23 | for (const [src, newLink] of tsCandidate) { 24 | const prefix = src.prefix ?? '' 25 | const regex = new RegExp(escapeRegExp(String.raw`${prefix}${src.data}`), 'g') 26 | text = text.replace(regex, prefix + newLink) 27 | } 28 | 29 | return text 30 | } 31 | -------------------------------------------------------------------------------- /src/assets/icon-account-settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ctx/cfg/workspace.ts: -------------------------------------------------------------------------------- 1 | import { PlatformCfg } from '@/ctx/cfg/platform' 2 | import getPlatformCfg = PlatformCfg.getPlatformCfg 3 | import os from 'os' 4 | import { ConfigurationTarget, Uri, workspace } from 'vscode' 5 | import { Alert } from '@/infra/alert' 6 | import { PostFileMapManager } from '@/service/post/post-file-map' 7 | 8 | export namespace WorkspaceCfg { 9 | export function getWorkspaceUri() { 10 | const path = getPlatformCfg().get('workspace') ?? '~/Documents/Cnblogs' 11 | const absPath = path.replace('~', os.homedir()) 12 | return Uri.file(absPath) 13 | } 14 | 15 | export async function setWorkspaceUri(uri: Uri): Promise { 16 | const fsPath = uri.fsPath 17 | 18 | if (uri.scheme !== 'file') throw Error(`Invalid Uri: ${uri.path}`) 19 | 20 | try { 21 | await workspace.fs.stat(uri) 22 | } catch (e) { 23 | void Alert.err(`Invalid Uri: ${uri.path}`) 24 | throw e 25 | } 26 | 27 | const oldWorkspaceUri = WorkspaceCfg.getWorkspaceUri() 28 | const cfgTarget = ConfigurationTarget.Global 29 | await getPlatformCfg()?.update('workspace', fsPath, cfgTarget) 30 | PostFileMapManager.updateWithWorkspace(oldWorkspaceUri) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/setup/setup-ui.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceConfiguration as WorkspaceCfg } from 'vscode' 2 | import { getIngListWebviewProvider } from '@/service/ing/ing-list-webview-provider' 3 | import { extTreeViews } from '@/tree-view/tree-view-register' 4 | 5 | export function setupUi(cfg: WorkspaceCfg) { 6 | void getIngListWebviewProvider().reload() 7 | applyTreeViewTitleStyle(cfg) 8 | } 9 | 10 | export function applyTreeViewTitleStyle(cfg: WorkspaceCfg) { 11 | type Enum = 'normal' | 'short' 12 | const option = cfg.get('ui.treeViewTitleStyle') 13 | if (option === 'normal') { 14 | extTreeViews.postList.title = '随笔列表' 15 | extTreeViews.anotherPostList.title = '随笔列表' 16 | extTreeViews.account.title = '账号中心' 17 | extTreeViews.postCategoriesList.title = '随笔分类' 18 | extTreeViews.blogExport.title = '博客备份' 19 | extTreeViews.navi.title = '网站导航' 20 | return 21 | } 22 | if (option === 'short') { 23 | extTreeViews.postList.title = '随笔' 24 | extTreeViews.anotherPostList.title = '随笔' 25 | extTreeViews.account.title = '账号' 26 | extTreeViews.postCategoriesList.title = '分类' 27 | extTreeViews.blogExport.title = '备份' 28 | extTreeViews.navi.title = '导航' 29 | return 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /rs/src/cnb/post/del_some.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::http::unit_or_err; 4 | use crate::infra::http::{cons_query_string, setup_auth}; 5 | use crate::infra::result::ResultExt; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use alloc::vec::Vec; 10 | use anyhow::Result; 11 | use wasm_bindgen::prelude::*; 12 | 13 | #[wasm_bindgen(js_class = PostReq)] 14 | impl PostReq { 15 | #[wasm_bindgen(js_name = delSome)] 16 | pub async fn export_del_some(&self, post_ids: &[usize]) -> Result<(), String> { 17 | panic_hook!(); 18 | let result = del_some(&self.token, post_ids).await; 19 | result.err_to_string() 20 | } 21 | } 22 | 23 | async fn del_some(token: &Token, post_ids: &[usize]) -> Result<()> { 24 | let post_ids: Vec<(&str, &usize)> = post_ids.iter().map(|id| ("postIds", id)).collect(); 25 | let query = cons_query_string(post_ids); 26 | let url = blog_backend!("/bulk-operation/post?{}", query); 27 | 28 | let client = reqwest::Client::new(); 29 | 30 | let req = { 31 | let req = client.delete(url); 32 | setup_auth(req, &token.token, token.is_pat) 33 | }; 34 | 35 | let resp = req.send().await?; 36 | unit_or_err(resp).await 37 | } 38 | -------------------------------------------------------------------------------- /rs/src/cnb/post/update.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::{String, ToString}; 9 | use anyhow::Result; 10 | use mime::APPLICATION_JSON; 11 | use reqwest::header::CONTENT_TYPE; 12 | use wasm_bindgen::prelude::*; 13 | 14 | #[wasm_bindgen(js_class = PostReq)] 15 | impl PostReq { 16 | #[wasm_bindgen(js_name = update)] 17 | pub async fn export_update(&self, post_json: &str) -> HomoResult { 18 | panic_hook!(); 19 | let result = update(&self.token, post_json).await; 20 | result.homo_string() 21 | } 22 | } 23 | 24 | async fn update(token: &Token, post_json: &str) -> Result { 25 | let url = blog_backend!("/posts"); 26 | 27 | let client = reqwest::Client::new(); 28 | 29 | let req = { 30 | let req = client 31 | .post(url) 32 | .header(CONTENT_TYPE, APPLICATION_JSON.to_string()) 33 | .body(post_json.to_string()); 34 | setup_auth(req, &token.token, token.is_pat) 35 | }; 36 | 37 | let resp = req.send().await?; 38 | body_or_err(resp).await 39 | } 40 | -------------------------------------------------------------------------------- /src/infra/http/authed-req.ts: -------------------------------------------------------------------------------- 1 | import { AuthManager } from '@/auth/auth-manager' 2 | 3 | import { ReqHeaderKey } from '@/infra/http/infra/header' 4 | import { bearer } from '@/infra/http/infra/auth-type' 5 | import { Req } from '@/infra/http/req' 6 | 7 | type Header = Map 8 | 9 | async function makeAuthed(header: Header) { 10 | const token = await AuthManager.acquireToken() 11 | header.set(ReqHeaderKey.AUTHORIZATION, bearer(token)) 12 | 13 | // TODO: need better solution 14 | if (token.length === 64) header.set(ReqHeaderKey.AUTHORIZATION_TYPE, 'pat') 15 | } 16 | 17 | export namespace AuthedReq { 18 | export async function put(url: string, header: Header, body: string) { 19 | await makeAuthed(header) 20 | return Req.put(url, header, body) 21 | } 22 | 23 | export async function del(url: string, header: Header) { 24 | await makeAuthed(header) 25 | return Req.del(url, header) 26 | } 27 | 28 | export async function post(url: string, header: Header, body: string) { 29 | await makeAuthed(header) 30 | return Req.post(url, header, body) 31 | } 32 | 33 | export async function get(url: string, header: Header) { 34 | await makeAuthed(header) 35 | return Req.get(url, header) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /rs/src/cnb/ing/mod.rs: -------------------------------------------------------------------------------- 1 | mod comment; 2 | mod get_comment; 3 | mod get_list; 4 | mod publish; 5 | 6 | use crate::cnb::oauth::Token; 7 | use crate::panic_hook; 8 | use alloc::string::{String, ToString}; 9 | use lazy_static::lazy_static; 10 | use regex::Regex; 11 | use serde_repr::{Deserialize_repr, Serialize_repr}; 12 | use wasm_bindgen::prelude::*; 13 | 14 | #[derive(Clone, Debug, Serialize_repr, Deserialize_repr)] 15 | #[repr(u8)] 16 | pub enum IngSendFrom { 17 | None = 0, 18 | Ms = 1, 19 | GTalk = 2, 20 | Qq = 3, 21 | Sms = 5, 22 | CellPhone = 6, 23 | Web = 8, 24 | VsCode = 9, 25 | Cli = 13, 26 | } 27 | 28 | #[wasm_bindgen(js_name = IngReq)] 29 | pub struct IngReq { 30 | token: Token, 31 | } 32 | 33 | #[wasm_bindgen(js_class = IngReq)] 34 | impl IngReq { 35 | #[wasm_bindgen(constructor)] 36 | pub fn new(token: Token) -> IngReq { 37 | panic_hook!(); 38 | IngReq { token } 39 | } 40 | } 41 | 42 | #[wasm_bindgen(js_name = ingStarIconToText)] 43 | pub fn ing_star_tag_to_text(icon: &str) -> String { 44 | lazy_static! { 45 | static ref REGEX: Regex = Regex::new(r#""#).unwrap(); 46 | } 47 | let caps = REGEX.captures(icon).unwrap(); 48 | let star_text = caps.get(1).unwrap().as_str(); 49 | star_text.to_string() 50 | } 51 | -------------------------------------------------------------------------------- /src/cmd/post-cat/new-post-cat.ts: -------------------------------------------------------------------------------- 1 | import { ProgressLocation, window } from 'vscode' 2 | import { PostCatService } from '@/service/post/post-cat' 3 | import { extTreeViews } from '@/tree-view/tree-view-register' 4 | import { inputPostCat } from './input-post-cat' 5 | import { postCategoryDataProvider } from '@/tree-view/provider/post-category-tree-data-provider' 6 | import { PostCateStore } from '@/stores/post-cate-store' 7 | 8 | export async function newPostCat() { 9 | const input = await inputPostCat('新建分类') 10 | if (input === undefined) return 11 | 12 | const opt = { 13 | title: '正在新建博文分类', 14 | location: ProgressLocation.Notification, 15 | } 16 | 17 | await window.withProgress(opt, async p => { 18 | p.report({ 19 | increment: 30, 20 | }) 21 | await PostCatService.create(input) 22 | p.report({ 23 | increment: 70, 24 | }) 25 | 26 | await postCategoryDataProvider.refreshAsync() 27 | 28 | const allCategory = (await PostCateStore.createAsync()).getFlatAll() 29 | const newCategory = allCategory.find(x => x.title === input.title) 30 | if (newCategory !== undefined) await extTreeViews.postCategoriesList.reveal(newCategory) 31 | 32 | p.report({ 33 | increment: 100, 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/infra/alert.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { ProgressLocation, Uri, window } from 'vscode' 3 | 4 | export namespace Alert { 5 | export const err = window.showErrorMessage 6 | 7 | export const info = window.showInformationMessage 8 | 9 | export const warn = window.showWarningMessage 10 | 11 | export function infoWithTimeout(info: string, sec: number) { 12 | return window.withProgress( 13 | { 14 | title: info, 15 | location: ProgressLocation.Notification, 16 | }, 17 | () => 18 | new Promise(resolve => { 19 | setTimeout(resolve, sec * 1000) 20 | }) 21 | ) 22 | } 23 | 24 | /** 25 | * alert that file not linked to the post 26 | * @param file the file path, could be a string or {@link Uri} object 27 | * @param trimExt 28 | */ 29 | export function fileNotLinkedToPost(file: string | Uri, { trimExt = true } = {}) { 30 | file = file instanceof Uri ? file.fsPath : file 31 | file = trimExt ? path.basename(file, path.extname(file)) : file 32 | void Alert.warn(`本地文件 ${file} 未关联博客园博文`) 33 | } 34 | 35 | export function throwWithWarn(message: string): never { 36 | void warn(message) 37 | throw Error(message) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rs/src/cnb/post_cat/update.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post_cat::PostCatReq; 3 | use crate::http::unit_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::ResultExt; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::{String, ToString}; 9 | use anyhow::Result; 10 | use mime::APPLICATION_JSON; 11 | use reqwest::header::CONTENT_TYPE; 12 | use wasm_bindgen::prelude::*; 13 | 14 | #[wasm_bindgen(js_class = PostCatReq)] 15 | impl PostCatReq { 16 | #[wasm_bindgen(js_name = update)] 17 | pub async fn export_update(&self, cat_id: usize, cat_json: String) -> Result<(), String> { 18 | panic_hook!(); 19 | let result = update(&self.token, cat_id, cat_json).await; 20 | result.err_to_string() 21 | } 22 | } 23 | 24 | async fn update(token: &Token, cat_id: usize, cat_json: String) -> Result<()> { 25 | let url = blog_backend!("/category/blog/{}", cat_id); 26 | 27 | let client = reqwest::Client::new(); 28 | 29 | let req = { 30 | let req = client.put(url); 31 | let req = req 32 | .header(CONTENT_TYPE, APPLICATION_JSON.to_string()) 33 | .body(cat_json); 34 | setup_auth(req, &token.token, token.is_pat) 35 | }; 36 | 37 | let resp = req.send().await?; 38 | unit_or_err(resp).await 39 | } 40 | -------------------------------------------------------------------------------- /src/tree-view/model/blog-export/post.ts: -------------------------------------------------------------------------------- 1 | import type { ExportPost } from '@/model/blog-export/export-post' 2 | import { BaseTreeItemSource } from '@/tree-view/model/base-tree-item-source' 3 | import { ExportPostEntryTreeItem } from '@/tree-view/model/blog-export' 4 | import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode' 5 | import { WorkspaceCfg } from '@/ctx/cfg/workspace' 6 | import { extName } from '@/ctx/ext-const' 7 | 8 | export class ExportPostTreeItem extends BaseTreeItemSource { 9 | constructor( 10 | public readonly parent: ExportPostEntryTreeItem, 11 | public readonly post: ExportPost 12 | ) { 13 | super() 14 | } 15 | 16 | toTreeItem(): TreeItem | Promise { 17 | const { title, isMarkdown } = this.post 18 | 19 | return { 20 | label: title, 21 | iconPath: new ThemeIcon(isMarkdown ? 'markdown' : 'file-code'), 22 | collapsibleState: TreeItemCollapsibleState.None, 23 | command: { 24 | title: '查看博文', 25 | command: extName`.backup.view-post`, 26 | arguments: [this], 27 | }, 28 | resourceUri: Uri.joinPath(WorkspaceCfg.getWorkspaceUri(), title + (isMarkdown ? '.md' : '.html')), 29 | contextValue: 'cnblogs-export-post', 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc.json", 3 | "root": true, 4 | "extends": ["@cnblogs/typescript"], 5 | "parser": "@typescript-eslint/parser", 6 | "parserOptions": { 7 | "ecmaVersion": 6, 8 | "sourceType": "module", 9 | "project": ["./tsconfig.json", "./ui/tsconfig.json", "./test/tsconfig.json"] 10 | }, 11 | "rules": { 12 | "prettier/prettier": [ 13 | "error", 14 | { 15 | "endOfLine": "auto", 16 | "semi": false 17 | } 18 | ], 19 | "@typescript-eslint/strict-boolean-expressions": [ 20 | "warn", 21 | { 22 | "allowString": false, 23 | "allowNumber": false, 24 | "allowNullableObject": false, 25 | "allowNullableBoolean": false 26 | } 27 | ] 28 | }, 29 | "ignorePatterns": [ 30 | "rs", 31 | "out", 32 | "pkg", 33 | "dist", 34 | "**/*.d.ts", 35 | "src/test/**", 36 | "src/wasm/**", 37 | "src/assets/**", 38 | "__mocks__/vscode.ts" 39 | ], 40 | "overrides": [ 41 | { 42 | "files": ["*.config.js"], 43 | "env": { 44 | "node": true 45 | } 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /rs/src/http/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod del; 2 | pub mod get; 3 | mod mime_infer; 4 | pub mod post; 5 | pub mod put; 6 | 7 | use crate::infra::result::IntoResult; 8 | use alloc::string::String; 9 | use anyhow::{bail, Result}; 10 | use core::convert::TryFrom; 11 | use core::ops::Not; 12 | use core::str::FromStr; 13 | use reqwest::header::HeaderMap; 14 | use reqwest::Response; 15 | use serde_json::Value; 16 | use wasm_bindgen::__rt::std::collections::HashMap; 17 | use wasm_bindgen::prelude::*; 18 | 19 | #[wasm_bindgen(js_name = RsHttp)] 20 | pub struct RsHttp; 21 | 22 | fn header_json_to_header_map(header_json: &str) -> Result { 23 | let header_json = Value::from_str(header_json)?; 24 | let header = serde_json::from_value::>(header_json)?; 25 | let header_map = HeaderMap::try_from(&header)?; 26 | 27 | header_map.into_ok() 28 | } 29 | 30 | pub async fn unit_or_err(resp: Response) -> Result<()> { 31 | let code = resp.status(); 32 | let body = resp.text().await?; 33 | 34 | if code.is_success().not() { 35 | bail!("{}: {}", code, body); 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | pub async fn body_or_err(resp: Response) -> Result { 42 | let code = resp.status(); 43 | let body = resp.text().await?; 44 | 45 | if code.is_success() { 46 | body.into_ok() 47 | } else { 48 | bail!("{}: {}", code, body) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}", 15 | "env": { 16 | "NODE_ENV": "Development", 17 | "NODE_TLS_REJECT_UNAUTHORIZED": "1" 18 | } 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index", 27 | "--disable-extensions" 28 | ], 29 | "outFiles": ["${workspaceFolder}/out/**/*.js", "${workspaceFolder}/dist/**/*.js"], 30 | "preLaunchTask": "tasks: watch-tests" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/cmd/post-list/open-post-file.ts: -------------------------------------------------------------------------------- 1 | import { TextDocumentShowOptions, Uri } from 'vscode' 2 | import { execCmd } from '@/infra/cmd' 3 | import { Post } from '@/model/post' 4 | import { PostFileMapManager } from '@/service/post/post-file-map' 5 | import { LocalPost } from '@/service/local-post' 6 | import { fsUtil } from '@/infra/fs/fsUtil' 7 | import { postPull } from './post-pull' 8 | 9 | export async function openPostFile( 10 | post: LocalPost | Post | string, 11 | options?: TextDocumentShowOptions, 12 | autoPull = false 13 | ) { 14 | let filePath = '' 15 | if (post instanceof LocalPost) { 16 | filePath = post.filePath 17 | } else if (post instanceof Post) { 18 | filePath = PostFileMapManager.getFilePath(post.id) ?? '' 19 | if (autoPull) { 20 | if (filePath.length === 0 || (filePath.length > 0 && !(await fsUtil.exists(filePath)))) 21 | if (await postPull(post, true, true)) filePath = PostFileMapManager.getFilePath(post.id) ?? '' 22 | } 23 | } else { 24 | filePath = post 25 | } 26 | 27 | if (filePath.length > 0) await openFile(filePath, options) 28 | } 29 | 30 | function openFile(filePath: string, options?: TextDocumentShowOptions) { 31 | return execCmd( 32 | 'vscode.open', 33 | Uri.file(filePath), 34 | Object.assign({ preview: false } as TextDocumentShowOptions, options ?? {}) 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/model/webview-msg.ts: -------------------------------------------------------------------------------- 1 | import { Post } from './post' 2 | import { Webview } from './webview-cmd' 3 | import { ColorThemeKind } from 'vscode' 4 | import { ImgUploadStatus } from './img-upload-status' 5 | import { PostCat } from '@/model/post-cat' 6 | import { SiteCat } from '@/model/site-category' 7 | import { PostTag } from '@/wasm' 8 | 9 | export namespace WebviewMsg { 10 | export type Msg = { 11 | command: Webview.Cmd.Ui | Webview.Cmd.Ext 12 | } 13 | 14 | export interface EditPostCfgMsg extends Msg { 15 | post: Post 16 | activeTheme: ColorThemeKind 17 | userCats: PostCat[] 18 | siteCats: SiteCat[] 19 | tags: PostTag[] 20 | breadcrumbs: string[] 21 | fileName: string 22 | } 23 | 24 | export interface UploadPostMsg extends Msg { 25 | post: Post 26 | } 27 | 28 | export interface UpdateBreadcrumbMsg extends Msg { 29 | breadcrumbs: string[] 30 | } 31 | 32 | export interface UploadImgMsg extends Msg { 33 | imageId: string 34 | } 35 | 36 | export interface UpdateImgUpdateStatusMsg extends Msg { 37 | imageId: string 38 | status: ImgUploadStatus 39 | } 40 | 41 | export interface SetFluentIconBaseUrlMsg extends Msg { 42 | baseUrl: string 43 | } 44 | 45 | export interface ChangeThemeMsg extends Msg { 46 | colorThemeKind: ColorThemeKind 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rs/src/cnb/ing/get_list.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::ing::IngReq; 2 | use crate::cnb::oauth::Token; 3 | use crate::http::body_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{openapi, panic_hook}; 7 | use alloc::string::String; 8 | use alloc::{format, vec}; 9 | use anyhow::Result; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = IngReq)] 13 | impl IngReq { 14 | #[wasm_bindgen(js_name = getList)] 15 | pub async fn export_get_list( 16 | &self, 17 | page_index: usize, 18 | page_size: usize, 19 | ing_type: usize, 20 | ) -> HomoResult { 21 | panic_hook!(); 22 | let result = get_list(&self.token, page_index, page_size, ing_type).await; 23 | result.homo_string() 24 | } 25 | } 26 | 27 | async fn get_list( 28 | token: &Token, 29 | page_index: usize, 30 | page_size: usize, 31 | ing_type: usize, 32 | ) -> Result { 33 | let url = openapi!("/statuses/@{}", ing_type); 34 | 35 | let client = reqwest::Client::new(); 36 | 37 | let req = { 38 | let req = client.get(url); 39 | 40 | let queries = vec![("pageIndex", page_index), ("pageSize", page_size)]; 41 | let req = req.query(&queries); 42 | 43 | setup_auth(req, &token.token, token.is_pat) 44 | }; 45 | 46 | let resp = req.send().await?; 47 | body_or_err(resp).await 48 | } 49 | -------------------------------------------------------------------------------- /src/tree-view/model/blog-export/parser.ts: -------------------------------------------------------------------------------- 1 | import { BlogExportRecord, BlogExportStatus, DownloadedBlogExport } from '@/model/blog-export' 2 | import { BlogExportRecordTreeItem } from './record' 3 | import { ThemeColor, ThemeIcon } from 'vscode' 4 | import { DownloadedExportsEntryTreeItem, DownloadedExportTreeItem } from '@/tree-view/model/blog-export/downloaded' 5 | import { BlogExportProvider } from '@/tree-view/provider/blog-export-provider' 6 | 7 | export function parseStatusIcon(status: BlogExportStatus) { 8 | switch (status) { 9 | case BlogExportStatus.done: 10 | return new ThemeIcon('pass', new ThemeColor('testing.iconPassed')) 11 | case BlogExportStatus.failed: 12 | return new ThemeIcon('error', new ThemeColor('errorForeground')) 13 | case BlogExportStatus.created: 14 | return new ThemeIcon('circle-large-outline') 15 | default: 16 | return new ThemeIcon('sync~spin') 17 | } 18 | } 19 | 20 | export function parseBlogExportRecords(treeDataProvider: BlogExportProvider, items: BlogExportRecord[]) { 21 | return items.map(i => new BlogExportRecordTreeItem(treeDataProvider, i)) 22 | } 23 | 24 | export function parseDownloadedExports( 25 | parent: DownloadedExportsEntryTreeItem, 26 | items: DownloadedBlogExport[] 27 | ): DownloadedExportTreeItem[] { 28 | return items.map(i => new DownloadedExportTreeItem(parent, i)) 29 | } 30 | -------------------------------------------------------------------------------- /src/service/post/create.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from 'os' 2 | import { window } from 'vscode' 3 | import { WorkspaceCfg } from '@/ctx/cfg/workspace' 4 | import { openPostFile } from '@/cmd/post-list/open-post-file' 5 | import { Post, PostType } from '@/model/post' 6 | import { PostService } from './post' 7 | import { Alert } from '@/infra/alert' 8 | import { PostListView } from '@/cmd/post-list/post-list-view' 9 | 10 | export async function createPost() { 11 | const workspacePath = WorkspaceCfg.getWorkspaceUri().fsPath 12 | const dir = workspacePath.replace(homedir(), '~') 13 | const title = await window.showInputBox({ 14 | placeHolder: '请输入标题', 15 | prompt: `文件将会保存至 ${dir}`, 16 | title: '新建博文', 17 | validateInput: input => { 18 | if (input === '') return '标题不能为空' 19 | return 20 | }, 21 | }) 22 | 23 | if (title == null) return 24 | 25 | const post = new Post() 26 | post.title = title 27 | post.postBody = '# Hello World\n' 28 | post.isMarkdown = true 29 | post.isDraft = true 30 | post.displayOnHomePage = true 31 | post.postType = PostType.blogPost 32 | const postId = (await PostService.update(post)).id 33 | if (postId > 0) { 34 | post.id = postId 35 | await PostListView.refresh() 36 | await openPostFile(post, undefined, true) 37 | } else { 38 | void Alert.err('创建博文失败,postId: ' + postId) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/cmd/post-list/del-post-to-local-file-map.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@/model/post' 2 | import { PostFileMap, PostFileMapManager } from '@/service/post/post-file-map' 3 | import { revealPostListItem } from '@/service/post/post-list-view' 4 | import { PostTreeItem } from '@/tree-view/model/post-tree-item' 5 | import { extTreeViews } from '@/tree-view/tree-view-register' 6 | import { Alert } from '@/infra/alert' 7 | 8 | async function confirm(postList: Post[]): Promise { 9 | const options = { 10 | detail: postList.map(x => x.title).join(', '), 11 | modal: true, 12 | } 13 | const answer = await Alert.info('确定要取消这些博文与本地文件的关联吗?', options) 14 | return answer === '确定' 15 | } 16 | 17 | export async function delPostToLocalFileMap(post?: Post | PostTreeItem) { 18 | post = post instanceof PostTreeItem ? post.post : post 19 | const view = extTreeViews.postList 20 | 21 | let selectedPost = view.selection 22 | .map(x => (x instanceof Post ? x : x instanceof PostTreeItem ? x.post : null)) 23 | .filter((x): x is Post => x != null) 24 | 25 | if (post === undefined) return 26 | if (!selectedPost.includes(post)) { 27 | await revealPostListItem(post) 28 | selectedPost = [post] 29 | } 30 | if (selectedPost.length <= 0) return 31 | 32 | if (!(await confirm(selectedPost))) return 33 | 34 | await PostFileMapManager.updateOrCreateMany(selectedPost.map(p => [p.id, ''] as PostFileMap)) 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 12 | "typescript.tsc.autoDetect": "off", 13 | "editor.formatOnSave": true, 14 | "cSpell.words": [ 15 | "ASPNETCORE", 16 | "CLIENTID", 17 | "CLIENTSECRET", 18 | "fluentui", 19 | "iconfont", 20 | "ing", 21 | "nbsp", 22 | "OAUTHCLIENTID", 23 | "OAUTHCLIENTSECRET", 24 | "randomstring", 25 | "singleline", 26 | "tailwindcss", 27 | "untildify", 28 | "vsix" 29 | ], 30 | "json.format.enable": false, 31 | "eslint.alwaysShowStatus": true, 32 | "eslint.lintTask.enable": true, 33 | "eslint.run": "onType", 34 | "eslint.debug": false, 35 | "eslint.format.enable": true, 36 | "editor.codeActionsOnSave": { 37 | "source.fixAll": "explicit" 38 | }, 39 | "jest.jestCommandLine": "npm run test:unit -- " 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/scripts/clipboard/windows10.ps1: -------------------------------------------------------------------------------- 1 | # Adapted from https://github.com/octan3/img-clipboard-dump/blob/master/dump-clipboard-png.ps1 2 | param($imagePath) 3 | 4 | # https://github.com/PowerShell/PowerShell/issues/7233 5 | # fix the output encoding bug 6 | [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding 7 | 8 | Add-Type -Assembly PresentationCore 9 | function main { 10 | $img = [Windows.Clipboard]::GetImage() 11 | 12 | if ($img -eq $null) { 13 | "no image" 14 | Exit 1 15 | } 16 | 17 | if (-not $imagePath) { 18 | "no image" 19 | Exit 1 20 | } 21 | 22 | $fcb = new-object Windows.Media.Imaging.FormatConvertedBitmap($img, [Windows.Media.PixelFormats]::Rgb24, $null, 0) 23 | $stream = [IO.File]::Open($imagePath, "OpenOrCreate") 24 | $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder 25 | $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($fcb)) | out-null 26 | $encoder.Save($stream) | out-null 27 | $stream.Dispose() | out-null 28 | 29 | $imagePath 30 | # fix windows 10 native cmd crash bug when "picgo upload" 31 | # https://github.com/PicGo/PicGo-Core/issues/32 32 | Exit 1 33 | } 34 | 35 | try { 36 | # For WIN10 37 | $file = Get-Clipboard -Format FileDropList 38 | if ($file -ne $null) { 39 | Convert-Path $file 40 | Exit 1 41 | } 42 | } catch { 43 | # For WIN7 WIN8 WIN10 44 | main 45 | } 46 | 47 | main -------------------------------------------------------------------------------- /src/cmd/blog-export/edit.ts: -------------------------------------------------------------------------------- 1 | import { openPostFile } from '@/cmd/post-list/open-post-file' 2 | import { Alert } from '@/infra/alert' 3 | import { ExportPostTreeItem } from '@/tree-view/model/blog-export/post' 4 | import fs from 'fs' 5 | import path from 'path' 6 | import sanitizeFileName from 'sanitize-filename' 7 | import { promisify } from 'util' 8 | import { WorkspaceCfg } from '@/ctx/cfg/workspace' 9 | 10 | export async function editExportPost(treeItem?: ExportPostTreeItem) { 11 | if (!(treeItem instanceof ExportPostTreeItem)) return void Alert.warn('不支持的参数输入') 12 | 13 | const { 14 | post: { title, isMarkdown, id: postId }, 15 | parent: { 16 | downloadedExport: { filePath: backupFilePath }, 17 | downloadedExport, 18 | }, 19 | } = treeItem 20 | 21 | const fileName = sanitizeFileName(title) 22 | const extName = isMarkdown ? 'md' : 'html' 23 | const dirname = WorkspaceCfg.getWorkspaceUri().fsPath 24 | const backupName = path.parse(backupFilePath).name 25 | fs.mkdirSync(dirname, { recursive: true }) 26 | const fullPath = path.join(`${dirname}`, `${fileName}.博客备份-${backupName}-${postId}.${extName}`) 27 | 28 | const { ExportPostStore } = await import('@/service/blog-export/blog-export-post.store') 29 | const store = new ExportPostStore(downloadedExport) 30 | await promisify(fs.writeFile)(fullPath, await store.getBody(postId)) 31 | 32 | store.dispose() 33 | 34 | return openPostFile(fullPath, {}) 35 | } 36 | -------------------------------------------------------------------------------- /src/cmd/browser.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode' 2 | import { execCmd } from '@/infra/cmd' 3 | import { UserService } from '@/service/user.service' 4 | import { Alert } from '@/infra/alert' 5 | 6 | export namespace Browser.Open { 7 | export function open(url: string) { 8 | return execCmd('vscode.open', Uri.parse(url)) 9 | } 10 | } 11 | 12 | export namespace Browser.Open.Cnb { 13 | export const home = () => open('https://www.cnblogs.com') 14 | export const news = () => open('https://news.cnblogs.com') 15 | export const ing = () => open('https://ing.cnblogs.com') 16 | export const q = () => open('https://q.cnblogs.com') 17 | } 18 | 19 | export namespace Browser.Open.User { 20 | export const accountSetting = () => open('https://account.cnblogs.com/settings/account') 21 | export const buyVip = () => open('https://cnblogs.vip/') 22 | 23 | export async function blog() { 24 | const blogApp = (await UserService.getUserInfo())?.blogApp 25 | 26 | if (blogApp == null) return void Alert.warn('未开通博客') 27 | 28 | void open(`https://www.cnblogs.com/${blogApp}`) 29 | } 30 | 31 | export const blogConsole = () => open('https://write.cnblogs.com') 32 | 33 | export async function home() { 34 | const accountId = (await UserService.getUserInfo())?.accountId 35 | if (accountId !== undefined) { 36 | const url = `https://home.cnblogs.com/u/${accountId}` 37 | return open(url) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /rs/src/cnb/ing/publish.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::ing::{IngReq, IngSendFrom}; 2 | use crate::cnb::oauth::Token; 3 | use crate::http::unit_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::ResultExt; 6 | use crate::{openapi, panic_hook}; 7 | use alloc::string::{String, ToString}; 8 | use anyhow::Result; 9 | use mime::APPLICATION_JSON; 10 | use reqwest::header::CONTENT_TYPE; 11 | use serde_json::json; 12 | use wasm_bindgen::prelude::*; 13 | 14 | #[wasm_bindgen(js_class = IngReq)] 15 | impl IngReq { 16 | #[wasm_bindgen(js_name = publish)] 17 | pub async fn export_publish(&self, content: &str, is_private: bool) -> Result<(), String> { 18 | panic_hook!(); 19 | let result = publish(&self.token, content, is_private).await; 20 | result.err_to_string() 21 | } 22 | } 23 | 24 | async fn publish(token: &Token, content: &str, is_private: bool) -> Result<()> { 25 | let url = openapi!("/statuses"); 26 | 27 | let body = json!({ 28 | "content": content, 29 | "isPrivate": is_private, 30 | "clientType": IngSendFrom::VsCode, 31 | }) 32 | .to_string(); 33 | 34 | let client = reqwest::Client::new(); 35 | 36 | let req = { 37 | let req = client.post(url); 38 | let req = req.header(CONTENT_TYPE, APPLICATION_JSON.to_string()); 39 | let req = req.body(body); 40 | setup_auth(req, &token.token, token.is_pat) 41 | }; 42 | 43 | let resp = req.send().await?; 44 | unit_or_err(resp).await 45 | } 46 | -------------------------------------------------------------------------------- /rs/src/cnb/img/download.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::img::{ImgBytes, ImgReq}; 2 | use crate::infra::result::{IntoResult, ResultExt}; 3 | use crate::panic_hook; 4 | use alloc::boxed::Box; 5 | use alloc::string::{String, ToString}; 6 | use anyhow::Result; 7 | use anyhow::{anyhow, bail}; 8 | use core::ops::Not; 9 | use wasm_bindgen::prelude::*; 10 | 11 | #[wasm_bindgen(js_class = ImgReq)] 12 | impl ImgReq { 13 | #[wasm_bindgen(js_name = download)] 14 | pub async fn export_download(url: &str) -> Result { 15 | panic_hook!(); 16 | let result = download(url).await; 17 | result.err_to_string() 18 | } 19 | } 20 | 21 | async fn download(url: &str) -> Result { 22 | let client = reqwest::Client::new(); 23 | let req = client.get(url); 24 | 25 | let resp = req.send().await?; 26 | 27 | let code = resp.status(); 28 | if code.is_success().not() { 29 | let body = resp.text().await?; 30 | bail!("{}: {}", code, body) 31 | } 32 | 33 | let mime = { 34 | let ct_header = resp 35 | .headers() 36 | .get("Content-Type") 37 | .ok_or(anyhow!("Can not get content type in header")); 38 | ct_header? 39 | .to_str() 40 | .map(|s| s.to_string()) 41 | .map_err(|_| anyhow!("Can not convert HeaderValue to str"))? 42 | }; 43 | 44 | let bytes: Box<[u8]> = resp.bytes().await?.to_vec().into_boxed_slice(); 45 | 46 | ImgBytes::new(bytes, mime).into_ok() 47 | } 48 | -------------------------------------------------------------------------------- /src/cmd/ing/comment-ing.ts: -------------------------------------------------------------------------------- 1 | import { IngService } from '@/service/ing/ing' 2 | import { getIngListWebviewProvider } from '@/service/ing/ing-list-webview-provider' 3 | import { ProgressLocation, window } from 'vscode' 4 | 5 | export async function handleCommentIng( 6 | ingId: number, 7 | ingContent: string, 8 | parentCommentId?: number, 9 | atUser?: { id: number; displayName: string } 10 | ) { 11 | let content = '' 12 | 13 | const maxIngContentLength = 50 14 | const baseTitle = parentCommentId !== undefined ? `回复@${atUser?.displayName}` : '评论闪存' 15 | const input = await window.showInputBox({ 16 | title: `${baseTitle}: ${ingContent.substring(0, maxIngContentLength)}${ 17 | ingContent.length > maxIngContentLength ? '...' : '' 18 | }`, 19 | prompt: atUser !== undefined ? `@${atUser.displayName}` : '', 20 | ignoreFocusOut: true, 21 | }) 22 | content = input ?? '' 23 | const { id: atUserId, displayName: atUserAlias } = atUser ?? {} 24 | const atContent = atUserAlias !== undefined ? `@${atUserAlias} ` : '' 25 | 26 | if (content !== '') { 27 | return window.withProgress({ location: ProgressLocation.Notification, title: '正在请求...' }, async p => { 28 | p.report({ increment: 30 }) 29 | const isSuccess = await IngService.comment(ingId, atContent + content, atUserId, parentCommentId ?? 0) 30 | if (isSuccess) await getIngListWebviewProvider().updateComments([ingId]) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tree-view/model/blog-export/record-metadata.ts: -------------------------------------------------------------------------------- 1 | import { BaseTreeItemSource } from '@/tree-view/model/base-tree-item-source' 2 | import { BlogExportRecordTreeItem } from '@/tree-view/model/blog-export/record' 3 | import { TreeItem, TreeItemCollapsibleState } from 'vscode' 4 | 5 | export class BlogExportRecordMetadata extends BaseTreeItemSource { 6 | static readonly contextValue = 'cnb-blog-export-record-meta' 7 | 8 | constructor( 9 | public readonly parent: BlogExportRecordTreeItem, 10 | public readonly blogExportRecordId: number, 11 | public readonly title: string, 12 | public readonly description?: string, 13 | public readonly icon?: TreeItem['iconPath'] 14 | ) { 15 | super() 16 | } 17 | 18 | static dateAdded(parent: BlogExportRecordTreeItem): BlogExportRecordMetadata { 19 | const { record } = parent 20 | return new BlogExportRecordMetadata(parent, record.id, `创建时间: ${record.dateAdded}`) 21 | } 22 | 23 | static status(parent: BlogExportRecordTreeItem) { 24 | const { record } = parent 25 | return new BlogExportRecordMetadata(parent, record.id, `创建时间: ${record.dateAdded}`) 26 | } 27 | 28 | toTreeItem(): TreeItem { 29 | return { 30 | contextValue: BlogExportRecordMetadata.contextValue, 31 | label: this.title, 32 | description: this.description, 33 | iconPath: this.icon, 34 | collapsibleState: TreeItemCollapsibleState.None, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/model/blog-post.ts: -------------------------------------------------------------------------------- 1 | import { AccessPermission, PostType } from '@/model/post' 2 | 3 | export type BlogPost = { 4 | postBody: string 5 | categoryIds: [] 6 | collectionIds: [] 7 | inSiteCandidate: boolean 8 | inSiteHome: boolean 9 | siteCategoryId: null 10 | blogTeamIds: [] 11 | displayOnHomePage: boolean 12 | isAllowComments: boolean 13 | includeInMainSyndication: boolean 14 | isOnlyForRegisterUser: boolean 15 | isUpdateDateAdded: boolean 16 | description: string 17 | featuredImage: null 18 | tags: [] 19 | password: null 20 | autoDesc: string 21 | changePostType: boolean 22 | blogId: number 23 | author: string 24 | removeScript: boolean 25 | clientInfo: null 26 | changeCreatedTime: boolean 27 | canChangeCreatedTime: boolean 28 | isContributeToImpressiveBugActivity: boolean 29 | usingEditorId: null 30 | sourceUrl: null 31 | 32 | // fields also in PostListRespItem 33 | id: number 34 | postType: PostType 35 | accessPermission: AccessPermission 36 | title: string 37 | url: string 38 | entryName: null 39 | datePublished: string 40 | dateUpdated: string 41 | isMarkdown: boolean 42 | isDraft: boolean 43 | isPinned: boolean 44 | isPublished: boolean 45 | 46 | // fields only in PostLispRespItem 47 | aggCount: number 48 | feedBackCount: number 49 | isInSiteCandidate: boolean 50 | isInSiteHome: boolean 51 | postConfig: number 52 | viewCount: number 53 | webCount: number 54 | } 55 | -------------------------------------------------------------------------------- /rs/src/cnb/post/get_count.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::infra::http::{cons_query_string, setup_auth}; 4 | use crate::infra::result::ResultExt; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::string::String; 7 | use alloc::{format, vec}; 8 | use anyhow::{anyhow, bail, Result}; 9 | use serde_json::Value; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostReq)] 13 | impl PostReq { 14 | #[wasm_bindgen(js_name = getCount)] 15 | pub async fn export_get_count(&self) -> Result { 16 | panic_hook!(); 17 | let result = get_count(&self.token).await; 18 | result.err_to_string() 19 | } 20 | } 21 | 22 | async fn get_count(token: &Token) -> Result { 23 | let query = vec![('p', 1), ('s', 1)]; 24 | let query = cons_query_string(query); 25 | let url = blog_backend!("/posts/list?{}", query); 26 | 27 | let client = reqwest::Client::new(); 28 | 29 | let req = { 30 | let req = client.get(url); 31 | setup_auth(req, &token.token, token.is_pat) 32 | }; 33 | 34 | let resp = req.send().await?; 35 | let code = resp.status(); 36 | let body = resp.text().await?; 37 | 38 | if code.is_success() { 39 | let obj: Value = serde_json::from_str(&body)?; 40 | let val = obj 41 | .get("postsCount") 42 | .and_then(|v| v.as_i64()) 43 | .map(|v| v as usize); 44 | val.ok_or(anyhow!("Unable to parse resp json")) 45 | } else { 46 | bail!("{}: {}", code, body) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/cmd/ing/pub-ing.ts: -------------------------------------------------------------------------------- 1 | import { IngType } from '@/model/ing' 2 | import { Alert } from '@/infra/alert' 3 | import { IngService } from '@/service/ing/ing' 4 | import { getIngListWebviewProvider } from '@/service/ing/ing-list-webview-provider' 5 | import { ProgressLocation, window } from 'vscode' 6 | import { Browser } from '@/cmd/browser' 7 | 8 | async function afterPub(ingIsPrivate: boolean) { 9 | await getIngListWebviewProvider().reload({ 10 | ingType: ingIsPrivate ? IngType.my : IngType.all, 11 | pageIndex: 1, 12 | }) 13 | 14 | const ingSite = 'https://ing.cnblogs.com' 15 | 16 | const opts = [ 17 | ['打开闪存', () => Browser.Open.open(ingSite)], 18 | ['我的闪存', () => Browser.Open.open(`${ingSite}/#my`)], 19 | ['新回应', () => Browser.Open.open(`${ingSite}/#recentcomment`)], 20 | ['提到我', () => Browser.Open.open(`${ingSite}/#mention`)], 21 | ] as const 22 | 23 | const selected = await Alert.info('闪存已发布, 快去看看吧', ...opts.map(opt => opt[0])) 24 | 25 | if (selected !== undefined) await opts.find(opt => opt[0] === selected)?.[1]() 26 | } 27 | 28 | export function pubIng(content: string, isPrivate: boolean) { 29 | const opt = { 30 | location: ProgressLocation.Notification, 31 | title: '正在发布...', 32 | } 33 | 34 | void window.withProgress(opt, async p => { 35 | p.report({ increment: 40 }) 36 | const isSuccess = await IngService.pub(content, isPrivate) 37 | p.report({ increment: 100 }) 38 | if (isSuccess) void afterPub(isPrivate) 39 | p.report({ increment: 100 }) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /rs/src/cnb/img/upload.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::img::ImgReq; 2 | use crate::cnb::oauth::Token; 3 | use crate::http::{body_or_err, RsHttp}; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::{HomoResult, ResultExt}; 6 | use crate::{blog_backend, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::String; 9 | use alloc::vec::Vec; 10 | use anyhow::{anyhow, Result}; 11 | use reqwest::multipart::{Form, Part}; 12 | use wasm_bindgen::prelude::*; 13 | 14 | #[wasm_bindgen(js_class = ImgReq)] 15 | impl ImgReq { 16 | #[wasm_bindgen(js_name = upload)] 17 | pub async fn export_upload(&self, bytes: Vec, mime: &str) -> HomoResult { 18 | panic_hook!(); 19 | let url = upload(&self.token, bytes, mime).await; 20 | url.err_to_string() 21 | } 22 | } 23 | 24 | async fn upload(token: &Token, bytes: Vec, mime: &str) -> Result { 25 | let client = reqwest::Client::new(); 26 | 27 | let url = blog_backend!("/posts/body/images"); 28 | 29 | let req = { 30 | let req = client.post(url); 31 | let req = setup_auth(req, &token.token, token.is_pat); 32 | 33 | let form = { 34 | let img_ext = RsHttp::export_mime_to_img_ext(mime) 35 | .ok_or(anyhow!("MIME must be like image/..."))?; 36 | let file_name = format!("image.{}", img_ext); 37 | 38 | let part = Part::bytes(bytes).file_name(file_name).mime_str(mime)?; 39 | Form::new().part("image", part) 40 | }; 41 | 42 | req.multipart(form) 43 | }; 44 | 45 | let resp = req.send().await?; 46 | 47 | body_or_err(resp).await 48 | } 49 | -------------------------------------------------------------------------------- /src/service/post/post-list-view.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@/model/post' 2 | import { extTreeViews } from '@/tree-view/tree-view-register' 3 | import { PostListState } from '@/model/post-list-state' 4 | import { LocalState } from '@/ctx/local-state' 5 | import { PostListRespItem } from '@/model/post-list-resp-item' 6 | import { ZzkSearchResult } from '@/model/zzk-search-result' 7 | 8 | export interface PostListModel { 9 | category: unknown // TODO: need type 10 | categoryName: string 11 | pageIndex: number 12 | pageSize: number 13 | postList: PostListRespItem[] 14 | postsCount: number 15 | 16 | zzkSearchResult: ZzkSearchResult | null 17 | } 18 | 19 | export async function revealPostListItem( 20 | post: Post | undefined, 21 | options?: { select?: boolean; focus?: boolean; expand?: boolean | number } 22 | ) { 23 | if (post === undefined) return 24 | 25 | const view = extTreeViews.visiblePostList() 26 | 27 | try { 28 | await view?.reveal(post, options) 29 | } catch (ex) { 30 | console.log(ex) 31 | } 32 | } 33 | 34 | export function getListState() { 35 | return LocalState.getState('postListState') 36 | } 37 | 38 | export async function updatePostListState( 39 | pageIndex: number, 40 | pageSize: number, 41 | pageCount: number, 42 | hasPrev: boolean, 43 | hasNext: boolean 44 | ): Promise { 45 | const finalState = { 46 | pageIndex, 47 | pageSize, 48 | pageCount, 49 | hasPrev, 50 | hasNext, 51 | } 52 | await LocalState.setState('postListState', finalState) 53 | } 54 | -------------------------------------------------------------------------------- /ui/post-cfg/components/OptionCheckBox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Stack } from '@fluentui/react' 2 | import * as React from 'react' 3 | import { Component } from 'react' 4 | 5 | type Option = { [key: string]: { label: string; checked: boolean } } 6 | 7 | type Props = { 8 | options: TOption 9 | onChange: (optionKey: keyof TOption, checked: boolean, stateObj: { [p in typeof optionKey]: boolean }) => void 10 | } 11 | 12 | export class OptionCheckBox extends Component> { 13 | constructor(props: Props) { 14 | super(props) 15 | } 16 | 17 | render() { 18 | return ( 19 | 20 | {this.renderOptions()} 21 | 22 | ) 23 | } 24 | 25 | private renderOptions() { 26 | const { options } = this.props 27 | return Object.keys(options).map((optionKey: keyof TOption) => { 28 | const { checked: isChecked, label: title } = options[optionKey] 29 | return ( 30 | 35 | this.props.onChange?.apply(this, [ 36 | optionKey, 37 | checked ?? false, 38 | { [optionKey]: checked ?? false }, 39 | ]) 40 | } 41 | /> 42 | ) 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/setup/setup-watch.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode' 2 | import { isTargetWorkspace } from '@/service/is-target-workspace' 3 | import { PostFileMapManager } from '@/service/post/post-file-map' 4 | import { execCmd } from '@/infra/cmd' 5 | import { setupUi } from '@/setup/setup-ui' 6 | import { LocalState } from '@/ctx/local-state' 7 | import { PostListView } from '@/cmd/post-list/post-list-view' 8 | import { postCategoryDataProvider } from '@/tree-view/provider/post-category-tree-data-provider' 9 | 10 | export const setupCfgWatch = () => 11 | workspace.onDidChangeConfiguration(async ev => { 12 | if (ev.affectsConfiguration('cnblogsClient')) isTargetWorkspace() 13 | 14 | if (ev.affectsConfiguration('workbench.iconTheme')) await postCategoryDataProvider.refreshAsync() 15 | 16 | if (ev.affectsConfiguration('cnblogsClient.pageSize.postList')) void PostListView.refresh({ queue: true }) 17 | 18 | if (ev.affectsConfiguration('cnblogsClient.markdown')) void execCmd('markdown.preview.refresh') 19 | 20 | if (ev.affectsConfiguration('cnblogsClient.ui')) setupUi(LocalState.getExtCfg()) 21 | }) 22 | 23 | export const setupWorkspaceWatch = () => 24 | workspace.onDidChangeWorkspaceFolders(() => { 25 | isTargetWorkspace() 26 | }) 27 | 28 | export const setupWorkspaceFileWatch = () => 29 | workspace.onDidRenameFiles(e => { 30 | for (const item of e.files) { 31 | const { oldUri, newUri } = item 32 | const postId = PostFileMapManager.getPostId(oldUri.path) 33 | if (postId !== undefined) void PostFileMapManager.updateOrCreate(postId, newUri.path) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /src/tree-view/navi-view.ts: -------------------------------------------------------------------------------- 1 | import { ProviderResult, TreeItem, TreeDataProvider, ThemeIcon } from 'vscode' 2 | 3 | export class NaviViewDataProvider implements TreeDataProvider { 4 | getTreeItem(el: TreeItem): TreeItem | Thenable { 5 | return el 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | getChildren(el?: TreeItem): ProviderResult { 10 | return [ 11 | { 12 | label: '首页', 13 | command: { 14 | title: '打开博客园首页', 15 | command: 'vscode-cnb.open.cnb-home', 16 | }, 17 | iconPath: new ThemeIcon('home'), 18 | }, 19 | { 20 | label: '新闻', 21 | command: { 22 | title: '打开博客园新闻', 23 | command: 'vscode-cnb.open.cnb-news', 24 | }, 25 | iconPath: new ThemeIcon('preview'), 26 | }, 27 | { 28 | label: '博问', 29 | command: { 30 | title: '打开博问', 31 | command: 'vscode-cnb.open.cnb-q', 32 | }, 33 | iconPath: new ThemeIcon('question'), 34 | }, 35 | { 36 | label: '闪存', 37 | command: { 38 | title: '打开闪存', 39 | command: 'vscode-cnb.open.cnb-ing', 40 | }, 41 | iconPath: new ThemeIcon('comment'), 42 | }, 43 | ] 44 | } 45 | } 46 | 47 | export const naviViewDataProvider = new NaviViewDataProvider() 48 | -------------------------------------------------------------------------------- /rs/src/cnb/post/get_list.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::infra::http::{cons_query_string, setup_auth}; 4 | use crate::infra::result::{HomoResult, ResultExt}; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::string::{String, ToString}; 7 | use alloc::{format, vec}; 8 | use anyhow::{anyhow, bail, Result}; 9 | use core::ops::Not; 10 | use serde_json::Value; 11 | use wasm_bindgen::prelude::*; 12 | 13 | #[wasm_bindgen(js_class = PostReq)] 14 | impl PostReq { 15 | #[wasm_bindgen(js_name = getList)] 16 | pub async fn export_get_list(&self, page_index: usize, page_cap: usize) -> HomoResult { 17 | panic_hook!(); 18 | let post_list_json = get_list(&self.token, page_index, page_cap).await; 19 | post_list_json.homo_string() 20 | } 21 | } 22 | 23 | async fn get_list(token: &Token, page_index: usize, page_cap: usize) -> Result { 24 | let query = vec![('t', 1), ('p', page_index), ('s', page_cap)]; 25 | let query = cons_query_string(query); 26 | let url = blog_backend!("/posts/list?{}", query); 27 | 28 | let client = reqwest::Client::new(); 29 | 30 | let req = { 31 | let req = client.get(url); 32 | setup_auth(req, &token.token, token.is_pat) 33 | }; 34 | let resp = req.send().await?; 35 | 36 | let code = resp.status(); 37 | let body = resp.text().await?; 38 | 39 | if code.is_success().not() { 40 | bail!("{}: {}", code, body) 41 | } 42 | 43 | let obj: Value = serde_json::from_str(&body)?; 44 | let val = obj.get("postList").map(|v| v.to_string()); 45 | val.ok_or(anyhow!("Unable to parse resp json")) 46 | } 47 | -------------------------------------------------------------------------------- /rs/src/cnb/oauth/revoke_token.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::OauthReq; 2 | use crate::http::unit_or_err; 3 | use crate::infra::http::cons_query_string; 4 | use crate::infra::result::ResultExt; 5 | use crate::{basic, oauth, panic_hook}; 6 | use alloc::string::{String, ToString}; 7 | use alloc::{format, vec}; 8 | use anyhow::Result; 9 | use base64::engine::general_purpose; 10 | use base64::Engine; 11 | use mime::APPLICATION_WWW_FORM_URLENCODED; 12 | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; 13 | use wasm_bindgen::prelude::*; 14 | 15 | #[wasm_bindgen(js_class = OauthReq)] 16 | impl OauthReq { 17 | #[wasm_bindgen(js_name = revokeToken)] 18 | pub async fn export_revoke_token(&self, token: &str) -> Result<(), String> { 19 | panic_hook!(); 20 | let result = revoke_token(&self.client_id, &self.client_sec, token).await; 21 | result.err_to_string() 22 | } 23 | } 24 | 25 | async fn revoke_token(client_id: &str, client_sec: &str, token: &str) -> Result<()> { 26 | let credentials = format!("{}:{}", client_id, client_sec); 27 | let credentials = general_purpose::STANDARD.encode(credentials); 28 | let url = oauth!("/connect/revocation"); 29 | 30 | let client = reqwest::Client::new(); 31 | 32 | let queries = vec![ 33 | ("client_id", client_id), 34 | ("token", token), 35 | ("token_type_hint", "refresh_token"), 36 | ]; 37 | 38 | let req = client 39 | .post(url) 40 | .header(CONTENT_TYPE, APPLICATION_WWW_FORM_URLENCODED.to_string()) 41 | .header(AUTHORIZATION, basic!(credentials)) 42 | .body(cons_query_string(queries)); 43 | 44 | let resp = req.send().await?; 45 | unit_or_err(resp).await 46 | } 47 | -------------------------------------------------------------------------------- /src/assets/icon-blog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cmd/post-cat/update-post-cat-treeview.ts: -------------------------------------------------------------------------------- 1 | import { ProgressLocation, window } from 'vscode' 2 | import { PostCat } from '@/model/post-cat' 3 | import { PostCatService } from '@/service/post/post-cat' 4 | import { inputPostCat } from './input-post-cat' 5 | import { Alert } from '@/infra/alert' 6 | import { PostCatTreeItem } from '@/tree-view/model/post-category-tree-item' 7 | import { extTreeViews } from '@/tree-view/tree-view-register' 8 | import { postCategoryDataProvider } from '@/tree-view/provider/post-category-tree-data-provider' 9 | 10 | export async function updatePostCatTreeView(arg?: PostCat | PostCatTreeItem) { 11 | let category: PostCat 12 | if (arg instanceof PostCat) { 13 | category = arg 14 | void extTreeViews.postCategoriesList.reveal(arg) 15 | } else if (arg instanceof PostCatTreeItem) { 16 | category = arg.category 17 | void extTreeViews.postCategoriesList.reveal(arg) 18 | } else { 19 | return 20 | } 21 | 22 | const addDto = await inputPostCat('编辑分类', category) 23 | if (addDto === undefined) return 24 | 25 | const updateDto = Object.assign(new PostCat(), category, addDto) 26 | 27 | const opt = { 28 | title: `正在更新分类 - ${updateDto.title}`, 29 | location: ProgressLocation.Notification, 30 | } 31 | await window.withProgress(opt, async p => { 32 | p.report({ increment: 10 }) 33 | try { 34 | await PostCatService.update(updateDto) 35 | await postCategoryDataProvider.refreshAsync() 36 | p.report({ increment: 100 }) 37 | } catch (e) { 38 | void Alert.err(`更新博文失败: ${e}`) 39 | p.report({ increment: 100 }) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/assets/icon-image-upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/post-cfg/components/select/PermissionSelect.tsx: -------------------------------------------------------------------------------- 1 | import { ChoiceGroup, IChoiceGroupOption, Label, Stack } from '@fluentui/react' 2 | import { AccessPermission } from '@/model/post' 3 | import React, { Component } from 'react' 4 | 5 | type Props = { 6 | accessPermission: AccessPermission 7 | onChange: (ap: AccessPermission) => void 8 | } 9 | 10 | export class PermissionSelect extends Component { 11 | constructor(props: Props) { 12 | super(props) 13 | } 14 | 15 | render() { 16 | const opt: IChoiceGroupOption[] = [ 17 | { 18 | text: '所有人', 19 | key: AccessPermission.undeclared.toString(), 20 | value: AccessPermission.undeclared, 21 | }, 22 | { 23 | text: '登录用户', 24 | key: AccessPermission.authenticated.toString(), 25 | value: AccessPermission.authenticated, 26 | }, 27 | { 28 | text: '仅自己', 29 | key: AccessPermission.owner.toString(), 30 | value: AccessPermission.owner, 31 | }, 32 | ] 33 | return ( 34 | 35 | 36 | { 39 | if (option !== undefined) this.props.onChange(option.value as AccessPermission) 40 | }} 41 | selectedKey={this.props.accessPermission?.toString()} 42 | styles={{ flexContainer: { display: 'flex', justifyContent: 'space-between' } }} 43 | /> 44 | 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/assets/icon-image-upload-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/jest.config.mjs: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from 'ts-jest' 2 | import tsConfig from '../tsconfig.json' assert { type: 'json' } 3 | 4 | const { compilerOptions } = tsConfig 5 | 6 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 7 | const config = { 8 | preset: 'ts-jest', 9 | displayName: { 10 | name: 'vscode-cnb', 11 | color: 'greenBright', 12 | }, 13 | testMatch: ['/src/**/*.test.ts'], 14 | testPathIgnorePatterns: ['/src/test/suite'], 15 | testEnvironment: 'node', 16 | detectOpenHandles: true, 17 | moduleFileExtensions: ['js', 'ts', 'json', 'mjs'], 18 | collectCoverage: false, 19 | transform: { 20 | '^.+\\.(mjs|ts|js|cjs|jsx)$': ['ts-jest', { tsconfig: '/tsconfig.spec.json', useESM: true }], 21 | }, 22 | rootDir: '../', 23 | globals: { 24 | // eslint-disable-next-line @typescript-eslint/naming-convention 25 | CNBLOGS_CLIENTID: '', 26 | // eslint-disable-next-line @typescript-eslint/naming-convention 27 | CNBLOGS_CLIENTSECRET: '', 28 | }, 29 | forceExit: true, 30 | roots: [''], 31 | verbose: true, 32 | modulePaths: ['', '/node_modules'], 33 | moduleDirectories: ['', '/node_modules'], 34 | transformIgnorePatterns: [ 35 | 'node_modules/(?!(got-fetch|got|@sindresorhus|p-cancelable|@szmarczak|cacheable-request|cacheable-lookup|normalize-url|responselike|lowercase-keys|mimic-response|form-data-encoder))', 36 | ], 37 | projects: [''], 38 | automock: false, 39 | moduleNameMapper: { 40 | ...pathsToModuleNameMapper(compilerOptions.paths), 41 | '^lodash-es.*$': 'lodash', 42 | }, 43 | } 44 | 45 | export default config 46 | -------------------------------------------------------------------------------- /src/cmd/post-cat/input-post-cat.ts: -------------------------------------------------------------------------------- 1 | import { PostCat, PostCatAddDto } from '@/model/post-cat' 2 | import { window } from 'vscode' 3 | 4 | async function setupTitle(parentTitle: string, oldVal: string) { 5 | const title = await window.showInputBox({ 6 | title: `分类标题 - ${parentTitle}(1/3)`, 7 | value: oldVal, 8 | placeHolder: '<必须>请输入分类标题', 9 | validateInput: input => { 10 | if (input === '') return '请输入分类标题' 11 | }, 12 | }) 13 | 14 | if (title === undefined) throw Error() 15 | 16 | return title 17 | } 18 | 19 | async function setupVisible(parentTitle: string) { 20 | const isVisible = await window.showQuickPick(['可见', '不可见'], { 21 | title: `分类可见性 - ${parentTitle}(2/3)`, 22 | canPickMany: false, 23 | }) 24 | 25 | if (isVisible === undefined) throw Error() 26 | 27 | return isVisible === '可见' 28 | } 29 | 30 | async function setupDescription(parentTitle: string, oldVal: string) { 31 | const description = await window.showInputBox({ 32 | title: `分类描述 - ${parentTitle}(3/3)`, 33 | value: oldVal, 34 | placeHolder: '<可选>请输入分类描述', 35 | }) 36 | 37 | if (description === undefined) throw Error() 38 | 39 | return description 40 | } 41 | 42 | export async function inputPostCat(parentTitle: string, cat?: PostCat) { 43 | try { 44 | const title = await setupTitle(parentTitle, cat?.title ?? '') 45 | const isVisible = await setupVisible(title) 46 | const description = await setupDescription(title, cat?.description ?? '') 47 | return { 48 | title, 49 | visible: isVisible, 50 | description, 51 | } 52 | } catch (_) { 53 | // input canceled, do nothing 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { setupExtTreeView } from '@/tree-view/tree-view-register' 2 | import { setupCmd } from '@/setup/setup-cmd' 3 | import { globalCtx } from '@/ctx/global-ctx' 4 | import { window, ExtensionContext } from 'vscode' 5 | import { AuthManager } from '@/auth/auth-manager' 6 | import { setupWorkspaceWatch, setupCfgWatch, setupWorkspaceFileWatch } from '@/setup/setup-watch' 7 | import { extUriHandler } from '@/infra/uri-handler' 8 | import { extendMarkdownIt } from '@/markdown/extend-markdownIt' 9 | import { getIngListWebviewProvider } from '@/service/ing/ing-list-webview-provider' 10 | import { setupUi } from '@/setup/setup-ui' 11 | import { LocalState } from '@/ctx/local-state' 12 | import { setupState } from '@/setup/setup-state' 13 | import { Alert } from './infra/alert' 14 | 15 | export async function activate(ctx: ExtensionContext) { 16 | globalCtx.extCtx = ctx 17 | 18 | // WRN: For old version compatibility, NEVER remove this line 19 | void LocalState.delSecret('user') 20 | 21 | try { 22 | await setupState() 23 | await AuthManager.updateAuthStatus() 24 | setupCmd() 25 | setupExtTreeView() 26 | } catch (e) { 27 | void Alert.err(`扩展激活失败,[立即反馈](https://github.com/cnblogs/vscode-cnb/issues),错误信息:${e}`) 28 | throw e 29 | } 30 | 31 | ctx.subscriptions.push( 32 | window.registerWebviewViewProvider(getIngListWebviewProvider().viewId, getIngListWebviewProvider()), 33 | setupCfgWatch(), 34 | setupWorkspaceWatch(), 35 | setupWorkspaceFileWatch() 36 | ) 37 | 38 | window.registerUriHandler(extUriHandler) 39 | 40 | setupUi(LocalState.getExtCfg()) 41 | 42 | return { extendMarkdownIt } 43 | } 44 | 45 | export function deactivate() { 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /src/model/blog-setting.ts: -------------------------------------------------------------------------------- 1 | export class BlogSetting implements BlogSiteDto, BlogSiteExtendDto { 2 | blogId = -1 3 | blogNews = '' 4 | secondaryCss = '' 5 | pageBeginHtml = '' 6 | pageEndHtml = '' 7 | 8 | title = '' 9 | subTitle = '' 10 | application = '' 11 | author = '' 12 | notifyMail = '' 13 | email = '' 14 | loginName = '' 15 | hasJsPermission = false 16 | timeZone = -1 17 | language = 'zh-cn' 18 | isDisableMainCss = false 19 | enableServiceAccess = false 20 | skin = '' 21 | registerTime = new Date().toString() 22 | codeHighlightTheme = '' 23 | codeHighlightEngine: CodeHighlightEngineEnum = CodeHighlightEngineEnum.highlightJs 24 | enableCodeLineNumber = false 25 | blogNewsUseMarkdown = false 26 | 27 | constructor(blogSite: BlogSiteDto, extend: BlogSiteExtendDto) { 28 | Object.assign(this, blogSite) 29 | Object.assign(this, extend) 30 | } 31 | } 32 | 33 | export enum CodeHighlightEngineEnum { 34 | highlightJs = 1, 35 | prismJs, 36 | } 37 | 38 | export type BlogSiteDto = { 39 | title: string 40 | subTitle: string 41 | application: string 42 | author: string 43 | notifyMail: string 44 | email: string 45 | loginName: string 46 | hasJsPermission: boolean 47 | timeZone: number 48 | language: string 49 | isDisableMainCss: boolean 50 | enableServiceAccess: boolean 51 | skin: string 52 | registerTime: string 53 | codeHighlightTheme: string 54 | codeHighlightEngine: CodeHighlightEngineEnum 55 | enableCodeLineNumber: boolean 56 | blogNewsUseMarkdown: boolean 57 | } 58 | 59 | export type BlogSiteExtendDto = { 60 | blogId: number 61 | blogNews: string 62 | secondaryCss: string 63 | pageBeginHtml: string 64 | pageEndHtml: string 65 | } 66 | -------------------------------------------------------------------------------- /ui/post-cfg/components/select/CatSelect.tsx: -------------------------------------------------------------------------------- 1 | import { ComboBox } from '@fluentui/react' 2 | import { Component } from 'react' 3 | import { PostCat } from '@/model/post-cat' 4 | 5 | type Props = { 6 | userCats: PostCat[] 7 | selectedCatIds: number[] 8 | onChange: (categoryIds: number[]) => void 9 | } 10 | type State = { selectedCatIds: number[] } 11 | 12 | export class CatSelect extends Component { 13 | constructor(props: Props) { 14 | super(props) 15 | this.state = { selectedCatIds: this.props.selectedCatIds } 16 | } 17 | 18 | render() { 19 | const opts = this.props.userCats.map(cat => ({ 20 | data: cat.categoryId, 21 | key: cat.categoryId, 22 | text: cat.title, 23 | })) 24 | 25 | return ( 26 | { 34 | if (opt !== undefined) { 35 | if (opt.selected !== true || val === undefined) { 36 | const selectedCatIds = this.state.selectedCatIds.filter(x => x !== opt.data) 37 | this.setState({ selectedCatIds }) 38 | this.props.onChange(selectedCatIds) 39 | } else { 40 | this.state.selectedCatIds.push(opt.data as number) 41 | this.setState(this.state) 42 | this.props.onChange(this.state.selectedCatIds) 43 | } 44 | } 45 | }} 46 | /> 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /download-iconfont.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import fetch from 'node-fetch' 3 | import fs from 'fs' 4 | import AdmZip from 'adm-zip' 5 | 6 | const url = 7 | 'https://www.iconfont.cn/api/project/download.zip?spm=a313x.7781069.1998910419.d7543c303&pid=2996691&ctoken=ndNRCUzYy381Rxk59b1LjTrg' 8 | const cookie = 9 | 'EGG_SESS_ICONFONT=X2bT0AZ-TAIilwY-GdJPZzopst30wSOteTYESbBaXxbdSzdNvsW9cIk8Rv2OFK9WB4P6YDevBbM0tOZXSeQ-PlBr9j4tU6xOUFjFBJ0DQn-bvfiHQ9VToJtqTPiCmSRpfaiJg2PNK_U65bOD27CiBF0XriLwpr2VwR2IdTxDcEjB_TASVO4TZeLD4yutVl7F-HAekMbP05tFgoqkHKErlg==; cna=G1LxGnOXCnwCAXPBn9cnCizc; hasViewVideo=true; ctoken=tpg9HdfcXbaBj1GRmc0B9X0-; u=5429096; u.sig=TNtkaPPSd2m-XekHonfzX8cnJ4FYtYu3NQ3Ic536XRI; locale=en-us; isg=BOPj3u3ohSIY2UmfHCh8ajjwciGN2HcatGxS7hVBlcK5VAB2nKxAa4fCTizadM8S' 10 | const filename = 'download.zip' 11 | 12 | fetch(url, { headers: { cookie: cookie } }).then(f => { 13 | const dest = fs.createWriteStream('./download.zip') 14 | f.body?.pipe(dest) 15 | dest.on('finish', () => { 16 | try { 17 | const zip = AdmZip(filename, {}) 18 | const outFileName = 'icons.woff2' 19 | zip.getEntries() 20 | .filter(e => !e.isDirectory && e.name.startsWith('iconfont.woff2')) 21 | .forEach(e => { 22 | zip.extractEntryTo(e, './dist/assets', false, true, undefined, outFileName) 23 | zip.extractEntryTo(e, 'src/assets', false, true, undefined, outFileName) 24 | }) 25 | fs.unlink(filename, e => { 26 | if (e != null) { 27 | console.log(e) 28 | } 29 | console.log('iconfont assets downloaded') 30 | }) 31 | } catch (e) { 32 | console.warn('Failed to unzip iconfont assets! Some icons may not work correctly.', e) 33 | } 34 | }) 35 | }, undefined) 36 | -------------------------------------------------------------------------------- /src/ctx/cfg/markdown.ts: -------------------------------------------------------------------------------- 1 | import { LocalState } from '@/ctx/local-state' 2 | import getExtCfg = LocalState.getExtCfg 3 | import { ImgSrc } from '@/service/extract-img/get-replace-list' 4 | 5 | export namespace MarkdownCfg { 6 | const cfgGet = (key: string) => getExtCfg().get(`markdown.${key}`) 7 | 8 | export function isShowConfirmMsgWhenUploadPost(): boolean { 9 | return cfgGet('showConfirmMsgWhenUploadPost') ?? true 10 | } 11 | 12 | export function isIgnoreYfmWhenUploadPost(): boolean { 13 | return cfgGet('ignoreYfmWhenUploadPost') ?? false 14 | } 15 | 16 | export function isShowConfirmMsgWhenPullPost(): boolean { 17 | return cfgGet('showConfirmMsgWhenPullPost') ?? true 18 | } 19 | 20 | export function isEnableMarkdownEnhancement(): boolean { 21 | return cfgGet('enableEnhancement') ?? true 22 | } 23 | 24 | export function isEnableMarkdownFenceBlockquote(): boolean { 25 | return cfgGet('enableFenceQuote') ?? true 26 | } 27 | 28 | export function isEnableMarkdownImageSizing(): boolean { 29 | return cfgGet('enableImageSizing') ?? true 30 | } 31 | 32 | export function isEnableMarkdownHighlightCodeLines(): boolean { 33 | return cfgGet('enableHighlightCodeLines') ?? true 34 | } 35 | 36 | export function getAutoExtractImgSrc(): ImgSrc | undefined { 37 | type T = 'disable' | 'web' | 'dataUrl' | 'fs' | 'any' 38 | const cfg = cfgGet('autoExtractImages') ?? 'disable' 39 | 40 | if (cfg === 'disable') return 41 | if (cfg === 'fs') return ImgSrc.fs 42 | if (cfg === 'dataUrl') return ImgSrc.dataUrl 43 | if (cfg === 'web') return ImgSrc.web 44 | if (cfg === 'any') return ImgSrc.any 45 | } 46 | 47 | export function getApplyAutoExtractImgToLocal(): boolean { 48 | return cfgGet('applyAutoExtractImageToLocal') ?? true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/service/post/search-post-by-title.ts: -------------------------------------------------------------------------------- 1 | import { QuickPickItem, window } from 'vscode' 2 | import { Post } from '@/model/post' 3 | import { PostService } from './post' 4 | 5 | class PostPickItem implements QuickPickItem { 6 | label: string 7 | description?: string 8 | detail?: string 9 | picked?: boolean 10 | alwaysShow?: boolean 11 | 12 | constructor(public post: Post) { 13 | this.label = post.title 14 | this.description = post.description 15 | } 16 | } 17 | 18 | export function searchPostByTitle(title: string, quickPickTitle: string) { 19 | const quickPick = window.createQuickPick() 20 | quickPick.title = quickPickTitle 21 | quickPick.value = title ?? '' 22 | quickPick.placeholder = '输入标题以搜索随笔' 23 | 24 | const handleValueChange = async () => { 25 | if (quickPick.value.length === 0) return 26 | 27 | const keyword = quickPick.value 28 | quickPick.busy = true 29 | 30 | try { 31 | const data = await PostService.search(1, 20, keyword) 32 | const postList = data.page.items 33 | const pickItems = postList.map(p => new PostPickItem(p)) 34 | if (keyword === quickPick.value) quickPick.items = pickItems 35 | } finally { 36 | quickPick.busy = false 37 | } 38 | } 39 | 40 | quickPick.onDidChangeValue(handleValueChange) 41 | let selected: PostPickItem | undefined = undefined 42 | quickPick.onDidChangeSelection(() => { 43 | selected = quickPick.selectedItems[0] 44 | if (selected !== undefined) quickPick.hide() 45 | }) 46 | 47 | const fut = new Promise(resolve => { 48 | quickPick.onDidHide(() => { 49 | resolve(selected?.post) 50 | quickPick.dispose() 51 | }) 52 | }) 53 | 54 | quickPick.show() 55 | void handleValueChange() 56 | 57 | return fut 58 | } 59 | -------------------------------------------------------------------------------- /rs/src/base64.rs: -------------------------------------------------------------------------------- 1 | use crate::infra::result::{HomoResult, IntoResult, ResultExt}; 2 | use crate::panic_hook; 3 | use alloc::string::String; 4 | use anyhow::Result; 5 | use base64::{engine::general_purpose, Engine as _}; 6 | use wasm_bindgen::prelude::wasm_bindgen; 7 | 8 | #[wasm_bindgen(js_name = RsBase64)] 9 | struct RsBase64; 10 | 11 | #[wasm_bindgen(js_class = RsBase64)] 12 | impl RsBase64 { 13 | #[wasm_bindgen(js_name = encode)] 14 | pub fn export_encode(text: String) -> String { 15 | panic_hook!(); 16 | general_purpose::STANDARD.encode(text) 17 | } 18 | #[wasm_bindgen(js_name = decode)] 19 | pub fn export_decode(base64: &str) -> HomoResult { 20 | panic_hook!(); 21 | let text = decode(base64); 22 | 23 | text.homo_string() 24 | } 25 | 26 | #[wasm_bindgen(js_name = encodeUrl)] 27 | pub fn export_encode_url(text: String) -> String { 28 | panic_hook!(); 29 | base64url::encode(text) 30 | } 31 | #[wasm_bindgen(js_name = decodeUrl)] 32 | pub fn export_decode_url(base64url: &str) -> HomoResult { 33 | panic_hook!(); 34 | let text = decode_url(base64url); 35 | 36 | text.homo_string() 37 | } 38 | } 39 | 40 | pub fn decode(base64: &str) -> Result { 41 | let vec = general_purpose::STANDARD.decode(base64)?; 42 | let text = String::from_utf8(vec)?; 43 | 44 | text.into_ok() 45 | } 46 | 47 | pub fn decode_url(base64url: &str) -> Result { 48 | let vec = base64url::decode(base64url)?; 49 | let text = String::from_utf8(vec)?; 50 | 51 | text.into_ok() 52 | } 53 | 54 | #[test] 55 | fn test_encode_decode() { 56 | use alloc::string::ToString; 57 | let text = "hola".to_string(); 58 | let base64 = RsBase64::export_encode(text.clone()); 59 | assert_eq!(base64, "aG9sYQ=="); 60 | let decoded = RsBase64::export_decode(&base64); 61 | assert_eq!(decoded, Ok(text)); 62 | } 63 | -------------------------------------------------------------------------------- /src/model/webview-cmd.ts: -------------------------------------------------------------------------------- 1 | import { PostCat } from '@/model/post-cat' 2 | 3 | export namespace Webview.Cmd { 4 | export enum Ui { 5 | editPostCfg = 'editPostCfg', 6 | updateBreadcrumbs = 'updateBreadcrumbs', 7 | updateImageUploadStatus = 'updateImageUploadStatus', 8 | setFluentIconBaseUrl = 'setFluentIconBaseUrl', 9 | updateTheme = 'updateTheme', 10 | updateChildCategories = 'updateChildCategories', 11 | } 12 | 13 | export enum Ext { 14 | uploadPost = 'uploadPost', 15 | disposePanel = 'disposePanel', 16 | uploadImg = 'uploadImg', 17 | refreshPost = 'refreshPost', 18 | getChildCategories = 'getChildCategories', 19 | } 20 | 21 | export interface GetChildCategoriesPayload { 22 | parentId: number 23 | } 24 | 25 | export interface UpdateChildCategoriesPayload { 26 | parentId: number 27 | value: PostCat[] 28 | } 29 | 30 | export namespace Ing { 31 | export enum Ui { 32 | setAppState = 'setAppState', 33 | updateTheme = 'updateTheme', 34 | } 35 | 36 | export enum Ext { 37 | reload = 'reload', 38 | comment = 'comment', 39 | } 40 | 41 | export type CommentCmdPayload = { 42 | ingId: number 43 | atUser?: { id: number; displayName: string } 44 | parentCommentId?: number 45 | ingContent: string 46 | } 47 | } 48 | } 49 | 50 | export interface WebviewCommonCmd { 51 | payload: T 52 | command: unknown 53 | } 54 | 55 | export interface IngWebviewUiCmd = Record> 56 | extends WebviewCommonCmd { 57 | command: Webview.Cmd.Ing.Ui 58 | } 59 | 60 | export interface IngWebviewHostCmd = Record> 61 | extends WebviewCommonCmd { 62 | command: Webview.Cmd.Ing.Ext 63 | } 64 | -------------------------------------------------------------------------------- /src/cmd/workspace.ts: -------------------------------------------------------------------------------- 1 | import { execCmd } from '@/infra/cmd' 2 | import { Alert } from '@/infra/alert' 3 | import { WorkspaceCfg } from '@/ctx/cfg/workspace' 4 | import { TextDocument, WorkspaceEdit, window, Range } from 'vscode' 5 | 6 | export namespace Workspace { 7 | export function resetTextDoc(doc: TextDocument, text: string) { 8 | const firstLine = doc.lineAt(0) 9 | const lastLine = doc.lineAt(doc.lineCount - 1) 10 | const range = new Range(firstLine.range.start, lastLine.range.end) 11 | const we = new WorkspaceEdit() 12 | we.replace(doc.uri, range, text, { 13 | label: '', 14 | needsConfirmation: false, 15 | }) 16 | return we 17 | } 18 | 19 | export async function codeOpen() { 20 | const uri = WorkspaceCfg.getWorkspaceUri() 21 | const options = ['在当前窗口中打开', '在新窗口中打开'] 22 | const msg = `即将打开 ${uri.fsPath}` 23 | const input = await Alert.info(msg, { modal: true }, ...options) 24 | if (input === undefined) return 25 | 26 | const shouldOpenInNewWindow = input === options[1] 27 | 28 | await execCmd('vscode.openFolder', uri, shouldOpenInNewWindow) 29 | } 30 | 31 | export function osOpen() { 32 | void execCmd('revealFileInOS', WorkspaceCfg.getWorkspaceUri()) 33 | } 34 | 35 | export async function set() { 36 | const uris = await window.showOpenDialog({ 37 | title: '选择工作空间', 38 | canSelectFolders: true, 39 | canSelectFiles: false, 40 | canSelectMany: false, 41 | defaultUri: WorkspaceCfg.getWorkspaceUri(), 42 | }) 43 | 44 | if (uris === undefined) return 45 | 46 | const firstUri = uris[0] 47 | 48 | if (firstUri === undefined) return 49 | 50 | await WorkspaceCfg.setWorkspaceUri(firstUri) 51 | 52 | void Alert.info(`工作空间成功修改为: "${WorkspaceCfg.getWorkspaceUri().fsPath}"`) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ui/post-cfg/components/input/TitleInput.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Label, Stack, Text, TextField } from '@fluentui/react' 2 | import React, { Component } from 'react' 3 | 4 | type Props = { 5 | value: string 6 | fileName: string 7 | onChange: (value: string | null | undefined) => unknown 8 | } 9 | 10 | export default class TitleInput extends Component { 11 | constructor(props: Props) { 12 | super(props) 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | 20 | {this.props.fileName !== '' && this.props.fileName !== this.props.value ? ( 21 | { 23 | this.setState({ value: this.props.fileName }) 24 | this.props.onChange(this.props.value) 25 | }} 26 | styles={{ root: { height: 'auto', whiteSpace: 'nowrap' } }} 27 | secondaryText={this.props.fileName} 28 | > 29 | 使用本地文件名:  30 | 31 | "{this.props.fileName}" 32 | 33 | 34 | ) : ( 35 | <> 36 | )} 37 | 38 | 39 | { 42 | this.setState({ value: v ?? '' }) 43 | this.props.onChange(v) 44 | }} 45 | > 46 | 47 | 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /rs/src/cnb/post/search.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::oauth::Token; 2 | use crate::cnb::post::PostReq; 3 | use crate::infra::http::{cons_query_string, setup_auth}; 4 | use crate::infra::result::{HomoResult, IntoResult, ResultExt}; 5 | use crate::{blog_backend, panic_hook}; 6 | use alloc::string::{String, ToString}; 7 | use alloc::{format, vec}; 8 | use anyhow::{bail, Result}; 9 | use core::ops::Not; 10 | use wasm_bindgen::prelude::*; 11 | 12 | #[wasm_bindgen(js_class = PostReq)] 13 | impl PostReq { 14 | #[wasm_bindgen(js_name = search)] 15 | pub async fn export_search( 16 | &self, 17 | page_index: usize, 18 | page_cap: usize, 19 | keyword: Option, 20 | cat_id: Option, 21 | ) -> HomoResult { 22 | panic_hook!(); 23 | let json = search(&self.token, page_index, page_cap, keyword, cat_id).await; 24 | json.homo_string() 25 | } 26 | } 27 | 28 | async fn search( 29 | token: &Token, 30 | page_index: usize, 31 | page_cap: usize, 32 | keyword: Option, 33 | cat_id: Option, 34 | ) -> Result { 35 | let query = { 36 | let mut query = vec![ 37 | ("t", "1".to_string()), 38 | ("p", page_index.to_string()), 39 | ("s", page_cap.to_string()), 40 | ]; 41 | if let Some(kw) = keyword { 42 | query.push(("search", kw)) 43 | } 44 | if let Some(id) = cat_id { 45 | query.push(("cid", id.to_string())) 46 | } 47 | 48 | cons_query_string(query) 49 | }; 50 | 51 | let url = blog_backend!("/posts/list?{}", query); 52 | 53 | let client = reqwest::Client::new(); 54 | 55 | let req = { 56 | let req = client.get(url); 57 | setup_auth(req, &token.token, token.is_pat) 58 | }; 59 | let resp = req.send().await?; 60 | 61 | let code = resp.status(); 62 | let body = resp.text().await?; 63 | 64 | if code.is_success().not() { 65 | bail!("{}: {}", code, body) 66 | } 67 | 68 | body.into_ok() 69 | } 70 | -------------------------------------------------------------------------------- /src/cmd/post-list/copy-link.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@/model/post' 2 | import { Alert } from '@/infra/alert' 3 | import { PostFileMapManager } from '@/service/post/post-file-map' 4 | import { PostService } from '@/service/post/post' 5 | import { PostTreeItem } from '@/tree-view/model/post-tree-item' 6 | import { env, MessageItem, Uri } from 'vscode' 7 | 8 | type LinkFormat = 'markdown' | 'raw' | 'id' 9 | 10 | type CopyStrategy = { 11 | name: string 12 | provideContent: (post: Post) => string 13 | } 14 | 15 | const strategies: { [key in LinkFormat]: CopyStrategy } = { 16 | raw: { 17 | name: '复制链接', 18 | provideContent: ({ url }) => url, 19 | }, 20 | markdown: { 21 | name: '复制markdown格式链接', 22 | provideContent: ({ url, title }) => `[${title}](${url})`, 23 | }, 24 | id: { 25 | name: '复制Id', 26 | provideContent: ({ id }) => `${id}`, 27 | }, 28 | } 29 | 30 | export async function copyPostLink(input: Post | PostTreeItem | Uri) { 31 | let post 32 | if (input instanceof Post) { 33 | post = input 34 | } else if (input instanceof PostTreeItem) { 35 | post = input.post 36 | } else { 37 | // input instanceof Uri 38 | const postId = PostFileMapManager.findByFilePath(input.path)?.[0] 39 | if (postId === undefined || postId <= 0) { 40 | void Alert.fileNotLinkedToPost(input) 41 | return 42 | } else { 43 | const dto = await PostService.getPostEditDto(postId) 44 | post = dto.post 45 | } 46 | } 47 | 48 | const answer = await Alert.info( 49 | '选择链接格式', 50 | { modal: true }, 51 | ...(Object.keys(strategies) as LinkFormat[]).map(f => ({ 52 | title: strategies[f].name, 53 | format: f, 54 | isCloseAffordance: false, 55 | })) 56 | ) 57 | if (answer === undefined) return 58 | 59 | const contentToCopy = strategies[answer.format].provideContent(post) 60 | if (contentToCopy.length > 0) await env.clipboard.writeText(contentToCopy) 61 | } 62 | -------------------------------------------------------------------------------- /src/service/upload-img/image.service.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream' 2 | import { isString, merge, pick } from 'lodash-es' 3 | import path from 'path' 4 | import httpClient from '@/infra/http-client' 5 | import { ExtConst } from '@/ctx/ext-const' 6 | 7 | class ImageService { 8 | async upload( 9 | file: T 10 | ): Promise { 11 | // eslint-disable-next-line @typescript-eslint/naming-convention 12 | const FormData = (await import('form-data')).default 13 | const form = new FormData() 14 | const { name, fileName, filename, path: _path } = file 15 | const finalName = path.basename(isString(_path) ? _path : fileName || filename || name || 'image.png') 16 | const ext = path.extname(finalName) 17 | const mime = await import('mime') 18 | const mimeType = mime.lookup(ext, 'image/png') 19 | form.append('image', file, { filename: finalName, contentType: mimeType }) 20 | const response = await httpClient.post(`${ExtConst.ApiBase.BLOG_BACKEND}/posts/body/images`, { 21 | body: form, 22 | }) 23 | 24 | return response.body 25 | } 26 | 27 | /** 28 | * Download the image from web 29 | * This will reject if failed to download 30 | * @param url The url of the web image 31 | * @param name The name that expected applied to the downloaded image 32 | * @returns The {@link Readable} stream 33 | */ 34 | async download(url: string, name?: string): Promise { 35 | const response = await httpClient.get(url, { responseType: 'buffer' }) 36 | const contentType = response.headers['content-type'] ?? 'image/png' 37 | name = name ? 'image' : name 38 | const mime = await import('mime') 39 | 40 | return merge(Readable.from(response.body), { 41 | ...pick(response, 'httpVersion', 'headers'), 42 | path: `${name}.${mime.extension(contentType) ?? 'png'}`, 43 | }) 44 | } 45 | } 46 | 47 | export const imageService = new ImageService() 48 | -------------------------------------------------------------------------------- /rs/src/cnb/ing/comment.rs: -------------------------------------------------------------------------------- 1 | use crate::cnb::ing::IngReq; 2 | use crate::cnb::oauth::Token; 3 | use crate::http::unit_or_err; 4 | use crate::infra::http::setup_auth; 5 | use crate::infra::result::ResultExt; 6 | use crate::{openapi, panic_hook}; 7 | use alloc::format; 8 | use alloc::string::{String, ToString}; 9 | use anyhow::Result; 10 | use mime::APPLICATION_JSON; 11 | use reqwest::header::CONTENT_TYPE; 12 | use serde::{Deserialize, Serialize}; 13 | use wasm_bindgen::prelude::*; 14 | 15 | #[serde_with::skip_serializing_none] 16 | #[derive(Serialize, Deserialize, Debug, Default)] 17 | struct Body { 18 | #[serde(rename(serialize = "replyTo"))] 19 | reply_to: Option, 20 | #[serde(rename(serialize = "parentCommentId"))] 21 | parent_comment_id: Option, 22 | content: String, 23 | } 24 | 25 | #[wasm_bindgen(js_class = IngReq)] 26 | impl IngReq { 27 | #[wasm_bindgen(js_name = comment)] 28 | pub async fn export_comment( 29 | &self, 30 | ing_id: usize, 31 | content: String, 32 | reply_to: Option, 33 | parent_comment_id: Option, 34 | ) -> Result<(), String> { 35 | panic_hook!(); 36 | let result = comment(&self.token, ing_id, content, reply_to, parent_comment_id).await; 37 | result.err_to_string() 38 | } 39 | } 40 | 41 | async fn comment( 42 | token: &Token, 43 | ing_id: usize, 44 | content: String, 45 | reply_to: Option, 46 | parent_comment_id: Option, 47 | ) -> Result<()> { 48 | let url = openapi!("/statuses/{}/comments", ing_id); 49 | 50 | let client = reqwest::Client::new(); 51 | 52 | let req = { 53 | let req = client.post(url); 54 | let req = req.header(CONTENT_TYPE, APPLICATION_JSON.to_string()); 55 | 56 | let body = Body { 57 | reply_to, 58 | parent_comment_id, 59 | content, 60 | }; 61 | let body = serde_json::to_string_pretty(&body)?; 62 | let req = req.body(body); 63 | 64 | setup_auth(req, &token.token, token.is_pat) 65 | }; 66 | 67 | let resp = req.send().await?; 68 | unit_or_err(resp).await 69 | } 70 | -------------------------------------------------------------------------------- /src/assets/icon-blog-management.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tree-view/model/post-search-result-entry.ts: -------------------------------------------------------------------------------- 1 | import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode' 2 | import { Post } from '@/model/post' 3 | import { ZzkSearchResult } from '@/model/zzk-search-result' 4 | import { BaseEntryTreeItem } from './base-entry-tree-item' 5 | import { BaseTreeItemSource } from './base-tree-item-source' 6 | import { PostTreeItem } from './post-tree-item' 7 | 8 | export class PostSearchResultEntry extends BaseTreeItemSource implements BaseEntryTreeItem { 9 | readonly children: (PostTreeItem | TreeItem)[] 10 | 11 | constructor( 12 | public searchKey: string, 13 | public readonly postList: Post[], 14 | public readonly totalCount: number, 15 | public readonly zzkSearchResult: ZzkSearchResult | null 16 | ) { 17 | if (searchKey.length <= 0) throw Error('Empty search key is not allowed') 18 | 19 | super() 20 | this.children = this.parseChildren() 21 | } 22 | 23 | toTreeItem = (): TreeItem | Promise => 24 | Object.assign(new TreeItem(`搜索结果: "${this.searchKey}"`), { 25 | iconPath: new ThemeIcon('vscode-cnb-post-list-search'), 26 | collapsibleState: TreeItemCollapsibleState.Expanded, 27 | contextValue: 'cnblogs-post-search-results-entry', 28 | }) 29 | 30 | getChildren = () => this.children 31 | getChildrenAsync = () => Promise.resolve(this.children) 32 | 33 | private readonly parseChildren = () => [ 34 | this.buildSummaryTreeItem(), 35 | ...this.postList.map(post => new PostTreeItem(post, false)), 36 | ] 37 | 38 | private buildSummaryTreeItem(): TreeItem { 39 | const zzkCount = this.zzkSearchResult?.postIds.length ?? 0 40 | 41 | let label 42 | if (zzkCount === 0) label = `共找到 ${this.totalCount} 篇随笔` 43 | else label = `共找到 ${this.totalCount} 篇随笔, ${zzkCount} 篇来自找找看` 44 | 45 | const ti = new TreeItem(label) 46 | 47 | return Object.assign(ti, { 48 | iconPath: new ThemeIcon('vscode-cnb-post-list-search-result-summary'), 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/infra/alert' 2 | import { AuthManager } from '@/auth/auth-manager' 3 | import { UserInfo } from '@/model/user-info' 4 | import { ExtConst } from '@/ctx/ext-const' 5 | import fetch, { Headers } from 'node-fetch' 6 | 7 | export namespace UserService { 8 | export async function getUserInfo(): Promise { 9 | const token = await AuthManager.acquireToken() 10 | // TODO: need better solution 11 | const isPatToken = token.length === 64 12 | return getUserInfoWithToken(token, isPatToken) 13 | } 14 | 15 | export async function hasBlog(): Promise { 16 | const userInfo = await UserService.getUserInfo() 17 | return userInfo?.blogApp != null 18 | } 19 | 20 | export async function getUserInfoWithToken(token: string, isPat: boolean): Promise { 21 | const url = `${ExtConst.ApiBase.OPENAPI}/users/v2` 22 | 23 | const headers = new Headers() 24 | headers.append('authorization', `Bearer ${token}`) 25 | headers.append('content-type', 'application/json') 26 | if (isPat) headers.append('authorization-type', 'pat') 27 | 28 | const req = await fetch(url, { 29 | headers: { 30 | authorization: `Bearer ${token}`, 31 | 'content-type': 'application/json', 32 | 'authorization-type': isPat ? 'pat' : '', 33 | }, 34 | }) 35 | 36 | if (!req.ok) { 37 | const message = `${req.status} ${req.statusText}` 38 | if (req.status === 401) { 39 | void Alert.warn('获取用户信息失败,请重新登录') 40 | await AuthManager.logout() 41 | return null 42 | } else { 43 | void Alert.err(`获取用户信息失败,错误详情: ${message}`) 44 | } 45 | throw new Error(message) 46 | } 47 | 48 | const userInfo = (await req.json()) as UserInfo 49 | 50 | if (userInfo.userId == null) void Alert.err(`获取用户信息失败: userId is null`) 51 | 52 | if (userInfo.accountId === undefined) void Alert.err(`获取用户信息失败: accountId is undefined`) 53 | 54 | return userInfo 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/cmd/blog-export/view-post.ts: -------------------------------------------------------------------------------- 1 | import { ExportPostTreeItem } from '@/tree-view/model/blog-export/post' 2 | import { URLSearchParams } from 'url' 3 | import { languages, TextDocumentContentProvider, Uri, window, workspace } from 'vscode' 4 | 5 | export async function viewPostBlogExport(treeItem?: ExportPostTreeItem) { 6 | if (!(treeItem instanceof ExportPostTreeItem)) return 7 | if (treeItem.parent.downloadedExport == null) return 8 | 9 | const downloadedExport = treeItem.parent.downloadedExport 10 | const postId = treeItem.post.id 11 | const postTitle = treeItem.post.title 12 | const isMkdPost = treeItem.post.isMarkdown 13 | 14 | const schemaWithId = `vscode-cnb.backup.post-${postId}` 15 | 16 | const matchedEditor = window.visibleTextEditors.find(({ document }) => { 17 | if (document.uri.scheme === schemaWithId && !document.isClosed) { 18 | const query = new URLSearchParams(document.uri.query) 19 | const parsedId = Number.parseInt(query.get('postId') ?? '-1') 20 | if (parsedId > 0 && parsedId === postId) return true 21 | } 22 | 23 | return false 24 | }) 25 | if (matchedEditor !== undefined) { 26 | await window.showTextDocument(matchedEditor.document, { preview: false, preserveFocus: true }) 27 | return 28 | } 29 | 30 | const disposable = workspace.registerTextDocumentContentProvider( 31 | schemaWithId, 32 | new (class implements TextDocumentContentProvider { 33 | async provideTextDocumentContent(): Promise { 34 | const { ExportPostStore } = await import('@/service/blog-export/blog-export-post.store') 35 | const store = new ExportPostStore(downloadedExport) 36 | return store.getBody(postId).finally(() => store.dispose()) 37 | } 38 | })() 39 | ) 40 | 41 | const uri = Uri.parse(`${schemaWithId}:(只读) ${postTitle}.${isMkdPost ? 'md' : 'html'}?postId=${postId}`) 42 | const document = await workspace.openTextDocument(uri) 43 | 44 | await window.showTextDocument(document) 45 | await languages.setTextDocumentLanguage(document, isMkdPost ? 'markdown' : 'html') 46 | 47 | disposable.dispose() 48 | } 49 | -------------------------------------------------------------------------------- /src/service/blog-export/blog-export.ts: -------------------------------------------------------------------------------- 1 | import { BlogExportRecord, BlogExportRecordList } from '@/model/blog-export' 2 | import got from '@/infra/http-client' 3 | import { AuthedReq } from '@/infra/http/authed-req' 4 | import { consHeader } from '@/infra/http/infra/header' 5 | import { consUrlPara } from '@/infra/http/infra/url-para' 6 | import { ExtConst } from '@/ctx/ext-const' 7 | 8 | const basePath = `${ExtConst.ApiBase.BLOG_BACKEND}/blogExports` 9 | const downloadOrigin = 'https://export.cnblogs.com' 10 | 11 | export namespace BlogExportApi { 12 | export async function list({ pageIndex, pageSize }: { pageIndex?: number; pageSize?: number }) { 13 | const para = consUrlPara(['pageIndex', `${pageIndex ?? ''}`], ['pageSize', `${pageSize ?? ''}`]) 14 | const url = `${basePath}?${para}` 15 | const resp = await AuthedReq.get(url, consHeader()) 16 | 17 | return JSON.parse(resp) 18 | } 19 | 20 | export async function create() { 21 | const resp = await AuthedReq.post(basePath, consHeader(), '') 22 | return JSON.parse(resp) 23 | } 24 | 25 | export async function del(id: number) { 26 | const url = `${basePath}/${id}` 27 | await AuthedReq.del(url, consHeader()) 28 | } 29 | 30 | export async function getById(id: number) { 31 | const resp = await AuthedReq.get(`${basePath}/${id}`, consHeader()) 32 | return JSON.parse(resp) 33 | } 34 | 35 | export function download(blogId: number, exportId: number) { 36 | const g = got.extend({ 37 | hooks: { 38 | beforeRedirect: [ 39 | (opt, resp) => { 40 | const location = resp.headers.location 41 | if (location === undefined) return 42 | if (location.includes('account.cnblogs.com')) throw new Error('未授权') 43 | }, 44 | ], 45 | }, 46 | }) 47 | 48 | const url = `${downloadOrigin}/blogs/${blogId}/exports/${exportId}` 49 | 50 | return g.stream.get(url, { 51 | throwHttpErrors: true, 52 | followRedirect: true, 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/service/ing/ing.ts: -------------------------------------------------------------------------------- 1 | import { Ing, IngComment, IngType } from '@/model/ing' 2 | import { Alert } from '@/infra/alert' 3 | import { IngReq, Token } from '@/wasm' 4 | import { AuthManager } from '@/auth/auth-manager' 5 | 6 | async function getAuthedIngReq() { 7 | const token = await AuthManager.acquireToken() 8 | // TODO: need better solution 9 | const isPatToken = token.length === 64 10 | return new IngReq(new Token(token, isPatToken)) 11 | } 12 | 13 | async function getComment(id: number) { 14 | const req = await getAuthedIngReq() 15 | const resp = await req.getComment(id) 16 | const list = JSON.parse(resp) as [] 17 | return list.map(IngComment.parse) 18 | } 19 | 20 | export namespace IngService { 21 | export async function pub(content: string, isPrivate: boolean) { 22 | try { 23 | const req = await getAuthedIngReq() 24 | await req.publish(content, isPrivate) 25 | return true 26 | } catch (e) { 27 | void Alert.err(`闪存发布失败: ${e}`) 28 | return false 29 | } 30 | } 31 | 32 | export async function getList({ pageIndex = 1, pageSize = 30, type = IngType.all } = {}) { 33 | try { 34 | const req = await getAuthedIngReq() 35 | const resp = await req.getList(pageIndex, pageSize, type) 36 | const arr = JSON.parse(resp) as unknown[] 37 | return arr.map(Ing.parse) 38 | } catch (e) { 39 | void Alert.err(`获取闪存列表失败: ${e}`) 40 | return [] 41 | } 42 | } 43 | 44 | export async function getCommentList(...ingIds: number[]) { 45 | const futList = ingIds.map(async id => ({ [id]: await getComment(id) })) 46 | const resList = await Promise.all(futList) 47 | return resList.reduce((acc, it) => Object.assign(it, acc), {}) 48 | } 49 | 50 | export async function comment(ingId: number, content: string, replyTo?: number, parentCommentId?: number) { 51 | try { 52 | const req = await getAuthedIngReq() 53 | await req.comment(ingId, content, replyTo, parentCommentId) 54 | return true 55 | } catch (e) { 56 | void Alert.err(`发表评论失败, ${e}`) 57 | return false 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------