Skip to content

Commit 7452112

Browse files
authored
feat: support extract images (#67)
* feat: support extract images * feat(extract-images): allow optionally extract local or web images * feat(extract-images): allow automatically upload before save if configured * docs: add introduction about extract images
1 parent 19b0c5b commit 7452112

File tree

8 files changed

+337
-3
lines changed

8 files changed

+337
-3
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- [图片上传](#图片上传)
1717
- [博文分类管理](#博文分类管理)
1818
- [导出 pdf](#导出-pdf)
19+
- [提取图片](#提取图片)
1920
- [博文设置面板](#博文设置面板)
2021
- [vscode 版本要求](#vscode-版本要求)
2122
- [插件设置](#插件设置)
@@ -106,6 +107,22 @@
106107

107108
<kbd><img src="https://img2022.cnblogs.com/blog/35695/202209/35695-20220907203200119-1667606464.png"></kbd>
108109

110+
### 提取图片
111+
112+
你可能会在markdown文件中使用本地的相对路径的图片, 将这样的markdown发布到博客园会导致图片无法正常展示, 为此我们提供了 `提取图片` 功能, 你可以通过编辑器的上下文菜单调用此功能
113+
114+
![image](https://img2022.cnblogs.com/blog/1596066/202209/1596066-20220917215536822-836105648.png)
115+
116+
也可以在设置中配置保存到博客园时自动提取图片
117+
118+
![image](https://img2022.cnblogs.com/blog/1596066/202209/1596066-20220917215650930-372126612.png)
119+
120+
此功能除了可以提取本地图片, 也可以提取其他承载在第三方图床中的图片
121+
122+
![image](https://img2022.cnblogs.com/blog/1596066/202209/1596066-20220917215802986-44248462.png)
123+
124+
此功能会上传图片到博客园然后替换源markdown文件中的图片链接
125+
109126
### 博文设置面板
110127

111128
首次发布本地 markdown 文件到博客园时,会打开博文设置面板允许编辑博文相关的设置

package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@
226226
"title": "导出pdf",
227227
"category": "Cnblogs",
228228
"enablement": "vscode-cnb.isAuthorized"
229+
},
230+
{
231+
"command": "vscode-cnb.extract-images",
232+
"title": "提取图片",
233+
"category": "Cnblogs",
234+
"enablement": "vscode-cnb.isAuthorized"
229235
}
230236
],
231237
"configuration": [
@@ -262,6 +268,25 @@
262268
"scope": "application",
263269
"type": "boolean",
264270
"markdownDescription": "设置是否根据博文分类保存到不同的文件夹中"
271+
},
272+
"cnblogsClientForVSCode.automaticallyExtractImages": {
273+
"order": 4,
274+
"default": "---",
275+
"scope": "application",
276+
"enum": [
277+
"---",
278+
"local",
279+
"web",
280+
"all"
281+
],
282+
"enumItemLabels": [
283+
"不自动提取图片",
284+
"自动提取本地图片",
285+
"自动提取网络图片",
286+
"自动提取全部图片"
287+
],
288+
"editPresentation": "singlelineText",
289+
"description": "配置保存到博客园时要自动进行提取上传到博客园的图片"
265290
}
266291
}
267292
}
@@ -583,6 +608,11 @@
583608
"command": "vscode-cnb.export-post-to-pdf",
584609
"when": "resourceLangId == markdown",
585610
"group": "cnblogs@8"
611+
},
612+
{
613+
"command": "vscode-cnb.extract-images",
614+
"when": "resourceLangId == markdown",
615+
"group": "cnblogs@9"
586616
}
587617
],
588618
"editor/title": [

src/commands/commands-registration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { revealWorkspaceInOs } from './reveal-workspace-in-os';
3333
import { viewPostOnline } from './view-post-online';
3434
import { exportPostToPdf } from './pdf/export-pdf.command';
3535
import { pullPostRemoteUpdates } from './pull-post-remote-updates';
36+
import { extractImages } from './extract-images';
3637

3738
export const registerCommands = () => {
3839
const context = globalState.extensionContext;
@@ -74,6 +75,7 @@ export const registerCommands = () => {
7475
commands.registerCommand(`${appName}.reveal-workspace-in-os`, revealWorkspaceInOs),
7576
commands.registerCommand(`${appName}.view-post-online`, viewPostOnline),
7677
commands.registerCommand(`${appName}.export-post-to-pdf`, exportPostToPdf),
78+
commands.registerCommand(`${appName}.extract-images`, extractImages),
7779
];
7880
context?.subscriptions.push(...disposables);
7981
};

src/commands/extract-images.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Uri, workspace, window, MessageOptions, MessageItem, ProgressLocation, Range } from 'vscode';
2+
import { MarkdownImage, MarkdownImagesExtractor } from '../services/images-extractor.service';
3+
4+
export const extractImages = async (
5+
arg: unknown,
6+
inputImageType: MarkdownImagesExtractor['imageType'] | null | undefined
7+
) => {
8+
if (arg instanceof Uri && arg.scheme === 'file') {
9+
const markdown = new TextDecoder().decode(await workspace.fs.readFile(arg));
10+
const extractor = new MarkdownImagesExtractor(markdown, arg);
11+
const images = extractor.findImages();
12+
const availableWebImagesCount = images.filter(extractor.createImageTypeFilter('web')).length;
13+
const availableLocalImagesCount = images.filter(extractor.createImageTypeFilter('local')).length;
14+
if (images.length <= 0) {
15+
await window.showWarningMessage('没有可以提取的图片');
16+
return;
17+
}
18+
const messageItems: (MessageItem & Partial<Pick<MarkdownImagesExtractor, 'imageType'>>)[] = [
19+
{ title: '提取本地图片', imageType: 'local' },
20+
{ title: '提取网络图片', imageType: 'web' },
21+
{ title: '提取全部', imageType: 'all' },
22+
{ title: '取消', imageType: undefined, isCloseAffordance: true },
23+
];
24+
let result = messageItems.find(x => inputImageType != null && x.imageType === inputImageType);
25+
result = result
26+
? result
27+
: await window.showInformationMessage<typeof messageItems extends [...infer U] ? U[0] : never>(
28+
'请选择要提取哪些图片? 注意! 此操作会替换源文件中的图片链接!',
29+
{
30+
modal: true,
31+
detail:
32+
`共找到 ${availableWebImagesCount} 张可以提取的网络图片\n` +
33+
`${availableLocalImagesCount} 张可以提取的本地图片`,
34+
} as MessageOptions,
35+
...messageItems
36+
);
37+
const editor = window.visibleTextEditors.find(x => x.document.fileName === arg.fsPath);
38+
39+
if (result && result.imageType && editor) {
40+
extractor.imageType = result.imageType;
41+
if (extractor.findImages().length <= 0) {
42+
await window.showWarningMessage('没有可以提取的图片');
43+
return;
44+
}
45+
const document = editor.document;
46+
await document.save();
47+
const failedImages = await window.withProgress(
48+
{ title: '提取图片', location: ProgressLocation.Notification },
49+
async progress => {
50+
extractor.onProgress = (idx, images) => {
51+
const total = images.length;
52+
const image = images[idx];
53+
progress.report({
54+
increment: (idx / total) * 80,
55+
message: `[${idx + 1} / ${total}] 正在提取 ${image.symbol}`,
56+
});
57+
};
58+
const extractResults = await extractor.extract();
59+
let idx = 0;
60+
const total = extractResults.length;
61+
await editor.edit(editBuilder => {
62+
for (let [range, , extractedImage] of extractResults
63+
.filter((x): x is [source: MarkdownImage, result: MarkdownImage] => x[1] != null)
64+
.map(
65+
([sourceImage, result]): [
66+
range: Range | null,
67+
sourceImage: MarkdownImage,
68+
extractedImage: MarkdownImage
69+
] => {
70+
if (sourceImage.index == null) {
71+
return [null, sourceImage, result];
72+
}
73+
74+
const endPos = document.positionAt(
75+
sourceImage.index + sourceImage.symbol.length - 1
76+
);
77+
return [
78+
new Range(
79+
document.positionAt(sourceImage.index),
80+
endPos.with({ character: endPos.character + 1 })
81+
),
82+
sourceImage,
83+
result,
84+
];
85+
}
86+
)) {
87+
if (range == null) {
88+
continue;
89+
}
90+
progress.report({
91+
increment: (idx / total) * 20 + 80,
92+
message: `[${idx + 1} / ${total}] 执行替换 ${extractedImage.symbol}`,
93+
});
94+
95+
editBuilder.replace(range, extractedImage.symbol);
96+
}
97+
});
98+
await document.save();
99+
return extractResults.filter(x => x[1] === null).map(x => x[0]);
100+
}
101+
);
102+
if (failedImages && failedImages.length > 0) {
103+
await window.showErrorMessage(
104+
`${failedImages.length}张图片提取失败\n${failedImages
105+
.map(x => [x.symbol, extractor.errors.find(y => y[0] === x.symbol)?.[1] ?? ''].join(': '))
106+
.join('\n')}`
107+
);
108+
}
109+
}
110+
}
111+
};

src/commands/posts-list/save-post.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { PostEditDto } from '../../models/post-edit-dto';
1414
import { PostTitleSanitizer } from '../../services/post-title-sanitizer.service';
1515
import { postConfigurationPanel } from '../../services/post-configuration-panel.service';
1616
import { saveFilePendingChanges } from '../../utils/save-file-pending-changes';
17+
import { extractImages } from '../extract-images';
18+
import { Settings } from '../../services/settings.service';
1719

1820
const parseFileUri = async (fileUri: Uri | undefined): Promise<Uri | undefined> => {
1921
if (fileUri && fileUri.scheme !== 'file') {
@@ -104,9 +106,13 @@ export const saveLocalDraftToCnblogs = async (localDraft: LocalFileService) => {
104106
post,
105107
successCallback: async savedPost => {
106108
await refreshPostsList();
109+
await openPostFile(localDraft);
110+
if (Settings.automaticallyExtractImagesType) {
111+
await extractImages(localDraft.filePathUri, Settings.automaticallyExtractImagesType);
112+
}
107113
await PostFileMapManager.updateOrCreate(savedPost.id, localDraft.filePath);
108-
postsDataProvider.fireTreeDataChangedEvent(undefined);
109114
await openPostFile(localDraft);
115+
postsDataProvider.fireTreeDataChangedEvent(undefined);
110116
AlertService.info('博文已创建');
111117
},
112118
beforeUpdate: async (postToSave, panel) => {
@@ -162,6 +168,9 @@ export const savePostToCnblogs = async (input: Post | PostEditDto | undefined, i
162168
});
163169
let success = false;
164170
try {
171+
if (Settings.automaticallyExtractImagesType && localFilePath) {
172+
await extractImages(Uri.file(localFilePath), Settings.automaticallyExtractImagesType);
173+
}
165174
let { id: postId } = await postService.updatePost(post);
166175
if (!isNewPost) {
167176
await openPostInVscode(postId);

src/services/image.service.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import fetch from 'node-fetch';
2+
import FormData from 'form-data';
23
import { accountService } from './account.service';
34
import { globalState } from './global-state';
4-
import FormData from 'form-data';
55
import { throwIfNotOkResponse } from '../utils/throw-if-not-ok-response';
6+
import { Stream } from 'stream';
7+
import mime from 'mime';
68

79
export class ImageService {
810
private static _instance: ImageService;
@@ -18,7 +20,10 @@ export class ImageService {
1820

1921
async upload(file: any): Promise<string> {
2022
const form = new FormData();
21-
form.append('image', file, file.name);
23+
form.append('image', file, {
24+
filename: file.name ?? 'image.png',
25+
contentType: 'image/png',
26+
});
2227
const response = await fetch(`${globalState.config.apiBaseUrl}/api/posts/body/images`, {
2328
method: 'POST',
2429
headers: [accountService.buildBearerAuthorizationHeader()],
@@ -27,6 +32,22 @@ export class ImageService {
2732
await throwIfNotOkResponse(response);
2833
return await response.text();
2934
}
35+
36+
async download(
37+
link: string,
38+
fileNameWithoutExtension?: string
39+
): Promise<Stream | [statusCode: number, statusText: string, responseBody: string]> {
40+
const response = await fetch(link, {
41+
method: 'get',
42+
});
43+
const contentType = response.headers.get('content-type') ?? 'image/png';
44+
fileNameWithoutExtension = !fileNameWithoutExtension ? 'image' : fileNameWithoutExtension;
45+
return response.ok && response.body != null
46+
? Object.assign(Stream.Readable.from(await response.buffer()), {
47+
path: fileNameWithoutExtension + '.' + mime.extension(contentType),
48+
})
49+
: [response.status, response.statusText, await response.text()];
50+
}
3051
}
3152

3253
export const imageService = ImageService.instance;

0 commit comments

Comments
 (0)