From c9ce811ce91998d586cd8f7bb75f4f9e0ad99c96 Mon Sep 17 00:00:00 2001 From: douboer Date: Thu, 25 Sep 2025 22:35:01 +0800 Subject: [PATCH] update at 2025-09-25 22:35:01 --- CHANGELOG.md | 35 +++ README.md | 70 ++++- src/article-render.ts | 17 +- src/batch-filter.ts | 247 +++++++++++++++ src/batch-publish-modal.ts | 600 +++++++++++++++++++++++++++++++++++++ src/exif-orientation.ts | 117 ++++++++ src/main.ts | 9 + src/markdown/local-file.ts | 35 +++ src/setting-tab.ts | 2 + src/settings.ts | 26 +- todo.list | 55 +++- 11 files changed, 1192 insertions(+), 21 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/batch-filter.ts create mode 100644 src/batch-publish-modal.ts create mode 100644 src/exif-orientation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ecdfa58 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. + +## [Unreleased] +### Added +- EXIF 图片方向自动处理:自动检测 JPEG EXIF Orientation (1/3/6/8),按需旋转并转换为 PNG,保证公众号显示方向正确。 +- Gallery 短代码 `mppickall` 参数:`mppickall=1` 选取目录全部图片,`0` 或缺省按 `galleryNumPic` 限制。 + +### Changed +- README:新增图片方向处理说明、Gallery 参数使用示例。 + +### Notes +- 若遇到其他 EXIF 方向值(除 1/3/6/8),当前保持原样,可后续扩展。 + +## [1.3.0] - 2025-09-25 +### Optimized +- 主题资源加载与提示逻辑优化:升级提示清理旧主题再下载。 + +### Added +- 多主题/代码高亮资源增量更新支持。 + +### Fixed +- 若干边缘情况下的 frontmatter 解析回退稳定性。 + +## [1.2.x] +- 历史版本条目待补充(如需补录,请提供对应版本变更点)。 + +--- + +## 维护指引 +- 发布新版本:更新 `package.json` / `manifest.json` 的版本号;追加 `versions.json`;将当前 Unreleased 条目移动为新的版本号,并添加日期;再创建新的 Unreleased 模板。 +- 提交信息建议:`feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...` 等 Conventional Commits 风格。 diff --git a/README.md b/README.md index 1359f86..da60860 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,50 @@ > > 注意:如果修改过主题文件请做备份后再操作。 +完整历史变更请查看: [CHANGELOG](./CHANGELOG.md) + ## 1、简介 这是一个Obsidian插件,针对微信公众号编缉器进行了优化,通过本插件复制笔记可以把笔记样式同步到公众号编缉器,轻轻松松搞定文章格式,一劳永逸,而且支持代码高亮、代码行数显示、主题背景颜色等。针对微信公众号不能放链接也专门处理了,提供直接展示链接地址和文末脚注展示两种方式。本项目初衷仅是为了能够将Obsidian中笔记的样式完美同步到微信公众号的编辑器中,因此项目重点在于保证文章格式的一致性,而不是成为一个微信公众号编辑器。 -![](images/screenshot.png) +### 图片方向自动处理 + +为了优化微信公众号图片上传体验,插件新增了 EXIF 方向自动处理功能: + +**功能说明:** +- 自动检测 JPEG 图片的 EXIF Orientation 信息 +- 对存在方向问题的图片自动旋转并转换为 PNG 格式 +- 确保上传到微信公众号的图片显示方向正确 + +**支持的方向类型:** +- `Orientation=1`:正常方向(无需处理) +- `Orientation=3`:需旋转 180° +- `Orientation=6`:需顺时针旋转 90°(右旋 90°) +- `Orientation=8`:需逆时针旋转 90°(左旋 90°) + +**处理流程:** +1. 检测图片文件类型(仅处理 JPEG/JPG 格式) +2. 读取 EXIF 方向信息 +3. 如有方向问题,使用 Canvas 进行旋转处理 +4. 将处理后的图片转换为 PNG 格式上传 + +**用户体验:** +- 本地 Obsidian 中显示正常的图片,上传到公众号后也会保持正确方向 +- 自动处理,无需用户手动调整 +- 转换为 PNG 格式可避免 EXIF 信息导致的显示问题 + +### 调试日志 + +在控制台(开发者工具)可看到: +``` +[note2mp] active file path: your/file/path.md +[note2mp] use default cover: cover.png -> ![[cover.png]] +[note2mp] EXIF orientation detected: 6 +[note2mp] Image converted to PNG with rotation +``` +路径日志做了节流:同一文件 3 秒内不重复打印。后续可加"调试开关"以完全关闭。 + +### 摘要、封面裁剪、原文链接等ges/screenshot.png) ## 2、安装 首先,**请确认已关闭了Obsidian的安全模式**。如未关闭,请通过**设置——第三方插件——关闭安全模式**关闭。 @@ -315,12 +354,30 @@ https://www.bilibili.com/video/BV15XWVeEEJa/ ![[002.jpg]] ``` +可选参数新增: + +`mppickall=1` 选取目录中所有图片(忽略“Gallery 选取图片数”限制);`mppickall=0` 或缺省时按配置的数量限制。支持写法:`mppickall=1`、`mppickall='1'`、`mppickall="1"`(0 同理)。 + +示例: + +``` +{{}}{{}} +``` + +或属性顺序不同、带 figcaption: + +``` +{{}}{{}} +``` + +在 `mppickall=1` 情况下,仍保持文件名排序(同原逻辑)。 + 配置项: - Gallery 根路径(galleryPrePath):指向本地实际图片根目录,用于拼接短代码中的 dir 得到真实磁盘路径。 - Gallery 选取图片数(galleryNumPic):每个 gallery 最多展开前 N 张图片(按文件名排序)。 -可在插件设置界面直接修改,无需重启。若希望随机选取或按时间排序,可后续在 issue 中反馈需求。 +可在插件设置界面直接修改,无需重启。若希望随机选取或按时间排序,可后续在 issue 中反馈需求。若需要永久“全部图片”效果,可同时将“选取图片数”设为一个足够大的值,或在需要的单个 gallery 上使用 `mppickall=1` 精确控制。 ### Gallery 块与 figure 支持 @@ -379,15 +436,6 @@ https://www.bilibili.com/video/BV15XWVeEEJa/ 避免因为缓存未就绪导致标题/作者缺失。若需复杂 YAML(数组、多行字符串)建议等待官方缓存,或后续考虑引入完整 YAML 解析库。 -### 调试日志 - -在控制台(开发者工具)可看到: -``` -[note2mp] active file path: your/file/path.md -[note2mp] use default cover: cover.png -> ![[cover.png]] -``` -路径日志做了节流:同一文件 3 秒内不重复打印。后续可加“调试开关”以完全关闭。 - ### 摘要、封面裁剪、原文链接等 ```yaml --- diff --git a/src/article-render.ts b/src/article-render.ts index 109ac31..2b095c9 100644 --- a/src/article-render.ts +++ b/src/article-render.ts @@ -41,9 +41,11 @@ import { stat, readdir } from 'fs/promises'; const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims; // gallery 配置迁移到 NMPSettings(galleryPrePath, galleryNumPic) -// 匹配示例:{{}}{{}} -// figcaption 可选 -const GALLERY_SHORTCODE_REGEX = /{{}}\s*{{}}/g; +// 匹配示例:{{}}{{}} +// 支持可选 figcaption 以及 mppickall=1/0(无引号数字或布尔),若 mppickall=1 则选取目录内全部图片 +// 说明:为保持简单,mppickall 只支持 0/1,不写或写 0 则按限制数量。 +// mppickall 允许:mppickall=1 | mppickall='1' | mppickall="1" (0 同理) +const GALLERY_SHORTCODE_REGEX = /{{}}\s*{{}}/g; // 块级 gallery: // {{}}\n{{
}}\n...\n{{}} // 需要提取所有 figure 的 src basename 生成多行 wikilink @@ -70,16 +72,17 @@ function pickImages(all: string[], limit: number): string[] { async function transformGalleryShortcodes(md: string, prePath: string, numPic: number): Promise { // 逐个替换(异步)—— 使用 replace + 手动遍历实现 - const matches: { full: string; dir: string; caption?: string }[] = []; - md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption) => { - matches.push({ full, dir, caption }); + const matches: { full: string; dir: string; caption?: string; pickAll?: boolean }[] = []; + md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption, q1, q2, plain) => { + const flag = q1 || q2 || plain; // 三个捕获组任选其一 + matches.push({ full, dir, caption, pickAll: flag === '1' }); return full; }); let result = md; for (const m of matches) { const absDir = path.join(prePath, m.dir.replace(/^\//, '')); // 拼接绝对路径 const imgs = await listLocalImages(absDir); - const picked = pickImages(imgs, numPic); + const picked = m.pickAll ? imgs : pickImages(imgs, numPic); if (picked.length === 0) { // 无图则清空短代码(或保留原样,这里按需求替换为空) result = result.replace(m.full, ''); diff --git a/src/batch-filter.ts b/src/batch-filter.ts new file mode 100644 index 0000000..9c41ded --- /dev/null +++ b/src/batch-filter.ts @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2024-2025 Sun Booshi + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { App, TFile, MetadataCache } from 'obsidian'; + +export interface FilterCondition { + type: 'tag' | 'filename' | 'folder' | 'frontmatter'; + operator: 'contains' | 'equals' | 'startsWith' | 'endsWith' | 'exists'; + value?: string; + key?: string; // for frontmatter +} + +export interface BatchFilterConfig { + conditions: FilterCondition[]; + logic: 'and' | 'or'; + orderBy?: 'name' | 'created' | 'modified'; + orderDirection?: 'asc' | 'desc'; +} + +export class BatchArticleFilter { + private app: App; + private metadataCache: MetadataCache; + + constructor(app: App) { + this.app = app; + this.metadataCache = app.metadataCache; + } + + /** + * 筛选文章 + */ + async filterArticles(config: BatchFilterConfig): Promise { + const allMarkdownFiles = this.app.vault.getMarkdownFiles(); + const filtered = allMarkdownFiles.filter(file => this.matchesConditions(file, config)); + + return this.sortFiles(filtered, config.orderBy || 'name', config.orderDirection || 'asc'); + } + + /** + * 检查文件是否匹配条件 + */ + private matchesConditions(file: TFile, config: BatchFilterConfig): boolean { + const { conditions, logic } = config; + + if (conditions.length === 0) return true; + + const results = conditions.map(condition => this.checkCondition(file, condition)); + + if (logic === 'and') { + return results.every(result => result); + } else { + return results.some(result => result); + } + } + + /** + * 检查单个条件 + */ + private checkCondition(file: TFile, condition: FilterCondition): boolean { + const { type, operator, value, key } = condition; + + switch (type) { + case 'tag': + return this.checkTagCondition(file, operator, value); + case 'filename': + return this.checkFilenameCondition(file, operator, value); + case 'folder': + return this.checkFolderCondition(file, operator, value); + case 'frontmatter': + return this.checkFrontmatterCondition(file, operator, key, value); + default: + return false; + } + } + + /** + * 检查标签条件 + */ + private checkTagCondition(file: TFile, operator: string, value?: string): boolean { + if (!value) return false; + + const fileCache = this.metadataCache.getFileCache(file); + const tags = fileCache?.tags?.map(t => t.tag.replace('#', '')) || []; + const frontmatterTags = fileCache?.frontmatter?.tags || []; + const allTags = [...tags, ...frontmatterTags].map(tag => + typeof tag === 'string' ? tag : String(tag) + ); + + switch (operator) { + case 'contains': + return allTags.some(tag => tag.includes(value)); + case 'equals': + return allTags.includes(value); + case 'exists': + return allTags.length > 0; + default: + return false; + } + } + + /** + * 检查文件名条件 + */ + private checkFilenameCondition(file: TFile, operator: string, value?: string): boolean { + if (!value) return false; + + const filename = file.basename; + + switch (operator) { + case 'contains': + return filename.includes(value); + case 'equals': + return filename === value; + case 'startsWith': + return filename.startsWith(value); + case 'endsWith': + return filename.endsWith(value); + default: + return false; + } + } + + /** + * 检查文件夹条件 + */ + private checkFolderCondition(file: TFile, operator: string, value?: string): boolean { + if (!value) return false; + + const folderPath = file.parent?.path || ''; + + switch (operator) { + case 'contains': + return folderPath.includes(value); + case 'equals': + return folderPath === value; + case 'startsWith': + return folderPath.startsWith(value); + default: + return false; + } + } + + /** + * 检查 frontmatter 条件 + */ + private checkFrontmatterCondition(file: TFile, operator: string, key?: string, value?: string): boolean { + if (!key) return false; + + const fileCache = this.metadataCache.getFileCache(file); + const frontmatter = fileCache?.frontmatter; + + if (!frontmatter) return false; + + const fieldValue = frontmatter[key]; + + switch (operator) { + case 'exists': + return fieldValue !== undefined; + case 'equals': + return String(fieldValue) === value; + case 'contains': + return value ? String(fieldValue).includes(value) : false; + default: + return false; + } + } + + /** + * 排序文件 + */ + private sortFiles(files: TFile[], orderBy: string, direction: string): TFile[] { + return files.sort((a, b) => { + let compareResult = 0; + + switch (orderBy) { + case 'name': + compareResult = a.basename.localeCompare(b.basename); + break; + case 'created': + compareResult = a.stat.ctime - b.stat.ctime; + break; + case 'modified': + compareResult = a.stat.mtime - b.stat.mtime; + break; + default: + compareResult = a.basename.localeCompare(b.basename); + } + + return direction === 'desc' ? -compareResult : compareResult; + }); + } + + /** + * 从类似 database view 的配置创建筛选条件 + */ + static fromDatabaseConfig(config: any): BatchFilterConfig { + const conditions: FilterCondition[] = []; + + if (config.filters?.and) { + for (const filter of config.filters.and) { + if (filter['file.tags.contains']) { + conditions.push({ + type: 'tag', + operator: 'contains', + value: filter['file.tags.contains'] + }); + } + // 可以扩展更多条件类型 + } + } + + let orderBy: 'name' | 'created' | 'modified' = 'name'; + if (config.order?.[0] === 'file.name') { + orderBy = 'name'; + } else if (config.order?.[0] === 'file.ctime') { + orderBy = 'created'; + } else if (config.order?.[0] === 'file.mtime') { + orderBy = 'modified'; + } + + return { + conditions, + logic: 'and', + orderBy, + orderDirection: 'asc' + }; + } +} \ No newline at end of file diff --git a/src/batch-publish-modal.ts b/src/batch-publish-modal.ts new file mode 100644 index 0000000..b2ebc79 --- /dev/null +++ b/src/batch-publish-modal.ts @@ -0,0 +1,600 @@ +/* + * Copyright (c) 2024-2025 Sun Booshi + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { App, Modal, Setting, TFile, Notice, ButtonComponent } from 'obsidian'; +import { BatchArticleFilter, BatchFilterConfig } from './batch-filter'; +import NoteToMpPlugin from './main'; + +export class BatchPublishModal extends Modal { + plugin: NoteToMpPlugin; + filter: BatchArticleFilter; + filteredFiles: TFile[] = []; + selectedFiles: Set = new Set(); + + // UI 元素 + private filterContainer: HTMLElement; + private resultsContainer: HTMLElement; + private publishButton: ButtonComponent; + + // 鼠标框选相关 + private isSelecting = false; + private selectionStart: { x: number; y: number } | null = null; + private selectionBox: HTMLElement | null = null; + private isCtrlPressed = false; // 跟踪 Ctrl 键状态 + + // 筛选配置 + private filterConfig: BatchFilterConfig = { + conditions: [ + { + type: 'folder', + operator: 'contains', + value: 'content/post' + } + ], + logic: 'and', + orderBy: 'name', + orderDirection: 'asc' + }; + + constructor(app: App, plugin: NoteToMpPlugin) { + super(app); + this.plugin = plugin; + this.filter = new BatchArticleFilter(app); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('batch-publish-modal'); + + // 设置模态框的整体布局 + contentEl.style.display = 'flex'; + contentEl.style.flexDirection = 'column'; + contentEl.style.height = '80vh'; + contentEl.style.maxHeight = '600px'; + + // 标题 + contentEl.createEl('h2', { text: '批量发布到公众号' }); + + // 筛选条件区域 + this.filterContainer = contentEl.createDiv('filter-container'); + this.createFilterUI(); + + // 结果展示区域(可滚动) + this.resultsContainer = contentEl.createDiv('results-container'); + this.resultsContainer.style.flex = '1'; + this.resultsContainer.style.overflow = 'hidden'; + this.resultsContainer.style.display = 'flex'; + this.resultsContainer.style.flexDirection = 'column'; + + // 操作按钮(固定在底部) + const buttonContainer = contentEl.createDiv('button-container'); + buttonContainer.style.marginTop = '20px'; + buttonContainer.style.textAlign = 'center'; + buttonContainer.style.paddingTop = '15px'; + buttonContainer.style.borderTop = '1px solid var(--background-modifier-border)'; + buttonContainer.style.flexShrink = '0'; + + new ButtonComponent(buttonContainer) + .setButtonText('应用筛选') + .setCta() + .onClick(() => this.applyFilter()); + + this.publishButton = new ButtonComponent(buttonContainer) + .setButtonText('发布选中文章 (0)') + .setDisabled(true) + .onClick(() => this.publishSelected()); + + new ButtonComponent(buttonContainer) + .setButtonText('取消') + .onClick(() => this.close()); + + // 初始加载所有文章 + this.applyFilter(); + } + + /** + * 创建筛选条件界面 + */ + private createFilterUI() { + this.filterContainer.empty(); + + // 标签筛选 + new Setting(this.filterContainer) + .setName('按标签筛选') + .setDesc('输入要筛选的标签名称') + .addText(text => { + const tagCondition = this.filterConfig.conditions.find(c => c.type === 'tag'); + text.setPlaceholder('如: 篆刻') + .setValue(tagCondition?.value || '') + .onChange(value => { + this.updateTagCondition(value); + }); + + // 添加回车键监听 + text.inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.applyFilter(); + } + }); + }); + + // 文件名筛选 + new Setting(this.filterContainer) + .setName('按文件名筛选') + .setDesc('输入文件名关键词') + .addText(text => { + const nameCondition = this.filterConfig.conditions.find(c => c.type === 'filename'); + text.setPlaceholder('如: 故事') + .setValue(nameCondition?.value || '') + .onChange(value => { + this.updateFilenameCondition(value); + }); + + // 添加回车键监听 + text.inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.applyFilter(); + } + }); + }); + + // 文件夹筛选 + new Setting(this.filterContainer) + .setName('按文件夹筛选') + .setDesc('输入文件夹路径') + .addText(text => { + const folderCondition = this.filterConfig.conditions.find(c => c.type === 'folder'); + text.setPlaceholder('如: content/post') + .setValue(folderCondition?.value || 'content/post') + .onChange(value => { + this.updateFolderCondition(value); + }); + + // 添加回车键监听 + text.inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.applyFilter(); + } + }); + }); + + // 排序选项 + new Setting(this.filterContainer) + .setName('排序方式') + .setDesc('选择文章排序方式') + .addDropdown(dropdown => { + dropdown.addOption('name', '按文件名') + .addOption('created', '按创建时间') + .addOption('modified', '按修改时间') + .setValue(this.filterConfig.orderBy || 'name') + .onChange(value => { + this.filterConfig.orderBy = value as any; + }); + }) + .addDropdown(dropdown => { + dropdown.addOption('asc', '升序') + .addOption('desc', '降序') + .setValue(this.filterConfig.orderDirection || 'asc') + .onChange(value => { + this.filterConfig.orderDirection = value as any; + }); + }); + } + + /** + * 更新标签条件 + */ + private updateTagCondition(value: string) { + // 移除现有的标签条件 + this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'tag'); + + if (value.trim()) { + this.filterConfig.conditions.push({ + type: 'tag', + operator: 'contains', + value: value.trim() + }); + } + } + + /** + * 更新文件名条件 + */ + private updateFilenameCondition(value: string) { + this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'filename'); + + if (value.trim()) { + this.filterConfig.conditions.push({ + type: 'filename', + operator: 'contains', + value: value.trim() + }); + } + } + + /** + * 更新文件夹条件 + */ + private updateFolderCondition(value: string) { + this.filterConfig.conditions = this.filterConfig.conditions.filter(c => c.type !== 'folder'); + + if (value.trim()) { + this.filterConfig.conditions.push({ + type: 'folder', + operator: 'contains', + value: value.trim() + }); + } + } + + /** + * 应用筛选条件 + */ + private async applyFilter() { + try { + this.filteredFiles = await this.filter.filterArticles(this.filterConfig); + this.selectedFiles.clear(); + this.displayResults(); + this.updatePublishButton(); + } catch (error) { + new Notice('筛选文章时出错: ' + error.message); + console.error(error); + } + } + + /** + * 显示筛选结果 + */ + private displayResults() { + this.resultsContainer.empty(); + + if (this.filteredFiles.length === 0) { + this.resultsContainer.createEl('p', { + text: '未找到匹配的文章', + cls: 'no-results' + }); + return; + } + + // 统计信息 + const statsEl = this.resultsContainer.createDiv('results-stats'); + statsEl.textContent = `找到 ${this.filteredFiles.length} 篇文章`; + statsEl.style.flexShrink = '0'; + + // 全选/取消全选 + const selectAllContainer = this.resultsContainer.createDiv('select-all-container'); + selectAllContainer.style.flexShrink = '0'; + const selectAllCheckbox = selectAllContainer.createEl('input', { + type: 'checkbox' + }); + selectAllContainer.createSpan({ text: ' 全选/取消全选' }); + + selectAllCheckbox.addEventListener('change', () => { + if (selectAllCheckbox.checked) { + this.filteredFiles.forEach(file => this.selectedFiles.add(file)); + } else { + this.selectedFiles.clear(); + } + this.updateCheckboxes(); + this.updatePublishButton(); + }); + + // 文章列表(可滚动区域) + const listContainer = this.resultsContainer.createDiv('articles-list'); + listContainer.style.flex = '1'; + listContainer.style.overflowY = 'auto'; + listContainer.style.border = '1px solid var(--background-modifier-border)'; + listContainer.style.borderRadius = '4px'; + listContainer.style.padding = '10px'; + listContainer.style.position = 'relative'; + listContainer.style.userSelect = 'none'; // 禁用文本选择 + + // 添加鼠标框选功能 + this.setupMouseSelection(listContainer); + + this.filteredFiles.forEach(file => { + const itemEl = listContainer.createDiv('article-item'); + itemEl.style.display = 'flex'; + itemEl.style.alignItems = 'center'; + itemEl.style.padding = '5px 0'; + itemEl.style.borderBottom = '1px solid var(--background-modifier-border-hover)'; + itemEl.style.cursor = 'pointer'; + itemEl.setAttribute('data-file-path', file.path); + + const checkbox = itemEl.createEl('input', { + type: 'checkbox' + }); + checkbox.style.marginRight = '10px'; + + const titleEl = itemEl.createSpan({ text: file.basename }); + titleEl.style.flex = '1'; + + const pathEl = itemEl.createEl('small', { text: file.path }); + pathEl.style.color = 'var(--text-muted)'; + pathEl.style.marginLeft = '10px'; + + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedFiles.add(file); + } else { + this.selectedFiles.delete(file); + } + this.updatePublishButton(); + }); + + // 点击整行也能选择 + itemEl.addEventListener('click', (e) => { + if (e.target !== checkbox) { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change')); + } + }); + + // 存储引用以便后续更新 + (checkbox as any)._file = file; + }); + } + + /** + * 更新所有复选框状态 + */ + private updateCheckboxes() { + const checkboxes = this.resultsContainer.querySelectorAll('.articles-list input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + const file = (checkbox as any)._file; + if (file) { + (checkbox as HTMLInputElement).checked = this.selectedFiles.has(file); + } + }); + } + + /** + * 更新发布按钮 + */ + private updatePublishButton() { + const count = this.selectedFiles.size; + this.publishButton.setButtonText(`发布选中文章 (${count})`); + this.publishButton.setDisabled(count === 0); + } + + /** + * 发布选中的文章 + */ + private async publishSelected() { + if (this.selectedFiles.size === 0) { + new Notice('请选择要发布的文章'); + return; + } + + const files = Array.from(this.selectedFiles); + const total = files.length; + let completed = 0; + let failed = 0; + + // 显示进度 + const notice = new Notice(`开始批量发布 ${total} 篇文章...`, 0); + + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + try { + // 更新进度 + notice.setMessage(`正在发布: ${file.basename} (${i + 1}/${total})`); + + // 激活预览视图并发布 + await this.plugin.activateView(); + const preview = this.plugin.getNotePreview(); + if (preview) { + await preview.renderMarkdown(file); + await preview.postArticle(); + completed++; + } else { + throw new Error('无法获取预览视图'); + } + + // 避免请求过于频繁 + if (i < files.length - 1) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + } catch (error) { + console.error(`发布文章 ${file.basename} 失败:`, error); + failed++; + } + } + + // 显示最终结果 + notice.hide(); + new Notice(`批量发布完成!成功: ${completed} 篇,失败: ${failed} 篇`); + + if (completed > 0) { + this.close(); + } + + } catch (error) { + notice.hide(); + new Notice('批量发布过程中出错: ' + error.message); + console.error(error); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + + // 清理鼠标框选相关的事件监听器 + this.cleanupMouseSelection(); + } + + /** + * 设置鼠标框选功能 + */ + private setupMouseSelection(container: HTMLElement) { + container.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; // 只响应左键 + if (e.target !== container && !(e.target as HTMLElement).closest('.article-item')) { + return; + } + + this.isSelecting = true; + this.isCtrlPressed = e.ctrlKey || e.metaKey; // 检测 Ctrl 键(Mac 上是 Cmd 键) + + const containerRect = container.getBoundingClientRect(); + this.selectionStart = { + x: e.clientX - containerRect.left + container.scrollLeft, + y: e.clientY - containerRect.top + container.scrollTop + }; + + // 创建选择框 + this.selectionBox = document.createElement('div'); + this.selectionBox.style.position = 'absolute'; + this.selectionBox.style.border = '1px dashed var(--interactive-accent)'; + this.selectionBox.style.backgroundColor = 'var(--interactive-accent-hover)'; + this.selectionBox.style.opacity = '0.3'; + this.selectionBox.style.zIndex = '1000'; + this.selectionBox.style.pointerEvents = 'none'; + + // Ctrl 键时使用不同的视觉样式表示取消选中模式 + if (this.isCtrlPressed) { + this.selectionBox.style.border = '1px dashed var(--text-error)'; + this.selectionBox.style.backgroundColor = 'var(--background-modifier-error-hover)'; + } + + container.appendChild(this.selectionBox); + + e.preventDefault(); + }); + + container.addEventListener('mousemove', (e) => { + if (!this.isSelecting || !this.selectionStart || !this.selectionBox) return; + + const containerRect = container.getBoundingClientRect(); + const currentX = e.clientX - containerRect.left + container.scrollLeft; + const currentY = e.clientY - containerRect.top + container.scrollTop; + + const left = Math.min(this.selectionStart.x, currentX); + const top = Math.min(this.selectionStart.y, currentY); + const width = Math.abs(currentX - this.selectionStart.x); + const height = Math.abs(currentY - this.selectionStart.y); + + this.selectionBox.style.left = left + 'px'; + this.selectionBox.style.top = top + 'px'; + this.selectionBox.style.width = width + 'px'; + this.selectionBox.style.height = height + 'px'; + + // 检测哪些文件项在选择框内 + this.updateSelectionByBox(container, left, top, width, height); + }); + + container.addEventListener('mouseup', () => { + if (this.isSelecting) { + this.isSelecting = false; + this.selectionStart = null; + this.isCtrlPressed = false; + + if (this.selectionBox) { + this.selectionBox.remove(); + this.selectionBox = null; + } + + this.updatePublishButton(); + } + }); + + // 防止拖拽离开容器时无法结束选择 + document.addEventListener('mouseup', () => { + if (this.isSelecting) { + this.isSelecting = false; + this.selectionStart = null; + this.isCtrlPressed = false; + + if (this.selectionBox) { + this.selectionBox.remove(); + this.selectionBox = null; + } + } + }); + } + + /** + * 根据选择框更新文件选择状态 + */ + private updateSelectionByBox(container: HTMLElement, boxLeft: number, boxTop: number, boxWidth: number, boxHeight: number) { + const items = container.querySelectorAll('.article-item'); + + items.forEach(item => { + const itemEl = item as HTMLElement; + + // 获取元素相对于容器的位置(考虑滚动) + let itemTop = 0; + let currentEl = itemEl; + while (currentEl && currentEl !== container) { + itemTop += currentEl.offsetTop; + currentEl = currentEl.offsetParent as HTMLElement; + } + + const itemLeft = itemEl.offsetLeft; + const itemRight = itemLeft + itemEl.offsetWidth; + const itemBottom = itemTop + itemEl.offsetHeight; + + const boxRight = boxLeft + boxWidth; + const boxBottom = boxTop + boxHeight; + + // 检测是否有重叠 + const isIntersecting = !(itemRight < boxLeft || + itemLeft > boxRight || + itemBottom < boxTop || + itemTop > boxBottom); + + if (isIntersecting) { + const checkbox = itemEl.querySelector('input[type="checkbox"]') as HTMLInputElement; + const file = (checkbox as any)._file as TFile; + + if (checkbox && file) { + if (this.isCtrlPressed) { + // Ctrl+框选:取消选中 + checkbox.checked = false; + this.selectedFiles.delete(file); + } else { + // 普通框选:选中 + checkbox.checked = true; + this.selectedFiles.add(file); + } + } + } + }); + } + + /** + * 清理鼠标框选相关资源 + */ + private cleanupMouseSelection() { + if (this.selectionBox) { + this.selectionBox.remove(); + this.selectionBox = null; + } + this.isSelecting = false; + this.selectionStart = null; + } +} \ No newline at end of file diff --git a/src/exif-orientation.ts b/src/exif-orientation.ts new file mode 100644 index 0000000..60f09c4 --- /dev/null +++ b/src/exif-orientation.ts @@ -0,0 +1,117 @@ +/* + * Lightweight EXIF orientation reader + conditional JPEG -> PNG converter. + * We only care about orientation values 3,6,8 (rotations). Others return as-is. + */ + +export async function readOrientation(blob: Blob): Promise { + try { + console.log(`[readOrientation] Blob type: ${blob.type}, size: ${blob.size}`); + if (blob.type !== 'image/jpeg' && blob.type !== 'image/jpg') { + console.log(`[readOrientation] Not a JPEG, blob type: ${blob.type}`); + return null; + } + const buf = await blob.arrayBuffer(); + const view = new DataView(buf); + console.log(`[readOrientation] ArrayBuffer length: ${buf.byteLength}`); + // JPEG starts with 0xFFD8 + if (view.getUint16(0) !== 0xFFD8) { + console.log(`[readOrientation] Not a valid JPEG, header: ${view.getUint16(0).toString(16)}`); + return null; + } + console.log(`[readOrientation] Valid JPEG detected`); + let offset = 2; + const length = view.byteLength; + while (offset < length) { + if (view.getUint8(offset) !== 0xFF) break; + const marker = view.getUint8(offset + 1); + console.log(`[readOrientation] Processing marker: 0xFF${marker.toString(16).padStart(2, '0')} at offset ${offset}`); + if (marker === 0xE1) { // APP1 EXIF + const size = view.getUint16(offset + 2, false); + const exifHeader = offset + 4; + console.log(`[readOrientation] Found APP1 segment, size: ${size}`); + if (view.getUint32(exifHeader, false) === 0x45786966) { // 'Exif' + console.log(`[readOrientation] Found EXIF header`); + const tiff = exifHeader + 6; + const endian = view.getUint16(tiff, false); + const little = endian === 0x4949; // 'II' + console.log(`[readOrientation] Endian: ${little ? 'little' : 'big'} (${endian.toString(16)})`); + const getU16 = (p:number) => view.getUint16(p, little); + const getU32 = (p:number) => view.getUint32(p, little); + if (getU16(tiff + 2) !== 0x002A) { + console.log(`[readOrientation] Invalid TIFF magic: ${getU16(tiff + 2).toString(16)}`); + return null; + } + const ifdOffset = getU32(tiff + 4); + let dir = tiff + ifdOffset; + const entries = getU16(dir); + console.log(`[readOrientation] IFD has ${entries} entries`); + dir += 2; + for (let i=0;i { + console.log(`[exif-orientation] Processing ${filename}, blob type: ${blob.type}, size: ${blob.size}`); + const ori = await readOrientation(blob); + console.log(`[exif-orientation] Detected orientation for ${filename}: ${ori}`); + return new Promise((resolve) => { + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const w = img.naturalWidth; + const h = img.naturalHeight; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + resolve({ blob, filename, changed: false, orientation: ori }); + return; + } + canvas.width = w; + canvas.height = h; + ctx.drawImage(img, 0, 0); + canvas.toBlob(b => { + URL.revokeObjectURL(url); + if (!b) { + resolve({ blob, filename, changed: false, orientation: ori }); + return; + } + const pngName = filename.replace(/\.(jpe?g)$/i, '') + '_converted.png'; + resolve({ blob: b, filename: pngName, changed: true, orientation: ori }); + }, 'image/png'); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ blob, filename, changed: false, orientation: ori }); + }; + img.src = url; + }); +} diff --git a/src/main.ts b/src/main.ts index 5a38bf5..e76909a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { NoteToMpSettingTab } from './setting-tab'; import AssetsManager from './assets'; import { setVersion, uevent } from './utils'; import { WidgetsModal } from './widgets-modal'; +import { BatchPublishModal } from './batch-publish-modal'; export default class NoteToMpPlugin extends Plugin { @@ -79,6 +80,14 @@ export default class NoteToMpPlugin extends Plugin { } }); + this.addCommand({ + id: 'note-to-mp-batch-publish', + name: '批量发布文章', + callback: () => { + new BatchPublishModal(this.app, this).open(); + } + }); + this.addCommand({ id: 'note-to-mp-pub', name: '发布公众号文章', diff --git a/src/markdown/local-file.ts b/src/markdown/local-file.ts index ff8a44b..aee1456 100644 --- a/src/markdown/local-file.ts +++ b/src/markdown/local-file.ts @@ -25,6 +25,7 @@ import { Notice, TAbstractFile, TFile, Vault, MarkdownView, requestUrl, Platform import { Extension } from "./extension"; import { NMPSettings } from "../settings"; import { IsImageLibReady, PrepareImageLib, WebpToJPG, UploadImageToWx } from "../imagelib"; +import { convertJpegIfNeeded } from "../exif-orientation"; declare module 'obsidian' { interface Vault { @@ -87,6 +88,26 @@ export class LocalImageManager { if (file == null) continue; let fileData = await vault.readBinary(file); let name = file.name; + + // 处理 EXIF Orientation + try { + // 根据文件扩展名确定 MIME 类型 + const mimeType = /\.jpe?g$/i.test(name) ? 'image/jpeg' : + /\.png$/i.test(name) ? 'image/png' : + /\.gif$/i.test(name) ? 'image/gif' : + /\.webp$/i.test(name) ? 'image/webp' : 'application/octet-stream'; + + const processed = await convertJpegIfNeeded(new Blob([fileData], { type: mimeType }), name); + if (processed.changed) { + fileData = await processed.blob.arrayBuffer(); + name = processed.filename; + console.log(`[local-file] Applied orientation fix (${processed.orientation}) to ${name}`); + } + } catch (error) { + console.warn(`[local-file] EXIF orientation processing failed for ${name}:`, error); + // 继续使用原始文件 + } + if (this.isWebp(file)) { if (IsImageLibReady()) { fileData = WebpToJPG(fileData); @@ -199,6 +220,20 @@ export class LocalImageManager { filename = 'remote_img' + this.getImageExtFromBlob(blob); } + // 处理 EXIF Orientation + try { + const processed = await convertJpegIfNeeded(blob, filename); + if (processed.changed) { + data = await processed.blob.arrayBuffer(); + filename = processed.filename; + blob = new Blob([data]); + console.log(`[local-file] Applied orientation fix (${processed.orientation}) to remote ${filename}`); + } + } catch (error) { + console.warn(`[local-file] EXIF orientation processing failed for remote ${filename}:`, error); + // 继续使用原始文件 + } + if (this.isWebp(filename)) { if (IsImageLibReady()) { data = WebpToJPG(data); diff --git a/src/setting-tab.ts b/src/setting-tab.ts index 22f7eb9..cf3e374 100644 --- a/src/setting-tab.ts +++ b/src/setting-tab.ts @@ -323,6 +323,7 @@ export class NoteToMpSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl) .setName('启用空行渲染') .addToggle(toggle => { @@ -345,6 +346,7 @@ export class NoteToMpSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Excalidraw 渲染为 PNG 图片') + .setDesc('开启:将 Excalidraw 笔记/嵌入转换为位图 PNG 插入;关闭:保持原始 SVG/矢量渲染(更清晰,体积更小)。') .addToggle(toggle => { toggle.setValue(this.settings.excalidrawToPNG); toggle.onChange(async (value) => { diff --git a/src/settings.ts b/src/settings.ts index e8008dd..dff57b2 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -3,7 +3,12 @@ * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights + if (ignoreFrontmatterImage !== undefined) { + settings.ignoreFrontmatterImage = ignoreFrontmatterImage; + } + if (Array.isArray(batchPublishPresets)) { + settings.batchPublishPresets = batchPublishPresets; + }n the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: @@ -51,6 +56,13 @@ export class NMPSettings { defaultCoverPic: string; // 是否忽略 frontmatter 中的 cover/image 字段(用户要求:封面不使用 frontmatter image) ignoreFrontmatterImage: boolean; + // 批量发布筛选条件预设 + batchPublishPresets: Array<{ + name: string; + tags?: string[]; + folders?: string[]; + filenameKeywords?: string[]; + }>; private static instance: NMPSettings; @@ -86,7 +98,16 @@ export class NMPSettings { this.galleryNumPic = 2; // 默认封面:使用当前笔记同目录下的 cover.png (若存在会被后续流程正常解析;不存在则无效但可被用户覆盖) this.defaultCoverPic = 'cover.png'; - this.ignoreFrontmatterImage = false; + this.ignoreFrontmatterImage = false; + // 批量发布预设 + this.batchPublishPresets = [ + { + name: '篆刻文章', + tags: ['篆刻'], + folders: [], + filenameKeywords: [] + } + ]; } resetStyelAndHighlight() { @@ -121,6 +142,7 @@ export class NMPSettings { galleryNumPic, defaultCoverPic, ignoreFrontmatterImage, + batchPublishPresets = [], } = data; const settings = NMPSettings.getInstance(); diff --git a/todo.list b/todo.list index d0c0b7d..6a798d5 100644 --- a/todo.list +++ b/todo.list @@ -45,7 +45,6 @@ src可能使用link: ![[2025ZK12-2.jpg]] ✅ - 4. 参考以下代码,渲染[fig content/],|| content,||r content,||g content,||b content等标签: `\[fig([^>]*?)/\]` `$1` @@ -82,4 +81,58 @@ content3 8. 在h1前使用||h1 来增加修饰编号,01,02,03…… +9. 支持选中多篇文章,邮件"发布到公众号"。问题obsidian只能连续选择,是不能跳着选。 +改变思路:通过database按tags筛选文件,筛选出文件,执行命令。 +增加命令 - 批量发布 +``` +在obsidian中通过database筛选出文章,送到发布公众号: +views: + - type: table + name: 表格 + filters: + and: + - file.tags.contains("篆刻") + order: + - file.name + +实现: +1. 回车键执行“应用筛选” +2. 支持鼠标框选文件 + +修正问题:当滚动条下拉后,无法框选 + +鼠标框选选中,control+鼠标框选取消选中 +``` +✅ + +10. 默认选择“原创”“允许留言”。 + +11. gallery短代码增加是否使用dir中的所有图片的开关。mppickall=1,选取dir中的所有图片,mppickall=0,按“选取图片数”配置选取图片数量。 +{{}}{{}} +{{ mppickall=1}}{{}} +(hugo中发布会忽略mppickall信息) +✅ + +12. 图片旋转问题❓在mac预览和obsidian中查看都正常的图片。上传公众号被左旋90度❓note-to-mp中没有旋转逻辑。 +exiftool -Orientation -n image.jpg +Orientation : 6 +• 1 → 正常方向 +• 3 → 倒过来 +• 6 → 右转 90° +• 8 → 左转 90° + +Orientation : 1 -- 没有问题。 +Orientation : 6 -- 图片左旋90度,需右选90才正常。 + +需求: +- 在mac预览和obsidian中查看都正常的图片。上传公众号被左旋90度。通过exiftool -Orientation -n image.jpg查看显示,Orientation : 6。 +在插件中需要判断Orientation的值,除了Orientation为1不需要旋转,其他情况依据该值执行旋转操作: +1 → 不需要旋转 +3 → 旋转180度 +6 → 右转 90° +8 → 左转 90° +没有解决❗️ +- 在mac预览和obsidian中查看都正常的图片。上传公众号被左旋90度。通过exiftool -Orientation -n image.ext查看显示,Orientation : 6。 +为了规避这个问题,图片不做旋转处理,直接转为png上传公众号。解决。因为PNG不带orientation信息。 +✅