Improve code quality (#23)

* improve code quality

* add complexity check

* add dependency-cruiser

* add ts-prune

* add eslint-plugin-boundaries

* add quality script

* fix github actions
This commit is contained in:
aleksejs1 2025-12-20 12:54:50 +02:00 committed by GitHub
parent bf39688a94
commit d3fa61e98c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1319 additions and 350 deletions

24
.dependency-cruiser.cjs Normal file
View file

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

View file

@ -4,9 +4,6 @@ on:
push:
branches:
- master
pull_request:
branches:
- master
permissions:
contents: read

View file

@ -33,3 +33,6 @@ jobs:
- name: Run type check
run: npm run typecheck
- name: Run dependency check
run: npm run lint:deps

View file

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

614
package-lock.json generated
View file

@ -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",

View file

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

View file

@ -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<string, unknown> = new Map();
private fileCacheMap = new Map<string, unknown>();
setFileCache(file: TFile, cache: unknown) {
this.fileCacheMap.set(file.path, cache);
}

View file

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

View file

@ -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<string, TFile> {
return super.scanFiles(files, propertyPrefix);
}
public hasSyncLabel(
contact: GoogleContact,
syncLabel: string,
labelMap: Record<string, string>
): 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

View file

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

View file

@ -1,4 +1,4 @@
import { GoogleContactsService } from '../../core/GoogleContactsService';
import { GoogleContactsService } from '../../services/GoogleContactsService';
import { requestUrl } from 'obsidian';
import {
URL_PEOPLE_CONNECTIONS,

View file

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

View file

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

View file

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

View file

@ -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<string, FieldAdapter>,
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,

View file

@ -7,8 +7,10 @@ export class AddressAdapter implements FieldAdapter {
contact: GoogleContact,
context?: Record<string, unknown>
): 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 =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,6 @@
import { FieldAdapter, ExtractionResult } from '../interfaces';
import { GoogleContact } from '../../types/Contact';
export interface LabelAdapterContext {
labelMap: Record<string, string>;
}
export class LabelAdapter implements FieldAdapter {
extract(
contact: GoogleContact,

View file

@ -6,129 +6,91 @@ export class NameAdapter implements FieldAdapter {
contact: GoogleContact,
context?: Record<string, unknown>
): 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<GoogleContact['names']>[number],
index: number
): void {
const fields: Record<string, string> = {
givenName: 'GN',
middleName: 'MN',
familyName: 'FN',
honorificPrefix: 'PREFIX',
honorificSuffix: 'SUFFIX',
};
for (const [prop, suffix] of Object.entries(fields)) {
const value = (name as Record<string, unknown>)[prop];
if (typeof value === 'string') {
results.push({ value, suffix, index });
}
}
}
private addPhoneticFields(
results: ExtractionResult[],
name: NonNullable<GoogleContact['names']>[number],
index: number
): void {
const fields: Record<string, string> = {
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<string, unknown>)[prop];
if (typeof value === 'string') {
results.push({ value, suffix, index });
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string | null> {
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<string, string> = {};
private async getLabelMap(
token: string
): Promise<Record<string, string> | 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<GoogleContact[] | null> {
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<ContactSyncSettings>
);
}
/**

View file

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

View file

@ -15,29 +15,56 @@ export class ContactAuditService {
async auditContacts(token: string): Promise<void> {
new Notice(t('Starting contact audit...'));
let labelMap: Record<string, string> = {};
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<Record<string, string> | 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<GoogleContact[] | null> {
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<string, string>
): Set<string> {
const validGoogleIds = new Set<string>();
googleContacts.forEach((contact) => {
if (this.hasSyncLabel(contact, this.settings.syncLabel, labelMap)) {
@ -47,32 +74,44 @@ export class ContactAuditService {
}
}
});
return validGoogleIds;
}
private identifyOrphans(
validGoogleIds: Set<string>
): { 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<string, unknown>
| 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<string, string>
): 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;
}
}

View file

@ -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<string, string> = 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<string, string>
): Record<string, string> {
return Object.fromEntries(
Object.entries(labelMap).map(([key, value]) => [value, key])
) as Record<string, string>;
}
private async processContact(
contact: GoogleContact,
config: ContactNoteConfig,
labelMap: Record<string, string>,
invertedLabelMap: Record<string, string>,
filesIdMapping: Record<string, TFile>
): Promise<void> {
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<string, TFile>
): Promise<string> {
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<string, TFile>,
noteBody: string
): Promise<TFile | null> {
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<string, string>,
namingStrategy: NamingStrategy,
organizationAsLink: boolean = false,
trackSyncTime: boolean = false
organizationAsLink = false,
trackSyncTime = false
): Record<string, string | string[]> {
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<string, string>
): 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<string, TFile>> {
): Record<string, TFile> {
const idToFileMapping: Record<string, TFile> = {};
for (const file of files) {
const frontmatter = this.metadataCache.getFileCache(file)?.frontmatter;
const frontmatter = this.metadataCache.getFileCache(file)?.frontmatter as
| Record<string, unknown>
| 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;
}
}

View file

@ -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<GoogleContact[]> {
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 {};
}

View file

@ -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<string, string | string[]> | 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);
}
}

View file

@ -34,7 +34,11 @@ export class VaultService {
*/
async createFolderIfNotExists(folderPath: string): Promise<void> {
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<TFile | null> {
getFileByPath(filePath: string): TFile | null {
return this.vault.getFileByPath(filePath);
}
@ -71,6 +75,6 @@ export class VaultService {
async renameFile(file: TFile, newPath: string): Promise<void> {
const normalizedPath = normalizePath(newPath);
return await this.fileManager.renameFile(file, normalizedPath);
await this.fileManager.renameFile(file, normalizedPath);
}
}

View file

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

10
src/types/IPlugin.ts Normal file
View file

@ -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<void>;
setupAutoSync(): void;
}