├── .prettierignore ├── .eslintignore ├── .gitattributes ├── .commitlintrc.json ├── .npmpackagejsonlintrc ├── .husky ├── pre-commit └── commit-msg ├── .prettierrc.json ├── .lintstagedrc ├── src ├── diff │ ├── utils │ │ ├── is-object.ts │ │ ├── is-array.ts │ │ └── append-dot-path.ts │ └── index.ts ├── sentence │ ├── utils │ │ ├── humanize.ts │ │ ├── get-old-val.ts │ │ ├── get-field.ts │ │ ├── format-property-value.ts │ │ ├── get-dot-path.ts │ │ └── get-new-val.ts │ └── index.ts ├── engine │ ├── utils │ │ ├── get-prefilter.ts │ │ ├── defaults.ts │ │ └── array-preprocessor.ts │ └── index.ts ├── index.ts └── types.ts ├── jest.config.ts ├── tsconfig.lint.json ├── .gitignore ├── .editorconfig ├── test ├── duplicated-key-in-template.test.ts ├── array-of-objects.test.ts ├── get-new-value.test.ts ├── ignore-arrays.test.ts ├── sensitive-paths.test.ts ├── custom-templates.test.ts ├── arrays.test.ts ├── test.test.ts ├── nested-empty.test.ts └── prefilter.test.ts ├── tsconfig.json ├── .eslintrc ├── .github └── workflows │ └── node.js.yml ├── LICENSE ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.* 2 | dist 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { "extends": ["@commitlint/config-conventional"] } 2 | -------------------------------------------------------------------------------- /.npmpackagejsonlintrc: -------------------------------------------------------------------------------- 1 | { "extends": "npm-package-json-lint-config-default" } 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.md,!test/**/*.md": "prettier --check", 3 | "*.{ts,js}": ["prettier --write", "eslint --fix"] 4 | } 5 | -------------------------------------------------------------------------------- /src/diff/utils/is-object.ts: -------------------------------------------------------------------------------- 1 | export default function isObject(x: unknown) { 2 | return typeof x === 'object' && x !== null 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | testEnvironment: 'node', 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*"], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | dist 8 | .vscode 9 | *.tgz 10 | 11 | Thumbs.db 12 | tmp/ 13 | temp/ 14 | *.lcov 15 | .env 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /src/diff/utils/is-array.ts: -------------------------------------------------------------------------------- 1 | import { type Diff } from 'deep-diff' 2 | 3 | export function isArrayDiff(diff: Diff): boolean { 4 | const isArray = diff.kind === 'A' || diff.path?.map((p) => typeof p).includes('number') 5 | return Boolean(isArray) 6 | } 7 | -------------------------------------------------------------------------------- /test/duplicated-key-in-template.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | 3 | it('I can reuse FIELD name in template', () => { 4 | const hr = new HR({ templates: { E: 'FIELD: OLDVALUE -> FIELD: NEWVALUE' } }).diff 5 | 6 | expect(hr(1, 2)).toEqual([': 1 -> : 2']) 7 | expect(hr({ val: 1 }, { val: 2 })).toEqual(['Val: 1 -> Val: 2']) 8 | }) 9 | -------------------------------------------------------------------------------- /src/sentence/utils/humanize.ts: -------------------------------------------------------------------------------- 1 | import humanizeStr from 'humanize-string' 2 | import titleize from 'titleize' 3 | import { type DiffConfig } from '../../types' 4 | 5 | export default function humanize(prop: string, config: Pick) { 6 | return config.dontHumanizePropertyNames ? prop : titleize(humanizeStr(prop)) 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "moduleResolution": "Node16", 5 | "target": "ES2022", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "declaration": true 10 | }, 11 | "watchOptions": { 12 | "excludeDirectories": ["dist", "node_modules"] 13 | }, 14 | "exclude": ["node_modules", "dist"], 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /src/sentence/utils/get-old-val.ts: -------------------------------------------------------------------------------- 1 | import { type DiffContext } from '../index' 2 | import formatPropertyValue from './format-property-value' 3 | 4 | export default function getOldValue(context: DiffContext) { 5 | if (typeof context.diff === 'string') { 6 | return '' 7 | } 8 | 9 | let formatted = '' 10 | if ('lhs' in context.diff && context.diff.lhs) { 11 | formatted = formatPropertyValue(context.diff.lhs, context.config) 12 | } else if (context.diff.val) { 13 | formatted = formatPropertyValue(context.diff.val, context.config) 14 | } 15 | 16 | return formatted.replace(/"/g, '') 17 | } 18 | -------------------------------------------------------------------------------- /src/sentence/utils/get-field.ts: -------------------------------------------------------------------------------- 1 | import { type DiffContext } from '../index' 2 | import humanize from './humanize' 3 | 4 | export default function getField(context: DiffContext): string { 5 | if (typeof context.diff === 'string') { 6 | return '' 7 | } 8 | 9 | if (context.diff.path) { 10 | let propertyIndex = context.diff.path.length - 1 11 | while (typeof context.diff.path[propertyIndex] !== 'string') { 12 | propertyIndex -= 1 13 | } 14 | 15 | const property = context.diff.path[propertyIndex] 16 | return humanize(String(property), context.config) 17 | } 18 | 19 | return '' 20 | } 21 | -------------------------------------------------------------------------------- /src/sentence/utils/format-property-value.ts: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format' 2 | import { type DiffConfigWithoutTemplates } from '../../types' 3 | 4 | export default function formatPropertyValue(value: unknown, config: Pick) { 5 | if (typeof value === 'string') { 6 | return `"${value}"` 7 | } 8 | 9 | if (typeof value === 'number' || typeof value === 'boolean') { 10 | return String(value) 11 | } 12 | 13 | if (typeof value === 'bigint') { 14 | return `${String(value)}n` 15 | } 16 | 17 | if (value instanceof Date && config.dateFormat) { 18 | return `${format(value, config.dateFormat)}` 19 | } 20 | 21 | return JSON.stringify(value) 22 | } 23 | -------------------------------------------------------------------------------- /src/sentence/utils/get-dot-path.ts: -------------------------------------------------------------------------------- 1 | import { type DiffContext } from '../index' 2 | import { dotPathReducer } from '../../diff/utils/append-dot-path' 3 | 4 | export default function getDotPath(context: DiffContext): string { 5 | if (typeof context.diff === 'string') { 6 | return `${context.config.objectName}` 7 | } 8 | 9 | if (context.diff.dotPath) { 10 | return `${context.config.objectName}.${context.diff.dotPath}` 11 | } 12 | 13 | const { diff } = context 14 | 15 | const path = Array.isArray(diff.path) ? [...diff.path].reduce(dotPathReducer({ path: diff.path }), '') : '' 16 | 17 | // eslint-disable-next-line unicorn/consistent-destructuring 18 | return `${context.config.objectName}.${path}` 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["xo", "xo-typescript", "plugin:unicorn/recommended", "prettier"], 3 | "plugins": ["@typescript-eslint"], 4 | "parserOptions": { 5 | "project": "./tsconfig.lint.json" 6 | }, 7 | "rules": { 8 | "@typescript-eslint/naming-convention": "off", 9 | "@typescript-eslint/no-confusing-void-expression": "off", 10 | "@typescript-eslint/object-curly-spacing": "off", 11 | "@typescript-eslint/indent": "off", 12 | "operator-linebreak": "off", 13 | "import/no-anonymous-default-export": "off", 14 | "@typescript-eslint/comma-dangle": "off", 15 | "@typescript-eslint/ban-types": "off", 16 | "@typescript-eslint/no-unsafe-call": "off", 17 | "unicorn/prefer-module": "off", 18 | "unicorn/prevent-abbreviations": "off", 19 | "unicorn/prefer-spread": "off" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/engine/utils/get-prefilter.ts: -------------------------------------------------------------------------------- 1 | import type deepDiff from 'deep-diff' 2 | import { type DiffConfigWithoutTemplates } from '../../types' 3 | 4 | export default function getPrefilter( 5 | config: DiffConfigWithoutTemplates 6 | ): deepDiff.PreFilter | undefined { 7 | let prefilter: deepDiff.PreFilter | undefined 8 | 9 | if (config.prefilter && Array.isArray(config.prefilter)) { 10 | prefilter = (path: unknown[], key): boolean => 11 | Boolean( 12 | Array.isArray(path) && 13 | path.length === 0 && 14 | config.prefilter && 15 | Array.isArray(config.prefilter) && 16 | config.prefilter.includes(key) 17 | ) 18 | } else if (typeof config.prefilter === 'function') { 19 | prefilter = config.prefilter 20 | } 21 | 22 | return prefilter 23 | } 24 | -------------------------------------------------------------------------------- /src/sentence/utils/get-new-val.ts: -------------------------------------------------------------------------------- 1 | import { type DiffContext } from '../index' 2 | import formatPropertyValue from './format-property-value' 3 | 4 | export function isDisplayable(value: unknown): boolean { 5 | return Boolean(value) || Number.isFinite(value) || ['boolean', 'bigint'].includes(typeof value) 6 | } 7 | 8 | export function getNewValue(context: DiffContext): string { 9 | let formatted 10 | if (typeof context.diff === 'string') { 11 | return '' 12 | } 13 | 14 | if ('val' in context.diff && isDisplayable(context.diff.val)) { 15 | formatted = formatPropertyValue(context.diff.val, context.config) 16 | } else if ('rhs' in context.diff && context.diff.rhs) { 17 | formatted = formatPropertyValue(context.diff.rhs, context.config) 18 | } else { 19 | formatted = '' 20 | } 21 | 22 | return formatted.replace(/"/g, '') 23 | } 24 | -------------------------------------------------------------------------------- /test/array-of-objects.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | import { isArrayDiff } from '../src/diff/utils/is-array' 3 | 4 | describe('array of objects', () => { 5 | const hr = new HR().diff 6 | 7 | it('array of primitives', () => { 8 | const lhs = { 9 | items: [1, 3], 10 | } 11 | 12 | const rhs = { 13 | items: [1, 2, 3], 14 | } 15 | 16 | expect(hr(lhs, rhs)).toEqual(['Array "Items" (at Obj.items), had a value of "2" inserted at index 1']) 17 | }) 18 | 19 | it('array of objects', () => { 20 | const lhs = { 21 | items: [{ key: 1 }, { key: 3 }], 22 | } 23 | 24 | const rhs = { 25 | items: [{ key: 1 }, { key: 2 }, { key: 3 }], 26 | } 27 | 28 | expect(hr(lhs, rhs)).toEqual(['Array "Items" (at Obj.items), had a value of "{key:2}" inserted at index 1']) 29 | 30 | expect(isArrayDiff({ kind: 'E', path: ['items', 1, 'key'], lhs: 3, rhs: 2 })).toBeTruthy() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/get-new-value.test.ts: -------------------------------------------------------------------------------- 1 | import { getNewValue } from '../src/sentence/utils/get-new-val' 2 | import { type DiffContext } from '../src/sentence' 3 | import { defaultConfig } from '../src/engine/utils/defaults' 4 | 5 | function getContextForValue(value: unknown): DiffContext { 6 | return { 7 | diff: { 8 | path: [], 9 | dotPath: '', 10 | kind: 'I', 11 | index: 0, 12 | val: value, 13 | }, 14 | templates: defaultConfig().templates, 15 | config: defaultConfig(), 16 | } 17 | } 18 | 19 | describe('get new value', () => { 20 | it('positive number', () => { 21 | expect(getNewValue(getContextForValue(5))).toEqual('5') 22 | }) 23 | 24 | it('zero number', () => { 25 | expect(getNewValue(getContextForValue(0))).toEqual('0') 26 | }) 27 | 28 | it('false boolean', () => { 29 | expect(getNewValue(getContextForValue(false))).toEqual('false') 30 | }) 31 | 32 | it('bigint zero number', () => { 33 | expect(getNewValue(getContextForValue(0n))).toEqual('0n') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ['master'] 9 | pull_request: 10 | branches: ['master'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x, 16.x, 18.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm i -g npm@latest 29 | - run: npm install 30 | - run: npm run lint 31 | - run: npm run build --if-present 32 | - run: npm test 33 | -------------------------------------------------------------------------------- /test/ignore-arrays.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | 3 | describe('ignore-arrays', () => { 4 | let hr: Function 5 | 6 | beforeEach(() => { 7 | hr = new HR({ ignoreArrays: true }).diff 8 | }) 9 | 10 | test('humanReadable is a function', () => { 11 | expect(typeof hr === 'function').toBeTruthy() 12 | }) 13 | 14 | test('ignores arrays', () => { 15 | const lhs = { 16 | foo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 17 | bar: 'hello', 18 | baz: 12, 19 | chip: 'dale', 20 | biz: { baz: [1, 2, { hello: ['then'] }, 4, 5] }, 21 | base: [1, 2, 3, 4, 5], 22 | } 23 | 24 | const rhs = { 25 | foo: [1, 2, 3, 4, 5, 5, 7, 8, 9, 10, 11], 26 | bar: 'hello world', 27 | baz: 10, 28 | biz: { baz: [1, 2, { hello: ['there', 'was'] }, 5] }, 29 | base: [1, 2, 2, 5], 30 | } 31 | 32 | expect(hr(lhs, rhs)).toEqual([ 33 | '"Bar", with a value of "hello" (at Obj.bar) was changed to "hello world"', 34 | '"Baz", with a value of "12" (at Obj.baz) was changed to "10"', 35 | '"Chip", with a value of "dale" (at Obj.chip) was removed', 36 | ]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/diff/utils/append-dot-path.ts: -------------------------------------------------------------------------------- 1 | import type deepDiff from 'deep-diff' 2 | import { isArrayDiff } from './is-array' 3 | 4 | export function appendDotPath(diff: deepDiff.Diff): string { 5 | if (!diff.path) { 6 | return '' 7 | } 8 | 9 | if (!isArrayDiff(diff)) { 10 | return diff.path.reduce(dotPathReducer({ path: diff.path }), '') 11 | } 12 | 13 | let propertyIndex = diff.path.length - 1 14 | while (typeof diff.path[propertyIndex] !== 'string') { 15 | propertyIndex -= 1 16 | } 17 | 18 | return diff.path.slice(0, propertyIndex + 1).reduce(dotPathReducer({ path: diff.path }), '') 19 | } 20 | 21 | export function dotPathReducer(diff: { path: any[] | undefined | string[] | unknown[] }) { 22 | return function (acc: string, value: unknown, i: number): string { 23 | return typeof value === 'string' 24 | ? diff.path && typeof diff.path[i + 1] === 'string' 25 | ? acc.concat(`${String(value)}.`) 26 | : acc.concat(String(value)) 27 | : diff.path && typeof diff.path[i + 1] === 'string' 28 | ? acc.concat(`[${String(value)}].`) 29 | : acc.concat(`[${String(value)}]`) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Spencer Snyder (http://spencersnyder.io/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/sensitive-paths.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | 3 | describe('sensitive-paths', () => { 4 | let hr: Function 5 | 6 | beforeEach(() => { 7 | const sensitivePaths = ['foo', 'bar', 'biz', 'arr', 'arr2', 'arr3'] 8 | hr = new HR({ sensitivePaths }).diff 9 | }) 10 | 11 | test('humanReadable is a function', () => { 12 | expect(typeof hr === 'function').toBeTruthy() 13 | }) 14 | 15 | test('Uses custom templates successfully', () => { 16 | const lhs = { 17 | foo: 'bar', 18 | biz: 'baz', 19 | arr: [1, 2, 3], 20 | arr2: [1, 2, 3, 4], 21 | arr3: [1, 2, 3, 4], 22 | } 23 | const rhs = { 24 | bar: 'foo', 25 | biz: 'buz', 26 | arr: [1, 2, 5, 3], 27 | arr2: [1, 2, 4], 28 | arr3: [1, 2, 4, 4], 29 | } 30 | 31 | expect(hr(lhs, rhs)).toEqual([ 32 | '"Foo" (at Obj.foo) was removed', 33 | '"Biz" (at Obj.biz) was changed', 34 | '"Bar" (at Obj.bar) was added', 35 | 'Array "Arr" (at Obj.arr), had a value inserted at index 2', 36 | 'Array "Arr2" (at Obj.arr2), had a value removed at index 2', 37 | 'Array "Arr3" (at Obj.arr3), had a value changed at index 2', 38 | ]) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { humanReadableDiffGenerator } from './engine' 2 | import { defaultConfig } from './engine/utils/defaults' 3 | import type DiffSentence from './sentence' 4 | import { type DiffConfig, type DiffConfigWithoutTemplates, type InputDiffConfig } from './types' 5 | 6 | class DiffEngine { 7 | public diff 8 | 9 | protected readonly sentenceDiffs: DiffSentence[] 10 | protected readonly sentences: string[] 11 | 12 | private readonly config: DiffConfigWithoutTemplates 13 | private readonly templates 14 | 15 | constructor(config: InputDiffConfig = {}) { 16 | const cfg: DiffConfig = { 17 | ...defaultConfig(), 18 | ...config, 19 | templates: { 20 | ...defaultConfig().templates, 21 | ...config.templates, 22 | }, 23 | } 24 | const { templates, ...conf } = cfg 25 | this.config = conf 26 | this.templates = { ...defaultConfig().templates, ...templates } 27 | this.sentenceDiffs = [] 28 | this.sentences = [] 29 | this.diff = humanReadableDiffGenerator({ 30 | config: this.config, 31 | sentenceDiffs: this.sentenceDiffs, 32 | templates: this.templates, 33 | sentences: this.sentences, 34 | }) 35 | } 36 | } 37 | 38 | // ES module export 39 | export = DiffEngine 40 | -------------------------------------------------------------------------------- /src/engine/utils/defaults.ts: -------------------------------------------------------------------------------- 1 | import { type DefaultDiffConfig } from '../../types' 2 | 3 | export function defaultConfig(): DefaultDiffConfig { 4 | return { 5 | dateFormat: 'MM/dd/yyyy hh:mm a', 6 | objectName: 'Obj', 7 | ignoreArrays: false, 8 | sensitivePaths: [], 9 | dontHumanizePropertyNames: false, 10 | templates: { 11 | N: '"FIELD", with a value of "NEWVALUE" (at DOTPATH) was added', 12 | D: '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was removed', 13 | E: '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was changed to "NEWVALUE"', 14 | I: 'Array "FIELD" (at DOTPATH), had a value of "NEWVALUE" inserted at index INDEX', 15 | R: 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" removed at index INDEX', 16 | AE: 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" changed to "NEWVALUE" at index INDEX', 17 | NS: '"FIELD" (at DOTPATH) was added', 18 | DS: '"FIELD" (at DOTPATH) was removed', 19 | ES: '"FIELD" (at DOTPATH) was changed', 20 | IS: 'Array "FIELD" (at DOTPATH), had a value inserted at index INDEX', 21 | RS: 'Array "FIELD" (at DOTPATH), had a value removed at index INDEX', 22 | AES: 'Array "FIELD" (at DOTPATH), had a value changed at index INDEX', 23 | }, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/diff/index.ts: -------------------------------------------------------------------------------- 1 | import type deepDiff from 'deep-diff' 2 | import { appendDotPath } from './utils/append-dot-path' 3 | import isObject from './utils/is-object' 4 | import { isArrayDiff } from './utils/is-array' 5 | 6 | export default class Diff { 7 | public readonly isArray: boolean 8 | public readonly lhs: unknown 9 | public readonly rhs: unknown 10 | public index: number | undefined 11 | public readonly path: unknown[] | undefined 12 | public val: unknown // Seems to be invalid // TODO this is probably bug 13 | public readonly dotPath: string 14 | readonly kind: 'N' | 'D' | 'A' | 'E' 15 | private readonly item: deepDiff.Diff | undefined 16 | private readonly hasNestedChanges: boolean 17 | 18 | constructor(diff: deepDiff.Diff) { 19 | this.kind = diff.kind 20 | if (diff.kind !== 'E' && diff.kind !== 'D' && diff.kind !== 'N') { 21 | this.index = diff.index 22 | } 23 | 24 | if (diff.kind !== 'A' && diff.kind !== 'N') { 25 | this.lhs = diff.lhs 26 | } 27 | 28 | if (diff.kind !== 'A' && diff.kind !== 'D') { 29 | this.rhs = diff.rhs 30 | } 31 | 32 | if (diff.kind !== 'E' && diff.kind !== 'D' && diff.kind !== 'N') { 33 | this.item = diff.item 34 | } 35 | 36 | this.path = diff.path 37 | this.isArray = isArrayDiff(diff) 38 | this.hasNestedChanges = diff.kind !== 'E' && diff.kind !== 'D' && diff.kind !== 'N' && isObject(diff.item) 39 | 40 | this.dotPath = appendDotPath(diff) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/custom-templates.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | 3 | describe('custom-templates', () => { 4 | let hr: Function 5 | 6 | beforeEach(() => { 7 | const templates = { 8 | N: 'testing "FIELD" "NEWVALUE" DOTPATH added', 9 | D: 'testing "FIELD" "OLDVALUE" DOTPATH removed', 10 | E: 'testing "FIELD" "OLDVALUE" (DOTPATH) "NEWVALUE" edit', 11 | I: 'testing Arr "FIELD" (DOTPATH), "NEWVALUE" inserted at INDEX', 12 | R: 'testing Arr "FIELD" (DOTPATH), "OLDVALUE" removed at INDEX', 13 | AE: 'testing Arr "FIELD" (DOTPATH), "OLDVALUE" "NEWVALUE" changed at INDEX', 14 | } 15 | hr = new HR({ templates }).diff 16 | }) 17 | 18 | it('humanReadable is a function', () => { 19 | expect(typeof hr === 'function').toBeTruthy() 20 | }) 21 | 22 | it('Uses custom templates successfully', () => { 23 | const lhs = { 24 | foo: 'bar', 25 | biz: 'baz', 26 | arr: [1, 2, 3], 27 | arr2: [1, 2, 3, 4], 28 | arr3: [1, 2, 3, 4], 29 | } 30 | const rhs = { 31 | bar: 'foo', 32 | biz: 'buz', 33 | arr: [1, 2, 5, 3], 34 | arr2: [1, 2, 4], 35 | arr3: [1, 2, 4, 4], 36 | } 37 | 38 | expect(hr(lhs, rhs)).toEqual([ 39 | 'testing "Foo" "bar" Obj.foo removed', 40 | 'testing "Biz" "baz" (Obj.biz) "buz" edit', 41 | 'testing "Bar" "foo" Obj.bar added', 42 | 'testing Arr "Arr" (Obj.arr), "5" inserted at 2', 43 | 'testing Arr "Arr2" (Obj.arr2), "3" removed at 2', 44 | 'testing Arr "Arr3" (Obj.arr3), "3" "4" changed at 2', 45 | ]) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/engine/index.ts: -------------------------------------------------------------------------------- 1 | import deepDiff from 'deep-diff' 2 | import DiffSentence from '../sentence/index' 3 | import Diff from '../diff/index' 4 | import { type DiffEngineContext } from '../types' 5 | import { preProcessArrayDiffs } from './utils/array-preprocessor' 6 | import getPrefilter from './utils/get-prefilter' 7 | 8 | export function humanReadableDiffGenerator(context: DiffEngineContext): (lhs: unknown, rhs: unknown) => string[] { 9 | return (lhs: unknown, rhs: unknown): string[] => { 10 | const arrayDiffs = [] 11 | const sentences = [] 12 | const computedPreFilter = getPrefilter(context.config) 13 | 14 | const differences = deepDiff(lhs, rhs, computedPreFilter) 15 | 16 | if (!differences) { 17 | return [] 18 | } 19 | 20 | for (const singleDeepDiff of differences) { 21 | const diff = new Diff(singleDeepDiff) 22 | 23 | if (diff.isArray) { 24 | if (!context.config.ignoreArrays) { 25 | arrayDiffs.push(diff) 26 | } 27 | 28 | continue 29 | } 30 | 31 | const sentenceDiff = new DiffSentence(diff, context.config, context.templates) 32 | context.sentenceDiffs.push(sentenceDiff) 33 | sentences.push(sentenceDiff.format()) 34 | } 35 | 36 | if (!context.config.ignoreArrays) { 37 | for (const diff of preProcessArrayDiffs(arrayDiffs, lhs, rhs)) { 38 | const sentenceDiff = new DiffSentence(diff, context.config, context.templates) 39 | context.sentenceDiffs.push(sentenceDiff) 40 | sentences.push(sentenceDiff.format()) 41 | } 42 | } 43 | 44 | return sentences 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/arrays.test.ts: -------------------------------------------------------------------------------- 1 | import HRDiff from '../src/index' 2 | 3 | describe('arrays', () => { 4 | let hr: Function 5 | 6 | beforeEach(() => { 7 | hr = new HRDiff().diff 8 | }) 9 | 10 | it('humanReadable is a function', () => { 11 | expect(typeof hr === 'function').toBeTruthy() 12 | }) 13 | 14 | it('processes an array', () => { 15 | const lhs = { 16 | foo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 17 | bar: 'hello', 18 | baz: 12, 19 | chip: 'dale', 20 | biz: { baz: [1, 2, { hello: ['then'] }, 4, 5] }, 21 | base: [1, 2, 3, 4, 5], 22 | } 23 | 24 | const rhs = { 25 | foo: [1, 2, 3, 4, 5, 5, 7, 8, 9, 10, 11], 26 | bar: 'hello world', 27 | baz: 10, 28 | biz: { baz: [1, 2, { hello: ['there', 'was'] }, 5] }, 29 | base: [1, 2, 2, 5], 30 | } 31 | 32 | expect(hr(lhs, rhs)).toEqual([ 33 | '"Bar", with a value of "hello" (at Obj.bar) was changed to "hello world"', 34 | '"Baz", with a value of "12" (at Obj.baz) was changed to "10"', 35 | '"Chip", with a value of "dale" (at Obj.chip) was removed', 36 | 'Array "Foo" (at Obj.foo), had a value of "11" inserted at index 10', 37 | 'Array "Foo" (at Obj.foo), had a value of "6" changed to "5" at index 5', 38 | 'Array "Baz" (at Obj.biz.baz), had a value of "4" removed at index 3', 39 | 'Array "Hello" (at Obj.biz.baz[2].hello), had a value of "was" inserted at index 1', 40 | 'Array "Hello" (at Obj.biz.baz[2].hello), had a value of "then" changed to "there" at index 0', 41 | 'Array "Base" (at Obj.base), had a value of "4" removed at index 3', 42 | 'Array "Base" (at Obj.base), had a value of "3" changed to "2" at index 2', 43 | ]) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/test.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | 3 | describe('test', () => { 4 | let hr: Function 5 | 6 | beforeEach(() => { 7 | hr = new HR().diff 8 | }) 9 | 10 | test('humanReadable is a function', () => { 11 | expect(typeof hr === 'function').toBeTruthy() 12 | }) 13 | 14 | test('Describes an object new key addition', () => { 15 | const lhs = {} 16 | const rhs = { 17 | bar: 'hello world', 18 | } 19 | 20 | expect(hr(lhs, rhs)).toEqual(['"Bar", with a value of "hello world" (at Obj.bar) was added']) 21 | }) 22 | 23 | test('Describes an object key removal', () => { 24 | const lhs = { 25 | bar: 'hello world', 26 | } 27 | const rhs = {} 28 | 29 | expect(hr(lhs, rhs)).toEqual(['"Bar", with a value of "hello world" (at Obj.bar) was removed']) 30 | }) 31 | 32 | test('Describes an object key edit', () => { 33 | const lhs = { foo: 'hello' } 34 | const rhs = { 35 | foo: 'hello world', 36 | } 37 | 38 | expect(hr(lhs, rhs)).toEqual(['"Foo", with a value of "hello" (at Obj.foo) was changed to "hello world"']) 39 | }) 40 | 41 | test('Describes an array insertion', () => { 42 | const lhs = { foo: [1, 2, 3, 4, 5, 6] } 43 | const rhs = { 44 | foo: [1, 2, 3, 8, 4, 5, 6], 45 | } 46 | 47 | expect(hr(lhs, rhs)).toEqual(['Array "Foo" (at Obj.foo), had a value of "8" inserted at index 3']) 48 | }) 49 | 50 | test('Describes an array removal', () => { 51 | const lhs = { foo: [1, 2, 3, 4, 5, 6] } 52 | const rhs = { 53 | foo: [1, 2, 4, 5, 6], 54 | } 55 | 56 | expect(hr(lhs, rhs)).toEqual(['Array "Foo" (at Obj.foo), had a value of "3" removed at index 2']) 57 | }) 58 | 59 | test('Describes an array edit', () => { 60 | const lhs = { foo: [1, 2, 3, 4, 5, 6] } 61 | const rhs = { 62 | foo: [1, 2, 8, 4, 5, 6], 63 | } 64 | 65 | expect(hr(lhs, rhs)).toEqual(['Array "Foo" (at Obj.foo), had a value of "3" changed to "8" at index 2']) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/nested-empty.test.ts: -------------------------------------------------------------------------------- 1 | import DiffEngine from '../src/index' 2 | import { preProcessArrayDiffs } from '../src/engine/utils/array-preprocessor' 3 | import Diff from '../src/diff' 4 | 5 | describe('empty array', () => { 6 | const { diff } = new DiffEngine() 7 | 8 | const lhs = { 9 | name: 'lhs', 10 | flags: [], 11 | } 12 | const rhs = { 13 | name: 'rhs', 14 | flags: [1], 15 | } 16 | 17 | it('without nesting', () => { 18 | expect(diff(lhs, rhs)).toEqual([ 19 | '"Name", with a value of "lhs" (at Obj.name) was changed to "rhs"', 20 | 'Array "Flags" (at Obj.flags), had a value of "1" inserted at index 0', 21 | ]) 22 | }) 23 | it('with nesting', () => { 24 | expect(diff({ nested: lhs }, { nested: rhs })).toEqual([ 25 | '"Name", with a value of "lhs" (at Obj.nested.name) was changed to "rhs"', 26 | 'Array "Flags" (at Obj.nested.flags), had a value of "1" inserted at index 0', 27 | ]) 28 | }) 29 | 30 | it('preProcessArrayDiffs without nesting', () => { 31 | expect( 32 | preProcessArrayDiffs( 33 | [ 34 | new Diff({ 35 | kind: 'A', 36 | index: 0, 37 | item: { kind: 'N', rhs: 1 }, 38 | path: ['flags'], 39 | }), 40 | ], 41 | lhs, 42 | rhs 43 | ) 44 | ).toEqual([ 45 | { 46 | kind: 'I', 47 | index: 0, 48 | val: 1, 49 | path: ['flags'], 50 | dotPath: 'flags', 51 | }, 52 | ]) 53 | }) 54 | 55 | it('preProcessArrayDiffs with nesting', () => { 56 | expect( 57 | preProcessArrayDiffs( 58 | [ 59 | new Diff({ 60 | kind: 'A', 61 | index: 0, 62 | item: { kind: 'N', rhs: 1 }, 63 | path: ['nested', 'flags'], 64 | }), 65 | ], 66 | { nested: lhs }, 67 | { nested: rhs } 68 | ) 69 | ).toEqual([ 70 | { 71 | kind: 'I', 72 | index: 0, 73 | val: 1, 74 | path: ['nested', 'flags'], 75 | dotPath: 'nested.flags', 76 | }, 77 | ]) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type deepDiff from 'deep-diff' 2 | import type DiffSentence from './sentence' 3 | 4 | export type DefaultDiffConfig = { 5 | dateFormat: string // 'MM/dd/yyyy hh:mm a', 6 | objectName: string // 'Obj', 7 | ignoreArrays: boolean // False, 8 | sensitivePaths: string[] 9 | dontHumanizePropertyNames: boolean // False, 10 | templates: { 11 | N: string // '"FIELD", with a value of "NEWVALUE" (at DOTPATH) was added'; 12 | D: string // '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was removed'; 13 | E: string // '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was changed to "NEWVALUE"'; 14 | I: string // 'Array "FIELD" (at DOTPATH), had a value of "NEWVALUE" inserted at index INDEX'; 15 | R: string // 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" removed at index INDEX'; 16 | AE: string // 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" changed to "NEWVALUE" at index INDEX'; 17 | NS: string // '"FIELD" (at DOTPATH) was added'; 18 | DS: string // '"FIELD" (at DOTPATH) was removed'; 19 | ES: string // '"FIELD" (at DOTPATH) was changed'; 20 | IS: string // 'Array "FIELD" (at DOTPATH), had a value inserted at index INDEX'; 21 | RS: string // 'Array "FIELD" (at DOTPATH), had a value removed at index INDEX'; 22 | AES: string // 'Array "FIELD" (at DOTPATH), had a value changed at index INDEX'; 23 | } 24 | } 25 | 26 | export type DiffConfig = DefaultDiffConfig & { 27 | prefilter?: deepDiff.PreFilter | undefined 28 | } 29 | 30 | export type DiffConfigWithoutTemplates = Omit 31 | 32 | export type InputDiffConfig = Partial<{ 33 | dateFormat: DefaultDiffConfig['dateFormat'] // 'MM/dd/yyyy hh:mm a', 34 | objectName: DefaultDiffConfig['objectName'] // 'Obj', 35 | ignoreArrays: DefaultDiffConfig['ignoreArrays'] // False, 36 | sensitivePaths: DefaultDiffConfig['sensitivePaths'] 37 | dontHumanizePropertyNames: DefaultDiffConfig['dontHumanizePropertyNames'] // False, 38 | templates: Partial 39 | prefilter: deepDiff.PreFilter | undefined 40 | }> 41 | 42 | export type DiffEngineContext = { 43 | // Diff: (lhs: unknown, rhs: unknown) => string; 44 | sentenceDiffs: DiffSentence[] 45 | sentences: string[] 46 | config: DiffConfigWithoutTemplates 47 | templates: DefaultDiffConfig['templates'] 48 | } 49 | -------------------------------------------------------------------------------- /test/prefilter.test.ts: -------------------------------------------------------------------------------- 1 | import HR from '../src/index' 2 | import { type InputDiffConfig } from '../src/types' 3 | 4 | describe('prefilter', () => { 5 | let hr: Function 6 | 7 | beforeEach(() => { 8 | hr = (config: InputDiffConfig) => new HR(config).diff 9 | }) 10 | 11 | test('humanReadable is a function', () => { 12 | expect(typeof hr === 'function').toBeTruthy() 13 | }) 14 | 15 | test('prefilters with an array of values', () => { 16 | const lhs = { 17 | foo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 18 | bar: 'hello', 19 | baz: 12, 20 | chip: 'dale', 21 | biz: { baz: [1, 2, { hello: ['then'] }, 4, 5] }, 22 | base: [1, 2, 3, 4, 5], 23 | } 24 | const rhs = { 25 | foo: [1, 2, 3, 4, 5, 5, 7, 8, 9, 10, 11], 26 | bar: 'hello world', 27 | baz: 10, 28 | biz: { baz: [1, 2, { hello: ['there', 'was'] }, 5] }, 29 | base: [1, 2, 2, 5], 30 | } 31 | 32 | expect(hr({ prefilter: ['baz', 'foo'] })(lhs, rhs)).toEqual([ 33 | '"Bar", with a value of "hello" (at Obj.bar) was changed to "hello world"', 34 | '"Chip", with a value of "dale" (at Obj.chip) was removed', 35 | 'Array "Baz" (at Obj.biz.baz), had a value of "4" removed at index 3', 36 | 'Array "Hello" (at Obj.biz.baz[2].hello), had a value of "was" inserted at index 1', 37 | 'Array "Hello" (at Obj.biz.baz[2].hello), had a value of "then" changed to "there" at index 0', 38 | 'Array "Base" (at Obj.base), had a value of "4" removed at index 3', 39 | 'Array "Base" (at Obj.base), had a value of "3" changed to "2" at index 2', 40 | ]) 41 | }) 42 | 43 | test('prefilters with a function', () => { 44 | const lhs = { 45 | foo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 46 | bar: 'hello', 47 | baz: 12, 48 | chip: 'dale', 49 | biz: { baz: [1, 2, { hello: ['then'] }, 4, 5] }, 50 | base: [1, 2, 3, 4, 5], 51 | } 52 | const rhs = { 53 | foo: [1, 2, 3, 4, 5, 5, 7, 8, 9, 10, 11], 54 | bar: 'hello world', 55 | baz: 10, 56 | biz: { baz: [1, 2, { hello: ['there', 'was'] }, 5] }, 57 | base: [1, 2, 2, 5], 58 | } 59 | 60 | expect(hr({ prefilter: (path: string, key: number) => key === 2 })(lhs, rhs)).toEqual([ 61 | '"Bar", with a value of "hello" (at Obj.bar) was changed to "hello world"', 62 | '"Baz", with a value of "12" (at Obj.baz) was changed to "10"', 63 | '"Chip", with a value of "dale" (at Obj.chip) was removed', 64 | 'Array "Foo" (at Obj.foo), had a value of "11" inserted at index 10', 65 | 'Array "Foo" (at Obj.foo), had a value of "6" changed to "5" at index 5', 66 | 'Array "Baz" (at Obj.biz.baz), had a value of "4" removed at index 3', 67 | 'Array "Base" (at Obj.base), had a value of "4" removed at index 3', 68 | ]) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "human-object-diff", 3 | "version": "3.0.0", 4 | "description": "Human Readable Difference Between Two Objects", 5 | "keywords": [ 6 | "deep-diff", 7 | "human-readable", 8 | "humanize", 9 | "object-diff" 10 | ], 11 | "homepage": "https://github.com/Spence-S/human-object-diff", 12 | "bugs": { 13 | "url": "https://github.com/Spence-S/human-object-diff/issues", 14 | "email": "sasnyde2@gmail.com" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/Spence-S/human-object-diff" 19 | }, 20 | "license": "MIT", 21 | "author": "Spencer Snyder (https://spencersnyder.io/)", 22 | "contributors": [ 23 | "Spencer Snyder (https://spencersnyder.io/)" 24 | ], 25 | "main": "./dist/index.js", 26 | "types": "./dist/index.d.ts", 27 | "files": [ 28 | "dist/**/*", 29 | "LICENSE", 30 | "README.md" 31 | ], 32 | "scripts": { 33 | "build": "npm run clean && tsc --project tsconfig.json", 34 | "clean": "rm -rf dist", 35 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", 36 | "lint": "prettier . --write && eslint '**/*.{js,ts}' --fix", 37 | "prepare": "husky install", 38 | "test": "jest" 39 | }, 40 | "husky": { 41 | "hooks": { 42 | "pre-commit": "lint-staged && npm test", 43 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 44 | } 45 | }, 46 | "commitlint": { 47 | "extends": [ 48 | "@commitlint/config-conventional" 49 | ] 50 | }, 51 | "dependencies": { 52 | "date-fns": "^2.29.3", 53 | "deep-diff": "^1.0.2", 54 | "eslint-config-prettier": "^8.8.0", 55 | "fast-deep-equal": "^3.1.3", 56 | "humanize-string": "^2.1.0", 57 | "titleize": "^2.1.0" 58 | }, 59 | "devDependencies": { 60 | "@commitlint/cli": "^17.5.0", 61 | "@commitlint/config-conventional": "^17.4.4", 62 | "@types/deep-diff": "^1.0.2", 63 | "@types/jest": "^29.5.0", 64 | "@typescript-eslint/eslint-plugin": "^5.57.0", 65 | "@typescript-eslint/parser": "^5.57.0", 66 | "codecov": "^3.8.2", 67 | "cross-env": "^7.0.3", 68 | "eslint": "^8.36.0", 69 | "eslint-config-xo": "^0.43.1", 70 | "eslint-config-xo-lass": "^2.0.1", 71 | "eslint-config-xo-typescript": "^0.56.0", 72 | "eslint-plugin-unicorn": "^46.0.0", 73 | "fixpack": "^4.0.0", 74 | "husky": "^8.0.3", 75 | "jest": "^29.5.0", 76 | "lint-staged": "^13.2.0", 77 | "np": "^7.6.4", 78 | "npm-package-json-lint": "^6.4.0", 79 | "npm-package-json-lint-config-default": "^5.0.0", 80 | "nyc": "^15.1.0", 81 | "prettier": "^2.8.7", 82 | "prettier-plugin-packagejson": "^2.4.3", 83 | "ts-jest": "^29.0.5", 84 | "typescript": "^5.0.2" 85 | }, 86 | "engines": { 87 | "node": ">=14", 88 | "npm": ">=8" 89 | }, 90 | "overrides": { 91 | "decamelize": "4.0.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/sentence/index.ts: -------------------------------------------------------------------------------- 1 | import { type DiffConfig, type DiffConfigWithoutTemplates } from '../types' 2 | import { type Change } from '../engine/utils/array-preprocessor' 3 | import type Diff from '../diff' 4 | import { getNewValue } from './utils/get-new-val' 5 | import getField from './utils/get-field' 6 | import getDotpath from './utils/get-dot-path' 7 | import getOldVal from './utils/get-old-val' 8 | 9 | type Token = 'FIELD' | 'DOTPATH' | 'NEWVALUE' | 'OLDVALUE' | 'INDEX' | 'POSITION' 10 | 11 | export type DiffContext = { 12 | diff: string | Change | Diff 13 | config: DiffConfigWithoutTemplates 14 | templates: DiffConfig['templates'] 15 | } 16 | 17 | export default class DiffSentence { 18 | private readonly template: string 19 | private readonly diff: string | Change | Diff 20 | 21 | private readonly 'FIELD': string 22 | private readonly 'DOTPATH': string 23 | private readonly 'NEWVALUE': string 24 | private readonly 'OLDVALUE': string 25 | private readonly 'INDEX': string 26 | private readonly 'POSITION': string 27 | 28 | constructor(diff: string | Change | Diff, config: DiffConfigWithoutTemplates, templates: DiffConfig['templates']) { 29 | const context: DiffContext = { diff, config, templates } 30 | this.diff = diff 31 | this.FIELD = getField(context) 32 | this.OLDVALUE = getOldVal(context) 33 | this.NEWVALUE = getNewValue(context) 34 | this.DOTPATH = getDotpath(context) 35 | if (typeof diff !== 'string') { 36 | this.INDEX = String(diff.index) 37 | } 38 | 39 | if (typeof diff !== 'string') { 40 | this.POSITION = String(diff.index && diff.index - 1) 41 | } 42 | 43 | this.template = this.getTemplate(context) 44 | this.format = this.format.bind(this) 45 | } 46 | 47 | format(): string { 48 | let sentence = this.template 49 | const tokens: Token[] = ['FIELD', 'DOTPATH', 'NEWVALUE', 'OLDVALUE', 'INDEX', 'POSITION'] 50 | for (const token of tokens) { 51 | sentence = sentence.replace(new RegExp(token, 'g'), this[token]) 52 | } 53 | 54 | return sentence 55 | } 56 | 57 | getTemplate({ config, templates, diff }: DiffContext): string { 58 | if (typeof diff === 'string') { 59 | return diff 60 | } 61 | 62 | return templates[this.getTemplateKey(config, diff)] 63 | } 64 | 65 | private getTemplateKey(config: DiffConfigWithoutTemplates, diff: Change | Diff): keyof DiffContext['templates'] { 66 | if (config.sensitivePaths.includes(diff.dotPath)) { 67 | if (diff.kind === 'A') { 68 | throw new Error('Diff kind AS is not handled') 69 | } 70 | 71 | if ( 72 | diff.kind === 'N' || 73 | diff.kind === 'D' || 74 | diff.kind === 'E' || 75 | diff.kind === 'I' || 76 | diff.kind === 'R' || 77 | diff.kind === 'AE' 78 | ) { 79 | return `${diff.kind}S` 80 | } 81 | 82 | throw new Error(`Diff kind ${diff.kind}S is not handled`) 83 | } 84 | 85 | if (diff.kind === 'A') { 86 | throw new Error('Diff kind A is not handled') 87 | } 88 | 89 | return diff.kind 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/engine/utils/array-preprocessor.ts: -------------------------------------------------------------------------------- 1 | import equal from 'fast-deep-equal' 2 | import type Diff from '../../diff' 3 | import { type DiffConfig } from '../../types' 4 | 5 | export type Change = 6 | | { 7 | path: string[] 8 | dotPath: string 9 | kind: 'I' | 'R' 10 | index: number 11 | val: unknown 12 | } 13 | | { 14 | path: string[] 15 | dotPath: string 16 | kind: keyof DiffConfig['templates'] 17 | isArray: boolean 18 | lhs: unknown 19 | rhs: unknown 20 | index: number | undefined 21 | val: unknown 22 | } 23 | 24 | export function splitPath(path: string): string[] { 25 | return path.split(/[.[\]]/gi).filter(Boolean) 26 | } 27 | 28 | export function preProcessArrayDiffs(diffs: Diff[] = [], lhs: unknown = [], rhs: unknown = []): Array { 29 | const groupedDiffs = groupDiffsByPath(diffs) 30 | 31 | let diffStrings: Array = [] 32 | 33 | for (const path in groupedDiffs) { 34 | if (Object.prototype.hasOwnProperty.call(groupedDiffs, path)) { 35 | let lhsValue = lhs 36 | let rhsValue = rhs 37 | 38 | for (const p of splitPath(path)) { 39 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 40 | // @ts-expect-error 41 | lhsValue = lhsValue[p] 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-expect-error 44 | rhsValue = rhsValue[p] 45 | } 46 | 47 | const groupedDiff = groupedDiffs[path] 48 | 49 | const { insertions, cutoff } = getInsertions(lhsValue, rhsValue) 50 | 51 | const changes = [ 52 | ...insertions, 53 | ...groupedDiff 54 | .filter((diff) => Number(diff.index) < cutoff && diff.kind === 'E') 55 | .map( 56 | (diff: Diff): Change => ({ 57 | ...diff, 58 | dotPath: path, 59 | kind: 'AE', 60 | path: splitPath(path), 61 | }) 62 | ), 63 | ].map((diff) => ({ 64 | ...diff, 65 | path: splitPath(path), 66 | dotPath: path, 67 | })) 68 | diffStrings = [...diffStrings, ...changes] 69 | } 70 | } 71 | 72 | return diffStrings 73 | } 74 | 75 | function groupDiffsByPath(diffs: Diff[]): Record { 76 | const diffGroups: Record = {} 77 | 78 | for (const diff of diffs) { 79 | diff.index = diff.index ?? (Array.isArray(diff.path) ? Number(diff.path[diff.path.length - 1]) : 0) 80 | if (diffGroups[diff.dotPath] && Array.isArray(diffGroups[diff.dotPath])) { 81 | diffGroups[diff.dotPath].push(diff) 82 | } else { 83 | diffGroups[diff.dotPath] = [diff] 84 | } 85 | } 86 | 87 | return diffGroups 88 | } 89 | 90 | function getInsertions( 91 | lhs: unknown = [], 92 | rhs: unknown = [] 93 | ): { 94 | insertions: Array<{ 95 | kind: 'I' | 'R' 96 | index: number 97 | val: unknown 98 | }> 99 | cutoff: number 100 | } { 101 | if (!Array.isArray(lhs) || !Array.isArray(rhs)) { 102 | return { 103 | cutoff: 0, 104 | insertions: [], 105 | } 106 | } 107 | 108 | const insertionCount = rhs.length - lhs.length 109 | const kind: 'I' | 'R' = insertionCount !== 0 && insertionCount > 0 ? 'I' : 'R' 110 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 111 | const longer = kind === 'I' ? [...rhs] : [...lhs] 112 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 113 | const shorter = kind === 'I' ? [...lhs] : [...rhs] 114 | const longerLength = longer.length 115 | const insertions = [] 116 | 117 | let absCount = Math.abs(insertionCount) 118 | let negIndex = 0 119 | 120 | while (absCount !== 0) { 121 | negIndex -= 1 122 | if (equal(longer[longer.length - 1], shorter[longer.length - 1 - absCount])) { 123 | longer.pop() 124 | shorter.pop() 125 | } else { 126 | const value = longer.pop() as unknown 127 | const index = longerLength - Math.abs(negIndex) 128 | insertions.push({ 129 | kind, 130 | index, 131 | val: value, 132 | }) 133 | absCount -= 1 134 | } 135 | } 136 | 137 | return { 138 | insertions, 139 | cutoff: Math.min(...insertions.map((ins) => ins.index)), 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # human-object-diff 2 | 3 | [![build status](https://img.shields.io/travis/com/Spence-S/human-object-diff.svg)](https://travis-ci.com/Spence-S/human-object-diff) 4 | [![code coverage](https://img.shields.io/codecov/c/github/Spence-S/human-object-diff.svg)](https://codecov.io/gh/Spence-S/human-object-diff) 5 | [![code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | [![made with lass](https://img.shields.io/badge/made_with-lass-95CC28.svg)](https://lass.js.org) 8 | [![license](https://img.shields.io/github/license/Spence-S/human-object-diff.svg)](LICENSE) 9 | [![npm downloads](https://img.shields.io/npm/dt/human-object-diff.svg)](https://npm.im/human-object-diff) 10 | 11 | > Configurable Human Readable Difference Between Two Plain Objects 12 | 13 | ## Table of Contents 14 | 15 | - [Install](#install) 16 | - [Usage](#usage) 17 | - [Configuring](#configuring) 18 | - [Options](#options) 19 | - [Custom Templates](#custom-templates) 20 | - [Support for Dates](#support-for-dates) 21 | - [Prefiltering](#prefiltering) 22 | - [Contributors](#contributors) 23 | - [License](#license) 24 | 25 | ## Install 26 | 27 | [npm][]: 28 | 29 | ```bash 30 | npm install human-object-diff 31 | ``` 32 | 33 | [yarn][]: 34 | 35 | ```bash 36 | yarn add human-object-diff 37 | ``` 38 | 39 | ## Usage 40 | 41 | Common JS 42 | 43 | ```typescript 44 | const HumanDiff = require('human-object-diff') 45 | 46 | const lhs = { foo: 'bar' } 47 | const rhs = { foo: 'baz' } 48 | const options = {} 49 | 50 | const { diff } = new HumanDiff(options) 51 | 52 | console.log(diff(lhs, rhs)) 53 | // -> ['"Foo", with a value of "bar" (at Obj.foo) was changed to "baz"'] 54 | ``` 55 | 56 | ### Options 57 | 58 | `human-object-diff` supports a variety of options to allow you to take control over the output of your object diff. 59 | 60 | | Option | Type | Default | Description | 61 | | -------------- | -------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | 62 | | objectName | String | 'Obj' | This is the object name when presented in the path. ie... "Obj.foo" ignored if hidePath is true | 63 | | prefilter | [String] | Func | See [prefiltering](#prefiltering) | 64 | | dateFormat | String | 'MM/dd/yyyy hh:mm a' | dateFns format string, see [below](#support-for-dates) | 65 | | ignoreArrays | Bool | false | If array differences aren't needed, set to true and skip processing | 66 | | templates | Object | see [templates](#custom-templates) | Completely customize the output | 67 | | sensitivePaths | [String] | | Paths that will use the sensitive field templates if they are defined, use [] as any index | 68 | 69 | ### Custom Templates 70 | 71 | `human-object-dff` let's you fully customize your sentences by allowing you to pass custom sentence templates. 72 | 73 | The default template looks like the following: 74 | 75 | ```js 76 | const templates = { 77 | N: '"FIELD", with a value of "NEWVALUE" (at DOTPATH) was added', 78 | D: '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was removed', 79 | E: '"FIELD", with a value of "OLDVALUE" (at DOTPATH) was changed to "NEWVALUE"', 80 | I: 'Array "FIELD" (at DOTPATH), had a value of "NEWVALUE" inserted at index INDEX', 81 | R: 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" removed at index INDEX', 82 | AE: 'Array "FIELD" (at DOTPATH), had a value of "OLDVALUE" changed to "NEWVALUE" at index INDEX', 83 | NS: '"FIELD" (at DOTPATH) was added', 84 | DS: '"FIELD" (at DOTPATH) was removed', 85 | ES: '"FIELD" (at DOTPATH) was changed', 86 | IS: 'Array "FIELD" (at DOTPATH), had a value inserted at index INDEX', 87 | RS: 'Array "FIELD" (at DOTPATH), had a value removed at index INDEX', 88 | AES: 'Array "FIELD" (at DOTPATH), had a value changed at index INDEX', 89 | } 90 | ``` 91 | 92 | Where N is a new key, D is a deleted key, E is an edited key, I is an inserted array value, R is a removed array value, 93 | and AE is an edited array property. 94 | 95 | We also expose a sensitiveFields array option which will cause a path to use the S option template. 96 | 97 | You can define each sentence in the templates to be whatever you'd like. The following tokens can be used to replace 98 | their diff values in the final output. 99 | 100 | The available tokens that can plug in to your sentence templates 101 | are `FIELD`, `DOTPATH`,`NEWVALUE`,`OLDVALUE`, `INDEX`, `POSITION`. Position is just index+1. Be aware that not all 102 | sentence types will have values for each token. For instance, non-array changes will not have a position or an index. 103 | 104 | ### Support for Dates 105 | 106 | `human-object-diff` uses `date-fns` format function under the hood to show human readable date differences. We also 107 | supply a `dateFormat` option where you can supply your own date formatting string. Please note, that date-fns format 108 | strings are different from moment.js format strings. Please refer to the 109 | documentation [here](https://date-fns.org/v2.8.1/docs/format) 110 | and [here](https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md) 111 | 112 | ### Prefiltering 113 | 114 | There may be some paths in your object diffs that you'd like to ignore. You can do that with prefiltering. As a 115 | convenience, you can add this option as an array of strings, which are the keys of the root paths of the objects. 116 | 117 | for instance 118 | 119 | ```js 120 | const lhs = { foo: 'bar', biz: { foo: 'baz' } } 121 | const rhs = { foo: 'bar', biz: { foo: 'buzz' } } 122 | 123 | const { diff } = new HumanDiff({ prefilter: ['foo'] }) 124 | 125 | diff(lhs, rhs) 126 | ``` 127 | 128 | You would still see the diffs for `biz.foo` but you would ignore the diff for `foo`. 129 | 130 | You can also pass a function for this option which will be directly passed to 131 | the [underlying diff library](https://www.npmjs.com/package/deep-diff). 132 | 133 | The prefilter function takes a signature of `function(path, key)`. Here path is an array that represents the path 134 | leading up to the object property. The key is the key, or what would be the final element of the path. The function 135 | returns true for any paths you would want to ignore. 136 | 137 | For instance, in the object below: 138 | 139 | ```js 140 | const obj = { foo: { bar: [1, 2, { baz: 'buzz' }] } } 141 | ``` 142 | 143 | The path and key for `foo` would be path \[] and key 'foo'. 144 | 145 | The path and key for `foo.bar` would be path \['foo'] key 'bar' 146 | 147 | for `foo.bar[2].baz` it would be path: \['foo', 'bar', 2] and key 'baz' 148 | 149 | To ignore changes in `foo.bar` you could pass a functions like 150 | 151 | ```js 152 | const prefilter = (path, key) => path[0] === 'foo' && key === 'bar' 153 | ``` 154 | 155 | ## A Note On Arrays 156 | 157 | > \*\*There are known bug related to arrays of objects. We plan to release different array processing algorithms in the 158 | > future that can handle more complex objects. As of the latest version it is reccomended to only diff between flat 159 | > arrays 160 | > of strings and numbers. Otherwise there isn't guarantee of accuracy or if diffs won't be duplicated in some ways. 161 | 162 | `human-object-diff` parses arrays in an opinionated way. It does it's best to resolve Arrays into groups of insertions 163 | and removals. Typical diff libraries look at arrays on an element by element basis and emit a difference for every 164 | changes element. While this is benefical for many programatic tasks, humans typically don't look at arrays in the same 165 | way. `human-object-diff` attempts to reduce array changes to a number of insertions, removals, and edits. An example can 166 | better describe the difference. 167 | 168 | ```js 169 | const lhs = [1, 2, 3, 4] 170 | const rhs = [0, 1, 2, 3, 4] 171 | ``` 172 | 173 | Consider the above arrays and their differences. A typical array diff would behave like this and output something like 174 | the following. 175 | 176 | 1. A change at index 0 from 1 to 0 177 | 2. A change at index 1 from 2 to 1 178 | 3. A change at index 2 from 3 to 2 179 | 4. A change at index 3 from 4 to 3 180 | 5. An addition of 4 at index 4 181 | 182 | `human-object-diff` attempts to reduce these differences to something like the following. 183 | 184 | 1. An insertion of 0 at index 0. ("Array 'lhs' had a value of 0 inserted at index 0") 185 | 186 | This is much more understandable to a human brain. We've simply inserted a number at an index. 187 | 188 | ## Diff Memory 189 | 190 | The diff engine object created when `new HumanDiff()` is invoked contains a `sentences` property which you can use to 191 | recall the last diff that was computed. 192 | 193 | ```js 194 | const diffEngine = new HumanDiff() 195 | diffEngine.diff(lhs, rhs) 196 | 197 | diffEngine.sentences // -> same as the output of the last diff 198 | ``` 199 | 200 | ## Contributors 201 | 202 | | Name | Website | 203 | | ------------------ | --------------------------- | 204 | | **Spencer Snyder** | | 205 | 206 | ## License 207 | 208 | [MIT](LICENSE) © [Spencer Snyder](https://spencersnyder.io/) 209 | 210 | ## 211 | 212 | [npm]: https://www.npmjs.com/ 213 | [yarn]: https://yarnpkg.com/ 214 | 215 | Real world example 216 | I selected open source package and decided to add dual support for it. 217 | --------------------------------------------------------------------------------