From 10274cc4eb757254c37f35152f1a4729e56bff40 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 07:38:27 +0800 Subject: [PATCH 1/9] feat: Support for other delimiters (especially ";") Fixes #36 --- README.md | 42 +++++---- README_zh.md | 36 +++++--- src/main.ts | 2 + src/utils/csv-utils.ts | 89 +++++++++++++++++- src/view.ts | 165 ++++++++++++++++++++++++---------- styles.css | 17 ++++ test/detect-delimiter.test.ts | 28 ++++++ 7 files changed, 301 insertions(+), 78 deletions(-) create mode 100644 test/detect-delimiter.test.ts diff --git a/README.md b/README.md index aed5935..70abc08 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A plugin designed to view and edit `CSV files` directly within Obsidian. - **Toggle** between the table view and raw source-mode. - **Edit** cells directly by clicking and typing. - **Manage** rows and columns (add, delete, move) with a simple right-click on the header. +- **Switch Delimiter Non‑Destructively**: Auto‑detects the file delimiter (comma, semicolon, tab, etc.). Changing the delimiter in the toolbar only re-parses the view; it does NOT rewrite your file. Your original delimiter is preserved when saving edits. I have a plan to design my own database using json and csv only. If you have fancy idea about tables or csv, please feel free to issue (I will consider it in csv-lite or my new plugin) or search it in community. @@ -38,27 +39,38 @@ Because it is designed to be simple and straightforward. It also keeps up with t ## Philosophy -- No fancy UI, SAY NO TO - - modals - - sidebar - - settingTab - - other online docs & tutorials -- All functions of the ui components above will be covered in a single File view. -- All in TextFileView/workspace. -- No more pollution to your vault, all metadata store in `./.obsidian/plugins/csv` in json format. (Currently no `data.json`) -- Every function must be completed within 3 steps: +- No fancy UI, SAY NO TO + - modals + - sidebar + - settingTab + - other online docs & tutorials +- All functions of the ui components above will be covered in a single File view. +- All in TextFileView/workspace. +- No more pollution to your vault, all metadata store in `./.obsidian/plugins/csv` in json format. (Currently no `data.json`) +- Every function must be completed within 3 steps: 0. Locate it visually - 1. Click/Hotkey - 2. Input (if needed) - 3. Confirm/Leave -- The interface should remain minimal yet functional. -- Users shouldn't need to leave their workflow environment. -- CSV manipulation should be as natural as text editing. + 1. Click/Hotkey + 2. Input (if needed) + 3. Confirm/Leave +- The interface should remain minimal yet functional. +- Users shouldn't need to leave their workflow environment. +- CSV manipulation should be as natural as text editing. ## Purpose This plugin enhances Obsidian's functionality by allowing users to work with CSV (Comma-Separated Values) files seamlessly within their vault, eliminating the need to switch between different applications for CSV handling. +### Delimiter Handling Philosophy + +Team repositories or shared datasets often mix delimiter styles (`,` `;` `\t`). For safety: + +1. The plugin auto-detects the delimiter when opening a file. +2. The dropdown ("Auto, , ;") lets you temporarily re-interpret the file without changing it on disk. +3. Saving edits (cell changes, row/column operations) writes the file back using the ORIGINAL detected delimiter, not the one you temporarily selected—unless the file already used that delimiter. +4. This prevents accidental mass diffs in version control or breaking pipelines that assume a specific separator. + +If you ever need an explicit “convert delimiter” feature, open an issue—we’ll gate it behind a confirmation instead of doing it silently. + ## Getting Started Install the plugin through Obsidian's community plugins section and start viewing your CSV files directly in your notes. diff --git a/README_zh.md b/README_zh.md index 686cd00..e60447a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -22,6 +22,7 @@ - **切换** 表格视图和原始源码模式 - **编辑** 直接点击并输入即可编辑单元格 - **管理** 行和列(添加、删除、移动),只需右键点击表头 +- **非破坏性分隔符切换**:自动检测文件原始分隔符(逗号、分号、制表符等)。在工具栏切换分隔符只会重新解析视图,不会改写文件;保存编辑时仍使用文件原始分隔符,避免产生大规模 diff。 我计划仅用 json 和 csv 设计自己的数据库。如果你有关于表格或 csv 的新想法,欢迎提 issue(我会考虑加入 csv-lite 或新插件),或在社区中搜索。 @@ -33,27 +34,38 @@ ## 理念 -- 没有花哨的 UI,拒绝以下内容: - - 模态框 - - 侧边栏 - - 设置选项卡 - - 多余的在线文档和教程 -- 上述所有 UI 组件的功能都将在单一文件视图中实现 -- 一切都在 TextFileView/workspace 中完成 -- 不再污染你的库,所有元数据都以 json 格式存储在 `./.obsidian/plugins/csv`(目前没有 `data.json`) -- 每个功能必须在 3 步内完成: +- 没有花哨的 UI,拒绝以下内容: + - 模态框 + - 侧边栏 + - 设置选项卡 + - 多余的在线文档和教程 +- 上述所有 UI 组件的功能都将在单一文件视图中实现 +- 一切都在 TextFileView/workspace 中完成 +- 不再污染你的库,所有元数据都以 json 格式存储在 `./.obsidian/plugins/csv`(目前没有 `data.json`) +- 每个功能必须在 3 步内完成: 0. 视觉定位 1. 点击/快捷键 2. 输入(如需) 3. 确认/离开 -- 界面应保持极简但实用 -- 用户无需离开工作流环境 -- CSV 操作应像文本编辑一样自然 +- 界面应保持极简但实用 +- 用户无需离开工作流环境 +- CSV 操作应像文本编辑一样自然 ## 目的 本插件增强了 Obsidian 的功能,让用户可以在库内无缝处理 CSV(逗号分隔值)文件,无需在不同应用间切换。 +### 分隔符处理策略 + +多人协作或跨区域数据常混用不同分隔符(`,` `;` `\t`)。为降低风险: + +1. 打开文件时自动检测分隔符。 +2. 工具栏下拉(Auto, , ;)只是临时“重新解释”数据,不修改磁盘文件。 +3. 你在表格中做的内容编辑(单元格/行列操作)保存时仍按最初检测出的原始分隔符写回。 +4. 避免意外把所有分号文件改成逗号,减少版本库噪音。 + +如果需要真正“转换分隔符”的功能,欢迎提 issue,我们会加确认步骤而不是静默改写。 + ## 开始使用 通过 Obsidian 的社区插件区安装本插件,即可直接在笔记中查看和编辑 CSV 文件。 diff --git a/src/main.ts b/src/main.ts index f1989b6..92c7e37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,12 @@ import { i18n } from "./i18n"; interface CSVPluginSettings { csvSettings: string; + preferredDelimiter?: string; // user global preference, e.g. ',' ';' '\t' or 'auto' } const DEFAULT_SETTINGS: CSVPluginSettings = { csvSettings: "default", + preferredDelimiter: 'auto', }; export default class CSVPlugin extends Plugin { diff --git a/src/utils/csv-utils.ts b/src/utils/csv-utils.ts index bc64693..486e099 100644 --- a/src/utils/csv-utils.ts +++ b/src/utils/csv-utils.ts @@ -6,7 +6,7 @@ export interface CSVParseConfig { header: boolean; dynamicTyping: boolean; skipEmptyLines: boolean; - delimiter?: string; + delimiter?: string; // use 'auto' to enable auto-detection quoteChar: string; escapeChar: string; } @@ -17,10 +17,85 @@ export class CSVUtils { header: false, dynamicTyping: false, skipEmptyLines: false, - delimiter: ",", + delimiter: 'auto', quoteChar: '"', // 关键:这是修复报告bug的关键 escapeChar: '"', - }; + }; + + /** + * 简单的分隔符检测器:在前几行统计候选分隔符(逗号/分号/制表符/竖线) + * 对每个候选符号,统计每行在引号外出现的分隔符数量,选择字段数量最一致且>1的分隔符。 + */ + static detectDelimiter(csvString: string, quoteChar = '"') : string { + if (!csvString || csvString.length === 0) return ','; + const candidates = [',', ';', '\t', '|']; + + // Build logical records by honoring quoted multiline fields. + const records: string[] = []; + let cur = ''; + let inQuote = false; + for (let i = 0; i < csvString.length; i++) { + const ch = csvString[i]; + if (ch === quoteChar) { + // handle escaped quote "" + if (i + 1 < csvString.length && csvString[i + 1] === quoteChar) { + cur += quoteChar; + i++; // skip escaped + continue; + } + inQuote = !inQuote; + cur += ch; + continue; + } + if (!inQuote && ch === '\n') { + records.push(cur); + cur = ''; + continue; + } + // keep CR if present inside quotes or ignore standalone CR + if (!inQuote && ch === '\r') continue; + cur += ch; + } + if (cur.length > 0) records.push(cur); + + // limit to first 20 non-empty records + const sample = records.map(r => r).filter(r => r.trim().length > 0).slice(0, 20); + if (sample.length === 0) return ','; + + function countFields(record: string, delim: string) { + let inQ = false; + let count = 0; + for (let i = 0; i < record.length; i++) { + const ch = record[i]; + if (ch === quoteChar) { + if (i + 1 < record.length && record[i + 1] === quoteChar) { + i++; // skip escaped + continue; + } + inQ = !inQ; + continue; + } + if (!inQ && ch === delim) count++; + } + return count + 1; + } + + let best: { delim: string; score: number; avgFields: number; consistency: number } | null = null; + for (const d of candidates) { + const counts = sample.map(r => countFields(r, d)); + const avg = counts.reduce((a,b) => a+b,0)/counts.length; + const variance = counts.reduce((a,b) => a + Math.pow(b - avg, 2), 0) / counts.length; + const score = (avg > 1 ? avg : 0) - variance * 0.1; + if (!best || score > best.score) { + best = { delim: d, score, avgFields: avg, consistency: variance }; + } + } + if (best && best.avgFields >= 1.5) { + return best.delim; + } + return ','; + } + /** * 解析CSV字符串为二维数组 @@ -31,7 +106,12 @@ export class CSVUtils { ): string[][] { try { const parseConfig = { ...this.defaultConfig, ...config }; - const parseResult = Papa.parse(csvString, parseConfig); + // 如果启用了自动检测,则尝试检测分隔符并覆盖parseConfig.delimiter + if (!parseConfig.delimiter || parseConfig.delimiter === 'auto') { + const detected = this.detectDelimiter(csvString, parseConfig.quoteChar); + parseConfig.delimiter = detected; + } + const parseResult: any = Papa.parse(csvString, parseConfig as any); if (parseResult.errors && parseResult.errors.length > 0) { console.warn("CSV解析警告:", parseResult.errors); @@ -58,6 +138,7 @@ export class CSVUtils { return Papa.unparse(data, { ...defaultUnparseConfig, ...config }); } + /** * 确保表格数据规整(所有行的列数相同) */ diff --git a/src/view.ts b/src/view.ts index 4f6c429..33f51eb 100644 --- a/src/view.ts +++ b/src/view.ts @@ -38,8 +38,10 @@ export class CSVView extends TextFileView { private autoResize: boolean = true; // 新增:解析器设置状态 - private delimiter: string = ","; + private delimiter: string = 'auto'; private quoteChar: string = '"'; + // 保存文件原始分隔符(用于非破坏性编辑后保存仍保持原始格式) + private originalFileDelimiter: string | null = null; // 编辑栏 private editBarEl: HTMLElement; @@ -93,7 +95,9 @@ export class CSVView extends TextFileView { return "table"; } getViewData() { - return CSVUtils.unparseCSV(this.tableData); + // 使用原始文件分隔符(如果已检测到),否则使用当前解析器的实际分隔符 + const delim = this.originalFileDelimiter || (this.delimiter === 'auto' ? undefined : this.delimiter); + return CSVUtils.unparseCSV(this.tableData, delim ? { delimiter: delim } as any : undefined); } // We need to create a wrapper for the original requestSave @@ -133,6 +137,15 @@ export class CSVView extends TextFileView { quoteChar: this.quoteChar, }); + // 初次或在未设置 originalFileDelimiter 时检测并缓存原始分隔符 + if (!this.originalFileDelimiter) { + try { + this.originalFileDelimiter = CSVUtils.detectDelimiter(data, this.quoteChar); + } catch (e) { + console.warn('Failed to detect original delimiter:', e); + } + } + // 确保至少有一行一列 if (!this.tableData || this.tableData.length === 0) { this.tableData = [[""]]; @@ -595,17 +608,42 @@ export class CSVView extends TextFileView { cls: "csv-parser-settings", }); + // 分隔符选择:下拉(Auto, comma, semicolon) new Setting(parserSettingsEl) .setName(i18n.t("settings.fieldSeparator")) .setDesc(i18n.t("settings.fieldSeparatorDesc")) - .addText((text) => { - text.setValue(this.delimiter) - .setPlaceholder("例如:, 或 ; 或 \\t 表示制表符") - .onChange(async (value) => { - // 处理制表符的特殊情况 - this.delimiter = value === "\\t" ? "\t" : value; - this.reparseAndRefresh(); - }); + .addDropdown((dropdown: DropdownComponent) => { + // 初始值:优先使用主插件的全局偏好 + try { + const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite'); + if (mainPlugin && mainPlugin.settings && mainPlugin.settings.preferredDelimiter) { + this.delimiter = mainPlugin.settings.preferredDelimiter; + } + } catch (e) {} + + // 检测当前文件的分隔符(用于在 Auto 模式下显示检测结果) + const detected = CSVUtils.detectDelimiter(this.data || '', this.quoteChar); + + dropdown.addOption('auto', `Auto (detected: ${detected})`); + dropdown.addOption(',', ','); + dropdown.addOption(';', ';'); + // set initial + dropdown.setValue(this.delimiter || 'auto'); + + dropdown.onChange(async (value) => { + this.delimiter = value === '\\t' ? '\t' : value; + // 保存全局偏好(如果主插件可用) + try { + const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite'); + if (mainPlugin && typeof mainPlugin.saveSettings === 'function') { + mainPlugin.settings = { ...(mainPlugin.settings || {}), preferredDelimiter: this.delimiter }; + await mainPlugin.saveSettings(); + } + } catch (e) {} + + // 非破坏性:仅重新解析视图,不写回文件 + this.reparseAndRefresh(); + }); }); new Setting(parserSettingsEl) @@ -662,6 +700,40 @@ export class CSVView extends TextFileView { this.refresh(); }); + // 紧凑型:分隔符下拉(放在重置按钮旁,便于快速切换),使用简短的Label + const delimiterContainer = buttonsGroup.createEl('div', { cls: 'csv-delimiter-compact' }); + new Setting(delimiterContainer) + .addDropdown((dropdown: DropdownComponent) => { + // 初始化值(与 parser settings 保持一致) + try { + const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite'); + if (mainPlugin && mainPlugin.settings && mainPlugin.settings.preferredDelimiter) { + this.delimiter = mainPlugin.settings.preferredDelimiter; + } + } catch (e) {} + + const detected = CSVUtils.detectDelimiter(this.data || '', this.quoteChar); + dropdown.addOption('auto', `Auto (${detected})`); + dropdown.addOption(',', ','); + dropdown.addOption(';', ';'); + dropdown.setValue(this.delimiter || 'auto'); + + dropdown.onChange(async (value) => { + this.delimiter = value === '\\t' ? '\t' : value; + // 保存偏好 + try { + const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite'); + if (mainPlugin && typeof mainPlugin.saveSettings === 'function') { + mainPlugin.settings = { ...(mainPlugin.settings || {}), preferredDelimiter: this.delimiter }; + await mainPlugin.saveSettings(); + } + } catch (e) {} + + // 非破坏性:仅重新解析视图 + this.reparseAndRefresh(); + }); + }); + // 编辑栏(sticky工具栏内,按钮和搜索栏之后) @@ -959,50 +1031,49 @@ export class CSVView extends TextFileView { (el as HTMLElement).style.removeProperty('left'); (el as HTMLElement).style.removeProperty('top'); }); + // 计算行号的实际宽度 + const getRowNumberWidth = (): number => { + const firstRowNumber = this.tableEl.querySelector('tbody tr td:first-child') as HTMLElement; + return firstRowNumber ? firstRowNumber.offsetWidth : 40; // 默认40px + }; - // 获取行号列的实际宽度 - const getRowNumberWidth = (): number => { - const firstRowNumber = this.tableEl.querySelector('tbody tr td:first-child') as HTMLElement; - return firstRowNumber ? firstRowNumber.offsetWidth : 40; // 默认40px - }; - - // 获取表头的实际高度 - const getHeaderHeight = (): number => { - const headerRow = this.tableEl.querySelector('thead tr') as HTMLElement; - return headerRow ? headerRow.offsetHeight : 30; // 默认30px - }; + // 获取表头的实际高度 + const getHeaderHeight = (): number => { + const headerRow = this.tableEl.querySelector('thead tr') as HTMLElement; + return headerRow ? headerRow.offsetHeight : 30; // 默认30px + }; - // 计算累积的固定列宽度 - const calculateStickyColumnsWidth = (upToIndex: number): number => { - let totalWidth = getRowNumberWidth(); // 从行号列开始 - for (let i = 0; i < upToIndex; i++) { - if (this.stickyColumns.has(i)) { - const headerCell = this.tableEl.querySelector(`thead tr th:nth-child(${i + 2})`) as HTMLElement; - if (headerCell) { - totalWidth += headerCell.offsetWidth; - } else { - totalWidth += this.columnWidths[i] || 100; // 使用预设宽度或默认值 + // 计算累积的固定列宽度(从行号列开始) + const calculateStickyColumnsWidth = (upToIndex: number): number => { + let totalWidth = getRowNumberWidth(); // 从行号列开始 + for (let i = 0; i < upToIndex; i++) { + if (this.stickyColumns.has(i)) { + const headerCell = this.tableEl.querySelector(`thead tr th:nth-child(${i + 2})`) as HTMLElement; + if (headerCell) { + totalWidth += headerCell.offsetWidth; + } else { + totalWidth += this.columnWidths[i] || 100; // 使用预设宽度或默认 + } } } - } - return totalWidth; - }; + return totalWidth; + }; - // 计算累积的固定行高度 - const calculateStickyRowsHeight = (upToIndex: number): number => { - let totalHeight = getHeaderHeight(); // 从表头开始 - for (let i = 0; i < upToIndex; i++) { - if (this.stickyRows.has(i)) { - const row = this.tableEl.querySelector(`tbody tr:nth-child(${i + 1})`) as HTMLElement; - if (row) { - totalHeight += row.offsetHeight; - } else { - totalHeight += 32; // 默认行高 + // 计算累积的固定行高度(从表头开始) + const calculateStickyRowsHeight = (upToIndex: number): number => { + let totalHeight = getHeaderHeight(); // 从表头开始 + for (let i = 0; i < upToIndex; i++) { + if (this.stickyRows.has(i)) { + const row = this.tableEl.querySelector(`tbody tr:nth-child(${i + 1})`) as HTMLElement; + if (row) { + totalHeight += row.offsetHeight; + } else { + totalHeight += 32; // 默认行高 + } } } - } - return totalHeight; - }; + return totalHeight; + }; // 默认固定表头行(A、B、C、D...) if (this.stickyHeaders) { diff --git a/styles.css b/styles.css index 520e122..7f5853b 100644 --- a/styles.css +++ b/styles.css @@ -102,6 +102,23 @@ If your plugin does not need CSS, delete this file. flex-wrap: wrap; } +/* 紧凑型分隔符下拉容器,放在按钮组内:去掉 Setting 默认的下方 padding 并垂直居中 */ +.csv-delimiter-compact { + display: flex; + align-items: center; +} +.csv-delimiter-compact .setting-item { + padding-top: 0 !important; + padding-bottom: 0 !important; /* 去掉 11.25px 的底部内边距 */ + margin: 0 !important; +} +.csv-delimiter-compact .setting-item .setting-item-info { + display: none; /* 隐藏多余描述,保持紧凑 */ +} +.csv-delimiter-compact .dropdown { + min-width: 72px; /* 视觉上和按钮宽度接近 */ +} + /* Pin按钮样式 */ .csv-pin-btn { position: absolute; diff --git a/test/detect-delimiter.test.ts b/test/detect-delimiter.test.ts new file mode 100644 index 0000000..7105f2d --- /dev/null +++ b/test/detect-delimiter.test.ts @@ -0,0 +1,28 @@ +import { CSVUtils } from '../src/utils/csv-utils'; + +describe('Delimiter detection', () => { + test('detects comma for simple CSV', () => { + const csv = 'a,b,c\n1,2,3\n4,5,6'; + expect(CSVUtils.detectDelimiter(csv)).toBe(','); + }); + + test('detects semicolon for semicolon CSV', () => { + const csv = 'a;b;c\n1;2;3\n4;5;6'; + expect(CSVUtils.detectDelimiter(csv)).toBe(';'); + }); + + test('detects tab for TSV', () => { + const csv = 'a\tb\tc\n1\t2\t3\n4\t5\t6'; + expect(CSVUtils.detectDelimiter(csv)).toBe('\t'); + }); + + test('ignores delimiters inside quotes and detects correctly', () => { + const csv = 'name,desc\n"Quote, inside",value\n"Another, one",other'; + expect(CSVUtils.detectDelimiter(csv)).toBe(','); + }); + + test('handles multiline quoted fields and selects best delimiter', () => { + const csv = 'name;notes\n"Multi\nLine; still inside";ok\n"Another\nEntry";good'; + expect(CSVUtils.detectDelimiter(csv)).toBe(';'); + }); +}); From e2b11ffa32181aa17cc73bd65e3b396156c126d1 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 16:38:41 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20delimiter-compact=20style=20adjustme?= =?UTF-8?q?nt=20=E8=B0=83=E6=95=B4=E7=B4=A7=E5=87=91=E5=9E=8B=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E6=A0=B7=E5=BC=8F=E4=BB=A5=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E4=B8=8E=E6=8C=89=E9=92=AE=E5=9E=82=E7=9B=B4=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- styles.css | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/styles.css b/styles.css index 7f5853b..b2cdb89 100644 --- a/styles.css +++ b/styles.css @@ -108,15 +108,26 @@ If your plugin does not need CSS, delete this file. align-items: center; } .csv-delimiter-compact .setting-item { - padding-top: 0 !important; - padding-bottom: 0 !important; /* 去掉 11.25px 的底部内边距 */ - margin: 0 !important; + display: flex !important; + align-items: center !important; + padding-top: 0 !important; + padding-bottom: 0 !important; /* 去掉 11.25px 的底部内边距 */ + margin: 0 !important; } .csv-delimiter-compact .setting-item .setting-item-info { display: none; /* 隐藏多余描述,保持紧凑 */ } .csv-delimiter-compact .dropdown { - min-width: 72px; /* 视觉上和按钮宽度接近 */ + min-width: 72px; /* 视觉上和按钮宽度接近 */ +} +/* Align dropdown vertically with buttons */ +.csv-delimiter-compact .setting-item-control { + margin: 0 !important; +} +.csv-delimiter-compact .setting-item-control .dropdown { + height: 32px !important; + padding: 0 8px !important; + line-height: 32px !important; } /* Pin按钮样式 */ From 24a7a75753805e2c6a53540ed100ef65f96b140b Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 16:43:07 +0800 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=A0=8F=E7=BB=84=E4=BB=B6=E5=8F=8A=E5=85=B6=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view/operation-bar.ts | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/view/operation-bar.ts diff --git a/src/view/operation-bar.ts b/src/view/operation-bar.ts deleted file mode 100644 index c002963..0000000 --- a/src/view/operation-bar.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ButtonComponent, Setting } from "obsidian"; -import { i18n } from "../i18n"; - -export interface OperationBarOptions { - operationEl: HTMLElement; - delimiter: string; - quoteChar: string; - onDelimiterChange: (delimiter: string) => void; - onQuoteCharChange: (quoteChar: string) => void; - onUndo: () => void; - onRedo: () => void; - onAddRow: () => void; - onDeleteRow: () => void; - onAddColumn: () => void; - onDeleteColumn: () => void; - onResetColumnWidth: () => void; - isSourceMode: boolean; - onToggleSourceMode: () => void; -} - -export function renderOperationBar(options: OperationBarOptions) { - // ...操作栏渲染逻辑骨架... -} From d8d0785cf18bd787658fc92f683603bf5dbcfb24 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 16:43:22 +0800 Subject: [PATCH 4/9] chore: update to 1.1.4 --- manifest.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index ffc17ab..5a254d6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "csv-lite", "name": "CSV Lite", - "version": "1.1.3", + "version": "1.1.4", "minAppVersion": "1.8.0", "description": "Just open and edit CSV files directly, no more. Keep it simple.", "author": "Jay Bridge", diff --git a/package.json b/package.json index 02050ca..ef35bce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-csv", - "version": "1.1.3", + "version": "1.1.4", "description": "CSV viewer and editor for Obsidian", "main": "main.js", "scripts": { From ae94a89e83631da7e36e6922aab67cdf21fad45f Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 16:44:55 +0800 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20package-lock.?= =?UTF-8?q?json=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c186d1d..3824fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-csv", - "version": "1.1.2", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-csv", - "version": "1.1.2", + "version": "1.1.4", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.8.1", From 7e4205e0511e50fc9bcadf3eca3293822c9e5ef6 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Tue, 16 Sep 2025 17:17:03 +0800 Subject: [PATCH 6/9] chore: add ci.yml --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..61c4564 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ "dev", "master", "main" ] + pull_request: + branches: [ "dev", "master", "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Prettier check (if present) + run: | + if npx --no-install prettier --version >/dev/null 2>&1; then + npx prettier --check "**/*.{ts,js,json,md,css}" || (echo "Prettier check failed" && exit 1) + else + echo "Prettier not installed - skipping check" + fi + + - name: TypeScript typecheck and build + run: npm run build + + - name: Run tests + run: npm test From 26a679a4c9a7e8e2d1bc9e7a3b0ff8a74cfbcd38 Mon Sep 17 00:00:00 2001 From: Jay Bridge <142153595+LIUBINfighter@users.noreply.github.com> Date: Sun, 7 Dec 2025 04:57:44 +0800 Subject: [PATCH 7/9] Fix/viewname (#42) * fix: avoid view name collision with other plugins by renaming to csv-lite-view & csv-lite-source-view * chore: update comments to reflect csv-lite view name --- src/source-view.ts | 8 ++++---- src/view.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/source-view.ts b/src/source-view.ts index 67fc603..01521d5 100644 --- a/src/source-view.ts +++ b/src/source-view.ts @@ -3,7 +3,7 @@ import { EditorState, Extension, RangeSetBuilder } from "@codemirror/state"; import { EditorView, keymap, placeholder, lineNumbers, drawSelection, Decoration, ViewPlugin, ViewUpdate, DecorationSet } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; -export const VIEW_TYPE_CSV_SOURCE = "csv-source-view"; +export const VIEW_TYPE_CSV_SOURCE = "csv-lite-source-view"; // 分隔符高亮插件(逗号、分号、制表符) const separatorHighlightPlugin = ViewPlugin.fromClass(class { @@ -62,7 +62,7 @@ export class SourceView extends TextFileView { // 1. 在 view header 的 view-actions 区域插入切换按钮(lucide/table 图标) // 交互说明: // - 切换按钮始终位于 header 区域,风格与 Obsidian 原生一致。 - // - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-view)。 + // - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-lite-view)。 // - 若有,则激活该 leaf(workspace.setActiveLeaf)。 // - 若无,则新建 leaf 并打开目标视图。 // - 不主动关闭原有视图,用户可自行关闭。 @@ -75,7 +75,7 @@ export class SourceView extends TextFileView { btn.onclick = async () => { const file = this.file; if (!file) return; - const leaves = this.app.workspace.getLeavesOfType('csv-view'); + const leaves = this.app.workspace.getLeavesOfType('csv-lite-view'); let found = false; for (const leaf of leaves) { if (leaf.view && (leaf.view as any).file && (leaf.view as any).file.path === file.path) { @@ -88,7 +88,7 @@ export class SourceView extends TextFileView { const newLeaf = this.app.workspace.getLeaf(true); await newLeaf.openFile(file, { active: true }); await newLeaf.setViewState({ - type: 'csv-view', + type: 'csv-lite-view', active: true, state: { file: file.path } }); diff --git a/src/view.ts b/src/view.ts index 33f51eb..ab98a61 100644 --- a/src/view.ts +++ b/src/view.ts @@ -19,7 +19,7 @@ import { renderTable } from "./view/table-render"; import { HighlightManager } from "./utils/highlight-manager"; import { setupHeaderContextMenu } from "./view/header-context-menu"; -export const VIEW_TYPE_CSV = "csv-view"; +export const VIEW_TYPE_CSV = "csv-lite-view"; export class CSVView extends TextFileView { public file: TFile | null; @@ -559,7 +559,7 @@ export class CSVView extends TextFileView { // 1. 在 view header 的 view-actions 区域插入切换按钮(lucide/file-code 图标) // 交互说明: // - 切换按钮始终位于 header 区域,风格与 Obsidian 原生一致。 - // - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-source-view)。 + // - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-lite-source-view)。 // - 若有,则激活该 leaf(workspace.setActiveLeaf)。 // - 若无,则新建 leaf 并打开目标视图。 // - 不主动关闭原有视图,用户可自行关闭。 @@ -572,7 +572,7 @@ export class CSVView extends TextFileView { btn.onclick = async () => { const file = this.file; if (!file) return; - const leaves = this.app.workspace.getLeavesOfType('csv-source-view'); + const leaves = this.app.workspace.getLeavesOfType('csv-lite-source-view'); let found = false; for (const leaf of leaves) { if (leaf.view && (leaf.view as any).file && (leaf.view as any).file.path === file.path) { @@ -585,7 +585,7 @@ export class CSVView extends TextFileView { const newLeaf = this.app.workspace.getLeaf(true); await newLeaf.openFile(file, { active: true, state: { mode: "source" } }); await newLeaf.setViewState({ - type: "csv-source-view", + type: "csv-lite-source-view", active: true, state: { file: file.path } }); @@ -983,7 +983,7 @@ export class CSVView extends TextFileView { const leaf = this.app.workspace.getLeaf(true); await leaf.openFile(file, { active: true, state: { mode: "source" } }); await leaf.setViewState({ - type: "csv-source-view", + type: "csv-lite-source-view", active: true, state: { file: file.path } }); From 5c4d53deef1223c4d60850a7a3910d8713051a46 Mon Sep 17 00:00:00 2001 From: Jay Bridge <142153595+LIUBINfighter@users.noreply.github.com> Date: Sun, 7 Dec 2025 04:58:04 +0800 Subject: [PATCH 8/9] Fixes #41 i18n and prevent global undo/redo from inactive views. (#43) * fix: localize notices + restrict undo/redo to active view; add history manager i18n and tests * fix: i18n for parse warnings and table utils; add tests for history manager --- src/i18n/en.ts | 15 ++++++++++- src/i18n/zh-cn.ts | 15 ++++++++++- src/utils/csv-utils.ts | 8 +++--- src/utils/history-manager.ts | 9 ++++--- src/utils/table-utils.ts | 5 ++-- src/view.ts | 3 +++ test/history-manager.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 7 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 test/history-manager.test.ts diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 55126f6..b28b551 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -15,7 +15,8 @@ export const enUS = { }, csv: { error: 'Error', - parsingFailed: 'Failed to parse CSV. Please check file format.' + parsingFailed: 'Failed to parse CSV. Please check file format.', + parseWarning: 'CSV parse warning:' }, settings: { fieldSeparator: 'Field Separator', @@ -45,4 +46,16 @@ export const enUS = { moveColLeft: 'Move column left', moveColRight: 'Move column right', } + , + tableMessages: { + atLeastOneRow: 'At least one row must remain', + atLeastOneColumn: 'At least one column must remain' + } + , + notifications: { + undo: 'Undid last action', + noMoreUndo: 'There is nothing more to undo', + redo: 'Redid action', + noMoreRedo: 'There is nothing more to redo' + } }; diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 7375a4a..110bc8b 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -17,7 +17,8 @@ export const zhCN = { }, csv: { error: '错误', - parsingFailed: 'CSV解析失败,请检查文件格式' + parsingFailed: 'CSV解析失败,请检查文件格式', + parseWarning: 'CSV解析提示:' }, settings: { fieldSeparator: '字段分隔符', @@ -47,4 +48,16 @@ export const zhCN = { moveColLeft: '向左移动一列', moveColRight: '向右移动一列', } + , + tableMessages: { + atLeastOneRow: '至少需要保留一行', + atLeastOneColumn: '至少需要保留一列' + } + , + notifications: { + undo: '已撤销上一步操作', + noMoreUndo: '没有更多可撤销的操作', + redo: '已重做操作', + noMoreRedo: '没有更多可重做的操作' + } }; diff --git a/src/utils/csv-utils.ts b/src/utils/csv-utils.ts index 486e099..12f0fc6 100644 --- a/src/utils/csv-utils.ts +++ b/src/utils/csv-utils.ts @@ -114,14 +114,14 @@ export class CSVUtils { const parseResult: any = Papa.parse(csvString, parseConfig as any); if (parseResult.errors && parseResult.errors.length > 0) { - console.warn("CSV解析警告:", parseResult.errors); - new Notice(`CSV解析提示: ${parseResult.errors[0].message}`); + console.warn("CSV parse warnings:", parseResult.errors); + new Notice(`${i18n.t("csv.parseWarning")} ${parseResult.errors[0].message}`); } return parseResult.data as string[][]; } catch (error) { - console.error("CSV解析错误:", error); - new Notice(`${i18n.t("csv.error")}: CSV解析失败,请检查文件格式`); + console.error("CSV parse error:", error); + new Notice(i18n.t("csv.parsingFailed")); return [[""]]; } } diff --git a/src/utils/history-manager.ts b/src/utils/history-manager.ts index 424f83e..2502406 100644 --- a/src/utils/history-manager.ts +++ b/src/utils/history-manager.ts @@ -1,4 +1,5 @@ import { Notice } from "obsidian"; +import { i18n } from "../i18n"; export class HistoryManager { private history: T[] = []; @@ -38,10 +39,10 @@ export class HistoryManager { undo(): T | null { if (this.canUndo()) { this.currentIndex--; - new Notice("已撤销上一步操作"); + new Notice(i18n.t("notifications.undo")); return this.getCurrentState(); } else { - new Notice("没有更多可撤销的操作"); + new Notice(i18n.t("notifications.noMoreUndo")); return null; } } @@ -52,10 +53,10 @@ export class HistoryManager { redo(): T | null { if (this.canRedo()) { this.currentIndex++; - new Notice("已重做操作"); + new Notice(i18n.t("notifications.redo")); return this.getCurrentState(); } else { - new Notice("没有更多可重做的操作"); + new Notice(i18n.t("notifications.noMoreRedo")); return null; } } diff --git a/src/utils/table-utils.ts b/src/utils/table-utils.ts index 940c2fc..1df5e0d 100644 --- a/src/utils/table-utils.ts +++ b/src/utils/table-utils.ts @@ -1,4 +1,5 @@ import { Notice } from "obsidian"; +import { i18n } from "../i18n"; export class TableUtils { /** @@ -42,7 +43,7 @@ export class TableUtils { */ static deleteRow(tableData: string[][]): string[][] { if (tableData.length <= 1) { - new Notice("至少需要保留一行"); + new Notice(i18n.t("tableMessages.atLeastOneRow")); return tableData; } @@ -61,7 +62,7 @@ export class TableUtils { */ static deleteColumn(tableData: string[][]): string[][] { if (!tableData[0] || tableData[0].length <= 1) { - new Notice("至少需要保留一列"); + new Notice(i18n.t("tableMessages.atLeastOneColumn")); return tableData; } diff --git a/src/view.ts b/src/view.ts index ab98a61..1d7ff76 100644 --- a/src/view.ts +++ b/src/view.ts @@ -793,6 +793,9 @@ export class CSVView extends TextFileView { document, "keydown", (event: KeyboardEvent) => { + // Only handle undo/redo when this view is the active leaf + if (this.app.workspace.activeLeaf !== this.leaf) return; + // 检测Ctrl+Z (或Mac上的Cmd+Z) if ((event.ctrlKey || event.metaKey) && event.key === "z") { if (event.shiftKey) { diff --git a/test/history-manager.test.ts b/test/history-manager.test.ts new file mode 100644 index 0000000..79821a0 --- /dev/null +++ b/test/history-manager.test.ts @@ -0,0 +1,50 @@ +import { TableHistoryManager } from "../src/utils/history-manager"; +import { Notice } from "obsidian"; +import { i18n } from "../src/i18n"; + +beforeEach(() => { + // reset Notice mock + (Notice as any).mockClear && (Notice as any).mockClear(); +}); + +test('undo on empty history shows noMoreUndo message', () => { + const hm = new TableHistoryManager([["a"]], 10); + // Initially only one state, cannot undo + const res = hm.undo(); + expect(res).toBeNull(); + expect(Notice).toHaveBeenCalledTimes(1); + expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.noMoreUndo')); +}); + +test('undo when available shows undo message', () => { + const hm = new TableHistoryManager([["a"]], 10); + hm.push([["b"]]); + // Now we can undo + const res = hm.undo(); + expect(res).toEqual([["a"]]); + expect(Notice).toHaveBeenCalledTimes(1); + expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.undo')); +}); + +test('redo on no-op shows noMoreRedo message', () => { + const hm = new TableHistoryManager([["a"]], 10); + // Nothing to redo + const res = hm.redo(); + expect(res).toBeNull(); + expect(Notice).toHaveBeenCalledTimes(1); + expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.noMoreRedo')); +}); + +test('redo when available shows redo message', () => { + const hm = new TableHistoryManager([["a"]], 10); + hm.push([["b"]]); + // undo back to first + const u = hm.undo(); + expect(u).toEqual([["a"]]); + // redo + const r = hm.redo(); + expect(r).toEqual([["b"]]); + // Two notices were called (undo, redo) + expect(Notice).toHaveBeenCalledTimes(2); + expect((Notice as any).mock.calls[1][0]).toBe(i18n.t('notifications.redo')); +}); From 30c0b8081a337b6834afbaf19dbf6b2bf86d6d82 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Sun, 7 Dec 2025 05:05:15 +0800 Subject: [PATCH 9/9] chore: bump version to 1.1.5 --- manifest.json | 2 +- package-lock.json | 2 +- package.json | 2 +- versions.json | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 5a254d6..4b90d8d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "csv-lite", "name": "CSV Lite", - "version": "1.1.4", + "version": "1.1.5", "minAppVersion": "1.8.0", "description": "Just open and edit CSV files directly, no more. Keep it simple.", "author": "Jay Bridge", diff --git a/package-lock.json b/package-lock.json index 3824fe8..edbfa66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "obsidian-csv", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index ef35bce..f48a63e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-csv", - "version": "1.1.4", + "version": "1.1.5", "description": "CSV viewer and editor for Obsidian", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 26382a1..75cae92 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "0.15.0" + "1.0.0": "0.15.0", + "1.1.5": "1.8.0" }