diff --git a/README.md b/README.md index 6ce12f6..a3318d8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,40 @@ If disabled, a timestamp will be used as the title e.g. `note-splitter-170259191 > [!NOTE] > Disabled by default. +### Append to split content + +This text will be appended to each section of split content. + +**Example:** + +Suppose you have two sentences and your delimiter is set to a period (`.`). + +```markdown +This is sentence 1. This is sentence 2. +``` + +The split content would result in: + +```markdown +This is sentence 1 +``` + +```markdown +This is sentence 2 +``` + +If you want to retain the period at the end of each sentence, simply add a period into the input field of this setting. + +The updated result would be: + +```markdown +This is sentence 1. +``` + +```markdown +This is sentence 2. +``` + ### Delete original If enabled, the original note will be deleted after a successful split. diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts new file mode 100644 index 0000000..490db5e --- /dev/null +++ b/__mocks__/obsidian.ts @@ -0,0 +1 @@ +export const normalizePath = jest.fn((path) => path); diff --git a/bun.lockb b/bun.lockb index 74bd8e4..a7b7ff6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..74f4614 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, +}; diff --git a/manifest.json b/manifest.json index a3a3bc4..5a7f845 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "note-splitter", "name": "Note Splitter", - "version": "1.1.1", + "version": "1.2.0", "minAppVersion": "0.15.0", "description": "Split a note into individual notes based on a delimiter.", "author": "DecafDev", diff --git a/package.json b/package.json index 188f323..01b2503 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "obsidian-note-splitter", - "version": "1.1.1", + "version": "1.2.0", "description": "Split notes based on a delimiter", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "jest --config jest.config.js", "format": "prettier --write .", "version": "node version-bump.mjs && git add manifest.json versions.json" }, @@ -13,13 +14,16 @@ "author": "DecafDev", "license": "MIT", "devDependencies": { + "@types/jest": "^29.5.12", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", "esbuild": "0.17.3", + "jest": "^29.7.0", "obsidian": "latest", "prettier": "^3.3.2", + "ts-jest": "^29.2.4", "tslib": "2.4.0", "typescript": "4.7.4" } diff --git a/src/main.ts b/src/main.ts index a28e715..c029695 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,13 @@ -import { MarkdownView, Notice, Plugin, TFile, normalizePath } from "obsidian"; -import { escapeInvalidFileNameChars, removeFrontmatterBlock, trimForFileName } from "./utils"; +import { MarkdownView, Notice, Plugin } from "obsidian"; import NoteSplitterSettingsTab from "./obsidian/note-splitter-settings-tab"; - -interface NoteSplitterSettings { - saveFolderPath: string; - useContentAsTitle: boolean; - delimiter: string; - deleteOriginalNote: boolean; -} +import { splitByDelimiter } from "./splitter/split-by-delimiter"; +import { NodeFileSystem, NoteSplitterSettings, Notifier } from "./types"; const DEFAULT_SETTINGS: NoteSplitterSettings = { saveFolderPath: "note-splitter", useContentAsTitle: false, delimiter: "\\n", + appendToSplitContent: "", deleteOriginalNote: false, }; @@ -40,101 +35,24 @@ export default class NoteSplitterPlugin extends Plugin { return; } - if (view.getMode() !== 'source') { - new Notice("Please switch to editing mode to split the note."); + if (view.getMode() !== "source") { + new Notice("Please switch to editing mode to split this note."); return; } - this.splitNoteByDelimiter(file); + const fileSystem: NodeFileSystem = { + create: (filePath, content) => this.app.vault.create(filePath, content), + createFolder: (folderPath) => this.app.vault.createFolder(folderPath), + delete: (file) => this.app.vault.delete(file), + read: (file) => this.app.vault.read(file), + }; + const notifier: Notifier = (message: string) => new Notice(message); + await splitByDelimiter(fileSystem, notifier, file, this.settings); }, }); } - onunload() { } - - private async splitNoteByDelimiter(file: TFile) { - //Obsidian will store `\n`` as `\\n` in the settings - const delimiter = this.settings.delimiter.replace(/\\n/g, "\n"); - - if (delimiter === "") { - new Notice("No delimiter set. Please set a delimiter in the settings."); - return; - } - - const data = await this.app.vault.cachedRead(file); - - const dataWithoutFrontmatter = removeFrontmatterBlock(data); - if (dataWithoutFrontmatter === "") { - new Notice("No content to split."); - return; - } - - const splitContent = dataWithoutFrontmatter - .split(delimiter) - .map((content) => content.trim()) - .filter((content) => content !== ""); - - if (splitContent.length === 0) { - new Notice("No content to split."); - return; - } - - if (splitContent.length === 1) { - new Notice("Only one piece of content found. Nothing to split."); - return; - } - - const folderPath = - this.settings.saveFolderPath || - file.parent?.path || - this.settings.saveFolderPath; - - try { - await this.app.vault.createFolder(folderPath); - } catch (err) { - //Folder already exists - } - - let filesCreated = 0; - for (const [i, content] of splitContent.entries()) { - let fileName = content.split("\n")[0]; - if (this.settings.useContentAsTitle) { - fileName = escapeInvalidFileNameChars(fileName); - fileName = trimForFileName(fileName, ".md"); - } else { - fileName = `split-note-${Date.now() + i}`; - } - - const filePath = normalizePath(`${folderPath}/${fileName}.md`); - - try { - await this.app.vault.create(filePath, content); - filesCreated++; - } catch (err) { - if (err.message.includes("already exists")) { - const newFilePath = `${folderPath}/Split conflict ${crypto.randomUUID()}.md`; - try { - await this.app.vault.create(newFilePath, content); - filesCreated++; - } catch (err) { - console.error(err); - new Notice(`Error creating file: ${err.message}`); - } - continue; - } - new Notice(`Error creating file: ${err.message}`); - console.log(err); - } - } - - if (filesCreated === splitContent.length && this.settings.deleteOriginalNote) { - await this.app.vault.delete(file); - } - - new Notice( - "Split into " + filesCreated + " note" + (filesCreated > 1 ? "s" : "") + ".", - ); - } + onunload() {} async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); diff --git a/src/obsidian/note-splitter-settings-tab.ts b/src/obsidian/note-splitter-settings-tab.ts index cf449da..e63a729 100644 --- a/src/obsidian/note-splitter-settings-tab.ts +++ b/src/obsidian/note-splitter-settings-tab.ts @@ -48,6 +48,16 @@ export default class NoteSplitterSettingsTab extends PluginSettingTab { }), ); + new Setting(containerEl) + .setName("Append to split content") + .setDesc("Text to append to the split content.") + .addText((text) => + text.setValue(this.plugin.settings.appendToSplitContent).onChange(async (value) => { + this.plugin.settings.appendToSplitContent = value; + await this.plugin.saveSettings(); + }), + ); + new Setting(containerEl) .setName("Delete original") .setDesc("Delete the original note after a successful split.") diff --git a/src/splitter/remove-frontmatter-block.ts b/src/splitter/remove-frontmatter-block.ts new file mode 100644 index 0000000..285f68e --- /dev/null +++ b/src/splitter/remove-frontmatter-block.ts @@ -0,0 +1,4 @@ +export const removeFrontmatterBlock = (content: string) => { + const FRONTMATTER_REGEX = /^---[\s\S]*?---/; + return content.replace(FRONTMATTER_REGEX, "").trim(); +}; diff --git a/src/splitter/sanitize-file-name.ts b/src/splitter/sanitize-file-name.ts new file mode 100644 index 0000000..da65d15 --- /dev/null +++ b/src/splitter/sanitize-file-name.ts @@ -0,0 +1,22 @@ +/** + * Sanitizes a file name for use in a file system + */ +export const sanitizeFileName = (name: string) => { + // Replace colon with hyphen + name = name.replace(/:/g, "-"); + // Replace back slash with space + name = name.replace(/\\/g, " "); + // Replace forward slash with space + name = name.replace(/\//g, " "); + // Replace carrot with nothing + name = name.replace(/\^/g, ""); + // Replace left bracket with nothing + name = name.replace(/\[/g, ""); + // Replace right bracket with nothing + name = name.replace(/\]/g, ""); + // Replace hash tag with nothing + name = name.replace(/#/g, ""); + // Replace pipe with nothing + name = name.replace(/\|/g, ""); + return name.trim(); +}; diff --git a/src/splitter/split-by-delimiter.ts b/src/splitter/split-by-delimiter.ts new file mode 100644 index 0000000..30fa78c --- /dev/null +++ b/src/splitter/split-by-delimiter.ts @@ -0,0 +1,102 @@ +import { normalizePath, TFile } from "obsidian"; +import { removeFrontmatterBlock } from "./remove-frontmatter-block"; +import { sanitizeFileName } from "./sanitize-file-name"; +import { truncateFileName } from "./truncate-file-name"; +import { NodeFileSystem, NoteSplitterSettings, Notifier } from "src/types"; + +export const splitByDelimiter = async ( + fileSystem: NodeFileSystem, + notify: Notifier, + file: TFile, + { + delimiter, + saveFolderPath, + useContentAsTitle, + appendToSplitContent, + deleteOriginalNote, + }: Pick< + NoteSplitterSettings, + | "saveFolderPath" + | "delimiter" + | "useContentAsTitle" + | "appendToSplitContent" + | "deleteOriginalNote" + >, +) => { + const escapedDelimiter = delimiter.replace(/\\n/g, "\n"); + + if (escapedDelimiter === "") { + notify("No delimiter set. Please set a delimiter in the settings."); + return; + } + + const data = await fileSystem.read(file); + const dataWithoutFrontmatter = removeFrontmatterBlock(data); + if (dataWithoutFrontmatter === "") { + notify("No content to split."); + return; + } + + const splitContent = dataWithoutFrontmatter + .split(escapedDelimiter) + .map((content) => content.trim()) + .filter((content) => content !== ""); + + if (splitContent.length === 1) { + notify("Only one section of content found. Nothing to split."); + return; + } + + const folderPath = saveFolderPath || ""; + + try { + await fileSystem.createFolder(folderPath); + } catch (err) { + // Folder already exists + } + + let filesCreated = 0; + for (const [i, originalContent] of splitContent.entries()) { + let updatedContent = originalContent; + if (appendToSplitContent.length > 0) { + updatedContent += appendToSplitContent; + } + + let fileName = ""; + if (useContentAsTitle) { + fileName = originalContent.split("\n")[0] + ".md"; + } else { + fileName = `split-note-${Date.now() + i}.md`; + } + + fileName = sanitizeFileName(fileName); + fileName = truncateFileName(fileName); + + const filePath = normalizePath(`${folderPath}/${fileName}`); + + try { + await fileSystem.create(filePath, updatedContent); + filesCreated++; + } catch (err) { + if (err.message.includes("already exists")) { + const newFilePath = `${folderPath}/Split conflict ${crypto.randomUUID()}.md`; + try { + await fileSystem.create(newFilePath, updatedContent); + filesCreated++; + } catch (err) { + console.error(err); + notify(`Error creating file: ${err.message}`); + } + continue; + } + notify(`Error creating file: ${err.message}`); + console.log(err); + } + } + + if (deleteOriginalNote && filesCreated === splitContent.length) { + await fileSystem.delete(file); + } + + notify("Split into " + filesCreated + " note" + (filesCreated > 1 ? "s" : "") + "."); +}; diff --git a/src/splitter/truncate-file-name.ts b/src/splitter/truncate-file-name.ts new file mode 100644 index 0000000..98ee941 --- /dev/null +++ b/src/splitter/truncate-file-name.ts @@ -0,0 +1,15 @@ +/** + * Truncates the string to the maximum length allowed for a file name + */ +export const truncateFileName = (name: string) => { + const MAX_LENGTH = 255; + + const splitArr = name.split("."); + if (splitArr.length < 2) { + throw new Error("Invalid file name"); + } + + const baseName = splitArr[0]; + const extension = splitArr[1]; + return baseName.substring(0, MAX_LENGTH - extension.length - 1) + "." + splitArr[1]; +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1a9e2df --- /dev/null +++ b/src/types.ts @@ -0,0 +1,18 @@ +import { TFile, TFolder } from "obsidian"; + +export interface NoteSplitterSettings { + saveFolderPath: string; + useContentAsTitle: boolean; + delimiter: string; + appendToSplitContent: string; + deleteOriginalNote: boolean; +} + +export interface NodeFileSystem { + read(file: TFile): Promise; + create(filePath: string, content: string): Promise; + createFolder(folderPath: string): Promise; + delete(file: TFile): Promise; +} + +export type Notifier = (message: string) => void; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index c8e10d5..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const removeFrontmatterBlock = (data: string) => { - // Define the regular expression for the frontmatter block - const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/; - return data.replace(FRONTMATTER_REGEX, "").trim(); -}; - -/** - * Escapes characters invalid for a file name - * @param value - */ -export const escapeInvalidFileNameChars = (value: string) => { - // Replace colon with hyphen - value = value.replace(/:/g, "-"); - // Replace back slash with space - value = value.replace(/\\/g, " "); - // Replace forward slash with space - value = value.replace(/\//g, " "); - // Replace carrot with nothing - value = value.replace(/\^/g, ""); - // Replace left bracket with nothing - value = value.replace(/\[/g, ""); - // Replace right bracket with nothing - value = value.replace(/\]/g, ""); - // Replace hash tag with nothing - value = value.replace(/#/g, ""); - // Replace pipe with nothing - value = value.replace(/\|/g, ""); - return value; -}; - -/** - * Trims the string to the maximum length allowed for a file name - */ -export const trimForFileName = (value: string, extension: string) => { - const MAX_LENGTH = 255; - return value.substring(0, MAX_LENGTH - extension.length - 1); -}; diff --git a/test/integration/split-by-delimiter.test.ts b/test/integration/split-by-delimiter.test.ts new file mode 100644 index 0000000..9efeaf3 --- /dev/null +++ b/test/integration/split-by-delimiter.test.ts @@ -0,0 +1,170 @@ +import { TFile } from "obsidian"; +import { splitByDelimiter } from "src/splitter/split-by-delimiter"; +import { NodeFileSystem, Notifier } from "src/types"; + +const mockFileSystem: NodeFileSystem = { + read: jest.fn((file: TFile) => { + if (file.path === "file1.md") { + return Promise.resolve("---\nkey:value\n---\n"); + } else if (file.path === "file2.md") { + return Promise.resolve("This is my content"); + } else { + return Promise.resolve("This is sentence 1\nThis is sentence 2"); + } + }), + create: jest.fn(), + createFolder: jest.fn(), + delete: jest.fn(), +}; + +const mockNotifier: Notifier = jest.fn(); + +describe("splitByDelimiter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should notify if no delimiter is set", async () => { + // Arrange + const file = { + path: "file1.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: "", + deleteOriginalNote: false, + }); + + expect(mockNotifier).toHaveBeenCalledWith(expect.stringContaining("No delimiter")); + }); + + it("should notify if there is no content is set", async () => { + // Arrange + const file = { + path: "file1.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: "", + deleteOriginalNote: false, + }); + + expect(mockNotifier).toHaveBeenCalledWith(expect.stringContaining("No content")); + }); + + it("should notify if only one section is found", async () => { + // Arrange + const file = { + path: "file2.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: "", + deleteOriginalNote: false, + }); + + expect(mockNotifier).toHaveBeenCalledWith(expect.stringContaining("one section")); + }); + + it("should split into 2 files", async () => { + // Arrange + const file = { + path: "file3.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: "", + deleteOriginalNote: false, + }); + + expect(mockFileSystem.read).toHaveBeenCalledTimes(1); + expect(mockFileSystem.create).toHaveBeenCalledTimes(2); + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("split-note"), + "This is sentence 1", + ); + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("split-note"), + "This is sentence 2", + ); + expect(mockFileSystem.delete).not.toHaveBeenCalled(); + expect(mockNotifier).toHaveBeenCalledWith(expect.stringContaining("2 notes")); + }); + + it("should delete the original note when the setting is true", async () => { + // Arrange + const file = { + path: "file3.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: "", + deleteOriginalNote: true, + }); + + expect(mockFileSystem.delete).toHaveBeenCalledWith(file); + }); + + it("should append to split content", async () => { + // Arrange + const file = { + path: "file3.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: false, + appendToSplitContent: ".", + deleteOriginalNote: false, + }); + + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("split-note"), + "This is sentence 1.", + ); + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("split-note"), + "This is sentence 2.", + ); + }); + + it("should use content as title", async () => { + // Arrange + const file = { + path: "file3.md", + } as TFile; + + await splitByDelimiter(mockFileSystem, mockNotifier, file, { + delimiter: "\n", + saveFolderPath: "", + useContentAsTitle: true, + appendToSplitContent: "", + deleteOriginalNote: false, + }); + + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("sentence 1"), + "This is sentence 1", + ); + expect(mockFileSystem.create).toHaveBeenCalledWith( + expect.stringContaining("sentence 2"), + "This is sentence 2", + ); + }); +}); diff --git a/test/unit/remove-frontmatter-block.test.ts b/test/unit/remove-frontmatter-block.test.ts new file mode 100644 index 0000000..91a4546 --- /dev/null +++ b/test/unit/remove-frontmatter-block.test.ts @@ -0,0 +1,19 @@ +import { removeFrontmatterBlock } from "src/splitter/remove-frontmatter-block"; + +describe("removeFrontmatterBlock", () => { + it("should remove frontmatter block", () => { + // Arrange + const content = `--- + key1: value + key2: value + --- + + This is some text`; + + // Act + const result = removeFrontmatterBlock(content); + + // Assert + expect(result).toEqual("This is some text"); + }); +}); diff --git a/test/unit/sanitize-file-name.test.ts b/test/unit/sanitize-file-name.test.ts new file mode 100644 index 0000000..c6e837e --- /dev/null +++ b/test/unit/sanitize-file-name.test.ts @@ -0,0 +1,47 @@ +import { sanitizeFileName } from "src/splitter/sanitize-file-name"; + +describe("sanitizeFileName", () => { + it("should replace colon with hyphen", () => { + // Arrange + const content = "file:name"; + + // Act + const result = sanitizeFileName(content); + + // Assert + expect(result).toEqual("file-name"); + }); + + it("should replace backslash with space", () => { + // Arrange + const content = "file\\name"; + + // Act + const result = sanitizeFileName(content); + + // Assert + expect(result).toEqual("file name"); + }); + + it("should replace forward slash with space", () => { + // Arrange + const content = "file//name"; + + // Act + const result = sanitizeFileName(content); + + // Assert + expect(result).toEqual("file name"); + }); + + it("should remove invalid characters", () => { + // Arrange + const content = "file name #|^[]"; + + // Act + const result = sanitizeFileName(content); + + // Assert + expect(result).toEqual("file name"); + }); +}); diff --git a/test/unit/truncate-file-name.test.ts b/test/unit/truncate-file-name.test.ts new file mode 100644 index 0000000..1fb896a --- /dev/null +++ b/test/unit/truncate-file-name.test.ts @@ -0,0 +1,26 @@ +import { truncateFileName } from "src/splitter/truncate-file-name"; + +describe("truncateFileName", () => { + it("should throw an error if the file name is invalid", () => { + // Arrange + const fileName = "file"; + + // Act + const action = () => truncateFileName(fileName); + + // Assert + expect(action).toThrow(); + }); + + it("should truncate the string to 255 characters", () => { + // Arrange + const fileName = "a".repeat(260) + ".md"; + + // Act + const result = truncateFileName(fileName); + + // Assert + expect(result.length).toEqual(255); + expect(result.endsWith(".md")).toEqual(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c3939c0..100c501 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,9 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "esModuleInterop": true, + "lib": ["DOM", "ES5", "ES6", "ES7"], + "types": ["jest"] }, "include": ["**/*.ts"] } diff --git a/versions.json b/versions.json index bf0e1b0..cd1f624 100644 --- a/versions.json +++ b/versions.json @@ -11,5 +11,6 @@ "0.6.0": "0.15.0", "1.0.0": "0.15.0", "1.1.0": "0.15.0", - "1.1.1": "0.15.0" + "1.1.1": "0.15.0", + "1.2.0": "0.15.0" }