第一版

This commit is contained in:
mousebomb 2025-05-14 16:21:41 +08:00
parent 619bc7a1ea
commit e61575910e
5 changed files with 2776 additions and 169 deletions

121
README.md
View file

@ -1,94 +1,57 @@
# Obsidian Sample Plugin
# Obsidian Diary ICS
This is a sample plugin for Obsidian (https://obsidian.md).
这是一个Obsidian插件用于将Obsidian的日记系统内容同步到系统的日历应用中如macOS日历、Windows日历等
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
## 核心功能
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
### 生成ICS日历订阅文件
- 插件会根据Obsidian中的日记内容自动生成一个ICS格式的日历订阅文件.ics
- 该文件将被托管在本地的一个HTTP端口上例如`http://127.0.0.1:19347/feed.ics`
- 系统日历应用可以订阅这个链接从而实时同步Obsidian的日记内容到系统日历中
## First time developing plugins?
### 日记内容解析规则
- 插件会解析Obsidian中的日记笔记文件通常是按日期命名的Markdown文件
- 从这些文件中提取出一级或二级标题(由用户配置)
- 每个提取出的标题将作为一条日历条目,对应当天(即文件名中的日期)
Quick starting guide for new plugin devs:
### 日历条目详情
每个日历条目(事件)将包含以下内容:
- **标题**:从日记文件中提取的一级或二级标题
- **备注Description**:包含一个可点击的链接,格式为:`obsidian://open?vault=你的库名&file=日记文件路径`点击后可以直接跳转回Obsidian的对应日记文件中
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## 示例说明
## Releasing new releases
假设你有一个日记文件:`2025-05-14.md`,内容如下:
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
```markdown
# 今天的工作总结
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## 上午任务
- 完成项目A的模块1
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
## 下午任务
- 与团队开会讨论需求
```
If you have multiple URLs, you can also do:
用户在插件设置中设置了提取所有二级标题。则,插件会提取出两个日历条目:
- 事件1标题为"上午任务",备注中包含链接
- 事件2标题为"下午任务",备注中包含链接
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
系统日历订阅了 `http://127.0.0.1:19347/feed.ics` 后,就能看到这两个事件。
## API Documentation
## 使用方法
See https://github.com/obsidianmd/obsidian-api
1. 在Obsidian中安装并启用此插件
2. 在插件设置中配置:
- 要提取的标题级别(一级或二级标题)
- HTTP服务器端口默认为19347
3. 复制插件提供的ICS订阅链接
4. 在系统日历应用中添加该订阅链接
5. 现在你的Obsidian日记内容将自动同步到系统日历中
## 开发信息
- 本插件使用TypeScript开发
- 使用Obsidian API访问日记文件内容并监听文件变化
- 启动本地HTTP服务器提供ICS文件
- 按照ICS标准生成日历文件

328
main.ts
View file

@ -1,85 +1,70 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import * as http from 'http';
import { createEvents, EventAttributes } from 'ics';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
interface DiaryIcsSettings {
port: number;
headingLevel: string;
includeContent: boolean;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
const DEFAULT_SETTINGS: DiaryIcsSettings = {
port: 19347,
headingLevel: 'h2',
includeContent: true
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
export default class DiaryIcsPlugin extends Plugin {
settings: DiaryIcsSettings;
server: http.Server | null = null;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
// 添加图标到左侧边栏
const ribbonIconEl = this.addRibbonIcon('calendar-with-checkmark', 'Diary ICS', (evt: MouseEvent) => {
// 点击图标时显示ICS订阅链接
new Notice(`ICS订阅链接: http://127.0.0.1:${this.settings.port}/feed.ics`);
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
// 添加状态栏项目
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
statusBarItemEl.setText(`ICS: http://127.0.0.1:${this.settings.port}/feed.ics`);
// This adds a simple command that can be triggered anywhere
// 添加命令复制ICS订阅链接
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
id: 'copy-ics-url',
name: '复制ICS订阅链接',
callback: () => {
new SampleModal(this.app).open();
const url = `http://127.0.0.1:${this.settings.port}/feed.ics`;
navigator.clipboard.writeText(url);
new Notice('ICS订阅链接已复制到剪贴板');
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
// 添加设置选项卡
this.addSettingTab(new DiaryIcsSettingTab(this.app, this));
// 启动HTTP服务器
this.startServer();
// 监听文件变化以便更新ICS内容
this.registerEvent(
this.app.vault.on('modify', (file) => {
if (this.isDiaryFile(file)) {
console.log('日记文件已更新:', file.path);
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
})
);
}
onunload() {
// 关闭HTTP服务器
if (this.server) {
this.server.close();
this.server = null;
console.log('ICS HTTP服务器已关闭');
}
}
async loadSettings() {
@ -88,29 +73,152 @@ export default class MyPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
// 重启服务器以应用新设置
if (this.server) {
this.server.close();
this.server = null;
}
this.startServer();
}
// 启动HTTP服务器提供ICS文件
startServer() {
const port = this.settings.port;
this.server = http.createServer(async (req, res) => {
if (req.url === '/feed.ics') {
try {
const icsContent = await this.generateIcsContent();
res.writeHead(200, {
'Content-Type': 'text/calendar',
'Content-Disposition': 'attachment; filename="obsidian-diary.ics"'
});
res.end(icsContent);
console.log('已提供ICS文件');
} catch (error) {
console.error('生成ICS文件时出错:', error);
res.writeHead(500);
res.end('生成ICS文件时出错');
}
} else {
res.writeHead(404);
res.end('未找到');
}
});
this.server.listen(port, '127.0.0.1', () => {
console.log(`ICS HTTP服务器已启动: http://127.0.0.1:${port}/feed.ics`);
new Notice(`ICS服务器已启动: http://127.0.0.1:${port}/feed.ics`);
});
this.server.on('error', (error) => {
console.error('HTTP服务器错误:', error);
new Notice(`HTTP服务器错误: ${error.message}`);
});
}
// 判断文件是否为日记文件基于文件名格式YYYY-MM-DD.md
isDiaryFile(file: TFile): boolean {
return file.extension === 'md' && /^\d{4}-\d{2}-\d{2}$/.test(file.basename);
}
// 解析日记文件,提取标题
async parseDiaryFile(file: TFile): Promise<{title: string, content: string}[]> {
const content = await this.app.vault.read(file);
const entries: {title: string, content: string}[] = [];
// 根据设置选择要提取的标题级别
const headingPattern = this.settings.headingLevel === 'h1'
? '^# (.+)$'
: '^## (.+)$';
// 使用字符串分割方法而不是正则表达式的exec方法避免lastIndex问题
const lines = content.split('\n');
let currentTitle = '';
let currentContent = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = new RegExp(headingPattern).exec(line);
if (headingMatch) {
// 如果已经有标题,保存之前的条目
if (currentTitle) {
entries.push({
title: currentTitle,
content: currentContent.join('\n').trim()
});
currentContent = [];
}
currentTitle = headingMatch[1];
} else if (currentTitle) {
currentContent.push(line);
}
}
// 添加最后一个条目
if (currentTitle) {
entries.push({
title: currentTitle,
content: currentContent.join('\n').trim()
});
}
return entries;
}
// 生成ICS文件内容
async generateIcsContent(): Promise<string> {
const events: EventAttributes[] = [];
const vaultName = this.app.vault.getName();
console.log (" 生成ICS文件内容: ", vaultName);
// 获取所有日记文件
const files = this.app.vault.getMarkdownFiles()
.filter(file => this.isDiaryFile(file));
console.log (" 生成ICS文件内容: ", files.length, " 个日记文件");
for (const file of files) {
// 从文件名解析日期
const [year, month, day] = file.basename.split('-').map(Number);
const entries = await this.parseDiaryFile(file);
console.log (" 解析日记文件: ", file.path, " 解析条目: ", entries.length, " 条");
for (const entry of entries) {
// 创建事件
events.push({
title: entry.title,
description: this.settings.includeContent
? `${entry.content}\n\n点击打开: obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(file.path)}`
: `点击打开: obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(file.path)}`,
start: [year, month, day],
status: 'CONFIRMED',
busyStatus: 'FREE'
});
}
}
// 使用ics库生成ICS内容
return new Promise((resolve, reject) => {
createEvents(events, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
class DiaryIcsSettingTab extends PluginSettingTab {
plugin: DiaryIcsPlugin;
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
constructor(app: App, plugin: DiaryIcsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
@ -120,15 +228,65 @@ class SampleSettingTab extends PluginSettingTab {
containerEl.empty();
containerEl.createEl('h2', {text: 'Diary ICS 设置'});
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.setName('HTTP服务器端口')
.setDesc('本地HTTP服务器使用的端口号')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.setPlaceholder('99347')
.setValue(this.plugin.settings.port.toString())
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
const port = parseInt(value);
if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port;
await this.plugin.saveSettings();
}
}));
new Setting(containerEl)
.setName('标题级别')
.setDesc('从日记中提取哪一级标题作为日历条目')
.addDropdown(dropdown => dropdown
.addOption('h1', '一级标题 (#)')
.addOption('h2', '二级标题 (##)')
.setValue(this.plugin.settings.headingLevel)
.onChange(async (value) => {
this.plugin.settings.headingLevel = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('包含内容')
.setDesc('在日历事件描述中包含标题下的内容')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeContent)
.onChange(async (value) => {
this.plugin.settings.includeContent = value;
await this.plugin.saveSettings();
}));
// 显示当前ICS订阅链接
containerEl.createEl('h3', {text: 'ICS订阅链接'});
const linkEl = containerEl.createEl('div', {text: `http://127.0.0.1:${this.plugin.settings.port}/feed.ics`});
linkEl.style.padding = '10px';
linkEl.style.backgroundColor = '#f5f5f5';
linkEl.style.borderRadius = '5px';
linkEl.style.marginBottom = '20px';
// 添加复制按钮
const copyButton = containerEl.createEl('button', {text: '复制链接'});
copyButton.style.marginBottom = '20px';
copyButton.addEventListener('click', () => {
const url = `http://127.0.0.1:${this.plugin.settings.port}/feed.ics`;
navigator.clipboard.writeText(url);
new Notice('ICS订阅链接已复制到剪贴板');
});
// 添加使用说明
containerEl.createEl('h3', {text: '使用说明'});
containerEl.createEl('p', {text: '1. 复制上面的ICS订阅链接'});
containerEl.createEl('p', {text: '2. 在系统日历应用中添加该订阅链接'});
containerEl.createEl('p', {text: '3. 确保Obsidian在运行状态以便HTTP服务器能够提供ICS文件'});
}
}

View file

@ -1,11 +1,10 @@
{
"id": "diary-ics",
"name": "diary-ics",
"name": "Obsidian Diary ICS",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Sync Obsidian diary entries to system calendar via ICS feed.",
"description": "将Obsidian日记内容同步到系统日历应用中通过ICS订阅链接实现实时同步。",
"author": "Obsidian",
"authorUrl": "https://mousebomb.org",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": true
}

2483
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "obsidian-sample-plugin",
"name": "obsidian-diary-ics",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"description": "Sync Obsidian diary entries to system calendar via ICS feed.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@ -11,6 +11,10 @@
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"ics": "^3.1.0",
"http": "^0.0.1-security"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",