Harden the release branch for Obsidian market submission

Constraint: Keep the plugin release-safe without introducing new dependencies
Rejected: Commit the branch with mismatched version metadata and missing release verifier | that would leave the release flow broken
Confidence: high
Scope-risk: moderate
Directive: Keep package, manifest, and versions metadata aligned before cutting future release tags
Tested: npm run verify:release; npm run lint; npm run build
Not-tested: Real Obsidian desktop and mobile smoke tests on this branch
This commit is contained in:
Lemon695 2026-06-08 16:21:05 +08:00
parent 85f17e8a64
commit fe9dda8898
12 changed files with 166 additions and 331 deletions

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ data.json
/build.sh
/lint-result.txt
/docs/
/.omx/

View file

@ -84,7 +84,7 @@
There are three ways to open the image gallery:
1. **Command Palette**: Press `Ctrl/Cmd + P` and search for "Open Current Note Image Gallery"
1. **Command Palette**: Press `Ctrl/Cmd + P` and search for "Open gallery for current note"
2. **Ribbon Icon**: Click the image gallery icon in the left sidebar
3. **Hotkey**: Assign a custom hotkey in Settings → Hotkeys
@ -136,6 +136,16 @@ Access plugin settings via Settings → Note Image Gallery
---
## 🔒 Privacy and disclosures
- **No account required**: The plugin works without signing in to any service.
- **No telemetry or ads**: The plugin doesn't collect analytics or show advertisements.
- **Network use**: The plugin may send network requests only when your current note contains remote image URLs and you choose to open the gallery.
- **External file access**: The plugin reads images referenced by the current note and stores remote-image cache files inside the plugin data directory.
- **Open source**: The source code is available in this repository.
---
## 🎯 Performance Tips
1. **Enable Caching**: Keep image caching enabled for faster loading of remote images
@ -156,6 +166,9 @@ npm install
# Build for development (with watch mode)
npm run dev
# Run lint
npm run lint
# Build for production
npm run build
@ -169,7 +182,9 @@ npm run build
obsidian-note-image-gallery/
├── src/
│ ├── main.ts # Plugin entry point
│ ├── settings.ts # Settings tab and configuration
│ ├── settings.ts # Settings tab and configuration
│ ├── i18n/
│ │ └── locale.ts # Internationalization strings
│ ├── service/
│ │ ├── current-note-image-gallery-service.ts # Main gallery service
│ │ ├── image-cache-service.ts # Cache management

View file

@ -84,7 +84,7 @@
有三种方式打开图片墙:
1. **命令面板**:按 `Ctrl/Cmd + P` 并搜索"当前文件"或"Current file"
1. **命令面板**:按 `Ctrl/Cmd + P` 并搜索“打开当前笔记图片墙”或 “Open gallery for current note”
2. **侧边栏图标**:点击左侧边栏中的图片墙图标
3. **快捷键**:在设置 → 快捷键中自定义快捷键
@ -136,6 +136,16 @@
---
## 🔒 隐私与披露
- **无需账号**:插件不需要登录任何服务即可使用。
- **无遥测、无广告**:插件不会收集分析数据,也不会展示广告。
- **网络访问**:仅当当前笔记包含远程图片 URL 且你主动打开图片墙时,插件才会请求这些远程图片。
- **外部文件访问**:插件会读取当前笔记引用的图片,并把远程图片缓存写入插件数据目录。
- **开源**:源码已在本仓库公开。
---
## 🎯 性能优化建议
1. **启用缓存**:保持图片缓存开启以加快远程图片加载速度
@ -156,6 +166,9 @@ npm install
# 开发构建(带监听模式)
npm run dev
# 运行 Lint
npm run lint
# 生产构建
npm run build
@ -169,7 +182,7 @@ npm run build
obsidian-note-image-gallery/
├── src/
│ ├── main.ts # 插件入口
│ ├── settings.ts # 设置选项卡和配置
│ ├── settings.ts # 设置选项卡和配置
│ ├── i18n/
│ │ └── locale.ts # 国际化翻译
│ ├── service/

View file

@ -1,7 +1,7 @@
{
"id": "note-image-gallery",
"name": "Note Image Gallery",
"version": "1.0.11",
"version": "1.1.0",
"minAppVersion": "1.9.14",
"description": "Display all images from your note in a gallery view.",
"author": "Lemon695",

View file

@ -1,12 +1,13 @@
{
"name": "obsidian-note-image-gallery",
"version": "1.0.10",
"version": "1.1.0",
"description": "An Obsidian plugin to display note's images in a gallery view",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"verify:release": "node scripts/verify-release.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
@ -19,7 +20,7 @@
"note"
],
"author": "",
"license": "MIT",
"license": "GPL-3.0-only",
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",

111
scripts/verify-release.mjs Normal file
View file

@ -0,0 +1,111 @@
/* eslint-env node */
/* global console, process */
import { existsSync, readFileSync } from "fs";
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function readText(path) {
return readFileSync(path, "utf8");
}
const packageJson = readJson("package.json");
const manifest = readJson("manifest.json");
const versions = readJson("versions.json");
const readme = readText("README.md");
const readmeZh = readText("README_zh.md");
const license = readText("LICENSE");
const checks = [];
function check(ok, label, detail = "") {
checks.push({ ok, label, detail });
}
function includesAll(text, snippets) {
return snippets.every((snippet) => text.includes(snippet));
}
check(existsSync("README.md"), "README.md exists");
check(existsSync("README_zh.md"), "README_zh.md exists");
check(existsSync("LICENSE"), "LICENSE exists");
check(existsSync("manifest.json"), "manifest.json exists");
check(existsSync("package-lock.json"), "package-lock.json exists");
check(
packageJson.version === manifest.version,
"package.json version matches manifest.json",
`${packageJson.version} vs ${manifest.version}`
);
check(
versions[manifest.version] === manifest.minAppVersion,
"versions.json contains current release mapping",
`${manifest.version} -> ${versions[manifest.version] ?? "missing"}`
);
check(
/^\d+\.\d+\.\d+$/.test(manifest.version),
"manifest version uses x.y.z format",
manifest.version
);
check(
/^[a-z-]+$/.test(manifest.id) && !manifest.id.endsWith("plugin") && !manifest.id.includes("obsidian"),
"manifest id satisfies Obsidian rules",
manifest.id
);
check(
packageJson.license === "GPL-3.0-only",
"package.json license is aligned",
packageJson.license
);
check(
license.includes("GNU GENERAL PUBLIC LICENSE"),
"LICENSE content looks like GPL"
);
check(
includesAll(readme, [
"## 🔒 Privacy and disclosures",
"No account required",
"No telemetry or ads",
"Network use",
"External file access"
]),
"README.md contains disclosures"
);
check(
includesAll(readmeZh, [
"## 🔒 隐私与披露",
"无需账号",
"无遥测、无广告",
"网络访问",
"外部文件访问"
]),
"README_zh.md contains disclosures"
);
check(
!existsSync("main.js"),
"main.js is not committed in repo root"
);
const failed = checks.filter((item) => !item.ok);
for (const item of checks) {
const icon = item.ok ? "✅" : "❌";
const suffix = item.detail ? `${item.detail}` : "";
console.log(`${icon} ${item.label}${suffix}`);
}
if (failed.length > 0) {
console.error(`\nRelease verification failed: ${failed.length} issue(s) found.`);
process.exit(1);
}
console.log("\nRelease verification passed.");

View file

@ -3,12 +3,12 @@ import { getLanguage } from 'obsidian';
export const translations = {
'en-GB': {
// Command names
currentFile: 'Current file',
currentFile: 'Open gallery for current note',
clearImageCache: 'Clear image cache',
// Main plugin
loadingPlugin: 'Loading plugin v',
openGallery: 'Open gallery',
openGallery: 'Open image gallery',
imageCacheCleared: 'Image cache cleared',
noImagesFound: 'No images found in current note',
errorOpeningGallery: 'Error opening image gallery',
@ -25,7 +25,7 @@ export const translations = {
cacheSizeZeroOrInvalid: 'Cache size is 0 or invalid',
getCacheSizeFailed: 'Failed to get cache size:',
unableToGetCacheSize: 'Unable to get cache size, please try to reinitialize cache',
cacheStatus: 'Cache Status',
cacheStatus: 'Cache status',
currentCacheSize: 'Current cache size: {size} MB / {maxSize} MB',
maxCacheSize: 'Maximum cache size',
maxCacheSizeDesc: 'Maximum size for image cache: {size} MB',
@ -35,7 +35,7 @@ export const translations = {
clearCache: 'Clear cache',
clearCacheDesc: 'Delete all cached images',
clearAllCache: 'Clear all cache',
developer: 'Developer',
developer: 'Developer tools',
debugMode: 'Debug mode',
debugModeDesc: 'Enable debug mode to log detailed information to the console.',
debugModeStatus: 'Debug mode {status}',
@ -73,12 +73,12 @@ export const translations = {
},
'zh': {
// Command names
currentFile: '当前文件',
currentFile: '打开当前笔记图片墙',
clearImageCache: '清除图片缓存',
// Main plugin
loadingPlugin: '加载插件 v',
openGallery: '打开图片',
openGallery: '打开图片画廊',
imageCacheCleared: '图片缓存已清除',
noImagesFound: '当前笔记中未找到图片',
errorOpeningGallery: '打开图片墙时出错',
@ -105,7 +105,7 @@ export const translations = {
clearCache: '清除缓存',
clearCacheDesc: '删除所有缓存的图片',
clearAllCache: '清除全部缓存',
developer: '开发者',
developer: '开发者工具',
debugMode: '调试模式',
debugModeDesc: '启用调试模式以在控制台中记录详细信息。',
debugModeStatus: '调试模式已{status}',
@ -162,15 +162,3 @@ export function t(key: keyof typeof translations["en-GB"], params?: Record<strin
}
return text;
}
/**
*
*/
export function debugLocale(): void {
console.debug('=== Locale Debug Info ===');
console.debug('Current locale:', getLocale());
console.debug('localStorage language:', window.localStorage.getItem('language'));
console.debug('navigator.language:', navigator.language);
console.debug('moment locale:', (window as unknown as Record<string, unknown>).moment);
console.debug('========================');
}

View file

@ -5,17 +5,6 @@ import {RetryHandler} from "../utils/retry-handler";
import {ResourceManager} from "../utils/resource-manager";
import {t} from "../i18n/locale";
// 定义Electron请求接口用于向后兼容
interface ElectronRequest {
abort(): void;
}
interface ImageRequest {
controller?: AbortController;
electronRequest?: ElectronRequest;
timestamp: number;
}
interface ImageData {
path: string;
element: HTMLElement;
@ -40,7 +29,6 @@ export class CurrentNoteImageGalleryService extends Modal {
private images: string[] = [];
private loadedImages = 0;
private totalImages = 0;
private currentRequests: Map<string, ImageRequest> = new Map();
private imageDataMap: Map<string, ImageData> = new Map();
private queueImageLoad: (imagePath: string, isVisible?: boolean) => void = () => {
};
@ -69,7 +57,6 @@ export class CurrentNoteImageGalleryService extends Modal {
onOpen() {
this.loadedImages = 0;
this.currentRequests.clear();
this.imageDataMap.clear();
const {contentEl} = this;
@ -521,40 +508,6 @@ export class CurrentNoteImageGalleryService extends Modal {
}
}
private createImageElement(imagePath: string, imageWall: HTMLElement) {
const imageDiv = imageWall.createDiv('nig-image-item');
imageDiv.setAttribute('data-path', imagePath);
// 存储图片元素引用
this.imageDataMap.set(imagePath, {
path: imagePath,
element: imageDiv,
isLoading: false,
hasError: false
});
// 监听此元素以实现懒加载
this.intersectionObserver?.observe(imageDiv);
// 添加点击事件用于查看大图
imageDiv.addEventListener('click', (e) => {
// 阻止事件冒泡,避免与其他插件(如 Image Toolkit冲突
e.stopPropagation();
e.preventDefault();
const currentIndex = this.images.indexOf(imagePath);
this.createLightboxWithNavigation(currentIndex);
});
imageDiv.addEventListener('contextmenu', (e) => {
e.preventDefault();
const img = imageDiv.querySelector('img');
if (img) {
this.createContextMenu(e, img);
}
});
}
/**
* 线
*/
@ -1323,185 +1276,6 @@ export class CurrentNoteImageGalleryService extends Modal {
}
}
private async loadWeiboImage(
imagePath: string,
img: HTMLImageElement,
imageDiv: HTMLElement,
loadingText: HTMLElement,
retryCount = 0
): Promise<void> {
try {
// 异步获取缓存
const cachedImage = await this.plugin.imageCacheService.getCachedImage(imagePath);
if (cachedImage) {
log.debug(() => `Loading Weibo image from cache: ${imagePath}`);
await new Promise<void>((resolve, reject) => {
img.onload = () => {
this.handleImageLoadSuccess(img, imageDiv, loadingText, imagePath);
resolve();
};
img.onerror = (e) => {
const error = e instanceof Error ? e : new Error('Cached Weibo image load error');
log.error(() => `Cached Weibo image load error`, error);
reject(error);
};
// 设置图片源为缓存的base64数据
img.src = cachedImage.data;
}).catch(async (e) => {
await this.loadWeiboImageWithoutCache(imagePath, img, imageDiv, loadingText, retryCount, () => {}, () => {});
});
return;
}
await this.loadWeiboImageWithoutCache(imagePath, img, imageDiv, loadingText, retryCount, () => {}, () => {});
} catch (error) {
const currentRequestId = imageDiv.getAttribute('data-request-id');
this.handleError(error instanceof Error ? error : new Error(String(error)), imageDiv, currentRequestId || undefined, retryCount);
throw error;
}
}
private async loadWeiboImageWithoutCache(
imagePath: string,
img: HTMLImageElement,
imageDiv: HTMLElement,
loadingText: HTMLElement,
retryCount: number,
resolve: () => void,
reject: (error: unknown) => void
): Promise<void> {
// 使用 Obsidian 的 requestUrl API 代替 electron.remote
const MAX_RETRIES = 3;
try {
log.debug(() => `Loading Weibo image via requestUrl (no cache): ${imagePath}`);
const response = await requestUrl({
url: imagePath,
method: 'GET',
headers: {
'Referer': 'https://weibo.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
if (response.status !== 200) {
throw new Error(`HTTP Error: ${response.status}`);
}
const arrayBuffer = response.arrayBuffer;
const contentType = response.headers['content-type'] || 'image/jpeg';
const blob = new Blob([arrayBuffer], {type: contentType});
// 使用 ResourceManager 管理 objectURL
const objectUrl = this.resourceManager.createObjectURL(imagePath, blob);
img.onload = () => {
if (loadingText && loadingText.parentNode) {
loadingText.remove();
}
// 移除占位符
const placeholder = imageDiv.querySelector('.nig-image-placeholder');
if (placeholder) {
placeholder.remove();
}
const ratio = img.naturalHeight / img.naturalWidth;
const baseHeight = 10;
const heightSpan = Math.min(Math.ceil(ratio * baseHeight), 30);
imageDiv.setCssStyles({ gridRowEnd: `span ${heightSpan}` });
img.setCssStyles({ opacity: '1' });
this.loadedImages++;
this.updateProgressBar();
const imageData = this.imageDataMap.get(imagePath);
if (imageData) {
imageData.isLoading = false;
}
resolve();
};
img.onerror = async () => {
if (retryCount < MAX_RETRIES) {
log.debug(() => `Retrying Weibo image load (${retryCount + 1}/${MAX_RETRIES}): ${imagePath}`);
await this.loadWeiboImage(imagePath, img, imageDiv, loadingText, retryCount + 1);
resolve();
} else {
this.handleImageLoadFailure(imageDiv, t('loadingFailed'));
reject(new Error('Max retries reached'));
}
};
img.src = objectUrl;
const imageData = this.imageDataMap.get(imagePath);
if (imageData) {
imageData.objectUrl = objectUrl;
}
// 尝试缓存图片
try {
await this.plugin.imageCacheService.cacheImage(
imagePath,
arrayBuffer,
undefined,
contentType
);
log.debug(() => `Weibo image cached: ${imagePath}`);
} catch (cacheError) {
log.error(() => `Failed to cache Weibo image: ${imagePath}`, cacheError instanceof Error ? cacheError : undefined);
}
} catch (error) {
log.error(() => `requestUrl failed to load Weibo image: ${imagePath}`, error instanceof Error ? error : undefined);
const imageData = this.imageDataMap.get(imagePath);
if (imageData) {
imageData.isLoading = false;
}
if (retryCount < MAX_RETRIES) {
log.debug(() => `Retrying after request failure (${retryCount + 1}/${MAX_RETRIES}): ${imagePath}`);
window.setTimeout(() => {
this.loadWeiboImage(imagePath, img, imageDiv, loadingText, retryCount + 1)
.then(resolve)
.catch(reject);
}, 1000 * (retryCount + 1));
} else {
this.handleImageLoadFailure(imageDiv, t('loadingFailed'));
reject(error);
}
}
}
private handleError(error: Error, imageDiv: HTMLElement, requestId: string | undefined, retryCount: number) {
const MAX_RETRIES = 3;
log.error(() => 'Error loading Weibo image:', error instanceof Error ? error : undefined);
if (requestId) {
this.currentRequests.delete(requestId);
}
if (retryCount < MAX_RETRIES) {
log.debug(() => `Retrying after error (${retryCount + 1}/${MAX_RETRIES})`);
window.setTimeout(() => {
const img = imageDiv.querySelector('img');
const loadingText = imageDiv.querySelector('.nig-loading-text');
if (img && loadingText) {
const imgSrc = img.src;
void this.loadWeiboImage(imgSrc, img, imageDiv, loadingText as HTMLElement, retryCount + 1);
}
}, 1000 * (retryCount + 1));
} else {
this.handleImageLoadFailure(imageDiv, t('loadingFailed'));
}
}
private updateProgressBar() {
const progressEl = this.contentEl.querySelector('progress');
const progressText = this.contentEl.querySelector('.nig-progress-text');
@ -1569,16 +1343,6 @@ export class CurrentNoteImageGalleryService extends Modal {
return dest.path;
}
// 如果是文件名(没有路径),尝试在库中查找
if (!cleanLink.includes('/')) {
const allFiles = this.app.vault.getFiles();
const matchedFiles = allFiles.filter(f => f.name === cleanLink);
if (matchedFiles.length > 0) {
log.debug(() => `Found by filename: ${matchedFiles[0].path}`);
return matchedFiles[0].path;
}
}
// 如果都失败了尝试添加_resources前缀
if (!cleanLink.startsWith('_resources/')) {
const resourcePath = `_resources/${cleanLink}`;
@ -2048,19 +1812,6 @@ export class CurrentNoteImageGalleryService extends Modal {
onClose() {
this.isClosed = true;
this.currentRequests.forEach((request) => {
try {
if (request.controller) {
request.controller.abort();
} else if (request.electronRequest) {
request.electronRequest.abort();
}
} catch (e) {
log.error(() => 'Error aborting request:', e instanceof Error ? e : undefined);
}
});
this.currentRequests.clear();
this.resourceManager.revokeAll();
this.imageDataMap.clear();

View file

@ -41,7 +41,6 @@ export class ImageCacheService {
constructor(app: App, plugin: Plugin) {
this.app = app;
this.pluginDir = plugin.manifest.dir!;
void this.loadCacheIndex();
}
/**

View file

@ -42,14 +42,18 @@ class HybridImageExtractor implements ImageExtractor {
* ![[]]
*/
class WikiImageExtractor implements ImageExtractor {
// 修改正则:不匹配后面紧跟 ]( 的情况(混合格式)
private readonly regex = /!\[\[(.*?)]](?!\()/g;
private readonly regex = /!\[\[(.*?)]]/g;
extract(content: string): string[] {
const images: string[] = [];
let match;
while ((match = this.regex.exec(content)) !== null) {
const nextChar = content[match.index + match[0].length];
if (nextChar === '(') {
continue;
}
if (match[1]) {
const imagePath = this.cleanImagePath(match[1]);
if (imagePath) {
@ -79,8 +83,7 @@ class WikiImageExtractor implements ImageExtractor {
* ![alt](image.jpg)
*/
class MarkdownImageExtractor implements ImageExtractor {
// 修改正则:不匹配前面是 ]] 的情况(混合格式)
private readonly regex = /(?<!]])!\[.*?\]\((.*?)\)/g;
private readonly regex = /!\[.*?]\((.*?)\)/g;
extract(content: string): string[] {
const images: string[] = [];
@ -111,43 +114,6 @@ class MarkdownImageExtractor implements ImageExtractor {
}
}
/**
*
* ![](image.jpg)
*/
class SimpleImageExtractor implements ImageExtractor {
private readonly regex = /!\[]\((.*?)\)/g;
extract(content: string): string[] {
const images: string[] = [];
let match;
while ((match = this.regex.exec(content)) !== null) {
if (match[1]) {
const imagePath = this.processImagePath(match[1]);
if (imagePath) {
images.push(imagePath);
}
}
}
return images;
}
private processImagePath(path: string): string {
const trimmedPath = path.trim();
// 移除路径中的引号(如果存在)
const cleanPath = trimmedPath.replace(/['"]/g, '');
// 过滤掉 markdown 文件
if (cleanPath.toLowerCase().endsWith('.md')) {
return '';
}
return cleanPath;
}
}
export class ImageExtractorService {
private readonly extractors: ImageExtractor[];
@ -157,8 +123,7 @@ export class ImageExtractorService {
this.extractors = [
new HybridImageExtractor(),
new WikiImageExtractor(),
new MarkdownImageExtractor(),
new SimpleImageExtractor()
new MarkdownImageExtractor()
];
}

View file

@ -90,17 +90,6 @@ export class ObsidianImageLoader {
result.push(`${activeFile.parent.path}/_attachments/${filename}`);
result.push(`${activeFile.parent.path}/attachments/${filename}`);
}
const matchingFiles = this.app.vault.getFiles().filter(f => f.name === filename);
for (const file of matchingFiles) {
result.push(file.path);
}
}
const allFiles = this.app.vault.getFiles();
const matchingPathFiles = allFiles.filter(f => f.path.includes(originalPath));
for (const file of matchingPathFiles) {
result.push(file.path);
}
return [...new Set(result)];

View file

@ -2,5 +2,7 @@
"1.0.0": "0.15.0",
"1.0.1": "1.8.4",
"1.0.2": "1.8.4",
"1.0.3": "1.8.4"
"1.0.3": "1.8.4",
"1.0.11": "1.9.14",
"1.1.0": "1.9.14"
}