diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..e61ed2d --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,24 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'error', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (e.g. use dependency inversion, standard interfaces, pull out ' + + 'common parts to a separate module)', + from: {}, + to: { circular: true } + } + ], + options: { + doNotFollow: { + path: 'node_modules' + }, + tsPreCompilationDeps: true, + tsConfig: { + fileName: 'tsconfig.json' + } + } +}; diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 051f206..404c881 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,9 +4,6 @@ on: push: branches: - master - pull_request: - branches: - - master permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d531cda..18234e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,3 +33,6 @@ jobs: - name: Run type check run: npm run typecheck + - name: Run dependency check + run: npm run lint:deps + diff --git a/eslint.config.js b/eslint.config.js index eddfe88..fd8dc2d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,205 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; +import boundaries from 'eslint-plugin-boundaries'; export default tseslint.config( + { + ignores: ['dist/', 'node_modules/', 'coverage/', 'main.js'], + }, eslint.configs.recommended, - tseslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, prettier, + { + plugins: { + boundaries, + }, + settings: { + 'boundaries/elements': [ + { + type: 'entry', + pattern: 'src/main.ts', + }, + { + type: 'core', + pattern: 'src/core/**/*', + }, + { + type: 'plugin', + pattern: 'src/plugin/**/*', + }, + { + type: 'services', + pattern: 'src/services/**/*', + }, + { + type: 'auth', + pattern: 'src/auth/**/*', + }, + { + type: 'types', + pattern: 'src/types/**/*', + }, + { + type: 'utils', + pattern: 'src/utils/**/*', + }, + { + type: 'i18n', + pattern: 'src/i18n/**/*', + }, + { + type: 'config', + pattern: 'src/config/**/*', + }, + { + type: 'tests', + pattern: 'src/__tests__/**/*', + }, + { + type: 'mocks', + pattern: 'src/__mocks__/**/*', + }, + ], + }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + 'boundaries/entry-point': [ + 'error', + { + default: 'allow', + }, + ], + 'boundaries/no-private': 'error', + 'boundaries/element-types': [ + 'error', + { + default: 'disallow', + rules: [ + { + from: 'entry', + allow: [ + 'core', + 'plugin', + 'services', + 'auth', + 'types', + 'utils', + 'i18n', + 'config', + ], + }, + { + from: 'core', + allow: [ + 'types', + 'utils', + 'config', + 'i18n', + 'core', + ], + }, + { + from: 'plugin', + allow: [ + 'core', + 'services', + 'auth', + 'types', + 'utils', + 'i18n', + 'config', + 'plugin', + ], + }, + { + from: 'services', + allow: ['types', 'utils', 'config', 'services'], + }, + { + from: 'auth', + allow: ['types', 'utils', 'config', 'auth'], + }, + { + from: 'types', + allow: ['types'], + }, + { + from: 'utils', + allow: ['utils'], + }, + { + from: 'i18n', + allow: ['i18n', 'types'], + }, + { + from: 'config', + allow: ['config'], + }, + { + from: 'tests', + allow: [ + 'core', + 'plugin', + 'services', + 'auth', + 'types', + 'utils', + 'i18n', + 'config', + 'mocks', + 'tests', + ], + }, + ], + }, + ], + curly: ['error', 'all'], + eqeqeq: ['error', 'always'], + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { + allowNumber: true, + allowBoolean: true, + }, + ], + complexity: ['error', 10], + }, + }, + { + files: ['src/__{tests,mocks}__/**/*.ts'], + rules: { + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-extraneous-class': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/no-useless-constructor': 'off', + }, + } ); diff --git a/package-lock.json b/package-lock.json index ef8d21a..e433498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,10 @@ "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", + "dependency-cruiser": "^17.3.4", "eslint": "^9.25.1", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-boundaries": "^5.3.1", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.5.1", @@ -28,6 +30,7 @@ "prettier": "^3.5.3", "rollup": "^4.40.0", "ts-jest": "^29.3.2", + "ts-prune": "^0.10.3", "typedoc": "^0.28.3", "typescript": "^5.8.3", "typescript-eslint": "^8.31.0" @@ -549,6 +552,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@boundaries/elements": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-1.1.2.tgz", + "integrity": "sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==", + "dev": true, + "dependencies": { + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.8", + "is-core-module": "2.16.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/@codemirror/state": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", @@ -1316,6 +1335,41 @@ "license": "MIT", "peer": true }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", @@ -1775,6 +1829,40 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz", + "integrity": "sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1935,6 +2023,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2238,6 +2332,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -2779,6 +2891,12 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-11.0.3.tgz", + "integrity": "sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw==", + "dev": true + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -2837,6 +2955,31 @@ "dev": true, "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2938,6 +3081,53 @@ "node": ">=0.10.0" } }, + "node_modules/dependency-cruiser": { + "version": "17.3.4", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.3.4.tgz", + "integrity": "sha512-co6WQmyTl7KLrL6OR3cCFl0Ol2SBoY5e5kQBfE6ZR8+W+GCBqoq7A67br51O/Tp2ixL2ZVmLrPcyddtuXKBnYA==", + "dev": true, + "dependencies": { + "acorn": "8.15.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.4", + "commander": "14.0.2", + "enhanced-resolve": "5.18.4", + "ignore": "7.0.5", + "interpret": "3.1.1", + "is-installed-globally": "1.0.0", + "json5": "2.2.3", + "memoize": "10.2.0", + "picomatch": "4.0.3", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.7.3", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "5.0.0" + }, + "bin": { + "depcruise": "bin/dependency-cruise.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruise.mjs", + "dependency-cruiser": "bin/dependency-cruise.mjs" + }, + "engines": { + "node": "^20.12||^22||>=24" + } + }, + "node_modules/dependency-cruiser/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2994,6 +3184,19 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3129,6 +3332,71 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-boundaries": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-5.3.1.tgz", + "integrity": "sha512-91StsOYtDyrna1fyRJ+1Ps5CnrfyFLbdCouPZ3E/o2cllLxJke3OoScdqjpBSl7pNEYbojhpNlurQAr30sf9Bg==", + "dev": true, + "dependencies": { + "@boundaries/elements": "1.1.2", + "chalk": "4.1.2", + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -3368,6 +3636,34 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3382,6 +3678,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3621,6 +3926,21 @@ "node": "*" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -3795,6 +4115,24 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3864,6 +4202,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -3881,6 +4235,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4935,6 +5301,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5134,6 +5506,21 @@ "dev": true, "license": "MIT" }, + "node_modules/memoize": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", + "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", + "dev": true, + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5141,6 +5528,15 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5217,6 +5613,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -5418,6 +5826,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5455,6 +5869,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5672,6 +6095,26 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -5679,6 +6122,27 @@ "dev": true, "license": "MIT" }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5799,6 +6263,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -5848,6 +6322,38 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6140,6 +6646,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6216,6 +6735,15 @@ "node": ">=8.0" } }, + "node_modules/true-myth": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/true-myth/-/true-myth-4.1.1.tgz", + "integrity": "sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg==", + "dev": true, + "engines": { + "node": "10.* || >= 12.*" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6295,6 +6823,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-morph": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-13.0.3.tgz", + "integrity": "sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw==", + "dev": true, + "dependencies": { + "@ts-morph/common": "~0.12.3", + "code-block-writer": "^11.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -6338,6 +6876,70 @@ } } }, + "node_modules/ts-prune": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-prune/-/ts-prune-0.10.3.tgz", + "integrity": "sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw==", + "dev": true, + "dependencies": { + "commander": "^6.2.1", + "cosmiconfig": "^7.0.1", + "json5": "^2.1.3", + "lodash": "^4.17.21", + "true-myth": "^4.1.0", + "ts-morph": "^13.0.1" + }, + "bin": { + "ts-prune": "lib/index.js" + } + }, + "node_modules/ts-prune/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6548,6 +7150,18 @@ "makeerror": "1.0.12" } }, + "node_modules/watskeburt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-5.0.0.tgz", + "integrity": "sha512-fEMhfIzu9WOuAJdDcTT+aPjn0JHI2+UeJ+zWSEs/tgMvc+MFDZVmhlZ8C1uJWXax1ETYc4trUnHFHyx2DrG0jQ==", + "dev": true, + "bin": { + "watskeburt": "dist/run-cli.js" + }, + "engines": { + "node": "^20.12||^22.13||>=24.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2a40356..c786f2b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "clean": "rm -rf dist", "build": "npm run clean && npx rollup -c", "doc": "npx typedoc", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint:deps": "depcruise src", + "lint:prune": "ts-prune", + "quality": "npm run typecheck && npm run lint:deps && npm run lint:prune && npm run lint && npm run test" }, "keywords": [], "author": "", @@ -24,8 +27,10 @@ "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", + "dependency-cruiser": "^17.3.4", "eslint": "^9.25.1", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-boundaries": "^5.3.1", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.5.1", @@ -33,6 +38,7 @@ "prettier": "^3.5.3", "rollup": "^4.40.0", "ts-jest": "^29.3.2", + "ts-prune": "^0.10.3", "typedoc": "^0.28.3", "typescript": "^5.8.3", "typescript-eslint": "^8.31.0" diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index 5bd69d9..9c5a4a4 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -8,13 +8,12 @@ export class Vault { } } export class TFile { - path: string = ''; + path = ''; } export class TFolder { children: unknown[] = []; } export class Notice { - // eslint-disable-next-line @typescript-eslint/no-unused-vars constructor(_message: string) { // console.debug('Notice:', message); } @@ -27,7 +26,7 @@ export class FileManager { } } export class MockMetadataCache { - private fileCacheMap: Map = new Map(); + private fileCacheMap = new Map(); setFileCache(file: TFile, cache: unknown) { this.fileCacheMap.set(file.path, cache); } diff --git a/src/__tests__/ContactAuditService.test.ts b/src/__tests__/ContactAuditService.test.ts index d05442b..2212611 100644 --- a/src/__tests__/ContactAuditService.test.ts +++ b/src/__tests__/ContactAuditService.test.ts @@ -1,6 +1,6 @@ -import { ContactAuditService } from '../core/ContactAuditService'; +import { ContactAuditService } from '../services/ContactAuditService'; import { App, TFolder, TFile } from 'obsidian'; // Mock these -import { GoogleContactsService } from '../core/GoogleContactsService'; +import { GoogleContactsService } from '../services/GoogleContactsService'; import { ContactSyncSettings } from '../types/Settings'; import { getAllMarkdownFilesInFolder } from '../utils/getAllMarkdownFilesInFolder'; @@ -48,6 +48,7 @@ describe('ContactAuditService', () => { contactsFolder: 'Contacts', syncLabel: 'My Contacts', accessToken: 'valid-token', + propertyNamePrefix: '', } as unknown as ContactSyncSettings; // Default valid mocks @@ -57,10 +58,13 @@ describe('ContactAuditService', () => { (googleService.fetchGoogleContacts as jest.Mock).mockResolvedValue([]); // Mock TFolder return - if (!mockVault.getAbstractFileByPath) + if (!mockVault.getAbstractFileByPath) { throw new Error('mockVault.getAbstractFileByPath is undefined'); + } mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'Contacts') return new TFolder(); // Using mocked TFolder + if (path === 'Contacts') { + return new TFolder(); + } // Using mocked TFolder return null; }); @@ -72,8 +76,9 @@ describe('ContactAuditService', () => { }); it('should handle missing contacts folder', async () => { - if (!mockVault.getAbstractFileByPath) + if (!mockVault.getAbstractFileByPath) { throw new Error('mockVault.getAbstractFileByPath is undefined'); + } mockVault.getAbstractFileByPath.mockReturnValue(null); await service.auditContacts('token'); // valid, implies it returned early without throwing @@ -133,11 +138,16 @@ describe('ContactAuditService', () => { ]); // Mock metadata - if (!mockMetadataCache.getFileCache) + if (!mockMetadataCache.getFileCache) { throw new Error('mockMetadataCache.getFileCache is undefined'); + } mockMetadataCache.getFileCache.mockImplementation((file: TFile) => { - if (file === fileA) return { frontmatter: { id: 'contactA' } }; - if (file === fileB) return { frontmatter: { id: 'contactB' } }; + if (file === fileA) { + return { frontmatter: { id: 'contactA' } }; + } + if (file === fileB) { + return { frontmatter: { id: 'contactB' } }; + } return {}; }); @@ -170,8 +180,9 @@ describe('ContactAuditService', () => { (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([fileA]); - if (!mockMetadataCache.getFileCache) + if (!mockMetadataCache.getFileCache) { throw new Error('mockMetadataCache.getFileCache is undefined'); + } mockMetadataCache.getFileCache.mockReturnValue({ frontmatter: { id: 'contactA' }, }); @@ -208,11 +219,16 @@ describe('ContactAuditService', () => { (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([fileA, fileB]); - if (!mockMetadataCache.getFileCache) + if (!mockMetadataCache.getFileCache) { throw new Error('mockMetadataCache.getFileCache is undefined'); + } mockMetadataCache.getFileCache.mockImplementation((file: TFile) => { - if (file === fileA) return { frontmatter: { id: 'contactA' } }; - if (file === fileB) return { frontmatter: { id: 'contactB' } }; + if (file === fileA) { + return { frontmatter: { id: 'contactA' } }; + } + if (file === fileB) { + return { frontmatter: { id: 'contactB' } }; + } return {}; }); diff --git a/src/__tests__/core/ContactNoteWriter.test.ts b/src/__tests__/core/ContactNoteWriter.test.ts index 3f8699e..733bfb6 100644 --- a/src/__tests__/core/ContactNoteWriter.test.ts +++ b/src/__tests__/core/ContactNoteWriter.test.ts @@ -1,5 +1,5 @@ import { MockMetadataCache } from 'src/__mocks__/obsidian'; -import { ContactNoteWriter } from '../../core/ContactNoteWriter'; +import { ContactNoteWriter } from '../../services/ContactNoteWriter'; import { Vault, TFile, FileStats, MetadataCache, FileManager } from 'obsidian'; import { GoogleContact } from 'src/types/Contact'; import { ContactNoteConfig } from 'src/types/ContactNoteConfig'; @@ -55,8 +55,25 @@ jest.mock('src/utils/getAllMarkdownFilesInFolder', () => ({ getAllMarkdownFilesInFolder: jest.fn(), })); +class TestContactNoteWriter extends ContactNoteWriter { + public scanFiles( + files: TFile[], + propertyPrefix: string + ): Record { + return super.scanFiles(files, propertyPrefix); + } + + public hasSyncLabel( + contact: GoogleContact, + syncLabel: string, + labelMap: Record + ): boolean { + return super.hasSyncLabel(contact, syncLabel, labelMap); + } +} + describe('ContactNoteWriter', () => { - let contactNoteWriter: ContactNoteWriter; + let contactNoteWriter: TestContactNoteWriter; let vault: Vault; let metadataCache: MetadataCache; let fileManager: FileManager; @@ -72,7 +89,7 @@ describe('ContactNoteWriter', () => { getFileByPath: jest.fn(), processFrontMatter: jest.fn(), } as unknown as FileManager; - contactNoteWriter = new ContactNoteWriter( + contactNoteWriter = new TestContactNoteWriter( vault, metadataCache, fileManager @@ -96,7 +113,7 @@ describe('ContactNoteWriter', () => { (vault.getFolderByPath as jest.Mock).mockReturnValue(mockFolder); (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([]); (vault.create as jest.Mock).mockResolvedValue(undefined); - (vault.getFileByPath as jest.Mock).mockResolvedValue(null); + (vault.getFileByPath as jest.Mock).mockReturnValue(null); const config: ContactNoteConfig = { prefix: 'prefix-', @@ -145,7 +162,7 @@ describe('ContactNoteWriter', () => { (vault.getFolderByPath as jest.Mock).mockReturnValue(mockFolder); (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([]); (vault.create as jest.Mock).mockResolvedValue(undefined); - (vault.getFileByPath as jest.Mock).mockResolvedValue(null); + (vault.getFileByPath as jest.Mock).mockReturnValue(null); const config: ContactNoteConfig = { prefix: 'prefix-', @@ -193,7 +210,7 @@ describe('ContactNoteWriter', () => { (vault.getFolderByPath as jest.Mock).mockReturnValue(mockFolder); (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([]); (vault.create as jest.Mock).mockResolvedValue(undefined); - (vault.getFileByPath as jest.Mock).mockResolvedValue(null); + (vault.getFileByPath as jest.Mock).mockReturnValue(null); const config: ContactNoteConfig = { prefix: 'prefix-', @@ -235,7 +252,7 @@ describe('ContactNoteWriter', () => { (vault.getFolderByPath as jest.Mock).mockReturnValue(mockFolder); (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([]); (vault.create as jest.Mock).mockResolvedValue(undefined); - (vault.getFileByPath as jest.Mock).mockResolvedValue(null); + (vault.getFileByPath as jest.Mock).mockReturnValue(null); const config: ContactNoteConfig = { prefix: 'prefix-', @@ -275,7 +292,7 @@ describe('ContactNoteWriter', () => { (vault.getFolderByPath as jest.Mock).mockReturnValue(mockFolder); (getAllMarkdownFilesInFolder as jest.Mock).mockReturnValue([]); (vault.create as jest.Mock).mockResolvedValue(undefined); - (vault.getFileByPath as jest.Mock).mockResolvedValue(null); + (vault.getFileByPath as jest.Mock).mockReturnValue(null); const config: ContactNoteConfig = { prefix: 'prefix-', @@ -323,10 +340,7 @@ describe('ContactNoteWriter', () => { }, }); - const result = await contactNoteWriter['scanFiles']( - mockFiles, - 'propertyPrefix-' - ); + const result = contactNoteWriter.scanFiles(mockFiles, 'propertyPrefix-'); expect(result).toEqual({ '123': mockFiles[0], // Expect the file with id 123 @@ -348,7 +362,7 @@ describe('ContactNoteWriter', () => { }; const mockLabelMap = { family: 'group1' }; - const result = contactNoteWriter['hasSyncLabel']( + const result = contactNoteWriter.hasSyncLabel( mockContact, 'family', mockLabelMap @@ -370,7 +384,7 @@ describe('ContactNoteWriter', () => { }; const mockLabelMap = { family: 'group1' }; - const result = contactNoteWriter['hasSyncLabel']( + const result = contactNoteWriter.hasSyncLabel( mockContact, 'family', mockLabelMap @@ -386,7 +400,7 @@ describe('ContactNoteWriter', () => { }; const mockLabelMap = { family: 'group1' }; - const result = contactNoteWriter['hasSyncLabel']( + const result = contactNoteWriter.hasSyncLabel( mockContact, 'family', mockLabelMap diff --git a/src/__tests__/core/ContactNoteWriterProcess.test.ts b/src/__tests__/core/ContactNoteWriterProcess.test.ts index a073416..1e630ad 100644 --- a/src/__tests__/core/ContactNoteWriterProcess.test.ts +++ b/src/__tests__/core/ContactNoteWriterProcess.test.ts @@ -1,5 +1,5 @@ import { MockMetadataCache } from 'src/__mocks__/obsidian'; -import { ContactNoteWriter } from '../../core/ContactNoteWriter'; +import { ContactNoteWriter } from '../../services/ContactNoteWriter'; import { FileManager, MetadataCache, Vault } from 'obsidian'; import { GoogleContact } from 'src/types/Contact'; import { ContactNoteConfig } from 'src/types/ContactNoteConfig'; diff --git a/src/__tests__/core/GoogleContactsService.test.ts b/src/__tests__/core/GoogleContactsService.test.ts index fb133fb..0f8c580 100644 --- a/src/__tests__/core/GoogleContactsService.test.ts +++ b/src/__tests__/core/GoogleContactsService.test.ts @@ -1,4 +1,4 @@ -import { GoogleContactsService } from '../../core/GoogleContactsService'; +import { GoogleContactsService } from '../../services/GoogleContactsService'; import { requestUrl } from 'obsidian'; import { URL_PEOPLE_CONNECTIONS, diff --git a/src/__tests__/utils/getAllMarkdownFilesInFolder.test.ts b/src/__tests__/utils/getAllMarkdownFilesInFolder.test.ts index 506b1d8..e7b15da 100644 --- a/src/__tests__/utils/getAllMarkdownFilesInFolder.test.ts +++ b/src/__tests__/utils/getAllMarkdownFilesInFolder.test.ts @@ -15,10 +15,10 @@ describe('getAllMarkdownFilesInFolder', () => { } } - const isTFolder = (obj: MockTFolder): obj is MockTFolder => - obj && obj.__isMockTFolder; - const isTFile = (obj: MockTFile): obj is MockTFile => - obj && obj.__isMockTFile; + const isTFolder = (obj: unknown): obj is MockTFolder => + typeof obj === 'object' && obj !== null && '__isMockTFolder' in obj; + const isTFile = (obj: unknown): obj is MockTFile => + typeof obj === 'object' && obj !== null && '__isMockTFile' in obj; beforeAll(() => { Object.defineProperty(TFolder, Symbol.hasInstance, { diff --git a/src/auth/AuthManager.ts b/src/auth/AuthManager.ts index 9c0152f..01a3cef 100644 --- a/src/auth/AuthManager.ts +++ b/src/auth/AuthManager.ts @@ -1,6 +1,13 @@ import type { ContactSyncSettings } from '../types/Settings'; import { requestUrl } from 'obsidian'; import { URL_OAUTH_TOKEN, URI_OATUH_REDIRECT } from '../config'; +import { RequestUrlResponse } from 'obsidian'; + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; +} /** * Manages OAuth2 authentication for accessing the Google contacts API. @@ -41,7 +48,7 @@ export class AuthManager { redirect_uri: URI_OATUH_REDIRECT, grant_type: 'authorization_code', }; - const response = await requestUrl({ + const res: RequestUrlResponse = await requestUrl({ url: URL_OAUTH_TOKEN, method: 'POST', headers: { @@ -50,10 +57,10 @@ export class AuthManager { body: JSON.stringify(body), }); - const data = await response.json; - this.accessToken = await data.access_token; - this.refreshToken = (await data.refresh_token) || this.refreshToken; - this.tokenExpiresAt = Date.now() + (await data.expires_in) * 1000; + const data = res.json as TokenResponse; + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token ?? this.refreshToken; + this.tokenExpiresAt = Date.now() + data.expires_in * 1000; } /** @@ -81,7 +88,7 @@ export class AuthManager { } try { - const response = await requestUrl({ + const res: RequestUrlResponse = await requestUrl({ url: URL_OAUTH_TOKEN, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -93,7 +100,7 @@ export class AuthManager { }).toString(), }); - const data = await response.json; + const data = res.json as TokenResponse; if (!data.access_token) { throw new Error('Failed to refresh token'); } diff --git a/src/config/index.ts b/src/config/index.ts index 385c96b..41fdaf3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,7 +3,7 @@ export const URL_OAUTH_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'; export const URL_OAUTH_TOKEN = 'https://oauth2.googleapis.com/token'; /** Google People API endpoints */ -export const URL_PEOPLE_BASE = 'https://people.googleapis.com/v1'; +const URL_PEOPLE_BASE = 'https://people.googleapis.com/v1'; export const URL_PEOPLE_CONNECTIONS = `${URL_PEOPLE_BASE}/people/me/connections`; export const PERSONAL_FIELDS = `names,emailAddresses,phoneNumbers,birthdays,memberships,metadata,addresses,biographies,organizations`; export const URL_CONTACT_GROUPS = `${URL_PEOPLE_BASE}/contactGroups?pageSize=1000`; diff --git a/src/core/Formatter.ts b/src/core/Formatter.ts index a8d8d91..685b56a 100644 --- a/src/core/Formatter.ts +++ b/src/core/Formatter.ts @@ -16,7 +16,7 @@ import { LabelAdapter } from './adapters/LabelAdapter'; /** * Formatter class responsible for coordinating field extraction and key generation. */ -export class Formatter { +class Formatter { constructor( private adapters: Record, private strategy: KeyNamingStrategy @@ -48,7 +48,7 @@ export class Formatter { results.forEach((result, arrayIndex) => { // Use result.index if present (for grouping subfields), // otherwise use array index - const index = result.index !== undefined ? result.index : arrayIndex; + const index = result.index ?? arrayIndex; const key = this.strategy.generateKey( fieldId, index, diff --git a/src/core/adapters/AddressAdapter.ts b/src/core/adapters/AddressAdapter.ts index ea3c77d..1bc19d6 100644 --- a/src/core/adapters/AddressAdapter.ts +++ b/src/core/adapters/AddressAdapter.ts @@ -7,8 +7,10 @@ export class AddressAdapter implements FieldAdapter { contact: GoogleContact, context?: Record ): ExtractionResult[] { - const addresses = contact.addresses || []; - if (addresses.length === 0) return []; + const addresses = contact.addresses ?? []; + if (addresses.length === 0) { + return []; + } // Check if we're using VCF strategy const isVcfStrategy = diff --git a/src/core/adapters/BioAdapter.ts b/src/core/adapters/BioAdapter.ts index 6abd501..39ac87a 100644 --- a/src/core/adapters/BioAdapter.ts +++ b/src/core/adapters/BioAdapter.ts @@ -3,7 +3,7 @@ import { GoogleContact } from '../../types/Contact'; export class BioAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - return (contact.biographies || []) + return (contact.biographies ?? []) .filter((item) => item.value) .map((item) => ({ value: item.value })); } diff --git a/src/core/adapters/BirthdayAdapter.ts b/src/core/adapters/BirthdayAdapter.ts index f180c4d..f7c803c 100644 --- a/src/core/adapters/BirthdayAdapter.ts +++ b/src/core/adapters/BirthdayAdapter.ts @@ -3,7 +3,9 @@ import { GoogleContact } from '../../types/Contact'; export class BirthdayAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - if (!contact.birthdays || contact.birthdays.length === 0) return []; + if (!contact.birthdays || contact.birthdays.length === 0) { + return []; + } const results: ExtractionResult[] = []; contact.birthdays.forEach((bday) => { diff --git a/src/core/adapters/DepartmentAdapter.ts b/src/core/adapters/DepartmentAdapter.ts index a6eac64..a792888 100644 --- a/src/core/adapters/DepartmentAdapter.ts +++ b/src/core/adapters/DepartmentAdapter.ts @@ -3,7 +3,7 @@ import { GoogleContact } from '../../types/Contact'; export class DepartmentAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - return (contact.organizations || []) + return (contact.organizations ?? []) .filter((item) => item.department) .map((item) => ({ value: item.department })); } diff --git a/src/core/adapters/EmailAdapter.ts b/src/core/adapters/EmailAdapter.ts index 7b664bd..91f9d60 100644 --- a/src/core/adapters/EmailAdapter.ts +++ b/src/core/adapters/EmailAdapter.ts @@ -3,7 +3,7 @@ import { GoogleContact } from '../../types/Contact'; export class EmailAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - return (contact.emailAddresses || []) + return (contact.emailAddresses ?? []) .filter((item) => item.value) .map((item) => ({ value: item.value })); } diff --git a/src/core/adapters/GoogleIdAdapter.ts b/src/core/adapters/GoogleIdAdapter.ts index f9d1ecf..9efce58 100644 --- a/src/core/adapters/GoogleIdAdapter.ts +++ b/src/core/adapters/GoogleIdAdapter.ts @@ -3,7 +3,9 @@ import { GoogleContact } from '../../types/Contact'; export class GoogleIdAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - if (!contact.resourceName) return []; + if (!contact.resourceName) { + return []; + } // resourceName is like "people/c1234567890" // We want the ID part "c1234567890" diff --git a/src/core/adapters/JobTitleAdapter.ts b/src/core/adapters/JobTitleAdapter.ts index 9570528..67a5cd8 100644 --- a/src/core/adapters/JobTitleAdapter.ts +++ b/src/core/adapters/JobTitleAdapter.ts @@ -3,7 +3,7 @@ import { GoogleContact } from '../../types/Contact'; export class JobTitleAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - return (contact.organizations || []) + return (contact.organizations ?? []) .filter((item) => item.title) .map((item) => ({ value: item.title })); } diff --git a/src/core/adapters/LabelAdapter.ts b/src/core/adapters/LabelAdapter.ts index 9f8abe4..39eb4b6 100644 --- a/src/core/adapters/LabelAdapter.ts +++ b/src/core/adapters/LabelAdapter.ts @@ -1,10 +1,6 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; -export interface LabelAdapterContext { - labelMap: Record; -} - export class LabelAdapter implements FieldAdapter { extract( contact: GoogleContact, diff --git a/src/core/adapters/NameAdapter.ts b/src/core/adapters/NameAdapter.ts index 0fa80b3..b60b36f 100644 --- a/src/core/adapters/NameAdapter.ts +++ b/src/core/adapters/NameAdapter.ts @@ -6,129 +6,91 @@ export class NameAdapter implements FieldAdapter { contact: GoogleContact, context?: Record ): ExtractionResult[] { - const results: ExtractionResult[] = []; const isVcfStrategy = context?.namingStrategy === 'VcfNamingStrategy'; - // For Default strategy: only extract displayName - if (!isVcfStrategy) { - const displayName = contact.names?.[0]?.displayName; - - if (displayName) { - contact.names?.forEach((item) => { - if (item.displayName) { - results.push({ value: item.displayName }); - } - }); - } else if (contact.organizations?.[0]?.name) { - // Fall back to organization name if no displayName - results.push({ value: contact.organizations[0].name }); - } - - return results; + if (isVcfStrategy) { + return this.extractVcf(contact); } - // For VCF strategy: extract all structured name fields - const names = contact.names || []; + return this.extractDefault(contact); + } + + private extractDefault(contact: GoogleContact): ExtractionResult[] { + const results: ExtractionResult[] = []; + const names = contact.names ?? []; + + if (names.length > 0) { + names.forEach((item) => { + if (item.displayName) { + results.push({ value: item.displayName }); + } + }); + } else if (contact.organizations?.[0]?.name) { + results.push({ value: contact.organizations[0].name }); + } + + return results; + } + + private extractVcf(contact: GoogleContact): ExtractionResult[] { + const results: ExtractionResult[] = []; + const names = contact.names ?? []; if (names.length === 0) { - // Fall back to organization name if no name data if (contact.organizations?.[0]?.name) { results.push({ value: contact.organizations[0].name }); } return results; } - // Process each name entry (usually just one, but could be multiple) names.forEach((name, nameIndex) => { - // Main name fields - all subfields of this name get the same index - // Note: displayName is handled by FormattedNameAdapter for FN field - - if (name.givenName) { - results.push({ value: name.givenName, suffix: 'GN', index: nameIndex }); - } - - if (name.middleName) { - results.push({ - value: name.middleName, - suffix: 'MN', - index: nameIndex, - }); - } - - if (name.familyName) { - results.push({ - value: name.familyName, - suffix: 'FN', - index: nameIndex, - }); - } - - if (name.honorificPrefix) { - results.push({ - value: name.honorificPrefix, - suffix: 'PREFIX', - index: nameIndex, - }); - } - - if (name.honorificSuffix) { - results.push({ - value: name.honorificSuffix, - suffix: 'SUFFIX', - index: nameIndex, - }); - } - - // Phonetic name fields - if (name.phoneticFullName) { - results.push({ - value: name.phoneticFullName, - suffix: 'PHONETIC_FULL', - index: nameIndex, - }); - } - - if (name.phoneticGivenName) { - results.push({ - value: name.phoneticGivenName, - suffix: 'PHONETIC_GN', - index: nameIndex, - }); - } - - if (name.phoneticMiddleName) { - results.push({ - value: name.phoneticMiddleName, - suffix: 'PHONETIC_MN', - index: nameIndex, - }); - } - - if (name.phoneticFamilyName) { - results.push({ - value: name.phoneticFamilyName, - suffix: 'PHONETIC_FN', - index: nameIndex, - }); - } - - if (name.phoneticHonorificPrefix) { - results.push({ - value: name.phoneticHonorificPrefix, - suffix: 'PHONETIC_PREFIX', - index: nameIndex, - }); - } - - if (name.phoneticHonorificSuffix) { - results.push({ - value: name.phoneticHonorificSuffix, - suffix: 'PHONETIC_SUFFIX', - index: nameIndex, - }); - } + this.addNameFields(results, name, nameIndex); + this.addPhoneticFields(results, name, nameIndex); }); return results; } + + private addNameFields( + results: ExtractionResult[], + name: NonNullable[number], + index: number + ): void { + const fields: Record = { + givenName: 'GN', + middleName: 'MN', + familyName: 'FN', + honorificPrefix: 'PREFIX', + honorificSuffix: 'SUFFIX', + }; + + for (const [prop, suffix] of Object.entries(fields)) { + const value = (name as Record)[prop]; + if (typeof value === 'string') { + results.push({ value, suffix, index }); + } + } + } + + private addPhoneticFields( + results: ExtractionResult[], + name: NonNullable[number], + index: number + ): void { + const fields: Record = { + phoneticFullName: 'PHONETIC_FULL', + phoneticGivenName: 'PHONETIC_GN', + phoneticMiddleName: 'PHONETIC_MN', + phoneticFamilyName: 'PHONETIC_FN', + phoneticHonorificPrefix: 'PHONETIC_PREFIX', + phoneticHonorificSuffix: 'PHONETIC_SUFFIX', + }; + + for (const [prop, suffix] of Object.entries(fields)) { + const value = (name as Record)[prop]; + if (typeof value === 'string') { + results.push({ value, suffix, index }); + } + } + } } diff --git a/src/core/adapters/OrganizationAdapter.ts b/src/core/adapters/OrganizationAdapter.ts index c558285..cc00225 100644 --- a/src/core/adapters/OrganizationAdapter.ts +++ b/src/core/adapters/OrganizationAdapter.ts @@ -1,10 +1,6 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; -export interface OrganizationAdapterContext { - organizationAsLink: boolean; -} - export class OrganizationAdapter implements FieldAdapter { extract( contact: GoogleContact, @@ -14,7 +10,7 @@ export class OrganizationAdapter implements FieldAdapter { const isVcfStrategy = context?.namingStrategy === 'VcfNamingStrategy' || context?.namingStrategy === 'VcfNamingStrategy'; - return (contact.organizations || []) + return (contact.organizations ?? []) .map((org) => org.name) .filter((name) => !!name) .map((name) => ({ diff --git a/src/core/adapters/PhoneAdapter.ts b/src/core/adapters/PhoneAdapter.ts index 5356820..fecb9d4 100644 --- a/src/core/adapters/PhoneAdapter.ts +++ b/src/core/adapters/PhoneAdapter.ts @@ -3,7 +3,7 @@ import { GoogleContact } from '../../types/Contact'; export class PhoneAdapter implements FieldAdapter { extract(contact: GoogleContact): ExtractionResult[] { - return (contact.phoneNumbers || []) + return (contact.phoneNumbers ?? []) .filter((item) => item.value) .map((item) => ({ value: item.value })); } diff --git a/src/core/strategies/DefaultNamingStrategy.ts b/src/core/strategies/DefaultNamingStrategy.ts index 42112b1..fa4a8c5 100644 --- a/src/core/strategies/DefaultNamingStrategy.ts +++ b/src/core/strategies/DefaultNamingStrategy.ts @@ -5,7 +5,7 @@ export class DefaultNamingStrategy implements KeyNamingStrategy { baseKey: string, index: number, prefix: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + _suffix?: string ): string { const keySuffix = index === 0 ? '' : `_${index + 1}`; diff --git a/src/core/strategies/VcfNamingStrategy.ts b/src/core/strategies/VcfNamingStrategy.ts index e2edfaa..8d54642 100644 --- a/src/core/strategies/VcfNamingStrategy.ts +++ b/src/core/strategies/VcfNamingStrategy.ts @@ -24,7 +24,7 @@ export class VcfNamingStrategy implements KeyNamingStrategy { _prefix: string, suffix?: string ): string { - const vcfKey = this.keyMap[baseKey] || baseKey.toUpperCase(); + const vcfKey = this.keyMap[baseKey] ?? baseKey.toUpperCase(); // VCF format doesn't support prefixes, but needs indexed notation // for multiple values: EMAIL[2], EMAIL[3], etc. // This format is compatible with obsidian-vcf-contacts plugin diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts index 23e18ca..2866fc8 100644 --- a/src/i18n/translator.ts +++ b/src/i18n/translator.ts @@ -7,7 +7,7 @@ import { ru, en, lv } from './translations'; export class Translator { private language: string; - constructor(language: string = 'en') { + constructor(language = 'en') { this.language = language; } @@ -42,7 +42,7 @@ export class Translator { } // Singleton-like instance for general use -export const translator = new Translator(getLanguage()); // Default to the current Obsidian language +const translator = new Translator(getLanguage()); // Default to the current Obsidian language /** * Global translation function. diff --git a/src/main.ts b/src/main.ts index a4fa46d..9c7d8cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,10 @@ import { ContactSyncSettingTab } from './plugin/settings'; import { DEFAULT_SETTINGS } from './config'; import { Plugin, Notice } from 'obsidian'; import { AuthManager } from './auth/AuthManager'; -import { GoogleContactsService } from './core/GoogleContactsService'; -import { ContactNoteWriter } from './core/ContactNoteWriter'; +import { GoogleContactsService } from './services/GoogleContactsService'; +import { ContactNoteWriter } from './services/ContactNoteWriter'; import { ContactNoteConfig } from './types/ContactNoteConfig'; -import { ContactAuditService } from './core/ContactAuditService'; +import { ContactAuditService } from './services/ContactAuditService'; import { t } from './i18n/translator'; import type { GoogleContact } from './types/Contact'; @@ -75,21 +75,25 @@ export default class GoogleContactsSyncPlugin extends Plugin { ); if (this.settings.syncOnStartup || this.shouldSyncNow()) { - this.syncContacts(); + void this.syncContacts(); } this.setupAutoSync(); } - async onunload() { - if (this.syncIntervalId) clearInterval(this.syncIntervalId); + onunload() { + if (this.syncIntervalId) { + clearInterval(this.syncIntervalId); + } } /** * Sets up automatic periodic syncing using setInterval based on plugin settings. */ setupAutoSync() { - if (this.syncIntervalId) clearInterval(this.syncIntervalId); + if (this.syncIntervalId) { + clearInterval(this.syncIntervalId); + } const interval = this.settings.syncIntervalMinutes; @@ -98,7 +102,7 @@ export default class GoogleContactsSyncPlugin extends Plugin { window.setInterval( () => { if (this.shouldSyncNow()) { - this.syncContacts(); + void this.syncContacts(); } }, interval * 60 * 1000 @@ -114,8 +118,12 @@ export default class GoogleContactsSyncPlugin extends Plugin { shouldSyncNow(): boolean { const { lastSyncTime, syncIntervalMinutes } = this.settings; - if (syncIntervalMinutes === 0) return false; - if (!lastSyncTime) return true; + if (syncIntervalMinutes === 0) { + return false; + } + if (!lastSyncTime) { + return true; + } const last = new Date(lastSyncTime).getTime(); const now = Date.now(); @@ -128,18 +136,44 @@ export default class GoogleContactsSyncPlugin extends Plugin { * Performs the contact synchronization process: fetching, processing, and saving contact notes. */ async syncContacts() { - this.updateLastSyncTime(); + void this.updateLastSyncTime(); + const token = await this.getToken(); + if (!token) { + return; + } + + const labelMap = await this.getLabelMap(token); + if (!labelMap) { + return; + } + + const contacts = await this.getContacts(token); + if (!contacts) { + return; + } + + const config = this.getNoteConfig(); + await this.noteWriter?.writeNotesForContacts(config, labelMap, contacts); + + new Notice(t('Google contacts synced!')); + } + + private async getToken(): Promise { const token = await this.auth?.ensureValidToken(); if (!token) { new Notice(t('Failed to obtain access token. Please re-authenticate.')); - return; + return null; } - this.updateAuthSettings(); + void this.updateAuthSettings(); + return token; + } - let labelMap: Record = {}; + private async getLabelMap( + token: string + ): Promise | null> { try { - labelMap = (await this.googleService?.fetchGoogleGroups(token)) || {}; + return (await this.googleService?.fetchGoogleGroups(token)) ?? {}; } catch (error) { console.error( 'Failed to fetch Google groups', @@ -148,12 +182,13 @@ export default class GoogleContactsSyncPlugin extends Plugin { new Notice( t('Failed to fetch Google groups. Check console for details.') ); - return; + return null; } + } - let contacts: GoogleContact[] = []; + private async getContacts(token: string): Promise { try { - contacts = (await this.googleService?.fetchGoogleContacts(token)) || []; + return (await this.googleService?.fetchGoogleContacts(token)) ?? []; } catch (error) { console.error( 'Failed to fetch Google contacts', @@ -162,13 +197,15 @@ export default class GoogleContactsSyncPlugin extends Plugin { new Notice( t('Failed to fetch Google contacts. Check console for details.') ); - return; + return null; } + } - const config: ContactNoteConfig = { + private getNoteConfig(): ContactNoteConfig { + return { folderPath: this.settings.contactsFolder, - prefix: this.settings.fileNamePrefix || '', - propertyPrefix: this.settings.propertyNamePrefix || '', + prefix: this.settings.fileNamePrefix, + propertyPrefix: this.settings.propertyNamePrefix, syncLabel: this.settings.syncLabel, noteBody: this.settings.noteTemplate || '# Notes\n', organizationAsLink: this.settings.organizationAsLink, @@ -176,10 +213,6 @@ export default class GoogleContactsSyncPlugin extends Plugin { renameFiles: this.settings.renameFiles, namingStrategy: this.settings.namingStrategy, }; - - await this.noteWriter?.writeNotesForContacts(config, labelMap, contacts); - - new Notice(t('Google contacts synced!')); } /** @@ -204,7 +237,11 @@ export default class GoogleContactsSyncPlugin extends Plugin { * Loads plugin settings from disk, applying defaults as needed. */ async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + (await this.loadData()) as Partial + ); } /** diff --git a/src/core/FolderSuggest.ts b/src/plugin/FolderSuggest.ts similarity index 100% rename from src/core/FolderSuggest.ts rename to src/plugin/FolderSuggest.ts diff --git a/src/plugin/settings.ts b/src/plugin/settings.ts index ecd4a0c..6dbbddd 100644 --- a/src/plugin/settings.ts +++ b/src/plugin/settings.ts @@ -1,10 +1,10 @@ import { Notice, Setting, App, PluginSettingTab } from 'obsidian'; import { LINK_TO_MANUAL } from '../config'; import { getAuthUrl } from '../auth/getAuthUrl'; -import GoogleContactsSyncPlugin from '../main'; +import { IPlugin } from '../types/IPlugin'; import { NamingStrategy } from 'src/types/Settings'; import { t } from '../i18n/translator'; -import { FolderSuggest } from 'src/core/FolderSuggest'; +import { FolderSuggest } from './FolderSuggest'; /** * Settings tab for the Google contacts sync plugin. @@ -12,14 +12,14 @@ import { FolderSuggest } from 'src/core/FolderSuggest'; */ export class ContactSyncSettingTab extends PluginSettingTab { /** Reference to the main plugin instance */ - plugin: GoogleContactsSyncPlugin; + plugin: IPlugin; /** * Constructs the settings tab. * @param app The current Obsidian app instance. * @param plugin The instance of the GoogleContactsSyncPlugin. */ - constructor(app: App, plugin: GoogleContactsSyncPlugin) { + constructor(app: App, plugin: IPlugin) { super(app, plugin); this.plugin = plugin; } diff --git a/src/core/ContactAuditService.ts b/src/services/ContactAuditService.ts similarity index 65% rename from src/core/ContactAuditService.ts rename to src/services/ContactAuditService.ts index d222dd2..81ee99c 100644 --- a/src/core/ContactAuditService.ts +++ b/src/services/ContactAuditService.ts @@ -15,29 +15,56 @@ export class ContactAuditService { async auditContacts(token: string): Promise { new Notice(t('Starting contact audit...')); - let labelMap: Record = {}; + const labelMap = await this.getLabelMap(token); + if (!labelMap) { + return; + } + + const googleContacts = await this.getGoogleContacts(token); + if (!googleContacts) { + return; + } + + const validGoogleIds = this.getValidGoogleIds(googleContacts, labelMap); + const orphans = this.identifyOrphans(validGoogleIds); + + if (orphans) { + await this.generateReport(orphans); + } + } + + private async getLabelMap( + token: string + ): Promise | null> { try { - labelMap = (await this.googleService.fetchGoogleGroups(token)) || {}; + return await this.googleService.fetchGoogleGroups(token); } catch (error) { console.error('Failed to fetch Google groups', error); new Notice( t('Failed to fetch Google groups. Check console for details.') ); - return; + return null; } + } - let googleContacts: GoogleContact[] = []; + private async getGoogleContacts( + token: string + ): Promise { try { - googleContacts = - (await this.googleService.fetchGoogleContacts(token)) || []; + return await this.googleService.fetchGoogleContacts(token); } catch (error) { console.error('Failed to fetch Google contacts', error); new Notice( t('Failed to fetch Google contacts. Check console for details.') ); - return; + return null; } + } + private getValidGoogleIds( + googleContacts: GoogleContact[], + labelMap: Record + ): Set { const validGoogleIds = new Set(); googleContacts.forEach((contact) => { if (this.hasSyncLabel(contact, this.settings.syncLabel, labelMap)) { @@ -47,32 +74,44 @@ export class ContactAuditService { } } }); + return validGoogleIds; + } + private identifyOrphans( + validGoogleIds: Set + ): { file: TFile; id: string }[] | null { const folderPath = this.settings.contactsFolder; const folder = this.app.vault.getAbstractFileByPath(folderPath); if (!folder || !(folder instanceof TFolder)) { new Notice(`${t('Contacts folder not found')}: ${folderPath}`); - return; + return null; } const files = getAllMarkdownFilesInFolder(folder); const orphans: { file: TFile; id: string }[] = []; - - const propertyPrefix = this.settings.propertyNamePrefix || ''; - const idField = `${propertyPrefix}id`; + const idField = `${this.settings.propertyNamePrefix}id`; for (const file of files) { - const cache = this.app.metadataCache.getFileCache(file); - const id = cache?.frontmatter?.[idField]; - - if (id) { - if (!validGoogleIds.has(String(id))) { - orphans.push({ file, id: String(id) }); - } + const id = this.getContactIdFromFile(file, idField); + if (id && !validGoogleIds.has(id)) { + orphans.push({ file, id }); } } - await this.generateReport(orphans); + return orphans; + } + + private getContactIdFromFile(file: TFile, idField: string): string | null { + const cache = this.app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter as + | Record + | undefined; + const id = frontmatter?.[idField]; + + if (typeof id === 'string' || typeof id === 'number') { + return String(id); + } + return null; } private async generateReport( @@ -113,7 +152,7 @@ export class ContactAuditService { new Notice(`${t('Audit complete. Report saved to')} ${reportPath}`); const reportFile = this.app.vault.getAbstractFileByPath(reportPath); if (reportFile instanceof TFile) { - this.app.workspace.getLeaf(true).openFile(reportFile); + void this.app.workspace.getLeaf(true).openFile(reportFile); } } @@ -122,18 +161,24 @@ export class ContactAuditService { syncLabel: string, labelMap: Record ): boolean { - if (!syncLabel) return true; + if (!syncLabel) { + return true; + } const targetGroupId = labelMap[syncLabel]; - if (!targetGroupId) return false; + if (!targetGroupId) { + return false; + } - return (contact.memberships || []).some( + return (contact.memberships ?? []).some( (m) => m.contactGroupMembership?.contactGroupId === targetGroupId ); } private getContactId(contact: GoogleContact): string | null { - if (!contact.resourceName) return null; - return contact.resourceName.split('/').pop() || null; + if (!contact.resourceName) { + return null; + } + return contact.resourceName.split('/').pop() ?? null; } } diff --git a/src/core/ContactNoteWriter.ts b/src/services/ContactNoteWriter.ts similarity index 65% rename from src/core/ContactNoteWriter.ts rename to src/services/ContactNoteWriter.ts index 4e82a49..0b36df9 100644 --- a/src/core/ContactNoteWriter.ts +++ b/src/services/ContactNoteWriter.ts @@ -7,8 +7,8 @@ import { } from 'obsidian'; import { GoogleContact } from 'src/types/Contact'; import { getAllMarkdownFilesInFolder } from 'src/utils/getAllMarkdownFilesInFolder'; -import { createDefaultFormatter } from './Formatter'; -import { VaultService } from 'src/services/VaultService'; +import { createDefaultFormatter } from '../core/Formatter'; +import { VaultService } from './VaultService'; import { ContactNoteConfig } from 'src/types/ContactNoteConfig'; import { NamingStrategy } from 'src/types/Settings'; @@ -70,56 +70,118 @@ export class ContactNoteWriter { await this.vaultService.createFolderIfNotExists(config.folderPath); const folder = this.vaultService.getFolderByPath(config.folderPath); - if (!folder) return; + if (!folder) { + return; + } + const files = getAllMarkdownFilesInFolder(folder); - const filesIdMapping = await this.scanFiles(files, config.propertyPrefix); - const invertedLabelMap: Record = Object.fromEntries( - Object.entries(labelMap).map((a) => a.reverse()) - ); + const filesIdMapping = this.scanFiles(files, config.propertyPrefix); + const invertedLabelMap = this.getInvertedLabelMap(labelMap); for (const contact of contacts) { - if (!this.hasSyncLabel(contact, config.syncLabel, labelMap)) continue; - - const id = this.getContactId(contact); - if (!id) continue; - - const filename = this.getFilename( + await this.processContact( contact, - id, - config.folderPath, - config.prefix - ); - if (!filename) continue; - - if (config.renameFiles && filesIdMapping[id]) { - const existingFile = filesIdMapping[id]; - if (existingFile.path !== filename) { - await this.vaultService.renameFile(existingFile, filename); - delete filesIdMapping[id]; - filesIdMapping[id] = (await this.vaultService.getFileByPath( - filename - )) as TFile; - } - } - - await this.fileManager.processFrontMatter( - filesIdMapping[id] || - (await this.vaultService.getFileByPath(filename)) || - (await this.vaultService.createFile(filename, config.noteBody)), - this.processFrontMatter( - this.generateFrontmatterLines( - config.propertyPrefix, - contact, - invertedLabelMap, - config.namingStrategy, - config.organizationAsLink, - config.trackSyncTime - ) - ) + config, + labelMap, + invertedLabelMap, + filesIdMapping ); } } + private getInvertedLabelMap( + labelMap: Record + ): Record { + return Object.fromEntries( + Object.entries(labelMap).map(([key, value]) => [value, key]) + ) as Record; + } + + private async processContact( + contact: GoogleContact, + config: ContactNoteConfig, + labelMap: Record, + invertedLabelMap: Record, + filesIdMapping: Record + ): Promise { + if (!this.hasSyncLabel(contact, config.syncLabel, labelMap)) { + return; + } + + const id = this.getContactId(contact); + if (!id) { + return; + } + + let filename = this.getFilename( + contact, + id, + config.folderPath, + config.prefix + ); + if (!filename) { + return; + } + + if (config.renameFiles && filesIdMapping[id]) { + filename = await this.ensureRenamed(id, filename, filesIdMapping); + } + + const file = await this.getOrCreateFile( + id, + filename, + filesIdMapping, + config.noteBody + ); + if (!file) { + return; + } + await this.fileManager.processFrontMatter( + file, + this.processFrontMatter( + this.generateFrontmatterLines( + config.propertyPrefix, + contact, + invertedLabelMap, + config.namingStrategy, + config.organizationAsLink, + config.trackSyncTime + ) + ) + ); + } + + private async ensureRenamed( + id: string, + filename: string, + filesIdMapping: Record + ): Promise { + const existingFile = filesIdMapping[id]; + if (existingFile && existingFile.path !== filename) { + await this.vaultService.renameFile(existingFile, filename); + const updatedFile = this.vaultService.getFileByPath(filename); + if (updatedFile) { + filesIdMapping[id] = updatedFile; + return filename; + } + } + return existingFile?.path ?? filename; + } + + private async getOrCreateFile( + id: string, + filename: string, + filesIdMapping: Record, + noteBody: string + ): Promise { + const existingFile = + filesIdMapping[id] ?? this.vaultService.getFileByPath(filename); + if (existingFile) { + return existingFile; + } + return await this.vaultService.createFile(filename, noteBody); + } + /** * Generates a filename for the contact note based on the contact's display name and ID. * Falls back to organization name if no display name is available. @@ -138,8 +200,10 @@ export class ContactNoteWriter { ): string | null { // Try displayName first, then organization name, then fall back to ID const name = - contact.names?.[0]?.displayName || contact.organizations?.[0]?.name || id; - if (!name) return null; + contact.names?.[0]?.displayName ?? contact.organizations?.[0]?.name ?? id; + if (!name) { + return null; + } const safeName = name.replace(/[\\/:*?"<>|]/g, '_'); const filename = normalizePath(`${folderPath}/${prefix}${safeName}.md`); return filename; @@ -168,8 +232,10 @@ export class ContactNoteWriter { * @returns The extracted contact ID, or null if not found. */ private getContactId(contact: GoogleContact): string | null { - if (!contact.resourceName) return null; - return contact.resourceName.split('/').pop() || null; + if (!contact.resourceName) { + return null; + } + return contact.resourceName.split('/').pop() ?? null; } /** @@ -186,8 +252,8 @@ export class ContactNoteWriter { contact: GoogleContact, invertedLabelMap: Record, namingStrategy: NamingStrategy, - organizationAsLink: boolean = false, - trackSyncTime: boolean = false + organizationAsLink = false, + trackSyncTime = false ): Record { const formatter = createDefaultFormatter(namingStrategy); const formattedFields = formatter.generateFrontmatter( @@ -205,9 +271,7 @@ export class ContactNoteWriter { }; if (trackSyncTime && namingStrategy !== NamingStrategy.VCF) { - frontmatterLines[`${propertyPrefix}synced`] = String( - new Date().toISOString() - ); + frontmatterLines[`${propertyPrefix}synced`] = new Date().toISOString(); } return frontmatterLines; @@ -226,10 +290,12 @@ export class ContactNoteWriter { syncLabel: string, labelMap: Record ): boolean { - if (!syncLabel) return true; + if (!syncLabel) { + return true; + } const targetGroupId = labelMap[syncLabel]; - return (contact.memberships || []).some( + return (contact.memberships ?? []).some( (m) => m.contactGroupMembership?.contactGroupId === targetGroupId ); } @@ -241,17 +307,20 @@ export class ContactNoteWriter { * @param propertyPrefix - The prefix to use for identifying frontmatter properties. * @returns A mapping of property values to TFile objects. */ - protected async scanFiles( + protected scanFiles( files: TFile[], propertyPrefix: string - ): Promise> { + ): Record { const idToFileMapping: Record = {}; for (const file of files) { - const frontmatter = this.metadataCache.getFileCache(file)?.frontmatter; + const frontmatter = this.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; const idFieldName = `${propertyPrefix}id`; - if (frontmatter && frontmatter[idFieldName]) { - idToFileMapping[frontmatter[idFieldName]] = file; + const idValue = frontmatter?.[idFieldName]; + if (typeof idValue === 'string' || typeof idValue === 'number') { + idToFileMapping[String(idValue)] = file; } } diff --git a/src/core/GoogleContactsService.ts b/src/services/GoogleContactsService.ts similarity index 82% rename from src/core/GoogleContactsService.ts rename to src/services/GoogleContactsService.ts index d7c0b9f..cbefbf2 100644 --- a/src/core/GoogleContactsService.ts +++ b/src/services/GoogleContactsService.ts @@ -5,6 +5,16 @@ import { PERSONAL_FIELDS, } from '../config'; import type { GoogleContact, GoogleContactGroup } from '../types/Contact'; +import { RequestUrlResponse } from 'obsidian'; + +interface PeopleConnectionsResponse { + connections?: GoogleContact[] | undefined; + nextPageToken?: string | undefined; +} + +interface ContactGroupsResponse { + contactGroups?: GoogleContactGroup[]; +} /** * Core service responsible for interacting with Google contacts and Contact Groups APIs. @@ -24,10 +34,7 @@ export class GoogleContactsService { async fetchGoogleContacts(token: string): Promise { let allContacts: GoogleContact[] = []; let nextPageToken: string | undefined = undefined; - let data: { - connections: GoogleContact[]; - nextPageToken?: string | undefined; - } = { + let data: PeopleConnectionsResponse = { connections: [], nextPageToken: undefined, }; @@ -36,14 +43,14 @@ export class GoogleContactsService { const url = `${URL_PEOPLE_CONNECTIONS}?personFields=${PERSONAL_FIELDS}&pageSize=1000${nextPageToken ? `&pageToken=${nextPageToken}` : ''}`; try { - const res = await requestUrl({ + const res: RequestUrlResponse = await requestUrl({ url, headers: { Authorization: `Bearer ${token}`, }, }); - data = await res.json; + data = (await res.json) as PeopleConnectionsResponse; } catch (error) { console.error( 'Failed to fetch Google contacts', @@ -54,7 +61,7 @@ export class GoogleContactsService { nextPageToken: undefined, }; } - allContacts = allContacts.concat(data.connections || []); + allContacts = allContacts.concat(data.connections ?? []); nextPageToken = data.nextPageToken; } while (nextPageToken); @@ -75,7 +82,7 @@ export class GoogleContactsService { }, }); - const data = await groupResponse.json; + const data = (await groupResponse.json) as ContactGroupsResponse; if (!Array.isArray(data.contactGroups)) { return {}; } diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts deleted file mode 100644 index 892697c..0000000 --- a/src/services/MetadataService.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TFile, CachedMetadata, MetadataCache } from 'obsidian'; - -/** - * Service class for managing metadata in Obsidian files. - * Encapsulates metadata operations to facilitate testing and decouple from direct API usage. - */ -export class MetadataService { - private metadataCache: MetadataCache; - - /** - * Creates an instance of MetadataService. - * - * @param metadataCache - The Obsidian MetadataCache instance to operate on. - */ - constructor(metadataCache: MetadataCache) { - this.metadataCache = metadataCache; - } - - /** - * Retrieves the frontmatter metadata from a file. - * - * @param file - The TFile to retrieve frontmatter from. - * @returns The frontmatter as a record of key-value pairs, or undefined if not found. - */ - getFrontmatter(file: TFile): Record | undefined { - const cache = this.metadataCache.getFileCache(file); - return cache?.frontmatter; - } - - /** - * Retrieves the YAML frontmatter from a file. - * - * @param file - The TFile to retrieve YAML frontmatter from. - * @returns The YAML frontmatter as a string, or null if not found. - */ - getFileCache(file: TFile): CachedMetadata | null { - return this.metadataCache.getFileCache(file); - } -} diff --git a/src/services/VaultService.ts b/src/services/VaultService.ts index e900ee8..273cf33 100644 --- a/src/services/VaultService.ts +++ b/src/services/VaultService.ts @@ -34,7 +34,11 @@ export class VaultService { */ async createFolderIfNotExists(folderPath: string): Promise { const normalizedPath = normalizePath(folderPath); - await this.vault.createFolder(normalizedPath).catch(() => {}); + try { + await this.vault.createFolder(normalizedPath); + } catch { + // Folder already exists or other error we can ignore + } } /** @@ -43,7 +47,7 @@ export class VaultService { * @param filePath - The path of the file to retrieve. * @returns The TFile if found, or null. */ - async getFileByPath(filePath: string): Promise { + getFileByPath(filePath: string): TFile | null { return this.vault.getFileByPath(filePath); } @@ -71,6 +75,6 @@ export class VaultService { async renameFile(file: TFile, newPath: string): Promise { const normalizedPath = normalizePath(newPath); - return await this.fileManager.renameFile(file, normalizedPath); + await this.fileManager.renameFile(file, normalizedPath); } } diff --git a/src/types/Contact.ts b/src/types/Contact.ts index 65ad9d4..687e9be 100644 --- a/src/types/Contact.ts +++ b/src/types/Contact.ts @@ -61,7 +61,7 @@ export interface GoogleContact { /** * Represents a contact's birthday, including optional structured date and metadata. */ -export interface Birthday { +interface Birthday { /** Structured date object (year, month, day) */ date?: { year?: number; @@ -79,7 +79,7 @@ export interface Birthday { /** * Represents a membership in a Google contact Group. */ -export interface ContactGroupMembership { +interface ContactGroupMembership { /** Identifier of the contact group (e.g., "abc123") */ contactGroupId: string; @@ -90,7 +90,7 @@ export interface ContactGroupMembership { /** * Represents a contact's membership in various domains or groups. */ -export interface Membership { +interface Membership { /** Membership in a specific contact group */ contactGroupMembership?: ContactGroupMembership; diff --git a/src/types/IPlugin.ts b/src/types/IPlugin.ts new file mode 100644 index 0000000..b72dcbd --- /dev/null +++ b/src/types/IPlugin.ts @@ -0,0 +1,10 @@ +import { Plugin } from 'obsidian'; +import { ContactSyncSettings } from './Settings'; +import { AuthManager } from '../auth/AuthManager'; + +export interface IPlugin extends Plugin { + settings: ContactSyncSettings; + auth: AuthManager | null; + saveSettings(): Promise; + setupAutoSync(): void; +}