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(';'); + }); +});