diff --git a/.gitignore b/.gitignore index fb2a529..f26e680 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ data.json .DS_Store package-lock.json -otherplugins \ No newline at end of file +otherplugins +update.sh \ No newline at end of file diff --git a/data.json b/data.json index 9f81a52..80773cf 100644 --- a/data.json +++ b/data.json @@ -1,5 +1,6 @@ { "strict_mode": false, "vaultDir": "D:\\iLanix\\Obsidian\nD:\\github\\ObsidianZY", - "git_repo": "https://github.com/zigholding/ObsidianZ/tree/master\nhttps://gitee.com/zigholding/ObsidianZ/tree/master" + "git_repo": "https://github.com/zigholding/ObsidianZ/tree/master\nhttps://gitee.com/zigholding/ObsidianZ/tree/master", + "wxmp_config": "" } \ No newline at end of file diff --git a/main.ts b/main.ts index 199d937..066ce4f 100644 --- a/main.ts +++ b/main.ts @@ -1,6 +1,7 @@ import {Notice, Plugin, TFile, TFolder } from 'obsidian'; import { FsEditor } from 'src/fseditor'; +import { Wxmp } from 'src/wxmp'; import { Strings } from 'src/strings'; import {MySettings,NoteSyncSettingTab,DEFAULT_SETTINGS} from 'src/setting' @@ -8,14 +9,17 @@ import { addCommands } from 'src/commands'; import {dialog_suggest} from 'src/gui/inputSuggester' import {dialog_prompt} from 'src/gui/inputPrompt' +import {EasyAPI} from 'src/easyapi/easyapi' export default class NoteSyncPlugin extends Plugin { strings : Strings; settings: MySettings; fsEditor : FsEditor; yaml: string; - dialog_suggest: Function - dialog_prompt: Function + dialog_suggest: Function; + dialog_prompt: Function; + easyapi: EasyAPI; + wxmp: Wxmp; async onload() { @@ -31,9 +35,12 @@ export default class NoteSyncPlugin extends Plugin { async _onload_() { this.yaml = 'note-sync' this.strings = new Strings(); + this.easyapi = new EasyAPI(this.app); + await this.loadSettings(); this.fsEditor = new FsEditor(this); + this.wxmp = new Wxmp(this); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new NoteSyncSettingTab(this.app, this)); addCommands(this); diff --git a/manifest.json b/manifest.json index cca788a..d260ce0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "note-sync", "name": "Note Sync", - "version": "0.5.3", + "version": "0.6.0", "minAppVersion": "1.8.3", "description": "Sync notes or plugins between vaults.", "author": "ZigHolding", diff --git a/src/commands.ts b/src/commands.ts index f4d5670..c7af206 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,7 @@ import { it } from 'node:test'; const cmd_export_current_note = (plugin:NoteSyncPlugin) => ({ id: 'export_current_note', name: plugin.strings.cmd_export_current_note, + icon:'file-export', callback: async () => { let tfile = plugin.app.workspace.getActiveFile(); await plugin.export_readme(tfile,null); @@ -17,6 +18,7 @@ const cmd_export_current_note = (plugin:NoteSyncPlugin) => ({ const cmd_set_vexporter = (plugin:NoteSyncPlugin) => ({ id: 'set_vexporter', name: plugin.strings.cmd_set_vexporter, + icon:'settings', callback: async () => { let tfile = plugin.app.workspace.getActiveFile(); if(!tfile){return} @@ -43,6 +45,7 @@ const cmd_set_vexporter = (plugin:NoteSyncPlugin) => ({ const cmd_export_plugin = (plugin:NoteSyncPlugin) => ({ id: 'export_plugin', name: plugin.strings.cmd_export_plugin, + icon:'arrow-right-from-line', callback: async () => { let plugins = Object.keys((plugin.app as any).plugins.plugins); @@ -112,6 +115,7 @@ const cmd_export_plugin = (plugin:NoteSyncPlugin) => ({ const cmd_download_git_repo = (plugin:NoteSyncPlugin) => ({ id: 'cmd_download_git_repo', name: plugin.strings.cmd_download_git_repo, + icon:'cloud-download', callback: async () => { let repos = plugin.settings.git_repo.split('\n') let repo = await plugin.dialog_suggest(repos,repos); @@ -210,8 +214,24 @@ const cmd_download_git_repo = (plugin:NoteSyncPlugin) => ({ } }); +const cmd_export_wxmp = (plugin:NoteSyncPlugin) => ({ + id: 'cmd_export_wxmp', + name: plugin.strings.cmd_export_wxmp, + icon:'aperture', + hotkeys: [{ modifiers: ['Alt', 'Shift'], key: 'P' }], + callback: async () => { + if(!plugin.easyapi.cfile){return} + let ctx = await plugin.app.vault.read(plugin.easyapi.cfile); + let html = plugin.wxmp.marked.marked(ctx); + let rhtml = await plugin.wxmp.html_to_wxmp(html); + plugin.wxmp.copy_as_html(rhtml); + new Notice(`${plugin.strings.cmd_export_wxmp}: OK`,5000) + } +}); + const commandBuilders:Array = [ + cmd_export_wxmp, ]; @@ -219,7 +239,8 @@ const commandBuildersDesktop:Array = [ cmd_export_current_note, cmd_set_vexporter, cmd_export_plugin, - cmd_download_git_repo + cmd_download_git_repo, + cmd_export_wxmp ]; export function addCommands(plugin:NoteSyncPlugin) { diff --git a/src/easyapi/css.ts b/src/easyapi/css.ts new file mode 100644 index 0000000..191917f --- /dev/null +++ b/src/easyapi/css.ts @@ -0,0 +1,40 @@ + + + +import { App, View, WorkspaceLeaf,TFile,TFolder } from 'obsidian'; + +import {EasyAPI} from 'src/easyapi/easyapi' + +export class CSS { + app: App; + ea: EasyAPI; + + constructor(app: App, api:EasyAPI) { + this.app = app; + this.ea = api; + } + + async toogle_note_css(document:any,name:string,refresh=false) { + let tfile = this.ea.file.get_tfile(name); + if(!tfile){return} + + let link = document.getElementById(tfile.basename); + if(link && refresh){ + link.remove() + }else{ + let css = await this.ea.editor.extract_code_block(tfile,'css') + let inner = css.join('\n') + if(link){ + link.innerHTML = inner + }else{ + if(inner!=''){ + let styleElement = document.createElement('style') + styleElement.innerHTML=inner; + styleElement.id = tfile.basename; + document.head.appendChild(styleElement); + } + } + } + } +} + diff --git a/src/easyapi/easyapi.ts b/src/easyapi/easyapi.ts new file mode 100644 index 0000000..7bcfb32 --- /dev/null +++ b/src/easyapi/easyapi.ts @@ -0,0 +1,99 @@ + + +import { App, View, WorkspaceLeaf } from 'obsidian'; + +import {dialog_suggest} from './gui/inputSuggester' +import {dialog_prompt} from './gui/inputPrompt' +import {EasyEditor } from './editor'; +import {File } from './file'; +import {Random } from './random'; +import { Waiter } from './waiter'; +import { Templater } from './templater'; +import {Time} from './time' + +export class EasyAPI { + app: App; + dialog_suggest: Function + dialog_prompt: Function + editor: EasyEditor + file: File + random: Random + waiter: Waiter + tpl: Templater + time: Time + + constructor(app: App) { + this.app = app; + this.dialog_suggest = dialog_suggest; + this.dialog_prompt = dialog_prompt; + this.editor = new EasyEditor(app,this); + this.file = new File(app,this); + this.waiter = new Waiter(app,this); + this.random = new Random(app,this); + this.tpl = new Templater(app,this); + this.time = new Time(app,this) + } + + get_plugin(name:string){ + return (this.app as any).plugins?.plugins[name] + } + + get ea(){ + return this.get_plugin('easyapi'); + } + + get nc(){ + return this.get_plugin('note-chain'); + } + + get ns(){ + return this.get_plugin('note-sync'); + } + + get qa(){ + return this.get_plugin('quickadd')?.api; + } + + get dv(){ + return this.get_plugin('dataview')?.api; + } + + get cfile(){ + return this.app.workspace.getActiveFile(); + } + + get cmeta(){ + let cfile = this.cfile; + if(cfile){ + return this.app.metadataCache.getFileCache(cfile) + } + } + + get cfm(){ + let cmeta = this.cmeta; + if(cmeta){ + return cmeta.frontmatter; + } + } + + get ccontent(){ + let cfile = this.cfile; + if(cfile){ + return this.app.vault.read(cfile); + } + } + + get cfolder(){ + return this.cfile?.parent; + } + + get cview(){ + let view = (this.app.workspace as any).getActiveFileView() + return view; + } + + get ceditor(){ + let editor = this.cview?.editor; + return editor; + } +} \ No newline at end of file diff --git a/src/easyapi/editor.ts b/src/easyapi/editor.ts new file mode 100644 index 0000000..c7d7b58 --- /dev/null +++ b/src/easyapi/editor.ts @@ -0,0 +1,379 @@ + + + +import { App, View, WorkspaceLeaf,TFile } from 'obsidian'; + +import {EasyAPI} from 'src/easyapi/easyapi' + +export class EasyEditor { + yamljs = require('js-yaml'); + app: App; + ea: EasyAPI; + + constructor(app: App, api:EasyAPI) { + this.app = app; + this.ea = api; + } + + cn2num(chinese:string) { + let v = parseFloat(chinese); + if(!Number.isNaN(v)){return v} + + chinese = chinese.trim() + const cnNumbers:{[key:string]:number} = { + "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, + "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, + "十": 10, "百": 100, "千": 1000, "万": 10000 + }; + + let sign = 1.0; + let i = 0; + + // 处理负号(JavaScript中汉字是双字节字符) + if (i + 1 <= chinese.length && chinese[i] === "负") { + sign = -1.0; + i += 1; + } + + let integer_total = 0; + let decimal_total = 0.0; + let temp = 0; + let processing_decimal = false; + let decimal_factor = 0.1; + + while (i < chinese.length) { + const c = chinese[i]; + i += 1; + + // 处理小数点 + if (c === "点") { + processing_decimal = true; + integer_total += temp; + temp = 0; + continue; + } + if(!(c in cnNumbers)){ + return parseFloat('-') + } + if (!processing_decimal) { + // 整数部分处理 + if (cnNumbers.hasOwnProperty(c)) { + const num = cnNumbers[c]; + + if (num >= 10) { // 处理单位 + if (temp === 0 && num === 10) { + integer_total += 1 * num; // 特殊处理"十"前无数字的情况 + } else { + integer_total += temp * num; + } + temp = 0; // 重置temp + } else { // 处理数字 + temp = temp * 10 + num; + } + } + } else { + // 小数部分处理 + if (cnNumbers.hasOwnProperty(c) && cnNumbers[c] < 10) { + decimal_total += cnNumbers[c] * decimal_factor; + decimal_factor *= 0.1; + } + } + } + + // 处理最后的临时值 + integer_total += temp; + + return sign * (integer_total + decimal_total); + } + + slice_by_position(ctx:string,pos:any){ + if(pos.position){ + pos = pos.position + } + return ctx.slice(pos.start.offset,pos.end.offset); + } + + parse_list_regx(aline:string,regx:RegExp,field:{[key:string]:number}={}){ + let match = aline.match(regx); + if(!match){return null} + let res:{[key:string]:string} = {src:aline} + for(let k in field){ + res[k] = match[field[k]] + } + return res + } + + parse_list_dataview(aline:string,src='_src_'){ + let res:{[key:string]:string} = {}; + if(src){ + res[src] = aline; + } + let regex = /[($$](.*?)::(.*?)[)$$]/g; + let match; + while ((match = regex.exec(aline)) !== null) { + let key = match[1].trim(); // 提取 key 并去除两端空格 + let value = match[2].trim(); // 提取 value 并去除两端空格 + res[key] = value; + } + return res; + } + + keys_in(keys:Array,obj:object){ + for(let k of keys){ + if(!(k in obj)){ + return false + } + } + return true; + } + + async extract_code_block(tfile:TFile|string,btype:string){ + let xfile = this.ea.file.get_tfile(tfile); + if(xfile){ + tfile = await this.app.vault.cachedRead(xfile); + } + if(typeof(tfile)!='string'){return []} + + let blocks = []; + let reg = new RegExp(`\`\`\`${btype}\\n([\\s\\S]*?)\n\`\`\``,'g');; + let matches; + while ((matches = reg.exec(tfile)) !== null) { + blocks.push(matches[1].trim()); + } + + reg = new RegExp(`~~~${btype}\\n([\\s\\S]*?)\n~~~`,'g');; + while ((matches = reg.exec(tfile)) !== null) { + blocks.push(matches[1].trim()); + } + return blocks; + } + + async get_selection(cancel_selection=false){ + let editor = (this.app.workspace as any).getActiveFileView()?.editor; + if(editor){ + let sel = editor.getSelection(); + if(cancel_selection){ + let cursor = editor.getCursor();  + await editor.setSelection(cursor, cursor); + } + return sel; + }else{ + return ''; + } + } + + async get_code_section(tfile:TFile,ctype='',idx=0,as_simple=true){ + let dvmeta = this.app.metadataCache.getFileCache(tfile); + let ctx = await this.app.vault.cachedRead(tfile); + let section = dvmeta?.sections?.filter(x=>x.type=='code').filter(x=>{ + let c = ctx.slice(x.position.start.offset,x.position.end.offset).trim(); + return c.startsWith('```'+ctype) || c.startsWith('~~~'+ctype); + })[idx] + if(section){ + let c = ctx.slice( + section.position.start.offset, + section.position.end.offset + ); + + if(as_simple){ + return c.slice(4+ctype.length,c.length-4) + }else{ + let res = { + code:c, + section:section, + ctx:ctx + } + return res; + } + } + } + + async get_heading_section(tfile:TFile,heading:string,idx=0, with_heading=true){ + let dvmeta = this.app.metadataCache.getFileCache(tfile); + let ctx = await this.app.vault.cachedRead(tfile); + if(!dvmeta?.headings){ + return ''; + } + let section = dvmeta?.headings?.filter(x=>x.heading==heading)[idx] + if(section){ + let idx = dvmeta.headings.indexOf(section)+1; + while(idx{return x.position.start.line<=cursor.line && x.position.end.line>=cursor.line} + )[0] + let ctx = await this.app.vault.cachedRead(tfile); + if(!section){return ''} + return ctx.slice( + section.position.start.offset, + section.position.end.offset + ) + }else{ + return null; + } + } + + set_obj_value(data:any,key:string,value:any){ + let items = key.trim().split('.') + if(!items){return} + let curr = data + for(let item of items.slice(0,items.length-1)){ + let kv = item.match(/^(.*?)(\[-?\d+\])?$/) // 匹配数组索引, 如 key[0] 或 key + if(!kv){return} + let k = kv[1] // 键名 + if(kv[2]){ // 有索引 + let i = parseInt(kv[2].slice(1,kv[2].length-1)) // 索引 + if(!(k in curr)){ // 键不存在 + curr[k] = [{}] // 创建空数组 + curr = curr[k][0] + }else{ + if(Array.isArray(curr[k])){ + let tmp = {} + if(i<0){ + curr[k].splice(-i-1,0,tmp) + }else if(ix.name==path) + if(ctfiles.length>0){ + if(only_first){ + return ctfiles[0] + }else{ + return ctfiles + } + } + + if(tfiles.length>0){ + if(only_first){ + return tfiles[0] + }else{ + return tfiles + } + } + return null; + }catch{ + return null + } + } + + get_tfiles_of_folder(tfolder:TFolder|null,n=0):any{ + if(!tfolder){return [];} + let notes = []; + for(let c of tfolder.children){ + if(c instanceof TFile && c.extension==='md'){ + notes.push(c); + }else if(c instanceof TFolder && n!=0){ + let tmp = this.get_tfiles_of_folder(c,n-1); + for(let x of tmp){ + notes.push(x); + } + } + } + return notes; + } + + generate_structure(tfolder:TFolder, depth = 0, isRoot = true,only_folder=false,only_md=true) { + let structure = ''; + const indentUnit = ' '; // 关键修改点:每层缩进 4 空格 + const verticalLine = '│ '; // 垂直连接线密度增强 + const indent = verticalLine.repeat(Math.max(depth - 1, 0)) + indentUnit.repeat(depth > 0 ? 1 : 0); + const children = tfolder.children || []; + + // 显示根目录名称 + if (isRoot) { + structure += `${tfolder.name}/\n`; + isRoot = false; + } + + children.forEach((child, index) => { + const isLast = index === children.length - 1; + const prefix = isLast ? '└── ' : '├── '; // 统一符号风格 + + if (child instanceof TFolder) { + // 目录节点:增加垂直连接线密度 + structure += `${indent}${prefix}${child.name}/\n`; + structure += this.generate_structure(child, depth + 1, isRoot,only_folder,only_md); + } else if(!only_folder) { + // 文件节点:对齐符号与目录 + if(only_md && (child as TFile).extension!='md'){return} + structure += `${indent}${prefix}${child.name}\n`; + } + }); + return structure; + } +} + diff --git a/src/easyapi/gui/inputPrompt.ts b/src/easyapi/gui/inputPrompt.ts new file mode 100644 index 0000000..cb9b26a --- /dev/null +++ b/src/easyapi/gui/inputPrompt.ts @@ -0,0 +1,170 @@ +import type { App} from "obsidian"; +import { ButtonComponent, Modal, TextComponent } from "obsidian"; + +/** + * Copy from QuickAdd + */ + +export default class InputPrompt extends Modal { + public waitForClose: Promise; + + private resolvePromise: (input: string) => void; + private rejectPromise: (reason?: unknown) => void; + private didSubmit = false; + private inputComponent: TextComponent; + private input: string; + private readonly placeholder: string; + + public static Prompt( + app: App, + header: string, + placeholder?: string, + value?: string + ): Promise { + const newPromptModal = new InputPrompt( + app, + header, + placeholder, + value + ); + return newPromptModal.waitForClose; + } + + protected constructor( + app: App, + private header: string, + placeholder?: string, + value?: string + ) { + super(app); + this.placeholder = placeholder ?? ""; + this.input = value ?? ""; + + this.waitForClose = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + + this.display(); + this.open(); + + } + + private display() { + this.containerEl.addClass("quickAddModal", "qaInputPrompt"); + this.contentEl.empty(); + this.titleEl.textContent = this.header; + + const mainContentContainer: HTMLDivElement = this.contentEl.createDiv(); + this.inputComponent = this.createInputField( + mainContentContainer, + this.placeholder, + this.input + ); + this.createButtonBar(mainContentContainer); + } + + protected createInputField( + container: HTMLElement, + placeholder?: string, + value?: string + ) { + const textComponent = new TextComponent(container); + (textComponent as any).inputEl.classList.add("input-field"); + (textComponent as any) + .setPlaceholder(placeholder ?? "") + .setValue(value ?? "") + .onChange((value:string) => (this.input = value)) + .inputEl.addEventListener("keydown", this.submitEnterCallback); + + return textComponent; + } + + private createButton( + container: HTMLElement, + text: string, + callback: (evt: MouseEvent) => unknown + ) { + const btn = new ButtonComponent(container); + btn.setButtonText(text).onClick(callback); + + return btn; + } + + private createButtonBar(mainContentContainer: HTMLDivElement) { + const buttonBarContainer: HTMLDivElement = + mainContentContainer.createDiv(); + this.createButton( + buttonBarContainer, + "Ok", + this.submitClickCallback + ).setCta(); + this.createButton( + buttonBarContainer, + "Cancel", + this.cancelClickCallback + ); + + buttonBarContainer.classList.add("button-bar"); + + } + + private submitClickCallback = (evt: MouseEvent) => this.submit(); + private cancelClickCallback = (evt: MouseEvent) => this.cancel(); + + private submitEnterCallback = (evt: KeyboardEvent) => { + if (!evt.isComposing && evt.key === "Enter") { + evt.preventDefault(); + this.submit(); + } + }; + + private submit() { + this.didSubmit = true; + + this.close(); + } + + private cancel() { + this.close(); + } + + private resolveInput() { + if (!this.didSubmit) this.rejectPromise("No input given."); + else this.resolvePromise(this.input); + } + + private removeInputListener() { + this.inputComponent.inputEl.removeEventListener( + "keydown", + this.submitEnterCallback + ); + } + + onOpen() { + super.onOpen(); + + this.inputComponent.inputEl.focus(); + this.inputComponent.inputEl.select(); + } + + onClose() { + super.onClose(); + this.resolveInput(); + this.removeInputListener(); + } +} + + +export async function dialog_prompt(header: string='Input', placeholder: string='',value:string='') { + try{ + return await InputPrompt.Prompt( + this.app, + header, + placeholder, + value + ) + }catch{ + return null + } +} diff --git a/src/easyapi/gui/inputSuggester.ts b/src/easyapi/gui/inputSuggester.ts new file mode 100644 index 0000000..2ca261a --- /dev/null +++ b/src/easyapi/gui/inputSuggester.ts @@ -0,0 +1,139 @@ +import { FuzzySuggestModal } from "obsidian"; +import type { FuzzyMatch , App} from "obsidian"; + +// 添加类型声明 +interface SuggesterChooser { + values: { + item: string; + match: { score: number; matches: unknown[] }; + }[]; + selectedItem: number; + [key: string]: unknown; +} + +// 扩展FuzzySuggestModal的类型 +interface ExtendedFuzzySuggestModal extends FuzzySuggestModal { + chooser: SuggesterChooser; +} + +type Options = { + limit: FuzzySuggestModal["limit"]; + emptyStateText: FuzzySuggestModal["emptyStateText"]; + placeholder: Parameters< + FuzzySuggestModal["setPlaceholder"] + >[0] extends string + ? string + : never; +}; + +/** + * Copy from QuickAdd + */ +export default class InputSuggester extends FuzzySuggestModal { + private resolvePromise: (value: string) => void; + private rejectPromise: (reason?: unknown) => void; + public promise: Promise; + private resolved: boolean; + public new_value: boolean; + inputEl: any; + + public static Suggest( + app: App, + displayItems: string[], + items: string[], + options: Partial = {}, + new_value:boolean=false + ) { + const newSuggester = new InputSuggester( + app, + displayItems, + items, + options, + new_value + ); + return newSuggester.promise; + } + + public constructor( + app: App, + private displayItems: string[], + private items: string[], + options: Partial = {}, + new_value: boolean = false + ) { + super(app); + this.new_value = new_value + + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + + this.inputEl.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.code !== "Tab") { + return; + } + + // 使用类型断言来访问chooser + const self = this as unknown as ExtendedFuzzySuggestModal; + const { values, selectedItem } = self.chooser; + + const { value } = this.inputEl; + this.inputEl.value = values[selectedItem].item ?? value; + }); + + if (options.placeholder) this.setPlaceholder(options.placeholder); + if (options.limit) this.limit = options.limit; + if (options.emptyStateText) + this.emptyStateText = options.emptyStateText; + + this.open(); + } + + getItemText(item: string): string { + if (item === this.inputEl.value) return item; + + return this.displayItems[this.items.indexOf(item)]; + } + + getItems(): string[] { + if (this.inputEl.value === ""||!this.new_value) return this.items; + return [...this.items,this.inputEl.value]; + } + + selectSuggestion( + value: FuzzyMatch, + evt: MouseEvent | KeyboardEvent + ) { + this.resolved = true; + super.selectSuggestion(value, evt); + } + + onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void { + this.resolved = true; + this.resolvePromise(item); + } + + onClose() { + super.onClose(); + if (!this.resolved) this.rejectPromise("no input given."); + } +} + +export async function dialog_suggest(displayItems:Array,items:Array,placeholder='',new_value=false) { + try{ + return await InputSuggester.Suggest( + this.app, + displayItems, + items, + { + placeholder: placeholder, + }, + new_value + ) + }catch(error){ + + return null + } + +} diff --git a/src/easyapi/random.ts b/src/easyapi/random.ts new file mode 100644 index 0000000..eb0120d --- /dev/null +++ b/src/easyapi/random.ts @@ -0,0 +1,143 @@ +import { App, TFile,moment } from "obsidian"; +import { EasyAPI } from "./easyapi"; + + +export class Random { + app:App; + ea:EasyAPI; + constructor(app:App,ea:EasyAPI){ + this.app = app; + this.ea = ea; + } + + /** + * 随机获取 M 个值,位于 0~N 之间 + * @param {number} N - 最大值(不包含) + * @param {number} M - 需要获取的随机数数量 + * @param {boolean} repeat - 是否允许重复值 + * @returns {number[]} - 包含 M 个随机数的数组 + */ + random_number(N:number, M:number, repeat = false) { + if (M <= 0) return []; + if (!repeat && M > N) { + throw new Error("当不允许重复时,M 不能大于 N"); + } + + const result = []; + + if (repeat) { + // 允许重复值的情况 + for (let i = 0; i < M; i++) { + result.push(Math.floor(Math.random() * N)); + } + } else { + // 不允许重复值的情况 + const numbers = Array.from({ length: N }, (_, i) => i); + + // 使用 Fisher-Yates 洗牌算法 + for (let i = numbers.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [numbers[i], numbers[j]] = [numbers[j], numbers[i]]; + } + + // 取前 M 个 + result.push(...numbers.slice(0, M)); + } + + return result; + } + + // 线性同余生成器 (LCG) + lcg(seed:number) { + const a = 1664525; + const c = 1013904223; + const m = Math.pow(2, 32); + return (a * seed + c) % m; + } + + /** + * 基于日期生成固定随机数序列 + * @param {moment} t - 时间对象(使用moment.js) + * @param {number} N - 随机数范围上限(0到N-1) + * @param {number} M - 需要的随机数数量 + * @returns {number[]} - 排序后的随机数数组 + */ + random_number_for_date(t:moment.Moment, N:number, M:number) { + if (M <= 0) return []; + if (M >= N) return Array.from({length: N}, (_, i) => i); + + // 使用年月日作为种子,确保同一天生成相同的序列 + const dateStr = t.format('YYYY-MM-DD'); + let seed = 0; + for (let i = 0; i < dateStr.length; i++) { + seed = (seed << 5) - seed + dateStr.charCodeAt(i); + seed |= 0; // 转换为32位整数 + } + + // 使用Fisher-Yates算法生成随机排列 + const numbers = Array.from({length: N}, (_, i) => i); + let currentSeed = seed; + + for (let i = N - 1; i > 0; i--) { + currentSeed = this.lcg(currentSeed); + const j = Math.abs(currentSeed) % (i + 1); + [numbers[i], numbers[j]] = [numbers[j], numbers[i]]; + } + + // 取前M个并排序 + return numbers.slice(0, M).sort((a, b) => a - b); + } + + // 根据字符串返回 0~N 之间的整数 + string_to_random_number(str:string, N:number) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // 转换为32位整数 + } + return Math.abs(hash) % N; + } + + // 从数组中随机获取 N 个元素 + random_elements(arr:any[], n:number ) { + // 复制数组避免修改原数组 + const shuffled = [...arr]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; // 交换 + } + return shuffled.slice(0, n); // 取前N个 + } + + _get_tfiles_(filter:null|Function){ + let tfiles = this.ea.nc.chain.get_all_tfiles(); + if(filter){ + tfiles = tfiles.filter((x:TFile)=>filter(x)) + } + return tfiles; + } + random_notes(n=3,filter=null){ + let tfiles = this._get_tfiles_(filter); + let idx = this.random_number(tfiles.length,n) + tfiles = idx.map(i=>tfiles[i]) + return tfiles + } + + random_daily_notes(n=3,before_today=true,filter=null){ + let t = moment(moment().format('YYYY-MM-DD') ) + let dnote = this.ea.nc.chain.get_last_daily_note() + if(dnote){ + t = moment(dnote.basename) + } + let tfiles = this._get_tfiles_(filter); + + if(before_today){ + tfiles = tfiles.filter( + (f:TFile)=>f.stat.ctimetfiles[i]) + return tfiles + } +} \ No newline at end of file diff --git a/src/easyapi/templater.ts b/src/easyapi/templater.ts new file mode 100644 index 0000000..19dcc63 --- /dev/null +++ b/src/easyapi/templater.ts @@ -0,0 +1,155 @@ +import { App, TFile,moment } from "obsidian"; +import { EasyAPI } from "./easyapi"; + + +export class Templater { + app:App; + ea:EasyAPI; + constructor(app:App,ea:EasyAPI){ + this.app = app; + this.ea = ea; + } + + get tpl(){ + return this.ea.get_plugin('templater-obsidian'); + } + + get_tp_func(target:string) { + + let items = target.split("."); + if(items[0].localeCompare("tp")!=0 || items.length!=3){return undefined;} + + let modules = this.tpl.templater.functions_generator. + internal_functions.modules_array.filter( + (item:any)=>(item.name.localeCompare(items[1])==0) + ); + if(modules.length==0){return undefined} + return modules[0].static_functions.get(items[2]); + } + + async get_tp_user_func(target:string) { + if(!target.match(/^tp\.user\.\w+$/)){ + return null + } + + let items = target.split("."); + if(items[0].localeCompare("tp")!=0 || items[1].localeCompare("user")!=0 || items.length!=3){return undefined;} + + let funcs = await this.tpl.templater. + functions_generator. + user_functions. + user_script_functions. + generate_user_script_functions(); + return funcs.get(items[2]) + } + + async templater$1(template:string|TFile|null, active_file:TFile|null, target_file:any,extra=null) { + let config = { + template_file: template, + active_file: active_file, + target_file: target_file, + extra: extra, + run_mode: "DynamicProcessor", + }; + + let {templater} = this.tpl; + let functions = await templater.functions_generator.internal_functions.generate_object(config); + functions.user = {}; + let userScriptFunctions = await templater.functions_generator.user_functions.user_script_functions.generate_user_script_functions(config); + userScriptFunctions.forEach((value:any,key:any)=>{ + functions.user[key] = value; + } + ); + if (template) { + let userSystemFunctions = await templater.functions_generator.user_functions.user_system_functions.generate_system_functions(config); + userSystemFunctions.forEach((value:any,key:any)=>{ + functions.user[key] = value; + } + ); + } + return async(command:any)=>{ + return await templater.parser.parse_commands(command, functions); + }; + } + + async extract_templater_block(tfile:TFile|string,reg=/<%\*\s*([\s\S]*?)\s*-?%>/g){ + let xfile = this.ea.file.get_tfile(tfile); + if(xfile){ + tfile = await this.app.vault.cachedRead(xfile); + } + if(typeof(tfile)!='string'){return []} + + let blocks = []; + let matches; + while ((matches = reg.exec(tfile)) !== null) { + blocks.push(matches[0].trim()); + } + + let tpls = await this.ea.editor.extract_code_block(tfile,'js //templater'); + for(let tpl of tpls){ + blocks.push(`<%*\n${tpl}\n-%>`) + } + return blocks; + } + + // target_file:target>activate>template + async parse_templater(template:string|TFile,extract=true,extra:any=null,idx:number[]|null=null,target='') { + let file = this.ea.file.get_tfile(template) + if(file){ + template = file + } + let blocks:Array; + let template_file = null; + if(template instanceof TFile){ + template_file = template + if(extract){ + blocks = await this.extract_templater_block(template); + }else{ + let item = await this.app.vault.cachedRead(template) + blocks = [item] + } + }else{ + if(extract){ + blocks = await this.extract_templater_block(template); + }else{ + blocks = [template] + } + } + + let active_file = this.ea.cfile; + let target_file:any = this.ea.file.get_tfile(target); + if(!target){ + if(active_file){ + target_file = active_file; + }else if (file){ + target_file = file; + }else{ + throw new Error("Target File must be TFile"); + } + } + + let templateFunc = await this.templater$1(template_file,active_file,target_file,extra=extra); + if(templateFunc){ + let res = [] + if(idx){ + for(let i of idx){ + let block = blocks[i]; + if(block){ + let item = await templateFunc(block); + res.push(item); + }else{ + res.push(''); + } + } + }else{ + for(let block of blocks){ + let item = await templateFunc(block); + res.push(item) + } + } + return res; + }else{ + return [] + } + } +} \ No newline at end of file diff --git a/src/easyapi/time.ts b/src/easyapi/time.ts new file mode 100644 index 0000000..86ad965 --- /dev/null +++ b/src/easyapi/time.ts @@ -0,0 +1,353 @@ +import { moment,App } from 'obsidian'; +import { Moment } from 'moment'; +import { EasyAPI } from "./easyapi"; + +export class Time{ + app:App; + ea:EasyAPI; + constructor(app:App,ea:EasyAPI){ + this.app = app; + this.ea = ea; + } + + get today(){ + let t = moment().format('YYYY-MM-DD'); + return moment(t) + } + + as_date(t:Moment){ + let xt = t.format('YYYY-MM-DD'); + return moment(xt) + } + + /** + * 获取相对于基准日期的偏移月份的指定日期 + * @param {number} dayIndex - 日期索引(正数表示第几天,负数表示倒数第几天) + * @param {number} monthOffset - 月份偏移量(正数为未来月份,负数为过去月份) + * @param {Date|string|moment.Moment} baseDate - 基准日期,默认为当日 + * @returns {moment.Moment} 计算后的目标日期 + */ + relative_month_day(dayIndex:number, monthOffset:number=0, baseDate=this.today) { + // 创建基准日期的moment对象 + let baseMoment = moment(baseDate).clone(); + + // 计算目标月份 + let targetMoment = baseMoment.clone().add(monthOffset, 'months'); + + // 处理日期索引 + if (dayIndex > 0) { + // 正数索引:设置为目标月份的第N天 + targetMoment.startOf('month').add(dayIndex - 1, 'days'); + } else { + // 负数索引:设置为目标月份的倒数第N天 + targetMoment.endOf('month').add(dayIndex + 1, 'days'); + } + return this.as_date(targetMoment); + } + + /** + * 获取相对于基准日期的偏移周数的指定星期几 + * @param {number} dayIndex - 星期索引(0-6,0为周日,1为周一,依此类推;或使用负数表示倒数) + * @param {number} weekOffset - 周数偏移量(正数为未来周数,负数为过去周数) + * @param {Date|string|moment.Moment} baseDate - 基准日期,默认为当日 + * @returns {moment.Moment} 计算后的目标日期 + * + * @example + * relative_week_day(1, 0) // 本周一 + * relative_week_day(0, -1) // 上周日 + * relative_week_day(6, 2) // 两周后的周六 + * relative_week_day(-1, 1) // 下周的倒数第1天(周六) + */ + relative_week_day(dayIndex:number, weekOffset:number = 0, baseDate = this.today) { + // 创建基准日期的moment对象并克隆(避免污染原对象) + let baseMoment = moment(baseDate).clone(); + + // 处理周偏移:先移动到目标周的开始(周一) + let targetMoment = baseMoment.add(weekOffset, 'weeks'); + + // 处理星期索引 + if (dayIndex >= 0) { + // 正数索引:直接设置为目标星期(0=周日到6=周六) + targetMoment.day(dayIndex); + } else { + // 负数索引:计算目标周的倒数第N天 + // 1. 先移动到目标周的周末(周日) + // 2. 再向前移动 |dayIndex| - 1 天(例如dayIndex=-1为周六) + targetMoment.endOf('week').add(dayIndex + 1, 'days'); + } + + // 返回标准化后的日期(去除时间部分) + return this.as_date(targetMoment); + } + + /** + * 解析中文自然语言日期(新增支持"下个月5号"/"上个月15号"等格式) + * @param {string} msg - 包含日期的文本(如"下个月5号开会") + * @param {moment.Moment} base - 基准日期,默认为当天 + * @returns {{date: string, text: string}} 处理后的日期和文本 + * + * @example + * parse_date("下个月5号评审") // {date: "2025-07-05", text: "评审"} + * parse_date("上个月15号账单") // {date: "2025-05-15", text: "账单"} + */ + extract_chinese_date(msg:string, base = this.today) { + let result:{[key:string]:any} = { date: base.format('YYYY-MM-DD'), text: msg }; + + // 1. 处理相对天数(今天/昨天/明天等) + let dayKeywords = [ + { pattern: /^大前天/, days: -3 }, + { pattern: /^前天/, days: -2 }, + { pattern: /^昨天/, days: -1 }, + { pattern: /^今天/, days: 0 }, + { pattern: /^明天/, days: 1 }, + { pattern: /^后天/, days: 2 }, + { pattern: /^大后天/, days: 3 } + ]; + for (let { pattern, days } of dayKeywords) { + if (pattern.test(msg)) { + result.date = base.clone().add(days, 'days').format('YYYY-MM-DD'); + result.text = msg.replace(pattern, '').trim(); + return result; + } + } + + // 3. 处理「下个月X号」或「上个月X号」格式 + let monthDayMatch = msg.match(/^(上个月|下个月)(\d{1,2})号?/); + if (monthDayMatch) { + let [fullMatch, direction, day] = monthDayMatch; + let monthOffset = direction === '上个月' ? -1 : 1; + let targetDate = base.clone().add(monthOffset, 'months').date(parseInt(day)); + + if (targetDate.date() !== parseInt(day)) { + targetDate.endOf('month'); + } + + result.date = targetDate.format('YYYY-MM-DD'); + result.text = msg.slice(fullMatch.length).trim(); + return result; + } + + // 4. 处理周几和下周几 + let weekMatch = msg.match(/^([上下]([一二三四五六七八九十两]|\d+)周周|上上周|上上星期|上周|上星期|周|星期|下周|下星期|下下周|下下星期)([一二三四五六七日]|[1-7])/); + if (weekMatch) { + let [fullMatch,weekStr, weekCount,dayChar] = weekMatch; + let dayMap:{[key:string]:any} = { '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '日':7, '七':7}; + if(weekCount){ + let nmap:{[key:string]:any} = { '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '日':7, '七':7,'八':8,'九':9,'两':2}; + weekCount = nmap[weekCount] || parseInt(weekCount); + } + let targetDay = dayMap[dayChar] || parseInt(dayChar); + let weekOffset = ['周','星期'].contains(weekStr)? 0 : + ['下周','下星期'].contains(weekStr)? 1 : + ['下下周','下下星期'].contains(weekStr)? 2 : + ['上周','上星期'].contains(weekStr)? -1 : + ['上上周','上上星期'].contains(weekStr)? -2 : + (msg.slice(0,1)=='上' ? -weekCount : weekCount); + let date = this.relative_week_day(targetDay,weekOffset as number,base); + + if (date.isBefore(base, 'day')) { + date.add(1, 'week'); + } + + result.date = date.format('YYYY-MM-DD'); + result.text = msg.slice(fullMatch.length).trim(); + return result; + } + + // 5. 处理x月y号格式 + let absoluteMonthMatch = msg.match(/^(\d{1,2})月(\d{1,2})(?:号|日)?/); + if (absoluteMonthMatch) { + const [fullMatch, month, day] = absoluteMonthMatch; + let date = base.clone().month(parseInt(month) - 1).date(parseInt(day)); + + if (date.isBefore(base, 'day')) { + date.add(1, 'year'); + } + + result.date = date.format('YYYY-MM-DD'); + result.text = msg.slice(fullMatch.length).trim(); + return result; + } + if(result.text==msg){ + result.date = null; + } + return result; + } + + + + parse_minutes(xt:string) { + if(typeof xt == 'number'){return xt;} + if(xt.match(/^\d*$/) && parseInt(xt)){return parseInt(xt);} + + let items = xt.match(/^(.{1,2})个半小时$/); + if(items){return this.ea.editor.cn2num(items[1])*60+30} + + let compoundMatch = xt.match(/^(.*?)(h|hour|hours|时|小时|个小时)(.*?)(m|min|minute|minutes|分|分钟)?$/i); + if (compoundMatch) { + let hours = this.ea.editor.cn2num(compoundMatch[1]) || 0; + let minutes = this.ea.editor.cn2num(compoundMatch[3]) || 0; + return Math.round(hours * 60 + minutes); + } + // 处理简单格式,仅分钟 + let simpleMatch = xt.match(/^(.*?)(m|min|minute|minutes|分|分钟)$/i); + if (simpleMatch) { + let value = this.ea.editor.cn2num(simpleMatch[1]); + return Math.round(value); + } + return Number.NaN; + } + + + parse_time(st:string|Moment, date:Moment|string = this.today,nearest=true) { + if(!st){return null} + if(moment.isMoment(st)){return st} + + if(moment.isMoment(date)){ + date = date.format('YYYY-MM-DD'); + } + + let items = st.match(/^(\d{2}):?(\d{2})$/); + + if(items){ + let t = moment(`${date} ${items[1]}:${items[2]}:00`, "YYYY-MM-DD HH:mm:ss"); + if(t.isValid()){return t} + } + + let cnTimeRegex = /^(早上|上午|凌晨|下午|晚上)?([零一二三四五六七八九十百]+|[\d]+)点(半|([零一二三四五六七八九十]+)分?|([\d]+)分?)?$/; + let match = st.match(cnTimeRegex); + + if (match) { + let [_, period, hourStr, minuteCnStr] = match; + let hour = this.ea.editor.cn2num(hourStr); + + let minute = 0; + if (minuteCnStr === '半') { + minute = 30; + } else if (minuteCnStr) { + minute = this.ea.editor.cn2num(minuteCnStr); + } + + if (['下午'].includes(period)) { + hour = hour >= 12 ? hour : hour + 12; + } else if (['晚上'].includes(period)){ + hour = hour >=5 && hour<12? hour+12:hour; + }else if (!period && nearest && hour<=12) { + let t = moment(); + let a = t.hour()*60+t.minutes(); + let b = hour*60+minute; + if(a>b && (a-b)>(b-a+12*60)){ + hour=hour+12; + } + } + + hour %= 24; + return moment(`${date} ${hour}:${minute}`, "YYYY-MM-DD HH:mm"); + } + return null + } + + time_plus_minutes(st:string,xt:string){ + let t = this.parse_time(st); + let n = this.parse_minutes(xt); + if(!t || typeof t == 'string' || Number.isNaN(n)){return null} + return t.clone().add(xt, 'minutes'); + } + + generate_start_times(jobs:Array,delta=10, is_today = true,st:string|Moment='06:45',compress=true) { + // 从 st 到当前时间 + let _st = this.parse_time(st) + if(!_st){return []} + st = _st; + let timeList = []; + let t = this.parse_time(moment().format('HH:mm')); + if (!is_today || true) { + t = this.parse_time(moment().format('23:59')); + } + if(!t){return []} + for (let hour = st.hour(); hour <= t.hour(); hour++) { + let startMinute = (hour === st.hour()) ? st.minute() : 0; + let endMinute = (hour === t.hour()) ? t.minute()+1 : 60; + for (let minute = startMinute; minute < endMinute; minute += delta) { + let time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + let ct = this.parse_time(time); + if(!ct){continue} + let flag = true; + for (let item of jobs) { + if (item.st <= ct && item.et > ct) { + flag = false; + break; + } + } + if (flag) { + timeList.push(time); + } + } + } + let et = this.get_max_endt(jobs)?.format('HH:mm'); + if (et && !timeList.contains(et)) { + timeList.push(et) + } + timeList = timeList.sort((a, b) => -a.localeCompare(b)); + if(compress){ + return this.compress_timelist(timeList,delta) + }else{ + return timeList + } + + } + + get_max_endt(jobs:Array,st='06:45') { + if (jobs.length == 0) { + return this.parse_time(st) + } else { + return moment.unix( + Math.max(...jobs.map(x => x.et)) / 1000 + ) + } + } + + compress_timelist(timeList:Array,delta=5){ + let compressedList = []; + let startRange = null; + let prevTime = null; + + for (let i = 0; i < timeList.length; i++) { + let currentTime = timeList[i]; + let currentParsed = this.parse_time(currentTime); + if(!currentParsed){continue} + + if (prevTime === null) { + startRange = currentTime; + } else { + let prevParsed = this.parse_time(prevTime); + if(!prevParsed){continue} + // 检查是否连续(相差5分钟) + let diffMinutes = (prevParsed.hour() * 60 + prevParsed.minute()) - + (currentParsed.hour() * 60 + currentParsed.minute()); + + if (diffMinutes !== delta) { + if (startRange !== prevTime) { + compressedList.push(startRange) + compressedList.push(prevTime); + } else { + compressedList.push(startRange); + } + startRange = currentTime; + } + } + + prevTime = currentTime; + } + + // 处理最后一个范围 + if (startRange !== prevTime) { + compressedList.push(startRange) + compressedList.push(prevTime); + } else if (prevTime !== null) { + compressedList.push(prevTime); + } + + return compressedList; + } +} \ No newline at end of file diff --git a/src/easyapi/waiter.ts b/src/easyapi/waiter.ts new file mode 100644 index 0000000..4c69d63 --- /dev/null +++ b/src/easyapi/waiter.ts @@ -0,0 +1,38 @@ +import { App, TFile,moment } from "obsidian"; +import { EasyAPI } from "./easyapi"; + + +export class Waiter { + app:App; + ea:EasyAPI; + constructor(app:App,ea:EasyAPI){ + this.app = app; + this.ea = ea; + } + + async wait(condition:Function,timeout:number=0){ + let start = moment(); + while (!condition()) { + let end = moment(); + if ((start.valueOf()-end.valueOf())/1000 > timeout) { + return false; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return true; + } + + async wait_for(vfunc:Function,timeout:number=30){ + let start = moment(); + let res = await vfunc(); + while (!res) { + let end = moment(); + if ((start.valueOf()-end.valueOf())/1000 > timeout) { + return null; + } + await new Promise(resolve => setTimeout(resolve, 100)); + res = await vfunc(); + } + return res; + } +} \ No newline at end of file diff --git a/src/fseditor.ts b/src/fseditor.ts index 34f4f4d..5dbf0dd 100644 --- a/src/fseditor.ts +++ b/src/fseditor.ts @@ -330,4 +330,25 @@ export class FsEditor{ } ); } -} \ No newline at end of file + + list_dir_recursive(path:string,with_folder=false):Array{ + if(!this.isdir(path)){return []} + let res = [] + let items = this.plugin.fsEditor.list_dir(path,true) + for(let item of items){ + if(this.isfile(item)){ + res.push(item) + }else if(this.isdir(item)){ + if(with_folder){ + res.push() + } + let sitems = this.list_dir_recursive(item,with_folder); + for(let i of sitems){ + res.push(i) + } + } + } + return res; + } + +} diff --git a/src/setting.ts b/src/setting.ts index 0bef9c1..dde7887 100644 --- a/src/setting.ts +++ b/src/setting.ts @@ -8,12 +8,20 @@ export interface MySettings { strict_mode: boolean; vaultDir:string; git_repo:string; + wxmp_config:string; } export const DEFAULT_SETTINGS: MySettings = { strict_mode:false, vaultDir: '', - git_repo: 'https://github.com/zigholding/ObsidianZ/tree/master\nhttps://gitee.com/zigholding/ObsidianZ/tree/master' + git_repo: 'https://github.com/zigholding/ObsidianZ/tree/master\nhttps://gitee.com/zigholding/ObsidianZ/tree/master', + wxmp_config: ` +h1: ob 公众号标题 h1 样式 +h2: ob 公众号标题 h2 样式 +h3: ob 公众号标题 hx 样式 +p code: ob 公众号行内代码样式 +li code: ob 公众号行内代码样式 +`.trim() } export class NoteSyncSettingTab extends PluginSettingTab { @@ -71,5 +79,14 @@ export class NoteSyncSettingTab extends PluginSettingTab { this.plugin.settings.git_repo = value; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName(this.plugin.strings.setting_wxmp_config) + .addTextArea(text => text + .setValue(this.plugin.settings.wxmp_config) + .onChange(async (value) => { + this.plugin.settings.wxmp_config = value; + await this.plugin.saveSettings(); + })); } } diff --git a/src/strings.ts b/src/strings.ts index 42115e2..ce3aeb6 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -42,6 +42,14 @@ export class Strings{ } } + get cmd_export_wxmp(){ + if(this.language=='zh'){ + return '导出微信公众号'; + }else{ + return 'Export wxmp'; + } + } + get prompt_path_of_folder(){ if(this.language=='zh'){ return '输入文件夹路径' @@ -97,6 +105,15 @@ export class Strings{ } } + get setting_wxmp_config(){ + if(this.language=='zh'){ + return '微信公众号样式配置'; + }else{ + return 'Style config for wxmp'; + } + } + + get item_copy_data_json(){ if(this.language=='zh'){ return '复制 data.json'; diff --git a/src/wxmp.ts b/src/wxmp.ts new file mode 100644 index 0000000..d51b142 --- /dev/null +++ b/src/wxmp.ts @@ -0,0 +1,430 @@ + +import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder } from 'obsidian'; +import NoteSyncPlugin from "../main"; +import { on } from 'node:events'; + +export class Wxmp { + marked: any; + hljs: any; + app: App; + plugin: NoteSyncPlugin; + + constructor(plugin: NoteSyncPlugin) { + this.plugin = plugin; + this.app = plugin.app; + this.marked = require('marked'); + this.hljs = require('highlight.js'); + } + + get ctx_map(){ + let config:{[key:string]:any} = {}; + for(let line of this.plugin.settings.wxmp_config.split('\n')){ + let [key, value] = line.split(':'); + if(!key || !value){continue} + config[key] = value; + } + if(!config['h1']){ + config['h1'] = this.format_wxmp_h1; + } + if(!config['h2']){ + config['h2'] = this.format_wxmp_h2; + } + if(!config['h3']){ + config['h3'] = this.format_wxmp_h3; + } + if(!config['p code']){ + config['p code'] = this.format_wxmp_p_code; + } + if(!config['li code']){ + config['li code'] = this.format_wxmp_li_code; + } + return config; + } + + arrayBufferToBase64(buffer: ArrayBuffer) { + let binary = ''; + let bytes = new Uint8Array(buffer); + let len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + async read_html_from_clipboard(mime = 'text/html') { + let ctxs = await navigator.clipboard.read() + + for (let ctx of ctxs) { + let blob = await ctx.getType(mime) + let html = await blob.text() + return html + } + } + + async replace_regx_with_tpl(rhtml:string, regx:RegExp, tpl:string) { + let matches = [...rhtml.matchAll(regx)]; + + let replacements = await Promise.all( + matches.map(async ([match, title]) => { + let msg = await this.plugin.easyapi.tpl.parse_templater(tpl, true, title); + return { match, replacement: msg[0] }; + }) + ); + + // 逐个替换 + for (let { match, replacement } of replacements) { + rhtml = rhtml.replace(match, replacement); + } + + return rhtml; + } + + convertVaultImageLinksToImgTag(htmlString:string) { + return htmlString.replace(/!\[\[([^\]]+?)\]\]/g, (match, filename) => { + return ``; + }); + } + + async convertImageTagsToBase64(htmlString:string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + + const imgElements = doc.querySelectorAll('img'); + + for (let img of Array.from(imgElements)) { + let src = img.getAttribute('src'); + if (!src) continue; + + // 只处理 vault 中的本地图片 + let fname = decodeURIComponent(src.replace(/^.*[\\\/]/, '')); + + try { + let base64 = await this.image_to_img(fname, true); // 返回 base64 + if (base64) { + img.setAttribute('src', base64); + } + } catch (e) { + console.warn(`图片 ${src} 转换失败:`, e); + } + } + + return new XMLSerializer().serializeToString(doc.body); + } + + + async html_to_wxmp(html:string) { + let rhtml; + + // 替换图片 + rhtml = this.convertVaultImageLinksToImgTag(html); + rhtml = await this.convertImageTagsToBase64(rhtml); + // 替换链接 + rhtml = this.html_replace_url(rhtml); + + // 替换代码 + rhtml = this.html_replace_code(rhtml) + + // 替换标题 + for (let k in this.ctx_map) { + rhtml = await this.set_tag_with_tpl(rhtml, k, this.ctx_map[k]); + } + + // [[格式化图片链接]] + rhtml = this.formatWeChatImageLink(rhtml) + + // 列表最后一个元素段后距设置为 24px + rhtml = this.setLastLiMargin(rhtml) + // 列表之前一个元素段后距设置为 8px + rhtml = this.setParagraphSpacingBeforeList(rhtml) + return rhtml + } + + async set_tag_with_tpl(htmlString:string, selector:string, tpl:string|Function) { + let parser = new DOMParser(); + let doc = parser.parseFromString(htmlString, 'text/html'); + let items = doc.querySelectorAll(selector); + + await Promise.all(Array.from(items).map(async (item) => { + let content = item.textContent; + + if(typeof tpl == 'function'){ + let rendered = tpl(content); + if(rendered){ + item.innerHTML = rendered; + } + }else{ + // 模板渲染,传入content + let rendered = await this.plugin.easyapi.tpl.parse_templater(tpl, true, content); + if (rendered.length > 0) { + // 只替换内容,不替换标签和属性 + item.innerHTML = rendered[0]; + } + } + })); + + // 序列化回字符串 + let serializer = new XMLSerializer(); + let modifiedHtmlString = serializer.serializeToString(doc.body); + return modifiedHtmlString; + } + + + setLastLiMargin(htmlString:string) { + let parser = new DOMParser(); + let doc = parser.parseFromString(htmlString, 'text/html'); + + let lists = doc.querySelectorAll('ol, ul'); + + lists.forEach(list => { + let lis = list.querySelectorAll('li'); + if (lis.length > 0) { + let lastLi = lis[lis.length - 1]; + + // 保留原始内容,包裹一个section加margin + let originalHTML = lastLi.innerHTML; + lastLi.innerHTML = ` +
+ ${originalHTML} +
+ `; + } + }); + + let serializer = new XMLSerializer(); + return serializer.serializeToString(doc.body); + } + + + setParagraphSpacingBeforeList(htmlString:string) { + // 创建一个新的DOM解析器 + let parser = new DOMParser(); + // 将HTML字符串解析为文档对象 + let doc = parser.parseFromString(htmlString, 'text/html'); + + // 获取文档中的所有有序列表和无序列表 + let lists = doc.querySelectorAll('ol, ul'); + + lists.forEach(list => { + // 找到列表前面的第一个段落 + let precedingParagraph = list.previousElementSibling; + if (precedingParagraph && precedingParagraph.tagName.toLowerCase() === 'p') { + // 设置段后距为8px + (precedingParagraph as any).style.marginBottom = '8px'; + } + }); + + // 将修改后的文档对象转换回HTML字符串 + let serializer = new XMLSerializer(); + let modifiedHtmlString = serializer.serializeToString(doc.body); + + return modifiedHtmlString; + } + + html_replace_url(html:string) { + let regx = /]*class="external-link"[^>]*href="(.*?)"[^>]*?>([\s\S]*?)<\/a>/g + let rhtml = html.replace(regx, (m, href, text) => { + + let flag = false + for (let url of [ + 'https://mmbiz.qpic.cn', + 'https://mp.weixin.qq.com' + ]) { + if (href.trim().startsWith(url)) { + flag = true + break + } + } + if (!flag) { + return `${text}` + } + return `${text}` + }) + return rhtml + } + + + html_replace_code(html:string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const codeBlocks = Array.from(doc.querySelectorAll('pre > code[class^="language-"]')); + + codeBlocks.forEach(preCode => { + const langMatch = preCode.className.match(/language-(\w+)/); + const lang = langMatch ? langMatch[1] : 'plaintext'; + const rawCode = preCode.textContent; + + let result; + try { + result = this.hljs.highlight(rawCode, { language: lang }); + } catch (e) { + console.warn(`高亮失败: ${lang}`, e); + return; + } + + // 替换 class 为公众号 class + const classPairs = [ + ['hljs-keyword', 'code-snippet__keyword'], + ['hljs-attr', 'code-snippet__attr'], + ['hljs-string', 'code-snippet__string'], + ['hljs-number', 'code-snippet__number'], + ['hljs-comment', 'code-snippet__comment'], + ['hljs-title', 'code-snippet__title'], + ['hljs-variable', 'code-snippet__variable'], + ['hljs-operator', 'code-snippet__operator'], + ['hljs-punctuation', 'code-snippet__punctuation'], + ]; + let highlightedHtml = result.value; + for (let [from, to] of classPairs) { + highlightedHtml = highlightedHtml.replaceAll(from, to); + } + + const lines = highlightedHtml.split('\n'); + + // 构造 section 容器 + const section = doc.createElement('section'); + section.className = `code-snippet__fix code-snippet__${lang}`; + + // 构造行号 + const ul = doc.createElement('ul'); + ul.className = `code-snippet__line-index code-snippet__${lang}`; + lines.forEach(() => ul.appendChild(doc.createElement('li'))); + section.appendChild(ul); + + // 构造代码主体 + const pre = doc.createElement('pre'); + pre.className = `code-snippet__${lang}`; + pre.setAttribute('data-lang', lang); + + lines.forEach((line: string) => { + const codeLine = doc.createElement('code'); + const span = doc.createElement('span'); + span.setAttribute('leaf', ''); + span.innerHTML = line.trim() === '' ? '
' : line; + codeLine.appendChild(span); + pre.appendChild(codeLine); + }); + + section.appendChild(pre); + + // 隐藏的 mp-style-type + const mpStyleP = doc.createElement('p'); + mpStyleP.setAttribute('style', 'display: none;'); + mpStyleP.innerHTML = ``; + + // 替换原来的
+            const preElement = preCode.parentElement;
+            if (preElement) {
+                preElement.replaceWith(section, mpStyleP);
+            }
+        });
+
+        return new XMLSerializer().serializeToString(doc.body);
+    }
+
+
+    async image_to_img(fname: string, as_base64 = false) {
+        if (fname.startsWith('!')) {
+            fname = fname.slice(1)
+        }
+        let img_ext = ['png', 'jpg', 'jpeg']
+        let tfile = this.plugin.easyapi.file.get_tfile(fname)
+        if (!tfile) { return }
+        let ext = tfile.extension.toLowerCase()
+        if (!img_ext.contains(ext)) { return }
+        let data = await app.vault.readBinary(tfile)
+        let text = this.arrayBufferToBase64(data);
+
+        let bs64 = `data:image/png;base64,${text}`
+        if (as_base64) {
+            return bs64;
+        }
+        let html = ``
+        return html
+    }
+
+    async copy_as_html(ctx: string | string[]) {
+        if (typeof (ctx) == 'string') {
+            ctx = [ctx]
+        }
+        let data = new ClipboardItem({
+            "text/html": new Blob(ctx, {
+                type: "text/html"
+            }),
+            "text/plain": new Blob(ctx, {
+                type: "text/plain"
+            }),
+        });
+        await navigator.clipboard.write([data]);
+    }
+
+    formatWeChatImageLink(inputHtml:string) {
+        // 创建一个临时的 div 元素来解析 HTML
+        let tempDiv = document.createElement('div');
+        tempDiv.innerHTML = inputHtml;
+
+        // 获取所有的  标签
+        let aTags = tempDiv.querySelectorAll('a');
+
+        // 遍历所有的  标签
+        aTags.forEach(aTag => {
+            let imgTag = aTag.querySelector('img');
+
+            if (imgTag) {
+                // 提取 href 和 src
+                let href = aTag.getAttribute('href');
+                let src = imgTag.getAttribute('src');
+
+                // 获取图片格式(如 png, jpg, gif 等)
+                let imgFormat = src?.split('.').pop();
+
+                // 构建新的 HTML
+                let newHtml = `
+					
+						
+							
+						
+					
+				`;
+
+                // 替换原始的  标签
+                aTag.outerHTML = newHtml;
+            }
+        });
+
+        // 返回格式化后的 HTML
+        return tempDiv.innerHTML;
+    }
+
+    format_wxmp_h1(title:string){
+        let css = `
+        

${title}

+ `.trim() + return css; + } + + format_wxmp_h2(title:string){ + let css = ` +

${title}

+ `.trim() + return css; + } + + format_wxmp_h3(title:string){ + let css = ` +

${title}

+ `.trim() + return css; + } + + format_wxmp_p_code(code:string){ + let css = ` + ${code}`.trim() + return css; + } + + format_wxmp_li_code(code:string){ + let css = ` + ${code}`.trim() + return css; + } +}