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 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/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/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/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 33f51eb..1d7ff76 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 } }); @@ -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) { @@ -983,7 +986,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 } }); 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')); +}); 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" }