diff --git a/package-lock.json b/package-lock.json index f76aabd..cf445aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,19 @@ "name": "obsidian-cubox", "version": "1.0.0", "license": "MIT", + "dependencies": { + "luxon": "^3.1.1", + "mustache": "^4.2.0" + }, "devDependencies": { - "@types/mustache": "^4.2.0", + "@types/luxon": "^3.4.2", + "@types/mustache": "^4.2.5", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", "esbuild": "0.17.3", + "luxon": "^3.1.1", "mustache": "^4.2.0", "obsidian": "latest", "tslib": "2.4.0", @@ -559,6 +565,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "dev": true + }, "node_modules/@types/mustache": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.5.tgz", @@ -1686,6 +1698,15 @@ "dev": true, "peer": true }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index 53244c2..de021ad 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,21 @@ "author": "", "license": "MIT", "devDependencies": { + "@types/mustache": "^4.2.5", "@types/node": "^16.11.6", + "@types/luxon": "^3.4.2", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", "esbuild": "0.17.3", + "mustache": "^4.2.0", "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4", - "@types/mustache": "^4.2.0", - "mustache": "^4.2.0" + "luxon": "^3.1.1" + }, + "dependencies": { + "mustache": "^4.2.0", + "luxon": "^3.1.1" } } diff --git a/src/cuboxApi.ts b/src/cuboxApi.ts index 1e12b5f..dc1ce78 100644 --- a/src/cuboxApi.ts +++ b/src/cuboxApi.ts @@ -31,11 +31,6 @@ export interface CuboxHighlight { create_time: string; } -export interface CuboxApiOptions { - domain: string; - apiKey: string; -} - export interface CuboxFolder { id: string; name: string; @@ -78,17 +73,17 @@ export class CuboxApi { private endpoint: string; private apiKey: string; - constructor(options: CuboxApiOptions) { - this.endpoint = 'https://test.cubox.pro'//`https://${options.domain}`; - this.apiKey = options.apiKey; + constructor(domain: string, apiKey: string) { + this.endpoint = 'https://test.cubox.pro'//`https://${domain}`; + this.apiKey = apiKey; } /** * 同时更新域名和 API Key */ - updateConfig(options: CuboxApiOptions): void { - this.endpoint = 'https://test.cubox.pro'// `https://${options.domain}`; - this.apiKey = options.apiKey; + updateConfig(domain: string, apiKey: string): void { + this.endpoint = 'https://test.cubox.pro'// `https://${domain}`; + this.apiKey = apiKey; } /** diff --git a/src/main.ts b/src/main.ts index 1b0fa0a..c4f4c06 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ import { addIcon, 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 { CuboxApi } from './cuboxApi'; +import { TemplateProcessor, FRONT_MATTER_VARIABLES } from './templateProcessor'; +import { formatDateTime } from './utils'; import { ALL_FOLDERS_ID, FolderSelectModal } from './modal/folderSelectModal'; -import { filenameTemplateInstructions, metadataTemplateInstructions, contentTemplateInstructions } from './templateInstructions'; +import { filenameTemplateInstructions, metadataVariablesInstructions, contentTemplateInstructions } from './templateInstructions'; import { ALL_CONTENT_TYPES, TypeSelectModal } from './modal/typeSelectModal'; import { StatusSelectModal } from './modal/statusSelectModal'; import { ALL_TAGS_ID, TagSelectModal } from './modal/tagSelectModal'; @@ -22,7 +22,7 @@ interface CuboxSyncSettings { syncFrequency: number; targetFolder: string; filenameTemplate: string; - frontMatterTemplate: string; + frontMatterVariables: string[]; contentTemplate: string; highlightInContent: boolean; dateFormat: string; @@ -44,7 +44,7 @@ const DEFAULT_SETTINGS: CuboxSyncSettings = { syncFrequency: 30, // 分钟 targetFolder: 'Cubox', filenameTemplate: '{{title}}-{{create_time}}', - frontMatterTemplate: 'id: {{{id}}}', + frontMatterVariables: ['id', 'cubox_url', 'url', 'tags'], contentTemplate: '# {{{title}}}\n\n{{{description}}}\n\n[Read in Cubox]({{{cubox_url}}})\n[Read Original]({{{url}}})\n\n{{#highlights.length}}\n## Annotations\n\n{{#highlights}}\n> {{{highlight_text}}}\n{{{highlight_note}}}\n[Link️]({{{highlight_url}}})\n\n{{/highlights}}\n{{/highlights.length}}', highlightInContent: true, dateFormat: 'YYYY-MM-dd', @@ -63,10 +63,7 @@ export default class CuboxSyncPlugin extends Plugin { await this.loadSettings(); // 初始化 API 和模板处理器 - this.cuboxApi = new CuboxApi({ - domain: this.settings.domain, - apiKey: this.settings.apiKey - }); + this.cuboxApi = new CuboxApi(this.settings.domain, this.settings.apiKey); this.templateProcessor = new TemplateProcessor(); // 设置模板处理器的日期格式 this.templateProcessor.setDateFormat(this.settings.dateFormat); @@ -126,16 +123,10 @@ export default class CuboxSyncPlugin extends Plugin { // 更新 API 配置 if (this.cuboxApi) { - this.cuboxApi.updateConfig({ - domain: this.settings.domain, - apiKey: this.settings.apiKey - }); + this.cuboxApi.updateConfig(this.settings.domain, this.settings.apiKey); } else { // 如果实例不存在,创建新实例 - this.cuboxApi = new CuboxApi({ - domain: this.settings.domain, - apiKey: this.settings.apiKey - }); + this.cuboxApi = new CuboxApi(this.settings.domain, this.settings.apiKey); } // 更新模板处理器的日期格式 @@ -224,8 +215,8 @@ export default class CuboxSyncPlugin extends Plugin { fullArticle ); - const frontMatter = this.templateProcessor.processFrontMatterTemplate( - this.settings.frontMatterTemplate, + const frontMatter = this.templateProcessor.processFrontMatter( + this.settings.frontMatterVariables, fullArticle ); @@ -236,7 +227,7 @@ export default class CuboxSyncPlugin extends Plugin { // 组合最终内容 let finalContent = ''; - if (frontMatter) { + if (frontMatter.length > 0) { finalContent = `---\n${frontMatter}\n---\n\n`; } finalContent += fullArticle.content; @@ -308,7 +299,7 @@ export default class CuboxSyncPlugin extends Plugin { } // 使用新的格式化方法 - return formatISODateTime(new Date(this.settings.lastSyncTime).toISOString(), 'yyyy-MM-dd HH:mm'); + return formatDateTime(new Date(this.settings.lastSyncTime).toISOString(), 'yyyy-MM-dd HH:mm'); } } @@ -587,16 +578,23 @@ class CuboxSyncSettingTab extends PluginSettingTab { }); // 更新前置元数据模板设置 - const metadataInstructionsFragment = document.createRange().createContextualFragment(metadataTemplateInstructions); + const metadataInstructionsFragment = document.createRange().createContextualFragment(metadataVariablesInstructions); new Setting(containerEl) - .setName('Metadata Template') + .setName('Metadata Variables') .setDesc(metadataInstructionsFragment) .addTextArea(text => text - .setPlaceholder('Enter front matter template') - .setValue(this.plugin.settings.frontMatterTemplate) + .setPlaceholder('Enter front matter variables') + .setValue(this.plugin.settings.frontMatterVariables.join(',')) .onChange(async (value) => { - this.plugin.settings.frontMatterTemplate = value; + this.plugin.settings.frontMatterVariables = value + .split(',') + .map((v) => v.trim()) + .filter( + (v, i, a) => + FRONT_MATTER_VARIABLES.includes(v.split('::')[0]) && + a.indexOf(v) === i, + ) await this.plugin.saveSettings(); }) // 设置文本区域的大小 @@ -608,7 +606,7 @@ class CuboxSyncSettingTab extends PluginSettingTab { .setIcon('reset') .setTooltip('Reset to default') .onClick(async () => { - this.plugin.settings.frontMatterTemplate = DEFAULT_SETTINGS.frontMatterTemplate; + this.plugin.settings.frontMatterVariables = DEFAULT_SETTINGS.frontMatterVariables; await this.plugin.saveSettings(); this.display(); // 刷新显示 })); diff --git a/src/templateInstructions.ts b/src/templateInstructions.ts index c200f44..380caf9 100644 --- a/src/templateInstructions.ts +++ b/src/templateInstructions.ts @@ -15,7 +15,7 @@ Enter template for creating synced article file name. `; -export const metadataTemplateInstructions = ` +export const metadataVariablesInstructions = ` Enter the metadata separated by comma. you can also use custom aliases in the format of metadata::alias.
diff --git a/src/templateProcessor.ts b/src/templateProcessor.ts index 432e407..b4fbffb 100644 --- a/src/templateProcessor.ts +++ b/src/templateProcessor.ts @@ -1,8 +1,26 @@ import { CuboxArticle, CuboxHighlight } from './cuboxApi'; -import { formatISODateTime, generateSafeFileArticleName } from './utils'; -import * as Mustache from 'mustache'; +import { formatDateTime, generateSafeFileArticleName } from './utils'; +//import * as Mustache from 'mustache'; +import Mustache from 'mustache'; import { parseYaml, stringifyYaml } from 'obsidian'; +export const FRONT_MATTER_VARIABLES = [ + 'title', + 'article_title', + 'tags', + 'create_time', + 'update_time', + 'domain', + 'url', + 'cubox_url', + 'description', + 'description', + 'words_count', + 'type', + 'words_count', + 'id' +] + export class TemplateProcessor { // 存储日期格式 private dateFormat: string = 'yyyy-MM-dd'; @@ -25,106 +43,75 @@ export class TemplateProcessor { return article.title || 'Untitled'; } - // 使用简单的字符串替换而不是 Mustache - let filename = template; + // 准备用于 Mustache 的视图对象 + const view = { + title: article.title || '', + article_title: article.article_title || '', + create_time: article.create_time ? formatDateTime(article.create_time, this.dateFormat) : '', + update_time: article.update_time ? formatDateTime(article.update_time, this.dateFormat) : '', + domain: article.domain || '', + type: article.type || '', + id: article.id || '', + // 可以根据需要添加更多字段 + }; - // 替换所有支持的模板字段 - if (article.title) { - filename = filename.replace(/{{{title}}}/g, article.title); - } - if (article.article_title) { - filename = filename.replace(/{{{article_title}}}/g, article.article_title); - } - - const createDate = article.create_time ? formatISODateTime(article.create_time, this.dateFormat) : ''; - if (createDate) { - filename = filename.replace(/{{{create_time}}}/g, createDate); + let filename = ''; + try { + filename = Mustache.render(template, view); + } catch (error) { + console.error('模板渲染失败:', error); + // 使用备用方案 + filename = article.title || 'Untitled'; } - const updateDate = article.update_time ? formatISODateTime(article.update_time, this.dateFormat) : ''; - if (updateDate) { - filename = filename.replace(/{{{update_time}}}/g, updateDate); + // 限制文件名长度不超过100个字符 + const MAX_LENGTH = 100; + if (filename.length > MAX_LENGTH) { + // 如果超过长度限制,截取前100个字符 + filename = filename.substring(0, MAX_LENGTH); } - if (article.domain) { - filename = filename.replace(/{{{domain}}}/g, article.domain); - } - - if (article.type) { - filename = filename.replace(/{{{type}}}/g, article.type); - } - - // 移除任何未被替换的模板标记 (如果某些字段不存在) - filename = filename.replace(/{{{[^}]+}}}/g, ''); - // 处理文件名安全性 return generateSafeFileArticleName(filename); } /** * 处理前置元数据模板 - * @param template 模板字符串 + * @param templateVariables 模板字符串 * @param article 文章数据 */ - processFrontMatterTemplate(template: string, article: CuboxArticle): string { - if (!template) { + processFrontMatter(templateVariables: string[], article: CuboxArticle): string { + if (templateVariables.length === 0) { return ''; } - try { - // 首先尝试将模板解析为 YAML 对象 - // 如果模板已经是 YAML 格式,则直接使用 - const yamlObj = parseYaml(template); - - // 准备用于替换的数据 - const data = this.prepareTemplateData(article); - - // 递归处理 YAML 对象中的所有字符串值 - const processedYaml = this.processYamlObject(yamlObj, data); - - // 将处理后的对象转换回 YAML 字符串 - return stringifyYaml(processedYaml); - } catch (e) { - // 如果解析失败,则使用 Mustache 渲染模板 - // 这允许用户直接输入 YAML 格式的字符串 - const data = this.prepareTemplateData(article); - const renderedTemplate = Mustache.render(template, data); - - // 尝试将渲染后的模板解析为 YAML 并重新格式化 - try { - const yamlObj = parseYaml(renderedTemplate); - return stringifyYaml(yamlObj); - } catch (e) { - // 如果仍然无法解析,则返回原始渲染结果 - return renderedTemplate; - } + let frontMatter: { [id: string]: unknown } = { + id: article.id, // id is required for deduplication } - } - /** - * 递归处理 YAML 对象中的所有字符串值 - * @param obj YAML 对象 - * @param data 替换数据 - */ - private processYamlObject(obj: any, data: any): any { - if (typeof obj === 'string') { - // 如果是字符串,使用 Mustache 进行模板替换 - return Mustache.render(obj, data); - } else if (Array.isArray(obj)) { - // 如果是数组,递归处理每个元素 - return obj.map(item => this.processYamlObject(item, data)); - } else if (obj !== null && typeof obj === 'object') { - // 如果是对象,递归处理每个属性 - const result: {[key: string]: any} = {}; - for (const key in obj) { - // 处理键名中的模板变量 - const processedKey = Mustache.render(key, data); - result[processedKey] = this.processYamlObject(obj[key], data); + for (const item of templateVariables) { + // split the item into variable and alias + const aliasedVariables = item.split('::') + const variable = aliasedVariables[0] + if ( + variable === 'tags' && + article.tags && + article.tags.length > 0 + ) { + // tags are handled separately + // use label names as tags + frontMatter[variable] = article.tags + continue + } + + const value = (article as any)[variable] + if (value) { + // if variable is in article, use it + frontMatter[variable] = value } - return result; } - // 其他类型(数字、布尔值等)直接返回 - return obj; + + return stringifyYaml(frontMatter) } /** @@ -164,7 +151,7 @@ export class TemplateProcessor { private prepareTemplateData(article: CuboxArticle): any { // 格式化日期 const createDate = article.create_time || ''; - const formattedDate = createDate ? formatISODateTime(createDate, this.dateFormat) : ''; + const formattedDate = createDate ? formatDateTime(createDate, this.dateFormat) : ''; // 格式化高亮内容 let highlightsText = ''; @@ -190,39 +177,4 @@ export class TemplateProcessor { tags: article.tags || [] }; } - - /** - * 将对象转换为 YAML 格式的 frontmatter - * @param obj 要转换的对象 - */ - objectToYamlFrontmatter(obj: any): string { - try { - return stringifyYaml(obj); - } catch (e) { - console.error('将对象转换为 YAML 失败:', e); - return ''; - } - } - - /** - * 解析 YAML 格式的 frontmatter 为对象 - * @param yaml YAML 字符串 - */ - yamlFrontmatterToObject(yaml: string): any { - try { - return parseYaml(yaml); - } catch (e) { - console.error('解析 YAML 失败:', e); - return {}; - } - } - - /** - * 格式化日期 - 使用工具方法 - * @param dateStr 日期字符串 - * @param format 格式模板 - */ - formatDate(dateStr: string, format: string): string { - return formatISODateTime(dateStr, format); - } } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 53c5c19..fb053ee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { DateTime } from 'luxon'; + // 文件名非法字符正则表达式 export const ILLEGAL_CHAR_REGEX_FILE = /[<>:"/\\|?*\u0000-\u001F]/g; export const ILLEGAL_CHAR_REGEX_FOLDER = /[<>:"\\|?*\u0000-\u001F]/g; @@ -32,46 +34,60 @@ export const generateSafeFileArticleName = (title: string): string => { }; /** - * 格式化 ISO 时间字符串为可读格式 - * @param isoString ISO 格式的时间字符串 - * @param format 可选的格式化模式 + * 格式化时间字符串为指定格式 + * @param dateString 输入的时间字符串 + * @param format 输出格式,支持:YYYY(年), MM(月), DD(日), HH(时), mm(分), ss(秒) * @returns 格式化后的时间字符串 */ -export const formatISODateTime = (isoString: string, format: string = 'yyyy-MM-dd HH:mm'): string => { - if (!isoString) return ''; +export const formatDateTime = (dateString: string, format: string = 'YYYY-MM-DD HH:mm:ss'): string => { + if (!dateString) return ''; try { - // 直接使用 Date 对象处理时间 - const date = new Date(isoString); + // 预处理输入的时间字符串,处理非标准格式 + let normalizedString = dateString; - // 检查日期是否有效 - if (isNaN(date.getTime())) { - return isoString; - } + // 处理类似 2024-04-23T14:30:42:780+08:00 格式(秒与毫秒之间用冒号) + normalizedString = normalizedString.replace(/(\d{2}):(\d{2}):(\d{2}):(\d{3})/, '$1:$2:$3.$4'); - // 根据不同格式返回不同的日期字符串 - 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 DateTime.fromISO(normalizedString).toFormat('yyyy-MM-dd HH:mm'); - // 默认返回原始字符串 - return isoString; + // // 检查日期是否有效 + // if (isNaN(date.getTime())) { + // console.warn(`Invalid date string: ${dateString}`); + // return dateString; + // } + + // // 提取日期各部分 + // const year = date.getFullYear(); + // const month = date.getMonth() + 1; + // const day = date.getDate(); + // const hours = date.getHours(); + // const minutes = date.getMinutes(); + // const seconds = date.getSeconds(); + + // // 格式化各部分,补零 + // const YYYY = String(year); + // const YY = String(year).slice(-2); + // const MM = String(month).padStart(2, '0'); + // const DD = String(day).padStart(2, '0'); + // const HH = String(hours).padStart(2, '0'); + // const mm = String(minutes).padStart(2, '0'); + // const ss = String(seconds).padStart(2, '0'); + + // // 使用正则表达式替换格式字符串中的占位符 + // let result = format + // .replace(/YYYY/g, YYYY) + // .replace(/YY/g, YY) + // .replace(/MM/g, MM) + // .replace(/DD/g, DD) + // .replace(/HH/g, HH) + // .replace(/mm/g, mm) + // .replace(/ss/g, ss); + + // return result; } catch (error) { - console.error('Error formatting ISO date:', error); - return isoString; + console.error('Error formatting date:', error); + return dateString; } }; @@ -81,5 +97,5 @@ export const formatISODateTime = (isoString: string, format: string = 'yyyy-MM-d * @returns 格式化后的当前时间字符串 */ export const getCurrentFormattedTime = (format: string = 'yyyy-MM-dd HH:mm'): string => { - return formatISODateTime(new Date().toISOString(), format); + return formatDateTime(new Date().toISOString(), format); }; \ No newline at end of file