update at 2025-09-25 22:35:01
This commit is contained in:
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal file
@@ -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 风格。
|
||||||
70
README.md
70
README.md
@@ -6,11 +6,50 @@
|
|||||||
>
|
>
|
||||||
> 注意:如果修改过主题文件请做备份后再操作。
|
> 注意:如果修改过主题文件请做备份后再操作。
|
||||||
|
|
||||||
|
完整历史变更请查看: [CHANGELOG](./CHANGELOG.md)
|
||||||
|
|
||||||
## 1、简介
|
## 1、简介
|
||||||
|
|
||||||
这是一个Obsidian插件,针对微信公众号编缉器进行了优化,通过本插件复制笔记可以把笔记样式同步到公众号编缉器,轻轻松松搞定文章格式,一劳永逸,而且支持代码高亮、代码行数显示、主题背景颜色等。针对微信公众号不能放链接也专门处理了,提供直接展示链接地址和文末脚注展示两种方式。本项目初衷仅是为了能够将Obsidian中笔记的样式完美同步到微信公众号的编辑器中,因此项目重点在于保证文章格式的一致性,而不是成为一个微信公众号编辑器。
|
这是一个Obsidian插件,针对微信公众号编缉器进行了优化,通过本插件复制笔记可以把笔记样式同步到公众号编缉器,轻轻松松搞定文章格式,一劳永逸,而且支持代码高亮、代码行数显示、主题背景颜色等。针对微信公众号不能放链接也专门处理了,提供直接展示链接地址和文末脚注展示两种方式。本项目初衷仅是为了能够将Obsidian中笔记的样式完美同步到微信公众号的编辑器中,因此项目重点在于保证文章格式的一致性,而不是成为一个微信公众号编辑器。
|
||||||
|
|
||||||

|
### 图片方向自动处理
|
||||||
|
|
||||||
|
为了优化微信公众号图片上传体验,插件新增了 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、安装
|
## 2、安装
|
||||||
首先,**请确认已关闭了Obsidian的安全模式**。如未关闭,请通过**设置——第三方插件——关闭安全模式**关闭。
|
首先,**请确认已关闭了Obsidian的安全模式**。如未关闭,请通过**设置——第三方插件——关闭安全模式**关闭。
|
||||||
@@ -315,12 +354,30 @@ https://www.bilibili.com/video/BV15XWVeEEJa/
|
|||||||
![[002.jpg]]
|
![[002.jpg]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
可选参数新增:
|
||||||
|
|
||||||
|
`mppickall=1` 选取目录中所有图片(忽略“Gallery 选取图片数”限制);`mppickall=0` 或缺省时按配置的数量限制。支持写法:`mppickall=1`、`mppickall='1'`、`mppickall="1"`(0 同理)。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{<gallery dir="/img/guanzhan/1" mppickall=1/>}}{{<load-photoswipe>}}
|
||||||
|
```
|
||||||
|
|
||||||
|
或属性顺序不同、带 figcaption:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{<gallery dir="/img/guanzhan/1" figcaption="毕业展" mppickall=1/>}}{{<load-photoswipe>}}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `mppickall=1` 情况下,仍保持文件名排序(同原逻辑)。
|
||||||
|
|
||||||
配置项:
|
配置项:
|
||||||
|
|
||||||
- Gallery 根路径(galleryPrePath):指向本地实际图片根目录,用于拼接短代码中的 dir 得到真实磁盘路径。
|
- Gallery 根路径(galleryPrePath):指向本地实际图片根目录,用于拼接短代码中的 dir 得到真实磁盘路径。
|
||||||
- Gallery 选取图片数(galleryNumPic):每个 gallery 最多展开前 N 张图片(按文件名排序)。
|
- Gallery 选取图片数(galleryNumPic):每个 gallery 最多展开前 N 张图片(按文件名排序)。
|
||||||
|
|
||||||
可在插件设置界面直接修改,无需重启。若希望随机选取或按时间排序,可后续在 issue 中反馈需求。
|
可在插件设置界面直接修改,无需重启。若希望随机选取或按时间排序,可后续在 issue 中反馈需求。若需要永久“全部图片”效果,可同时将“选取图片数”设为一个足够大的值,或在需要的单个 gallery 上使用 `mppickall=1` 精确控制。
|
||||||
|
|
||||||
### Gallery 块与 figure 支持
|
### Gallery 块与 figure 支持
|
||||||
|
|
||||||
@@ -379,15 +436,6 @@ https://www.bilibili.com/video/BV15XWVeEEJa/
|
|||||||
|
|
||||||
避免因为缓存未就绪导致标题/作者缺失。若需复杂 YAML(数组、多行字符串)建议等待官方缓存,或后续考虑引入完整 YAML 解析库。
|
避免因为缓存未就绪导致标题/作者缺失。若需复杂 YAML(数组、多行字符串)建议等待官方缓存,或后续考虑引入完整 YAML 解析库。
|
||||||
|
|
||||||
### 调试日志
|
|
||||||
|
|
||||||
在控制台(开发者工具)可看到:
|
|
||||||
```
|
|
||||||
[note2mp] active file path: your/file/path.md
|
|
||||||
[note2mp] use default cover: cover.png -> ![[cover.png]]
|
|
||||||
```
|
|
||||||
路径日志做了节流:同一文件 3 秒内不重复打印。后续可加“调试开关”以完全关闭。
|
|
||||||
|
|
||||||
### 摘要、封面裁剪、原文链接等
|
### 摘要、封面裁剪、原文链接等
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ import { stat, readdir } from 'fs/promises';
|
|||||||
const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims;
|
const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims;
|
||||||
|
|
||||||
// gallery 配置迁移到 NMPSettings(galleryPrePath, galleryNumPic)
|
// gallery 配置迁移到 NMPSettings(galleryPrePath, galleryNumPic)
|
||||||
// 匹配示例:{{<gallery dir="/img/guanzhan/1" figcaption="毕业展"/>}}{{<load-photoswipe>}}
|
// 匹配示例:{{<gallery dir="/img/guanzhan/1" figcaption="毕业展" mppickall=1/>}}{{<load-photoswipe>}}
|
||||||
// figcaption 可选
|
// 支持可选 figcaption 以及 mppickall=1/0(无引号数字或布尔),若 mppickall=1 则选取目录内全部图片
|
||||||
const GALLERY_SHORTCODE_REGEX = /{{<gallery\s+dir="([^"]+)"(?:\s+figcaption="([^"]*)")?\s*\/?>}}\s*{{<load-photoswipe>}}/g;
|
// 说明:为保持简单,mppickall 只支持 0/1,不写或写 0 则按限制数量。
|
||||||
|
// mppickall 允许:mppickall=1 | mppickall='1' | mppickall="1" (0 同理)
|
||||||
|
const GALLERY_SHORTCODE_REGEX = /{{<gallery\s+dir="([^"]+)"(?:\s+figcaption="([^"]*)")?(?:\s+mppickall=(?:"(1|0)"|'(1|0)'|(1|0)))?\s*\/?>}}\s*{{<load-photoswipe>}}/g;
|
||||||
// 块级 gallery:
|
// 块级 gallery:
|
||||||
// {{<gallery>}}\n{{<figure src="/img/a.png" caption=".." >}}\n...\n{{</gallery>}}
|
// {{<gallery>}}\n{{<figure src="/img/a.png" caption=".." >}}\n...\n{{</gallery>}}
|
||||||
// 需要提取所有 figure 的 src basename 生成多行 wikilink
|
// 需要提取所有 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<string> {
|
async function transformGalleryShortcodes(md: string, prePath: string, numPic: number): Promise<string> {
|
||||||
// 逐个替换(异步)—— 使用 replace + 手动遍历实现
|
// 逐个替换(异步)—— 使用 replace + 手动遍历实现
|
||||||
const matches: { full: string; dir: string; caption?: string }[] = [];
|
const matches: { full: string; dir: string; caption?: string; pickAll?: boolean }[] = [];
|
||||||
md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption) => {
|
md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption, q1, q2, plain) => {
|
||||||
matches.push({ full, dir, caption });
|
const flag = q1 || q2 || plain; // 三个捕获组任选其一
|
||||||
|
matches.push({ full, dir, caption, pickAll: flag === '1' });
|
||||||
return full;
|
return full;
|
||||||
});
|
});
|
||||||
let result = md;
|
let result = md;
|
||||||
for (const m of matches) {
|
for (const m of matches) {
|
||||||
const absDir = path.join(prePath, m.dir.replace(/^\//, '')); // 拼接绝对路径
|
const absDir = path.join(prePath, m.dir.replace(/^\//, '')); // 拼接绝对路径
|
||||||
const imgs = await listLocalImages(absDir);
|
const imgs = await listLocalImages(absDir);
|
||||||
const picked = pickImages(imgs, numPic);
|
const picked = m.pickAll ? imgs : pickImages(imgs, numPic);
|
||||||
if (picked.length === 0) {
|
if (picked.length === 0) {
|
||||||
// 无图则清空短代码(或保留原样,这里按需求替换为空)
|
// 无图则清空短代码(或保留原样,这里按需求替换为空)
|
||||||
result = result.replace(m.full, '');
|
result = result.replace(m.full, '');
|
||||||
|
|||||||
247
src/batch-filter.ts
Normal file
247
src/batch-filter.ts
Normal file
@@ -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<TFile[]> {
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
600
src/batch-publish-modal.ts
Normal file
600
src/batch-publish-modal.ts
Normal file
@@ -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<TFile> = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/exif-orientation.ts
Normal file
117
src/exif-orientation.ts
Normal file
@@ -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<number | null> {
|
||||||
|
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<entries;i++) {
|
||||||
|
const entry = dir + i*12;
|
||||||
|
const tag = getU16(entry);
|
||||||
|
if (tag === 0x0112) { // Orientation
|
||||||
|
const orientationValue = getU16(entry + 8);
|
||||||
|
console.log(`[readOrientation] Found Orientation tag: ${orientationValue}`);
|
||||||
|
return orientationValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[readOrientation] No Orientation tag found, returning 1`);
|
||||||
|
return 1; // treat as normal
|
||||||
|
}
|
||||||
|
offset += 2 + size;
|
||||||
|
} else if (marker === 0xDA) { // SOS
|
||||||
|
console.log(`[readOrientation] Reached SOS marker, stopping`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
const size = view.getUint16(offset + 2, false);
|
||||||
|
offset += 2 + size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[readOrientation] Error reading orientation:`, e);
|
||||||
|
}
|
||||||
|
console.log(`[readOrientation] No orientation found, returning null`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts JPEG to PNG without rotation, regardless of orientation.
|
||||||
|
* Returns a PNG blob + suggested filename.
|
||||||
|
*/
|
||||||
|
export async function convertJpegIfNeeded(blob: Blob, filename: string): Promise<{blob: Blob; filename: string; changed: boolean; orientation: number | null}> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { NoteToMpSettingTab } from './setting-tab';
|
|||||||
import AssetsManager from './assets';
|
import AssetsManager from './assets';
|
||||||
import { setVersion, uevent } from './utils';
|
import { setVersion, uevent } from './utils';
|
||||||
import { WidgetsModal } from './widgets-modal';
|
import { WidgetsModal } from './widgets-modal';
|
||||||
|
import { BatchPublishModal } from './batch-publish-modal';
|
||||||
|
|
||||||
|
|
||||||
export default class NoteToMpPlugin extends Plugin {
|
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({
|
this.addCommand({
|
||||||
id: 'note-to-mp-pub',
|
id: 'note-to-mp-pub',
|
||||||
name: '发布公众号文章',
|
name: '发布公众号文章',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Notice, TAbstractFile, TFile, Vault, MarkdownView, requestUrl, Platform
|
|||||||
import { Extension } from "./extension";
|
import { Extension } from "./extension";
|
||||||
import { NMPSettings } from "../settings";
|
import { NMPSettings } from "../settings";
|
||||||
import { IsImageLibReady, PrepareImageLib, WebpToJPG, UploadImageToWx } from "../imagelib";
|
import { IsImageLibReady, PrepareImageLib, WebpToJPG, UploadImageToWx } from "../imagelib";
|
||||||
|
import { convertJpegIfNeeded } from "../exif-orientation";
|
||||||
|
|
||||||
declare module 'obsidian' {
|
declare module 'obsidian' {
|
||||||
interface Vault {
|
interface Vault {
|
||||||
@@ -87,6 +88,26 @@ export class LocalImageManager {
|
|||||||
if (file == null) continue;
|
if (file == null) continue;
|
||||||
let fileData = await vault.readBinary(file);
|
let fileData = await vault.readBinary(file);
|
||||||
let name = file.name;
|
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 (this.isWebp(file)) {
|
||||||
if (IsImageLibReady()) {
|
if (IsImageLibReady()) {
|
||||||
fileData = WebpToJPG(fileData);
|
fileData = WebpToJPG(fileData);
|
||||||
@@ -199,6 +220,20 @@ export class LocalImageManager {
|
|||||||
filename = 'remote_img' + this.getImageExtFromBlob(blob);
|
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 (this.isWebp(filename)) {
|
||||||
if (IsImageLibReady()) {
|
if (IsImageLibReady()) {
|
||||||
data = WebpToJPG(data);
|
data = WebpToJPG(data);
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export class NoteToMpSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('启用空行渲染')
|
.setName('启用空行渲染')
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
@@ -345,6 +346,7 @@ export class NoteToMpSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Excalidraw 渲染为 PNG 图片')
|
.setName('Excalidraw 渲染为 PNG 图片')
|
||||||
|
.setDesc('开启:将 Excalidraw 笔记/嵌入转换为位图 PNG 插入;关闭:保持原始 SVG/矢量渲染(更清晰,体积更小)。')
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle.setValue(this.settings.excalidrawToPNG);
|
toggle.setValue(this.settings.excalidrawToPNG);
|
||||||
toggle.onChange(async (value) => {
|
toggle.onChange(async (value) => {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
*
|
*
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
* 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
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
* furnished to do so, subject to the following conditions:
|
* furnished to do so, subject to the following conditions:
|
||||||
@@ -51,6 +56,13 @@ export class NMPSettings {
|
|||||||
defaultCoverPic: string;
|
defaultCoverPic: string;
|
||||||
// 是否忽略 frontmatter 中的 cover/image 字段(用户要求:封面不使用 frontmatter image)
|
// 是否忽略 frontmatter 中的 cover/image 字段(用户要求:封面不使用 frontmatter image)
|
||||||
ignoreFrontmatterImage: boolean;
|
ignoreFrontmatterImage: boolean;
|
||||||
|
// 批量发布筛选条件预设
|
||||||
|
batchPublishPresets: Array<{
|
||||||
|
name: string;
|
||||||
|
tags?: string[];
|
||||||
|
folders?: string[];
|
||||||
|
filenameKeywords?: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
private static instance: NMPSettings;
|
private static instance: NMPSettings;
|
||||||
|
|
||||||
@@ -87,6 +99,15 @@ export class NMPSettings {
|
|||||||
// 默认封面:使用当前笔记同目录下的 cover.png (若存在会被后续流程正常解析;不存在则无效但可被用户覆盖)
|
// 默认封面:使用当前笔记同目录下的 cover.png (若存在会被后续流程正常解析;不存在则无效但可被用户覆盖)
|
||||||
this.defaultCoverPic = 'cover.png';
|
this.defaultCoverPic = 'cover.png';
|
||||||
this.ignoreFrontmatterImage = false;
|
this.ignoreFrontmatterImage = false;
|
||||||
|
// 批量发布预设
|
||||||
|
this.batchPublishPresets = [
|
||||||
|
{
|
||||||
|
name: '篆刻文章',
|
||||||
|
tags: ['篆刻'],
|
||||||
|
folders: [],
|
||||||
|
filenameKeywords: []
|
||||||
|
}
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStyelAndHighlight() {
|
resetStyelAndHighlight() {
|
||||||
@@ -121,6 +142,7 @@ export class NMPSettings {
|
|||||||
galleryNumPic,
|
galleryNumPic,
|
||||||
defaultCoverPic,
|
defaultCoverPic,
|
||||||
ignoreFrontmatterImage,
|
ignoreFrontmatterImage,
|
||||||
|
batchPublishPresets = [],
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const settings = NMPSettings.getInstance();
|
const settings = NMPSettings.getInstance();
|
||||||
|
|||||||
55
todo.list
55
todo.list
@@ -45,7 +45,6 @@ src可能使用link:
|
|||||||
![[2025ZK12-2.jpg]]
|
![[2025ZK12-2.jpg]]
|
||||||
✅
|
✅
|
||||||
|
|
||||||
|
|
||||||
4.
|
4.
|
||||||
参考以下代码,渲染[fig content/],|| content,||r content,||g content,||b content等标签:
|
参考以下代码,渲染[fig content/],|| content,||r content,||g content,||b content等标签:
|
||||||
`\[fig([^>]*?)/\]` `<span style="font-style: italic; font-size: 14px; background-color: #f5f5f5; padding: 2px;">$1</span>`
|
`\[fig([^>]*?)/\]` `<span style="font-style: italic; font-size: 14px; background-color: #f5f5f5; padding: 2px;">$1</span>`
|
||||||
@@ -82,4 +81,58 @@ content3
|
|||||||
|
|
||||||
8. 在h1前使用||h1 来增加修饰编号,01,02,03……
|
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,按“选取图片数”配置选取图片数量。
|
||||||
|
{{<gallery dir="/img/guanzhan/1" figcaption="毕业展" mppickall=1/>}}{{<load-photoswipe>}}
|
||||||
|
{{<gallery dir="/img/guanzhan/1"/> mppickall=1}}{{<load-photoswipe>}}
|
||||||
|
(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信息。
|
||||||
|
✅
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user