├── .prettierignore ├── .npmrc ├── .eslintignore ├── versions.json ├── sync.sh ├── .editorconfig ├── src ├── utils │ ├── sanitizeTitle.ts │ ├── dateUtil.ts │ ├── fileUtils.ts │ ├── cookiesUtil.ts │ └── frontmatter.ts ├── index.d.ts ├── assets │ ├── wereadOfficialTemplate.njk │ ├── notebookTemplate.njk │ └── templateInstructions.html ├── components │ ├── wereadReading.ts │ ├── wereadLogoutModel.ts │ ├── wereadLoginModel.ts │ ├── cookieCloudConfigModel.ts │ └── templateEditorWindow.ts ├── cookieCloud.ts ├── renderer.ts ├── syncNotebooks.ts ├── settings.ts ├── fileManager.ts ├── api.ts ├── models.ts ├── parser │ └── parseResponse.ts └── settingTab.ts ├── .prettierrc ├── manifest.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── CI.yml │ └── release.yml └── copilot-instructions.md ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── LICENSE ├── package.json ├── webpack.config.js ├── docs ├── template-editor-window.md └── weread-api.md ├── README.md ├── main.ts └── style.css /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.7", 3 | "1.0.1": "0.12.0", 4 | "0.13.0": "0.12.0" 5 | } 6 | -------------------------------------------------------------------------------- /sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rsync -av --delete --include={"main.js","style.css","manifest.json"} --exclude='*' ./dist/ ~/Library/Mobile\ Documents/iCloud~md~obsidian/Documents/xuan/.obsidian/plugins/obsidian-weread-plugin -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /src/utils/sanitizeTitle.ts: -------------------------------------------------------------------------------- 1 | import sanitize from 'sanitize-filename'; 2 | 3 | export const sanitizeTitle = (title: string): string => { 4 | const santizedTitle = title.replace(/[':#|]/g, '').trim(); 5 | return sanitize(santizedTitle); 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "overrides": [ 7 | { 8 | "files": "*.ts", 9 | "options": { 10 | "parser": "typescript" 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.njk' { 2 | const content: any; 3 | export default content; 4 | } 5 | 6 | declare module '*.html' { 7 | const content: any; 8 | export default content; 9 | } 10 | 11 | declare module '*.svg' { 12 | const content: any; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-weread-plugin", 3 | "name": "Weread", 4 | "version": "0.15.0", 5 | "minAppVersion": "0.12.0", 6 | "description": "This is obsidian plugin for Tencent weread.", 7 | "author": "hankzhao", 8 | "authorUrl": "https://zhaohongxuan.github.io", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: 提议一个新功能 4 | title: "[Feature]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **您的功能请求是否与问题有关?请描述。 11 | 清晰简洁地描述问题所在。 12 | 13 | **描述您想要的解决方案** 14 | 清晰简洁地描述您想要发生的事情。 15 | 16 | **描述您考虑过的替代方案** 17 | 清晰简洁地描述您考虑过的任何替代解决方案或功能。 18 | 19 | **其他信息** 20 | 在此处添加有关新功能的其他信息或屏幕截图。 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | dist 25 | -------------------------------------------------------------------------------- /src/utils/dateUtil.ts: -------------------------------------------------------------------------------- 1 | export const formatTimeDuration = (durationInSeconds: number): string => { 2 | const hours: number = Math.floor(durationInSeconds / 3600); 3 | const minutes: number = Math.floor((durationInSeconds % 3600) / 60); 4 | const formattedDuration = `${hours}小时${minutes}分钟`; 5 | 6 | return formattedDuration; 7 | }; 8 | 9 | export const formatTimestampToDate = (readingBookDate: number): string => { 10 | return window.moment(readingBookDate * 1000).format('YYYY-MM-DD'); 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "lib": [ 14 | "ES2021", 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": ["**/*.ts" ] 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | export const getLinesInString = (input: string) => { 2 | const lines: string[] = []; 3 | let tempString = input; 4 | 5 | while (tempString.contains('\n')) { 6 | const lineEndIndex = tempString.indexOf('\n'); 7 | lines.push(tempString.slice(0, lineEndIndex)); 8 | tempString = tempString.slice(lineEndIndex + 1); 9 | } 10 | 11 | lines.push(tempString); 12 | 13 | return lines; 14 | }; 15 | 16 | export const escapeRegExp = (text) => { 17 | return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | env: 12 | PLUGIN_NAME: obsidian-weread-plugin 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18 24 | 25 | - name: Build 26 | id: build 27 | run: | 28 | npm install 29 | npm run build 30 | 31 | - name: Lint 32 | id: lint 33 | run: | 34 | npm run lint -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 报告一个BUG帮助我们做得更好 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: zhaohongxuan 7 | 8 | --- 9 | 10 | **描述bug ** 11 | 首先确定bug能稳定复现,对报错信息进行清晰简洁描述,如果描述不清晰,issue将会关闭 12 | 13 | **重现步骤** 14 | 重现错误的步骤: 15 | 1. 点击“...” 16 | 2. 点击“...”。 17 | 3. 点击“...”。 18 | 4. 出现XXX错误 19 | 20 | **预期行为** 21 | 清晰简洁地描述您期望发生的事情。 22 | 23 | **截图** 24 | 如果适用,请添加屏幕截图以帮助解释您的问题。 25 | 26 | **桌面(请填写以下信息):** 27 | - 操作系统: [例如 Windows 10,macOS 13等] 28 | - Obsidian版本:[例如 1.5.3] 29 | - 插件版本: [例如 0.7.1] 30 | 31 | **移动端(请填写以下信息):** 32 | - 设备:[例如 iPhone14] 33 | - 操作系统:[例如 iOS16] 34 | - Obsidian版本:[例如 1.5.3] 35 | - 插件版本:[例如 0.7.1] 36 | 37 | **补充信息** 38 | 在此处添加有关该问题的任何信息。 39 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint","prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "parserOptions": { 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "prettier/prettier": 2, //means error 19 | "no-unused-vars": "off", 20 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 21 | "@typescript-eslint/ban-ts-comment": "off", 22 | "no-prototype-builtins": "off", 23 | "@typescript-eslint/no-empty-function": "off", 24 | "@typescript-eslint/no-explicit-any": ["off"], 25 | "@typescript-eslint/no-var-requires": 0 26 | } 27 | } -------------------------------------------------------------------------------- /src/assets/wereadOfficialTemplate.njk: -------------------------------------------------------------------------------- 1 | --- 2 | isbn: {{metaData.isbn}} 3 | lastReadDate: {{metaData.lastReadDate}} 4 | --- 5 | 《{{metaData.title}}》 6 | {{metaData.author}} 7 | {{metaData.noteCount}}个笔记 8 | {% if bookReview.bookReviews %}{% for bookReview in bookReview.bookReviews %} 9 | ◆ 点评 10 | {{bookReview.createTime}} 11 | {{bookReview.mdContent}}{% endfor%}{% endif %} 12 | {% for chapter in chapterHighlights %} 13 | {% if chapter.level == 1 %}## ◆ {{chapter.chapterTitle}}{% elif chapter.level == 2 %}### ◆ {{chapter.chapterTitle}}{% elif chapter.level == 3 %}#### ◆ {{chapter.chapterTitle}}{% endif %} 14 | {% for highlight in chapter.highlights %}{% if highlight.reviewContent %} 15 | 16 | {{highlight.createTime}} 发表想法 17 | {{highlight.reviewContent}} 18 | 19 | > {{ highlight.markText |trim }} ^{{highlight.bookmarkId}} 20 | {% else %} 21 | 22 | > {{ highlight.markText |trim }} ^{{highlight.bookmarkId}}{% endif %}{% endfor %}{% endfor %} 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jsonmartin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/components/wereadReading.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceLeaf, ItemView } from 'obsidian'; 2 | import WereadPlugin from '../../main'; 3 | 4 | export const WEREAD_BROWSER_VIEW_ID = 'weread-reading-view'; 5 | 6 | export class WereadReadingView extends ItemView { 7 | plugin: WereadPlugin; 8 | getViewType(): string { 9 | return WEREAD_BROWSER_VIEW_ID; 10 | } 11 | getDisplayText(): string { 12 | return '微信读书'; 13 | } 14 | leaf: WorkspaceLeaf; 15 | private webviewEl: HTMLElement; 16 | 17 | constructor(leaf: WorkspaceLeaf) { 18 | super(leaf); 19 | } 20 | 21 | getIcon(): string { 22 | return 'book-open'; 23 | } 24 | 25 | async onClose() { 26 | // Nothing to clean up. 27 | } 28 | 29 | async onOpen() { 30 | this.webviewEl = this.contentEl.doc.createElement('webview'); 31 | this.webviewEl.setAttribute('allowpopups', ''); 32 | this.webviewEl.addClass('weread-frame'); 33 | this.webviewEl.setAttribute('src', 'https://r.qq.com'); 34 | this.leaf.setViewState({ 35 | type: WEREAD_BROWSER_VIEW_ID, 36 | active: true 37 | }); 38 | 39 | this.contentEl.appendChild(this.webviewEl); 40 | this.contentEl.addClass('weread-view-content'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/cookiesUtil.ts: -------------------------------------------------------------------------------- 1 | import { Cookie } from 'set-cookie-parser'; 2 | import { get } from 'svelte/store'; 3 | import { settingsStore } from '../settings'; 4 | 5 | export const parseCookies = (cookieInput: string): Cookie[] => { 6 | if (cookieInput === '') { 7 | return []; 8 | } 9 | 10 | const pairs = cookieInput.split(';'); 11 | const splittedPairs = pairs.map((cookie) => cookie.split('=')); 12 | const cookieArr: Cookie[] = splittedPairs.map((pair) => { 13 | return { 14 | name: decodeURIComponent(pair[0].trim()), 15 | value: decodeURIComponent(pair[1].trim()) 16 | }; 17 | }); 18 | return cookieArr; 19 | }; 20 | 21 | export const getCookieString = (cookies: Cookie[]): string => { 22 | return cookies 23 | .map((cookie) => { 24 | const key = cookie.name; 25 | const value = cookie.value; 26 | const decodeValue = value.indexOf('%') !== -1 ? decodeURIComponent(value) : value; 27 | return key + '=' + decodeValue; 28 | }) 29 | .join(';'); 30 | }; 31 | 32 | export const getEncodeCookieString = (): string => { 33 | const cookiesArr = get(settingsStore).cookies; 34 | return cookiesArr 35 | .map((cookie) => { 36 | const key = cookie.name; 37 | const value = cookie.value; 38 | return key + '=' + encodeURIComponent(value); 39 | }) 40 | .join(';'); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/wereadLogoutModel.ts: -------------------------------------------------------------------------------- 1 | import { settingsStore } from '../settings'; 2 | import { WereadSettingsTab } from '../settingTab'; 3 | 4 | export default class WereadLoginModel { 5 | private modal: any; 6 | private settingTab: WereadSettingsTab; 7 | constructor(settingTab: WereadSettingsTab) { 8 | this.settingTab = settingTab; 9 | const { remote } = require('electron'); 10 | const { BrowserWindow: RemoteBrowserWindow } = remote; 11 | this.modal = new RemoteBrowserWindow({ 12 | parent: remote.getCurrentWindow(), 13 | width: 960, 14 | height: 540, 15 | show: false 16 | }); 17 | this.modal.once('ready-to-show', () => { 18 | this.modal.setTitle('注销微信读书,点击头像选择->退出登录'); 19 | this.modal.show(); 20 | }); 21 | const session = this.modal.webContents.session; 22 | const filter = { 23 | urls: ['https://weread.qq.com/api/auth/logout'] 24 | }; 25 | session.webRequest.onCompleted(filter, (details) => { 26 | if (details.statusCode == 200 || details.statusCode == 204) { 27 | console.log('weread logout success, clear cookies...'); 28 | settingsStore.actions.clearCookies(); 29 | this.settingTab.display(); 30 | this.modal.close(); 31 | } 32 | }); 33 | } 34 | 35 | async doLogout() { 36 | await this.modal.loadURL('https://weread.qq.com'); 37 | } 38 | 39 | onClose() { 40 | this.modal.close(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-weread-plugin", 3 | "version": "0.15.0", 4 | "description": "This is a community plugin for tencent weread (https://r.qq.com)", 5 | "main": "main.ts", 6 | "scripts": { 7 | "clean": "rimraf dist main.js", 8 | "build": "svelte-check && npm run lint && webpack", 9 | "dev": "NODE_ENV=development webpack && ./sync.sh", 10 | "lint": "eslint . --ext .ts --fix" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "^16.11.35", 17 | "@types/nunjucks": "^3.2.1", 18 | "@typescript-eslint/eslint-plugin": "^5.2.0", 19 | "@typescript-eslint/parser": "^5.2.0", 20 | "builtin-modules": "^3.2.0", 21 | "copy-webpack-plugin": "^10.2.4", 22 | "css-loader": "^6.8.1", 23 | "esbuild": "0.13.12", 24 | "esbuild-css-modules-plugin": "^3.1.0", 25 | "eslint": "^8.19.0", 26 | "eslint-config-prettier": "^8.5.0", 27 | "eslint-plugin-prettier": "^4.0.0", 28 | "obsidian": "^1.4.11", 29 | "style-loader": "^3.3.3", 30 | "svelte-check": "^2.7.0", 31 | "svelte-preprocess": "^4.10.6", 32 | "ts-loader": "^9.3.0", 33 | "tslib": "2.3.1", 34 | "typescript": "^4.4.4", 35 | "webpack": "^5.72.0", 36 | "webpack-cli": "^4.9.2" 37 | }, 38 | "dependencies": { 39 | "@types/crypto-js": "^4.1.2", 40 | "@types/lodash.pickby": "^4.6.7", 41 | "@types/set-cookie-parser": "^2.4.2", 42 | "crypto-js": "^4.1.1", 43 | "electron": "^31.3.1", 44 | "lodash.pickby": "^4.6.0", 45 | "node-html-markdown": "^1.2.0", 46 | "nunjucks": "^3.2.4", 47 | "sanitize-filename": "^1.6.3", 48 | "set-cookie-parser": "^2.5.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | const sveltePreprocess = require('svelte-preprocess'); 4 | 5 | const isDevMode = process.env.NODE_ENV === 'development'; 6 | 7 | module.exports = { 8 | entry: './main.ts', 9 | output: { 10 | path: path.resolve(__dirname, './dist'), 11 | filename: 'main.js', 12 | libraryTarget: 'commonjs' 13 | }, 14 | target: 'node', 15 | mode: isDevMode ? 'development' : 'production', 16 | ...(isDevMode ? { devtool: 'eval' } : {}), 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | options: { 23 | transpileOnly: true 24 | } 25 | }, 26 | { 27 | test: /\.(svelte)$/, 28 | use: [ 29 | { loader: 'babel-loader' }, 30 | { 31 | loader: 'svelte-loader', 32 | options: { 33 | preprocess: sveltePreprocess({}) 34 | } 35 | } 36 | ] 37 | }, 38 | { 39 | test: /\.(svg|njk|html)$/, 40 | type: 'asset/source' 41 | }, 42 | { 43 | test: /\.css$/, 44 | use: ['style-loader', 'css-loader'] 45 | } 46 | ] 47 | }, 48 | plugins: [ 49 | new CopyPlugin({ 50 | patterns: [ 51 | { from: './manifest.json', to: '.' }, 52 | { from: './style.css', to: '.' } 53 | ] 54 | }) 55 | ], 56 | resolve: { 57 | alias: { 58 | svelte: path.resolve('node_modules', 'svelte'), 59 | '~': path.resolve(__dirname, 'src') 60 | }, 61 | extensions: ['.ts', '.tsx', '.js', '.svelte'], 62 | mainFields: ['svelte', 'browser', 'module', 'main'] 63 | }, 64 | externals: { 65 | electron: 'commonjs2 electron', 66 | obsidian: 'commonjs2 obsidian' 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/assets/notebookTemplate.njk: -------------------------------------------------------------------------------- 1 | --- 2 | isbn: {{metaData.isbn}} 3 | lastReadDate: {{metaData.lastReadDate}} 4 | --- 5 | # 元数据 6 | > [!abstract] {{metaData.title}} 7 | > - ![ {{metaData.title}}|200]({{metaData.cover}}) 8 | > - 书名: {{metaData.title}} 9 | > - 作者: {{metaData.author}} 10 | > - 简介: {% set intro = metaData.intro | replace("\n","") | replace("\r","") %}{{intro}} 11 | > - 出版时间: {{metaData.publishTime}} 12 | > - ISBN: {{metaData.isbn}} 13 | > - 分类: {{metaData.category}} 14 | > - 出版社: {{metaData.publisher}} 15 | > - PC地址:{{metaData.pcUrl}} 16 | 17 | # 高亮划线 18 | {% for chapter in chapterHighlights %} 19 | {% if chapter.level == 1 %}## {{chapter.chapterTitle}}{% elif chapter.level == 2 %}### {{chapter.chapterTitle}}{% elif chapter.level == 3 %}#### {{chapter.chapterTitle}}{% endif %} 20 | {% for highlight in chapter.highlights %}{% if highlight.reviewContent %} 21 | > 📌 {{ highlight.markText |trim }} ^{{highlight.bookmarkId}} 22 | - 💭 {{highlight.reviewContent}} - ⏱ {{highlight.createTime}} {% else %} 23 | > 📌 {{ highlight.markText |trim }} 24 | > ⏱ {{highlight.createTime}} ^{{highlight.bookmarkId}}{% endif %} 25 | {% endfor %}{% endfor %} 26 | # 读书笔记 27 | {% for chapter in bookReview.chapterReviews %}{% if chapter.reviews or chapter.chapterReview %} 28 | ## {{chapter.chapterTitle}} 29 | {% if chapter.chapterReviews %}{% for chapterReview in chapter.chapterReviews %} 30 | ### 章节评论 No.{{loop.index}} 31 | - {{chapterReview.content}} ^{{chapterReview.reviewId}} 32 | - ⏱ {{chapterReview.createTime}} {% endfor %}{% endif %}{% if chapter.reviews %}{% for review in chapter.reviews %} 33 | ### 划线评论 34 | > 📌 {{review.abstract |trim }} ^{{review.reviewId}} 35 | - 💭 {{review.content}} 36 | - ⏱ {{review.createTime}} 37 | {% endfor %} {% endif %} {% endif %} {% endfor %} 38 | # 本书评论 39 | {% if bookReview.bookReviews %}{% for bookReview in bookReview.bookReviews %} 40 | ## 书评 No.{{loop.index}} 41 | {{bookReview.mdContent}} ^{{bookReview.reviewId}} 42 | ⏱ {{bookReview.createTime}} 43 | {% endfor %}{% endif %} 44 | -------------------------------------------------------------------------------- /src/components/wereadLoginModel.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import { parseCookies } from '../utils/cookiesUtil'; 3 | import { settingsStore } from '../settings'; 4 | import { WereadSettingsTab } from '../settingTab'; 5 | 6 | export default class WereadLoginModel { 7 | private modal: any; 8 | private settingTab: WereadSettingsTab; 9 | constructor(settingTab: WereadSettingsTab) { 10 | this.settingTab = settingTab; 11 | const { remote } = require('electron'); 12 | const { BrowserWindow: RemoteBrowserWindow } = remote; 13 | this.modal = new RemoteBrowserWindow({ 14 | parent: remote.getCurrentWindow(), 15 | width: 960, 16 | height: 540, 17 | show: false 18 | }); 19 | 20 | this.modal.once('ready-to-show', () => { 21 | this.modal.setTitle('登录微信读书~'); 22 | this.modal.show(); 23 | }); 24 | 25 | const session = this.modal.webContents.session; 26 | 27 | const loginFilter = { 28 | urls: ['https://weread.qq.com/api/auth/getLoginInfo?uid=*'] 29 | }; 30 | 31 | session.webRequest.onCompleted(loginFilter, (details) => { 32 | if (details.statusCode == 200) { 33 | console.log('weread login success, redirect to weread shelf'); 34 | this.modal.loadURL('https://weread.qq.com/web/shelf'); 35 | } 36 | }); 37 | 38 | const filter = { 39 | urls: ['https://weread.qq.com/web/user?userVid=*'] 40 | }; 41 | session.webRequest.onSendHeaders(filter, (details) => { 42 | const cookies = details.requestHeaders['Cookie']; 43 | const cookieArr = parseCookies(cookies); 44 | const wr_name = cookieArr.find((cookie) => cookie.name == 'wr_name').value; 45 | if (wr_name !== '') { 46 | settingsStore.actions.setCookies(cookieArr); 47 | settingTab.display(); 48 | this.modal.close(); 49 | } else { 50 | this.modal.reload(); 51 | } 52 | }); 53 | } 54 | 55 | async doLogin() { 56 | try { 57 | await this.modal.loadURL('https://weread.qq.com/#login'); 58 | } catch (error) { 59 | console.log(error); 60 | new Notice('加载微信读书登录页面失败'); 61 | } 62 | } 63 | 64 | onClose() { 65 | this.modal.close(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/assets/templateInstructions.html: -------------------------------------------------------------------------------- 1 | 模板使用说明 2 | 3 |

功能开关

4 |

✂️ 自动去空白:开启后会自动移除模板标签({% %})前后的换行和空格,让你可以用缩进写出更易读的模板代码。

5 |

📝 Markdown渲染:开启后右侧预览会渲染为格式化的 Markdown,关闭则显示原始文本。

6 | 7 |

可用变量

8 | 元数据变量(metaData) 9 | 23 | 划线变量(chapterHighlights) 24 | 31 | 笔记笔记(bookReview) 32 | 43 | -------------------------------------------------------------------------------- /src/cookieCloud.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl, Notice, RequestUrlParam } from 'obsidian'; 2 | import { parseCookies } from './utils/cookiesUtil'; 3 | import { settingsStore } from './settings'; 4 | import { get } from 'svelte/store'; 5 | import * as CryptoJS from 'crypto-js'; 6 | 7 | export default class CookieCloudManager { 8 | async getCookie() { 9 | const info = get(settingsStore).cookieCloudInfo; 10 | if (!info || info.serverUrl === '' || info.uuid === '' || info.password === '') { 11 | new Notice(`请检查 CookieCloud 配置`); 12 | return false; 13 | } 14 | 15 | const req: RequestUrlParam = { 16 | url: `${info.serverUrl}/get/${info.uuid}`, 17 | method: 'GET' 18 | }; 19 | 20 | try { 21 | const resp = await requestUrl(req); 22 | console.debug('request cookiecloud server resp', resp); 23 | 24 | if (resp.status !== 200) { 25 | new Notice(`CookieCloud 获取失败,请检查配置`); 26 | return false; 27 | } 28 | 29 | const json = resp['json']; 30 | if (json && json.encrypted) { 31 | const { cookie_data } = this.cookieDecrypt( 32 | info.uuid, 33 | json.encrypted, 34 | info.password 35 | ); 36 | 37 | for (const key in cookie_data) { 38 | const cookieStr = [ 39 | ...cookie_data[key].filter((item: { domain: string }) => 40 | item.domain.endsWith('weread.qq.com') 41 | ) 42 | ] 43 | .map((item) => `${item.name}=${item.value}`) 44 | .join('; '); 45 | 46 | return this.updateCookies(cookieStr); 47 | } 48 | } 49 | 50 | new Notice(`CookieCloud 获取微信读书登录信息失败,请检查配置`); 51 | return false; 52 | } catch (e) { 53 | new Notice(`CookieCloud 获取失败,请检查配置或网络连接`); 54 | return false; 55 | } 56 | } 57 | 58 | private cookieDecrypt(uuid: string, encrypted: string, password: string) { 59 | const the_key = CryptoJS.MD5(uuid + '-' + password) 60 | .toString() 61 | .substring(0, 16); 62 | 63 | try { 64 | const decrypted = CryptoJS.AES.decrypt(encrypted, the_key).toString(CryptoJS.enc.Utf8); 65 | const parsed = JSON.parse(decrypted); 66 | return parsed; 67 | } catch (e) { 68 | new Notice(`解密失败,请检查配置`); 69 | return ''; 70 | } 71 | } 72 | 73 | private updateCookies(cookies: string) { 74 | const cookieArr = parseCookies(cookies); 75 | const wrName = cookieArr.find((cookie) => cookie.name == 'wr_name'); 76 | if (wrName !== undefined && wrName.value !== '') { 77 | settingsStore.actions.setCookies(cookieArr); 78 | new Notice(`CookieCloud 获取 cookie 成功`); 79 | return true; 80 | } 81 | 82 | new Notice(`CookieCloud 获取微信读书登录信息失败,请检查配置`); 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions for obsidian-weread-plugin 2 | 3 | ## Project Overview 4 | - This is an Obsidian community plugin for syncing WeRead (微信读书) book metadata, highlights, notes, and reviews into Markdown files in an Obsidian vault. 5 | - The plugin supports login via WeChat QR code, cookie management, custom templates (Nunjucks), and flexible file naming/frontmatter. 6 | - Main logic is in `src/`, with subfolders for API, models, rendering, settings, and utilities. Templates are in `src/assets/`. 7 | 8 | ## Key Components 9 | - `src/api.ts`: Handles WeRead API requests and data fetching. 10 | - `src/syncNotebooks.ts`: Core sync logic for books, highlights, and notes. 11 | - `src/renderer.ts`: Converts fetched data into Markdown using Nunjucks templates. 12 | - `src/settings.ts` & `src/settingTab.ts`: Plugin settings UI and logic. 13 | - `src/utils/`: Utility functions for cookies, dates, file management, frontmatter, etc. 14 | - `src/components/`: UI models for login/logout and reading state. 15 | 16 | ## Developer Workflows 17 | - **Build:** Use `npm run build` (uses webpack, see `webpack.config.js`). 18 | - **Release:** See GitHub Actions workflows in `.github/workflows/` for CI and release automation. 19 | - **Versioning:** Use `version-bump.mjs` and `versions.json` for version management. 20 | - **Sync:** Main entry is `Sync Weread command` (see README for usage). 21 | 22 | ## Project Conventions 23 | - All book/note sync is overwrite-based: do not edit synced files directly. 24 | - Templates use Nunjucks (`.njk` in `src/assets/`). 25 | - Settings and state are managed via Obsidian's plugin API and custom UI components. 26 | - Cookie management is abstracted in `src/cookieCloud.ts` and `src/utils/cookiesUtil.ts`. 27 | - File and frontmatter logic is in `src/utils/fileUtils.ts` and `src/utils/frontmatter.ts`. 28 | 29 | ## Integration Points 30 | - Relies on WeRead web APIs (see `docs/weread-api.md`). 31 | - Uses Nunjucks for template rendering. 32 | - Integrates with Obsidian's plugin API for UI, commands, and file management. 33 | 34 | ## Examples & References 35 | - See `README.md` for user-facing features and workflows. 36 | - See `docs/weread-api.md` for API details. 37 | - Templates: `src/assets/notebookTemplate.njk`, `src/assets/wereadOfficialTemplate.njk`. 38 | 39 | ## Tips for AI Agents 40 | - When adding features, follow the modular structure (API, sync, render, settings, utils). 41 | - Respect the overwrite sync model—never write code that edits user notes in place. 42 | - Use existing utility functions for cookies, file, and frontmatter handling. 43 | - Reference and extend Nunjucks templates for new output formats. 44 | - For new settings, update both `settings.ts` and `settingTab.ts`. 45 | 46 | --- 47 | _Last updated: 2025-11-13_ 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-weread-plugin 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp ./dist/main.js ./dist/manifest.json ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./dist/main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload style.css 68 | id: upload-style 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./dist/style.css 75 | asset_name: style.css 76 | asset_content_type: text/css 77 | 78 | - name: Upload manifest.json 79 | id: upload-manifest 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./dist/manifest.json 86 | asset_name: manifest.json 87 | asset_content_type: application/json -------------------------------------------------------------------------------- /src/components/cookieCloudConfigModel.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Modal, Setting } from 'obsidian'; 2 | import { settingsStore } from '../settings'; 3 | import { get } from 'svelte/store'; 4 | import { WereadSettingsTab } from '../settingTab'; 5 | import CookieCloudManager from '../cookieCloud'; 6 | 7 | export default class CookieCloudConfigModal extends Modal { 8 | private cookieCloudManager: CookieCloudManager; 9 | private wereadSettingsTab: WereadSettingsTab; 10 | 11 | private serverUrl = ''; 12 | private uuid = ''; 13 | private password = ''; 14 | 15 | constructor(app: App, private settingTab: WereadSettingsTab) { 16 | super(app); 17 | this.cookieCloudManager = new CookieCloudManager(); 18 | this.wereadSettingsTab = settingTab; 19 | this.loadConfigFromSettings(); 20 | } 21 | 22 | private loadConfigFromSettings(): void { 23 | const info = get(settingsStore).cookieCloudInfo; 24 | if (info) { 25 | this.serverUrl = info.serverUrl; 26 | this.uuid = info.uuid; 27 | this.password = info.password; 28 | } 29 | } 30 | 31 | onOpen() { 32 | const { contentEl } = this; 33 | contentEl.addClass('cookie-cloud-modal'); 34 | 35 | new Setting(contentEl).setHeading().setName('CookieCloud 配置'); 36 | 37 | new Setting(contentEl).setName('服务器地址').addText((text) => 38 | text 39 | .setPlaceholder('请输入内容') 40 | .setValue(this.serverUrl) 41 | .onChange((value) => { 42 | this.serverUrl = value; 43 | }) 44 | ); 45 | 46 | new Setting(contentEl).setName('用户KEY').addText((text) => 47 | text 48 | .setPlaceholder('请输入内容') 49 | .setValue(this.uuid) 50 | .onChange((value) => { 51 | this.uuid = value; 52 | }) 53 | ); 54 | 55 | new Setting(contentEl).setName('端对端加密密码').addText((text) => 56 | text 57 | .setPlaceholder('请输入内容') 58 | .setValue(this.password) 59 | .onChange((value) => { 60 | this.password = value; 61 | }) 62 | ); 63 | 64 | new Setting(contentEl).addButton((button) => 65 | button 66 | .setButtonText('确定') 67 | .setCta() 68 | .onClick(() => { 69 | this.onSubmit(); 70 | }) 71 | ); 72 | } 73 | 74 | onSubmit() { 75 | if (!this.serverUrl) { 76 | new Notice('请输入服务器地址'); 77 | return; 78 | } 79 | 80 | if (!this.uuid) { 81 | new Notice('请输入用户KEY'); 82 | return; 83 | } 84 | 85 | if (!this.password) { 86 | new Notice('请输入端对端加密密码'); 87 | return; 88 | } 89 | 90 | settingsStore.actions.setCookieCloudInfo({ 91 | serverUrl: this.serverUrl, 92 | uuid: this.uuid, 93 | password: this.password 94 | }); 95 | 96 | // 修改 CookieCloud 配置后,先清除 cookie 在从新配置的 CookieCloud 获取 cookie 97 | new Notice(`清除 Cookie`); 98 | settingsStore.actions.clearCookies(); 99 | 100 | this.cookieCloudManager.getCookie().then(async (refreshSuccess) => { 101 | if (refreshSuccess) { 102 | this.wereadSettingsTab.display(); 103 | this.close(); 104 | } 105 | }); 106 | } 107 | 108 | onClose() { 109 | const { contentEl } = this; 110 | contentEl.empty(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/utils/frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { Notice, parseYaml, stringifyYaml, TFile } from 'obsidian'; 2 | import type { Notebook } from '../models'; 3 | import { formatTimeDuration, formatTimestampToDate } from './dateUtil'; 4 | import { settingsStore } from '../settings'; 5 | import { get } from 'svelte/store'; 6 | 7 | type FrontMatterContent = { 8 | doc_type: string; 9 | bookId: string; 10 | noteCount: number; 11 | reviewCount: number; 12 | author?: string; 13 | cover?: string; 14 | readingStatus?: string; 15 | progress?: string; 16 | readingTime?: string; 17 | totalReadDay?: number; 18 | readingDate?: string; 19 | finishedDate?: string; 20 | }; 21 | 22 | export const frontMatterDocType = 'weread-highlights-reviews'; 23 | 24 | enum ReadingStatus { 25 | '未标记' = 1, 26 | '在读' = 2, 27 | '读过' = 3, 28 | '读完' = 4 29 | } 30 | 31 | export const buildFrontMatter = ( 32 | markdownContent: string, 33 | noteBook: Notebook, 34 | existFile?: TFile 35 | ) => { 36 | const frontMatter: FrontMatterContent = { 37 | doc_type: frontMatterDocType, 38 | bookId: noteBook.metaData.bookId, 39 | reviewCount: noteBook.metaData.reviewCount, 40 | noteCount: noteBook.metaData.noteCount 41 | }; 42 | 43 | const saveReadingInfoToggle = get(settingsStore).saveReadingInfoToggle; 44 | 45 | if (saveReadingInfoToggle) { 46 | (frontMatter.author = noteBook.metaData.author), 47 | (frontMatter.cover = noteBook.metaData.cover); 48 | 49 | const readInfo = noteBook.metaData.readInfo; 50 | if (readInfo) { 51 | frontMatter.readingStatus = ReadingStatus[readInfo.markedStatus]; 52 | frontMatter.progress = 53 | readInfo.readingProgress === undefined ? '-1' : readInfo.readingProgress + '%'; 54 | frontMatter.totalReadDay = readInfo.totalReadDay; 55 | frontMatter.readingTime = formatTimeDuration(readInfo.readingTime); 56 | frontMatter.readingDate = formatTimestampToDate(readInfo.readingBookDate); 57 | if (readInfo.finishedDate) { 58 | frontMatter.finishedDate = formatTimestampToDate(readInfo.finishedDate); 59 | } 60 | } 61 | } 62 | let existFrontMatter = Object(); 63 | if (existFile) { 64 | const cache = app.metadataCache.getFileCache(existFile); 65 | existFrontMatter = cache.frontmatter; 66 | if (existFrontMatter === undefined) { 67 | new Notice('weread front matter invalid'); 68 | throw Error('weread front matter invalid'); 69 | } 70 | delete existFrontMatter['position']; 71 | } 72 | 73 | const idx = markdownContent.indexOf('---'); 74 | let templateFrontMatter = Object(); 75 | if (idx !== -1) { 76 | const startInd = markdownContent.indexOf('---') + 4; 77 | const endInd = markdownContent.substring(startInd).indexOf('---') - 1; 78 | const templateYmlRaw = markdownContent.substring(startInd, startInd + endInd); 79 | templateFrontMatter = parseYaml(templateYmlRaw); 80 | const freshMarkdownContent = markdownContent.substring(endInd + 4); 81 | const freshFrontMatter = { ...existFrontMatter, ...frontMatter, ...templateFrontMatter }; 82 | const frontMatterStr = stringifyYaml(freshFrontMatter); 83 | return '---\n' + frontMatterStr + freshMarkdownContent; 84 | } 85 | 86 | const frontMatterStr = stringifyYaml(frontMatter); 87 | return '---\n' + frontMatterStr + '---\n' + markdownContent; 88 | }; 89 | -------------------------------------------------------------------------------- /docs/template-editor-window.md: -------------------------------------------------------------------------------- 1 | # 原生窗口模板编辑器 2 | 3 | ## 概述 4 | 5 | 新的模板编辑器使用 Electron 的 `BrowserWindow` 创建了一个真正的原生窗口,提供类似 macOS 原生应用的体验。 6 | 7 | ## 功能特性 8 | 9 | ### 三栏布局 10 | 11 | 1. **左侧:模板说明文档** (300px,可调整 250-500px) 12 | - 显示完整的 Nunjucks 模板语法说明 13 | - 包含可用变量、过滤器和示例代码 14 | - 支持拖动调整宽度 15 | 16 | 2. **中间:模板编辑器** (自适应宽度) 17 | - 语法高亮的代码编辑器 18 | - 支持 Nunjucks 模板语法 19 | - 实时语法验证 20 | 21 | 3. **右侧:实时预览** (自适应宽度) 22 | - 300ms 防抖的实时渲染 23 | - 使用示例数据预览效果 24 | - 错误提示显示 25 | 26 | ### 窗口功能 27 | 28 | - **原生窗口体验**:使用 Electron BrowserWindow,提供完整的原生窗口功能 29 | - **可拖动标题栏**:通过标题栏拖动整个窗口 30 | - **最小尺寸**:1200×600px (初始尺寸:1600×900px) 31 | - **响应式布局**:自动适应窗口大小 32 | - **深色主题**:专为深色模式优化的 UI 33 | 34 | ## 技术实现 35 | 36 | ### 组件结构 37 | 38 | ``` 39 | templateEditorWindow.ts 40 | ├── Constructor 41 | │ ├── 创建 BrowserWindow 42 | │ ├── 设置窗口参数 43 | │ └── 初始化 Renderer 44 | ├── loadContent() 45 | │ └── 加载 HTML 内容 46 | ├── generateHTML() 47 | │ ├── 生成完整的 HTML/CSS/JS 48 | │ ├── 嵌入模板说明文档 49 | │ └── 设置事件监听器 50 | ├── open() 51 | │ ├── 设置 IPC 通信 52 | │ ├── update-preview: 更新预览 53 | │ ├── save-template: 保存模板 54 | │ └── close-window: 关闭窗口 55 | └── buildSampleNotebook() 56 | └── 生成示例数据 57 | ``` 58 | 59 | ### IPC 通信 60 | 61 | **渲染进程 → 主进程** 62 | - `update-preview`: 发送模板字符串以更新预览 63 | - `save-template`: 保存模板 64 | - `close-window`: 关闭窗口 65 | 66 | **主进程 → 渲染进程** 67 | - `preview-updated`: 返回渲染结果或错误信息 68 | 69 | ### 样式设计 70 | 71 | - **字体**:SF Mono, Monaco, Cascadia Code (代码编辑器) 72 | - **配色**:基于 VS Code 深色主题 73 | - **布局**:Flexbox 响应式布局 74 | - **交互**:平滑过渡动画和悬停效果 75 | 76 | ## 使用方式 77 | 78 | ### 在设置页面 79 | 80 | ```typescript 81 | // settingTab.ts 82 | private template(): void { 83 | const descFragment = document.createRange() 84 | .createContextualFragment(templateInstructions); 85 | 86 | new Setting(this.containerEl) 87 | .setName('笔记模板') 88 | .setDesc(descFragment) 89 | .addButton((button) => { 90 | return button 91 | .setButtonText('编辑模板') 92 | .setCta() 93 | .onClick(() => { 94 | const editorWindow = new TemplateEditorWindow( 95 | get(settingsStore).template, 96 | (newTemplate: string) => { 97 | settingsStore.actions.setTemplate(newTemplate); 98 | } 99 | ); 100 | editorWindow.open(); 101 | }); 102 | }); 103 | } 104 | ``` 105 | 106 | ### 编程调用 107 | 108 | ```typescript 109 | import { TemplateEditorWindow } from './components/templateEditorWindow'; 110 | 111 | // 创建编辑器窗口 112 | const editor = new TemplateEditorWindow( 113 | currentTemplate, // 当前模板字符串 114 | (newTemplate: string) => { // 保存回调 115 | // 处理保存的模板 116 | console.log('新模板:', newTemplate); 117 | } 118 | ); 119 | 120 | // 打开窗口 121 | editor.open(); 122 | ``` 123 | 124 | ## 与之前实现的对比 125 | 126 | | 特性 | 之前 (Modal) | 现在 (BrowserWindow) | 127 | |------|-------------|---------------------| 128 | | 窗口类型 | Obsidian Modal | Electron 原生窗口 | 129 | | 布局 | 两栏(编辑器+预览) | 三栏(说明+编辑器+预览) | 130 | | 大小调整 | 8方向拖拽调整 | 原生窗口缩放 | 131 | | 窗口拖动 | 自定义拖拽实现 | 原生标题栏拖拽 | 132 | | 最大化 | 自定义按钮 | 原生最大化按钮 | 133 | | 最小尺寸 | 600×400px | 1200×600px | 134 | | 初始尺寸 | 95vw×90vh | 1600×900px | 135 | | 说明文档 | 在设置页面 | 集成在窗口左侧 | 136 | | 主题适配 | CSS 变量 | 独立样式 | 137 | 138 | ## 优势 139 | 140 | 1. **真正的原生体验**:使用系统原生窗口,符合用户习惯 141 | 2. **三栏布局**:说明、编辑、预览一目了然 142 | 3. **更大的工作空间**:1600×900px 的初始尺寸 143 | 4. **原生窗口管理**:支持系统级的窗口操作(最小化、最大化、关闭) 144 | 5. **可调整的说明面板**:根据需要调整说明文档的宽度 145 | 6. **专业的编辑体验**:类似 IDE 的界面设计 146 | 147 | ## 注意事项 148 | 149 | 1. **仅支持桌面端**:BrowserWindow 仅在 Electron 环境中可用 150 | 2. **内存管理**:确保窗口关闭时清理 IPC 监听器 151 | 3. **调试**:可以使用 Electron DevTools 调试窗口内容 152 | 4. **性能**:使用 300ms 防抖优化实时预览性能 153 | 154 | ## 未来改进 155 | 156 | - [ ] 添加快捷键支持 (Cmd+S 保存等) 157 | - [ ] 支持多标签页编辑多个模板 158 | - [ ] 添加模板历史记录 159 | - [ ] 支持模板导入导出 160 | - [ ] 添加语法高亮和自动补全 161 | - [ ] 支持实时预览滚动同步 162 | 163 | --- 164 | 165 | **最后更新**: 2025-11-22 166 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import * as nunjucks from 'nunjucks'; 2 | import type { Notebook, RenderTemplate } from './models'; 3 | import { settingsStore } from './settings'; 4 | import { get } from 'svelte/store'; 5 | export class Renderer { 6 | constructor() { 7 | nunjucks 8 | .configure({ autoescape: false }) 9 | // 自定义函数 https://mozilla.github.io/nunjucks/api.html#addfilter 10 | .addFilter('replace', function (str, pattern, replacement) { 11 | if (!str) return ''; 12 | 13 | if (typeof pattern === 'string') { 14 | try { 15 | // 如果 pattern 以 /.../ 开头和结尾,解析为正则表达式 16 | if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) { 17 | const regexBody = pattern.slice(1, pattern.lastIndexOf('/')); 18 | const flags = pattern.slice(pattern.lastIndexOf('/') + 1); 19 | pattern = new RegExp(regexBody, flags); 20 | } else { 21 | return str.replaceAll(pattern, replacement); 22 | } 23 | } catch (e) { 24 | // 如果正则表达式无效,回退到字符串替换 25 | return String(str).replaceAll(pattern, replacement); 26 | } 27 | } else if (pattern instanceof RegExp) { 28 | return String(str).replace(pattern, replacement); 29 | } 30 | return String(str).replaceAll(pattern, replacement); 31 | }); 32 | } 33 | 34 | validate(template: string): boolean { 35 | try { 36 | nunjucks.renderString(template, {}); 37 | return true; 38 | } catch (error) { 39 | console.error('validate weread template error,please check', error); 40 | return false; 41 | } 42 | } 43 | 44 | render(entry: Notebook): string { 45 | const { metaData, chapterHighlights, bookReview } = entry; 46 | 47 | const context: RenderTemplate = { 48 | metaData, 49 | chapterHighlights, 50 | bookReview 51 | }; 52 | const settings = get(settingsStore); 53 | const template = settings.template; 54 | const trimBlocks = settings.trimBlocks; 55 | 56 | // 如果启用了 trimBlocks,使用配置的环境 57 | if (trimBlocks) { 58 | const env = new nunjucks.Environment(null, { 59 | autoescape: false, 60 | trimBlocks: true, 61 | lstripBlocks: true 62 | }); 63 | 64 | // 添加自定义过滤器 65 | env.addFilter('replace', function (str, pattern, replacement) { 66 | if (!str) return ''; 67 | 68 | if (typeof pattern === 'string') { 69 | try { 70 | if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) { 71 | const regexBody = pattern.slice(1, pattern.lastIndexOf('/')); 72 | const flags = pattern.slice(pattern.lastIndexOf('/') + 1); 73 | pattern = new RegExp(regexBody, flags); 74 | } else { 75 | return str.replaceAll(pattern, replacement); 76 | } 77 | } catch (e) { 78 | return String(str).replaceAll(pattern, replacement); 79 | } 80 | } else if (pattern instanceof RegExp) { 81 | return String(str).replace(pattern, replacement); 82 | } 83 | return String(str).replaceAll(pattern, replacement); 84 | }); 85 | 86 | const content = env.renderString(template, context); 87 | return content; 88 | } else { 89 | // 使用默认配置(不去空白) 90 | const content = nunjucks.renderString(template, context); 91 | return content; 92 | } 93 | } 94 | 95 | /** 96 | * Render a notebook with a custom template string (without using global settings) 97 | * @param templateStr - The template string to use for rendering 98 | * @param entry - The notebook data to render 99 | * @param trimBlocks - Whether to automatically remove newlines after template tags 100 | * @returns The rendered content 101 | */ 102 | renderWithTemplate(templateStr: string, entry: Notebook, trimBlocks = false): string { 103 | const { metaData, chapterHighlights, bookReview } = entry; 104 | 105 | const context: RenderTemplate = { 106 | metaData, 107 | chapterHighlights, 108 | bookReview 109 | }; 110 | 111 | // 创建临时环境以支持 trimBlocks 配置 112 | const env = new nunjucks.Environment(null, { 113 | autoescape: false, 114 | trimBlocks: trimBlocks, 115 | lstripBlocks: trimBlocks 116 | }); 117 | 118 | // 添加自定义过滤器 119 | env.addFilter('replace', function (str, pattern, replacement) { 120 | if (!str) return ''; 121 | 122 | if (typeof pattern === 'string') { 123 | try { 124 | // 如果 pattern 以 /.../ 开头和结尾,解析为正则表达式 125 | if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) { 126 | const regexBody = pattern.slice(1, pattern.lastIndexOf('/')); 127 | const flags = pattern.slice(pattern.lastIndexOf('/') + 1); 128 | pattern = new RegExp(regexBody, flags); 129 | } else { 130 | return str.replaceAll(pattern, replacement); 131 | } 132 | } catch (e) { 133 | // 如果正则表达式无效,回退到字符串替换 134 | return String(str).replaceAll(pattern, replacement); 135 | } 136 | } else if (pattern instanceof RegExp) { 137 | return String(str).replace(pattern, replacement); 138 | } 139 | return String(str).replaceAll(pattern, replacement); 140 | }); 141 | 142 | const content = env.renderString(templateStr, context); 143 | return content; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Weread Plugin 2 | 3 | [![](https://github.com/zhaohongxuan/obsidian-weread-plugin/actions/workflows/CI.yml/badge.svg)](https://github.com/zhaohongxuan/obsidian-weread-plugin/actions/workflows/CI.yml) 4 | [![Release Obsidian plugin](https://github.com/zhaohongxuan/obsidian-weread-plugin/actions/workflows/release.yml/badge.svg)](https://github.com/zhaohongxuan/obsidian-weread-plugin/actions/workflows/release.yml) 5 | [![GitHub license](https://badgen.net/github/license/Naereen/Strapdown.js)](https://github.com/zhaohongxuan/obsidian-weread-plugin/blob/main/LICENSE) 6 | [![Github all releases](https://img.shields.io/github/downloads/zhaohongxuan/obsidian-weread-plugin/total.svg)](https://GitHub.com/zhaohongxuan/obsidian-weread-plugin/releases/) 7 | [![GitLab latest release](https://badgen.net/github/release/zhaohongxuan/obsidian-weread-plugin/)](https://github.com/zhaohongxuan/obsidian-weread-plugin/releases) 8 | 9 | 10 | 11 | Obsidian微信读书插件是一个社区插件,用来同步微信读书中书籍`元信息`、`高亮标注`,`划线感想`、`书评`等,并将这些信息转换为markdown格式保存到Obsidian的文件夹中,初次使用,如果笔记数量较多,更新会比较慢,后面再去更新的时候只会更新`划线数量`或者`笔记数量`有变化的书籍,一般速度很快。 12 | 13 | ## 更新历史 14 | https://github.com/zhaohongxuan/obsidian-weread-plugin/releases 15 | 16 | ## 功能 17 | - 同步书籍元数据例如:书籍封面,作者、出版社、ISBN,出版时间等 18 | - 同步微信读书的高亮划线 19 | - 读书笔记分为`划线笔记`,`页面笔记`, `章节笔记`,`书籍书评` 20 | - 支持微信扫码登录,理论上可以和浏览器一样保持长时间不掉线。 21 | - 校验Cookie有效期自动刷新Cookie 22 | - 自定义笔记生成模板 template 23 | - 文件名支持多种格式设置 24 | - 自定义FrontMatter,可在头部yaml文件中增加自己需要的字段,比如标签,阅读状态等 25 | - 公众号划线和笔记归类同步 26 | - 支持移动端同步,可以在手机和平板上使用本插件 27 | - 支持Daily Notes,将当日读书笔记同步至Daily Notes中,已经在[0.4.0](https://github.com/zhaohongxuan/obsidian-weread-plugin/releases/tag/0.4.0)中支持 28 | - 同步热门划线到笔记中(TBD) 29 | 30 | ## 安装方法 31 | 插件市场直接搜索`weread`,找到`Weread Plugin`点击`install`安装,安装完成后点击`Enable`使插件启用,也可以直接在[release](https://github.com/zhaohongxuan/obsidian-weread-plugin/releases)页面手动下载。 32 | ## 设置 33 | 1. 打开Obsidian点击`设置`进入设置界面,找到`Obsidian Weread Plugin`进入到插件设置页面 34 | 2. 点击右侧`登录`按钮,在弹出的登录页面扫码登录,登录完成后,会显示个人昵称 35 | 3. 注销登录可以清除Obsidian插件的Cookie信息,注销方法,和网页版微信读书一样,右上角点击头像,点击退出登录 36 | 4. 设置笔记保存位置,笔记最小划线数量,笔记文件夹分类 image 37 | 38 | 39 | 40 | ## 使用 41 | 42 | ⚠️ 本插件是覆盖式更新,请不要在同步的文件里修改内容,写`永久笔记`(为什么写永久笔记参考[《卡片笔记写作法》](https://book.douban.com/subject/35503571/))的时候可以使用[Block引用](https://help.obsidian.md/How+to/Link+to+blocks) 的方式,在外部引用进行批注。 43 | 44 |
45 | 46 | 基础使用 47 | 48 | 1. 点击左侧Ribbon上的微信读书按钮,或者command+P(windows ctrl+P)调出Command Pattle 输入Weread 找到`Sync Weread command`即可同步。 49 | ![sync|50](https://cdn.jsdelivr.net/gh/zhaohongxuan/picgo@master/20220522222015.png) 50 | 2. 默认模板效果(theme:minimal) ![](https://cdn.jsdelivr.net/gh/zhaohongxuan/picgo@master/20220522221449.png) 51 | 使用dataview+minimal cards的显示效果,[参考这里](https://github.com/zhaohongxuan/obsidian-weread-plugin/wiki/%E4%BD%BF%E7%94%A8Dataview%E8%BF%9B%E8%A1%8C%E4%B9%A6%E7%B1%8D%E7%AE%A1%E7%90%86): 52 | ![](https://cdn.jsdelivr.net/gh/zhaohongxuan/picgo@master/20220529135016.png) 53 |
54 | 55 |
56 | 同步笔记到Daily Notes 57 | 58 | 1. 在设置中打开同步到Daily Notes的开关,然后分别设置Daily Notes的目录以及文件格式 59 | 2. 如果Daily Note是Periodic Notes管理的,可以改成Periodic Notes的格式,比如我使用的格式`YYYY/[W]ww/YYYY-MM-DD`,就会按照 年/周/日的维度在文件夹中寻找Daily Notes. 60 | 3. 设置在Daily Notes的特定的区间插入,可以修改默认值为你想要的markdown格式的内容,比如在`某两个标题`之间插入,注意📢,区间内的内容是会被覆盖的,不要在区间内修改文本。 61 | ![](https://user-images.githubusercontent.com/8613196/179385400-d556527f-8d73-4ca7-b348-62810df96fe2.png) 62 |
63 | 64 | ## 已知问题 65 | - 长期不使用本插件Cookie可能会失效,需要重新登录。 66 | - 偶尔可能会有网络连接问题,重新点击同步即可,已同步的笔记不会再次更新。 67 | 68 | ## TODO 69 | - [x] 解决Obsidian中CORS问题 70 | - [x] 设置界面笔记保存路径 71 | - [x] 优化文件同步逻辑,不需要每次都删除重建,可以根据Note的数量来判断 72 | - [x] 被动刷新Cookie延长有效期 73 | - [x] 多处登录导致Cookie失效Fix 74 | - [x] 弹出扫码框登录自动获取Cookie 75 | - [x] 书名重复导致同步失败 76 | - [x] 设置页面支持设置Template格式 77 | - [x] 文件名模板 78 | - [x] 移动端适配 79 | - [x] 阅读状态元数据,比如阅读中,阅读完成等等,以及阅读时间的分布等 80 | - [x] 按照章节Index进行排序 81 | - [x] 保留多个章节层级 82 | - [x] 同步微信公众号文章 83 | - [ ] 模板预览功能 84 | - [ ] 导出热门划线 https://github.com/zhaohongxuan/obsidian-weread-plugin/issues/42 85 | - [ ] 设置页面,目录选择优化 https://github.com/zhaohongxuan/obsidian-weread-plugin/issues/39 86 | 87 | 88 | ## Weread API 89 | [Weread API](./docs/weread-api.md) 90 | 91 | ## 赞赏 92 | 93 | 94 | 95 | ## 免责声明 96 | 本程序没有爬取任何书籍内容,只提供登录用户的图书以及笔记信息,没有侵犯书籍作者版权和微信读书官方利益。 97 | ## 感谢 98 | - [wereader](https://github.com/arry-lee/wereader) 99 | - [Kindle Plugin](https://github.com/hadynz/obsidian-kindle-plugin) 100 | - [Hypothesis Plugin](https://github.com/weichenw/obsidian-hypothesis-plugin) 101 | - [Obsidian Plugin Developer Docs](https://marcus.se.net/obsidian-plugin-docs/) 102 | - [http proxy middleware](https://github.com/chimurai/http-proxy-middleware) 103 | - [nunjucks](https://github.com/mozilla/nunjucks) 104 | 105 | ## Supported By 106 | JetBrains Logo (Main) logo. 107 | -------------------------------------------------------------------------------- /docs/weread-api.md: -------------------------------------------------------------------------------- 1 | 2 | # weread 3 | 4 | ## Endpoints 5 | 6 | - [weread](#weread) 7 | - [Endpoints](#endpoints) 8 | - [Weread API](#weread-api) 9 | - [1. Weread 获取书籍的热门划线](#1-weread-获取书籍的热门划线) 10 | - [2. Weread 获取书籍详情](#2-weread-获取书籍详情) 11 | - [3. Weread 获取书籍个人想法](#3-weread-获取书籍个人想法) 12 | - [4. Weread 获取书籍划线](#4-weread-获取书籍划线) 13 | - [5. Weread 获取用户的Notebook](#5-weread-获取用户的notebook) 14 | 15 | -------- 16 | 17 | ## Weread API 18 | 19 | ### 1. Weread 获取书籍的热门划线 20 | 21 | ***Endpoint:*** 22 | 23 | ```bash 24 | Method: GET 25 | Type: 26 | URL: https://i.weread.qq.com/book/bestbookmarks 27 | ``` 28 | 29 | 30 | ***Headers:*** 31 | 32 | | Key | Value | Description | 33 | | --- | ------|-------------| 34 | | Host | i.weread.qq.com | | 35 | | Connection | keep-alive | | 36 | | Upgrade-Insecure-Requests | 1 | | 37 | | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 | | 38 | | Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 | | 39 | | Accept-Encoding | gzip, deflate, br | | 40 | | Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | | 41 | 42 | 43 | 44 | ***Query params:*** 45 | 46 | | Key | Value | Description | 47 | | --- | ------|-------------| 48 | | bookId | 26785321 | | 49 | 50 | 51 | 52 | ### 2. Weread 获取书籍详情 53 | 54 | 55 | 56 | ***Endpoint:*** 57 | 58 | ```bash 59 | Method: GET 60 | Type: 61 | URL: https://i.weread.qq.com/book/info 62 | ``` 63 | 64 | 65 | ***Headers:*** 66 | 67 | | Key | Value | Description | 68 | | --- | ------|-------------| 69 | | Host | i.weread.qq.com | | 70 | | Connection | keep-alive | | 71 | | Upgrade-Insecure-Requests | 1 | | 72 | | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 | | 73 | | Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 | | 74 | | Accept-Encoding | gzip, deflate, br | | 75 | | Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | | 76 | 77 | 78 | 79 | ***Query params:*** 80 | 81 | | Key | Value | Description | 82 | | --- | ------|-------------| 83 | | bookId | 26785321 | | 84 | 85 | 86 | 87 | ### 3. Weread 获取书籍个人想法 88 | 89 | 90 | 91 | ***Endpoint:*** 92 | 93 | ```bash 94 | Method: GET 95 | Type: 96 | URL: https://i.weread.qq.com/review/list 97 | ``` 98 | 99 | 100 | ***Headers:*** 101 | 102 | | Key | Value | Description | 103 | | --- | ------|-------------| 104 | | Host | i.weread.qq.com | | 105 | | Connection | keep-alive | | 106 | | Upgrade-Insecure-Requests | 1 | | 107 | | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 | | 108 | | Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 | | 109 | | Accept-Encoding | gzip, deflate, br | | 110 | | Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | | 111 | 112 | 113 | 114 | ***Query params:*** 115 | 116 | | Key | Value | Description | 117 | | --- | ------|-------------| 118 | | bookId | 26785321 | | 119 | | listType | 11 | | 120 | | mine | 1 | | 121 | | synckey | 0 | | 122 | | listMode | | | 123 | 124 | 125 | 126 | ### 4. Weread 获取书籍划线 127 | 128 | 129 | 130 | ***Endpoint:*** 131 | 132 | ```bash 133 | Method: GET 134 | Type: 135 | URL: https://i.weread.qq.com/shelf/sync 136 | ``` 137 | 138 | 139 | ***Headers:*** 140 | 141 | | Key | Value | Description | 142 | | --- | ------|-------------| 143 | | Host | i.weread.qq.com | | 144 | | Connection | keep-alive | | 145 | | Upgrade-Insecure-Requests | 1 | | 146 | | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 | | 147 | | Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 | | 148 | | Accept-Encoding | gzip, deflate, br | | 149 | | Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | | 150 | 151 | 152 | 153 | ***Query params:*** 154 | 155 | | Key | Value | Description | 156 | | --- | ------|-------------| 157 | | userVid | 15707910 | | 158 | | synckey | 0 | | 159 | | lectureSynckey | 0 | | 160 | 161 | 162 | 163 | ### 5. Weread 获取用户的Notebook 164 | 165 | 166 | 167 | ***Endpoint:*** 168 | 169 | ```bash 170 | Method: GET 171 | Type: 172 | URL: https://i.weread.qq.com/user/notebooks 173 | ``` 174 | 175 | 176 | ***Headers:*** 177 | 178 | | Key | Value | Description | 179 | | --- | ------|-------------| 180 | | Host | i.weread.qq.com | | 181 | | Connection | keep-alive | | 182 | | Upgrade-Insecure-Requests | 1 | | 183 | | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 | | 184 | | Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 | | 185 | | Accept-Encoding | gzip, deflate, br | | 186 | | Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | | 187 | 188 | 189 | 190 | --- 191 | [Back to top](#weread) 192 | 193 | >Generated at 2022-05-13 07:58:06 by [docgen](https://github.com/thedevsaddam/docgen) 194 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Notice, Platform, Plugin, WorkspaceLeaf } from 'obsidian'; 2 | import FileManager from './src/fileManager'; 3 | import SyncNotebooks from './src/syncNotebooks'; 4 | import ApiManager from './src/api'; 5 | import { settingsStore } from './src/settings'; 6 | import { WereadSettingsTab } from './src/settingTab'; 7 | import { WEREAD_BROWSER_VIEW_ID, WereadReadingView } from './src/components/wereadReading'; 8 | import './style.css'; 9 | export default class WereadPlugin extends Plugin { 10 | private syncNotebooks: SyncNotebooks; 11 | private syncing = false; 12 | 13 | async onload() { 14 | console.log('load weread plugin'); 15 | settingsStore.initialise(this); 16 | 17 | const fileManager = new FileManager(this.app.vault, this.app.metadataCache); 18 | const apiManager = new ApiManager(); 19 | this.syncNotebooks = new SyncNotebooks(fileManager, apiManager); 20 | 21 | const ribbonEl = this.addRibbonIcon('book-open', '同步微信读书笔记', (event) => { 22 | if (event.button === 0) { 23 | this.startSync(); 24 | } 25 | }); 26 | 27 | ribbonEl.addEventListener('contextmenu', (event: MouseEvent) => { 28 | event.preventDefault(); 29 | event.stopPropagation(); // 阻止事件传播 30 | 31 | const preventDefaultMouseDown = (mouseDownEvent: MouseEvent) => { 32 | mouseDownEvent.preventDefault(); 33 | }; 34 | 35 | // 额外阻止mousedown事件的默认行为 36 | window.addEventListener('mousedown', preventDefaultMouseDown); 37 | 38 | const menu = new Menu(); 39 | menu.addItem((item) => 40 | item 41 | .setTitle('同步微信读书笔记') 42 | .setIcon('refresh-ccw') 43 | .onClick(() => { 44 | this.startSync(); 45 | }) 46 | ); 47 | 48 | menu.addItem((item) => 49 | item 50 | .setTitle('强制同步微信读书笔记') 51 | .setIcon('refresh-ccw-dot') 52 | .onClick(() => { 53 | this.startSync(true); 54 | }) 55 | ); 56 | 57 | menu.addItem((item) => 58 | item 59 | .setTitle('在新标签页打开微信读书') 60 | .setIcon('book-open-text') 61 | .onClick(() => { 62 | this.activateReadingView('TAB'); 63 | }) 64 | ); 65 | 66 | menu.addItem((item) => 67 | item 68 | .setTitle('在窗口打开微信读书') 69 | .setIcon('app-window') 70 | .onClick(() => { 71 | this.activateReadingView('WINDOW'); 72 | }) 73 | ); 74 | 75 | menu.showAtMouseEvent(event); 76 | menu.onHide(() => { 77 | window.removeEventListener('mousedown', preventDefaultMouseDown); 78 | }); 79 | }); 80 | 81 | this.addCommand({ 82 | id: 'sync-weread-notes-command', 83 | name: '同步微信读书笔记', 84 | callback: () => { 85 | this.startSync(); 86 | } 87 | }); 88 | 89 | this.addCommand({ 90 | id: 'Force-sync-weread-notes-command', 91 | name: '强制同步微信读书笔记', 92 | callback: () => { 93 | this.startSync(true); 94 | } 95 | }); 96 | 97 | this.registerView(WEREAD_BROWSER_VIEW_ID, (leaf) => new WereadReadingView(leaf)); 98 | 99 | this.addCommand({ 100 | id: 'open-weread-reading-view-tab', 101 | name: '在新标签页打开微信读书', 102 | callback: () => { 103 | this.activateReadingView('TAB'); 104 | } 105 | }); 106 | 107 | this.addCommand({ 108 | id: 'open-weread-reading-view-window', 109 | name: '在新窗口打开微信读书', 110 | callback: () => { 111 | this.activateReadingView('WINDOW'); 112 | } 113 | }); 114 | 115 | this.registerEvent( 116 | this.app.workspace.on('editor-menu', (menu, editor, view) => { 117 | const noteFile = fileManager.getWereadNoteAnnotationFile(view.file); 118 | if (noteFile == null) { 119 | return; 120 | } 121 | 122 | menu.addSeparator(); 123 | menu.addItem((item) => 124 | item 125 | .setIcon('refresh-ccw') 126 | .setTitle('同步当前读书笔记') 127 | .onClick(() => { 128 | this.syncNotebooks.syncNotebook(noteFile); 129 | }) 130 | ); 131 | }) 132 | ); 133 | 134 | this.addSettingTab(new WereadSettingsTab(this.app, this)); 135 | } 136 | 137 | async startSync(force = false) { 138 | if (this.syncing) { 139 | new Notice('正在同步微信读书笔记,请勿重复点击'); 140 | return; 141 | } 142 | this.syncing = true; 143 | try { 144 | await this.syncNotebooks.syncNotebooks(force, window.moment().format('YYYY-MM-DD')); 145 | console.log('syncing Weread note finish'); 146 | } catch (e) { 147 | if (Platform.isDesktopApp) { 148 | new Notice('同步微信读书笔记异常,请打开控制台查看详情'); 149 | } else { 150 | new Notice('同步微信读书笔记异常,请使用电脑端打开控制台查看详情' + e); 151 | } 152 | console.error('同步微信读书笔记异常', e); 153 | } finally { 154 | this.syncing = false; 155 | } 156 | } 157 | 158 | async activateReadingView(type: string) { 159 | const { workspace } = this.app; 160 | 161 | let leaf: WorkspaceLeaf | null = null; 162 | const leaves = workspace.getLeavesOfType(WEREAD_BROWSER_VIEW_ID); 163 | 164 | if (leaves.length > 0) { 165 | // A leaf with our view already exists, use that 166 | leaf = leaves[0]; 167 | } else { 168 | if (type === 'TAB') { 169 | leaf = workspace.getLeaf('split', 'vertical'); 170 | } else if (type === 'WINDOW') { 171 | leaf = workspace.openPopoutLeaf(); 172 | } 173 | await leaf.setViewState({ type: WEREAD_BROWSER_VIEW_ID, active: true }); 174 | } 175 | 176 | // "Reveal" the leaf in case it is in a collapsed sidebar 177 | workspace.revealLeaf(leaf); 178 | } 179 | onunload() { 180 | console.log('unloading weread plugin', new Date().toLocaleString()); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/syncNotebooks.ts: -------------------------------------------------------------------------------- 1 | import ApiManager from './api'; 2 | import FileManager from './fileManager'; 3 | import { Metadata, Notebook, AnnotationFile, BookProgressResponse } from './models'; 4 | import { 5 | parseHighlights, 6 | parseMetadata, 7 | parseChapterHighlightReview, 8 | parseChapterReviews, 9 | parseDailyNoteReferences, 10 | parseReviews, 11 | parseChapterResp, 12 | parseArticleHighlightReview 13 | } from './parser/parseResponse'; 14 | import { settingsStore } from './settings'; 15 | import { get } from 'svelte/store'; 16 | import { Notice } from 'obsidian'; 17 | export default class SyncNotebooks { 18 | private fileManager: FileManager; 19 | private apiManager: ApiManager; 20 | 21 | constructor(fileManager: FileManager, apiManeger: ApiManager) { 22 | this.fileManager = fileManager; 23 | this.apiManager = apiManeger; 24 | } 25 | 26 | async syncNotebook(noteFile: AnnotationFile) { 27 | const metaDataArr: Metadata[] = await this.getALlMetadata(); 28 | const currentBookMeta = metaDataArr.find((metaData) => metaData.bookId === noteFile.bookId); 29 | noteFile.new = true; 30 | currentBookMeta.file = noteFile; 31 | if (currentBookMeta) { 32 | const notebook = await this.convertToNotebook(currentBookMeta); 33 | await this.saveNotebook(notebook); 34 | new Notice(`当前笔记 《${currentBookMeta.title}》 同步成功!`); 35 | } else { 36 | new Notice(`当前笔记元数据缺少,同步失败!`); 37 | } 38 | } 39 | async syncNotebooks(force = false, journalDate: string) { 40 | new Notice('微信读书笔记同步开始!'); 41 | const syncStartTime = new Date().getTime(); 42 | const metaDataArr = await this.getALlMetadata(); 43 | const filterMetaArr = await this.filterNoteMetas(force, metaDataArr); 44 | let syncedNotebooks = 0; 45 | const progressNotice = new Notice('微信读书笔记同步中, 请稍后!', 300000); 46 | 47 | try { 48 | for (const meta of filterMetaArr) { 49 | const notebook = await this.convertToNotebook(meta); 50 | await this.saveNotebook(notebook); 51 | syncedNotebooks++; 52 | if (syncedNotebooks % 10 === 0 || syncedNotebooks === filterMetaArr.length) { 53 | const progress = (syncedNotebooks / filterMetaArr.length) * 100; 54 | progressNotice.setMessage( 55 | `微信读书笔记同步中, 请稍后!正在更新 ${ 56 | filterMetaArr.length 57 | } 本书 ,更新进度 ${progress.toFixed(0)}%` 58 | ); 59 | } 60 | } 61 | } finally { 62 | progressNotice.hide(); 63 | } 64 | this.saveToJounal(journalDate, metaDataArr); 65 | const syncEndTime = new Date().getTime(); 66 | const syncTimeInMilliseconds = syncEndTime - syncStartTime; 67 | const syncTimeInSeconds = (syncTimeInMilliseconds / 1000).toFixed(2); 68 | 69 | new Notice( 70 | `微信读书笔记同步完成!, 总共 ${metaDataArr.length} 本书 , 本次更新 ${filterMetaArr.length} 本书, 耗时${syncTimeInSeconds} 秒` 71 | ); 72 | } 73 | 74 | public async syncNotesToJounal(journalDate: string) { 75 | const metaDataArr = await this.getALlMetadata(); 76 | this.saveToJounal(journalDate, metaDataArr); 77 | } 78 | 79 | private async convertToNotebook(metaData: Metadata): Promise { 80 | const bookDetail = await this.apiManager.getBook(metaData.bookId); 81 | if (bookDetail) { 82 | metaData.category = bookDetail.category; 83 | metaData.publisher = bookDetail.publisher; 84 | metaData.isbn = bookDetail.isbn; 85 | metaData.intro = bookDetail.intro; 86 | metaData.totalWords = bookDetail.totalWords; 87 | metaData.rating = `${bookDetail.newRating / 10}%`; 88 | } 89 | const progress: BookProgressResponse = await this.apiManager.getProgress(metaData.bookId); 90 | if (progress && progress.book) { 91 | metaData.readInfo = { 92 | readingProgress: progress.book.progress, 93 | readingTime: progress.book.readingTime, 94 | readingBookDate: progress.book.startReadingTime, 95 | finishedDate: progress.book.finishTime 96 | }; 97 | } 98 | 99 | const highlightResp = await this.apiManager.getNotebookHighlights(metaData.bookId); 100 | const reviewResp = await this.apiManager.getNotebookReviews(metaData.bookId); 101 | const chapterResp = await this.apiManager.getChapters(metaData.bookId); 102 | const highlights = parseHighlights(highlightResp, reviewResp); 103 | const reviews = parseReviews(reviewResp); 104 | const chapters = parseChapterResp(chapterResp, highlightResp); 105 | let chapterHighlightReview; 106 | if (metaData.bookType === 3) { 107 | //公众号文章 108 | console.log('sync 公众号:', metaData.title); 109 | chapterHighlightReview = parseArticleHighlightReview(chapters, highlights, reviews); 110 | console.log('sync 公众号 result', metaData.title, chapterHighlightReview); 111 | } else { 112 | chapterHighlightReview = parseChapterHighlightReview(chapters, highlights, reviews); 113 | } 114 | const bookReview = parseChapterReviews(reviewResp); 115 | return { 116 | metaData: metaData, 117 | chapterHighlights: chapterHighlightReview, 118 | bookReview: bookReview 119 | }; 120 | } 121 | 122 | private async filterNoteMetas(force = false, metaDataArr: Metadata[]): Promise { 123 | const localFiles: AnnotationFile[] = await this.fileManager.getNotebookFiles(); 124 | const duplicateBookSet = this.getDuplicateBooks(metaDataArr); 125 | const filterMetaArr: Metadata[] = []; 126 | for (const metaData of metaDataArr) { 127 | // skip 公众号 128 | const saveArticle = get(settingsStore).saveArticleToggle; 129 | if (!saveArticle && metaData.bookType === 3) { 130 | continue; 131 | } 132 | if (metaData.noteCount < +get(settingsStore).noteCountLimit) { 133 | console.info( 134 | `[weread plugin] skip book ${metaData.title} note count: ${metaData.noteCount}` 135 | ); 136 | continue; 137 | } 138 | const localNotebookFile = await this.getLocalNotebookFile(metaData, localFiles, force); 139 | if (localNotebookFile && !localNotebookFile.new) { 140 | continue; 141 | } 142 | const isNoteBlacklisted = get(settingsStore).notesBlacklist.includes(metaData.bookId); 143 | if (isNoteBlacklisted) { 144 | console.info( 145 | `[weread plugin] skip book ${metaData.title},id:${metaData.bookId} for blacklist` 146 | ); 147 | continue; 148 | } 149 | metaData.file = localNotebookFile; 150 | if (duplicateBookSet.has(metaData.title)) { 151 | metaData.duplicate = true; 152 | } 153 | filterMetaArr.push(metaData); 154 | } 155 | return filterMetaArr; 156 | } 157 | 158 | private async getALlMetadata() { 159 | const noteBookResp: [] = await this.apiManager.getNotebooksWithRetry(); 160 | const metaDataArr = noteBookResp.map((noteBook) => parseMetadata(noteBook)); 161 | return metaDataArr; 162 | } 163 | 164 | private async saveToJounal(journalDate: string, metaDataArr: Metadata[]) { 165 | const metaDataArrInDate = metaDataArr.filter((meta) => meta.lastReadDate === journalDate); 166 | 167 | const notebooksInDate = []; 168 | for (const meta of metaDataArrInDate) { 169 | const notebook = await this.convertToNotebook(meta); 170 | notebooksInDate.push(notebook); 171 | } 172 | 173 | if (get(settingsStore).dailyNotesToggle) { 174 | const dailyNoteRefereneces = parseDailyNoteReferences(notebooksInDate); 175 | const dailyNotePath = this.fileManager.getDailyNotePath(window.moment()); 176 | console.log( 177 | 'get daily note path', 178 | dailyNotePath, 179 | ' size:', 180 | dailyNoteRefereneces.length 181 | ); 182 | this.fileManager.saveDailyNotes(dailyNotePath, dailyNoteRefereneces); 183 | } 184 | } 185 | 186 | private getDuplicateBooks(metaDatas: Metadata[]): Set { 187 | const bookArr = metaDatas.map((metaData) => metaData.title); 188 | const uniqueElements = new Set(bookArr); 189 | const filteredElements = bookArr.filter((item) => { 190 | if (uniqueElements.has(item)) { 191 | uniqueElements.delete(item); 192 | } else { 193 | return item; 194 | } 195 | }); 196 | return new Set(filteredElements); 197 | } 198 | 199 | async getLocalNotebookFile( 200 | notebookMeta: Metadata, 201 | localFiles: AnnotationFile[], 202 | force = false 203 | ): Promise { 204 | const localFile = localFiles.find((file) => file.bookId === notebookMeta.bookId) || null; 205 | if (localFile) { 206 | if ( 207 | localFile.noteCount == notebookMeta.noteCount && 208 | localFile.reviewCount == notebookMeta.reviewCount && 209 | !force 210 | ) { 211 | localFile.new = false; 212 | } else { 213 | localFile.new = true; 214 | } 215 | return localFile; 216 | } 217 | return null; 218 | } 219 | 220 | private async saveNotebook(notebook: Notebook): Promise { 221 | try { 222 | await this.fileManager.saveNotebook(notebook); 223 | } catch (e) { 224 | console.log('[weread plugin] sync note book error', notebook.metaData.title, e); 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Cookie } from 'set-cookie-parser'; 2 | import { writable } from 'svelte/store'; 3 | import notebookTemolate from './assets/notebookTemplate.njk'; 4 | import WereadPlugin from '../main'; 5 | 6 | interface WereadPluginSettings { 7 | loginMethod: string; 8 | cookies: Cookie[]; 9 | noteLocation: string; 10 | dailyNotesLocation: string; 11 | dailyNotesFormat: string; 12 | insertAfter: string; 13 | insertBefore: string; 14 | lastCookieTime: number; 15 | isCookieValid: boolean; 16 | user: string; 17 | userVid: string; 18 | template: string; 19 | noteCountLimit: number; 20 | subFolderType: string; 21 | fileNameType: string; 22 | removeParens: boolean; 23 | removeParensWhitelist: string; 24 | dailyNotesToggle: boolean; 25 | notesBlacklist: string; 26 | showEmptyChapterTitleToggle: boolean; 27 | convertTags: boolean; 28 | saveArticleToggle: boolean; 29 | saveReadingInfoToggle: boolean; 30 | trimBlocks: boolean; 31 | cookieCloudInfo: { 32 | serverUrl: string; 33 | uuid: string; 34 | password: string; 35 | }; 36 | } 37 | 38 | const DEFAULT_SETTINGS: WereadPluginSettings = { 39 | loginMethod: 'scan', 40 | cookies: [], 41 | noteLocation: '/', 42 | dailyNotesLocation: '/', 43 | insertAfter: '', 44 | insertBefore: '', 45 | dailyNotesFormat: 'YYYY-MM-DD', 46 | lastCookieTime: -1, 47 | isCookieValid: false, 48 | user: '', 49 | userVid: '', 50 | template: notebookTemolate, 51 | noteCountLimit: -1, 52 | subFolderType: '-1', 53 | fileNameType: 'BOOK_NAME', 54 | removeParens: false, 55 | removeParensWhitelist: '', 56 | dailyNotesToggle: false, 57 | notesBlacklist: '', 58 | showEmptyChapterTitleToggle: false, 59 | convertTags: false, 60 | saveArticleToggle: true, 61 | saveReadingInfoToggle: true, 62 | trimBlocks: false, 63 | cookieCloudInfo: { 64 | serverUrl: '', 65 | uuid: '', 66 | password: '' 67 | } 68 | }; 69 | 70 | const createSettingsStore = () => { 71 | const store = writable(DEFAULT_SETTINGS as WereadPluginSettings); 72 | 73 | let _plugin!: WereadPlugin; 74 | 75 | const initialise = async (plugin: WereadPlugin): Promise => { 76 | const data = Object.assign({}, DEFAULT_SETTINGS, await plugin.loadData()); 77 | const settings: WereadPluginSettings = { ...data }; 78 | console.log('--------init cookie------', settings.cookies); 79 | if (settings.cookies.length > 1) { 80 | setUser(settings.cookies); 81 | } 82 | 83 | const wr_vid = settings.cookies.find((cookie) => cookie.name === 'wr_vid'); 84 | if (wr_vid === undefined || wr_vid.value === '') { 85 | settings.userVid = ''; 86 | settings.isCookieValid = false; 87 | } 88 | store.set(settings); 89 | _plugin = plugin; 90 | }; 91 | 92 | store.subscribe(async (settings) => { 93 | if (_plugin) { 94 | const data = { 95 | ...settings 96 | }; 97 | await _plugin.saveData(data); 98 | } 99 | }); 100 | 101 | const setLoginMethod = (method: string) => { 102 | store.update((settings) => { 103 | settings.loginMethod = method; 104 | return settings; 105 | }); 106 | }; 107 | 108 | const clearCookies = () => { 109 | console.log('[weread plugin] cookie已失效,清理cookie...'); 110 | store.update((state) => { 111 | state.cookies = []; 112 | state.lastCookieTime = new Date().getTime(); 113 | state.user = ''; 114 | state.userVid = ''; 115 | state.isCookieValid = false; 116 | return state; 117 | }); 118 | }; 119 | 120 | const setCookies = (cookies: Cookie[]) => { 121 | store.update((state) => { 122 | state.cookies = cookies; 123 | state.lastCookieTime = new Date().getTime(); 124 | state.isCookieValid = true; 125 | setUser(cookies); 126 | return state; 127 | }); 128 | }; 129 | 130 | const setUser = (cookies: Cookie[]) => { 131 | for (const cookie of cookies) { 132 | if (cookie.name == 'wr_name') { 133 | if (cookie.value !== '') { 134 | console.log('[weread plugin] setting user name=>', cookie.value); 135 | store.update((state) => { 136 | state.user = cookie.value; 137 | return state; 138 | }); 139 | } 140 | } 141 | if (cookie.name == 'wr_vid') { 142 | if (cookie.value !== '') { 143 | console.log('[weread plugin] setting user vid=>', cookie.value); 144 | store.update((state) => { 145 | state.userVid = cookie.value; 146 | return state; 147 | }); 148 | } 149 | } 150 | } 151 | }; 152 | 153 | const setNoteLocationFolder = (value: string) => { 154 | store.update((state) => { 155 | state.noteLocation = value; 156 | return state; 157 | }); 158 | }; 159 | const setTemplate = (template: string) => { 160 | store.update((state) => { 161 | state.template = template; 162 | return state; 163 | }); 164 | }; 165 | const setNoteCountLimit = (noteCountLimit: number) => { 166 | store.update((state) => { 167 | state.noteCountLimit = noteCountLimit; 168 | return state; 169 | }); 170 | }; 171 | 172 | const setSubFolderType = (subFolderType: string) => { 173 | store.update((state) => { 174 | state.subFolderType = subFolderType; 175 | return state; 176 | }); 177 | }; 178 | 179 | const setDailyNotesToggle = (dailyNotesToggle: boolean) => { 180 | store.update((state) => { 181 | state.dailyNotesToggle = dailyNotesToggle; 182 | return state; 183 | }); 184 | }; 185 | 186 | const setDailyNotesFolder = (value: string) => { 187 | store.update((state) => { 188 | state.dailyNotesLocation = value; 189 | return state; 190 | }); 191 | }; 192 | 193 | const setDailyNotesFormat = (value: string) => { 194 | store.update((state) => { 195 | state.dailyNotesFormat = value; 196 | return state; 197 | }); 198 | }; 199 | 200 | const setInsertAfter = (value: string) => { 201 | store.update((state) => { 202 | state.insertAfter = value; 203 | return state; 204 | }); 205 | }; 206 | 207 | const setInsertBefore = (value: string) => { 208 | store.update((state) => { 209 | state.insertBefore = value; 210 | return state; 211 | }); 212 | }; 213 | 214 | const setFileNameType = (fileNameType: string) => { 215 | store.update((state) => { 216 | state.fileNameType = fileNameType; 217 | return state; 218 | }); 219 | }; 220 | 221 | const setRemoveParens = (removeParens: boolean) => { 222 | store.update((state) => { 223 | state.removeParens = removeParens; 224 | return state; 225 | }); 226 | }; 227 | 228 | const setRemoveParensWhitelist = (whitelist: string) => { 229 | store.update((state) => { 230 | state.removeParensWhitelist = whitelist; 231 | return state; 232 | }); 233 | }; 234 | 235 | const setNoteBlacklist = (notebookBlacklist: string) => { 236 | store.update((state) => { 237 | state.notesBlacklist = notebookBlacklist; 238 | return state; 239 | }); 240 | }; 241 | 242 | const setEmptyChapterTitleToggle = (emtpyChapterTitleToggle: boolean) => { 243 | store.update((state) => { 244 | state.showEmptyChapterTitleToggle = emtpyChapterTitleToggle; 245 | return state; 246 | }); 247 | }; 248 | 249 | const setConvertTags = (convertTags: boolean) => { 250 | store.update((state) => { 251 | state.convertTags = convertTags; 252 | return state; 253 | }); 254 | }; 255 | 256 | const setSaveArticleToggle = (saveArticleToggle: boolean) => { 257 | store.update((state) => { 258 | state.saveArticleToggle = saveArticleToggle; 259 | return state; 260 | }); 261 | }; 262 | 263 | const setSaveReadingInfoToggle = (saveReadingInfoToggle: boolean) => { 264 | store.update((state) => { 265 | state.saveReadingInfoToggle = saveReadingInfoToggle; 266 | return state; 267 | }); 268 | }; 269 | 270 | const setCookieCloudInfo = (info: { serverUrl: string; uuid: string; password: string }) => { 271 | store.update((state) => { 272 | state.cookieCloudInfo = info; 273 | return state; 274 | }); 275 | }; 276 | 277 | const setTrimBlocks = (trimBlocks: boolean) => { 278 | store.update((state) => { 279 | state.trimBlocks = trimBlocks; 280 | return state; 281 | }); 282 | }; 283 | 284 | return { 285 | subscribe: store.subscribe, 286 | initialise, 287 | actions: { 288 | setLoginMethod, 289 | setNoteLocationFolder, 290 | setCookies, 291 | clearCookies, 292 | setTemplate, 293 | setNoteCountLimit, 294 | setSubFolderType, 295 | setFileNameType, 296 | setRemoveParens, 297 | setRemoveParensWhitelist, 298 | setDailyNotesToggle, 299 | setDailyNotesFolder, 300 | setDailyNotesFormat, 301 | setInsertAfter, 302 | setInsertBefore, 303 | setNoteBlacklist, 304 | setEmptyChapterTitleToggle, 305 | setConvertTags, 306 | setSaveArticleToggle, 307 | setSaveReadingInfoToggle, 308 | setCookieCloudInfo, 309 | setTrimBlocks 310 | } 311 | }; 312 | }; 313 | 314 | export const settingsStore = createSettingsStore(); 315 | -------------------------------------------------------------------------------- /src/fileManager.ts: -------------------------------------------------------------------------------- 1 | import { Vault, MetadataCache, TFile, TFolder, Notice, TAbstractFile } from 'obsidian'; 2 | import { Renderer } from './renderer'; 3 | import { sanitizeTitle } from './utils/sanitizeTitle'; 4 | import { AnnotationFile, DailyNoteReferenece, Metadata, Notebook } from './models'; 5 | import { frontMatterDocType, buildFrontMatter } from './utils/frontmatter'; 6 | import { get } from 'svelte/store'; 7 | import { settingsStore } from './settings'; 8 | import { getLinesInString } from './utils/fileUtils'; 9 | 10 | export default class FileManager { 11 | private vault: Vault; 12 | private metadataCache: MetadataCache; 13 | private renderer: Renderer; 14 | 15 | constructor(vault: Vault, metadataCache: MetadataCache) { 16 | this.vault = vault; 17 | this.metadataCache = metadataCache; 18 | this.renderer = new Renderer(); 19 | } 20 | 21 | public async saveDailyNotes(dailyNotePath: string, dailyNoteRefs: DailyNoteReferenece[]) { 22 | const fileExist = await this.fileExists(dailyNotePath); 23 | const toInsertContent = this.buildAppendContent(dailyNoteRefs); 24 | if (fileExist) { 25 | const dailyNoteFile = await this.getFileByPath(dailyNotePath); 26 | const existFileContent = await this.vault.cachedRead(dailyNoteFile); 27 | const freshContext = await this.insertAfter(existFileContent, toInsertContent); 28 | this.vault.modify(dailyNoteFile, freshContext); 29 | } else { 30 | new Notice('没有找到Daily Note,请先创建' + dailyNotePath); 31 | return; 32 | // todo toggle whether create auto 33 | // this.vault.create(dailyNotePath, toInsertContent); 34 | } 35 | } 36 | 37 | private buildAppendContent(dailyNoteRefs: DailyNoteReferenece[]): string { 38 | const appendContent = dailyNoteRefs 39 | .map((dailyNoteRef) => { 40 | const headContent: string = '\n### ' 41 | .concat(dailyNoteRef.metaData.title) 42 | .concat('\n'); 43 | const blockList = dailyNoteRef.refBlocks.map((refBlock) => { 44 | return `![[${this.getFileName(dailyNoteRef.metaData)}#^${ 45 | refBlock.refBlockId 46 | }]]`; 47 | }); 48 | const bodyContent = blockList.join('\n'); 49 | const finalContent = headContent + bodyContent; 50 | return finalContent; 51 | }) 52 | .join('\n'); 53 | 54 | return appendContent; 55 | } 56 | 57 | public getDailyNotePath(date: moment.Moment): string { 58 | let dailyNoteFileName; 59 | const dailyNotesFormat = get(settingsStore).dailyNotesFormat; 60 | 61 | try { 62 | dailyNoteFileName = date.format(dailyNotesFormat); 63 | } catch (e) { 64 | new Notice('Daily Notes 日期格式不正确' + dailyNotesFormat); 65 | throw e; 66 | } 67 | const dailyNotesLocation = get(settingsStore).dailyNotesLocation; 68 | return dailyNotesLocation + '/' + dailyNoteFileName + '.md'; 69 | } 70 | 71 | private async fileExists(filePath: string): Promise { 72 | return await this.vault.adapter.exists(filePath); 73 | } 74 | 75 | private async getFileByPath(filePath: string): Promise { 76 | const file: TAbstractFile = await this.vault.getAbstractFileByPath(filePath); 77 | 78 | if (!file) { 79 | console.error(`${filePath} not found`); 80 | return null; 81 | } 82 | 83 | if (file instanceof TFolder) { 84 | console.error(`${filePath} found but it's a folder`); 85 | return null; 86 | } 87 | 88 | if (file instanceof TFile) { 89 | return file; 90 | } 91 | } 92 | 93 | private async insertAfter(fileContent: string, formatted: string): Promise { 94 | const targetString: string = get(settingsStore).insertAfter; 95 | const targetRegex = new RegExp(`s*${targetString}s*`); 96 | const fileContentLines: string[] = getLinesInString(fileContent); 97 | const targetPosition = fileContentLines.findIndex((line) => targetRegex.test(line)); 98 | const targetNotFound = targetPosition === -1; 99 | if (targetNotFound) { 100 | new Notice(`没有在Daily Note中找到区间开始:${targetString}!请检查Daily Notes设置`); 101 | throw new Error('cannot find ' + targetString); 102 | } 103 | return this.insertTextAfterPosition(formatted, fileContent, targetPosition); 104 | } 105 | 106 | private insertTextAfterPosition(text: string, body: string, pos: number): string { 107 | const splitContent = body.split('\n'); 108 | const pre = splitContent.slice(0, pos + 1).join('\n'); 109 | const remainContent = splitContent.slice(pos + 1); 110 | const insertBefore = get(settingsStore).insertBefore; 111 | const endPostion = remainContent.findIndex((line) => 112 | new RegExp(`s*${insertBefore}s*`).test(line) 113 | ); 114 | const targetNotFound = endPostion === -1; 115 | if (targetNotFound) { 116 | new Notice(`没有在Daily Note中找到区间结束:${insertBefore}!请检查Daily Notes设置`); 117 | throw new Error('cannot find ' + insertBefore); 118 | } 119 | 120 | const post = remainContent.slice(endPostion - 1).join('\n'); 121 | return `${pre}\n${text}\n${post}`; 122 | } 123 | 124 | public async saveNotebook(notebook: Notebook): Promise { 125 | const localFile = notebook.metaData.file; 126 | if (localFile) { 127 | if (localFile.new) { 128 | const existingFile = localFile.file; 129 | console.log(`Updating ${existingFile.path}`); 130 | const freshContent = this.renderer.render(notebook); 131 | const fileContent = buildFrontMatter(freshContent, notebook, existingFile); 132 | await this.vault.modify(existingFile, fileContent); 133 | } 134 | } else { 135 | const newFilePath = await this.getNewNotebookFilePath(notebook); 136 | console.log(`Creating ${newFilePath}`); 137 | const markdownContent = this.renderer.render(notebook); 138 | const fileContent = buildFrontMatter(markdownContent, notebook); 139 | await this.vault.create(newFilePath, fileContent); 140 | } 141 | } 142 | 143 | public getWereadNoteAnnotationFile = (file: TFile): AnnotationFile | null => { 144 | const cache = this.metadataCache.getFileCache(file); 145 | const frontmatter = cache?.frontmatter; 146 | 147 | if ( 148 | frontmatter?.['doc_type'] === frontMatterDocType && 149 | frontmatter?.['bookId'] !== undefined 150 | ) { 151 | return { 152 | file, 153 | bookId: frontmatter['bookId'], 154 | reviewCount: frontmatter['reviewCount'], 155 | noteCount: frontmatter['noteCount'], 156 | new: false 157 | }; 158 | } 159 | 160 | return null; 161 | }; 162 | 163 | public async getNotebookFiles(): Promise { 164 | const files = this.vault.getMarkdownFiles(); 165 | return files 166 | .map((file) => { 167 | const cache = this.metadataCache.getFileCache(file); 168 | return { file, frontmatter: cache?.frontmatter }; 169 | }) 170 | .filter(({ frontmatter }) => frontmatter?.['doc_type'] === frontMatterDocType) 171 | .map( 172 | ({ file, frontmatter }): AnnotationFile => ({ 173 | file, 174 | bookId: frontmatter['bookId'], 175 | reviewCount: frontmatter['reviewCount'], 176 | noteCount: frontmatter['noteCount'], 177 | new: true 178 | }) 179 | ); 180 | } 181 | 182 | private async getNewNotebookFilePath(notebook: Notebook): Promise { 183 | const folderPath = `${get(settingsStore).noteLocation}/${this.getSubFolderPath( 184 | notebook.metaData 185 | )}`; 186 | if (!(await this.vault.adapter.exists(folderPath))) { 187 | console.info(`Folder ${folderPath} not found. Will be created`); 188 | await this.vault.createFolder(folderPath); 189 | } 190 | const fileName = this.getFileName(notebook.metaData); 191 | const filePath = `${folderPath}/${fileName}.md`; 192 | return filePath; 193 | } 194 | 195 | private getFileName(metaData: Metadata): string { 196 | const fileNameType = get(settingsStore).fileNameType; 197 | const baseFileName = sanitizeTitle(metaData.title); 198 | const removeParens = get(settingsStore).removeParens; 199 | const whitelistRaw = get(settingsStore).removeParensWhitelist || ''; 200 | const whitelistArr = whitelistRaw 201 | .split(/\r?\n/) 202 | .map((s) => s.trim()) 203 | .filter(Boolean); 204 | // 判断是否命中白名单 205 | const isWhitelisted = whitelistArr.some((keyword) => baseFileName.includes(keyword)); 206 | let fileName = baseFileName; 207 | if (removeParens && !isWhitelisted) { 208 | fileName = baseFileName.replace(/(.*)/g, ''); 209 | } 210 | 211 | switch (fileNameType) { 212 | case 'BOOK_ID': 213 | return metaData.bookId; 214 | 215 | case 'BOOK_NAME_AUTHOR': 216 | if (metaData.duplicate) { 217 | return `${fileName}-${metaData.author}-${metaData.bookId}`; 218 | } 219 | return `${fileName}-${metaData.author}`; 220 | 221 | case 'BOOK_NAME_BOOKID': 222 | return `${fileName}-${metaData.bookId}`; 223 | 224 | case 'BOOK_NAME': 225 | if (metaData.duplicate) { 226 | return `${fileName}-${metaData.bookId}`; 227 | } 228 | return fileName; 229 | 230 | default: 231 | return fileName; 232 | } 233 | } 234 | 235 | private getSubFolderPath(metaData: Metadata): string { 236 | const folderType = get(settingsStore).subFolderType; 237 | if (folderType == 'title') { 238 | return metaData.title; 239 | } else if (folderType == 'category') { 240 | if (metaData.category) { 241 | return metaData.category.split('-')[0]; 242 | } else { 243 | return metaData.author === '公众号' ? '公众号' : '未分类'; 244 | } 245 | } 246 | return ''; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { Notice, requestUrl, RequestUrlParam, Platform } from 'obsidian'; 2 | import { settingsStore } from './settings'; 3 | import { get } from 'svelte/store'; 4 | import { getCookieString } from './utils/cookiesUtil'; 5 | import { Cookie, parse, splitCookiesString } from 'set-cookie-parser'; 6 | import { 7 | HighlightResponse, 8 | BookReviewResponse, 9 | ChapterResponse, 10 | BookReadInfoResponse, 11 | BookDetailResponse, 12 | BookProgressResponse 13 | } from './models'; 14 | import CookieCloudManager from './cookieCloud'; 15 | export default class ApiManager { 16 | readonly baseUrl: string = 'https://weread.qq.com'; 17 | 18 | private getHeaders() { 19 | return { 20 | 'User-Agent': 21 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36', 22 | 'Accept-Encoding': 'gzip, deflate, br', 23 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 24 | accept: 'application/json, text/plain, */*', 25 | 'Content-Type': 'application/json', 26 | Cookie: getCookieString(get(settingsStore).cookies) 27 | }; 28 | } 29 | 30 | async refreshCookie() { 31 | const req: RequestUrlParam = { 32 | url: this.baseUrl, 33 | method: 'HEAD', 34 | headers: this.getHeaders() 35 | }; 36 | const resp = await requestUrl(req); 37 | const respCookie: string = resp.headers['set-cookie'] || resp.headers['Set-Cookie']; 38 | 39 | if (respCookie !== undefined && this.checkCookies(respCookie)) { 40 | new Notice('cookie已过期,尝试刷新Cookie成功'); 41 | this.updateCookies(respCookie); 42 | } else { 43 | const loginMethod = get(settingsStore).loginMethod; 44 | if (loginMethod === 'cookieCloud') { 45 | const cookieCloudManager = new CookieCloudManager(); 46 | const isSuccess = await cookieCloudManager.getCookie(); 47 | if (!isSuccess) { 48 | new Notice('尝试刷新Cookie失败'); 49 | } 50 | } else { 51 | new Notice('尝试刷新Cookie失败'); 52 | } 53 | } 54 | } 55 | 56 | async getNotebooksWithRetry() { 57 | let noteBookResp: [] = await this.getNotebooks(); 58 | if (noteBookResp === undefined || noteBookResp.length === 0) { 59 | //retry get notebooks 60 | noteBookResp = await this.getNotebooks(); 61 | } 62 | if (noteBookResp === undefined || noteBookResp.length === 0) { 63 | new Notice('长时间未登录,Cookie已失效,请重新扫码登录!'); 64 | settingsStore.actions.clearCookies(); 65 | throw Error('get weread note book error after retry'); 66 | } 67 | return noteBookResp; 68 | } 69 | 70 | async getNotebooks() { 71 | let noteBooks = []; 72 | const req: RequestUrlParam = { 73 | url: `${this.baseUrl}/api/user/notebook`, 74 | method: 'GET', 75 | headers: this.getHeaders() 76 | }; 77 | 78 | try { 79 | const resp = await requestUrl(req); 80 | if (resp.status === 401) { 81 | if (resp.json.errcode == -2012) { 82 | // 登录超时 -2012 83 | console.log('weread cookie expire retry refresh cookie... '); 84 | await this.refreshCookie(); 85 | } else { 86 | if (Platform.isDesktopApp) { 87 | new Notice('微信读书未登录或者用户异常,请在设置中重新登录!'); 88 | } else { 89 | new Notice('微信读书未登录或者用户异常,请在电脑端重新登录!'); 90 | } 91 | console.log( 92 | '微信读书未登录或者用户异常,请重新登录, http status code:', 93 | resp.json.errcode 94 | ); 95 | settingsStore.actions.clearCookies(); 96 | } 97 | } else { 98 | if (resp.json.errcode == -2012) { 99 | console.log('weread cookie expire retry refresh cookie... '); 100 | await this.refreshCookie(); 101 | } 102 | } 103 | 104 | // CookieCloud 请求到的 cookie 时间过长时,需要获取 set-cookie 更新 wr_skey,否则请求 /web 的接口会返回登录超时 105 | const respCookie: string = resp.headers['set-cookie'] || resp.headers['Set-Cookie']; 106 | if (respCookie !== undefined) { 107 | this.updateCookies(respCookie); 108 | } 109 | 110 | noteBooks = resp.json.books; 111 | } catch (e) { 112 | if (e.status == 401) { 113 | console.log(`parse request to cURL for debug: ${this.parseToCurl(req)}`); 114 | await this.refreshCookie(); 115 | } 116 | } 117 | 118 | return noteBooks; 119 | } 120 | 121 | private parseToCurl(req: RequestUrlParam) { 122 | const command = ['curl']; 123 | command.push(req.url); 124 | const requestHeaders = req.headers; 125 | Object.keys(requestHeaders).forEach((name) => { 126 | command.push('-H'); 127 | command.push( 128 | this.escapeStringPosix(name.replace(/^:/, '') + ': ' + requestHeaders[name]) 129 | ); 130 | }); 131 | command.push(' --compressed'); 132 | return command.join(' '); 133 | } 134 | 135 | private escapeStringPosix(str: string) { 136 | function escapeCharacter(x) { 137 | let code = x.charCodeAt(0); 138 | if (code < 256) { 139 | // Add leading zero when needed to not care about the next character. 140 | return code < 16 ? '\\x0' + code.toString(16) : '\\x' + code.toString(16); 141 | } 142 | code = code.toString(16); 143 | return '\\u' + ('0000' + code).substr(code.length, 4); 144 | } 145 | 146 | if (/[^\x20-\x7E]|'/.test(str)) { 147 | // Use ANSI-C quoting syntax. 148 | return ( 149 | "$'" + 150 | str 151 | .replace(/\\/g, '\\\\') 152 | .replace(/'/g, "\\'") 153 | .replace(/\n/g, '\\n') 154 | .replace(/\r/g, '\\r') 155 | .replace(/[^\x20-\x7E]/g, escapeCharacter) + 156 | "'" 157 | ); 158 | } else { 159 | // Use single quote syntax. 160 | return "'" + str + "'"; 161 | } 162 | } 163 | 164 | async getBook(bookId: string): Promise { 165 | try { 166 | const req: RequestUrlParam = { 167 | url: `${this.baseUrl}/web/book/info?bookId=${bookId}`, 168 | method: 'GET', 169 | headers: this.getHeaders() 170 | }; 171 | const resp = await requestUrl(req); 172 | if (resp.json.errCode == -2012) { 173 | // 登录超时 -2012 174 | console.log('weread cookie expire retry refresh cookie... '); 175 | await this.refreshCookie(); 176 | } 177 | return resp.json; 178 | } catch (e) { 179 | console.error('get book detail error', e); 180 | } 181 | } 182 | 183 | async getNotebookHighlights(bookId: string): Promise { 184 | try { 185 | const req: RequestUrlParam = { 186 | url: `${this.baseUrl}/web/book/bookmarklist?bookId=${bookId}`, 187 | method: 'GET', 188 | headers: this.getHeaders() 189 | }; 190 | const resp = await requestUrl(req); 191 | return resp.json; 192 | } catch (e) { 193 | console.error('get book highlight error' + bookId, e); 194 | } 195 | } 196 | 197 | async getNotebookReviews(bookId: string): Promise { 198 | try { 199 | const url = `${this.baseUrl}/web/review/list?bookId=${bookId}&listType=11&mine=1&synckey=0`; 200 | const req: RequestUrlParam = { url: url, method: 'GET', headers: this.getHeaders() }; 201 | const resp = await requestUrl(req); 202 | return resp.json; 203 | } catch (e) { 204 | new Notice( 205 | 'Failed to fetch weread notebook reviews . Please check your Cookies and try again.' 206 | ); 207 | console.error('get book review error' + bookId, e); 208 | } 209 | } 210 | 211 | async getChapters(bookId: string): Promise { 212 | try { 213 | const url = `${this.baseUrl}/web/book/chapterInfos`; 214 | const reqBody = { 215 | bookIds: [bookId] 216 | }; 217 | 218 | const req: RequestUrlParam = { 219 | url: url, 220 | method: 'POST', 221 | headers: this.getHeaders(), 222 | body: JSON.stringify(reqBody) 223 | }; 224 | 225 | const resp = await requestUrl(req); 226 | return resp.json; 227 | } catch (e) { 228 | new Notice( 229 | 'Failed to fetch weread notebook chapters . Please check your Cookies and try again.' 230 | ); 231 | console.error('get book chapters error' + bookId, e); 232 | } 233 | } 234 | /** 235 | * 获取书籍阅读进度信息 236 | * @param bookId 书籍ID 237 | * @returns 书籍阅读进度信息 238 | */ 239 | async getProgress(bookId: string): Promise { 240 | try { 241 | const url = `${this.baseUrl}/web/book/getProgress?bookId=${bookId}`; 242 | const req: RequestUrlParam = { url: url, method: 'GET', headers: this.getHeaders() }; 243 | const resp = await requestUrl(req); 244 | return resp.json; 245 | } catch (e) { 246 | new Notice('获取微信读书阅读进度信息失败,请检查您的 Cookies 并重试。'); 247 | console.error('get book progress error for bookId: ' + bookId, e); 248 | } 249 | } 250 | 251 | /** 252 | * @deprecated 该方法新 API 中已废弃,请使用 getProgress 方法代替 253 | */ 254 | async getBookReadInfo(bookId: string): Promise { 255 | try { 256 | const url = `${this.baseUrl}/web/book/readinfo?bookId=${bookId}&readingDetail=1&readingBookIndex=1&finishedDate=1`; 257 | const req: RequestUrlParam = { url: url, method: 'GET', headers: this.getHeaders() }; 258 | const resp = await requestUrl(req); 259 | return resp.json; 260 | } catch (e) { 261 | new Notice( 262 | 'Failed to fetch weread notebook read info . Please check your Cookies and try again.' 263 | ); 264 | console.error('get book read info error' + bookId, e); 265 | } 266 | } 267 | 268 | private checkCookies(respCookie: string): boolean { 269 | let refreshCookies: Cookie[]; 270 | if (Array.isArray(respCookie)) { 271 | refreshCookies = parse(respCookie); 272 | } else { 273 | const arrCookies = splitCookiesString(respCookie); 274 | refreshCookies = parse(arrCookies); 275 | } 276 | 277 | const wrName = refreshCookies.find((cookie) => cookie.name == 'wr_name'); 278 | return wrName !== undefined && wrName.value !== ''; 279 | } 280 | 281 | private updateCookies(respCookie: string) { 282 | let refreshCookies: Cookie[]; 283 | if (Array.isArray(respCookie)) { 284 | refreshCookies = parse(respCookie); 285 | } else { 286 | const arrCookies = splitCookiesString(respCookie); 287 | refreshCookies = parse(arrCookies); 288 | } 289 | const cookies = get(settingsStore).cookies; 290 | cookies.forEach((cookie) => { 291 | const newCookie = refreshCookies.find((freshCookie) => freshCookie.name == cookie.name); 292 | if (newCookie) { 293 | cookie.value = newCookie.value; 294 | } 295 | }); 296 | settingsStore.actions.setCookies(cookies); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/components/templateEditorWindow.ts: -------------------------------------------------------------------------------- 1 | import { Modal, App, Notice, MarkdownRenderer } from 'obsidian'; 2 | import { Renderer } from '../renderer'; 3 | import type { Notebook } from '../models'; 4 | import templateInstructions from '../assets/templateInstructions.html'; 5 | import { settingsStore } from '../settings'; 6 | import { get } from 'svelte/store'; 7 | 8 | export class TemplateEditorWindow extends Modal { 9 | private initialTemplate: string; 10 | private onSave: (template: string) => void; 11 | private renderer: Renderer; 12 | private editorEl: HTMLTextAreaElement; 13 | private previewEl: HTMLElement; 14 | private errorEl: HTMLElement; 15 | private debounceTimer: NodeJS.Timeout | null = null; 16 | private isMarkdownRendered = false; 17 | private trimBlocks: boolean; 18 | 19 | constructor(app: App, initialTemplate: string, onSave: (template: string) => void) { 20 | super(app); 21 | this.initialTemplate = initialTemplate; 22 | this.onSave = onSave; 23 | this.renderer = new Renderer(); 24 | // 从 settings 读取 trimBlocks 配置 25 | this.trimBlocks = get(settingsStore).trimBlocks; 26 | } 27 | 28 | // 禁用点击外部或按 ESC 关闭 29 | shouldCloseOnEsc(): boolean { 30 | return false; 31 | } 32 | 33 | onOpen() { 34 | const { contentEl, modalEl } = this; 35 | 36 | // 禁用点击外部关闭模态框 37 | modalEl.addEventListener('click', (e: MouseEvent) => { 38 | if (e.target === modalEl) { 39 | e.stopPropagation(); 40 | } 41 | }); 42 | 43 | // 设置模态框样式 44 | modalEl.addClass('weread-template-editor-modal'); 45 | modalEl.style.width = '95vw'; 46 | modalEl.style.height = '90vh'; 47 | modalEl.style.maxWidth = '95vw'; 48 | modalEl.style.maxHeight = '90vh'; 49 | 50 | // 创建标题栏 51 | const titleBar = contentEl.createDiv('weread-editor-titlebar'); 52 | titleBar.createEl('h2', { text: '📝 模板编辑器' }); 53 | 54 | const buttonGroup = titleBar.createDiv('weread-editor-buttons'); 55 | const cancelBtn = buttonGroup.createEl('button', { text: '取消', cls: 'mod-cancel' }); 56 | const saveBtn = buttonGroup.createEl('button', { text: '保存', cls: 'mod-cta' }); 57 | 58 | cancelBtn.onclick = () => this.handleCancel(); 59 | saveBtn.onclick = () => this.handleSave(); 60 | 61 | // 创建三栏布局容器 62 | const container = contentEl.createDiv('weread-editor-container'); 63 | 64 | // 左侧:说明文档 65 | const instructionsPanel = container.createDiv('weread-editor-instructions'); 66 | instructionsPanel.innerHTML = templateInstructions; 67 | 68 | // 中间:编辑器 69 | const editorPanel = container.createDiv('weread-editor-panel'); 70 | editorPanel.createEl('div', { text: '📄 模板编辑 (Nunjucks)', cls: 'panel-header' }); 71 | 72 | this.editorEl = editorPanel.createEl('textarea', { cls: 'weread-editor-textarea' }); 73 | this.editorEl.value = this.initialTemplate; 74 | 75 | // 右侧:预览 76 | const previewPanel = container.createDiv('weread-editor-preview'); 77 | const previewHeader = previewPanel.createDiv('panel-header'); 78 | previewHeader.createSpan({ text: '👁️ 实时预览' }); 79 | 80 | // 创建开关容器 81 | const toggleContainer = previewHeader.createDiv('weread-toggle-container'); 82 | 83 | // 添加 trimBlocks 切换开关 84 | const trimToggleWrapper = toggleContainer.createDiv('weread-toggle-wrapper'); 85 | trimToggleWrapper.createSpan({ text: '✂️ 自动去空白', cls: 'weread-toggle-label' }); 86 | const trimToggleSwitch = trimToggleWrapper.createDiv('weread-toggle-switch'); 87 | if (this.trimBlocks) { 88 | trimToggleSwitch.addClass('is-enabled'); 89 | } 90 | trimToggleSwitch.addEventListener('click', () => { 91 | this.trimBlocks = !this.trimBlocks; 92 | if (this.trimBlocks) { 93 | trimToggleSwitch.addClass('is-enabled'); 94 | } else { 95 | trimToggleSwitch.removeClass('is-enabled'); 96 | } 97 | // 保存到 settings 98 | settingsStore.actions.setTrimBlocks(this.trimBlocks); 99 | this.updatePreview(); 100 | }); 101 | 102 | // 添加渲染模式切换开关 103 | const renderToggleWrapper = toggleContainer.createDiv('weread-toggle-wrapper'); 104 | renderToggleWrapper.createSpan({ text: '📝 Markdown渲染', cls: 'weread-toggle-label' }); 105 | const renderToggleSwitch = renderToggleWrapper.createDiv('weread-toggle-switch'); 106 | if (this.isMarkdownRendered) { 107 | renderToggleSwitch.addClass('is-enabled'); 108 | } 109 | renderToggleSwitch.addEventListener('click', () => { 110 | this.isMarkdownRendered = !this.isMarkdownRendered; 111 | if (this.isMarkdownRendered) { 112 | renderToggleSwitch.addClass('is-enabled'); 113 | } else { 114 | renderToggleSwitch.removeClass('is-enabled'); 115 | } 116 | this.updatePreview(); 117 | }); 118 | 119 | const previewContent = previewPanel.createDiv('preview-content'); 120 | this.previewEl = previewContent.createEl('div', { cls: 'preview-text' }); 121 | this.errorEl = previewContent.createDiv('error-message'); 122 | 123 | // 初始预览 124 | this.updatePreview(); 125 | 126 | // 监听编辑器输入 127 | this.editorEl.addEventListener('input', () => { 128 | if (this.debounceTimer) { 129 | clearTimeout(this.debounceTimer); 130 | } 131 | this.debounceTimer = setTimeout(() => { 132 | this.updatePreview(); 133 | }, 300); 134 | }); 135 | } 136 | 137 | private updatePreview(): void { 138 | try { 139 | const templateStr = this.editorEl.value; 140 | const sampleNotebook = this.buildSampleNotebook(); 141 | const preview = this.renderer.renderWithTemplate( 142 | templateStr, 143 | sampleNotebook, 144 | this.trimBlocks 145 | ); 146 | 147 | // 清空预览容器 148 | this.previewEl.empty(); 149 | this.errorEl.style.display = 'none'; 150 | this.errorEl.textContent = ''; 151 | 152 | if (this.isMarkdownRendered) { 153 | // 渲染模式:使用 Obsidian 的 Markdown 渲染器 154 | this.previewEl.addClass('markdown-preview-view'); 155 | this.previewEl.removeClass('preview-source-mode'); 156 | MarkdownRenderer.renderMarkdown(preview, this.previewEl, '', null); 157 | } else { 158 | // 源码模式:显示原始文本 159 | this.previewEl.removeClass('markdown-preview-view'); 160 | this.previewEl.addClass('preview-source-mode'); 161 | const preEl = this.previewEl.createEl('pre'); 162 | preEl.textContent = preview; 163 | } 164 | } catch (error: any) { 165 | this.previewEl.empty(); 166 | this.errorEl.style.display = 'block'; 167 | this.errorEl.textContent = '❌ ' + (error.message || String(error)); 168 | } 169 | } 170 | 171 | private handleCancel(): void { 172 | // 检查是否有未保存的更改 173 | if (this.editorEl.value !== this.initialTemplate) { 174 | const confirmed = confirm('模板已修改但未保存,确定要关闭吗?'); 175 | if (!confirmed) { 176 | return; 177 | } 178 | } 179 | this.close(); 180 | } 181 | 182 | private handleSave(): void { 183 | const template = this.editorEl.value; 184 | const isValid = this.renderer.validate(template); 185 | if (!isValid) { 186 | new Notice('模板语法错误,请检查后再保存!'); 187 | return; 188 | } 189 | this.onSave(template); 190 | new Notice('模板已保存!'); 191 | this.close(); 192 | } 193 | 194 | onClose() { 195 | const { contentEl } = this; 196 | contentEl.empty(); 197 | if (this.debounceTimer) { 198 | clearTimeout(this.debounceTimer); 199 | } 200 | } 201 | 202 | private buildSampleNotebook(): Notebook { 203 | return { 204 | metaData: { 205 | bookId: '651358', 206 | title: '中国哲学简史', 207 | author: '冯友兰', 208 | cover: 'https://cdn.weread.qq.com/weread/cover/24/YueWen_651358/t7_YueWen_651358.jpg', 209 | url: 'https://weread.qq.com/web/reader/9f832d8059f05e9f8657f05', 210 | pcUrl: 'https://weread.qq.com/web/reader/9f832d8059f05e9f8657f05', 211 | bookType: 1, 212 | publishTime: '2013-01-01', 213 | noteCount: 128, 214 | reviewCount: 11, 215 | isbn: '9787301215692', 216 | category: '哲学宗教-东方哲学', 217 | publisher: '北京大学出版社', 218 | intro: '《中国哲学简史》打通古今中外的相关知识,以宏观开阔的视野对中国哲学进行了深入浅出的、融会贯通的讲解。', 219 | lastReadDate: '2022-05-20', 220 | totalWords: 450000, 221 | rating: '9.2', 222 | readInfo: { 223 | readingTime: 21720, 224 | totalReadDay: 7, 225 | continueReadDays: 5, 226 | readingBookCount: 3, 227 | readingBookDate: 1579929600, 228 | finishedDate: 1580227200, 229 | readingProgress: 100, 230 | markedStatus: 1, 231 | finishedBookCount: 12, 232 | finishedBookIndex: 1 233 | } 234 | }, 235 | chapterHighlights: [ 236 | { 237 | chapterUid: 1001, 238 | chapterIdx: 1, 239 | chapterTitle: '第一章 中国哲学的精神', 240 | level: 1, 241 | isMPChapter: 0, 242 | highlights: [ 243 | { 244 | bookmarkId: 'bookmark001', 245 | created: 1580041310, 246 | createTime: '2020-01-28 16:41:50', 247 | chapterUid: 1001, 248 | chapterIdx: 1, 249 | chapterTitle: '第一章 中国哲学的精神', 250 | markText: 251 | '宗教也和人生有关系。每种大宗教的核心都有一种哲学。事实上,每种大宗教就是一种哲学加上一定的上层建筑。', 252 | style: 0, 253 | colorStyle: 1, 254 | range: '0-50', 255 | reviewContent: '宗教与哲学的关系很深刻' 256 | }, 257 | { 258 | bookmarkId: 'bookmark002', 259 | created: 1580052228, 260 | createTime: '2020-01-28 22:06:21', 261 | chapterUid: 1001, 262 | chapterIdx: 1, 263 | chapterTitle: '第一章 中国哲学的精神', 264 | markText: '知者不惑,仁者不忧,勇者不惧', 265 | style: 0, 266 | colorStyle: 2, 267 | range: '100-150' 268 | }, 269 | { 270 | bookmarkId: 'bookmark003', 271 | created: 1580048348, 272 | createTime: '2020-01-28 16:52:28', 273 | chapterUid: 1001, 274 | chapterIdx: 1, 275 | chapterTitle: '第一章 中国哲学的精神', 276 | markText: 277 | '入世与出世是对立的,正如现实主义与理想主义也是对立的一样。中国哲学的任务,就是把这些反命题统一成一个合命题。', 278 | style: 0, 279 | colorStyle: 1, 280 | range: '150-200' 281 | } 282 | ] 283 | } 284 | ], 285 | bookReview: { 286 | chapterReviews: [], 287 | bookReviews: [ 288 | { 289 | reviewId: 'bookReview001', 290 | created: 1580227200, 291 | createTime: '2020-01-29 00:00:00', 292 | content: 293 | '这是一部名副其实的可以影响大众一生的文化经典。冯友兰先生以宏观开阔的视野对中国哲学进行了深入浅出的讲解,融会了史与思的智慧结晶。', 294 | mdContent: 295 | '## 总体评价\n\n这是一部影响深远的哲学经典著作,适合所有想要了解中国传统思想的读者。', 296 | type: 3 297 | } 298 | ] 299 | } 300 | }; 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from 'obsidian'; 2 | 3 | export interface HighlightResponse { 4 | synckey: number; 5 | updated: { 6 | bookId: string; 7 | bookVersion: number; 8 | chapterName: string; 9 | chapterUid: number; 10 | colorStyle: number; 11 | contextAbstract: string; 12 | markText: string; 13 | range: string; 14 | style: number; 15 | type: number; 16 | createTime: number; 17 | bookmarkId: string; 18 | refMpReviewId?: string; 19 | }[]; 20 | removed: any[]; 21 | chapters: { 22 | bookId: string; 23 | chapterUid: number; 24 | chapterIdx: number; 25 | title: string; 26 | }[]; 27 | refMpInfos?: { 28 | reviewId: string; 29 | title: string; 30 | pic_url: string; 31 | createTime: number; 32 | }[]; 33 | book: { 34 | bookId: string; 35 | version: number; 36 | format: string; 37 | soldout: number; 38 | bookStatus: number; 39 | cover: string; 40 | title: string; 41 | author: string; 42 | coverBoxInfo: { 43 | blurhash: string; 44 | colors: { 45 | key: string; 46 | hex: string; 47 | }[]; 48 | dominate_color: { 49 | hex: string; 50 | hsv: number[]; 51 | }; 52 | custom_cover: string; 53 | custom_rec_cover: string; 54 | }; 55 | }; 56 | } 57 | 58 | export interface BookReviewResponse { 59 | synckey: number; 60 | totalCount: number; 61 | reviews: { 62 | reviewId: string; 63 | review: { 64 | abstract: string; 65 | atUserVids: any[]; 66 | bookId: string; 67 | bookVersion: number; 68 | refMpInfo: { 69 | reviewId: string; 70 | title: string; 71 | pic_url: string; 72 | createTime: number; 73 | }; 74 | chapterName: string; 75 | chapterUid: number; 76 | content: string; 77 | contextAbstract: string; 78 | friendship: number; 79 | htmlContent: string; 80 | isPrivate: number; 81 | range: string; 82 | createTime: number; 83 | title: string; 84 | type: number; 85 | reviewId: string; 86 | userVid: number; 87 | topics: any[]; 88 | isLike: number; 89 | isReposted: number; 90 | book: { 91 | bookId: string; 92 | format: string; 93 | version: number; 94 | soldout: number; 95 | bookStatus: number; 96 | type: number; 97 | cover: string; 98 | title: string; 99 | author: string; 100 | payType: number; 101 | }; 102 | chapterIdx: number; 103 | chapterTitle: string; 104 | author: { 105 | userVid: number; 106 | name: string; 107 | avatar: string; 108 | isFollowing: number; 109 | isFollower: number; 110 | isHide: number; 111 | medalInfo: { 112 | id: string; 113 | desc: string; 114 | title: string; 115 | levelIndex: number; 116 | }; 117 | }; 118 | }; 119 | }[]; 120 | removed: any[]; 121 | atUsers: any[]; 122 | refUsers: any[]; 123 | columns: any[]; 124 | hasMore: number; 125 | } 126 | 127 | export type ChapterResponse = { 128 | data: { 129 | bookId: string; 130 | chapterUpdateTime: number; 131 | updated: Chapter[]; 132 | }[]; 133 | }; 134 | export interface BookProgressResponse { 135 | bookId: string; 136 | book: { 137 | appId: string; 138 | bookVersion: number; 139 | reviewId: string; 140 | chapterUid: number; 141 | chapterOffset: number; 142 | chapterIdx: number; 143 | updateTime: number; 144 | synckey: number; 145 | summary: string; 146 | repairOffsetTime: number; 147 | readingTime: number; 148 | progress: number; 149 | isStartReading: number; 150 | ttsTime: number; 151 | startReadingTime: number; 152 | installId: string; 153 | recordReadingTime: number; 154 | finishTime: number; 155 | }; 156 | canFreeRead: number; 157 | timestamp: number; 158 | } 159 | 160 | export type BookReadInfoResponse = { 161 | finishedBookCount: number; 162 | finishedBookIndex: number; 163 | finishedDate: number; 164 | readingBookCount: number; 165 | readingBookDate: number; 166 | readingProgress: number; 167 | readingReviewId: string; 168 | canCancelReadstatus: number; 169 | markedStatus: number; 170 | readingTime: number; 171 | totalReadDay: number; 172 | recordReadingTime: number; 173 | continueReadDays: number; 174 | continueBeginDate: number; 175 | continueEndDate: number; 176 | showSummary: number; 177 | showDetail: number; 178 | readDetail: { 179 | totalReadingTime: number; 180 | totalReadDay: number; 181 | continueReadDays: number; 182 | continueBeginDate: number; 183 | continueEndDate: number; 184 | beginReadingDate: number; 185 | lastReadingDate: number; 186 | longestReadingDate: number; 187 | avgReadingTime: number; 188 | longestReadingTime: number; 189 | data: { 190 | readDate: number; 191 | readTime: number; 192 | }[]; 193 | }; 194 | bookInfo: { 195 | bookId: string; 196 | title: string; 197 | author: string; 198 | translator: string; 199 | intro: string; 200 | cover: string; 201 | version: number; 202 | format: string; 203 | type: number; 204 | soldout: number; 205 | bookStatus: number; 206 | payType: number; 207 | finished: number; 208 | maxFreeChapter: number; 209 | free: number; 210 | mcardDiscount: number; 211 | ispub: number; 212 | extra_type: number; 213 | cpid: number; 214 | publishTime: string; 215 | lastChapterIdx: number; 216 | paperBook: { 217 | skuId: string; 218 | }; 219 | centPrice: number; 220 | readingCount: number; 221 | maxfreeInfo: { 222 | maxfreeChapterIdx: number; 223 | maxfreeChapterUid: number; 224 | maxfreeChapterRatio: number; 225 | }; 226 | blockSaveImg: number; 227 | language: string; 228 | hideUpdateTime: boolean; 229 | isEPUBComics: number; 230 | webBookControl: number; 231 | }; 232 | }; 233 | 234 | export type BookDetailResponse = { 235 | bookId: string; 236 | title: string; 237 | author: string; 238 | cover: string; 239 | version: number; 240 | format: string; 241 | type: number; 242 | price: number; 243 | originalPrice: number; 244 | soldout: number; 245 | bookStatus: number; 246 | payType: number; 247 | intro: string; 248 | centPrice: number; 249 | finished: number; 250 | maxFreeChapter: number; 251 | free: number; 252 | mcardDiscount: number; 253 | ispub: number; 254 | extra_type: number; 255 | cpid: number; 256 | publishTime: string; 257 | category: string; 258 | categories: { 259 | categoryId: number; 260 | subCategoryId: number; 261 | categoryType: number; 262 | title: string; 263 | }[]; 264 | hasLecture: number; 265 | lastChapterIdx: number; 266 | paperBook: { skuId: string }; 267 | blockSaveImg: number; 268 | language: string; 269 | hideUpdateTime: boolean; 270 | isEPUBComics: number; 271 | webBookControl: number; 272 | payingStatus: number; 273 | chapterSize: number; 274 | updateTime: number; 275 | onTime: number; 276 | lastChapterCreateTime: number; 277 | unitPrice: number; 278 | marketType: number; 279 | isbn: string; 280 | publisher: string; 281 | totalWords: number; 282 | bookSize: number; 283 | shouldHideTTS: number; 284 | recommended: number; 285 | lectureRecommended: number; 286 | follow: number; 287 | secret: number; 288 | offline: number; 289 | lectureOffline: number; 290 | finishReading: number; 291 | hideReview: number; 292 | hideFriendMark: number; 293 | blacked: number; 294 | isAutoPay: number; 295 | availables: number; 296 | paid: number; 297 | isChapterPaid: number; 298 | showLectureButton: number; 299 | wxtts: number; 300 | ratingCount: number; 301 | newRating: number; 302 | newRatingCount: number; 303 | newRatingDetail: { 304 | good: number; 305 | fair: number; 306 | poor: number; 307 | recent: number; 308 | myRating: string; 309 | title: string; 310 | }; 311 | }; 312 | 313 | export type Chapter = { 314 | chapterUid?: number; 315 | chapterIdx?: number; 316 | updateTime: number; 317 | title: string; 318 | isMPChapter: number; 319 | refMpReviewId?: string; 320 | level: number; 321 | }; 322 | 323 | export type Notebook = { 324 | metaData: Metadata; 325 | chapterHighlights: ChapterHighlightReview[]; 326 | bookReview: BookReview; 327 | }; 328 | 329 | export type Metadata = { 330 | bookId: string; 331 | author: string; 332 | title: string; 333 | url: string; 334 | pcUrl?: string; 335 | cover: string; 336 | bookType: number; 337 | publishTime: string; 338 | noteCount: number; 339 | reviewCount: number; 340 | isbn?: string; 341 | category?: string; 342 | publisher?: string; 343 | intro?: string; 344 | duplicate?: boolean; 345 | lastReadDate: string; 346 | file?: AnnotationFile; 347 | totalWords?: number; 348 | rating?: string; 349 | readInfo?: { 350 | markedStatus?: number; 351 | readingTime: number; 352 | totalReadDay?: number; 353 | continueReadDays?: number; 354 | readingBookCount?: number; 355 | readingBookDate: number; 356 | finishedBookCount?: number; 357 | finishedBookIndex?: number; 358 | finishedDate: number; 359 | readingProgress: number; 360 | }; 361 | }; 362 | 363 | export type Highlight = { 364 | bookmarkId: string; 365 | created: number; 366 | createTime: string; 367 | chapterUid: number; 368 | chapterIdx: number; 369 | chapterTitle: string; 370 | markText: string; 371 | style: number; 372 | colorStyle: number; 373 | reviewContent?: string; 374 | range: string; 375 | refMpReviewId?: string; 376 | }; 377 | 378 | export type BookReview = { 379 | chapterReviews: ChapterReview[]; 380 | bookReviews: Review[]; 381 | }; 382 | 383 | export type ChapterReview = { 384 | chapterUid: number; 385 | chapterTitle: string; 386 | chapterReviews?: Review[]; 387 | reviews: Review[]; 388 | }; 389 | 390 | export type Review = { 391 | reviewId: string; 392 | chapterUid?: number; 393 | chapterTitle?: string; 394 | created: number; 395 | createTime: string; 396 | content: string; 397 | mdContent?: string; 398 | abstract?: string; 399 | range?: string; 400 | type: number; 401 | refMpInfo?: { 402 | reviewId: string; 403 | title: string; 404 | pic_url: string; 405 | createTime: number; 406 | }; 407 | }; 408 | 409 | export type ChapterHighlightReview = { 410 | chapterUid?: number; 411 | chapterIdx?: number; 412 | chapterTitle: string; 413 | level: number; 414 | isMPChapter: number; 415 | // highlight and review can be empty, just output title 416 | highlights?: Highlight[]; 417 | chapterReviews?: Review[]; 418 | }; 419 | 420 | export type RenderTemplate = { 421 | metaData: Metadata; 422 | chapterHighlights: ChapterHighlightReview[]; 423 | bookReview: BookReview; 424 | }; 425 | 426 | export type DailyNoteReferenece = { 427 | metaData: Metadata; 428 | refBlocks: RefBlockDetail[]; 429 | }; 430 | 431 | export type RefBlockDetail = { 432 | refBlockId: string; 433 | createTime: number; 434 | }; 435 | 436 | export type AnnotationFile = { 437 | bookId?: string; 438 | noteCount: number; 439 | reviewCount: number; 440 | new: boolean; 441 | file: TFile; 442 | }; 443 | 444 | export type RecentBook = { 445 | bookId: string; 446 | title: string; 447 | recentTime: number; 448 | }; 449 | -------------------------------------------------------------------------------- /src/parser/parseResponse.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BookReview, 3 | BookReviewResponse, 4 | Chapter, 5 | ChapterHighlightReview, 6 | ChapterResponse, 7 | ChapterReview, 8 | DailyNoteReferenece, 9 | Highlight, 10 | HighlightResponse, 11 | Metadata, 12 | Notebook, 13 | RefBlockDetail, 14 | Review 15 | } from 'src/models'; 16 | import { NodeHtmlMarkdown } from 'node-html-markdown'; 17 | import * as CryptoJS from 'crypto-js'; 18 | import { settingsStore } from '../settings'; 19 | import { get } from 'svelte/store'; 20 | 21 | export const parseMetadata = (noteBook: any): Metadata => { 22 | const book = noteBook['book']; 23 | const cover: string = book['cover'].replace('/s_', '/t7_'); 24 | const lastReadDate = window.moment(noteBook['sort'] * 1000).format('YYYY-MM-DD'); 25 | const bookId = book['bookId']; 26 | const pcUrl = getPcUrl(bookId); 27 | const author = book['author'].replace(/\[(.*?)\]/g, '【$1】'); 28 | const metaData: Metadata = { 29 | bookId: book['bookId'], 30 | author: author, 31 | title: book['title'], 32 | url: book['url'], 33 | cover: cover, 34 | publishTime: book['publishTime'], 35 | noteCount: noteBook['noteCount'], 36 | reviewCount: noteBook['reviewCount'], 37 | bookType: book['type'], 38 | lastReadDate: lastReadDate, 39 | pcUrl: pcUrl 40 | }; 41 | return metaData; 42 | }; 43 | 44 | const convertTagToBiLink = (review: string) => { 45 | return review.replace(/(?<=^|\s)#([^\s]+)/g, '[[$1]]'); 46 | }; 47 | 48 | export const parseHighlights = ( 49 | highlightData: HighlightResponse, 50 | reviewData: BookReviewResponse 51 | ): Highlight[] => { 52 | const convertTags = get(settingsStore).convertTags; 53 | 54 | return highlightData.updated.map((highlight) => { 55 | const highlightRange = highlight.range; 56 | let reviewContent; 57 | if (reviewData.reviews) { 58 | const review = reviewData.reviews 59 | .map((review) => review.review) 60 | .filter((review) => review.range === highlightRange) 61 | .first(); 62 | if (review) { 63 | reviewContent = convertTags ? convertTagToBiLink(review.content) : review.content; 64 | } 65 | } 66 | 67 | const chapterInfo = highlightData.chapters 68 | .filter((chapter) => chapter.chapterUid === highlight.chapterUid) 69 | .first(); 70 | const intentMarkText = addIndentToParagraphs(highlight.markText); 71 | return { 72 | bookmarkId: highlight.bookmarkId?.replace(/[_~]/g, '-'), 73 | created: highlight.createTime, 74 | createTime: window.moment(highlight.createTime * 1000).format('YYYY-MM-DD HH:mm:ss'), 75 | chapterUid: highlight.chapterUid, 76 | chapterIdx: chapterInfo?.chapterIdx || highlight.chapterUid, 77 | refMpReviewId: highlight.refMpReviewId, 78 | range: highlight.range, 79 | style: highlight.style, 80 | colorStyle: highlight.colorStyle, 81 | chapterTitle: chapterInfo?.title || '未知章节', 82 | markText: intentMarkText, 83 | reviewContent: addIndentToParagraphs(reviewContent) 84 | }; 85 | }); 86 | }; 87 | 88 | const addIndentToParagraphs = (content: string): string => { 89 | if (content === undefined || content == '') { 90 | return content; 91 | } 92 | // 将字符串按换行符分割成段落数组 93 | const paragraphs = content.split('\n'); 94 | 95 | // 遍历段落数组,从第二个段落开始前面加上两个空格 96 | for (let i = 1; i < paragraphs.length; i++) { 97 | paragraphs[i] = ' ' + paragraphs[i]; 98 | } 99 | 100 | // 将段落数组重新组合成一个字符串 101 | return paragraphs.join('\n'); 102 | }; 103 | 104 | export const parseArticleHighlightReview = ( 105 | chapters: Chapter[], 106 | highlights: Highlight[], 107 | reviews?: Review[] 108 | ): ChapterHighlightReview[] => { 109 | const chapterResult: ChapterHighlightReview[] = []; 110 | 111 | for (const chapter of chapters) { 112 | const refMpReviewId = chapter.refMpReviewId; 113 | const chapterTitle = chapter.title; 114 | 115 | // find highlights by chapterUid 116 | const chapterHighlights = highlights 117 | .filter((highlight) => highlight.refMpReviewId === refMpReviewId) 118 | .sort((o1, o2) => { 119 | const o1Start = parseInt(o1.range.split('-')[0]); 120 | const o2Start = parseInt(o2.range.split('-')[0]); 121 | return o1Start - o2Start; 122 | }); 123 | let chapterReviews; 124 | if (chapterHighlights && chapterHighlights.length > 0 && reviews) { 125 | chapterReviews = reviews 126 | .filter((review) => refMpReviewId == review.refMpInfo?.reviewId) 127 | .sort((o1, o2) => { 128 | if (o1.range === undefined && o2.range === undefined) { 129 | return 0; 130 | } else if (o1.range === undefined) { 131 | return 1; 132 | } else if (o2.range === undefined) { 133 | return -1; 134 | } else { 135 | const o1Start = parseInt(o1.range.split('-')[0]); 136 | const o2Start = parseInt(o2.range.split('-')[0]); 137 | return o1Start - o2Start; 138 | } 139 | }); 140 | } 141 | 142 | if (chapterHighlights && chapterHighlights.length > 0) { 143 | chapterResult.push({ 144 | chapterTitle: chapterTitle, 145 | level: chapter.level, 146 | isMPChapter: chapter.isMPChapter, 147 | chapterReviews: chapterReviews, 148 | highlights: chapterHighlights 149 | }); 150 | } 151 | } 152 | 153 | return chapterResult.sort((o1, o2) => o1.chapterIdx - o2.chapterIdx); 154 | }; 155 | export const parseChapterHighlightReview = ( 156 | chapters: Chapter[], 157 | highlights: Highlight[], 158 | reviews?: Review[] 159 | ): ChapterHighlightReview[] => { 160 | const chapterResult: ChapterHighlightReview[] = []; 161 | 162 | for (const chapter of chapters) { 163 | const chapterUid = chapter.chapterUid; 164 | const chapterIdx = chapter.chapterIdx; 165 | const chapterTitle = chapter.title; 166 | 167 | // find highlights by chapterUid 168 | const chapterHighlights = highlights 169 | .filter((highlight) => highlight.chapterUid == chapterUid) 170 | .sort((o1, o2) => { 171 | const o1Start = parseInt(o1.range.split('-')[0]); 172 | const o2Start = parseInt(o2.range.split('-')[0]); 173 | return o1Start - o2Start; 174 | }); 175 | let chapterReviews; 176 | if (chapterHighlights && chapterHighlights.length > 0 && reviews) { 177 | chapterReviews = reviews 178 | .filter((review) => chapterUid == review.chapterUid && review.type == 1) 179 | .sort((o1, o2) => { 180 | if (o1.range === undefined && o2.range === undefined) { 181 | return 0; 182 | } else if (o1.range === undefined) { 183 | return 1; 184 | } else if (o2.range === undefined) { 185 | return -1; 186 | } else { 187 | const o1Start = parseInt(o1.range.split('-')[0]); 188 | const o2Start = parseInt(o2.range.split('-')[0]); 189 | return o1Start - o2Start; 190 | } 191 | }); 192 | } 193 | 194 | const showEmptyChapterTitleToggle = get(settingsStore).showEmptyChapterTitleToggle; 195 | // if showEmptyChapterTitle is true, will set chapter even there is no highlight in this chapter 196 | if ((chapterHighlights && chapterHighlights.length > 0) || showEmptyChapterTitleToggle) { 197 | chapterResult.push({ 198 | chapterUid: chapterUid, 199 | chapterIdx: chapterIdx, 200 | chapterTitle: chapterTitle, 201 | level: chapter.level, 202 | isMPChapter: chapter.isMPChapter, 203 | chapterReviews: chapterReviews, 204 | highlights: chapterHighlights 205 | }); 206 | } 207 | } 208 | 209 | return chapterResult.sort((o1, o2) => o1.chapterIdx - o2.chapterIdx); 210 | }; 211 | 212 | export const parseChapterResp = ( 213 | chapterResp: ChapterResponse, 214 | highlightResp: HighlightResponse 215 | ): Chapter[] => { 216 | if (chapterResp === undefined) { 217 | return []; 218 | } 219 | 220 | if (chapterResp.data !== undefined && chapterResp.data[0].updated.length > 0) { 221 | return chapterResp.data[0].updated; 222 | } 223 | 224 | if (highlightResp.refMpInfos !== undefined) { 225 | return highlightResp.refMpInfos.map((mpInfo) => { 226 | return { 227 | refMpReviewId: mpInfo.reviewId, 228 | updateTime: mpInfo.createTime, 229 | title: mpInfo.title, 230 | isMPChapter: 1, 231 | level: 2 232 | }; 233 | }); 234 | } 235 | return []; 236 | }; 237 | 238 | export const parseDailyNoteReferences = (notebooks: Notebook[]): DailyNoteReferenece[] => { 239 | const today = window.moment().format('YYYYMMDD'); 240 | const todayHighlightBlocks: DailyNoteReferenece[] = []; 241 | for (const notebook of notebooks) { 242 | const chapterHighlights = notebook.chapterHighlights; 243 | const todayHighlights = chapterHighlights 244 | .flatMap((chapterHighlight) => chapterHighlight.highlights) 245 | .filter((highlight) => { 246 | const createTime = window.moment(highlight.created * 1000).format('YYYYMMDD'); 247 | return today === createTime; 248 | }); 249 | const refBlocks: RefBlockDetail[] = []; 250 | if (todayHighlights) { 251 | for (const highlight of todayHighlights) { 252 | refBlocks.push({ 253 | refBlockId: highlight.bookmarkId, 254 | createTime: highlight.created 255 | }); 256 | } 257 | } 258 | // only record book have notes 259 | if (refBlocks.length > 0) { 260 | todayHighlightBlocks.push({ 261 | metaData: notebook.metaData, 262 | refBlocks: refBlocks 263 | }); 264 | } 265 | } 266 | return todayHighlightBlocks; 267 | }; 268 | 269 | export const parseReviews = (resp: BookReviewResponse): Review[] => { 270 | const convertTags = get(settingsStore).convertTags; 271 | return resp.reviews.map((reviewData) => { 272 | const review = reviewData.review; 273 | const created = review.createTime; 274 | const createTime = window.moment(created * 1000).format('YYYY-MM-DD HH:mm:ss'); 275 | 276 | const mdContent = review.htmlContent 277 | ? NodeHtmlMarkdown.translate(review.htmlContent) 278 | : null; 279 | const content = mdContent || review.content; 280 | const finalMdContent = convertTags ? convertTagToBiLink(content) : content; 281 | 282 | const reviewId: string = review.reviewId; 283 | return { 284 | bookId: review.bookId, 285 | created: created, 286 | createTime: createTime, 287 | chapterUid: review.chapterUid, 288 | chapterTitle: review.chapterTitle || review.refMpInfo?.title, 289 | content: convertTags ? convertTagToBiLink(review.content) : review.content, 290 | reviewId: reviewId?.replace(/[_~]/g, '-'), 291 | mdContent: finalMdContent, 292 | range: review.range, 293 | abstract: review.abstract, 294 | type: review.type 295 | }; 296 | }); 297 | }; 298 | 299 | export const parseChapterReviews = (resp: BookReviewResponse): BookReview => { 300 | const reviews = parseReviews(resp); 301 | const chapterReviews = reviews.filter((review) => review.type == 1); 302 | 303 | chapterReviews.sort((o1, o2) => { 304 | if (o1.range === undefined && o2.range === undefined) { 305 | return 0; 306 | } else if (o1.range === undefined) { 307 | return 1; 308 | } else if (o2.range === undefined) { 309 | return -1; 310 | } else { 311 | const o1Start = parseInt(o1.range.split('-')[0]); 312 | const o2Start = parseInt(o2.range.split('-')[0]); 313 | return o1Start - o2Start; 314 | } 315 | }); 316 | 317 | const entireReviews = reviews.filter((review) => review.type == 4); 318 | const chapterResult = new Map(); 319 | for (const review of chapterReviews) { 320 | const chapterUid = review.chapterUid; 321 | const chapterTitle = review.chapterTitle; 322 | const existChapter = chapterResult.get(review.chapterUid); 323 | if (existChapter == null) { 324 | const chapter: ChapterReview = { 325 | chapterUid: chapterUid, 326 | chapterTitle: chapterTitle, 327 | reviews: [], 328 | chapterReviews: [] 329 | }; 330 | if (review.range) { 331 | chapter.reviews.push(review); 332 | } else { 333 | chapter.chapterReviews.push(review); 334 | } 335 | chapterResult.set(review.chapterUid, chapter); 336 | } else { 337 | const chapterRview: ChapterReview = chapterResult.get(review.chapterUid); 338 | if (review.range) { 339 | chapterRview.reviews.push(review); 340 | } else { 341 | chapterRview.chapterReviews.push(review); 342 | } 343 | } 344 | } 345 | const chapterReviewResult: ChapterReview[] = Array.from(chapterResult.values()).sort( 346 | (o1, o2) => o1.chapterUid - o2.chapterUid 347 | ); 348 | return { 349 | bookReviews: entireReviews, 350 | chapterReviews: chapterReviewResult 351 | }; 352 | }; 353 | 354 | const getFa = (id: string): [string, string[]] => { 355 | if (/^\d*$/.test(id)) { 356 | const c: string[] = []; 357 | for (let a = 0; a < id.length; a += 9) { 358 | const b = id.slice(a, Math.min(a + 9, id.length)); 359 | c.push(parseInt(b, 10).toString(16)); 360 | } 361 | return ['3', c]; 362 | } 363 | let d = ''; 364 | for (let i = 0; i < id.length; i++) { 365 | d += id.charCodeAt(i).toString(16); 366 | } 367 | return ['4', [d]]; 368 | }; 369 | 370 | const getPcUrl = (bookId: string): string => { 371 | const str = CryptoJS.MD5(bookId).toString(CryptoJS.enc.Hex); 372 | const fa = getFa(bookId); 373 | let strSub = str.substr(0, 3); 374 | strSub += fa[0]; 375 | strSub += '2' + str.substr(str.length - 2, 2); 376 | 377 | for (let j = 0; j < fa[1].length; j++) { 378 | const n = fa[1][j].length.toString(16); 379 | if (n.length === 1) { 380 | strSub += '0' + n; 381 | } else { 382 | strSub += n; 383 | } 384 | strSub += fa[1][j]; 385 | if (j < fa[1].length - 1) { 386 | strSub += 'g'; 387 | } 388 | } 389 | 390 | if (strSub.length < 20) { 391 | strSub += str.substr(0, 20 - strSub.length); 392 | } 393 | 394 | strSub += CryptoJS.MD5(strSub).toString(CryptoJS.enc.Hex).substr(0, 3); 395 | const prefix = 'https://weread.qq.com/web/reader/'; 396 | return prefix + strSub; 397 | }; 398 | -------------------------------------------------------------------------------- /src/settingTab.ts: -------------------------------------------------------------------------------- 1 | import WereadPlugin from 'main'; 2 | import { PluginSettingTab, Setting, App, Platform } from 'obsidian'; 3 | import { settingsStore } from './settings'; 4 | import { get } from 'svelte/store'; 5 | import WereadLoginModel from './components/wereadLoginModel'; 6 | import WereadLogoutModel from './components/wereadLogoutModel'; 7 | import CookieCloudConfigModal from './components/cookieCloudConfigModel'; 8 | import { TemplateEditorWindow } from './components/templateEditorWindow'; 9 | 10 | import pickBy from 'lodash.pickby'; 11 | import { Renderer } from './renderer'; 12 | import { getEncodeCookieString } from './utils/cookiesUtil'; 13 | import { Notice } from 'obsidian'; 14 | 15 | export class WereadSettingsTab extends PluginSettingTab { 16 | private plugin: WereadPlugin; 17 | private renderer: Renderer; 18 | 19 | constructor(app: App, plugin: WereadPlugin) { 20 | super(app, plugin); 21 | this.plugin = plugin; 22 | this.renderer = new Renderer(); 23 | } 24 | 25 | display() { 26 | const { containerEl } = this; 27 | containerEl.empty(); 28 | containerEl.createEl('h2', { text: '设置微信读书插件' }); 29 | 30 | this.showLoginMethod(); 31 | 32 | const isCookieValid = get(settingsStore).isCookieValid; 33 | const loginMethod = get(settingsStore).loginMethod; 34 | 35 | if (loginMethod === 'scan') { 36 | if (Platform.isDesktopApp) { 37 | if (isCookieValid) { 38 | this.showLogout(); 39 | } else { 40 | this.showLogin(); 41 | } 42 | } else { 43 | if (isCookieValid) { 44 | this.showMobileLogout(); 45 | } else { 46 | this.showMobileLogin(); 47 | } 48 | } 49 | } else { 50 | this.showCookieCloudInfo(); 51 | } 52 | 53 | this.notebookFolder(); 54 | this.notebookBlacklist(); 55 | this.noteCountLimit(); 56 | this.fileNameType(); 57 | this.removeParens(); 58 | this.subFolderType(); 59 | this.convertTagToggle(); 60 | this.saveArticleToggle(); 61 | this.saveReadingInfoToggle(); 62 | this.showEmptyChapterTitleToggle(); 63 | this.dailyNotes(); 64 | const dailyNotesToggle = get(settingsStore).dailyNotesToggle; 65 | if (dailyNotesToggle) { 66 | this.dailyNotesFolder(); 67 | this.dailyNoteFormat(); 68 | this.insertAfter(); 69 | } 70 | this.template(); 71 | if (Platform.isDesktopApp) { 72 | this.showDebugHelp(); 73 | } 74 | } 75 | 76 | private showMobileLogin() { 77 | const info = this.containerEl.createDiv(); 78 | info.setText('微信读书未登录,请先在电脑端登录!'); 79 | } 80 | 81 | private showMobileLogout() { 82 | const info = this.containerEl.createDiv(); 83 | info.setText(`微信读书已登录,用户名:${get(settingsStore).user}`); 84 | } 85 | 86 | private notebookFolder(): void { 87 | new Setting(this.containerEl) 88 | .setName('笔记保存位置') 89 | .setDesc('请选择Obsidian Vault中微信读书笔记存放的位置') 90 | .addDropdown((dropdown) => { 91 | const files = (this.app.vault.adapter as any).files; 92 | const folders = pickBy(files, (val: any) => { 93 | return val.type === 'folder'; 94 | }); 95 | 96 | Object.keys(folders).forEach((val) => { 97 | dropdown.addOption(val, val); 98 | }); 99 | return dropdown 100 | .setValue(get(settingsStore).noteLocation) 101 | .onChange(async (value) => { 102 | settingsStore.actions.setNoteLocationFolder(value); 103 | }); 104 | }); 105 | } 106 | 107 | private notebookBlacklist(): void { 108 | new Setting(this.containerEl) 109 | .setName('书籍黑名单') 110 | .setDesc('请填写不同步的bookId,bookId可在meta信息中找到,多本书使用逗号「,」隔开') 111 | .addTextArea((input) => { 112 | input.setValue(get(settingsStore).notesBlacklist).onChange((value: string) => { 113 | settingsStore.actions.setNoteBlacklist(value); 114 | }); 115 | }); 116 | } 117 | 118 | private showLogin(): void { 119 | new Setting(this.containerEl).setName('登录微信读书').addButton((button) => { 120 | return button 121 | .setButtonText('登录') 122 | .setCta() 123 | .onClick(async () => { 124 | button.setDisabled(true); 125 | const logoutModel = new WereadLoginModel(this); 126 | await logoutModel.doLogin(); 127 | this.display(); 128 | }); 129 | }); 130 | } 131 | 132 | private saveArticleToggle(): void { 133 | new Setting(this.containerEl) 134 | .setName('同步公众号文章?') 135 | .setDesc('开启此选项会将同步公众号文章到单独的笔记中') 136 | .addToggle((toggle) => { 137 | return toggle.setValue(get(settingsStore).saveArticleToggle).onChange((value) => { 138 | settingsStore.actions.setSaveArticleToggle(value); 139 | this.display(); 140 | }); 141 | }); 142 | } 143 | private saveReadingInfoToggle(): void { 144 | new Setting(this.containerEl) 145 | .setName('保存阅读元数据?') 146 | .setDesc('开启此选项会阅读数据写入frontmatter') 147 | .addToggle((toggle) => { 148 | return toggle 149 | .setValue(get(settingsStore).saveReadingInfoToggle) 150 | .onChange((value) => { 151 | settingsStore.actions.setSaveReadingInfoToggle(value); 152 | this.display(); 153 | }); 154 | }); 155 | } 156 | private convertTagToggle(): void { 157 | new Setting(this.containerEl) 158 | .setName('将标签转换为双链?') 159 | .setDesc('开启此选项会笔记中的 #标签 转换为:[[标签]]') 160 | .addToggle((toggle) => { 161 | return toggle.setValue(get(settingsStore).convertTags).onChange((value) => { 162 | settingsStore.actions.setConvertTags(value); 163 | this.display(); 164 | }); 165 | }); 166 | } 167 | 168 | private dailyNotes(): void { 169 | new Setting(this.containerEl) 170 | .setName('是否保存笔记到 DailyNotes?') 171 | .setHeading() 172 | .addToggle((toggle) => { 173 | return toggle.setValue(get(settingsStore).dailyNotesToggle).onChange((value) => { 174 | console.debug('set daily notes toggle to', value); 175 | settingsStore.actions.setDailyNotesToggle(value); 176 | this.display(); 177 | }); 178 | }); 179 | } 180 | 181 | private dailyNotesFolder() { 182 | new Setting(this.containerEl) 183 | .setName('Daily Notes文件夹') 184 | .setDesc('请选择Daily Notes文件夹') 185 | .addDropdown((dropdown) => { 186 | const files = (this.app.vault.adapter as any).files; 187 | const folders = pickBy(files, (val: any) => { 188 | return val.type === 'folder'; 189 | }); 190 | 191 | Object.keys(folders).forEach((val) => { 192 | dropdown.addOption(val, val); 193 | }); 194 | return dropdown 195 | .setValue(get(settingsStore).dailyNotesLocation) 196 | .onChange(async (value) => { 197 | settingsStore.actions.setDailyNotesFolder(value); 198 | }); 199 | }); 200 | } 201 | 202 | private dailyNoteFormat() { 203 | new Setting(this.containerEl) 204 | .setName('Daily Notes Format') 205 | .setDesc( 206 | '请填写Daily Notes文件名格式,支持官方Daily Notes插件的格式,比如:YYYY-MM-DD \ 207 | 和 Periodic Notes的嵌套格式,比如 YYYY/[W]ww/YYYY-MM-DD' 208 | ) 209 | .addText((input) => { 210 | input.setValue(get(settingsStore).dailyNotesFormat).onChange((value: string) => { 211 | settingsStore.actions.setDailyNotesFormat(value); 212 | }); 213 | }); 214 | } 215 | 216 | private insertAfter() { 217 | new Setting(this.containerEl) 218 | .setName('在特定区间之内插入') 219 | .setDesc( 220 | '请填写Daily Notes中希望读书笔记插入的区间,使用前记得修改Daily Notes模板🫡, 💥注意: 区间之内的内容会被覆盖,请不要在区间内修改内容,' 221 | ) 222 | .addText((input) => { 223 | input.setValue(get(settingsStore).insertAfter).onChange((value: string) => { 224 | settingsStore.actions.setInsertAfter(value); 225 | }); 226 | }) 227 | .addButton((btn) => { 228 | return (btn.setButtonText('至').buttonEl.style.borderStyle = 'none'); 229 | }) 230 | .addText((input) => { 231 | input.setValue(get(settingsStore).insertBefore).onChange((value: string) => { 232 | settingsStore.actions.setInsertBefore(value); 233 | }); 234 | }); 235 | } 236 | 237 | private subFolderType(): void { 238 | new Setting(this.containerEl) 239 | .setName('文件夹分类') 240 | .setDesc('请选择按照哪个维度对笔记文件进行分类') 241 | .addDropdown((dropdown) => { 242 | dropdown.addOptions({ 243 | '-1': '无分类', 244 | title: '书名', 245 | category: '图书分类' 246 | }); 247 | return dropdown 248 | .setValue(get(settingsStore).subFolderType) 249 | .onChange(async (value) => { 250 | settingsStore.actions.setSubFolderType(value); 251 | }); 252 | }); 253 | } 254 | 255 | private fileNameType(): void { 256 | new Setting(this.containerEl) 257 | .setName('文件名模板') 258 | .setDesc('你选择你喜欢的文件名模板,重复的书会在文件名后加上ID') 259 | .addDropdown((dropdown) => { 260 | dropdown.addOptions({ 261 | BOOK_ID: 'bookId', 262 | BOOK_NAME: '书名', 263 | BOOK_NAME_AUTHOR: '书名-作者名', 264 | BOOK_NAME_BOOKID: '书名-bookId' 265 | }); 266 | return dropdown 267 | .setValue(get(settingsStore).fileNameType) 268 | .onChange(async (value) => { 269 | settingsStore.actions.setFileNameType(value); 270 | }); 271 | }); 272 | } 273 | 274 | private removeParens(): void { 275 | new Setting(this.containerEl) 276 | .setName('移除书名中的括号内容') 277 | .setDesc('是否移除书名中的括号及其内部文字(注:谨慎启用,可能导致重名)') 278 | .addToggle((toggle) => { 279 | return toggle.setValue(get(settingsStore).removeParens).onChange((value) => { 280 | settingsStore.actions.setRemoveParens(value); 281 | this.display(); 282 | }); 283 | }); 284 | // 白名单 textarea,仅在启用移除括号时显示 285 | if (get(settingsStore).removeParens) { 286 | new Setting(this.containerEl) 287 | .setName('括号移除白名单') 288 | .setDesc('如文件名包含下列任意文本,则不移除括号。每行一个关键词。') 289 | .addTextArea((text) => { 290 | text.setValue(get(settingsStore).removeParensWhitelist || '').onChange( 291 | (value: string) => { 292 | settingsStore.actions.setRemoveParensWhitelist(value); 293 | } 294 | ); 295 | }); 296 | } 297 | } 298 | 299 | private showLogout(): void { 300 | document.createRange().createContextualFragment; 301 | const desc = document.createRange().createContextualFragment( 302 | `1. 登录:点击登录按钮,在弹出页面【扫码登录】。 303 | 2. 注销:点击注销,在弹出书架页面右上角点击头像,下拉菜单选择【退出登录】` 304 | ); 305 | 306 | new Setting(this.containerEl) 307 | .setName(`微信读书已登录,用户名: ${get(settingsStore).user}`) 308 | .setDesc(desc) 309 | .addButton((button) => { 310 | return button 311 | .setButtonText('注销') 312 | .setCta() 313 | .onClick(async () => { 314 | button.setDisabled(true); 315 | const logoutModel = new WereadLogoutModel(this); 316 | await logoutModel.doLogout(); 317 | this.display(); 318 | }); 319 | }) 320 | .addButton((button) => { 321 | return button 322 | .setButtonText('拷贝Cookie') 323 | .setCta() 324 | .onClick(async () => { 325 | const cookieStr = getEncodeCookieString(); 326 | navigator.clipboard.writeText(cookieStr).then( 327 | function () { 328 | new Notice('拷贝Cookie到剪切板成功!'); 329 | }, 330 | function (error) { 331 | new Notice('拷贝Cookie到剪切板失败!'); 332 | console.error('拷贝微信读书Cookie失败', error); 333 | } 334 | ); 335 | }); 336 | }); 337 | } 338 | 339 | private template(): void { 340 | new Setting(this.containerEl) 341 | .setName('笔记模板设置') 342 | .setHeading() 343 | .addButton((button) => { 344 | return button 345 | .setButtonText('编辑模板') 346 | .setCta() 347 | .onClick(() => { 348 | const editorWindow = new TemplateEditorWindow( 349 | this.app, 350 | get(settingsStore).template, 351 | (newTemplate: string) => { 352 | settingsStore.actions.setTemplate(newTemplate); 353 | } 354 | ); 355 | editorWindow.open(); 356 | }); 357 | }); 358 | } 359 | 360 | private noteCountLimit() { 361 | new Setting(this.containerEl) 362 | .setName('笔记划线数量最小值') 363 | .setDesc('划线数量小于该值的笔记将不会被同步') 364 | .addDropdown((dropdown) => { 365 | dropdown 366 | .addOptions({ 367 | '-1': '无限制', 368 | '3': '3条', 369 | '5': '5条', 370 | '10': '10条', 371 | '15': '15条', 372 | '30': '30条' 373 | }) 374 | .setValue(get(settingsStore).noteCountLimit.toString()) 375 | .onChange(async (value) => { 376 | console.log('[weread plugin] new note count limit', value); 377 | settingsStore.actions.setNoteCountLimit(+value); 378 | }); 379 | }); 380 | } 381 | 382 | private showDebugHelp() { 383 | const info = this.containerEl.createDiv(); 384 | info.setAttr('align', 'center'); 385 | info.setText( 386 | '查看控制台日志: 使用以下快捷键快速打开控制台,查看本插件以及其他插件的运行日志' 387 | ); 388 | 389 | const keys = this.containerEl.createDiv(); 390 | keys.setAttr('align', 'center'); 391 | keys.style.margin = '10px'; 392 | if (Platform.isMacOS === true) { 393 | keys.createEl('kbd', { text: 'CMD (⌘) + OPTION (⌥) + I' }); 394 | } else { 395 | keys.createEl('kbd', { text: 'CTRL + SHIFT + I' }); 396 | } 397 | } 398 | 399 | private showEmptyChapterTitleToggle(): void { 400 | new Setting(this.containerEl) 401 | .setName('展示空白章节标题?') 402 | .setDesc('如果启用,则章节内没有划线也将展示章节标题') 403 | .setHeading() 404 | .addToggle((toggle) => { 405 | return toggle 406 | .setValue(get(settingsStore).showEmptyChapterTitleToggle) 407 | .onChange((value) => { 408 | console.debug('set empty chapter title toggle to', value); 409 | settingsStore.actions.setEmptyChapterTitleToggle(value); 410 | this.display(); 411 | }); 412 | }); 413 | } 414 | 415 | private showLoginMethod(): void { 416 | new Setting(this.containerEl).setName('登录方式').addDropdown((dropdown) => { 417 | dropdown.addOptions({ 418 | scan: '扫码登录', 419 | cookieCloud: 'CookieCloud登录' 420 | }); 421 | return dropdown.setValue(get(settingsStore).loginMethod).onChange(async (value) => { 422 | console.debug('set login method to', value); 423 | settingsStore.actions.setLoginMethod(value); 424 | settingsStore.actions.clearCookies(); 425 | this.display(); 426 | }); 427 | }); 428 | } 429 | 430 | private showCookieCloudInfo(): void { 431 | const isCookieValid = get(settingsStore).isCookieValid; 432 | let name = '配置 CookieCloud'; 433 | if (isCookieValid) { 434 | name = `微信读书已登录,用户名: ${get(settingsStore).user}`; 435 | } 436 | 437 | new Setting(this.containerEl).setName(name).addButton((button) => { 438 | return button.setIcon('settings-2').onClick(async () => { 439 | button.setDisabled(true); 440 | const configModel = new CookieCloudConfigModal(this.app, this); 441 | configModel.open(); 442 | this.display(); 443 | }); 444 | }); 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | .weread-view-content { 2 | padding: 0 !important; 3 | overflow: hidden !important; 4 | } 5 | 6 | .weread-frame { 7 | width: 100%; 8 | height: 100%; 9 | border: none; 10 | background-clip: content-box; 11 | } 12 | 13 | /* Template Editor Modal Styles */ 14 | .weread-template-editor-modal { 15 | width: 95vw; 16 | height: 90vh; 17 | max-width: none; 18 | max-height: none; 19 | } 20 | 21 | .weread-template-editor-modal .modal-content { 22 | height: 100%; 23 | display: flex; 24 | flex-direction: column; 25 | padding: 0; 26 | } 27 | 28 | .weread-editor-titlebar { 29 | padding: 12px 20px; 30 | border-bottom: 1px solid var(--background-modifier-border); 31 | display: flex; 32 | align-items: center; 33 | justify-content: space-between; 34 | background-color: var(--background-secondary); 35 | } 36 | 37 | .weread-editor-titlebar h2 { 38 | margin: 0; 39 | font-size: 1.3em; 40 | font-weight: 600; 41 | flex: 1; 42 | } 43 | 44 | .weread-editor-buttons { 45 | display: flex; 46 | gap: 8px; 47 | } 48 | 49 | .weread-editor-container { 50 | display: flex; 51 | flex: 1; 52 | overflow: hidden; 53 | height: 100%; 54 | } 55 | 56 | /* 左侧:说明文档面板 */ 57 | .weread-editor-instructions { 58 | width: 300px; 59 | min-width: 250px; 60 | flex-shrink: 0; 61 | background-color: var(--background-secondary); 62 | border-right: 1px solid var(--background-modifier-border); 63 | overflow-y: auto; 64 | padding: 20px; 65 | } 66 | 67 | .weread-editor-instructions h2 { 68 | font-size: 1.2em; 69 | margin-bottom: 16px; 70 | color: var(--text-normal); 71 | } 72 | 73 | .weread-editor-instructions h3 { 74 | font-size: 1em; 75 | margin-top: 16px; 76 | margin-bottom: 8px; 77 | color: var(--text-normal); 78 | } 79 | 80 | .weread-editor-instructions p, 81 | .weread-editor-instructions ul, 82 | .weread-editor-instructions ol { 83 | font-size: 0.9em; 84 | line-height: 1.6; 85 | margin-bottom: 12px; 86 | color: var(--text-muted); 87 | } 88 | 89 | .weread-editor-instructions code { 90 | background-color: var(--background-primary-alt); 91 | padding: 2px 6px; 92 | border-radius: 3px; 93 | font-family: var(--font-monospace); 94 | font-size: 0.9em; 95 | color: var(--text-accent); 96 | } 97 | 98 | .weread-editor-instructions ul, 99 | .weread-editor-instructions ol { 100 | padding-left: 20px; 101 | } 102 | 103 | /* 中间:编辑器面板 */ 104 | .weread-editor-panel { 105 | flex: 1; 106 | display: flex; 107 | flex-direction: column; 108 | background-color: var(--background-primary); 109 | border-right: 1px solid var(--background-modifier-border); 110 | min-width: 0; 111 | } 112 | 113 | .weread-editor-panel .panel-header { 114 | padding: 10px 16px; 115 | background-color: var(--background-secondary); 116 | border-bottom: 1px solid var(--background-modifier-border); 117 | font-size: 0.9em; 118 | font-weight: 500; 119 | color: var(--text-normal); 120 | } 121 | 122 | .weread-editor-textarea { 123 | flex: 1; 124 | width: 100%; 125 | padding: 16px; 126 | border: none; 127 | resize: none; 128 | font-family: var(--font-monospace); 129 | font-size: 13px; 130 | line-height: 1.6; 131 | background-color: var(--background-primary); 132 | color: var(--text-normal); 133 | outline: none; 134 | } 135 | 136 | .weread-editor-textarea:focus { 137 | outline: none; 138 | } 139 | 140 | /* 右侧:预览面板 */ 141 | .weread-editor-preview { 142 | flex: 1; 143 | display: flex; 144 | flex-direction: column; 145 | background-color: var(--background-primary); 146 | min-width: 0; 147 | } 148 | 149 | .weread-editor-preview .panel-header { 150 | padding: 10px 16px; 151 | background-color: var(--background-secondary); 152 | border-bottom: 1px solid var(--background-modifier-border); 153 | font-size: 0.9em; 154 | font-weight: 500; 155 | color: var(--text-normal); 156 | display: flex; 157 | justify-content: space-between; 158 | align-items: center; 159 | } 160 | 161 | /* Toggle 开关容器 */ 162 | .weread-toggle-container { 163 | display: flex; 164 | gap: 16px; 165 | align-items: center; 166 | } 167 | 168 | .weread-toggle-wrapper { 169 | display: flex; 170 | align-items: center; 171 | gap: 8px; 172 | } 173 | 174 | .weread-toggle-label { 175 | font-size: 0.85em; 176 | color: var(--text-normal); 177 | white-space: nowrap; 178 | } 179 | 180 | /* Toggle 开关样式 */ 181 | .weread-toggle-switch { 182 | position: relative; 183 | width: 40px; 184 | height: 20px; 185 | background-color: var(--background-modifier-border); 186 | border-radius: 10px; 187 | cursor: pointer; 188 | transition: background-color 0.3s; 189 | } 190 | 191 | .weread-toggle-switch::after { 192 | content: ''; 193 | position: absolute; 194 | top: 2px; 195 | left: 2px; 196 | width: 16px; 197 | height: 16px; 198 | background-color: white; 199 | border-radius: 50%; 200 | transition: transform 0.3s; 201 | } 202 | 203 | .weread-toggle-switch.is-enabled { 204 | background-color: var(--interactive-accent); 205 | } 206 | 207 | .weread-toggle-switch.is-enabled::after { 208 | transform: translateX(20px); 209 | } 210 | 211 | .weread-toggle-switch:hover { 212 | opacity: 0.8; 213 | } 214 | 215 | .weread-editor-preview .preview-content { 216 | flex: 1; 217 | overflow-y: auto; 218 | padding: 16px; 219 | } 220 | 221 | .weread-editor-preview .preview-text { 222 | color: var(--text-normal); 223 | margin: 0; 224 | } 225 | 226 | .weread-editor-preview .preview-text.preview-source-mode pre { 227 | font-family: var(--font-monospace); 228 | font-size: 13px; 229 | line-height: 1.6; 230 | white-space: pre-wrap; 231 | word-wrap: break-word; 232 | margin: 0; 233 | padding: 0; 234 | } 235 | 236 | .weread-editor-preview .preview-text.markdown-preview-view { 237 | font-family: var(--font-text); 238 | font-size: var(--font-text-size); 239 | line-height: var(--line-height-normal); 240 | padding: 0; 241 | } 242 | 243 | .weread-editor-preview .markdown-preview-view h1, 244 | .weread-editor-preview .markdown-preview-view h2, 245 | .weread-editor-preview .markdown-preview-view h3, 246 | .weread-editor-preview .markdown-preview-view h4, 247 | .weread-editor-preview .markdown-preview-view h5, 248 | .weread-editor-preview .markdown-preview-view h6 { 249 | font-weight: var(--font-bold); 250 | line-height: var(--line-height-tight); 251 | margin-top: 1em; 252 | margin-bottom: 0.5em; 253 | } 254 | 255 | .weread-editor-preview .markdown-preview-view p { 256 | margin-bottom: 1em; 257 | } 258 | 259 | .weread-editor-preview .markdown-preview-view ul, 260 | .weread-editor-preview .markdown-preview-view ol { 261 | padding-left: 2em; 262 | margin-bottom: 1em; 263 | } 264 | 265 | .weread-editor-preview .markdown-preview-view code { 266 | font-family: var(--font-monospace); 267 | background-color: var(--code-background); 268 | padding: 0.2em 0.4em; 269 | border-radius: 3px; 270 | } 271 | 272 | .weread-editor-preview .markdown-preview-view pre { 273 | background-color: var(--code-background); 274 | padding: 1em; 275 | border-radius: 5px; 276 | overflow-x: auto; 277 | } 278 | 279 | .weread-editor-preview .markdown-preview-view blockquote { 280 | border-left: 3px solid var(--background-modifier-border); 281 | padding-left: 1em; 282 | margin-left: 0; 283 | color: var(--text-muted); 284 | } 285 | 286 | .weread-editor-preview .error-message { 287 | color: #ffffff; 288 | background-color: #c73e1d; 289 | border: 1px solid #a02f18; 290 | padding: 16px; 291 | text-align: center; 292 | border-radius: 6px; 293 | margin: 8px 0; 294 | display: none; 295 | font-weight: 500; 296 | font-size: 0.95em; 297 | line-height: 1.5; 298 | } 299 | 300 | /* Template Preview Modal Styles */ 301 | .weread-template-preview-modal { 302 | width: 95vw; 303 | height: 90vh; 304 | max-width: none; 305 | max-height: none; 306 | } 307 | 308 | /* 模态窗口需要绝对定位以支持拖动 */ 309 | .modal.weread-template-preview-modal { 310 | position: fixed !important; 311 | } 312 | 313 | .weread-template-preview-modal .modal-content { 314 | height: 100%; 315 | display: flex; 316 | flex-direction: column; 317 | } 318 | 319 | .weread-modal-title { 320 | padding: 12px 20px; 321 | border-bottom: 1px solid var(--background-modifier-border); 322 | display: flex; 323 | align-items: center; 324 | justify-content: space-between; 325 | user-select: none; 326 | background-color: var(--background-secondary); 327 | } 328 | 329 | .weread-modal-title:active { 330 | cursor: move; 331 | } 332 | 333 | .weread-modal-title-text { 334 | margin: 0; 335 | font-size: 1.3em; 336 | font-weight: 600; 337 | flex: 1; 338 | } 339 | 340 | .weread-modal-controls { 341 | display: flex; 342 | gap: 8px; 343 | align-items: center; 344 | } 345 | 346 | .weread-modal-control-btn { 347 | width: 28px; 348 | height: 28px; 349 | padding: 0; 350 | border: none; 351 | border-radius: 4px; 352 | background-color: transparent; 353 | color: var(--text-muted); 354 | cursor: pointer; 355 | display: flex; 356 | align-items: center; 357 | justify-content: center; 358 | font-size: 14px; 359 | transition: all 0.2s ease; 360 | } 361 | 362 | .weread-modal-control-btn:hover { 363 | background-color: var(--background-modifier-hover); 364 | color: var(--text-normal); 365 | } 366 | 367 | .weread-template-container { 368 | display: flex; 369 | flex: 1; 370 | gap: 10px; 371 | padding: 10px; 372 | overflow: hidden; 373 | } 374 | 375 | .weread-editor-panel, 376 | .weread-preview-panel { 377 | flex: 1; 378 | display: flex; 379 | flex-direction: column; 380 | border: 1px solid var(--background-modifier-border); 381 | border-radius: 8px; 382 | overflow: hidden; 383 | background-color: var(--background-primary); 384 | } 385 | 386 | .weread-panel-header { 387 | padding: 12px 16px; 388 | background-color: var(--background-secondary); 389 | border-bottom: 1px solid var(--background-modifier-border); 390 | display: flex; 391 | align-items: center; 392 | justify-content: space-between; 393 | } 394 | 395 | .weread-panel-header h3 { 396 | margin: 0; 397 | font-size: 1.1em; 398 | font-weight: 500; 399 | color: var(--text-normal); 400 | } 401 | 402 | .weread-panel-subtitle { 403 | font-size: 0.85em; 404 | color: var(--text-muted); 405 | margin-left: 10px; 406 | } 407 | 408 | .weread-template-editor { 409 | flex: 1; 410 | width: 100%; 411 | padding: 16px; 412 | border: none; 413 | resize: none; 414 | font-family: var(--font-monospace); 415 | font-size: 0.9em; 416 | line-height: 1.6; 417 | background-color: var(--background-primary); 418 | color: var(--text-normal); 419 | outline: none; 420 | } 421 | 422 | .weread-template-editor:focus { 423 | outline: none; 424 | } 425 | 426 | .weread-preview-content { 427 | flex: 1; 428 | overflow-y: auto; 429 | padding: 16px; 430 | background-color: var(--background-primary); 431 | } 432 | 433 | .weread-preview-text { 434 | margin: 0; 435 | padding: 0; 436 | font-family: var(--font-monospace); 437 | font-size: 0.85em; 438 | line-height: 1.6; 439 | white-space: pre-wrap; 440 | word-wrap: break-word; 441 | color: var(--text-normal); 442 | } 443 | 444 | .weread-preview-error { 445 | padding: 20px; 446 | text-align: center; 447 | color: var(--text-error); 448 | } 449 | 450 | .weread-preview-error span { 451 | font-size: 1.2em; 452 | font-weight: 600; 453 | display: block; 454 | margin-bottom: 10px; 455 | } 456 | 457 | .weread-preview-error p { 458 | margin: 5px 0; 459 | font-size: 0.9em; 460 | color: var(--text-muted); 461 | } 462 | 463 | .weread-modal-footer { 464 | display: flex; 465 | justify-content: flex-end; 466 | gap: 10px; 467 | padding: 16px 20px; 468 | border-top: 1px solid var(--background-modifier-border); 469 | } 470 | 471 | .weread-modal-footer button { 472 | padding: 8px 20px; 473 | border-radius: 5px; 474 | font-size: 0.95em; 475 | cursor: pointer; 476 | border: 1px solid var(--background-modifier-border); 477 | background-color: var(--background-primary); 478 | color: var(--text-normal); 479 | transition: all 0.2s ease; 480 | } 481 | 482 | .weread-modal-footer button:hover { 483 | background-color: var(--background-modifier-hover); 484 | } 485 | 486 | .weread-modal-footer button.mod-cta { 487 | background-color: var(--interactive-accent); 488 | color: var(--text-on-accent); 489 | border-color: var(--interactive-accent); 490 | } 491 | 492 | .weread-modal-footer button.mod-cta:hover { 493 | background-color: var(--interactive-accent-hover); 494 | border-color: var(--interactive-accent-hover); 495 | } 496 | 497 | /* Scrollbar styles */ 498 | .weread-preview-content::-webkit-scrollbar, 499 | .weread-template-editor::-webkit-scrollbar { 500 | width: 8px; 501 | } 502 | 503 | .weread-preview-content::-webkit-scrollbar-track, 504 | .weread-template-editor::-webkit-scrollbar-track { 505 | background: var(--background-secondary); 506 | } 507 | 508 | .weread-preview-content::-webkit-scrollbar-thumb, 509 | .weread-template-editor::-webkit-scrollbar-thumb { 510 | background: var(--background-modifier-border); 511 | border-radius: 4px; 512 | } 513 | 514 | .weread-preview-content::-webkit-scrollbar-thumb:hover, 515 | .weread-template-editor::-webkit-scrollbar-thumb:hover { 516 | background: var(--text-muted); 517 | } 518 | 519 | /* Resize handles */ 520 | .weread-resize-handle { 521 | position: absolute; 522 | z-index: 10; 523 | } 524 | 525 | .weread-resize-n, 526 | .weread-resize-s { 527 | left: 0; 528 | right: 0; 529 | height: 8px; 530 | } 531 | 532 | .weread-resize-n { 533 | top: 0; 534 | cursor: ns-resize; 535 | } 536 | 537 | .weread-resize-s { 538 | bottom: 0; 539 | cursor: ns-resize; 540 | } 541 | 542 | .weread-resize-e, 543 | .weread-resize-w { 544 | top: 0; 545 | bottom: 0; 546 | width: 8px; 547 | } 548 | 549 | .weread-resize-e { 550 | right: 0; 551 | cursor: ew-resize; 552 | } 553 | 554 | .weread-resize-w { 555 | left: 0; 556 | cursor: ew-resize; 557 | } 558 | 559 | .weread-resize-ne, 560 | .weread-resize-nw, 561 | .weread-resize-se, 562 | .weread-resize-sw { 563 | width: 16px; 564 | height: 16px; 565 | } 566 | 567 | .weread-resize-ne { 568 | top: 0; 569 | right: 0; 570 | cursor: nesw-resize; 571 | } 572 | 573 | .weread-resize-nw { 574 | top: 0; 575 | left: 0; 576 | cursor: nwse-resize; 577 | } 578 | 579 | .weread-resize-se { 580 | bottom: 0; 581 | right: 0; 582 | cursor: nwse-resize; 583 | } 584 | 585 | .weread-resize-sw { 586 | bottom: 0; 587 | left: 0; 588 | cursor: nesw-resize; 589 | } 590 | 591 | /* 悬停时显示调整大小手柄 */ 592 | .weread-resize-handle:hover { 593 | background-color: var(--interactive-accent); 594 | opacity: 0.3; 595 | } 596 | 597 | /* 防止窗口调整大小时选中文本 */ 598 | .weread-template-preview-modal.is-resizing, 599 | .weread-template-preview-modal.is-dragging { 600 | user-select: none; 601 | } --------------------------------------------------------------------------------