feat: Support for other delimiters (especially ";") (#37)

Fixes #36
This commit is contained in:
Jay Bridge 2025-09-16 15:02:29 +08:00 committed by GitHub
parent 1c30bdd385
commit 0ab3467654
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 301 additions and 78 deletions

View file

@ -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 NonDestructively**: Autodetects 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. <!-- For in-markdown edit, I recommend `anyblock` with a much more complex syntax. -->
@ -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 <!-- - Readme. Actually it's important to update readme, I hope you won't notice this line QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
- 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 <!-- - Readme. Actually it's important to update readme, I hope you won't notice this line QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
- 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—well 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.

View file

@ -22,6 +22,7 @@
- **切换** 表格视图和原始源码模式
- **编辑** 直接点击并输入即可编辑单元格
- **管理** 行和列(添加、删除、移动),只需右键点击表头
- **非破坏性分隔符切换**:自动检测文件原始分隔符(逗号、分号、制表符等)。在工具栏切换分隔符只会重新解析视图,不会改写文件;保存编辑时仍使用文件原始分隔符,避免产生大规模 diff。
我计划仅用 json 和 csv 设计自己的数据库。如果你有关于表格或 csv 的新想法,欢迎提 issue我会考虑加入 csv-lite 或新插件),或在社区中搜索。<!-- 对于 Markdown 内编辑,推荐 `anyblock`,但语法更复杂。 -->
@ -33,27 +34,38 @@
## 理念
- 没有花哨的 UI拒绝以下内容
- 模态框
- 侧边栏
- 设置选项卡 <!-- - Readme. 实际上更新 readme 很重要,希望你不会注意到这一行 QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
- 多余的在线文档和教程
- 上述所有 UI 组件的功能都将在单一文件视图中实现
- 一切都在 TextFileView/workspace 中完成
- 不再污染你的库,所有元数据都以 json 格式存储在 `./.obsidian/plugins/csv`(目前没有 `data.json`
- 每个功能必须在 3 步内完成:
- 没有花哨的 UI拒绝以下内容
- 模态框
- 侧边栏
- 设置选项卡 <!-- - Readme. 实际上更新 readme 很重要,希望你不会注意到这一行 QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
- 多余的在线文档和教程
- 上述所有 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 文件。

View file

@ -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 {

View file

@ -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 });
}
/**
*
*/

View file

@ -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) {

View file

@ -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;

View file

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