This commit is contained in:
delphi 2025-02-26 14:20:31 +08:00
parent 494fa9de77
commit 9f00dad5e9
11 changed files with 3387 additions and 144 deletions

View file

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",

134
main.ts
View file

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
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!');
});
// 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');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// 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 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() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

View file

@ -1,11 +1,10 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "obsidian-cubox-sync",
"name": "Cubox Sync",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"description": "同步 Cubox 文章和高亮内容到 Obsidian",
"author": "Your Name",
"authorUrl": "https://github.com/yourusername",
"isDesktopOnly": false
}

2244
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-cubox",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"description": "This is a cubox plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@ -19,6 +19,8 @@
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "4.7.4",
"@types/mustache": "^4.2.0",
"mustache": "^4.2.0"
}
}

234
src/cuboxApi.ts Normal file
View file

@ -0,0 +1,234 @@
import { Notice } from 'obsidian';
import { formatISODateTime } from './utils';
export interface CuboxArticle {
cardId: string;
id: string; // 兼容原有接口
title: string;
description: string;
url: string;
domain: string;
contentId: string;
coverKey: string;
isArchived: boolean;
hasStar: boolean;
createTime: string;
updateTime: string;
content?: string;
highlights?: CuboxHighlight[];
tags?: string[];
folder?: string;
type?: string;
}
export interface CuboxHighlight {
id: string;
text: string;
note?: string;
color?: string;
createDate: string;
}
export interface CuboxApiOptions {
domain: string;
apiKey: string;
}
export interface CuboxFolder {
id: string;
name: string;
}
interface ListResponse {
code: number;
message: string;
data: {
list: CuboxArticle[];
};
}
interface ContentResponse {
code: number;
message: string;
data: string;
}
interface FoldersResponse {
code: number;
message: string;
data: {
list: CuboxFolder[];
};
}
export class CuboxApi {
private endpoint: string;
private apiKey: string;
constructor(options: CuboxApiOptions) {
this.endpoint = `https://${options.domain}`;
this.apiKey = options.apiKey;
}
/**
* API
*/
async testConnection(): Promise<boolean> {
try {
// 尝试获取一篇文章来测试连接
// const result = await this.getArticles(1, 1);
new Notice('测试 Cubox 连接成功');
return true;
} catch (error) {
console.error('Cubox API 连接测试失败:', error);
new Notice('Cubox API 连接失败,请检查域名和 API Key');
return false;
}
}
private async request(path: string, options: RequestInit = {}) {
const url = `${this.endpoint}${path}`;
const headers = {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json();
}
/**
*
* @param folders
* @param type
* @param status 'all' | 'read' | 'starred' | 'annotated'
* @param lastCardId ID
*/
async getArticles(
folders: string[] = [],
type?: string,
status?: string,
lastCardId: string | null = null,
pageSize: number = 50
): Promise<{
articles: CuboxArticle[],
hasMore: boolean,
lastCardId: string | null
}> {
try {
const searchParams = new URLSearchParams();
if (lastCardId !== null && lastCardId.length > 0) {
searchParams.append('lastCardId', lastCardId);
}
searchParams.append('pageSize', pageSize.toString());
// 如果有选择文件夹,添加文件夹过滤参数
if (folders && folders.length > 0) {
searchParams.append('folders', folders.join(','));
}
// 添加状态过滤参数
if (status && status !== 'all') {
switch (status) {
case 'read':
searchParams.append('isRead', 'true');
break;
case 'starred':
searchParams.append('hasStar', 'true');
break;
case 'annotated':
searchParams.append('hasAnnotation', 'true');
break;
}
}
const path = `/c/api/third-party/card/list?${searchParams.toString()}`;
const response = await this.request(path) as ListResponse;
const articles = response.data.list.map(article => {
// 添加兼容字段,并格式化日期
return {
...article,
id: article.cardId,
createDate: article.createTime ? formatISODateTime(article.createTime) : '',
// 其他可能需要的兼容字段
};
});
const hasMore = articles.length >= pageSize;
const newLastCardId = articles.length > 0 ? articles[articles.length - 1].cardId : null;
return {
articles,
hasMore,
lastCardId: newLastCardId
};
} catch (error) {
console.error('获取 Cubox 文章失败:', error);
new Notice('获取 Cubox 文章失败');
throw error;
}
}
/**
*
* @param articleId ID
*/
async getArticleDetail(articleId: string): Promise<string | null> {
try {
const path = `/c/api/third-party/card/content?cardId=${articleId}`;
const response = await this.request(path) as ContentResponse;
// 直接返回文章内容
return response.data;
} catch (error) {
console.error(`获取文章 ${articleId} 详情失败:`, error);
new Notice(`获取文章详情失败`);
return null;
}
}
/**
*
* @param articleId ID
*/
async getHighlights(articleId: string): Promise<CuboxHighlight[]> {
try {
// 这里需要实现获取高亮的API调用
// 暂时返回空数组
return [];
} catch (error) {
console.error(`获取文章 ${articleId} 高亮内容失败:`, error);
new Notice(`获取高亮内容失败`);
return [];
}
}
/**
*
*/
async getFolders(): Promise<CuboxFolder[]> {
try {
const path = '/c/api/third-party/folder/list';
const response = await this.request(path) as FoldersResponse;
return response.data.list || [];
} catch (error) {
console.error('获取 Cubox 文件夹列表失败:', error);
new Notice('获取 Cubox 文件夹列表失败');
throw error;
}
}
}

127
src/folderSelectModal.ts Normal file
View file

@ -0,0 +1,127 @@
import { App, Modal, Setting } from 'obsidian';
import { CuboxApi } from './cuboxApi';
export interface CuboxFolder {
id: string;
name: string;
}
export class FolderSelectModal extends Modal {
private folders: CuboxFolder[] = [];
private selectedFolders: Set<string> = new Set();
private onSave: (selectedFolders: string[]) => void;
private cuboxApi: CuboxApi;
constructor(app: App, cuboxApi: CuboxApi, initialSelected: string[], onSave: (selectedFolders: string[]) => void) {
super(app);
this.cuboxApi = cuboxApi;
this.onSave = onSave;
// 初始化已选择的文件夹
initialSelected.forEach(id => {
if (id) this.selectedFolders.add(id);
});
}
async onOpen() {
const { contentEl } = this;
// 设置标题
contentEl.createEl('h2', { text: 'Cubox: 管理要同步的文件夹' });
try {
// 显示加载中提示
const loadingEl = contentEl.createEl('p', { text: '加载文件夹列表...' });
// 获取文件夹列表
this.folders = await this.cuboxApi.getFolders();
// 移除加载提示
loadingEl.remove();
// 如果没有文件夹,显示提示
if (this.folders.length === 0) {
contentEl.createEl('p', { text: '没有找到文件夹' });
return;
}
// 添加"全选"选项
new Setting(contentEl)
.addToggle(toggle => toggle
.setValue(this.isAllSelected())
.onChange(value => {
if (value) {
// 全选
this.folders.forEach(folder => this.selectedFolders.add(folder.id));
} else {
// 全不选
this.selectedFolders.clear();
}
// 刷新界面
this.redraw();
}))
.setName('全部文件夹');
// 创建文件夹列表
this.createFolderList();
// 添加底部按钮
const footerEl = contentEl.createDiv('modal-footer');
// 取消按钮
const cancelButton = footerEl.createEl('button', { text: '取消' });
cancelButton.addEventListener('click', () => {
this.close();
});
// 保存按钮
const saveButton = footerEl.createEl('button', { text: '保存', cls: 'mod-cta' });
saveButton.addEventListener('click', () => {
this.onSave(Array.from(this.selectedFolders));
this.close();
});
} catch (error) {
console.error('加载文件夹失败:', error);
contentEl.createEl('p', { text: '加载文件夹失败,请检查网络连接和 API 密钥' });
}
}
private createFolderList() {
const { contentEl } = this;
// 清除现有列表
const existingList = contentEl.querySelector('.folder-list');
if (existingList) existingList.remove();
// 创建新列表容器
const listEl = contentEl.createDiv({ cls: 'folder-list' });
// 添加每个文件夹的选项
this.folders.forEach(folder => {
new Setting(listEl)
.addToggle(toggle => toggle
.setValue(this.selectedFolders.has(folder.id))
.onChange(value => {
if (value) {
this.selectedFolders.add(folder.id);
} else {
this.selectedFolders.delete(folder.id);
}
}))
.setName(folder.name);
});
}
private isAllSelected(): boolean {
return this.folders.length > 0 && this.folders.every(folder => this.selectedFolders.has(folder.id));
}
private redraw() {
this.createFolderList();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

504
src/main.ts Normal file
View file

@ -0,0 +1,504 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFolder } from 'obsidian';
import { CuboxApi, CuboxApiOptions } from './cuboxApi';
import { TemplateProcessor } from './templateProcessor';
import { formatISODateTime, getCurrentFormattedTime } from './utils';
import { FolderSelectModal } from './folderSelectModal';
interface CuboxSyncSettings {
domain: string;
apiKey: string;
folderFilter: string[];
typeFilter: string;
statusFilter: 'all' | 'read' | 'starred' | 'annotated';
syncFrequency: number;
targetFolder: string;
filenameTemplate: string;
frontMatterTemplate: string;
contentTemplate: string;
highlightInContent: boolean;
dateFormat: string;
lastSyncTime: number;
lastSyncCardId: string | null;
}
const DEFAULT_SETTINGS: CuboxSyncSettings = {
domain: 'cubox.cc',
apiKey: '',
folderFilter: [],
typeFilter: '',
statusFilter: 'all',
syncFrequency: 30, // 分钟
targetFolder: 'Cubox',
filenameTemplate: '{{cardTitle}}-{{createDate}}',
frontMatterTemplate: 'title: {{cardTitle}}\nurl: {{url}}\ndate: {{createDate}}',
contentTemplate: '# {{cardTitle}}\n\n{{{content}}}\n\n## Highlights\n{{#highlightsList}}\n- {{{text}}}\n{{/highlightsList}}',
highlightInContent: true,
dateFormat: 'YYYY-MM-dd',
lastSyncTime: 0,
lastSyncCardId: null
}
export default class CuboxSyncPlugin extends Plugin {
settings: CuboxSyncSettings;
cuboxApi: CuboxApi;
templateProcessor: TemplateProcessor;
syncIntervalId: number;
async onload() {
await this.loadSettings();
// 初始化 API 和模板处理器
this.cuboxApi = new CuboxApi({
domain: this.settings.domain,
apiKey: this.settings.apiKey
});
this.templateProcessor = new TemplateProcessor();
// 设置模板处理器的日期格式
this.templateProcessor.setDateFormat(this.settings.dateFormat);
// 添加左侧图标
const ribbonIconEl = this.addRibbonIcon('download', 'Sync Cubox', async (evt: MouseEvent) => {
new Notice('开始同步 Cubox 数据...');
await this.syncCubox();
});
ribbonIconEl.addClass('cubox-sync-ribbon-class');
// 添加状态栏
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText(`上次同步: ${this.formatLastSyncTime()}`);
// 添加同步命令
this.addCommand({
id: 'sync-cubox-data',
name: '同步 Cubox 数据',
callback: async () => {
await this.syncCubox();
}
});
// 添加设置选项卡
this.addSettingTab(new CuboxSyncSettingTab(this.app, this));
// 设置自动同步
this.setupAutoSync();
// 加载样式
this.loadStyles();
}
onunload() {
// 清除自动同步定时器
if (this.syncIntervalId) {
window.clearInterval(this.syncIntervalId);
}
// 移除样式
const styleEl = document.getElementById('cubox-sync-styles');
if (styleEl) styleEl.remove();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// 更新 API 配置
this.cuboxApi = new CuboxApi({
domain: this.settings.domain,
apiKey: this.settings.apiKey
});
// 更新模板处理器的日期格式
this.templateProcessor.setDateFormat(this.settings.dateFormat);
// 重新设置自动同步
this.setupAutoSync();
}
setupAutoSync() {
// 清除现有定时器
if (this.syncIntervalId) {
window.clearInterval(this.syncIntervalId);
}
// 如果频率大于0设置新的定时器
if (this.settings.syncFrequency > 0) {
this.syncIntervalId = window.setInterval(
async () => await this.syncCubox(),
this.settings.syncFrequency * 60 * 1000
);
}
}
async syncCubox(continueLastSync: boolean = false) {
try {
// 检查 API 连接
const isConnected = await this.cuboxApi.testConnection();
if (!isConnected) {
return;
}
// 确保目标文件夹存在
await this.ensureTargetFolder();
// 如果选择继续上次同步,则使用保存的 lastSyncCardId
let lastCardId: string | null = continueLastSync ? this.settings.lastSyncCardId : null;
let hasMore = true;
let syncCount = 0;
// 分页获取所有文章
while (hasMore) {
// 获取文章列表
const result = await this.cuboxApi.getArticles(
this.settings.folderFilter,
this.settings.typeFilter,
this.settings.statusFilter,
lastCardId
);
const { articles, hasMore: moreArticles, lastCardId: newLastCardId } = result;
if (articles.length === 0) {
break;
}
// 处理每篇文章
for (const article of articles) {
// 获取文章详情和高亮
const content = await this.cuboxApi.getArticleDetail(article.cardId);
if (content === null) continue;
// 获取高亮内容
const highlights = await this.cuboxApi.getHighlights(article.cardId);
// 合并文章基本信息、内容和高亮
const fullArticle = {
...article,
content: content,
highlights: highlights
};
// 处理文件名和内容
const filename = this.templateProcessor.processFilenameTemplate(
this.settings.filenameTemplate,
fullArticle
);
const frontMatter = this.templateProcessor.processFrontMatterTemplate(
this.settings.frontMatterTemplate,
fullArticle
);
const contentTemplate = this.templateProcessor.processContentTemplate(
this.settings.contentTemplate,
fullArticle
);
// 组合最终内容
let finalContent = '';
if (frontMatter) {
finalContent = `---\n${frontMatter}\n---\n\n`;
}
finalContent += contentTemplate;
// 创建或更新文件
const filePath = `${this.settings.targetFolder}/${filename}.md`;
await this.app.vault.adapter.write(filePath, finalContent);
syncCount++;
}
// 更新分页参数
hasMore = moreArticles;
lastCardId = newLastCardId;
// 在每次分页循环后保存当前的同步状态
this.settings.lastSyncCardId = lastCardId;
this.settings.lastSyncTime = Date.now();
await this.saveSettings();
// 只在 lastCardId 不为 null 时才继续分页
if (lastCardId === null) {
break;
}
}
// 同步完成后,清除 lastSyncCardId表示完整同步已完成
this.settings.lastSyncCardId = null;
await this.saveSettings();
new Notice(`成功同步 ${syncCount} 篇 Cubox 文章`);
} catch (error) {
console.error('同步 Cubox 数据失败:', error);
new Notice('同步 Cubox 数据失败,请检查设置和网络连接');
}
}
async ensureTargetFolder() {
const folderPath = this.settings.targetFolder;
// 检查文件夹是否存在
if (!(await this.app.vault.adapter.exists(folderPath))) {
// 创建文件夹
await this.app.vault.createFolder(folderPath);
}
}
formatLastSyncTime(): string {
if (!this.settings.lastSyncTime) {
return '从未同步';
}
// 使用新的格式化方法
return formatISODateTime(new Date(this.settings.lastSyncTime).toISOString(), 'yyyy-MM-dd HH:mm');
}
private loadStyles() {
// 添加自定义样式
const styleEl = document.createElement('style');
styleEl.id = 'cubox-sync-styles';
styleEl.textContent = `
/* 文件夹选择模态框样式 */
.folder-list {
max-height: 300px;
overflow-y: auto;
margin: 10px 0;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 5px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
gap: 10px;
}
.modal-footer button {
padding: 6px 12px;
border-radius: 4px;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.modal-footer button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
`;
document.head.appendChild(styleEl);
}
}
class CuboxSyncSettingTab extends PluginSettingTab {
plugin: CuboxSyncPlugin;
constructor(app: App, plugin: CuboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
// 连接设置部分
containerEl.createEl('h2', {text: 'Connect Obsidian to Your Cubox'});
new Setting(containerEl)
.setName('Cubox Server Domain')
.setDesc('Select the correct domain name of the Cubox you are using.')
.addDropdown(dropdown => dropdown
.addOption('cubox.cc', 'cubox.cc')
.setValue(this.plugin.settings.domain)
.onChange(async (value) => {
this.plugin.settings.domain = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Your Cubox API Key')
.setDesc('You can create a key in the settings of Cubox web app.')
.addText(text => text
.setPlaceholder('Enter your API key')
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
}));
// 过滤器设置部分
containerEl.createEl('h2', {text: 'Filter'});
new Setting(containerEl)
.setName('Manage')
.setDesc('选择要同步的文件夹')
.addButton(button => button
.setButtonText(this.getFolderFilterButtonText())
.onClick(async () => {
// 确保 API 已初始化
if (!this.plugin.settings.apiKey) {
new Notice('请先设置 API Key');
return;
}
// 打开文件夹选择模态框
const modal = new FolderSelectModal(
this.app,
this.plugin.cuboxApi,
this.plugin.settings.folderFilter,
async (selectedFolders) => {
this.plugin.settings.folderFilter = selectedFolders;
await this.plugin.saveSettings();
// 更新按钮文本
button.setButtonText(this.getFolderFilterButtonText());
}
);
modal.open();
}));
new Setting(containerEl)
.setName('Type Filter')
.setDesc('Enter the xxxxx, 0 means manual sync')
.addDropdown(dropdown => dropdown
.addOption('', 'Sync all the items')
.setValue(this.plugin.settings.typeFilter)
.onChange(async (value) => {
this.plugin.settings.typeFilter = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Status Filter')
.setDesc('Only the items that match the status will be synced.')
.addDropdown(dropdown => dropdown
.addOption('all', 'Sync all the items')
.addOption('read', 'Sync only read items')
.addOption('starred', 'Sync only starred items')
.addOption('annotated', 'Sync only annotated items')
.setValue(this.plugin.settings.statusFilter || 'all')
.onChange(async (value) => {
this.plugin.settings.statusFilter = value as 'all' | 'read' | 'starred' | 'annotated';
await this.plugin.saveSettings();
}));
// 同步设置部分
containerEl.createEl('h2', {text: 'Sync'});
new Setting(containerEl)
.setName('Frequency')
.setDesc('Enter the xxxxx, 0 means manual sync')
.addText(text => text
.setPlaceholder('Enter sync frequency in minutes')
.setValue(String(this.plugin.settings.syncFrequency))
.onChange(async (value) => {
const numValue = parseInt(value);
this.plugin.settings.syncFrequency = isNaN(numValue) ? 0 : numValue;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Target Folder')
.setDesc('Select the folder you would like to sync')
.addText(text => text
.setPlaceholder('Enter target folder path')
.setValue(this.plugin.settings.targetFolder)
.onChange(async (value) => {
this.plugin.settings.targetFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('File Name Template')
.setDesc('Enter the file name when the data is stored')
.addText(text => text
.setPlaceholder('Enter file name template')
.setValue(this.plugin.settings.filenameTemplate)
.onChange(async (value) => {
this.plugin.settings.filenameTemplate = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Front Matter')
.setDesc('Enter the metadata')
.addTextArea(text => text
.setPlaceholder('Enter front matter template')
.setValue(this.plugin.settings.frontMatterTemplate)
.onChange(async (value) => {
this.plugin.settings.frontMatterTemplate = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Content Template')
.setDesc('Enter the file name when the data is stored')
.addTextArea(text => text
.setPlaceholder('Enter content template')
.setValue(this.plugin.settings.contentTemplate)
.onChange(async (value) => {
this.plugin.settings.contentTemplate = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Highlight Text in Content')
.setDesc('Highlight text in articles')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.highlightInContent)
.onChange(async (value) => {
this.plugin.settings.highlightInContent = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Date Format')
.setDesc('Enter the xxxxx, 0 means manual sync')
.addText(text => text
.setPlaceholder('Enter date format')
.setValue(this.plugin.settings.dateFormat)
.onChange(async (value) => {
this.plugin.settings.dateFormat = value;
await this.plugin.saveSettings();
}));
// 状态部分
containerEl.createEl('h2', {text: 'Status'});
containerEl.createEl('p', {text: `Last sync: ${this.plugin.formatLastSyncTime()}`});
// 添加测试连接按钮
new Setting(containerEl)
.setName('Test Connection')
.setDesc('Test your Cubox API connection')
.addButton(button => button
.setButtonText('Test')
.onClick(async () => {
await this.plugin.cuboxApi.testConnection();
}));
// 添加立即同步按钮
new Setting(containerEl)
.setName('Sync Now')
.setDesc('Manually sync Cubox data')
.addButton(button => button
.setButtonText('Sync')
.onClick(async () => {
await this.plugin.syncCubox();
}));
}
// 获取文件夹过滤器按钮文本
private getFolderFilterButtonText(): string {
const count = this.plugin.settings.folderFilter.length;
if (count === 0) {
return 'Sync all folders';
} else {
return `已选择 ${count} 个文件夹`;
}
}
}

124
src/templateProcessor.ts Normal file
View file

@ -0,0 +1,124 @@
import { CuboxArticle, CuboxHighlight } from './cuboxApi';
import { formatISODateTime, generateSafeFileArticleName } from './utils';
import * as Mustache from 'mustache';
export class TemplateProcessor {
// 存储日期格式
private dateFormat: string = 'yyyy-MM-dd';
/**
*
* @param format
*/
setDateFormat(format: string): void {
this.dateFormat = format;
}
/**
*
* @param template
* @param article
*/
processFilenameTemplate(template: string, article: CuboxArticle): string {
if (!template) {
return article.title || 'Untitled';
}
// 准备用于 Mustache 的数据
const data = this.prepareTemplateData(article);
// 使用 Mustache 渲染模板
const filename = Mustache.render(template, data);
// 处理文件名安全性
return generateSafeFileArticleName(filename);
}
/**
*
* @param template
* @param article
*/
processFrontMatterTemplate(template: string, article: CuboxArticle): string {
if (!template) {
return '';
}
// 准备用于 Mustache 的数据
const data = this.prepareTemplateData(article);
// 使用 Mustache 渲染模板
return Mustache.render(template, data);
}
/**
*
* @param template
* @param article
*/
processContentTemplate(template: string, article: CuboxArticle): string {
if (!template) {
let content = ``;
if (article.content) {
content += article.content + '\n\n';
}
if (article.highlights && article.highlights.length > 0) {
content += '## Highlights\n\n';
article.highlights.forEach(highlight => {
content += `- ${highlight.text}\n`;
});
}
return content;
}
// 准备用于 Mustache 的数据
const data = this.prepareTemplateData(article);
// 使用 Mustache 渲染模板
return Mustache.render(template, data);
}
/**
* Mustache
* @param article
*/
private prepareTemplateData(article: CuboxArticle): any {
// 格式化日期
const createDate = article.createTime || '';
const formattedDate = createDate ? formatISODateTime(createDate, this.dateFormat) : '';
// 格式化高亮内容
let highlightsText = '';
if (article.highlights && article.highlights.length > 0) {
article.highlights.forEach(highlight => {
highlightsText += `- ${highlight.text}\n`;
});
}
// 返回包含所有可用变量的对象
return {
cardTitle: article.title || 'Untitled',
createDate: formattedDate,
content: article.content || '',
highlights: highlightsText,
url: article.url || '',
domain: article.domain || '',
// 添加原始对象,以便可以访问所有属性
article: article,
// 添加高亮数组,以便可以在模板中循环
highlightsList: article.highlights || []
};
}
/**
* - 使
* @param dateStr
* @param format
*/
formatDate(dateStr: string, format: string): string {
return formatISODateTime(dateStr, format);
}
}

85
src/utils.ts Normal file
View file

@ -0,0 +1,85 @@
// 文件名非法字符正则表达式
export const ILLEGAL_CHAR_REGEX_FILE = /[<>:"/\\|?*\u0000-\u001F]/g;
export const ILLEGAL_CHAR_REGEX_FOLDER = /[<>:"\\|?*\u0000-\u001F]/g;
export const REPLACEMENT_CHAR = '-';
/**
*
* @param str
* @returns
*/
export const replaceIllegalCharsFile = (str: string): string => {
return str.replace(ILLEGAL_CHAR_REGEX_FILE, REPLACEMENT_CHAR);
};
/**
*
* @param str
* @returns
*/
export const replaceIllegalCharsFolder = (str: string): string => {
return str.replace(ILLEGAL_CHAR_REGEX_FOLDER, REPLACEMENT_CHAR);
};
/**
*
* @param title
* @returns
*/
export const generateSafeFileArticleName = (title: string): string => {
const safeTitle = replaceIllegalCharsFile(title).trim();
return safeTitle;
};
/**
* ISO
* @param isoString ISO
* @param format
* @returns
*/
export const formatISODateTime = (isoString: string, format: string = 'yyyy-MM-dd HH:mm'): string => {
if (!isoString) return '';
try {
// 直接使用 Date 对象处理时间
const date = new Date(isoString);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return isoString;
}
// 根据不同格式返回不同的日期字符串
if (format === 'yyyy-MM-dd HH:mm' || format === 'YYYY-MM-dd HH:mm') {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
else if (format === 'yyyy-MM-dd' || format === 'YYYY-MM-dd') {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 默认返回原始字符串
return isoString;
} catch (error) {
console.error('Error formatting ISO date:', error);
return isoString;
}
};
/**
*
* @param format
* @returns
*/
export const getCurrentFormattedTime = (format: string = 'yyyy-MM-dd HH:mm'): string => {
return formatISODateTime(new Date().toISOString(), format);
};

View file

@ -6,3 +6,61 @@ available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
/* 文件夹选择模态框样式 */
.folder-list {
max-height: 300px;
overflow-y: auto;
margin: 10px 0;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 5px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
gap: 10px;
}
.modal-footer button {
padding: 6px 12px;
border-radius: 4px;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.modal-footer button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
/* 更具体的选择器,针对设置面板中的文本区域 */
.vertical-tab-content .setting-item:has(.setting-item-name:contains("Content Template")) textarea {
min-height: 300px !important;
height: 300px !important;
width: 100% !important;
}
.vertical-tab-content .setting-item:has(.setting-item-name:contains("Front Matter")) textarea {
min-height: 200px !important;
height: 200px !important;
width: 100% !important;
}
/* 备用选择器,直接针对插件设置中的所有文本区域 */
.plugin-settings-tab textarea {
min-height: 150px;
}
/* 针对特定插件的设置面板 */
.plugin-settings-tab[data-type="obsidian-cubox-sync"] textarea {
min-height: 150px;
}
/* 最直接的方法:针对所有多行文本输入框 */
.setting-item-control textarea {
min-height: 200px !important;
}