import { App, Modal, Notice, Plugin, TFile } from "obsidian"; const 插件前缀 = "[image-stripper]"; const 图片扩展名集合 = new Set([ "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tif", "tiff", "avif" ]); function 是否外链目标(target: string): boolean { const t = target.trim().toLowerCase(); return ( t.startsWith("http://") || t.startsWith("https://") || t.startsWith("data:") || t.startsWith("ftp://") ); } function 提取扩展名(pathOrName: string): string | null { const cleaned = pathOrName.trim().split(/[?#]/, 1)[0]; const idx = cleaned.lastIndexOf("."); if (idx === -1) return null; const ext = cleaned.slice(idx + 1).toLowerCase(); return ext || null; } function 标准化Markdown图片目标(raw: string): string { // Markdown 允许形如:![](path "title") 或 ![]() let t = raw.trim(); if (t.startsWith("<") && t.endsWith(">")) { t = t.slice(1, -1).trim(); } // 取第一个空白前的部分,忽略 title const firstPart = t.split(/\s+/, 1)[0] ?? ""; // 去掉可能的引号 return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim(); } function 收敛空行(content: string): string { return content.replace(/\n{3,}/g, "\n\n"); } function 解析Markdown链接目标(inner: string): string { // 形如:path "title" 或 "title" let t = inner.trim(); if (!t) return ""; if (t.startsWith("<")) { const endIdx = t.indexOf(">"); if (endIdx > 1) { return t.slice(1, endIdx).trim(); } } // 非尖括号形式:取第一个空白前的部分(忽略 title) const firstPart = t.split(/\s+/, 1)[0] ?? ""; return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim(); } type Markdown图片清理结果 = { 新内容: string; 移除引用次数: number; 候选目标列表: string[]; }; type Markdown图片过滤结果 = { 新内容: string; 移除引用次数: number; 候选目标列表: string[]; }; type 清理选项 = { shouldRemoveTarget: (target: string) => boolean; }; function 清理Markdown图片引用(content: string): Markdown图片清理结果 { const 候选目标列表: string[] = []; let 移除引用次数 = 0; let i = 0; let out = ""; while (i < content.length) { const bangIdx = content.indexOf("![", i); if (bangIdx === -1) { out += content.slice(i); break; } out += content.slice(i, bangIdx); // 找到 alt 结束的 ']' const altEndIdx = content.indexOf("]", bangIdx + 2); if (altEndIdx === -1) { out += content.slice(bangIdx); break; } // 必须紧跟 '(' 才算 Markdown 图片链接 const openParenIdx = altEndIdx + 1; if (content[openParenIdx] !== "(") { out += content.slice(bangIdx, openParenIdx); i = openParenIdx; continue; } // 扫描匹配成对括号,支持 URL / title 中出现括号 let depth = 0; let j = openParenIdx; let isEscaped = false; for (; j < content.length; j += 1) { const ch = content[j]; if (isEscaped) { isEscaped = false; continue; } if (ch === "\\") { isEscaped = true; continue; } if (ch === "(") { depth += 1; continue; } if (ch === ")") { depth -= 1; if (depth === 0) { break; } } } if (depth !== 0 || j >= content.length) { // 括号不匹配,保守:不删除 console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`); out += content.slice(bangIdx, openParenIdx + 1); i = openParenIdx + 1; continue; } const inner = content.slice(openParenIdx + 1, j); const target = 解析Markdown链接目标(inner); if (target) 候选目标列表.push(target); 移除引用次数 += 1; // 跳过整个 ![...](...) 片段 i = j + 1; } return { 新内容: out, 移除引用次数, 候选目标列表 }; } function 过滤Markdown图片引用(content: string, options: 清理选项): Markdown图片过滤结果 { const 候选目标列表: string[] = []; let 移除引用次数 = 0; let i = 0; let out = ""; while (i < content.length) { const bangIdx = content.indexOf("![", i); if (bangIdx === -1) { out += content.slice(i); break; } out += content.slice(i, bangIdx); const altEndIdx = content.indexOf("]", bangIdx + 2); if (altEndIdx === -1) { out += content.slice(bangIdx); break; } const openParenIdx = altEndIdx + 1; if (content[openParenIdx] !== "(") { out += content.slice(bangIdx, openParenIdx); i = openParenIdx; continue; } let depth = 0; let j = openParenIdx; let isEscaped = false; for (; j < content.length; j += 1) { const ch = content[j]; if (isEscaped) { isEscaped = false; continue; } if (ch === "\\") { isEscaped = true; continue; } if (ch === "(") { depth += 1; continue; } if (ch === ")") { depth -= 1; if (depth === 0) { break; } } } if (depth !== 0 || j >= content.length) { console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`); out += content.slice(bangIdx, openParenIdx + 1); i = openParenIdx + 1; continue; } const inner = content.slice(openParenIdx + 1, j); const target = 解析Markdown链接目标(inner); if (target) 候选目标列表.push(target); if (target && options.shouldRemoveTarget(target)) { 移除引用次数 += 1; i = j + 1; continue; } out += content.slice(bangIdx, j + 1); i = j + 1; } return { 新内容: out, 移除引用次数, 候选目标列表 }; } type 历史残留清理结果 = { 新内容: string; 移除残留次数: number; }; function 清理历史残留Svg尾巴(content: string): 历史残留清理结果 { // 针对旧实现可能留下的:以单引号开头、包含 URL 编码 SVG、并以 ") 结束" 的残留片段 // 示例:'%20fill='%23FFFFFF'...%3C/svg%3E) const svgTailRe = /(^|\n)\s*'[^ \n]*%3C\/svg%3E\)\s*(?=\n|$)/gi; let 移除残留次数 = 0; const 新内容 = content.replace(svgTailRe, (m: string, leadingNewline: string) => { 移除残留次数 += 1; return leadingNewline ? "\n" : ""; }); return { 新内容, 移除残留次数 }; } type 清理结果 = { 新内容: string; 移除引用次数: number; 候选目标列表: string[]; 移除残留次数: number; }; function 清理图片引用(content: string): 清理结果 { const mdResult = 清理Markdown图片引用(content); let 新内容 = mdResult.新内容; let 移除引用次数 = mdResult.移除引用次数; const 候选目标列表: string[] = [...mdResult.候选目标列表]; // Obsidian 内嵌:![[xxx.png]] / ![[xxx.png|100]] const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g; 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => { const link = String(linkGroup ?? "").trim(); if (link) { 候选目标列表.push(link); } 移除引用次数 += 1; return ""; }); const tailResult = 清理历史残留Svg尾巴(新内容); 新内容 = tailResult.新内容; 新内容 = 收敛空行(新内容); return { 新内容, 移除引用次数, 候选目标列表, 移除残留次数: tailResult.移除残留次数 }; } type 选择性清理结果 = { 新内容: string; 移除引用次数: number; 候选目标列表: string[]; 移除残留次数: number; }; function 提取图片引用目标列表(content: string): string[] { const mdResult = 过滤Markdown图片引用(content, { shouldRemoveTarget: () => false }); const 目标列表: string[] = [...mdResult.候选目标列表]; const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g; let match: RegExpExecArray | null = null; while ((match = wikiEmbedRe.exec(content)) !== null) { const link = String(match[1] ?? "").trim(); if (link) 目标列表.push(link); } return 目标列表; } function 清理图片引用_按目标过滤(content: string, options: 清理选项): 选择性清理结果 { const mdResult = 过滤Markdown图片引用(content, options); let 新内容 = mdResult.新内容; let 移除引用次数 = mdResult.移除引用次数; const 候选目标列表: string[] = [...mdResult.候选目标列表]; const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g; 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => { const link = String(linkGroup ?? "").trim(); if (link) { 候选目标列表.push(link); } if (link && options.shouldRemoveTarget(link)) { 移除引用次数 += 1; return ""; } return fullMatch; }); const tailResult = 清理历史残留Svg尾巴(新内容); 新内容 = tailResult.新内容; 新内容 = 收敛空行(新内容); return { 新内容, 移除引用次数, 候选目标列表, 移除残留次数: tailResult.移除残留次数 }; } function 从反链对象提取引用路径列表(backlinks: unknown): string[] { // 兼容不同 Obsidian 版本/类型:可能是 { data: { [path]: ... } } 或直接 { [path]: ... } if (!backlinks || typeof backlinks !== "object") return []; const asAny = backlinks as any; const data = asAny.data && typeof asAny.data === "object" ? asAny.data : asAny; if (!data || typeof data !== "object") return []; return Object.keys(data); } function 获取文件反链对象(app: App, file: TFile): unknown { const metadataCache = app.metadataCache as unknown as { getBacklinksForFile?: (targetFile: TFile) => unknown; }; if (typeof metadataCache.getBacklinksForFile === "function") { return metadataCache.getBacklinksForFile(file); } console.debug( `${插件前缀} 当前 Obsidian 版本未提供 getBacklinksForFile,跳过反链检查:file=${file.path}` ); return null; } async function 获取图片文件(app: App, activeFile: TFile, rawTarget: string): Promise { const target = rawTarget.trim(); if (!target) return null; if (是否外链目标(target)) return null; const ext = 提取扩展名(target); if (!ext || !图片扩展名集合.has(ext)) return null; let linkpath = target; try { linkpath = decodeURI(target); } catch { // ignore } const file = app.metadataCache.getFirstLinkpathDest(linkpath, activeFile.path); if (!file) return null; if (!(file instanceof TFile)) return null; const fileExt = (file.extension || "").toLowerCase(); if (!图片扩展名集合.has(fileExt)) return null; return file; } type 缩略图缓存项 = { mtime: number; dataUrl: string; }; type 插件缓存数据 = { thumbnailCache?: Record; }; type 缩略图选择项 = { file: TFile; dataUrl: string | null; isSelected: boolean; }; type 缩略图弹窗参数 = { items: 缩略图选择项[]; onConfirm: (selected: 缩略图选择项[]) => void; onCancel: () => void; }; function 获取图片MimeType(extension: string): string { const ext = extension.toLowerCase(); if (ext === "svg") return "image/svg+xml"; if (ext === "jpg" || ext === "jpeg") return "image/jpeg"; if (ext === "png") return "image/png"; if (ext === "gif") return "image/gif"; if (ext === "webp") return "image/webp"; if (ext === "bmp") return "image/bmp"; if (ext === "tif" || ext === "tiff") return "image/tiff"; if (ext === "avif") return "image/avif"; return "image/*"; } async function 生成缩略图DataUrl( app: App, file: TFile, maxSize: number ): Promise { try { const data = await app.vault.readBinary(file); const mime = 获取图片MimeType(file.extension || ""); const blob = new Blob([data], { type: mime }); const objectUrl = URL.createObjectURL(blob); const dataUrl = await new Promise((resolve) => { const img = new Image(); img.onload = () => { try { const width = img.naturalWidth || img.width; const height = img.naturalHeight || img.height; const scale = Math.min(maxSize / width, maxSize / height, 1); const targetW = Math.max(1, Math.round(width * scale)); const targetH = Math.max(1, Math.round(height * scale)); const canvas = document.createElement("canvas"); canvas.width = targetW; canvas.height = targetH; const ctx = canvas.getContext("2d"); if (!ctx) { resolve(null); return; } ctx.drawImage(img, 0, 0, targetW, targetH); resolve(canvas.toDataURL("image/png", 0.85)); } catch (err) { console.debug(`${插件前缀} 生成缩略图失败:${file.path}`, err); resolve(null); } finally { URL.revokeObjectURL(objectUrl); } }; img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(null); }; img.src = objectUrl; }); return dataUrl; } catch (err) { console.debug(`${插件前缀} 读取图片失败,无法生成缩略图:${file.path}`, err); return null; } } class 缩略图选择弹窗 extends Modal { private items: 缩略图选择项[]; private onConfirm: (selected: 缩略图选择项[]) => void; private onCancel: () => void; private 已确认 = false; constructor(app: App, options: 缩略图弹窗参数) { super(app); this.items = options.items; this.onConfirm = options.onConfirm; this.onCancel = options.onCancel; } onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.createEl("h2", { text: "选择要清理的图片" }); const actions = contentEl.createDiv(); actions.style.display = "flex"; actions.style.gap = "8px"; actions.style.marginBottom = "12px"; const 全选按钮 = actions.createEl("button", { text: "全选" }); const 全不选按钮 = actions.createEl("button", { text: "全不选" }); const grid = contentEl.createDiv(); grid.style.display = "grid"; grid.style.gridTemplateColumns = "repeat(auto-fill, minmax(140px, 1fr))"; grid.style.gap = "12px"; const renderItems = () => { grid.empty(); for (const item of this.items) { const card = grid.createDiv(); card.style.border = "1px solid var(--background-modifier-border)"; card.style.borderRadius = "8px"; card.style.padding = "8px"; card.style.display = "flex"; card.style.flexDirection = "column"; card.style.gap = "6px"; const checkbox = card.createEl("input", { type: "checkbox" }); checkbox.checked = item.isSelected; checkbox.onchange = () => { item.isSelected = checkbox.checked; }; if (item.dataUrl) { const img = card.createEl("img"); img.src = item.dataUrl; img.style.width = "100%"; img.style.height = "110px"; img.style.objectFit = "contain"; img.style.background = "var(--background-secondary)"; img.style.borderRadius = "6px"; } else { const placeholder = card.createDiv({ text: "无法生成缩略图" }); placeholder.style.height = "110px"; placeholder.style.display = "flex"; placeholder.style.alignItems = "center"; placeholder.style.justifyContent = "center"; placeholder.style.fontSize = "12px"; placeholder.style.color = "var(--text-muted)"; placeholder.style.background = "var(--background-secondary)"; placeholder.style.borderRadius = "6px"; } const nameEl = card.createDiv({ text: item.file.name }); nameEl.style.fontSize = "12px"; nameEl.style.wordBreak = "break-all"; const pathEl = card.createDiv({ text: item.file.path }); pathEl.style.fontSize = "10px"; pathEl.style.color = "var(--text-muted)"; pathEl.style.wordBreak = "break-all"; } }; renderItems(); 全选按钮.onclick = () => { for (const item of this.items) item.isSelected = true; renderItems(); }; 全不选按钮.onclick = () => { for (const item of this.items) item.isSelected = false; renderItems(); }; const footer = contentEl.createDiv(); footer.style.display = "flex"; footer.style.justifyContent = "flex-end"; footer.style.gap = "8px"; footer.style.marginTop = "16px"; const 取消按钮 = footer.createEl("button", { text: "取消" }); const 确认按钮 = footer.createEl("button", { text: "确认清理" }); 确认按钮.style.fontWeight = "bold"; 取消按钮.onclick = () => { this.close(); }; 确认按钮.onclick = () => { this.已确认 = true; const selected = this.items.filter((item) => item.isSelected); this.onConfirm(selected); this.close(); }; } onClose(): void { if (!this.已确认) { this.onCancel(); } this.contentEl.empty(); } } export default class ImageStripperPlugin extends Plugin { private thumbnailCache: Record = {}; private thumbnailCacheDirty = false; async onload(): Promise { await this.加载缩略图缓存(); this.addCommand({ id: "strip-images-in-current-note", name: "清理当前笔记图片", callback: async () => { await this.执行清理当前笔记图片(); } }); this.addCommand({ id: "strip-images-in-current-note-select", name: "清理当前笔记图片(可选择)", callback: async () => { await this.执行清理当前笔记图片_可选择(); } }); } onunload(): void { console.info(`${插件前缀} 插件已卸载`); } private async 执行清理当前笔记图片(): Promise { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。"); console.warn(`${插件前缀} 未找到 active file,已终止`); return; } console.info(`${插件前缀} 开始清理图片:file=${activeFile.path}`); const 原内容 = await this.app.vault.read(activeFile); const { 新内容, 移除引用次数, 候选目标列表, 移除残留次数 } = 清理图片引用(原内容); console.debug( `${插件前缀} 解析完成:文本长度=${原内容.length} -> ${新内容.length},移除引用次数=${移除引用次数},候选目标数=${候选目标列表.length},移除残留次数=${移除残留次数}` ); if (移除引用次数 === 0 && 移除残留次数 === 0) { new Notice("当前笔记未发现图片引用或可清理残留,无需清理。"); console.info(`${插件前缀} 未发现图片引用或残留,已结束`); return; } // 写回笔记内容 await this.app.vault.modify(activeFile, 新内容); console.info(`${插件前缀} 已写回清理后的笔记内容:file=${activeFile.path}`); const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表); console.info(`${插件前缀} 可评估删除的本地图片文件数=${图片文件列表.length}`); let 删除数量 = 0; for (const imageFile of 图片文件列表) { try { const backlinks = 获取文件反链对象(this.app, imageFile); const 引用路径列表 = 从反链对象提取引用路径列表(backlinks); const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length; if (其他引用数 === 0) { console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`); await this.app.vault.delete(imageFile); 删除数量 += 1; } else { console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`); } } catch (err) { console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err); } } const 提示 = 移除残留次数 > 0 ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。` : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`; new Notice(提示); console.info(`${插件前缀} 清理完成:${提示} file=${activeFile.path}`); } private async 执行清理当前笔记图片_可选择(): Promise { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。"); console.warn(`${插件前缀} 未找到 active file,已终止(可选择模式)`); return; } console.info(`${插件前缀} 开始清理图片(可选择):file=${activeFile.path}`); const 原内容 = await this.app.vault.read(activeFile); const 候选目标列表 = 提取图片引用目标列表(原内容); console.debug( `${插件前缀} 解析完成(可选择):文本长度=${原内容.length},候选目标数=${候选目标列表.length}` ); if (候选目标列表.length === 0) { new Notice("当前笔记未发现图片引用,无需清理。"); console.info(`${插件前缀} 未发现图片引用,已结束(可选择模式)`); return; } const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表); if (图片文件列表.length === 0) { new Notice("未解析到可用的本地图片文件。"); console.info(`${插件前缀} 未解析到可用图片文件,已结束(可选择模式)`); return; } console.info(`${插件前缀} 进入选择弹窗,图片数=${图片文件列表.length}`); const items = await this.构建缩略图选择项(图片文件列表); const selectedItems = await this.打开缩略图选择弹窗(items); await this.保存缩略图缓存(); if (!selectedItems) { console.info(`${插件前缀} 用户取消选择,未做任何改动`); return; } if (selectedItems.length === 0) { new Notice("未选择任何图片,已取消清理。"); console.info(`${插件前缀} 未选择图片,已结束(可选择模式)`); return; } const selectedPathSet = new Set(selectedItems.map((item) => item.file.path)); const targetToFilePathMap = await this.解析目标到文件路径Map(activeFile, 候选目标列表); const { 新内容, 移除引用次数, 移除残留次数 } = 清理图片引用_按目标过滤(原内容, { shouldRemoveTarget: (target: string) => { const resolvedPath = targetToFilePathMap.get(target); return Boolean(resolvedPath && selectedPathSet.has(resolvedPath)); } }); if (移除引用次数 === 0 && 移除残留次数 === 0) { new Notice("未发现可清理的目标(可能未被引用或已被保留)。"); console.info(`${插件前缀} 未发生清理变更,已结束(可选择模式)`); return; } await this.app.vault.modify(activeFile, 新内容); console.info(`${插件前缀} 已写回清理后的笔记内容(可选择):file=${activeFile.path}`); let 删除数量 = 0; for (const item of selectedItems) { const imageFile = item.file; try { const backlinks = 获取文件反链对象(this.app, imageFile); const 引用路径列表 = 从反链对象提取引用路径列表(backlinks); const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length; if (其他引用数 === 0) { console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`); await this.app.vault.delete(imageFile); 删除数量 += 1; } else { console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`); } } catch (err) { console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err); } } await this.保存缩略图缓存(); const 提示 = 移除残留次数 > 0 ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。` : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`; new Notice(提示); console.info(`${插件前缀} 清理完成(可选择):${提示} file=${activeFile.path}`); } private async 解析图片文件列表(activeFile: TFile, 候选目标列表: string[]): Promise { const 图片文件Map = new Map(); for (const rawTarget of 候选目标列表) { const file = await 获取图片文件(this.app, activeFile, rawTarget); if (file) { 图片文件Map.set(file.path, file); } else { console.debug(`${插件前缀} 跳过删除候选(外链/不可解析/非图片):target=${rawTarget}`); } } return Array.from(图片文件Map.values()); } private async 解析目标到文件路径Map( activeFile: TFile, 候选目标列表: string[] ): Promise> { const map = new Map(); for (const rawTarget of 候选目标列表) { const file = await 获取图片文件(this.app, activeFile, rawTarget); if (file) { map.set(rawTarget, file.path); } } return map; } private async 构建缩略图选择项(图片文件列表: TFile[]): Promise<缩略图选择项[]> { const items: 缩略图选择项[] = []; for (const file of 图片文件列表) { const dataUrl = await this.获取缩略图DataUrl(file, 160); items.push({ file, dataUrl, isSelected: true }); } return items; } private async 打开缩略图选择弹窗( items: 缩略图选择项[] ): Promise<缩略图选择项[] | null> { return await new Promise((resolve) => { const modal = new 缩略图选择弹窗(this.app, { items, onConfirm: (selected) => resolve(selected), onCancel: () => resolve(null) }); modal.open(); }); } private async 加载缩略图缓存(): Promise { const data = (await this.loadData()) as 插件缓存数据 | null; this.thumbnailCache = data?.thumbnailCache ?? {}; console.info(`${插件前缀} 已加载缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`); } private async 保存缩略图缓存(): Promise { if (!this.thumbnailCacheDirty) return; await this.saveData({ thumbnailCache: this.thumbnailCache }); this.thumbnailCacheDirty = false; console.info(`${插件前缀} 已保存缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`); } private async 获取缩略图DataUrl(file: TFile, maxSize: number): Promise { const cached = this.thumbnailCache[file.path]; if (cached && cached.mtime === file.stat.mtime) { return cached.dataUrl; } const dataUrl = await 生成缩略图DataUrl(this.app, file, maxSize); if (dataUrl) { this.thumbnailCache[file.path] = { mtime: file.stat.mtime, dataUrl }; this.thumbnailCacheDirty = true; } return dataUrl; } }