diff --git a/FEATURES.md b/FEATURES.md index c2f9459..c3b82f1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -36,3 +36,11 @@ Features are what the software should do. | Set a favorite property | #settings | | Set text properties | #settings | | Set the log level | #settings | +| When is a file deleted, refresh the view | | +| When is the metadata of a file changes, refresh the view | | +| When a file is renamed, refresh the view | | +| When a file is created, refresh the view | | +| When a file is modified, refresh the view | | +| Refresh time variables every minute | | +| When a setting changes, update the view | | +| Move focus with left and right arrows | | diff --git a/__mocks__/js-logger.ts b/__mocks__/js-logger.ts new file mode 100644 index 0000000..9c3d567 --- /dev/null +++ b/__mocks__/js-logger.ts @@ -0,0 +1,7 @@ +export default { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts new file mode 100644 index 0000000..16a60e1 --- /dev/null +++ b/__mocks__/obsidian.ts @@ -0,0 +1,8 @@ +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const DATE_TIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; + +export const moment = jest.fn((date, formats, strict) => { + return { + isValid: jest.fn(() => DATE_REGEX.test(date) || DATE_TIME_REGEX.test(date)), + }; +}); diff --git a/bun.lockb b/bun.lockb index 28feba7..48d5c37 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..74f4614 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, +}; diff --git a/package.json b/package.json index 1a06699..678c512 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json" + "test": "jest --config jest.config.js" }, "keywords": [], "author": "DecafDev", @@ -14,6 +14,7 @@ "devDependencies": { "@tsconfig/svelte": "^5.0.4", "@types/bun": "latest", + "@types/jest": "^29.5.12", "@types/lodash": "^4.17.0", "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "5.29.0", @@ -21,8 +22,10 @@ "builtin-modules": "3.3.0", "esbuild": "0.17.3", "esbuild-svelte": "^0.8.0", + "jest": "^29.7.0", "obsidian": "latest", "svelte-preprocess": "^5.1.4", + "ts-jest": "^29.1.4", "tslib": "2.4.0", "typescript": "4.7.4" }, diff --git a/src/svelte/app/index.svelte b/src/svelte/app/index.svelte index 1d211a1..50c9110 100644 --- a/src/svelte/app/index.svelte +++ b/src/svelte/app/index.svelte @@ -32,7 +32,7 @@ getStartOfLastWeekMillis, getStartOfTodayMillis, getStartOfThisWeekMillis, - } from "./services/time-utils"; + } from "../shared/services/time-utils"; let plugin: VaultExplorerPlugin; diff --git a/src/svelte/app/services/filters/property-groups-filter.ts b/src/svelte/app/services/filters/property-groups-filter.ts index ae63358..28e7d60 100644 --- a/src/svelte/app/services/filters/property-groups-filter.ts +++ b/src/svelte/app/services/filters/property-groups-filter.ts @@ -1,8 +1,9 @@ import { FrontMatterCache } from "obsidian"; import { CheckboxFilterCondition, DateFilterCondition, ListFilterCondition, NumberFilterCondition, PropertyFilter, PropertyFilterGroup } from "src/types"; import { FilterCondition, TextFilterCondition } from "src/types"; -import Logger from "js-logger"; -import { getEndOfDayMillis, getStartOfDayMillis, getTimeMillis, isDateSupported } from "../time-utils"; + +import { getEndOfDayMillis, getStartOfDayMillis, getTimeMillis } from "../../../shared/services/time-utils"; +import { loadPropertyValue } from "src/svelte/shared/services/property-utils"; //Tests //Group is enabled/disabled @@ -48,56 +49,29 @@ const filterByProperty = (frontmatter: FrontMatterCache | undefined, filter: Pro const { propertyName, condition, value, type, matchWhenPropertyDNE } = filter; if (propertyName === "") return true; - let propertyValue: (string | string[] | boolean | number | null) = frontmatter?.[propertyName] ?? null; - if (type === "text") { - //If the value is not a string, skip the filter - if (propertyValue !== null && typeof propertyValue !== "string") { - Logger.warn(`Property value is not a string: ${propertyValue}`); - return true; - } + const propertyValue = loadPropertyValue(frontmatter, propertyName, type); const doesMatch = doesTextMatchFilter(propertyValue, value, condition, matchWhenPropertyDNE); return doesMatch; - } else if (type === "list") { - if (propertyValue !== null && !Array.isArray(propertyValue)) { - Logger.warn(`Property value is not an array: ${propertyValue}`); - return true; - } - const compare = value.split(",").map((v) => v.trim()).filter((v) => v !== ""); - const doesMatch = doesListMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE); - return doesMatch; } else if (type === "number") { - if (propertyValue !== null && typeof propertyValue !== "number") { - Logger.warn(`Property value is not a number: ${propertyValue}`); - return true; - } + const propertyValue = loadPropertyValue(frontmatter, propertyName, type); const compare = parseFloat(value); const doesMatch = doesNumberMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE); return doesMatch; } else if (type === "checkbox") { - if (propertyValue !== null && typeof propertyValue !== "boolean") { - Logger.warn(`Property value is not a boolean: ${propertyValue}`); - return true; - } - + const propertyValue = loadPropertyValue(frontmatter, propertyName, type); const compare = value === "true"; const doesMatch = doesCheckboxMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE); return doesMatch; - } else if (type === "date" || type === "datetime") { - if (propertyValue !== null && typeof propertyValue !== "string") { - Logger.warn(`Property value is not a string: ${propertyValue}`); - return true; - //In older versions of Obsidian, the date could be stored in the frontmatter - //in an unsupported date format - } else if (propertyValue !== null && !isDateSupported(propertyValue)) { - Logger.warn(`Property value has unsupported date format: ${propertyValue}`); - return true; - } - + const propertyValue = loadPropertyValue(frontmatter, propertyName, type); const doesMatch = doesDateMatchFilter(condition, propertyValue, value, matchWhenPropertyDNE); return doesMatch; - + } else if (type === "list") { + const propertyValue = loadPropertyValue(frontmatter, propertyName, type); + const compare = value.split(",").map((v) => v.trim()).filter((v) => v !== ""); + const doesMatch = doesListMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE); + return doesMatch; } else { throw new Error(`Property filter type not supported: ${type}`); } diff --git a/src/svelte/app/services/render-utils.ts b/src/svelte/app/services/render-utils.ts index 287030f..8b56084 100644 --- a/src/svelte/app/services/render-utils.ts +++ b/src/svelte/app/services/render-utils.ts @@ -1,11 +1,12 @@ import { FrontMatterCache, TFile } from "obsidian"; -import { VaultExplorerPluginSettings } from "src/types"; +import { PropertyType, VaultExplorerPluginSettings } from "src/types"; import { MarkdownFileRenderData } from "../types"; -import { getTimeMillis, isDateSupported } from "./time-utils"; +import { getTimeMillis, isDateSupported } from "../../shared/services/time-utils"; import Logger from "js-logger"; +import { loadPropertyValue } from "src/svelte/shared/services/property-utils"; export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, file: TFile, frontmatter: FrontMatterCache | undefined,): MarkdownFileRenderData => { - const tags: string[] | null = loadPropertyValue(frontmatter, "tags", true); + const tags: string[] | null = loadPropertyValue(frontmatter, "tags", PropertyType.LIST); const { createdDate: createdDateProp, @@ -17,14 +18,14 @@ export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, f custom3: custom3Prop, } = settings.properties; - const url: string | null = loadPropertyValue(frontmatter, urlProp); - const favorite: string | null = loadPropertyValue(frontmatter, favoriteProp); - const creationDate: string | null = loadPropertyValue(frontmatter, createdDateProp); - const modifiedDate: string | null = loadPropertyValue(frontmatter, modifiedDateProp); + const url: string | null = loadPropertyValue(frontmatter, urlProp, PropertyType.TEXT); + const favorite: boolean | null = loadPropertyValue(frontmatter, favoriteProp, PropertyType.CHECKBOX); + const creationDate: string | null = loadPropertyValue(frontmatter, createdDateProp, PropertyType.DATE || PropertyType.DATETIME); + const modifiedDate: string | null = loadPropertyValue(frontmatter, modifiedDateProp, PropertyType.DATE || PropertyType.DATETIME); - const custom1: string | null = loadPropertyValue(frontmatter, custom1Prop); - const custom2: string | null = loadPropertyValue(frontmatter, custom2Prop); - const custom3: string | null = loadPropertyValue(frontmatter, custom3Prop); + const custom1: string | null = loadPropertyValue(frontmatter, custom1Prop, PropertyType.TEXT); + const custom2: string | null = loadPropertyValue(frontmatter, custom2Prop, PropertyType.TEXT); + const custom3: string | null = loadPropertyValue(frontmatter, custom3Prop, PropertyType.TEXT); let createdMillis = file.stat.ctime; if (creationDate != null) { @@ -62,29 +63,3 @@ export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, f }; } - -const loadPropertyValue = (frontmatter: FrontMatterCache | undefined, propertyName: string, isArray = false) => { - if (propertyName === "") { - return null; - } - - if (!frontmatter) { - return null; - } - - const propertyValue = frontmatter?.[propertyName]; - - if (isArray) { - if (propertyValue === undefined || propertyValue === null) { - return null; - } else if (Array.isArray(propertyValue)) { - return propertyValue; - } else { - //If the property is not an array, return it as an array - //This is a bug in Obsidian? - return [propertyValue]; - } - } - - return propertyValue ?? null; -} diff --git a/src/svelte/app/types.ts b/src/svelte/app/types.ts index 90ace98..1d99117 100644 --- a/src/svelte/app/types.ts +++ b/src/svelte/app/types.ts @@ -2,7 +2,7 @@ export interface MarkdownFileRenderData { name: string; path: string; tags: string[] | null; - favorite: string | null; + favorite: boolean | null; url: string | null; createdMillis: number; modifiedMillis: number; diff --git a/src/svelte/shared/services/property-utils.test.ts b/src/svelte/shared/services/property-utils.test.ts new file mode 100644 index 0000000..da10ebd --- /dev/null +++ b/src/svelte/shared/services/property-utils.test.ts @@ -0,0 +1,235 @@ + +import { FrontMatterCache } from "obsidian"; +import { PropertyType } from "src/types"; +import { loadPropertyValue } from "./property-utils"; + +describe("loadPropertyValue", () => { + it("returns null if frontmatter is undefined", () => { + //Arrange + const frontmatter: FrontMatterCache | undefined = undefined; + + //Act + const result = loadPropertyValue(frontmatter, "test", PropertyType.TEXT); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if propertyName is empty", () => { + //Arrange + const frontmatter: FrontMatterCache = {}; + const propertyName = ""; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.TEXT); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if propertyValue is undefined", () => { + //Arrange + const frontmatter: FrontMatterCache = {}; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.TEXT); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if propertyValue is null", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: null }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.TEXT); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if expectedType is PropertyType.TEXT and value is not a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: 1 }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.TEXT); + + //Assert + expect(result).toBeNull(); + }); + + it("returns propertyValue if expectedType is PropertyType.TEXT and value is a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: "test" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.TEXT); + + //Assert + expect(result).toBe("test"); + }); + + it("returns null if expectedType is PropertyType.NUMBER and value is not a number", () => { + //Arrange + const frontmatter: FrontMatterCache = { name: "test" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.NUMBER); + + //Assert + expect(result).toBeNull(); + }); + + it("returns propertyValue if expectedType is PropertyType.NUMBER and value is a number", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: 1 }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.NUMBER); + + //Assert + expect(result).toBe(1); + }); + + it("returns null if expectedType is PropertyType.CHECKBOX and value is not a boolean", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: "test" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.CHECKBOX); + + //Assert + expect(result).toBeNull(); + }); + + it("returns propertyValue if expectedType is PropertyType.TEXT and value is a boolean", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: true }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.CHECKBOX); + + //Assert + expect(result).toBe(true); + }); + + it("returns propertyValue as array if expectedType is PropertyType.LIST and value is an string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: "test" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.LIST); + + //Assert + expect(result).toEqual(["test"]); + }); + + it("returns propertyValue if expectedType is PropertyType.LIST and value is an array", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: ["test"] }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.LIST); + + //Assert + expect(result).toEqual(["test"]); + }); + + + it("returns propertyValue without null values if expectedType is PropertyType.LIST and value is an array", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: ["test", null] }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.LIST); + + //Assert + expect(result).toEqual(["test"]); + }); + + it("returns null if expectedType is PropertyType.DATE and value is not a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: 1 }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATE); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if expectedType is PropertyType.DATETIME and value is not a supported format", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: '<% tp.file.creation_date() %>' }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATETIME); + + //Assert + expect(result).toBeNull(); + }); + + + it("returns propertyValue if expectedType is PropertyType.DATE and value is a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: "2021-01-01" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATE); + + //Assert + expect(result).toBe("2021-01-01"); + }); + + it("returns null if expectedType is PropertyType.DATETIME and value is not a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: 1 }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATETIME); + + //Assert + expect(result).toBeNull(); + }); + + it("returns null if expectedType is PropertyType.DATETIME and value is not a supported format", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: '<% tp.file.last_modified_date("dddd Do MMMM YYYY HH: mm: ss") %>' }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATETIME); + + //Assert + expect(result).toBeNull(); + }); + + it("returns propertyValue if expectedType is PropertyType.LIST and value is a string", () => { + //Arrange + const frontmatter: FrontMatterCache = { test: "2021-01-01T00:00:00" }; + const propertyName = "test"; + + //Act + const result = loadPropertyValue(frontmatter, propertyName, PropertyType.DATETIME); + + //Assert + expect(result).toBe("2021-01-01T00:00:00"); + }); +}); diff --git a/src/svelte/shared/services/property-utils.ts b/src/svelte/shared/services/property-utils.ts new file mode 100644 index 0000000..120a224 --- /dev/null +++ b/src/svelte/shared/services/property-utils.ts @@ -0,0 +1,90 @@ +import Logger from "js-logger"; +import { FrontMatterCache } from "obsidian"; +import { PropertyType } from "src/types"; +import { isDateSupported } from "./time-utils"; + +/** + * Loads a property value from the frontmatter object + * @param frontmatter - The frontmatter object to load the property value from + * @param propertyName - The name of the property to load + * @param expectedType - The expected type of the property + * @returns - The property value or null if the property isn't valid + */ +export const loadPropertyValue = (frontmatter: FrontMatterCache | undefined, propertyName: string, expectedType: PropertyType): T | null => { + //If the file has no frontmatter, return null + if (!frontmatter) { + return null; + } + + //If the property name is empty, return null + //This can happen if the user has not set a property name + if (propertyName === "") { + return null; + } + + const propertyValue = frontmatter[propertyName]; + + if (propertyValue === undefined || propertyValue === null) { + return null; + } + + //Validate the property value for the expected type + if (expectedType === PropertyType.TEXT) { + if (typeof propertyValue !== "string") { + Logger.warn(`Property value of type 'text' is not a string: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.NUMBER) { + if (typeof propertyValue !== "number") { + Logger.warn(`Property value of type 'number' is not a number: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.DATE) { + if (typeof propertyValue !== "string") { + Logger.warn(`Property value of type 'date' is not a string: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.DATETIME) { + if (typeof propertyValue !== "string") { + Logger.warn(`Property value of type 'datetime' is not a string: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.CHECKBOX) { + if (typeof propertyValue !== "boolean") { + Logger.warn(`Property value of type 'checkbox' is not a boolean: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.LIST) { + if (!Array.isArray(propertyValue)) { + Logger.warn(`Property value of type 'list' is not an array: ${propertyValue}`); + //Don't return null here, because the property value can be converted to an array + } + } + + //In older versions of Obsidian, the date can be stored in the frontmatter + //in a format other than YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS + if (expectedType === PropertyType.DATE) { + if (!isDateSupported(propertyValue)) { + Logger.warn(`Property value of type 'date' has unsupported date format: ${propertyValue}`); + return null; + } + } else if (expectedType === PropertyType.DATETIME) { + if (!isDateSupported(propertyValue)) { + Logger.warn(`Property value of type 'datetime' has unsupported date format: ${propertyValue}`); + return null; + } + } + + if (expectedType === PropertyType.LIST) { + //If the property is not an array, return it as an array + //This is a bug in Obsidian? + if (!Array.isArray(propertyValue)) { + return [propertyValue] as unknown as T; + } + + //Filter out null and undefined values + return propertyValue.filter((v) => v !== null && v !== undefined) as unknown as T; + } + + return propertyValue as T; +} diff --git a/src/svelte/app/services/time-utils.ts b/src/svelte/shared/services/time-utils.ts similarity index 100% rename from src/svelte/app/services/time-utils.ts rename to src/svelte/shared/services/time-utils.ts diff --git a/tsconfig.json b/tsconfig.json index 5d5d6f1..75be12a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { - "types": ["svelte", "node"], + "types": ["svelte", "node", "jest"], "baseUrl": ".", "inlineSources": false, "module": "ESNext", diff --git a/version-bump.mjs b/version-bump.mjs deleted file mode 100644 index d409fa0..0000000 --- a/version-bump.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync, writeFileSync } from "fs"; - -const targetVersion = process.env.npm_package_version; - -// read minAppVersion from manifest.json and bump version to target version -let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); -const { minAppVersion } = manifest; -manifest.version = targetVersion; -writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); - -// update versions.json with target version and minAppVersion from manifest.json -let versions = JSON.parse(readFileSync("versions.json", "utf8")); -versions[targetVersion] = minAppVersion; -writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));