├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── README_cn.md ├── esbuild.config.mjs ├── images └── video.png ├── manifest.json ├── package.json ├── src ├── lang │ ├── helpers.ts │ └── locale │ │ ├── en.ts │ │ └── zh.ts ├── main.ts ├── meta.ts ├── settings.ts ├── settingsTab.ts └── utils.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | packages: write 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: "18.x" 23 | 24 | - name: Build plugin 25 | run: | 26 | npm install 27 | npm run build 28 | 29 | - name: Create release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: | 33 | tag="${GITHUB_REF#refs/tags/}" 34 | 35 | gh release create "$tag" \ 36 | --title="$tag" \ 37 | dist/main.js manifest.json styles.css 38 | 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | main.js 4 | data.json 5 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ExMemo-Tools - Use large models for smart document management and optimization, including relocating files, enhancing text, and generating metadata. 2 | Copyright (C) 2024 3 | 4 | GNU LESSER GENERAL PUBLIC LICENSE 5 | Version 3, 29 June 2007 6 | 7 | Copyright (C) 2007 Free Software Foundation, Inc. 8 | Everyone is permitted to copy and distribute verbatim copies 9 | of this license document, but changing it is not allowed. 10 | 11 | 12 | This version of the GNU Lesser General Public License incorporates 13 | the terms and conditions of version 3 of the GNU General Public 14 | License, supplemented by the additional permissions listed below. 15 | 16 | 0. Additional Definitions. 17 | 18 | As used herein, "this License" refers to version 3 of the GNU Lesser 19 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 20 | General Public License. 21 | 22 | "The Library" refers to a covered work governed by this License, 23 | other than an Application or a Combined Work as defined below. 24 | 25 | An "Application" is any work that makes use of an interface provided 26 | by the Library, but which is not otherwise based on the Library. 27 | Defining a subclass of a class defined by the Library is deemed a mode 28 | of using an interface provided by the Library. 29 | 30 | A "Combined Work" is a work produced by combining or linking an 31 | Application with the Library. The particular version of the Library 32 | with which the Combined Work was made is also called the "Linked 33 | Version". 34 | 35 | The "Minimal Corresponding Source" for a Combined Work means the 36 | Corresponding Source for the Combined Work, excluding any source code 37 | for portions of the Combined Work that, considered in isolation, are 38 | based on the Application, and not on the Linked Version. 39 | 40 | The "Corresponding Application Code" for a Combined Work means the 41 | object code and/or source code for the Application, including any data 42 | and utility programs needed for reproducing the Combined Work from the 43 | Application, but excluding the System Libraries of the Combined Work. 44 | 45 | 1. Exception to Section 3 of the GNU GPL. 46 | 47 | You may convey a covered work under sections 3 and 4 of this License 48 | without being bound by section 3 of the GNU GPL. 49 | 50 | 2. Conveying Modified Versions. 51 | 52 | If you modify a copy of the Library, and, in your modifications, a 53 | facility refers to a function or data to be supplied by an Application 54 | that uses the facility (other than as an argument passed when the 55 | facility is invoked), then you may convey a copy of the modified 56 | version: 57 | 58 | a) under this License, provided that you make a good faith effort to 59 | ensure that, in the event an Application does not supply the 60 | function or data, the facility still operates, and performs 61 | whatever part of its purpose remains meaningful, or 62 | 63 | b) under the GNU GPL, with none of the additional permissions of 64 | this License applicable to that copy. 65 | 66 | 3. Object Code Incorporating Material from Library Header Files. 67 | 68 | The object code form of an Application may incorporate material from 69 | a header file that is part of the Library. You may convey such object 70 | code under terms of your choice, provided that, if the incorporated 71 | material is not limited to numerical parameters, data structure 72 | layouts and accessors, or small macros, inline functions and templates 73 | (ten or fewer lines in length), you do both of the following: 74 | 75 | a) Give prominent notice with each copy of the object code that the 76 | Library is used in it and that the Library and its use are 77 | covered by this License. 78 | 79 | b) Accompany the object code with a copy of the GNU GPL and this license 80 | document. 81 | 82 | 4. Combined Works. 83 | 84 | You may convey a Combined Work under terms of your choice that, 85 | taken together, effectively do not restrict modification of the 86 | portions of the Library contained in the Combined Work and reverse 87 | engineering for debugging such modifications, if you also do each of 88 | the following: 89 | 90 | a) Give prominent notice with each copy of the Combined Work that 91 | the Library is used in it and that the Library and its use are 92 | covered by this License. 93 | 94 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 95 | document. 96 | 97 | c) For a Combined Work that displays copyright notices during 98 | execution, include the copyright notice for the Library among 99 | these notices, as well as a reference directing the user to the 100 | copies of the GNU GPL and this license document. 101 | 102 | d) Do one of the following: 103 | 104 | 0) Convey the Minimal Corresponding Source under the terms of this 105 | License, and the Corresponding Application Code in a form 106 | suitable for, and under terms that permit, the user to 107 | recombine or relink the Application with a modified version of 108 | the Linked Version to produce a modified Combined Work, in the 109 | manner specified by section 6 of the GNU GPL for conveying 110 | Corresponding Source. 111 | 112 | 1) Use a suitable shared library mechanism for linking with the 113 | Library. A suitable mechanism is one that (a) uses at run time 114 | a copy of the Library already present on the user's computer 115 | system, and (b) will operate properly with a modified version 116 | of the Library that is interface-compatible with the Linked 117 | Version. 118 | 119 | e) Provide Installation Information, but only if you would otherwise 120 | be required to provide such information under section 6 of the 121 | GNU GPL, and only to the extent that such information is 122 | necessary to install and execute a modified version of the 123 | Combined Work produced by recombining or relinking the 124 | Application with a modified version of the Linked Version. (If 125 | you use option 4d0, the Installation Information must accompany 126 | the Minimal Corresponding Source and Corresponding Application 127 | Code. If you use option 4d1, you must provide the Installation 128 | Information in the manner specified by section 6 of the GNU GPL 129 | for conveying Corresponding Source.) 130 | 131 | 5. Combined Libraries. 132 | 133 | You may place library facilities that are a work based on the 134 | Library side by side in a single library together with other library 135 | facilities that are not Applications and are not covered by this 136 | License, and convey such a combined library under terms of your 137 | choice, if you do both of the following: 138 | 139 | a) Accompany the combined library with a copy of the same work based 140 | on the Library, uncombined with any other library facilities, 141 | conveyed under the terms of this License. 142 | 143 | b) Give prominent notice with the combined library that part of it 144 | is a work based on the Library, and explaining where to find the 145 | accompanying uncombined form of the same work. 146 | 147 | 6. Revised Versions of the GNU Lesser General Public License. 148 | 149 | The Free Software Foundation may publish revised and/or new versions 150 | of the GNU Lesser General Public License from time to time. Such new 151 | versions will be similar in spirit to the present version, but may 152 | differ in detail to address new problems or concerns. 153 | 154 | Each version is given a distinguishing version number. If the 155 | Library as you received it specifies that a certain numbered version 156 | of the GNU Lesser General Public License "or any later version" 157 | applies to it, you have the option of following the terms and 158 | conditions either of that published version or of any later version 159 | published by the Free Software Foundation. If the Library as you 160 | received it does not specify a version number of the GNU Lesser 161 | General Public License, you may choose any version of the GNU Lesser 162 | General Public License ever published by the Free Software Foundation. 163 | 164 | If the Library as you received it specifies that a proxy can decide 165 | whether future versions of the GNU Lesser General Public License shall 166 | apply, that proxy's public statement of acceptance of any version is 167 | permanent authorization for you to choose that version for the 168 | Library. 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | English | [中文简体](https://github.com/exmemo-ai/obsidian-exmemo-assistant/blob/master/README_cn.md) 2 | 3 | ## Introduction 4 | 5 | ExMemo Assistant provides intelligent document management features. Leveraging the capabilities of large language models (LLM), it generates and updates file metadata automatically, thereby achieving efficient information management and document editing. 6 | 7 | Generate and update file metadata, including tags, descriptions, titles, edit times, etc. 8 | 9 | > **Important Note**: The plugin [obsidian-exmemo-tools](https://github.com/exmemo-ai/obsidian-exmemo-tools/) now covers all the features supported by this project and adds more functionality. Future development and updates will focus on exmemo-tools, so we recommend migrating to that tool. 10 | 11 | ### Tool Usage Video Tutorial 12 | [![Watch the video](https://img.youtube.com/vi/5naS9p8a1IE/hqdefault.jpg)](https://www.youtube.com/watch?v=5naS9p8a1IE) 13 | 14 | ## Usage 15 | 16 | ### Setup 17 | 18 | Before using the tool, ensure the following setup is completed: 19 | 20 | * First, configure options related to LLM, including the API key, base URL, and model name. 21 | * If using the auto-generate tags feature, it is recommended to pre-fill the tag list or automatically extract existing tags from the current repository to ensure generated tags align with the user's style. 22 | * To modify the method for generating descriptions, adjust the prompt words for generating descriptions in the settings. 23 | * For generating metadata for longer articles, the model call may incur higher costs. It is recommended to control costs using the "content truncation" feature in the settings. 24 | 25 | ### Generating Metadata 26 | 27 | Press Ctrl+P and select: ExMemo Assistant: Generate Metadata. 28 | 29 | Generating tags and descriptions can often be a daunting task. We frequently end up creating tags with the same meaning but different formulations, impacting subsequent processing. To solve this problem, we have implemented an automatic tag generation feature that can automatically create three tags each time. Users can define the range of tags in the settings or extract options from tags that appear more than twice in the current repository. For generating short descriptions of documents, the tool provides default prompt words, which users can edit in the settings to define their own style. 30 | 31 | During the process of generating tags and descriptions, the document content must be provided to LLM. For lengthy documents, this might lead to higher costs. Therefore, the tool offers a truncation feature in the settings, allowing only the head, tail, or mid-title of a document to be sent to the model. For documents containing tags and descriptions, users can opt not to regenerate this information in the settings to effectively control costs. 32 | 33 | Additionally, generating titles, creation dates, and editing dates, although common, can be tedious tasks. Our tool offers one-click generation for these metadata elements, greatly simplifying daily workflows. 34 | 35 | ## License 36 | 37 | This project is licensed under the GNU Lesser General Public License v3.0. For more details, please refer to the [LICENSE](./LICENSE) file. 38 | -------------------------------------------------------------------------------- /README_cn.md: -------------------------------------------------------------------------------- 1 | ## 介绍 2 | 3 | ExMemo Assistant 提供智能化的文档管理功能。结合大型语言模型(LLM)的能力,它能自动生成和更新文件的元信息,从而实现高效的信息管理和文档编辑。 4 | 5 | 生成并更新文件的元信息,包括标签、简述、标题、编辑时间等。 6 | 7 | > **重要提示**:插件 [obsidian-exmemo-tools](https://github.com/exmemo-ai/obsidian-exmemo-tools/) 的功能目前已涵盖本项目所支持的所有内容,并新增了更多功能。后续开发和更新将以 exmemo-tools 为主,建议您迁移使用该工具。 8 | 9 | ### tools 使用方法视频介绍 10 | [![B站_使用方法视频](./images/video.png)](https://www.bilibili.com/video/BV1podNYvEod) 11 | 12 | ## 使用方法 13 | 14 | ### 设置 15 | 16 | 在使用本工具前,请确保完成以下设置: 17 | 18 | * 首先,设置与 LLM 相关的选项,包括 API 密钥、基础 URL 和模型名称。 19 | * 如果使用自动生成标签功能,建议在使用前预先填写标签列表,或从当前仓库中自动提取已有标签,以便生成的标签更符合用户的风格。 20 | * 若需修改生成简述的方法,请在设置中调整生成描述的提示词。 21 | * 对于较长文章的元数据生成,调用模型时可能会产生较高费用,建议通过设置中的“内容截断”功能来控制成本。 22 | 23 | ### 生成元信息 24 | 25 | 通过按下 Ctrl+P,选择:ExMemo Assistant: 生成元数据。 26 | 27 | 生成标签和描述常常是个令人头疼的任务。我们经常会生成意思相同但写法不同的标签,这会对后续处理造成影响。为了解决这个问题,我们实现了自动生成标签的功能,每次可以自动生成三个标签。用户可以在设置中定义标签的范围,也可以从当前仓库中提取出现过两次以上的标签作为侯选项。对于文档短描述的生成,工具提供了默认的提示词,用户可以在设置中编辑提示词,以便定义自己的风格。 28 | 29 | 在生成标签和描述的过程中,需要将文档内容提供给 LLM。对于长度较长的文档,这可能会导致较高的费用。因此工具在设置中提供了截断功能,可以仅将文件的头部、首尾或文中标题传给模型。对于包含标签和描述的文档,可以在设置中选择不再重复生成这些信息,从而有效地控制费用。 30 | 31 | 此外,生成文件的标题、生成日期和编辑日期等操作虽然常用但却繁琐。我们的工具提供一键生成这些元信息,大大简化了日常工作流程。 32 | 33 | ## License 34 | 35 | 本项目采用 GNU Lesser General Public License v3.0 许可证。有关详细信息,请参见 [LICENSE](./LICENSE) 文件。 36 | 37 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const dir = './dist/'; 15 | 16 | const context = await esbuild.context({ 17 | banner: { 18 | js: banner, 19 | }, 20 | entryPoints: ["src/main.ts"], 21 | bundle: true, 22 | external: [ 23 | "obsidian", 24 | "electron", 25 | "@codemirror/autocomplete", 26 | "@codemirror/collab", 27 | "@codemirror/commands", 28 | "@codemirror/language", 29 | "@codemirror/lint", 30 | "@codemirror/search", 31 | "@codemirror/state", 32 | "@codemirror/view", 33 | "@lezer/common", 34 | "@lezer/highlight", 35 | "@lezer/lr", 36 | ...builtins], 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | sourcemap: prod ? false : "inline", 41 | treeShaking: true, 42 | outdir: dir, 43 | minify: prod, 44 | }); 45 | 46 | if (prod) { 47 | await context.rebuild(); 48 | process.exit(0); 49 | } else { 50 | await context.watch(); 51 | } 52 | -------------------------------------------------------------------------------- /images/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exmemo-ai/obsidian-exmemo-assistant/57bbcf2caa8129b8b0162f37c218c00e8351efc7/images/video.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "exmemo-assistant", 3 | "name": "ExMemo Assistant", 4 | "version": "1.0.1", 5 | "minAppVersion": "1.4.5", 6 | "description": "Using LLMs to manage files and generating metadata such as tags and summaries.", 7 | "author": "ExMemo AI", 8 | "authorUrl": "http://www.xyan666.com/", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exmemo-tools", 3 | "version": "1.0.0", 4 | "description": "Use LLMs for smart document management and optimization, including relocating files, enhancing text, and generating metadata.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "LGPL-3.0-or-later", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": ">=0.25.0", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | }, 24 | "dependencies": { 25 | "crypto-js": "^4.2.0", 26 | "openai": "^4.68.1", 27 | "wink-tokenizer": "^5.3.0", 28 | "obsidian": ">=0.12.12", 29 | "esbuild": ">=0.25.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lang/helpers.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | 3 | import en from "./locale/en"; 4 | import zhCN from "./locale/zh"; 5 | 6 | const localeMap: { [k: string]: Partial } = { 7 | en, 8 | "zh-cn": zhCN, 9 | }; 10 | 11 | const locale = localeMap[moment.locale()]; 12 | 13 | export function t(str: keyof typeof en): string { 14 | return (locale && locale[str]) || en[str]; 15 | } -------------------------------------------------------------------------------- /src/lang/locale/en.ts: -------------------------------------------------------------------------------- 1 | // English 2 | 3 | export default { 4 | // Basic translations 5 | "confirm": "Confirm", 6 | "yes": "Yes", 7 | "no": "No", 8 | "llmLoading": "LLM is thinking...", 9 | "noResult": "LLM no result", 10 | "pleaseOpenFile": "Please open a file first", 11 | "llmError": "An error occurred, please try again later", 12 | "inputPrompt": "Please enter the prompt", 13 | "chatButton": "Chat", 14 | "pleaseSelectText": "Please select the text to be processed first", 15 | "currentFileNotMarkdown": "The current file is not a markdown file", 16 | "fileAlreadyContainsTagsAndDescription": "The file already contains tags and description", 17 | "parseError": "Failed to parse the returned result", 18 | "metaUpdated": "Meta data updated", 19 | 20 | // LLM Settings 21 | "llmSettings": "LLM", 22 | "apiKey": "API key", 23 | "baseUrl": "Base URL", 24 | "modelName": "Model name", 25 | 26 | // Meta Update Settings 27 | "metaUpdateSetting": "Update meta", 28 | "updateMetaOptions": "Update", 29 | "updateMetaOptionsDesc": "If it already exists, choose whether to regenerate", 30 | "updateForce": "Force update existing items", 31 | "updateNoLLM": "Only update items that do not use LLM", 32 | 33 | // Content Truncation Settings 34 | "truncateContent": "Truncate long content?", 35 | "truncateContentDesc": "When using LLM, whether to truncate if the content exceeds the maximum word count", 36 | "maxContentLength": "Maximum content length", 37 | "maxContentLengthDesc": "Set the maximum token limit for the content", 38 | "truncateMethod": "Truncation method", 39 | "truncateMethodDesc": "Choose how to handle content that exceeds the limit", 40 | "head_only": "Extract only the beginning", 41 | "head_tail": "Extract the beginning and the end", 42 | "heading": "Extract the heading and the text below it", 43 | 44 | // Tag Settings 45 | "taggingOptions": "Tags", 46 | "taggingOptionsDesc": "Automatically generating tags", 47 | "extractTags": "Extract tags", 48 | "extractTagsDesc": "Extract tags that appear more than twice from all notes and fill them in the candidate box", 49 | "extract": "Extract", 50 | "tagList": "Tag list", 51 | "tagListDesc": "Optional tag list, separated by line breaks", 52 | "metaTagsPrompt": "Tags Generation Prompt", 53 | "metaTagsPromptDesc": "The prompt for generating tags, where you can set the language, capitalization, etc.", 54 | "defaultTagsPrompt": "Please extract up to three tags based on the following article content, and in the same language as the content.", 55 | 56 | // Description Settings 57 | "description": "Description", 58 | "descriptionDesc": "Automatically generating article descriptions", 59 | "descriptionPrompt": "Prompt", 60 | "descriptionPromptDesc": "Prompt for generating descriptions", 61 | "defaultSummaryPrompt": "Summarize the core content of the article directly without using phrases like 'this article.' The summary should be no more than 50 words, and in the same language as the content.", 62 | 63 | // Title Settings 64 | "title": "Title", 65 | "titleDesc": "Automatically generate document titles", 66 | "enableTitle": "Enable auto title generation", 67 | "enableTitleDesc": "Enable to automatically generate document titles", 68 | "titlePrompt": "Title prompt", 69 | "titlePromptDesc": "Prompt for generating titles", 70 | "defaultTitlePrompt": "Please generate a concise and clear title for this document, no more than 10 words, and do not use quotes.", 71 | 72 | // Edit Time Settings 73 | "editTime": "Edit time", 74 | "editTimeDesc": "Automatically update the edit time of the document", 75 | "enableEditTime": "Enable auto update edit time", 76 | "enableEditTimeDesc": "Enable to automatically update the edit time of the document", 77 | "editTimeFormat": "Edit time format", 78 | "editTimeFormatDesc": "Set the format of the edit time", 79 | 80 | // Custom Field Names 81 | "customFieldNames": "Custom field names", 82 | "customFieldNamesDesc": "Custom field names for metadata", 83 | "tagsFieldName": "Tags field name", 84 | "tagsFieldNameDesc": "Field name used for automatically generating tags (default: tags)", 85 | "descriptionFieldName": "Description field name", 86 | "descriptionFieldNameDesc": "Field name used for automatically generating descriptions (default: description)", 87 | "titleFieldName": "Title field name", 88 | "titleFieldNameDesc": "Field name used for automatically generating titles (default: title)", 89 | "updateTimeFieldName": "Update time field name", 90 | "updateTimeFieldNameDesc": "Field name used for automatically updating the update time (default: updated)", 91 | "createTimeFieldName": "Create time field name", 92 | "createTimeFieldNameDesc": "Field name used for automatically updating the create time (default: created)", 93 | 94 | // Custom Metadata 95 | "customMetadata": "Custom metadata", 96 | "customMetadataDesc": "Add custom metadata fields, e.g.: author=Author Name", 97 | "addField": "Add field", 98 | "fieldKey": "Field name", 99 | "fieldValue": "Field value", 100 | 101 | // Category Settings 102 | "categoryOptions": "Category", 103 | "categoryOptionsDesc": "Automatically select appropriate category for articles", 104 | "enableCategory": "Enable auto category", 105 | "enableCategoryDesc": "Enable to automatically select category for documents", 106 | "categoryFieldName": "Category field name", 107 | "categoryFieldNameDesc": "Field name used for automatically generating category (default: category)", 108 | "categoryList": "Category list", 109 | "categoryListDesc": "Optional category list, separated by line breaks", 110 | "metaCategoryPrompt": "Category prompt", 111 | "metaCategoryPromptDesc": "Prompt for generating category", 112 | "defaultCategoryPrompt": "Please select a suitable category for this document", 113 | "categoryUnknown": "Unknown", 114 | "defaultCategories": "[\"Travel\", \"Shopping\", \"Mood\", \"Book Review\", \"Tech & Knowledge\", \"Entertainment\", \"Papers to Read\", \"Ideas & Inspiration\", \"Todo\", \"Methodology\", \"Work Thoughts\", \"Investment\", \"Books to Read\", \"Personal Info\", \"Accounting\", \"Tasks\", \"Health\", \"Excerpts\", \"Daily Life\", \"Worldview\", \"Food\"]", 115 | 116 | // Donation Related 117 | "donate": "Donate", 118 | "supportThisPlugin": "Support this plugin", 119 | "supportThisPluginDesc": "If you find this plugin helpful, consider buying me a coffee!", 120 | "bugMeACoffee": "Buy me a coffee", 121 | 122 | // Commands 123 | "exmemoAdjustMeta": "Generate meta data" 124 | } -------------------------------------------------------------------------------- /src/lang/locale/zh.ts: -------------------------------------------------------------------------------- 1 | // 简体中文 2 | 3 | export default { 4 | // 基本翻译 5 | "confirm": "确认", 6 | "yes": "是", 7 | "no": "否", 8 | "llmLoading": "LLM 思考中...", 9 | "noResult": "LLM 无结果", 10 | "pleaseOpenFile": "请先打开一个文件", 11 | "llmError": "LLM 错误", 12 | "inputPrompt":"请输入提示词", 13 | "chatButton": "对话", 14 | "pleaseSelectText": "请先选择文本", 15 | "currentFileNotMarkdown": "当前文件不是 markdown 文件", 16 | "fileAlreadyContainsTagsAndDescription": "文件已经包含标签和描述", 17 | "parseError": "解析错误", 18 | "metaUpdated": "元数据已更新", 19 | 20 | // 设置相关 21 | "llmSettings": "LLM", 22 | "apiKey": "API 密钥", 23 | "baseUrl": "基础 URL", 24 | "modelName": "模型名称", 25 | 26 | // 元数据更新设置 27 | "metaUpdateSetting": "更新元数据", 28 | "updateMetaOptions": "更新选项", 29 | "updateMetaOptionsDesc": "如果已经存在,是否重新生成", 30 | "updateForce": "强制更新", 31 | "updateNoLLM": "只更新不用LLM的项", 32 | 33 | // 内容截断设置 34 | "truncateContent": "内容太长是否截断", 35 | "truncateContentDesc": "使用LLM时,如果内容超过最大字数,是否截断", 36 | "maxContentLength": "最大内容长度", 37 | "maxContentLengthDesc": "设置内容的最大 token 限制", 38 | "truncateMethod": "截断方式", 39 | "truncateMethodDesc": "选择内容超过限制时的处理方式", 40 | "head_only": "仅提取开头部分", 41 | "head_tail": "提取开头和结尾部分", 42 | "heading": "提取标题及其下方的文字", 43 | 44 | // 标签设置 45 | "taggingOptions": "标签", 46 | "taggingOptionsDesc": "自动生成标签", 47 | "extractTags": "提取标签", 48 | "extractTagsDesc": "从所有笔记中提取出现超过两次的标签", 49 | "extract": "提取", 50 | "tagList": "标签列表", 51 | "tagListDesc": "可选标签列表,使用回车分隔", 52 | "metaTagsPrompt": "标签生成提示词", 53 | "metaTagsPromptDesc": "用于生成标签的提示词,可在此设置语言、大小写等", 54 | "defaultTagsPrompt": "请提取这篇文章中最合适的不超过三个标签,并使用与内容相同的语言。", 55 | 56 | // 描述设置 57 | "description": "描述", 58 | "descriptionDesc": "自动生成文章描述", 59 | "descriptionPrompt": "描述提示词", 60 | "descriptionPromptDesc": "用于生成描述的提示词", 61 | "defaultSummaryPrompt": "直接总结文章的核心内容,不要使用'这篇文章'这样的短语,不超过50个字,且与内容使用相同语言回答。", 62 | 63 | // 标题相关 64 | "title": "标题", 65 | "titleDesc": "自动生成文档标题", 66 | "enableTitle": "启用自动生成标题", 67 | "enableTitleDesc": "启用后将自动生成文档标题", 68 | "titlePrompt": "标题生成提示词", 69 | "titlePromptDesc": "用于生成标题的提示词", 70 | "defaultTitlePrompt": "请为这篇文档生成一个简洁明了的标题,不超过10个字,不要使用引号。", 71 | 72 | // 编辑时间相关 73 | "editTime": "编辑时间", 74 | "editTimeDesc": "自动更新文档编辑时间", 75 | "enableEditTime": "启用自动更新编辑时间", 76 | "enableEditTimeDesc": "启用后将自动更新文档的编辑时间", 77 | "editTimeFormat": "时间格式", 78 | "editTimeFormatDesc": "编辑时间的格式,使用 moment.js 格式", 79 | 80 | // 自定义字段名相关 81 | "customFieldNames": "自定义字段名", 82 | "customFieldNamesDesc": "自定义生成的元数据字段名称", 83 | "tagsFieldName": "标签字段名", 84 | "tagsFieldNameDesc": "自动生成标签使用的字段名 (默认: tags)", 85 | "descriptionFieldName": "描述字段名", 86 | "descriptionFieldNameDesc": "自动生成描述使用的字段名 (默认: description)", 87 | "titleFieldName": "标题字段名", 88 | "titleFieldNameDesc": "自动生成标题使用的字段名 (默认: title)", 89 | "updateTimeFieldName": "更新时间字段名", 90 | "updateTimeFieldNameDesc": "自动更新编辑时间使用的字段名 (默认: updated)", 91 | "createTimeFieldName": "创建时间字段名", 92 | "createTimeFieldNameDesc": "自动生成创建时间使用的字段名 (默认: created)", 93 | 94 | // 自定义元数据相关 95 | "customMetadata": "自定义元数据", 96 | "customMetadataDesc": "添加自定义的元数据字段,如:author=作者名", 97 | "addField": "添加字段", 98 | "fieldKey": "字段名", 99 | "fieldValue": "字段值", 100 | 101 | // 类别设置相关 102 | "categoryOptions": "类别", 103 | "categoryOptionsDesc": "自动为文章选择合适的类别", 104 | "enableCategory": "启用自动分类", 105 | "enableCategoryDesc": "启用后将自动为文档选择类别", 106 | "categoryFieldName": "类别字段名", 107 | "categoryFieldNameDesc": "自动生成类别使用的字段名 (默认: category)", 108 | "categoryList": "类别列表", 109 | "categoryListDesc": "可选类别列表,使用回车分隔", 110 | "metaCategoryPrompt": "类别生成提示词", 111 | "metaCategoryPromptDesc": "用于生成类别的提示词", 112 | "defaultCategoryPrompt": "请为这篇文档选择一个合适的类别", 113 | "categoryUnknown": "未分类", 114 | "defaultCategories": "[\"旅行\", \"购物\", \"心情\", \"读后感\", \"知识科技\", \"娱乐\", \"待读论文\", \"灵感创意\", \"待办事项\", \"方法论\", \"工作思考\", \"投资\", \"待读书\", \"个人信息\", \"记帐\", \"待做\", \"健康\", \"摘录\", \"日常琐事\", \"世界观\", \"美食\"]", 115 | 116 | // 捐赠相关 117 | "donate": "捐赠", 118 | "supportThisPlugin": "支持此插件", 119 | "supportThisPluginDesc": "如果您喜欢这个插件,可以请我喝杯咖啡", 120 | "bugMeACoffee": "请我喝杯咖啡", 121 | 122 | // 命令相关 123 | "exmemoAdjustMeta": "生成元数据" 124 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownView, Plugin } from 'obsidian'; 2 | import { DEFAULT_SETTINGS, ExMemoSettings } from './settings'; 3 | import { ExMemoSettingTab } from './settingsTab'; 4 | import { adjustMdMeta } from './meta'; 5 | import { t } from "./lang/helpers" 6 | 7 | export default class ExMemoAsstPlugin extends Plugin { 8 | settings: ExMemoSettings; 9 | async onload() { 10 | await this.loadSettings(); 11 | this.addCommand({ 12 | id: 'adjust-meta', 13 | name: t('exmemoAdjustMeta'), 14 | editorCallback: (editor: Editor, view: MarkdownView) => { 15 | adjustMdMeta(this.app, this.settings); 16 | } 17 | }); 18 | this.addSettingTab(new ExMemoSettingTab(this.app, this)); 19 | } 20 | onunload() { 21 | } 22 | async loadSettings() { 23 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 24 | } 25 | async saveSettings() { 26 | await this.saveData(this.settings); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/meta.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, TFile } from 'obsidian'; 2 | import { ExMemoSettings } from "./settings"; 3 | import { getContent } from './utils'; 4 | import { callLLM } from "./utils"; 5 | import { t } from './lang/helpers'; 6 | import { updateFrontMatter } from './utils'; 7 | 8 | export async function adjustMdMeta(app: App, settings: ExMemoSettings) { 9 | const file = app.workspace.getActiveFile(); 10 | if (!file) { 11 | new Notice(t('pleaseOpenFile')); 12 | return; 13 | } 14 | if (file.extension !== 'md') { 15 | new Notice(t('currentFileNotMarkdown')); 16 | return; 17 | } 18 | 19 | // 解析前置元数据 20 | const fm = app.metadataCache.getFileCache(file); 21 | let frontMatter = fm?.frontmatter || {}; 22 | let hasChanges = false; 23 | 24 | // 根据更新方法决定是否强制更新 25 | const force = settings.metaUpdateMethod === 'force'; 26 | 27 | // 添加标签、类别、描述和标题 28 | if (!frontMatter[settings.metaTagsFieldName] || 29 | frontMatter[settings.metaTagsFieldName]?.length === 0 || 30 | !frontMatter[settings.metaDescriptionFieldName] || 31 | frontMatter[settings.metaDescriptionFieldName]?.trim() === '' || 32 | (settings.metaTitleEnabled && 33 | (!frontMatter[settings.metaTitleFieldName] || 34 | frontMatter[settings.metaTitleFieldName]?.trim() === '')) || 35 | (settings.metaCategoryEnabled && 36 | (!frontMatter[settings.metaCategoryFieldName] || 37 | frontMatter[settings.metaCategoryFieldName]?.trim() === '')) || 38 | force) { 39 | await addMetaByLLM(file, app, settings, frontMatter, force); 40 | hasChanges = true; 41 | } 42 | 43 | // 添加时间相关元数据 - 只在功能启用时执行 44 | if (settings.metaEditTimeEnabled) { 45 | try { 46 | // 使用原生 JavaScript Date 对象 47 | const now = new Date(); 48 | const formattedNow = formatDate(now, settings.metaEditTimeFormat); 49 | updateFrontMatter(file, app, settings.metaUpdatedFieldName, formattedNow, 'update'); 50 | 51 | // 添加创建时间 52 | const created = new Date(file.stat.ctime); 53 | const createdDate = formatDate(created, 'YYYY-MM-DD'); 54 | updateFrontMatter(file, app, settings.metaCreatedFieldName, createdDate, 'update'); 55 | 56 | hasChanges = true; 57 | } catch (error) { 58 | console.error('更新时间元数据时出错:', error); 59 | new Notice(t('llmError') + ': ' + error); 60 | } 61 | } 62 | 63 | // 添加自定义元数据 64 | if (settings.customMetadata && settings.customMetadata.length > 0) { 65 | for (const meta of settings.customMetadata) { 66 | if (meta.key && meta.value) { 67 | let finalValue: string | boolean = meta.value; 68 | if (meta.value.toLowerCase() === 'true' || meta.value.toLowerCase() === 'false') { 69 | finalValue = (meta.value.toLowerCase() === 'true') as boolean; 70 | } 71 | updateFrontMatter(file, app, meta.key, finalValue, force ? 'update' : 'keep'); 72 | } 73 | } 74 | hasChanges = true; 75 | } 76 | 77 | if (hasChanges) { 78 | new Notice(t('metaUpdated')); 79 | } 80 | } 81 | 82 | async function addMetaByLLM(file: TFile, app: App, settings: ExMemoSettings, frontMatter: any, force: boolean = false) { 83 | let content_str = ''; 84 | if (settings.metaIsTruncate) { 85 | content_str = await getContent(app, null, settings.metaMaxTokens, settings.metaTruncateMethod); 86 | } else { 87 | content_str = await getContent(app, null, -1, ''); 88 | } 89 | 90 | const tag_options = settings.tags.join(','); 91 | let categories_options = settings.categories.join(','); 92 | if (categories_options === '') { 93 | categories_options = t('categoryUnknown'); 94 | } 95 | 96 | const req = `I need to generate tags, category, description, and title for the following article. Requirements: 97 | 98 | 1. Tags: ${settings.metaTagsPrompt} 99 | Available tags: ${tag_options}. Feel free to create new ones if none are suitable. 100 | 101 | 2. Category: ${settings.metaCategoryPrompt} 102 | Available categories: ${categories_options}. Must choose ONE from the available categories. 103 | 104 | 3. Description: ${settings.metaDescription} 105 | 106 | 4. Title: ${settings.metaTitlePrompt} 107 | 108 | Please return in the following JSON format: 109 | { 110 | "tags": "tag1,tag2,tag3", 111 | "category": "category_name", 112 | "description": "brief summary", 113 | "title": "article title" 114 | } 115 | 116 | Article content: 117 | 118 | ${content_str}`; 119 | 120 | let ret = await callLLM(req, settings); 121 | if (ret === "" || ret === undefined || ret === null) { 122 | return; 123 | } 124 | ret = ret.replace(/`/g, ''); 125 | 126 | let ret_json = {} as { tags?: string; category?: string; description?: string; title?: string }; 127 | try { 128 | let json_str = ret.match(/{[^]*}/); 129 | if (json_str) { 130 | ret_json = JSON.parse(json_str[0]) as { tags?: string; category?: string; description?: string; title?: string }; 131 | } 132 | } catch (error) { 133 | new Notice(t('parseError') + "\n" + error); 134 | console.error("parseError:", error); 135 | return; 136 | } 137 | 138 | // 检查并更新各个字段 139 | if (ret_json.tags) { 140 | const tags = ret_json.tags.split(','); 141 | updateFrontMatter(file, app, settings.metaTagsFieldName, tags, 'append'); 142 | } 143 | 144 | if (ret_json.category && settings.metaCategoryEnabled) { 145 | const currentValue = frontMatter[settings.metaCategoryFieldName]; 146 | const isEmpty = !currentValue || currentValue.trim() === ''; 147 | updateFrontMatter(file, app, settings.metaCategoryFieldName, ret_json.category, 148 | force || isEmpty ? 'update' : 'keep'); 149 | } 150 | 151 | if (ret_json.description) { 152 | const currentValue = frontMatter[settings.metaDescriptionFieldName]; 153 | const isEmpty = !currentValue || currentValue.trim() === ''; 154 | updateFrontMatter(file, app, settings.metaDescriptionFieldName, ret_json.description, 155 | force || isEmpty ? 'update' : 'keep'); 156 | } 157 | 158 | if (settings.metaTitleEnabled && ret_json.title) { 159 | let title = ret_json.title.trim(); 160 | if ((title.startsWith('"') && title.endsWith('"')) || 161 | (title.startsWith("'") && title.endsWith("'"))) { 162 | title = title.substring(1, title.length - 1); 163 | } 164 | const currentValue = frontMatter[settings.metaTitleFieldName]; 165 | const isEmpty = !currentValue || currentValue.trim() === ''; 166 | updateFrontMatter(file, app, settings.metaTitleFieldName, title, 167 | force || isEmpty ? 'update' : 'keep'); 168 | } 169 | } 170 | 171 | // 使用自定义的日期格式化函数 172 | function formatDate(date: Date, format: string): string { 173 | // 简单的格式化实现,支持基本的 YYYY-MM-DD HH:mm:ss 格式 174 | const year = date.getFullYear(); 175 | const month = String(date.getMonth() + 1).padStart(2, '0'); 176 | const day = String(date.getDate()).padStart(2, '0'); 177 | const hours = String(date.getHours()).padStart(2, '0'); 178 | const minutes = String(date.getMinutes()).padStart(2, '0'); 179 | const seconds = String(date.getSeconds()).padStart(2, '0'); 180 | 181 | return format 182 | .replace('YYYY', year.toString()) 183 | .replace('MM', month) 184 | .replace('DD', day) 185 | .replace('HH', hours) 186 | .replace('mm', minutes) 187 | .replace('ss', seconds); 188 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { t } from "./lang/helpers"; 2 | 3 | export interface ExMemoSettings { 4 | llmToken: string; 5 | llmBaseUrl: string; 6 | llmModelName: string; 7 | llmPrompts: Record; 8 | llmDialogEdit: boolean 9 | tags: string[]; 10 | metaIsTruncate: boolean; 11 | metaMaxTokens: number; 12 | metaTruncateMethod: string; 13 | metaUpdateMethod: string; 14 | metaDescription: string; 15 | metaTitleEnabled: boolean; 16 | metaTitlePrompt: string; 17 | metaEditTimeEnabled: boolean; 18 | metaEditTimeFormat: string; 19 | selectExcludedFolders: string[]; 20 | metaTagsFieldName: string; 21 | metaDescriptionFieldName: string; 22 | metaTitleFieldName: string; 23 | metaUpdatedFieldName: string; 24 | metaCreatedFieldName: string; 25 | metaTagsPrompt: string; 26 | customMetadata: Array<{key: string, value: string}>; 27 | metaCategoryFieldName: string; 28 | categories: string[]; 29 | metaCategoryPrompt: string; 30 | metaCategoryEnabled: boolean; 31 | } 32 | 33 | export const DEFAULT_SETTINGS: ExMemoSettings = { 34 | llmToken: 'sk-', 35 | llmBaseUrl: 'https://api.openai.com/v1', 36 | llmModelName: 'gpt-4o', 37 | llmPrompts: {}, 38 | llmDialogEdit: false, 39 | tags: [], 40 | metaIsTruncate: true, 41 | metaMaxTokens: 1000, 42 | metaTruncateMethod: 'head_only', 43 | metaUpdateMethod: 'no-llm', 44 | metaDescription: t('defaultSummaryPrompt'), 45 | metaTitleEnabled: true, 46 | metaTitlePrompt: t('defaultTitlePrompt'), 47 | metaEditTimeEnabled: true, 48 | metaEditTimeFormat: 'YYYY-MM-DD HH:mm:ss', 49 | selectExcludedFolders: [], 50 | metaTagsFieldName: 'tags', 51 | metaDescriptionFieldName: 'description', 52 | metaTitleFieldName: 'title', 53 | metaUpdatedFieldName: 'updated', 54 | metaCreatedFieldName: 'created', 55 | metaTagsPrompt: t('defaultTagsPrompt'), 56 | customMetadata: [], 57 | metaCategoryFieldName: 'category', 58 | categories: JSON.parse(t('defaultCategories')), 59 | metaCategoryPrompt: t('defaultCategoryPrompt'), 60 | metaCategoryEnabled: true, 61 | } -------------------------------------------------------------------------------- /src/settingsTab.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, Setting, App, TextAreaComponent } from 'obsidian'; 2 | import { loadTags } from "./utils"; 3 | import { t } from "./lang/helpers"; 4 | 5 | export class ExMemoSettingTab extends PluginSettingTab { 6 | plugin; 7 | 8 | constructor(app: App, plugin: any) { 9 | super(app, plugin); 10 | this.plugin = plugin; 11 | } 12 | 13 | display(): void { 14 | let textComponent: TextAreaComponent; 15 | const { containerEl } = this; 16 | containerEl.empty(); 17 | 18 | // LLM 设置部分 19 | new Setting(containerEl).setName(t("llmSettings")) 20 | .setHeading(); 21 | new Setting(containerEl) 22 | .setName(t("apiKey")) 23 | .addText(text => text 24 | .setPlaceholder('Enter your token') 25 | .setValue(this.plugin.settings.llmToken) 26 | .onChange(async (value) => { 27 | this.plugin.settings.llmToken = value; 28 | await this.plugin.saveSettings(); 29 | })); 30 | new Setting(containerEl) 31 | .setName(t("baseUrl")) 32 | .addText(text => text 33 | .setPlaceholder('https://api.openai.com/v1') 34 | .setValue(this.plugin.settings.llmBaseUrl) 35 | .onChange(async (value) => { 36 | this.plugin.settings.llmBaseUrl = value; 37 | await this.plugin.saveSettings(); 38 | })); 39 | new Setting(containerEl) 40 | .setName(t("modelName")) 41 | .addText(text => text 42 | .setPlaceholder('gpt-4o') 43 | .setValue(this.plugin.settings.llmModelName) 44 | .onChange(async (value) => { 45 | this.plugin.settings.llmModelName = value; 46 | await this.plugin.saveSettings(); 47 | })); 48 | 49 | // 更新元数据设置部分 50 | new Setting(containerEl).setName(t("metaUpdateSetting")) 51 | .setHeading(); 52 | new Setting(containerEl) 53 | .setName(t("updateMetaOptions")) 54 | .setDesc(t("updateMetaOptionsDesc")) 55 | .setClass('setting-item-nested') 56 | .addDropdown((dropdown) => { 57 | dropdown 58 | .addOption('force', t("updateForce")) 59 | .addOption('no-llm', t("updateNoLLM")) 60 | .setValue(this.plugin.settings.metaUpdateMethod) 61 | .onChange(async (value) => { 62 | this.plugin.settings.metaUpdateMethod = value; 63 | await this.plugin.saveSettings(); 64 | }); 65 | }); 66 | 67 | const toggleCutSetting = new Setting(containerEl) 68 | .setName(t("truncateContent")) 69 | .setDesc(t("truncateContentDesc")) 70 | .setClass('setting-item-nested') 71 | .addToggle((toggle) => { 72 | toggle.setValue(this.plugin.settings.metaIsTruncate) 73 | .onChange(async (value) => { 74 | this.plugin.settings.metaIsTruncate = value; 75 | await this.plugin.saveSettings(); 76 | truncateSetting.setDisabled(!value); 77 | maxTokensSetting.setDisabled(!value); 78 | }); 79 | }); 80 | 81 | const maxTokensSetting = new Setting(containerEl) 82 | .setName(t("maxContentLength")) 83 | .setDesc(t("maxContentLengthDesc")) 84 | .setClass('setting-item-nested-2') 85 | .addText((text) => { 86 | text.setValue(this.plugin.settings.metaMaxTokens.toString()) 87 | .onChange(async (value) => { 88 | this.plugin.settings.metaMaxTokens = parseInt(value); 89 | await this.plugin.saveSettings(); 90 | }); 91 | }); 92 | 93 | const truncateSetting = new Setting(containerEl) 94 | .setName(t("truncateMethod")) 95 | .setDesc(t("truncateMethodDesc")) 96 | .setClass('setting-item-nested-2') 97 | .addDropdown((dropdown) => { 98 | dropdown 99 | .addOption('head_only', t("head_only")) 100 | .addOption('head_tail', t("head_tail")) 101 | .addOption('heading', t("heading")) 102 | .setValue(this.plugin.settings.metaTruncateMethod) 103 | .onChange(async (value) => { 104 | this.plugin.settings.metaTruncateMethod = value; 105 | await this.plugin.saveSettings(); 106 | }); 107 | }); 108 | 109 | if (toggleCutSetting) { 110 | truncateSetting.setDisabled(!this.plugin.settings.metaIsTruncate); 111 | maxTokensSetting.setDisabled(!this.plugin.settings.metaIsTruncate); 112 | } 113 | 114 | // 标签设置部分 115 | new Setting(containerEl).setName(t("taggingOptions")) 116 | .setDesc(t("taggingOptionsDesc")) 117 | .setHeading().setClass('setting-item-nested'); 118 | 119 | // 添加标签字段名设置 120 | new Setting(containerEl) 121 | .setName(t('tagsFieldName')) 122 | .setDesc(t('tagsFieldNameDesc')) 123 | .setClass('setting-item-nested') 124 | .addText(text => text 125 | .setValue(this.plugin.settings.metaTagsFieldName) 126 | .onChange(async (value) => { 127 | this.plugin.settings.metaTagsFieldName = value || 'tags'; 128 | await this.plugin.saveSettings(); 129 | })); 130 | 131 | new Setting(containerEl) 132 | .setName(t("extractTags")) 133 | .setDesc(t("extractTagsDesc")) 134 | .setClass('setting-item-nested') 135 | .addButton((btn) => { 136 | btn.setButtonText(t("extract")) 137 | .setCta() 138 | .onClick(async () => { 139 | const tags: Record = await loadTags(this.app); 140 | const sortedTags = Object.entries(tags).sort((a, b) => b[1] - a[1]); 141 | //const topTags = sortedTags.slice(0, 30).map(tag => tag[0]); 142 | const topTags = sortedTags.filter(([_, count]) => count > 2).map(([tag]) => tag); 143 | let currentTagList = this.plugin.settings.tags; 144 | for (const tag of topTags) { 145 | if (!currentTagList.includes(tag)) { 146 | currentTagList.push(tag); 147 | } 148 | } 149 | this.plugin.settings.tags = currentTagList; 150 | textComponent.setValue(this.plugin.settings.tags.join('\n')); 151 | }); 152 | }); 153 | new Setting(containerEl) 154 | .setName(t("tagList")) 155 | .setDesc(t("tagListDesc")) 156 | .setClass('setting-item-nested') 157 | .addTextArea((text) => { 158 | textComponent = text; 159 | text.setValue(this.plugin.settings.tags.join('\n')) 160 | .onChange(async (value) => { 161 | this.plugin.settings.tags = value.split('\n').map(tag => tag.trim()).filter(tag => tag !== ''); 162 | await this.plugin.saveSettings(); 163 | }); 164 | text.inputEl.setAttr('rows', '7'); 165 | text.inputEl.addClass('setting-textarea'); 166 | }); 167 | 168 | // 添加标签提示词设置 169 | new Setting(containerEl) 170 | .setName(t('metaTagsPrompt')) 171 | .setDesc(t('metaTagsPromptDesc')) 172 | .setClass('setting-item-nested') 173 | .addTextArea(text => { 174 | text.setPlaceholder(this.plugin.settings.metaTagsPrompt) 175 | .setValue(this.plugin.settings.metaTagsPrompt) 176 | .onChange(async (value) => { 177 | this.plugin.settings.metaTagsPrompt = value; 178 | await this.plugin.saveSettings(); 179 | }); 180 | text.inputEl.setAttr('rows', '3'); 181 | text.inputEl.addClass('setting-textarea'); 182 | }); 183 | 184 | // 类别设置部分 185 | new Setting(containerEl).setName(t("categoryOptions")) 186 | .setDesc(t("categoryOptionsDesc")) 187 | .setHeading().setClass('setting-item-nested'); 188 | 189 | new Setting(containerEl) 190 | .setName(t('enableCategory')) 191 | .setDesc(t('enableCategoryDesc')) 192 | .setClass('setting-item-nested') 193 | .addToggle(toggle => toggle 194 | .setValue(this.plugin.settings.metaCategoryEnabled) 195 | .onChange(async (value) => { 196 | this.plugin.settings.metaCategoryEnabled = value; 197 | await this.plugin.saveSettings(); 198 | })); 199 | 200 | // 添加类别字段名设置 201 | new Setting(containerEl) 202 | .setName(t('categoryFieldName')) 203 | .setDesc(t('categoryFieldNameDesc')) 204 | .setClass('setting-item-nested') 205 | .addText(text => text 206 | .setValue(this.plugin.settings.metaCategoryFieldName) 207 | .onChange(async (value) => { 208 | this.plugin.settings.metaCategoryFieldName = value || 'category'; 209 | await this.plugin.saveSettings(); 210 | })); 211 | 212 | new Setting(containerEl) 213 | .setName(t("categoryList")) 214 | .setDesc(t("categoryListDesc")) 215 | .setClass('setting-item-nested') 216 | .addTextArea((text) => { 217 | text.setValue(this.plugin.settings.categories.join('\n')) 218 | .onChange(async (value) => { 219 | this.plugin.settings.categories = value.split('\n').map(cat => cat.trim()).filter(cat => cat !== ''); 220 | await this.plugin.saveSettings(); 221 | }); 222 | text.inputEl.setAttr('rows', '5'); 223 | text.inputEl.addClass('setting-textarea'); 224 | }); 225 | 226 | // 添加类别提示词设置 227 | new Setting(containerEl) 228 | .setName(t('metaCategoryPrompt')) 229 | .setDesc(t('metaCategoryPromptDesc')) 230 | .setClass('setting-item-nested') 231 | .addTextArea(text => { 232 | text.setPlaceholder(this.plugin.settings.metaCategoryPrompt) 233 | .setValue(this.plugin.settings.metaCategoryPrompt) 234 | .onChange(async (value) => { 235 | this.plugin.settings.metaCategoryPrompt = value; 236 | await this.plugin.saveSettings(); 237 | }); 238 | text.inputEl.setAttr('rows', '3'); 239 | text.inputEl.addClass('setting-textarea'); 240 | }); 241 | 242 | // 描述设置部分 243 | new Setting(containerEl).setName(t("description")) 244 | .setDesc(t("descriptionDesc")) 245 | .setHeading().setClass('setting-item-nested'); 246 | 247 | // 添加描述字段名设置 248 | new Setting(containerEl) 249 | .setName(t('descriptionFieldName')) 250 | .setDesc(t('descriptionFieldNameDesc')) 251 | .setClass('setting-item-nested') 252 | .addText(text => text 253 | .setValue(this.plugin.settings.metaDescriptionFieldName) 254 | .onChange(async (value) => { 255 | this.plugin.settings.metaDescriptionFieldName = value || 'description'; 256 | await this.plugin.saveSettings(); 257 | })); 258 | 259 | new Setting(containerEl) 260 | .setName(t("descriptionPrompt")) 261 | .setDesc(t("descriptionPromptDesc")) 262 | .setClass('setting-item-nested') 263 | .addTextArea((text) => { 264 | text.setValue(this.plugin.settings.metaDescription) 265 | .onChange(async (value) => { 266 | this.plugin.settings.metaDescription = value; 267 | await this.plugin.saveSettings(); 268 | }); 269 | text.inputEl.setAttr('rows', '3'); 270 | text.inputEl.addClass('setting-textarea'); 271 | }); 272 | 273 | // 新增标题设置部分 274 | new Setting(containerEl).setName(t("title")) 275 | .setDesc(t("titleDesc")) 276 | .setHeading().setClass('setting-item-nested'); 277 | 278 | // 添加标题字段名设置 279 | new Setting(containerEl) 280 | .setName(t('titleFieldName')) 281 | .setDesc(t('titleFieldNameDesc')) 282 | .setClass('setting-item-nested') 283 | .addText(text => text 284 | .setValue(this.plugin.settings.metaTitleFieldName) 285 | .onChange(async (value) => { 286 | this.plugin.settings.metaTitleFieldName = value || 'title'; 287 | await this.plugin.saveSettings(); 288 | })); 289 | 290 | new Setting(containerEl) 291 | .setName(t("enableTitle")) 292 | .setDesc(t("enableTitleDesc")) 293 | .setClass('setting-item-nested') 294 | .addToggle((toggle) => { 295 | toggle.setValue(this.plugin.settings.metaTitleEnabled) 296 | .onChange(async (value) => { 297 | this.plugin.settings.metaTitleEnabled = value; 298 | await this.plugin.saveSettings(); 299 | titlePromptSetting.setDisabled(!value); 300 | }); 301 | }); 302 | 303 | const titlePromptSetting = new Setting(containerEl) 304 | .setName(t("titlePrompt")) 305 | .setDesc(t("titlePromptDesc")) 306 | .setClass('setting-item-nested') 307 | .addTextArea((text) => { 308 | text.setValue(this.plugin.settings.metaTitlePrompt) 309 | .onChange(async (value) => { 310 | this.plugin.settings.metaTitlePrompt = value; 311 | await this.plugin.saveSettings(); 312 | }); 313 | text.inputEl.setAttr('rows', '3'); 314 | text.inputEl.addClass('setting-textarea'); 315 | }); 316 | 317 | titlePromptSetting.setDisabled(!this.plugin.settings.metaTitleEnabled); 318 | 319 | // 新增编辑时间设置部分 320 | new Setting(containerEl).setName(t("editTime")) 321 | .setDesc(t("editTimeDesc")) 322 | .setHeading().setClass('setting-item-nested'); 323 | 324 | // 添加更新时间字段名设置 325 | new Setting(containerEl) 326 | .setName(t('updateTimeFieldName')) 327 | .setDesc(t('updateTimeFieldNameDesc')) 328 | .setClass('setting-item-nested') 329 | .addText(text => text 330 | .setValue(this.plugin.settings.metaUpdatedFieldName) 331 | .onChange(async (value) => { 332 | this.plugin.settings.metaUpdatedFieldName = value || 'updated'; 333 | await this.plugin.saveSettings(); 334 | })); 335 | 336 | // 添加创建时间字段名设置 337 | new Setting(containerEl) 338 | .setName(t('createTimeFieldName')) 339 | .setDesc(t('createTimeFieldNameDesc')) 340 | .setClass('setting-item-nested') 341 | .addText(text => text 342 | .setValue(this.plugin.settings.metaCreatedFieldName) 343 | .onChange(async (value) => { 344 | this.plugin.settings.metaCreatedFieldName = value || 'created'; 345 | await this.plugin.saveSettings(); 346 | })); 347 | 348 | new Setting(containerEl) 349 | .setName(t("enableEditTime")) 350 | .setDesc(t("enableEditTimeDesc")) 351 | .setClass('setting-item-nested') 352 | .addToggle((toggle) => { 353 | toggle.setValue(this.plugin.settings.metaEditTimeEnabled) 354 | .onChange(async (value) => { 355 | this.plugin.settings.metaEditTimeEnabled = value; 356 | await this.plugin.saveSettings(); 357 | editTimeFormatSetting.setDisabled(!value); 358 | }); 359 | }); 360 | 361 | const editTimeFormatSetting = new Setting(containerEl) 362 | .setName(t("editTimeFormat")) 363 | .setDesc(t("editTimeFormatDesc")) 364 | .setClass('setting-item-nested') 365 | .addText((text) => { 366 | text.setValue(this.plugin.settings.metaEditTimeFormat) 367 | .setPlaceholder('YYYY-MM-DD HH:mm:ss') 368 | .onChange(async (value) => { 369 | this.plugin.settings.metaEditTimeFormat = value; 370 | await this.plugin.saveSettings(); 371 | }); 372 | }); 373 | 374 | editTimeFormatSetting.setDisabled(!this.plugin.settings.metaEditTimeEnabled); 375 | 376 | // 添加自定义元数据设置 377 | new Setting(containerEl) 378 | .setName(t('customMetadata')) 379 | .setDesc(t('customMetadataDesc')) 380 | .setHeading().setClass('setting-item-nested') 381 | .addButton(button => button 382 | .setButtonText(t('addField')) 383 | .onClick(async () => { 384 | this.plugin.settings.customMetadata.push({ 385 | key: '', 386 | value: '' 387 | }); 388 | await this.plugin.saveSettings(); 389 | this.display(); 390 | })); 391 | 392 | interface CustomMetadata { 393 | key: string; 394 | value: string; 395 | } 396 | 397 | this.plugin.settings.customMetadata.forEach((meta: CustomMetadata, index: number) => { 398 | const setting = new Setting(containerEl) 399 | .addText(text => text 400 | .setPlaceholder(t('fieldKey')) 401 | .setValue(meta.key) 402 | .onChange(async (value) => { 403 | this.plugin.settings.customMetadata[index].key = value; 404 | await this.plugin.saveSettings(); 405 | })) 406 | .addText(text => text 407 | .setPlaceholder(t('fieldValue')) 408 | .setValue(meta.value) 409 | .onChange(async (value) => { 410 | this.plugin.settings.customMetadata[index].value = value; 411 | await this.plugin.saveSettings(); 412 | })) 413 | .addButton(button => button 414 | .setIcon('trash') 415 | .onClick(async () => { 416 | this.plugin.settings.customMetadata.splice(index, 1); 417 | await this.plugin.saveSettings(); 418 | this.display(); 419 | })); 420 | }); 421 | 422 | // 捐赠部分 423 | new Setting(containerEl).setName(t('donate')).setHeading(); 424 | new Setting(containerEl) 425 | .setName(t('supportThisPlugin')) 426 | .setDesc(t('supportThisPluginDesc')) 427 | .addButton((button) => { 428 | button.setButtonText(t('bugMeACoffee')) 429 | .setCta() 430 | .onClick(() => { 431 | window.open('https://buymeacoffee.com/xieyan0811y', '_blank'); 432 | }); 433 | }); 434 | } 435 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile, MarkdownView, Modal, Notice, getAllTags } from 'obsidian'; 2 | import OpenAI from "openai"; 3 | import { ExMemoSettings } from "./settings"; 4 | import { t } from "./lang/helpers" 5 | 6 | export async function callLLM(req: string, settings: ExMemoSettings): Promise { 7 | let ret = ''; 8 | let info = new Notice(t("llmLoading"), 0); 9 | //console.log('callLLM:', req.length, 'chars', req); 10 | //console.warn('callLLM:', settings.llmBaseUrl, settings.llmToken); 11 | const openai = new OpenAI({ 12 | apiKey: settings.llmToken, 13 | baseURL: settings.llmBaseUrl, 14 | dangerouslyAllowBrowser: true 15 | }); 16 | try { 17 | const completion = await openai.chat.completions.create({ 18 | model: settings.llmModelName, 19 | messages: [ 20 | { "role": "user", "content": req } 21 | ] 22 | }); 23 | if (completion.choices.length > 0) { 24 | ret = completion.choices[0].message['content'] || ret; 25 | } 26 | } catch (error) { 27 | new Notice(t("llmError") + "\n" + error as string); 28 | console.warn('Error:', error as string); 29 | } 30 | info.hide(); 31 | return ret 32 | } 33 | 34 | class ConfirmModal extends Modal { 35 | private resolvePromise: (value: boolean) => void; 36 | private message: string; 37 | 38 | constructor(app: App, message: string, onResolve: (value: boolean) => void) { 39 | super(app); 40 | this.message = message; 41 | this.resolvePromise = onResolve; 42 | } 43 | 44 | onOpen() { 45 | this.titleEl.setText(t("confirm")); 46 | this.contentEl.createEl('p', { text: this.message }); 47 | const buttonContainer = this.contentEl.createEl('div', { cls: 'dialog-button-container' }); 48 | 49 | const yesButton = buttonContainer.createEl('button', { text: t("yes") }); 50 | yesButton.onclick = () => { 51 | this.close(); 52 | this.resolvePromise(true); 53 | }; 54 | 55 | const noButton = buttonContainer.createEl('button', { text: t("no") }); 56 | noButton.onclick = () => { 57 | this.close(); 58 | this.resolvePromise(false); 59 | }; 60 | } 61 | } 62 | 63 | export async function confirmDialog(app: App, message: string): Promise { 64 | return new Promise((resolve) => { 65 | new ConfirmModal(app, message, resolve).open(); 66 | }); 67 | } 68 | 69 | function splitIntoTokens(str: string) { 70 | const regex = /[\u4e00-\u9fa5]|[a-zA-Z0-9]+|[\.,!?;,。!?;#]|[\n]/g; 71 | const tokens = str.match(regex); 72 | return tokens || []; 73 | } 74 | 75 | function joinTokens(tokens: any) { 76 | let result = ''; 77 | for (let i = 0; i < tokens.length; i++) { 78 | const token = tokens[i]; 79 | if (token === '\n') { 80 | result += token; 81 | } else if (/[\u4e00-\u9fa5]|[\.,!?;,。!?;#]/.test(token)) { 82 | result += token; 83 | } else { 84 | result += (i > 0 ? ' ' : '') + token; 85 | } 86 | } 87 | return result.trim(); 88 | } 89 | 90 | export async function loadTags(app: App): Promise> { 91 | // use getAllTags from obsidian API 92 | const tagsMap: Record = {}; 93 | app.vault.getMarkdownFiles().forEach((file: TFile) => { 94 | const cachedMetadata = app.metadataCache.getFileCache(file); 95 | if (cachedMetadata) { 96 | let tags = getAllTags(cachedMetadata); 97 | if (tags) { 98 | tags.forEach((tag) => { 99 | let tagName = tag; 100 | if (tagName.startsWith('#')) { 101 | tagName = tagName.slice(1); 102 | } 103 | if (tagsMap[tagName]) { 104 | tagsMap[tagName]++; 105 | } else { 106 | tagsMap[tagName] = 1; 107 | } 108 | }); 109 | } 110 | } 111 | }); 112 | return tagsMap; 113 | } 114 | 115 | export async function getContent(app: App, file: TFile | null, limit: number = 1000, method: string = "head_only"): Promise { 116 | let content_str = ''; 117 | if (file !== null) { // read from file 118 | content_str = await app.vault.read(file); 119 | } else { // read from active editor 120 | const editor = app.workspace.getActiveViewOfType(MarkdownView)?.editor; 121 | if (!editor) { 122 | return ''; 123 | } 124 | content_str = editor.getSelection(); 125 | content_str = content_str.trim(); 126 | if (content_str.length === 0) { 127 | content_str = editor.getValue(); 128 | } 129 | } 130 | if (content_str.length === 0) { 131 | return ''; 132 | } 133 | const tokens = splitIntoTokens(content_str); 134 | //console.log('token_count', tokens.length); 135 | if (tokens.length > limit && limit > 0) { 136 | if (method === "head_tail") { 137 | const left = Math.round(limit * 0.8); 138 | const right = Math.round(limit * 0.2); 139 | const leftTokens = tokens.slice(0, left); 140 | const rightTokens = tokens.slice(-right); 141 | content_str = joinTokens(leftTokens) + '\n...\n' + joinTokens(rightTokens); 142 | } else if (method === "head_only") { 143 | content_str = joinTokens(tokens.slice(0, limit)) + "..."; 144 | } else if (method === "heading") { 145 | let lines = content_str.split('\n'); 146 | lines = lines.filter(line => line.trim() !== ''); 147 | 148 | let new_lines: string[] = []; 149 | let captureNextParagraph = false; 150 | for (let line of lines) { 151 | if (line.startsWith('#')) { 152 | new_lines.push(line); 153 | captureNextParagraph = true; 154 | } 155 | else if (captureNextParagraph && line.trim() !== '') { 156 | const lineTokens = splitIntoTokens(line); 157 | new_lines.push(joinTokens(lineTokens.slice(0, 30)) + '...'); // 30 tokens 158 | captureNextParagraph = false; 159 | } 160 | } 161 | content_str = new_lines.join('\n'); 162 | const totalTokens = splitIntoTokens(content_str); 163 | if (totalTokens.length > limit) { 164 | content_str = joinTokens(totalTokens.slice(0, limit)); 165 | } else { 166 | let remainingTokens = limit - totalTokens.length; 167 | let head = joinTokens(tokens.slice(0, remainingTokens)) + "..."; 168 | content_str = `Outline: \n${content_str}\n\nBody: ${head}`; 169 | } 170 | } 171 | } 172 | //console.log('base', tokens.length, 'return', splitIntoTokens(content_str).length); 173 | return content_str; 174 | } 175 | 176 | export function updateFrontMatter(file: TFile, app: App, key: string, value: any, method: string) { 177 | app.fileManager.processFrontMatter(file, (frontmatter) => { 178 | if (value === undefined || value === null) { 179 | return; 180 | } 181 | if (method === `append`) { 182 | let old_value = frontmatter[key]; 183 | if (typeof value === 'string') { 184 | if (old_value === undefined) { 185 | old_value = ''; 186 | } 187 | frontmatter[key] = old_value + value; 188 | } else if (Array.isArray(value)) { 189 | if (old_value === undefined) { 190 | old_value = []; 191 | } 192 | const new_value = old_value.concat(value); 193 | const unique_value = Array.from(new Set(new_value)); 194 | frontmatter[key] = unique_value; 195 | } 196 | } else if (method === `update`) { 197 | frontmatter[key] = value; 198 | } else { // keep: keep_if_exists 199 | let old_value = frontmatter[key]; 200 | if (old_value !== undefined) { 201 | return; 202 | } 203 | frontmatter[key] = value; 204 | } 205 | }); 206 | } 207 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .setting-textarea { 2 | width: 400px; 3 | } 4 | 5 | .setting-item-nested { 6 | margin-left: 15px; 7 | } 8 | 9 | .setting-item-nested-2 { 10 | margin-left: 30px; 11 | } 12 | 13 | .dialog-button-container { 14 | display: flex; 15 | justify-content: space-around; 16 | margin-top: 15px; 17 | } 18 | 19 | .dialog-button-container button { 20 | padding: 8px 16px; 21 | margin: 0 5px; 22 | cursor: pointer; 23 | } 24 | 25 | .dialog-button-container.right-aligned { 26 | display: flex; 27 | justify-content: flex-end; 28 | margin: 10px; 29 | } 30 | 31 | .custom-suggestion-item { 32 | white-space: nowrap; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | display: block; 36 | width: 100%; 37 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.4.5" 3 | } 4 | --------------------------------------------------------------------------------